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

개요

이번 글에서는 Server-Sent Events(SSE)를 이용한 간단한 Maven, Servlet 3 기반의 Java 채팅 웹 애플리케이션을 구현해보고자 한다.

Server-Sent Events란 무엇인가?

  • Server-Sent Events(이하 SSE)는 HTTP 스트리밍을 통해 서버에서 클라이언트로 Push Notification을 할 수 있는 기술이다. HTML5 스펙에 명시된 표준 기술로 JavaScript 에서는 EventSource API를 이용하여 제어가 가능하다. Internet Explorer을 제외한 대부분의 브라우저에서 지원한다.
  • 전통적인 웹 애플리케이션이라면 클라이언트의 요청에 대해 서버가 응답하는 방식이지만 SSE를 이용하면 별도의 복잡한 기술이 필요없이 HTTP 프로토콜을 기반으로 서버에서 클라이언트로 Real-Time Push Notification을 전송할 수 있다. 한 번 연결이 맺어지면 클라이언트에 의해 종료될 때까지 서버와의 연결이 유지되며 서버가 원하는 시점에 클라이언트에게 메시지를 전송할 수 있다. 이러한 특징 덕분에 최소한 오버헤드로 모니터링 시스템의 그래프 갱신, 채팅 및 메신저 등 광범위하게 적용할 수 있다.

소스 코드

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</artifactId>
    <packaging>war</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <name>minichat Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.1.3</version>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP-java6</artifactId>
            <version>2.3.7</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.187</version>
        </dependency>
        <dependency>
            <groupId>org.sql2o</groupId>
            <artifactId>sql2o</artifactId>
            <version>1.5.4</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>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jstl</artifactId>
            <version>1.2</version>
        </dependency>
        <dependency>
            <groupId>taglibs</groupId>
            <artifactId>standard</artifactId>
            <version>1.1.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.4</version>
        </dependency>
    </dependencies>
    <build>
        <finalName>minichat</finalName>
    </build>
</project>

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"
    version="3.1">
    <display-name>MiniChat</display-name>
</web-app>

ApplicationConfig.java

package com.jsonobject.minichat.config;

import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@WebListener
public class ApplicationConfig implements ServletContextListener {

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

    private enum ApplicationMode {
        TEST, PRODUCTION,
    }

    private static final ApplicationMode APPLICATION_MODE = ApplicationMode.TEST;

    public static DataSource getDataSource(ServletContext sce) {
        DataSource dataSource = (DataSource) sce.getAttribute("dataSource");
        return dataSource;
    }

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        logger.info("===== contextInitialized() start");
        HikariConfig config = new HikariConfig();
        if (APPLICATION_MODE == ApplicationMode.TEST) {
            config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");
            config.setConnectionTestQuery("SELECT NOW()");
            config.setConnectionInitSql("CREATE TABLE IF NOT EXISTS \"Message\" (\"MessageId\" INT(11) NOT NULL AUTO_INCREMENT, \"UserName\" VARCHAR(255) NOT NULL, \"Message\" VARCHAR(1024) NOT NULL, \"DateCreated\" DATETIME NOT NULL, PRIMARY KEY(\"MessageId\")) AUTO_INCREMENT = 1");
            config.addDataSourceProperty("URL", "jdbc:h2:mem:minichat;MODE=MySQL");
            config.addDataSourceProperty("user", "minichatUser");
            config.addDataSourceProperty("password", "minichatPassword");
        }
        HikariDataSource dataSource = new HikariDataSource(config);
        sce.getServletContext().setAttribute("dataSource", dataSource);
        logger.info("===== contextInitialized() end");
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
    }

}

Message.java

package com.jsonobject.minichat.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 + "]";
    }
}

MessageDAO.java

package com.jsonobject.minichat.dao;

import java.util.List;
import com.jsonobject.minichat.model.Message;

public interface MessageDAO {
    public Long getLastMessageId();

    public void create(Message message);

    public List<Message> readById(Long messageId);
}

MessageDAOImpl.java

package com.jsonobject.minichat.dao;

import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sql2o.Connection;
import com.jsonobject.minichat.model.Message;

public class MessageDAOImpl implements MessageDAO {

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

    private Connection connection;

    public MessageDAOImpl(Connection connection) {
        super();
        this.connection = connection;
    }

    @Override
    public Long getLastMessageId() {
        logger.info("===== getLastMessageId(): start");
        if (this.connection == null) {
            return null;
        }
        Long lastMessageId = connection.createQuery("SELECT MAX(\"MessageId\") FROM \"Message\"").executeAndFetchFirst(
                Long.class);
        if (lastMessageId == null) {
            lastMessageId = 0L;
        }
        logger.info("===== getLastMessageId(): end, lastMessageId={}", lastMessageId.toString());

        return lastMessageId;
    }

    @Override
    public void create(Message message) {
        logger.info("===== create(): start, message={}", message.toString());
        if (this.connection == null) {
            return;
        }
        connection.createQuery("INSERT INTO \"Message\" (\"UserName\", \"Message\", \"DateCreated\") VALUES (:userName, :message, NOW())")
                .addParameter("userName", message.getUserName()).addParameter("message", message.getMessage()).executeUpdate();
        logger.info("===== create(): end");
    }

    @Override
    public List<Message> readById(Long messageId) {
        logger.info("===== readById(): start, messageId={}", messageId);
        List<Message> messageList = new ArrayList<Message>();
        if (this.connection == null) {
            return messageList;
        }
        messageList = connection
                .createQuery(
                        "SELECT \"MessageId\", \"UserName\", \"Message\", \"DateCreated\" FROM \"Message\" WHERE \"MessageId\" > :messageId ORDER BY \"MessageId\" ASC")
                .addParameter("messageId", messageId).executeAndFetch(Message.class);
        logger.info("===== readById(); end, messageList={}", messageList.toString());

        return messageList;
    }
}

MessageController.java

package com.jsonobject.minichat.controller;

import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sql2o.Connection;
import org.sql2o.Sql2o;
import com.google.gson.Gson;
import com.jsonobject.minichat.config.ApplicationConfig;
import com.jsonobject.minichat.dao.MessageDAO;
import com.jsonobject.minichat.dao.MessageDAOImpl;
import com.jsonobject.minichat.model.Message;

@WebServlet(urlPatterns = { "/message/*" }, asyncSupported = true)
public class MessageController extends HttpServlet {

    private static final long serialVersionUID = 1L;

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

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            logger.info("===== doGet(): start");
            String action = request.getParameter("action");
            Method actionMethod = this.getClass().getDeclaredMethod(action, HttpServletRequest.class, HttpServletResponse.class);
            actionMethod.invoke(this, request, response);
            logger.info("===== doGet(): end");
        } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
            logger.error("===== doGet(): excepton={}", ExceptionUtils.getRootCause(e));
        }
    }

    @SuppressWarnings("unchecked")
    @Override
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            logger.info("===== doPost(): start");
            Map<String, Object> param = new Gson().fromJson(request.getReader().readLine(), Map.class);
            String action = param.get("action").toString();
            Method actionMethod = this.getClass().getDeclaredMethod(action, Map.class, HttpServletRequest.class, HttpServletResponse.class);
            actionMethod.invoke(this, param, request, response);
            logger.info("===== doPost(): end");
        } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
            logger.error("===== doPost(): excepton={}", ExceptionUtils.getRootCause(e));
        }
    }

    @SuppressWarnings({ "unused" })
    private void getNewMessages(HttpServletRequest request, HttpServletResponse response) throws IOException, InterruptedException {
        logger.info("===== readNewMessages(): start");
        Connection db = new Sql2o(ApplicationConfig.getDataSource(getServletContext())).open();
        MessageDAO messageDAO = new MessageDAOImpl(db);
        Long lastMessageId = messageDAO.getLastMessageId();
        db.close();
        while (true) {
            db = new Sql2o(ApplicationConfig.getDataSource(getServletContext())).open();
            messageDAO = new MessageDAOImpl(db);
            List<Message> newMessageList = messageDAO.readById(lastMessageId);
            db.close();
            if (newMessageList.size() > 0) {
                lastMessageId = newMessageList.get(newMessageList.size() - 1).getMessageId();
                response.setContentType("text/event-stream;charset=UTF-8");
                response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
                response.setHeader("Pragma", "no-cache");
                response.setDateHeader("Expires", 0);
                PrintWriter writer = response.getWriter();
                writer.write("event: message\n\n");
                writer.write("data: " + new Gson().toJson(newMessageList) + "\n\n");
                writer.flush();
                logger.info("===== readNewMessages(): send new messages to client, message={}", newMessageList.toString());
            }
            Thread.sleep(2500);
        }
    }

    @SuppressWarnings({ "unused", "unchecked" })
    private void create(Map<String, Object> param, HttpServletRequest request, HttpServletResponse response) {
        logger.info("===== create(): start, param={}", param.toString());
        Message message = new Message();
        message.setUserName(((Map<String, Object>) param.get("data")).get("userName").toString());
        message.setMessage(((Map<String, Object>) param.get("data")).get("message").toString());
        Connection con = new Sql2o(ApplicationConfig.getDataSource(getServletContext())).beginTransaction();
        MessageDAO messageDAO = new MessageDAOImpl(con);
        messageDAO.create(message);
        con.commit();
        logger.info("===== create(): end");
    }
}

index.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Server-Sent Events 기반 미니 채팅방(by 지단로보트)</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 id="messages" class="bs-callout">
        <h3>Server-Sent Events 기반 미니 채팅방</h3>
        <p>&nbsp;</p>
    </div>
    <div class="bs-callout">
        <form>
            <div class="form-group">
                <input id="userName" class="form-control" type="text" placeholder="이름">
            </div>
            <div class="form-group">
                <textarea id="message" class="form-control" rows="3" placeholder="메시지"></textarea>
            </div>
            <div class="form-group">
                <button id="sendMessage" class="btn btn-primary btn-lg btn-block" type="button">메시지 전송</button>
            </div>
        </form>
    </div>
    <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() {
            if (typeof (EventSource) == 'undefined') {
                return;
            }
            var source = new EventSource(contextpath + '/message?action=getNewMessages');
            source.onmessage = function(event) {
                var newMessageList = JSON.parse(event.data);
                newMessageList
                        .forEach(function(newMessage) {
                            var messageTemplate = '<h4><strong>:userName</strong></h4><div class="alert alert-info"><strong>:message</strong>&nbsp;<span class="badge">:dateCreated</span></div>';
                            $('#messages').append(
                                    messageTemplate.replace(':userName', newMessage.userName).replace(':message', newMessage.message).replace(':dateCreated',
                                            Date.create(newMessage.dateCreated.iMillis).utc().format('{HH}:{mm}')));
                            window.scrollTo(0, document.body.scrollHeight);
                        });
            }
        };

        $('#sendMessage').click(function() {
            var message = {};
            message.userName = $('#userName').val();
            message.message = $('#message').val();
            $.ajax({
                type : 'post',
                url : contextpath + '/message',
                data : JSON.stringify({
                    action : 'create',
                    data : message
                }),
                contentType : 'application/json;charset=UTF-8'
            });
            $('#message').val('');
            $('#message').focus();
        });
        getNewMessages();
    </script>
</body>
</html>

실행 화면



한계 및 개선할 점

  • Server-Sent EventsInternet Explorer에서 지원하지 않는다.
  • SSE로 인해 클라이언트의 부하는 없어졌으나 서버에 불필요한 부하가 많다. Server-Sent Events를 이용하여 클라이언트-서버 간의 통신은 Asynchronous Notification을 구현하였으나 서버-저장소 간에는 여전히 Timer 방식의 Polling이라 진정한 의미의 브로드캐스트가 아니다.
  • 저장소로 문제를 제한하여 접근하면 Asynchronous Notification 기능을 지원하는 저장소를 이용하면 된다. 대표적으로 Redis가 있으며 PostgreSQL 또한 NOTIFY/LISTEN 기능을 제공한다. Java에서 이 기능을 이용하려면 별도의 JDBC 드라이버를 사용해야 한다. pgjdbc-ngNOTIFY/LISTEN 기능을 지원한다.
  • 저장소가 아닌 서버 구현의 관점에서 살펴보면 SSE를 브로드캐스트할 수 있는 Jersey 프레임워크를 이용하면 된다.
  • Jersey Server SSE API가 제공하는 SseBroadcaster 클래스를 이용하면 매우 간단한 코드로 SSE를 브로드캐스트할 수 있다.
  • 간략하게 설명하면 SSE 연결에 대한 Listen 시점에 싱글턴으로 생성된 SseBroadcaster 오브젝트의 add() 메써드로 해당 클라이언트를 브로드캐스트 명단에 등록한다. 그리고 서버에서 클라이언트에게 Notify하는 시점에 동일 오브젝트의 broadcast() 메써드를 실행하면 된다. 이러한 브로드캐스트를 적용하여 개선한 예제를 다음 글에서 소개할 예정이다.

참고 글

다음 글

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