jetty를 이용한 WebSocket 서버 구현하기

어플리케이션에 Servlet Container 이외도 여러가지 서버 기능을 내장할 수 있는 jetty 라이브러리를 이용해 WebSocket 서버를 구현하는 코드에 대한 뼈대를 정리한다. WebSocket는 HTML5에서 지원하는 기능으로 서버와 클라이언트는 연결되어 있는 상태에서 상호간에 데이터를 주고니 받거니 할 수 있는 기술이다. WebSocket은 그 막강한 통신 기능에 비해 서버와 클라이언트의 코드가 매우 작다. 그래서인지 뭔가 부족한 느낌이 드는데, 일단 기본적인거 정리하고 부족한 느낌이 드는 이유는 나중에 파악해 보자.

먼저 서버 단의 코드이다. Server를 구동하는 클래스와 WebSocket를 통해 접속하는 클라이언트를 나타내는 Session 범위의 클래스인데, 먼저 Server를 구동하는 클래스는 아래와 같다.

import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.websocket.server.WebSocketHandler;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;

public class test_webSocket {
    public static void main(String[] args) {
        Server server = new Server(8080);
		
        WebSocketHandler wsHandler = new WebSocketHandler() {
            @Override
            public void configure(WebSocketServletFactory factory) {
                factory.register(MyEchoSocket.class);
            };
        };

        ContextHandler context = new ContextHandler();
        context.setContextPath("/echoServer");
        context.setHandler(wsHandler);
		
        server.setHandler(context);

        try {
            server.start();
            server.join();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

그리고 다음은 서버에 접속하는 클라이언트(Session 객체로 구분) 마다 생성되는 Echo 기능을 갖는 MyEchoSocket 클래스이다.

import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketListener;

public class MyEchoSocket implements WebSocketListener {
    private Session outbound;
	
    public MyEchoSocket() {
        System.out.println("class loaded " + this.getClass());
    }

    @Override
    public void onWebSocketConnect(Session session) {
        this.outbound = session;		
    }

    @Override
    public void onWebSocketError(Throwable cause)
    {
        cause.printStackTrace(System.err);
    }

    @Override
    public void onWebSocketText(String message)
    {
        if ((outbound != null) && (outbound.isOpen()))
        {
            String echoMessage = outbound.hashCode() + " : " + message;
            outbound.getRemote().sendString(echoMessage, null);
        }
    }

    @Override
    public void onWebSocketBinary(byte[] payload, int offset, int len)
    {
        //.
    }

    @Override
    public void onWebSocketClose(int statusCode, String reason)
    {    	
    	System.out.println("Close, statusCode = " + statusCode + ", reasone = " + reason);
        this.outbound = null;
    }	
}

요즘 웹이 그렇듯이 WebSocket 기술은 Binary 데이터도 주고 받을 수 있다는 것을 알 수 있다. 중요한 것은 클라이언트 하나가 접속을 하면 위의 클래스가 매번 생성된다는 것이고, 클라이언트는 Session 객체를 통해 나타내지며, 이 객체를 통해 데이터를 주고 받을 수 있다.

서버를 살펴봤으니, 이제 클라이언트 코드를 살펴보면..




    
    


    

위의 13번 부분에 들어가는 js 코드는 다음과 같다.

        var webSocket = new WebSocket("ws://localhost:8080/echoServer/");
        var msgField = document.getElementById("messageField");
        var divMsg = document.getElementById("msg-box");

        function sendMsg() {
            var msgToSend = msgField.value;

            webSocket.send(msgToSend);

            divMsg.innerHTML += "
Client> " + msgToSend + "
"; msgField.value = ""; } webSocket.onmessage = function (message) { divMsg.innerHTML += "Server> : " + message.data; } webSocket.onopen = function () { alert("connection opened"); } webSocket.onclose = function () { alert("connection closed"); } webSocket.onerror = function (message) { alert("error: " + message); }

서버를 기동한 뒤, 클라이언트를 실행하고 메세지를 날리면, 해당 메세지가 메아리로 돌아오는 것을 볼 수 있다.

Java의 기본 log 기능 정리

log4j 등과 같은 로그를 위한 좋은 라이브러리가 있으나 Java에서 제공하는 기본 Log 기능에 대해 정리합니다. 먼저 가장 간단한 예는 아래와 같습니다. log에 대한 수준(Level)을 지정해 메세지를 콘솔에 표시하는 경우입니다.

package tst_Log_console;

import java.util.logging.Level;
import java.util.logging.Logger;

public class MainEntry {
    private final static Logger LOG = Logger.getGlobal();
	
    public static void main(String[] args) {
        LOG.setLevel(Level.INFO);
		
        LOG.severe("severe Log");
        LOG.warning("warning Log");
        LOG.info("info Log");
    }
}

Log의 수준은 높은 순서로 SEVERE, WARNING, INFO입니다. 각각이 의미하는 바는 심각함, 경고, 정보입니다. setLevel 함수를 통해 표시할 레벨의 수준을 조정할 수 있습니다. 위 코드에 대한 실행 결과는 다음과 같습니다.

4월 09, 2018 5:58:41 오후 tst_Log_console.MainEntry main
심각: severe Log
4월 09, 2018 5:58:41 오후 tst_Log_console.MainEntry main
경고: warning Log
4월 09, 2018 5:58:41 오후 tst_Log_console.MainEntry main
정보: info Log

위처럼 로그의 내용은 이미 정해져 있는데요. 만약 자신만의 로그 형식을 원한다면 먼저 Formatter 클래스를 상속받아 다음과 같은 클래스를 정의해야 합니다.

package tst_Log_console;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.LogRecord;

public class CustomLogFormatter extends Formatter {
    public String getHead(Handler h) {
        return "START LOG\n";
    }
	
    public String format(LogRecord rec) {
        StringBuffer buf = new StringBuffer(1000);

        buf.append(calcDate(rec.getMillis()));
        
        buf.append(" [");
        buf.append(rec.getLevel());
        buf.append("] ");
        
        buf.append("[");
        buf.append(rec.getSourceMethodName());
        buf.append("] ");
        
        buf.append(rec.getMessage());
        buf.append("\n");
        
        return buf.toString();
    }

    public String getTail(Handler h) {
    	return "END LOG\n";
    }
    
    private String calcDate(long millisecs) {
        SimpleDateFormat date_format = new SimpleDateFormat("yyyy-MM-dd HH:mm");
        Date resultdate = new Date(millisecs);
        return date_format.format(resultdate);
    }
}

그리고 이 클래스를 다음의 예처럼 활용할 수 있습니다.

package tst_Log_console;

import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;

public class MainEntry2 {
    private final static Logger LOG = Logger.getGlobal();
	
    public static void main(String[] args) {
        //=============================================
        // 기본 로그 제거
        //------------
        Logger rootLogger = Logger.getLogger("");
        Handler[] handlers = rootLogger.getHandlers();
        if (handlers[0] instanceof ConsoleHandler) {
            rootLogger.removeHandler(handlers[0]);
        }
        //=============================================
		
        LOG.setLevel(Level.INFO);
		
        Handler handler = new ConsoleHandler();
        CustomLogFormatter formatter = new CustomLogFormatter();
        handler.setFormatter(formatter);
        LOG.addHandler(handler);
		
        LOG.severe("severe Log");
        LOG.warning("warning Log");
        LOG.info("info Log");
    }
}

위의 클래스에서 getHead 함수는 로그 기록을 시작할때 한번만 호출되어 로그로써 표시되는 문자열을 반환합니다. 그리고 format은 개발자가 로그를 표시하기 원할때마다 메세지의 형식을 정의하기 위해 호출되는 함수입니다. 그리고 getTail은 프로그램이 종료될때 호출되는 로그 메세지로 표시할 문자열을 반환합니다. (그러나 현재 시점에서 이 getTail은 로그를 콘솔로 표시할 때 호출되지 않는 문제가 있음) 코드를 좀더 살펴보면, 12-20번 코드는 기본적으로 지정된 로그(콘솔창에 이미 정의된 형식으로 내요을 출력함)를 제거합니다. 그리고 24-27번 코드처럼 앞서 정의한 Formatter에 대한 상속 클래스를 생성해 지정합니다. 실행해 보면 다음과 같습니다.

START LOG
2018-04-09 18:06 [SEVERE] [main] severe Log
2018-04-09 18:06 [WARNING] [main] warning Log
2018-04-09 18:06 [INFO] [main] info Log

위의 경우처럼 로그를 화면이 아닌 파일로 저장하고 싶을 때가 있습니다. 이 경우 아래의 코드와 같이 하면 됩니다.

package tst_Log_console;

import java.io.IOException;
import java.util.logging.ConsoleHandler;
import java.util.logging.FileHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;

public class MainEntry3 {
    private final static Logger LOG = Logger.getGlobal();
	
    public static void main(String[] args) throws SecurityException, IOException {
        //=============================================
        // 기본 로그 제거
        //------------
        Logger rootLogger = Logger.getLogger("");
        Handler[] handlers = rootLogger.getHandlers();
        if (handlers[0] instanceof ConsoleHandler) {
            rootLogger.removeHandler(handlers[0]);
        }
        //=============================================
		
        LOG.setLevel(Level.INFO);
		
        Handler handler = new FileHandler("message.log", true);
        
        CustomLogFormatter formatter = new CustomLogFormatter();
        handler.setFormatter(formatter);
        LOG.addHandler(handler);
		
        LOG.severe("severe Log");
        LOG.warning("warning Log");
        LOG.info("info Log");
    }
}

달라지는 내용은 26번 코드에서 ConsoleHandler 대신 FileHandler로 변경되었다는 것입니다. 이 FileHandler 생성자는 첫번째 인자에 사용할 파일명을, 그리고 두번째 인자에 파일 기록시 APPEND 모드인지의 여부를 지정합니다.

mybatis 라이브러리 사용하기

mybatis API를 사용해 DB를 연결하고 SQL문을 던져 그 결과를 얻어오는 예제 코드를 정리합니다. 향후 한단계 수준 높은 응용을 위해 최소한의 코드만으로 mybatis를 활용하는 프로젝트가 갖춰야할 최소한의 설정 및 코드만을 언급합니다.

이클립스를 통해 신규 프로젝트를 생성하고, 이때 사용한 프로젝트 명은 tstMyBatis로 지정하였습니다. main 함수는 EntryMain 클래스에 담았습니다. EntryMain.java에 대한 내용은 다음과 같습니다.

package tstMyBatis;

import org.apache.ibatis.session.SqlSession;

public class EntryMain {
    public static void main(String[] args) {
        SqlSession session = SqlMapClient.getSqlSession();
        String player = session.selectOne("test.getFirstPlayer");
        System.out.println("Player Name: " + player);
        session.close();
    }
}

mybatis는 Java의 기본 라이브러리가 아니므로 https://github.com/mybatis/mybatis-3/releases 에서 다운로드 받습니다. 버전에 따라 파일명은 달라지겠지만, 파일 중 mybatis-3.4.6.jar 파일을 프로젝트에 libs 디렉토리를 만들어 넣고 참조합니다. 아울러 연결하고자 하는 DBMS를 위한 jdbc 라이브러리도 libs 디렉토리에 저장해 두고 참조합니다. 저 같은 경우 PostgreSQL를 사용하므로 postgresql-42.2.2.jar를 사용하였습니다.

위의 코드를 보면 SqlMapClient라는 클래스가 보입니다. 이 클래스에 대한 소스는 SqlMapClient.java이며 다음과 같습니다.

package tstMyBatis;

import java.io.IOException;
import java.io.Reader;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

public class SqlMapClient {
    private static SqlSession _session = null;
	
   static {
        try {
            String resource = "config/myBatisConfig.xml";
            Reader reader = Resources.getResourceAsReader(resource);
            SqlSessionFactory sqlMapper = new SqlSessionFactoryBuilder().build(reader);
			
            _session = sqlMapper.openSession();
         } catch(IOException e) {
             e.printStackTrace();
        }
    }
	
    public static SqlSession getSqlSession() {
        return _session;
    }
}

위의 코드를 보면 mybatis의 설정파일로 myBatisConfig.xml을 사용하고 있는데요. 이 파일의 내용은 다음과 같습니다.





    
    
    
        
            
            
            	
                
                
                
            
        
    

    
        
    

일단 이정도쯤에서 프로젝트의 구성을 살펴보면 아래와 같습니다.

다시 myBatisConfig.xml의 내용을 보면 속성정보로써 db.properties 파일과 맵퍼정보로써 sql.xml 파일에 대한 참조가 보입니다. db.properties 파일은 DB에 대한 연결에 필요한 정보가 저장되어 있으며 다음과 같습니다.

driver=org.postgresql.Driver
url=jdbc:postgresql://localhost:5432/postgres
username=postgres
password=#HappyDay#

앞서 언급했던 것처럼 PostgreSQL을 사용한다고 했으므로 이에 대해 필요한 값들이 담겨 있는 것을 볼 수 있습니다. 다음은 SQL에 대한 맵핑 정보가 담겨 있는 sql.xml 파일에 대한 내용입니다.



<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">


	

EntryMain 클래스의 8번 코드에서 실행할 SQL을 test.getFirstPlayer로 참조하고 있는데요. 바로 test가 mapper의 네임스페이스이고 getFirstPlayer가 실행할 SQL에 대한 ID입니다. 이제 실행하면 해당 SQL문이 실행되어 화면에 그 결과가 표시됩니다.

Java의 .properties 파일 읽기

Java는 알면 참 편한 기능들이 많습니다. 그중 Properties 라는 클래스인데요. 이 클래스는 Windows의 INI 파일과 같은 기능을 합니다. DB에 대한 연결정보를 파일로 저장해 놓고 사용하는 용도로 가장 많이 쓰이는데요. 이런 경우 db.proerties라는 파일명으로 자주 작명됩니다.

Java의 새로운 프로젝트를 생성하고 main 함수가 있는 클래스를 EntryMain이라고 한다면 프로젝트가 다음과 같이 구성되어 있다고 합시다. 물론 이 구성은 실제 제가 테스트한 것 입니다.

위의 구성을 보면 config에 db.properties 라는 파일도 보입니다. 이 파일에 db에 대한 연결정보를 가지고 있으며 내용은 다음과 같습니다.

driver=org.postgresql.Driver
url=jdbc:postgresql://localhost:5432/postgres
username=postgres
password=#goodday#

이제 이 db.properties에 담겨진 정보를 읽기 위해 Properties라는 클래스를 사용하는 코드는 다음과 같습니다.

package tstMyBatis;

import java.io.IOException;
import java.io.Reader;
import java.util.Properties;

public class EntryMain {

	public static void main(String[] args) {
		String resource = "config/DB.properties";
		Properties properties = new Properties();
		
        try {
    		Reader reader = Resources.getResourceAsReader(resource);
    		properties.load(reader);

        	System.out.println(properties.getProperty("driver"));
			System.out.println(properties.getProperty("username"));
			System.out.println(properties.getProperty("password"));
			System.out.println(properties.getProperty("url"));
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

실행해 보면, db.properties 파일을 담겨져 있는 4개의 정보가 콘솔에 표시되는 것을 볼 수 있을 것입니다. 안되다면, 코드에 오타는 없는지.. 그리고 Clean을 해보시기 바랍니다. Clean을 통해 bin 디렉토리에 config/db.properties가 복사되는 것을 보장하기 때문입니다.

[Java] 오늘 날짜, 어제 날짜

Java에서 오늘 날짜를 구하기 위한 코드입니다. 집계 서비스를 만들기 위해 사용한 코드인데요.

GregorianCalendar today = new GregorianCalendar();

int Year = today.get(Calendar.YEAR);
int Month = today.get(Calendar.MONTH);
int Date = today.get(Calendar.DATE);
int Hour = today.get(Calendar.HOUR_OF_DAY);

GregorianCalendar 클래스를 생성할 때 파라메터를 주지 않으면 오늘 날짜에 대한 객체가 생성됩니다. 주의할 점은 GregorianCalendar의 1월을 0부터 시작하므로, 필요시에 월의 값에 1을 더해줘야 합니다.

참고로 get 매서드에서 사용할 수 있는, 각 시간의 단위를 얻기 위해서 사용할 수 있는 값은 다음과 같습니다.

  • Calendar.YEAR – 년도
  • Calendar.MONTH – 월(0~11)
  • Calendar.DATE – 일
  • Calendar.AP_PM – 오전/오후
  • Calendar.HOUR – 시
  • Calendar.MINUTE – 분
  • Calendar.SECODE – 초

이제 여기서 한단계 더 나아가.. 어제는? 한달전은? 일주일전은? 어떻게 알 수 있을까.. 아래의 코드는 위의 today를 기준으로 1일 전을 나타내는 코드입니다.

today.add(Calendar.DAY_OF_MONTH, -1);

위의 코드를 이용하면 특정 날짜를 기준으로 원하는 날짜를 정확하게 파악할 수 있을 것입니다. GregorianCalendar 에서 기본적으로 사용하는 TimeZone은 OS에서 설정된 나라로 지정됩니다. 이 TimeZone은 setTimeZone 매서드를 통해 재변경이 가능합니다. 아래의 코드는 Europe/London에 대한 TimeZone으로 변경하는 코드 예입니다.

TimeZone timeZone = TimeZone.getTimeZone("Europe/London");
today.setTimeZone(timeZone);

끝으로 GregorianCalendar 객체의 시간을 다양하게 표현하기 위해 SimpleDateFormat 클래스를 사용할 수도 있습니다. 아래의 코드는 SimpleDateFormat 클래스르 사용해 원하는 형식으로 시간을 표현하는 코드 예입니다.

GregorianCalendar today = new GregorianCalendar();
SimpleDateFormat format = new SimpleDateFormat("yyyy년 MM월 dd일 aa hh시 mm분 ss초");
String strTime = format.format(today.getTime());