Server-Sent Events(SSE)를 이용한 Java 채팅 웹 애플리케이션 구현 v0.2

개요

앞서 지난 글에서 Server-Sent Events(이하 SSE)를 이용한 채팅 웹 애플리케이션 구현 예제를 소개했었다. 이번 글에서는 Jersey 프레임워크의 Server SSE API를 이용한 SSE 브로드캐스트 기능을 이용하여 완전한 Real-Time Server Push Notification을 구현해보고자 한다.

개선된 점

이번 글에서 소개하는 v0.2 예제에서는 v0.1 예제 대비 아래와 같은 부분이 개선되었다.

  • 본격적으로 Jersey 프레임워크를 도입하여 클라이언트로부터 새 메시지 도착시 곧바로 모든 SSE 연결에 새 메시지를 브로드캐스트(Broadcast)하는 기능을 추가하였다. v0.1은 브로드캐스트 기능이 존재하지 않아 각각의 SSE 연결마다 2.5초 간격으로(Polling) 새 메시지를 확인하여 시간 지연이 있었기 때문에 진정한 의미의 Asynchronous Notification이 되지 못했다.
  • 클라이언트가 새 메시지 수신시 브라우저의 탭(또는 창)에 해당 화면이 열려 있지 않아도 새 메시지가 도착했음을 알려주는 팝업 기능을 추가하였다. 이를 위해 HTML5 Web Notifications API를 사용하였다.

사전지식

본격적인 소스 코드 작성에 앞서 아래와 같은 개발환경 구축 및 사전지식이 필요하다.

  • JDK 7, Eclispe IDE for Java EE Developers(이하 Eclipse EE)가 설치되어 있어야 한다. 설치 방법은 이 글을 참고한다.
  • Maven을 기반으로 Jersey 2 프레임워크를 적용하여 웹 애플리케이션을 작성할 수 있어야 한다. 이 글을 참고한다.

/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.jsonobject</groupId>
    <artifactId>minichat-jersey</artifactId>
    <packaging>war</packaging>
    <version>0.2-SNAPSHOT</version>
    <name>minichat-jersey Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet-core</artifactId>
            <version>2.17</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet</artifactId>
            <version>2.17</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-sse</artifactId>
            <version>2.17</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-json-jackson</artifactId>
            <version>2.17</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.1.3</version>
        </dependency>
        <dependency>
            <groupId>joda-time</groupId>
            <artifactId>joda-time</artifactId>
            <version>2.7</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.3.1</version>
        </dependency>
    </dependencies>
    <build>
        <finalName>minichat-jersey</finalName>
    </build>
</project>

/src/main/webapp/WEB-INF/web.xml

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    metadata-complete="true" version="3.1">
    <display-name>MiniChat v0.2</display-name>
</web-app>

/src/main/java/com/jsonobject/minichatjersey/config/ApplicationConfig.java

package com.jsonobject.minichatjersey.config;

import javax.ws.rs.ApplicationPath;
import org.glassfish.jersey.server.ResourceConfig;

@ApplicationPath("/rest")
public class ApplicationConfig extends ResourceConfig {
    public ApplicationConfig() {
        packages("com.jsonobject.minichatjersey.resource");
    }
}

/src/main/java/com/jsonobject/minichatjersey/model/Message.java

package com.jsonobject.minichatjersey.model;

import org.joda.time.DateTime;

public class Message {
    private Long messageId;
    private String userName;
    private String message;
    private DateTime dateCreated;

    public Message() {
        super();
    }

    public Long getMessageId() {
        return messageId;
    }

    public void setMessageId(Long messageId) {
        this.messageId = messageId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public DateTime getDateCreated() {
        return dateCreated;
    }

    public void setDateCreated(DateTime dateCreated) {
        this.dateCreated = dateCreated;
    }

    @Override
    public String toString() {
        return "Message [messageId=" + messageId + ", userName=" + userName + ", message=" + message + ", dateCreated=" + dateCreated + "]";
    }
}

/src/main/java/com/jsonobject/minichatjersey/resource/MessageResource.java

package com.jsonobject.minichatjersey.resource;

import javax.servlet.ServletContext;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.glassfish.jersey.media.sse.EventOutput;
import org.glassfish.jersey.media.sse.OutboundEvent;
import org.glassfish.jersey.media.sse.SseBroadcaster;
import org.glassfish.jersey.media.sse.SseFeature;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.jsonobject.minichatjersey.model.Message;

@Path("/message")
public class MessageResource {

    private static final Logger logger = LoggerFactory.getLogger(MessageResource.class);

    @Context
    private ServletContext context;

    @POST
    @Path("/sendMessage")
    @Consumes(MediaType.APPLICATION_JSON)
    public void sendMessage(Message message) {
        message.setDateCreated(new DateTime());
        OutboundEvent.Builder eventBuilder = new OutboundEvent.Builder();
        OutboundEvent event = eventBuilder.name("message").mediaType(MediaType.TEXT_PLAIN_TYPE).data(String.class, new Gson().toJson(message)).build();
        getChannelBroadcaster().broadcast(event);
        logger.info("sendMessage(); broadcast, messsage={}", message.toString());
    }

    @GET
    @Path("/getNewMessages")
    @Produces(SseFeature.SERVER_SENT_EVENTS)
    public EventOutput listenToBroadcast() {
        logger.info("listenToBroadcast(): start");
        final EventOutput eventOutput = new EventOutput();
        getChannelBroadcaster().add(eventOutput);

        return eventOutput;
    }

    private SseBroadcaster getChannelBroadcaster() {
        SseBroadcaster broadcaster = (SseBroadcaster) context.getAttribute("CHANNEL");
        if (broadcaster == null) {
            broadcaster = new SseBroadcaster();
            context.setAttribute("CHANNEL", broadcaster);
        }
        return broadcaster;
    }
}
  • 순수 서블릿으로 작성되었던 v0.1 예제에 비해 Jersey 프레임워크의 힘을 빌어 소스 코드가 상당히 간결해진 것을 확인할 수 있다.
  • Resource 클래스는 Jersey 프레임워크에서 각 HTTP 요청에 대한 첫 관문 역할을 한다. Spring MVC와 비교하면 Controller 클래스와 동일한 기능을 수행한다. 차이점은 Controller 클래스는 단 한 번만 생성되어 싱글턴 오브젝트로 취급되지만 Resource 클래스는 매 요청시마다 오브젝트가 생성된다.
  • SSE 연결에 대한 브로드캐스트를 담당하는 SseBroadcaster 오브젝트의 사용을 확인할 수 있다. 모든 SSE 연결에 대해 브로드캐스트를 하기 위해서는 각 연결이 모두 한 채널에 묶여있어야 하며 이는 모두 동일한 SseBroadcaster 오브젝트에 Subscriber로 등록됨으로서 가능해진다. 모든 요청시 SseBroadcaster 오브젝트에 접근하기 위해 ServletContext에 해당 오브젝트를 저장하고 불러온다.
  • 클라이언트로부터 /getNewMessages 요청시 클라이언트와의 SSE 연결이 처음 수립되어 OPEN 상태가 된다. 서버에서는 해당 연결을 SseBroadcaster 오브젝트에 Subscriber로 등록하는 것 외에는 다른 작업을 하지 않는다.
  • 클라이언트로부터 /sendMessage 요청시 전송받은 메시지를 브로드캐스트한다. 매우 간단하다. 현재의 채팅 개념을 메신저 개념으로 바꾼다면 별도의 저장소에 해당 메시지를 등록하는 로직을 추가할 수 있을 것이다.

/src/main/webapp/index.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta content="width=device-width, initial-scale=1" name="viewport">
  <title>미니 채팅방</title>
  <link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/css/bootstrap.min.css" rel="stylesheet">
  <link href="http://getbootstrap.com/assets/css/docs.min.css" rel="stylesheet">
  <style>
  * {
      font-family: 'Malgun Gothic' !important;
  }
  </style>
</head>
<body>
  <div class="bs-callout" id="messages">
    <h2>미니 채팅방</h2>
    <h5>CreatedBy: <strong>Yi Tae-Hyeong</strong>, Version: <strong>0.2</strong></h5>
    <h5>Tech: <strong>Server-Sent Events(SSE)</strong></h5>
    <h5>Client: <strong>HTML5 SSE</strong></h5>
    <h5>Server: <strong>Jersey Server SSE</strong></h5>
    <p>&nbsp;</p>
  </div>
  <div class="bs-callout">
    <form>
      <div class="form-group">
        <input class="form-control" id="userName" placeholder="이름" type="text">
      </div>
      <div class="form-group">
        <textarea class="form-control" id="message" placeholder="메시지" rows="3"></textarea>
      </div>
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block" id="sendMessage" type="button">메시지 전송</button>
      </div>
    </form>
  </div>
  <script src="${pageContext.request.contextPath}/resources/libs/EventSource.js" type="text/javascript"></script>
  <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
  <script src="http://cdnjs.cloudflare.com/ajax/libs/sugar/1.4.1/sugar.min.js"></script>
  <script src="http://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js"></script>
  <script src="http://getbootstrap.com/assets/js/docs.min.js"></script>
  <script>
        var contextPath = '${pageContext.request.contextPath}';

        var getNewMessages = function() {
            var source = new EventSource(contextPath + '/rest/message/getNewMessages');
            source.onmessage = function(event) {
                var newMessage = JSON.parse(event.data);
                var messageTemplate = '<h4><strong>:userName</strong></h4><div class="alert :messageStyle"><strong>:message</strong>&nbsp;<span class="badge">:dateCreated</span></div>';
                var messageStyle = 'alert-info';
                if ($('#userName').val() !== newMessage.userName) {
                    messageStyle = 'alert-warning';
                }
                $('#messages').append(
                        messageTemplate.replace(':messageStyle', messageStyle).replace(':userName', newMessage.userName)
                                .replace(':message', newMessage.message).replace(':dateCreated',
                                        Date.create(newMessage.dateCreated.iMillis).format('{HH}:{mm}')));
                window.scrollTo(0, document.body.scrollHeight);
                notifyNewMessage(newMessage);
            }
            source.onerror = function(event) {
                source.close();
            }
        };

        var notifyNewMessage = function(message) {
            if (!('Notification' in window)) {
                return;
            }
            if (Notification.permission !== 'granted') {
                Notification.requestPermission();
            }
            notification = new Notification(message.userName, {
                body : message.message
            });
            notification.onshow = function() {
                setTimeout(function() {
                    notification.close()
                }, 5000);
            };
        };

        $('#sendMessage').click(function() {
            var message = {};
            message.userName = $('#userName').val();
            message.message = $('#message').val();
            if (message.userName === '' || message.message === '' || message.message.charCodeAt(0) === 10) {
                return;
            }
            $.ajax({
                type : 'post',
                url : contextPath + '/rest/message/sendMessage',
                data : JSON.stringify(message),
                contentType : 'application/json;charset=UTF-8'
            });
            $('#message').val('');
            $('#message').focus();
        });

        $("#message").keyup(function(event) {
            if (event.which == 13) {
                $('#sendMessage').click();
            }
        });

        getNewMessages();
  </script>
</body>
</html>
  • EventSource 오브젝트는 HTML5 표준에 명시된 오브젝트이지만 Internet Explorer에서는 지원되지 않는다. 그렇다고 실망할 필요는 없다. 고맙게도 EventSource 오브젝트를 에뮬레이션해주는 Polyfill 버전이 다수 존재한다. v0.2에서는 Yaffle이 제공하는 eventsource.min.js를 사용하여 크로스 브라우징 SSE를 구현하였다.
  • notifyNewMessage() 메써드는 새로 도착한 메시지에 대해 데스크탑에 팝업으로 알림 기능을 수행한다. 앞서 소개한 이를 위해 HTML5 Web Notifications API를 사용하였다. SSE와 마찬가지로 Internet Explorer에서는 지원되지 않는다.

실행 화면



개선할 점

  • SSE의 크로스 브라우징 측면에서 Polyfill 스크립트의 사용은 소극적인 처방이라고 할 수 있다. 이를 지원하는 Atmosphere라는 강력한 프레임워크가 존재한다. 추후 이를 적용한 새로운 예제를 선보일 예정이다.


저작자 표시 비영리 동일 조건 변경 허락
신고