티스토리 툴바


저작권 안내 (펌 하실 때)
  • 저작권자표시 Yes, 상업적이용 No , 컨텐츠변경 No

ORM!! 오늘 한번 더 ORM을 추종할 수 밖에 없는 일이 벌어졌다. 경험한 내용이 흥분도 되고 ORM의 좋음을 공유하고자 이렇게 글을 남긴다.


최초의 설계: 별도 클래스, 별도 테이블


현재 진행하는 프로젝트에서 컨텐츠에 대한 모델을 설계할 때 컨텐츠 종류마다 별도 테이블과 별도 클래스로 구성하도록 설계를 진행했었다. 각 컨텐츠들이 비슷한 데이터를 갖고 있었지만, 절반은 다른 데이터를 갖고 있었다. 또한, 컨텐츠 타입은 두 개였고 컨텐츠마다 사용되는 영역이 달랐기 때문에, 상위 클래스에 공통 정보를 두지 않고 서로 계층 관계에 묶이지 않는 별도 클래스로 구성하였다. 물론, 테이블도 별도로 구성하였다.




변화의 압박: 서로 다른 컨텐츠 종류에 공통으로 적용되는 기능들


최초 개발하는 동안에는 크게 문제될 것이 없었다. 그런데, PoC 프로젝트를 거의 마무리하는 과정에서 1.0 버전의 기획으로 다음의 기능들이 추가되었다.

  • 모든 컨텐츠에 대해 댓글 달기
  • 모든 컨텐츠에 대해 좋아요 하기
  • 모든 컨텐츠에 대해 즐겨찾기 하기
  • 모든 컨텐츠에 연관 정보 넣기
  • 새로운 종류의 컨텐츠 타입 추가 및 새로운 타입 컨텐츠에도 댓글/좋아요/연관 정보 넣기
고민에 휩싸이기 시작했다. 예를 들어, 댓글 구현은 다음과 같이 두 가지 중 하나로 할 수 있다.
  • 각 컨텐츠 타입마다 댓글을 위한 테이블을 구분해서 만들고, 각 컨텐츠 타입별로 댓글 관련 인터페이스 묶음 구현하기.
  • 댓글 테이블에 컨텐츠 타입 보관 위한 컬럼을 추가하고, 한 묶음의 인터페이스로 각 컨텐츠 타입을 위한 댓글 기능 구현하기.

구현이야 할 수 있겠지만, 둘 다 딱히 마음에 안 들었는다. 첫 번째 방법은 완전히 동일한 데이터 구조, 구현 코드, 테이블이 중복된다는 점이 불만이었다.



두 번째 방법은 하나의 신규 테이블에서 서로 다른 테이블에 대한 참조를 가져야 할 수도 있다는 것이 거슬렸다. 예를 들어, 두 컨텐츠에 동일하게 추가되는 정보인 경우, 두 컨텐츠에 대한 외부키를 각각 갖고 있어야 했다.



또한, 두 방법 모두 2.0 버전에 새로운 컨텐츠 타입이 추가되거나 각 컨텐츠 타입에 공통으로 적용되는 기능이 추가되면 같은 짓을 반복해야 하는 동일한 문제점을 갖고 있었다.


그래서, 뭐가 문제일까 하고 잠시 고민해 본 결과, 모델링을 잘못 했다는 결론에 다다랐다. PoC 시점에서는 그리 잘못 되지 않았지만, 몇 달 후에 신규 기능들이 추가되면서  최초에 만들었던 모델로는 깔끔하게 처리할 수 없게 된 것이다. 이 모든 문제를 깔끔하게 해결하는 방법은 모델을 다시 정리하는 것 뿐이었다.


상속으로 풀기로 결심!


이 모든 문제의 근원은 추상화의 변경에서 비롯되었다. 최초에는 두 개의 컨텐츠를 논리적인 하나의 컨텐츠로 바라보지 않았다. 그런데, 댓글, 연관 정보, 좋아요 기능이 생기면서 개별 컨텐츠 타입이 아닌 '컨텐츠'라는 개념이 도출된 것이다. 따라서, 컨텐츠라는 새로운 상위 개념을 도출했고, 이 상위 개념에 대해서 '좋아요', '댓글' 등의 기능을 적용하기로 하였다. 새로운 모델은 다음과 같이 변경되었다.



컨텐츠라는 단일 개념을 표현하기 위해 Content를 출현시켰고, 댓글과 좋아요 등의 기능은 새롭게 추가한 Content에 대고 구현을 하도록 했다. 각 개별 타입들의 변이는 Content의 하위 타입으로 처리하였다. 그리고, 각 컨텐츠 타입마다 별도의 테이블을 가졌던 것에서 모든 컨텐츠 타입을 한 개의 테이블에 저장하기로 결정했다.


변경의 여파는?


모델을 새롭게 정의했으니, 이제 기존 코드에 변경할 차례이다. 시간은 대략 얼마나 걸렸을까? 주요 기능의 정상적인 동작을 확인하는 데 까지 40분 정도 (밖에 안) 걸렸다. 물론, 개발 과정이기 때문에 기존 데이터의 마이그레이션이나 변경 등의 이슈는 없었고, 단지, 테스트를 위해 마련해 둔 데이터를 변경하는 정도의 데이터 수정 작업이 있었다. 하지만, 그렇다고 하더라도 모델을 변경하고 테이블을 합친 것에 비하면 이는 정말 짧은 시간이다.


그렇다면 어떻게 이것이 가능했을까? 이는 ORM을 사용했기 때문이다. 실제로 모델을 변경하기 위해 한 작업은 다음과 같다.

  • 상위 Content 클래스를 추가하고 각 하위 타입에 대해 공통인 필드와 관련 메서드를 추가
  • 각 하위 Content 타입 클래스에서 상위 클래스로 옮겨간 필드 및 관련 메서드 제거
  • Content 및 하위 클래스에 대한 ORM 설정
  • 테스트 데이터 구성
  • 기능 테스트 (약 40% 진행)
위에서 가장 시간이 오래 걸린 작업은 테스트 데이터 구성과 기능을 테스트 한 시간이다. Content 및 하위 클래스 구성, 그리고 ORM 관련 설정에는 불과 10분 정도 밖에 소요되지 않았다. 테스트 데이터를 구성하는데 10분 정도가 소요되었고, 기능 테스트에 20분 정도가 소요되었다. 물론, 전체 기능 중에서 조회 위주로만 기능을 테스트 했지만, 놀라운 점은 조회 기능 테스트하는 과정에서 오류가 발생하지 않았다는 점이다. 게다가 위의 작업 내역을 보면 DB 연동을 처리하는 리포지토리는 변경도 하지 않았다. 오!! 놀랍지 않은가?

ORM이 아니였다면?


어떻게 이런 과감한 변경을 하면서도 짧은 시간에 변경이 가능했을까? 정답은 바로 ORM 때문이다.

만약 ORM이 아니였다면 관련된 쿼리들을 쫓아다니면서 테이블을 변경해주고, 쿼리를 변경해주는 등의 쌩노가나를 해야만 했을 것이다. 물론, 이 과정에서 짜증 유발과 함께 오타도 발생했을 거고, 누가 오타를 덜 내면서 쿼리를 변경해 내느냐가 그 사람의 (노가다) 능력으로 인정받았을 것이다.

하지만, 이 프로젝트에서는 ORM을 사용했기에, 매우 적은 스트레스를 받으며, 약간의 노가다 만으로, 그야말로 스마트하게 변경 작업을 진행할 수 있었다. 프로젝트를 진행하다보면, SQL로 했으면 거부감이 확 들었을 이런 종류의 변경에서부터 작게는 사소한 컬럼 추가까지 다양한 변경이 발생하게 되는데, 이만하면 ORM을 도입하기 위한 약간의 학습 비용은 프로젝트 전체로 봤을 때 그리고 향후 유지보수를 생각해 봤을 때 절대로 큰 비용이 아닐 것이다. 아니 오히려 매우 작은 비용일 것이다.

저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

서블릿 3.0에 몇 가지 새로운 것들이 추가되었는데, 그 중 하나가 비동기 서블릿이다. 그 동안 서블릿은 한 개의 요청에 대해 한 개의 쓰레드를 사용하는 모델을 사용했었다. 일반적인 경우 이 방식은 알맞게 동작하지만, 서버에서 연결을 유지한 채 지속적으로 데이터를 받는 기능을 구현하기에는 적합하지 않은 모델이었다. 예를 들어, 채팅 어플리케이션을 개발하려면 클라이언트가 서버와 연결을 유지한채로 서버로부터 채팅 메시지를 받아와야 하는데, HTTP의 연결 유지 기능을 사용하면 서버의 쓰레드 풀의 쓰레드가 모두 사용되어서 더 이상 다른 클라이언트에 서비스를 제공할 수 없는 문제가 발생할 수 있다. 반대로 주기적으로 서버로부터 데이터를 읽어오면 불필요한 네트워크 트래픽이 발생하는 단점이 발생하게 된다.


이런 문제나 단점이 발생하는 이유는 서블릿 모델이 한 쓰레드가 클라이언트의 요청-응답 과정을 처리하기 때문문이다. 서블릿 3.0은 클라이언트의 요청을 받아들이는 쓰레드와 실제 클라이언트에게 응답을 제공하는 쓰레드를 분리할 수 있도록 함으로써, 즉 클라이언트에 대한 응답을 비동기로 처리할 수 있도록 함으로써 앞서 언급한 문제들을 해소할 수 있도록 하였다. 


서블릿 3.0의 비동기 처리


서블릿 3은 응답을 비동기로 처리하기 위한 기능이 추가되었다. 새로 추가된 비동기 기능을 설명하기에 앞서 먼저 기존 방식의 서블릿의 동작 방식을 간단하게 살펴보자.


public class HelloServlet extends HttpServlet {


    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse response)

            throws ServletException, IOException {

        response.setContentType("text/plain");

        response.setCharacterEncoding("UTF-8");


        PrintWriter writer = response.getWriter();

        writer.println("Hello");


        // 서블릿 실행이 종료되면 클라이언트에 응답 전송 및 스트림 종료

    }


}



기존 서블릿의 경우 클라이언트의 요청을 처리하는 쓰레드에서 클라이언트에 전송할 응답을 생성한다. 모든 실행이 끝나면 서블릿 컨테이너는 응답 전송을 완료하고 클라이언트와의 연결을 종료한다. 따라서, 연결이 유지되는 방식으로 Comet 구현시, 한 클라이언트가 한 쓰레드를 점유하게 되어 클라이언트의 개수가 증가할 경우 쓰레드가 부족해지는 상황이 발생하게 된다.


서블릿 3에 추가된 비동기 기능은 응답을 별도 쓰레드로 처리할 수 있도록 하였다. 아래 코드는 비동기 기능을 사용하여 응답을 생성하는 아주 간단한 비동기 지원 서블릿의 예이다.


@WebServlet(urlPatterns = "/hello", asyncSupported = true)

public class AsyncHelloWorldServlet extends HttpServlet {


    private Logger logger = Logger.getLogger(getClass());

    

    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse res)

            throws ServletException, IOException {

        final AsyncContext asyncContext = req.startAsync();

        

        new Thread(new Runnable() {

            

            @Override

            public void run() {

                try {

                    Thread.sleep(5000);

                } catch (InterruptedException e) {

                }

                HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();

                response.setContentType("text/plain");

                response.setCharacterEncoding("UTF-8");

                

                try {

                    response.getWriter().println("HELLO");

                } catch (IOException e) {

                    e.printStackTrace();

                }

                logger.info("complete response");

                asyncContext.complete();

            }

        }).start();

        

        logger.info("doGet return");

    }


}


위 코드에서 AsyncHelloWorldServlet은 @WebServlet 애노테이션의 asyncSupported 속성의 값을 true로 지정함으로써 비동기 방식을 지원한다고 설정하였다. (비동기 방식 지원은 web.xml을 통해서도 할 수 있다.)


비동기 지원 서블릿은 ServletRequest의 startAsync() 메서드를 이용해서 비동기로 요청을 처리하기 위한 AsyncContext 객체를 생성할 수 있다. AsyncContext 객체를 생성하면 서블릿의 메서드 실행이 종료되더라도 클라이언트와의 연결이 종료되지 않고 유지된다. 물론, 해당 서블릿을 실행하던 쓰레드는 컨테이너가 관리하는 쓰레드 풀로 반환되어 다른 클라이언트 요청을 처리할 수 있게 된다.


AsyncContext의 getResponse() 메서드를 사용하면 클라이언트에 데이터를 전송할 수 있는 HttpServletResponse를 구할 수 있다. 위 코드의 경우 별도 쓰레드에서 5초간 실행을 중지한 뒤에 AsyncContext를 이용해서 응답을 생성하고 있다. 클라이언트에 대한 응답이 완료되면, AsyncContext의 complete() 메서드를 호출해서 클라이언트와의 연결을 종료하게 된다.


웹 브라우저에서 위 서블릿에 연결하면, 전체 실행 흐름은 다음과 같이 흘러가게 된다.

  1. 클라이언트의 요청을 수신하는 쓰레드(T1)가 AsyncHelloWorldServlet의 doGet() 메서드를 실행한다.
  2. T1은 req.startAsync() 메서드를 이용해서 비동기 처리를 위한 AsyncContext 객체를 구한다.
  3. T1은 비동기로 응답을 처리할 쓰레드 T2를 생성하고 실행한다.
  4. T2는 5초간 실행을 중지한다.
  5. T1은 doGet() 메서드가 종료되고, 컨테이너의 쓰레드 풀에 반환된다.
  6. T2는 AsyncContext를 이용해서 클라이언트에 응답을 전송한다.
  7. T2는 complete()을 통해 클라이언트와의 연결을 종료한다.
  8. T2의 실행이 종료된다.
위 실행 흐름을 보면 서블릿의 실행이 종료된 이후 별도 쓰레드를 통해서 클라이언트에 응답이 전송됨을 알 수 있다. 실제로 웹 브라우저에서 http://localhost:8080/hello를 실행해보면 약 5초 후에 응답이 오는 것을 확인할 수 있다.

비동기 기능을 이용한 채팅 구현: 서버 측 코드

서블릿 비동기 기능을 활용하면 iframe 기반의 Comet을 통해서 쉽게 채팅 기능을 구현할 수 있다. 구현하는 방법은 다음과 같이 간단하다.
  • 클라이언트가 연결하면, 클라이언트에 대한 AsyncContext를 생성한 뒤 목록에 저장한다.
  • 클라이언트의 채팅 메시지를 수신하면 각 AsyncContext에 메시지를 전송한다.
실제 샘플 구현에 사용된 클래스는 다음과 같다.


  • ChatRoom : 채팅 방을 관리한다. 클라이언트 목록(AsyncContext)을 관리하고, AsyncContext를 이용해서 클라이언트에 메시지를 전송하는 역할을 수행한다.
  • ChatRoomLifeCycleManager: 컨테이너 시작시 ChatRoom을 초기화하고, 컨테이너 종료시 ChatRoom을 종료한다.
  • EnterServlet: 클라이언트 채팅방 입장 기능을 처리한다.
  • SendMessageServlet: 클라이언트의 채팅 메시지 전송 요청을 처리한다. 클라이언트 채팅 메시지를 전송하면, ChatRoom을 통해 각 클라이언트에 메시지를 푸쉬(push)한다.

먼저, EnterServlet을 살펴보자.


@WebServlet(urlPatterns = "/enter", asyncSupported = true)

public class EnterServlet extends HttpServlet {


    private Logger logger = Logger.getLogger(getClass());


    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)

            throws ServletException, IOException {

        processConnectionRequest(req, resp);

    }


    @Override

    protected void doPost(HttpServletRequest req, HttpServletResponse resp)

            throws ServletException, IOException {

        processConnectionRequest(req, resp);

    }


    private void processConnectionRequest(HttpServletRequest req,

            HttpServletResponse res) throws IOException {

        logger.info("Receive ENTER request");


        res.setContentType("text/html; charset=UTF-8");

        res.setHeader("Cache-Control", "private");

        res.setHeader("Pragma", "no-cache");

        res.setCharacterEncoding("UTF-8");


        PrintWriter writer = res.getWriter();

        // for IE

        writer.println("<!-- start chatting -->\n");

        writer.flush();


        AsyncContext asyncCtx = req.startAsync();

        addToChatRoom(asyncCtx);

    }


    private void addToChatRoom(AsyncContext asyncCtx) {

        asyncCtx.setTimeout(0);

        ChatRoom.getInstance().enter(asyncCtx);

        logger.info("New Client enter Room");

    }


}


EnterServlet은 클라이언트의 채팅방 입장 요청이 오면 비동기 모드를 시작한 뒤 AsyncContext를 ChatRoom.enter() 메서드를 이용해서 채팅에 클라이언트를 참여시킨다. 이후 ChatRoom은 AsyncContext 객체를 이용해서 클라이언트에 채팅 메시지를 전송한다.

@WebServlet(urlPatterns = "/sendMessage")
public class SendMessageServlet extends HttpServlet {

    private Logger logger = Logger.getLogger(getClass());
    
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
        logger.info("Receive SEND request");
        
        res.setContentType("text/plain");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");
        req.setCharacterEncoding("UTF-8");

        ChatRoom.getInstance().sendMessageToAll(req.getParameter("message"));

        res.getWriter().print("OK");
    }

}

SendMessageServlet은 클라이언트가 전송한 채팅 메시지를 ChatRoom.sendMessageToAll()에 전달한다. ChatRoom은 전달받은 메시지를 내부적으로 관리하는 모든 AsyncContext에 전송하게 된다.


여기서 알 수 있는 사실은, 채팅 메시지를 서버에 전송하는 커넥션과 채팅 메시지를 클라이언트에 뿌려주는 커넥션이 다르다는 사실이다. 앞서 EnterServlet에 연결한 클라이언트 커넥션은 AsyncContext를 이용해서 종료되지 않은 채로 ChatRoom에 전달된다. 반면, 채팅 메시지를 전송하기 위해 SendMessageServlet에 연결한 클라이언트 커넥션은 새로운 커넥션으로서 메시지를 전달하고서는 바로 커넥션을 종료하게 된다. 서버에서 클라이언트로의 메시지 전달은 ChatRoom에 보관된 AsyncContext를 통해서 이루어진다.


클라이언트에 서버 푸쉬 방식으로 메시지를 전달하는 ChatRoom 클래스는 다음과 같이 구현된다.


public class ChatRoom {


    private static ChatRoom INSTANCE = new ChatRoom();

    public static ChatRoom getInstance() {

        return INSTANCE;

    }


    private Logger logger = Logger.getLogger(getClass());

    private List<AsyncContext> clients = new LinkedList<AsyncContext>();

    private BlockingQueue<String> messageQueue = new LinkedBlockingQueue<String>();


    private Thread messageHandlerThread;

    private boolean running;


    private ChatRoom() {

    }


    public void init() {

        running = true;

        Runnable handler = new Runnable() {

            @Override

            public void run() {

                logger.info("Started Message Handler.");

                while (running) {

                    try {

                        String message = messageQueue.take();

                        logger.info("Take message [" + message + "] from messageQueue");

                        sendMessageToAllInternal(message);

                    } catch (InterruptedException ex) {

                        break;

                    }

                }

            }

        };

        messageHandlerThread = new Thread(handler);

        messageHandlerThread.start();

    }


    public void enter(final AsyncContext asyncCtx) {

        asyncCtx.addListener(new AsyncListener() {

            @Override

            public void onTimeout(AsyncEvent event) throws IOException {

                logger.info("onTimeout");

                clients.remove(asyncCtx);

            }

            @Override

            public void onError(AsyncEvent event) throws IOException {

                logger.info("onError");

                clients.remove(asyncCtx);

            }

            @Override

            public void onStartAsync(AsyncEvent event) throws IOException {}

            @Override

            public void onComplete(AsyncEvent event) throws IOException {}

        });

        try {

            sendMessageTo(asyncCtx, "Welcome!");

            clients.add(asyncCtx);

        } catch (IOException e) {

        }

    }


    public void sendMessageToAll(String message) {

        try {

            messageQueue.put(message);

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        logger.info("Add message [" + message + "] to messageQueue");

    }


    private void sendMessageToAllInternal(String message) {

        for (AsyncContext ac : clients) {

            try {

                sendMessageTo(ac, message);

            } catch (IOException e) {

                clients.remove(ac);

            }

        }

        logger.info("Send message [" + message + "] to all clients");

    }


    private void sendMessageTo(AsyncContext ac, String message)

            throws IOException {

        PrintWriter acWriter = ac.getResponse().getWriter();

        acWriter.println(toJSAppendCommand(message));

        acWriter.flush();

    }


    private String toJSAppendCommand(String message) {

        return "<script type='text/javascript'>\n"

                + "window.parent.chatapp.append({ message: \""

                + EscapeUtil.escape(message) + "\" });\n" + "</script>\n";

    }


    public void close() {

        running = false;

        messageHandlerThread.interrupt();

        logger.info("Stopped Message Handler.");


        for (AsyncContext ac : clients) {

            ac.complete();

        }

        logger.info("Complete All Client AsyncContext.");

    }

}


ChatRoom 클래스는 AsyncContext의 목록을 관리하기 위해 List를 사용하였다. 그리고, 클라이언트에 푸시할 채팅 메시지를 큐에 보관하고, 별도 쓰레드를 이용해서 큐에 보관된 메시지를 클라이언트에 전송하도록 구현하였다. 이렇게 구현한 이유는 ChatRoom에 채팅 메시지를 전송해 달라고 요청하는 쓰레드(즉, SendMessageServlet을 실행하는 쓰레드)와 실제로 채팅 메시지를 클라이언트에 푸시하는 쓰레드를 비동기로 실행하기 위함이다.


init() 메서드가 실행되면, messageQueue로부터 메시지를 읽어와 sendMessageToAllInternal() 메서드를 실행하는 쓰레드가 시작된다. 이 쓰레드는 running 필드가 false가 되거나 messageQueue로부터 데이터를 읽어오는 쓰레드에 인터럽트가 걸릴 때 까지 계속된다.


enter() 메서드는 AsyncContext 객체를 clients 리스트에 추가한다. 추가하기 전에 AsyncListener를 AsyncContext 객체에 등록한다. AsyncListener는 연결 타임아웃이 발생하거나 연결 에러가 발생하면 clients 리스트에서 해당 AsyncContext를 제거하는 기능을 수행해서 ChatRoom이 정상적인 클라이언트의 목록을 유지할 수 있도록 한다.


sendMessageToAll() 메서드는 messageQueue에 메시지를 등록한다. 앞서 말했듯이 SendMessageServlet은 ChatRoom의 sendMessageToAll() 메서드를 이용해서 채팅방에 참여한 모든 클라이언트에 채팅 메시지를 전송할 것은 요청하는데, sendMessageToAll() 메서드는 messageQueue에 보관만 하고 바로 리턴한다. 이렇게 함으로써 채팅 메시지를 전송한 클라이언트는 모든 클라이언트에 채팅 메시지가 전달될 때까지 기다리지 않고 연결을 종료할 수 있다.


messageQueue에 저장된 메시지는 앞서 init() 메서드에서 생성한 핸들러 쓰레드를 통해서 전체 클라이언트에 푸시된다.


각 클라이언트에 메시지를 전송하는 기능은 sendMessageTo() 메서드를 이용하여 구현하였다. 이 메서드를 보면 PrintWriter의 printlnl() 메서드를 이용해서 클라이언트에 메시지를 뿌린 뒤에 flush() 메서드를 실행하는데, flush() 메서드를 호출해야 클라이언트에 내용이 전달된다.


sendMessageTo()가 클라이언트에 전송하는 메시지는 다음과 같은 형식을 띈다.


<script type='text/javascript'>

window.parent.chatapp.append({ message: "채팅 메시지" });

</script>


클라이언트는 서버로부터 위 메시지를 받을 때 마다 자바 스크립트 코드를 실행하게 되며, 따라서 채팅 메시지가 수신될 때마다 자바 스크립트를 이용해서 채팅 메시지를 화면에 추가할 수 있게 된다.


비동기 기능을 이용한 채팅 구현: 클라이언트 측 코드


클라이언트 코드는 비교적 간단하다. 몇 가지 이벤트를 처리하기 위해 jQuery를 사용하였다.


<html>

<head>

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

<title>Chat</title>

<script src="/jquery-1.7.1.js" type="text/javascript"></script>

<script type="text/javascript">

var chatapp = {

append: function(msg) {

$("#chatmessage").append("<div>"+msg.message+"</div>");

}

};

$(function() {

$("#sendBtn").click(function() {

var msg = $("#message").val();

$.ajax({

type: "POST",

url: '/sendMessage',

data: {message: msg},

success: function(data) {}

});

$("#message").val("");

});

document.getElementById("comet-frame").src = "/enter";

});

</script>

</head>

<body>

<div id="chatmessage"></div>

<input type="text" name="message" id="message" />

<input type="button" name="sendBtn" id="sendBtn" value="보내기" />

<iframe id="comet-frame" style="display: none;"></iframe>

</body>

</html>


위 HTML에서 눈여겨 볼 부분은 chatapp과 숨겨진 iframe이다. comet-frame은 숨겨진 iframe인데, 웹 페이지 로딩이 완료되면 iframe의 주소가 /enter가 된다. 이는, iframe이 EnterServlet에 연결하게 되며, EnterServlet이 생성하는 AsyncContext를 통해서 채팅 메시지를 수신받게 된다. 앞서 ChatRoom은 자바 스크립트 코드를 채팅 메시지로 전송했었는데, 이 채팅 메시지가 iframe에 지속적으로 전달되는 것이다. 앞서 자바 스크립트 코드는 다음과 같았다.


<script type='text/javascript'>

window.parent.chatapp.append({ message: "채팅 메시지" });

</script>


위 코드에서 window.parent.chatapp은 앞서 HTML 코드에서 생성한 chatapp 객체가 된다. 따라서, iframe이 위 코드를 실행하면 chatapp.append() 메서드가 실행되어 chatmessage 영역에 채팅 메시지를 추가하게 된다.


sendBtn 버튼을 클릭하면 /sendMessage에 채팅 메시지를 전달한다. 즉, 채팅 메시지 전송 요청을 SendMessageServlet이 받게 되고, SendMessageServlet은 ChatRoom의 AsyncContext를 통해서 채팅 메시지를 클라이언트에 위 코드 형태로 푸시하게 된다. 각각의 웹 브라우저는 숨겨진 iframe을 통해서 위 코드를 받게 되고, 위 자바스크립트 코드를 실행함으로써 메시지를 화면에 뿌리게 된다.


아래는 두 개의 서로 다른 브라우저에서 채팅 메시지를 실행한 결과 화면을 보여주고 있다.



소스 코드 사용법


소스 코드는 Maven 프로젝트로 작성되었다. 다운로드 받은 뒤 압축을 풀고 다음의 명령을 실행하면 바로 예제를 테스트 해 볼 수 있다.


$ mvn jetty:run


소스 코드는 아래 링크에서 다운로드 받을 수 있다.


servlet-async.zip





저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 권남 2012/06/18 11:27  댓글주소  수정/삭제  댓글쓰기

    혹시 Servlet 3.0 기준의 Servlet/JSP 책도 출간 예정이신가요?
    기다리고 있습니다. ^^

    • madvirus 2012/06/18 11:49  댓글주소  수정/삭제

      서블릿을 많이 다루는 게 요즘같은 프레임워크 시대에는 다소 의미가 약해서 쓴다 해도 JSP 2.1->2.2로의 개정판 정도를 준비하게 될 것 같습니다.

  2. lahuman 2012/07/23 14:33  댓글주소  수정/삭제  댓글쓰기

    좋은글 잘 읽었습니다.

    감사합니다.

  3. 2013/01/21 10:59  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

    • madvirus 2013/01/21 11:56  댓글주소  수정/삭제

      이런 작업은 비동기 쓰레드를 사용하는 것 보단, 클라이언트에서 주기적으로 확인하는 방식을 사용하는 것이 좋을 것 같습니다.
      HTML5가 가능하다면 웹소켓을 사용하는 것도 좋을 것 같습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

Neo4j 소개

NoSQL, Cloud 등 2012/06/08 18:04

소셜 네트워크 서비스의 특징은 뭘까? 또는 요즘 서비스들의 특징은 뭘까? 한마디로 정의할 수는 없겠지만, 두드러진 특징 중 하나는 바로 데이터의 '관계(relation)'에 있다. 친구 관계, 사용자와 영화와의 관계, 구매되는 상품 간의 관계 등 관계가 서비스의 핵심적인 요소로 떠오르고 있다. 이런 관계를 표현하는 여러 가지 방법이 있는데, 그 중 하나는 그래프를 사용하는 것이다. 그래프는 Vertex와 Edge로 구성되기 때문에, 관계를 표현하기에 적합하다. 아래는 개발자와 개발 언어 간의 관계를 그래프를 이용해서 표현한 예이다.


[그래프는 Vertext와 Edge로 관계를 보다 직관적으로 표현할 수 있다.]


이런 그래프 데이터를 보관하고 조회하기 위해 RDB나 기타 DB를 사용할 수 있겠지만, 그래프 DB를 사용하면 그래프 자체를 있는 그대로 저장하고 사용할 수 있다. 본 글에서는 그래프DB 중에서 필자가 개인적으로 맘에 들었던 Neo4j를 소개하고자 한다.


Neo4j란?


Neo4j는 그래프 데이터를 저장하고 관리하기 위한 그래프 DB 이다. Neo4j의 주요 특징으로는 다음과 같은 것들이 있다.

  • 자바 기반의 그래프 DB로서, 임베딩 방식과 REST 방식을 지원한다.
  • 트랜잭션을 지원하며, JTA를 지원한다.
  • 인덱스 및 노드 탐색을 지원한다.
  • 이중화를 통한 고가용성을 지원한다. (Zookeeper 사용)
  • 백업/복구를 지원한다.

Neo4j의 그래프 모델


Neo4j의 그래프는 다음의 세 가지로 구성된다.

  • 노드Node (그래프의 Vertex)
  • 관계Relation (그래프의 Edge)
  • 프로퍼티 (노드와 관계의 속성 값)

Neo4j는 노드를 이용해서 데이터를 보관한다. 완전히 동일하진 않지만 관계형 데이터의 엔티티와 유사하다. 관계는 두 노드 사이의 연결을 표현하는 것으로, 방향성을 갖는다. 예를 들어, 앞서 그래프 예시에서 노드와 관계는  아래 그림과 같으며, 개발자1에서 Java로의 방향을 갖는 관계를 갖고 있다.



관계는 단방향일 수도 있고 양방향일 수도 있다. 예를 들어, 친구 관계는 단반향 보다는 양방향에 더 가까울 것이다. 또한, Neo4j의 관계는 타입을 갖는다. 예를 들어, 위 그림에서 관계는 LIKE 타입과 LOVE 타입 두 가지가 존재한다.


노드와 관계는 (키, 값)으로 구성되는 프로퍼티를 이용해서 값을 가질 수 있다. 예를 들어, 개발자를 위한 노드는 아이디, 이름,  이메일 등을 프로퍼티를 이용해서 저장할 수 있으며, 관계는 해당 언어 경력 시작 연도를 프로퍼티로 가질 수 있을 것이다.


그래프의 주요 특징 중 하나는 특정 조건에 따라 경로를 만들수 있다는 것이다. 예를 들어, 친구의 친구 또는 친구의 친구의 친구 관계에 해당하는 사람을 찾고 싶다고 해 보자. 이를 그래프로 표현하면 다음과 같이 직관적으로 탐색할 수 있게 된다.



Neo4j는 노드의 관계를 이용해서 탐색할 수 있는 API와 쿼리 언어를 지원하고 있기 때문에, 위와 같은 그래프에서 친구의 친구 또는 친구의 친구의 친구를 간단한 코드로 찾아낼 수 있다.


Neo4j 퀵스타트


퀵 스타트 문서는 http://docs.neo4j.org/chunked/stable/tutorials-java-embedded.html 에 잘 정리되어 있으니, 이 문서를 보면서 한 단계씩 따라해 볼 것을 권한다.


소셜 네트워크와 추천에서의 Neo4j


여러 가지 활용 분야가 있겠지만, 소셜 네트워크와 추천 기능이 필자가 Neo4j가 끌린 이유이다. 먼저 소셜 네트워크는 태생적으로 그래프에 적합한 데이터 구조를 갖고 있다. 트위터를 보면 한 사람이 다른 사람은 팔로우 하는데, 이 것은 그래프의 한 노드가 다른 노드에 대해 방향이 있는 관계를 갖는 것과 동일하다.


추천은 실제로 적용해보고 싶은 기능중의 하나로서, 특정 노드를 중심으로 한 추천을 가능하게 해 준다. 예를 들어, 회원이 컨텐츠를 좋아하는 관계는 다음과 같은 그래프로 표현할 수 있을 것이다.



위 그래프를 보면 "x가 좋아하는 영화 a를 좋아하는 다른 y가 좋아하는 영화"라는 공식을 사용해서 개인에 맞는 영화를 추천할 수 있음을 알 수 있다. 예를 들어, 로빈훗이 좋아하는 영화를 좋아하는 다른 사람이 좋아하는 영화는 '아이덴티티'와 '내 머리속의 지우개'이다. 여기에 함께 좋아하는 영화 개수가 더 많은 사용자의 랭킹을 높게 준다고 할 경우, '아이덴티티'가 '내 머리속의 지우개'보다 더 높은 점수를 받아서 추천 순위에서 상위에 위치하게 된다. 이걸 RDB와 쿼리를 이용해서 구현하려면 다소 복잡한 쿼리가 나오겠지만, Neo4j가 제공하는 Cypher라는 쿼리를 사용하면 다음과 같이 간단하게 개인에 맞춰진 추천 데이터를 구할 수 있다.


start me=node:USERID(id={0}) 

match me-[:LIKED]->movie<-[:LIKED]-otherUser-[:LIKED]->othermovie

return othermovie, count(*) as count order by count desc


유사한 방식으로 좋아하는 영화가 비슷한 사용자를 찾아서 친구로 추천할 수 있고, 특정 영화를 본 사용자들이 좋아하는 다른 영화를 추천할 수도 있을 것이다. 


Neo4j는 빅 데이터는 아니다


Neo4j는 아직 Shard를 지원하지 않는다. 즉, 모든 데이터가 단일 노드에 위치해야 한다는 얘기다. 물론, N대의 장비를 이용해서 읽기 성능의 확장과 가용성을 증가시킬 수는 있지만, 수평으로 장비를 늘린다고 해서 수용할 수 있는 데이터의 크기가 증가하는 것은 아니다. 따라서, 한 대의 장비의 수용 범위를 넘어서는 빅데이터를 보관해야 한다면 Neo4j는 적합하지 않다.


라이선스의 선택


Neo4j는  제공하는 기능에 따라 GPL, AGPL, 또는 상용 라이선스를 제공하고 있다. 


버전 

기능

라이선스 
Community

기본 기능 

GPL 
Advanced

모니터링 기능

AGPL 또는 상용 라이선스

Enterprise 

온라인 백업, HA, 모니터링

AGPL 또는 사용 라이선스


GPL과 AGPL의 가장 큰 차이점은 GPL은 모듈을 직접 사용하는 경우에만 소스를 오픈하면 되지만, AGPL은 REST 방식으로 사용해도 소스를 오픈해야 한다는 점이다. 예를 들어, Community 버전의 경우 Neo4j를 서버로 두고 별도 프로세스에서 REST API를 이용해서 접근한다면 내가 만든 소스 코드를 공개하지 않아도 되지만, 모니터링이나 HA 같은 기능을 사용하고 싶다면 임베딩 모드이건 서버 모드이건 상관없이 Neo4j를 사용하는 코드의 소스를 공개해야 한다. 소스를 공개하고 싶지 않다면 상용 라이선스를 사야 한다.


정리


Neo4j가 요즘 유행하는 빅 데이터는 아니지만, 거의 90%에 가까운 사이트의 데이터가 빅이 아님을 고려해 봤을 때 Neo4j는 소셜 기능, 추천 기능, 네트워크 분석 기능 등을 구현하기에 적합한 데이터베이스이다. 트랜잭션을 보장하고 있고, Spring Data Neo4j를 통해서 보다 쉽게 사용할 수도 있기 때문에, 그래프의 특징을 이용해서 특정 기능을 구현하고 싶은 개발자들은 Neo4j의 사용을 고려해볼만하다.

저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

필자는 Maven을 너무 좋아하기에 안드로이드 개발도 Maven 프로젝트로 관리하고 싶었다. 그래서 구글링을 좀 했고, 영어로 된 걸 매번 보고 싶지 않아 나중을 위해 한글로 정리해둔다.


M2E를 위한 Android Connector 설치


먼저 할 일은 이클립스에 설치한 Maven 플러그인과 ADT를 연결하는 위해 Android Connector를 설치하는 것이다. 설치를 하려면 Preferences > Android/Discovery > Open Catalog 메뉴를 실행한 뒤, 아래 그림이 나올 때 android로 검색하면 된다.



검색 결과로 나온 Android Connector를 선택한 뒤 설치하자.


Android용 Archetype 추가 (옵션)


Android Connector를 설치 후, pom.xml 파일에 maven-android-plugin을 설정하면 해당 프로젝트를 ADT와 연동해 준다. 하지만, pom.xml 파일을 처음부터 만들면 (다소) 귀찮을 수 있는데, 그걸 대신 해주는 archetype을 추가해주면 좀 더 편하게 pom.xml 파일을 생성할 수 있다.


Archetype 타입을 추가해주는 방법은 간단하다. New > Maven Project > Next > Select an Archetype 화면에서 [Add Archetype...] 버튼을 클릭한다. 그런 다음 아래와 같이 정보를 입력하고 [OK] 버튼을 클릭하면 해당 Archetype이 추가된다.


* Group Id: de.akquinet.android.archetypes

* Artifact Id: android-quickstart

* Version: 1.0.8


안드로이드 프로젝트 생성하기


안드로이드 프로젝트를 생성하는 방법은 간단하다. 앞서 생성한 android-quickstart Archetype을 이용해서 안드로이드 프로젝트를 생성하면 된다. android-quickstart Archetype을 선택하면 아래 그림과 같은 화면이 나온다. platform의 값에 사용할 안드로이드 플랫폼 버전을 입력해주면 된다.



프로젝트를 생성하면 잠시 후 아래 그림과 같이 Maven 프로젝트가 ADT와 연동된 것을 확인할 수 있다. 아래 그림을 보면 자원 관리를 위한 res 폴더, 자동 생성되는 파일을 위한 gen 폴더 등이 생성된 것을 확인할 수 있다.



생성된 pom.xml 파일을 maven-android-plugin 설정 및 플랫폼 버전 정보 등이 포함된 것을 확인할 수 있다.


<?xml version="1.0" encoding="UTF-8"?>

<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.madvirus</groupId>

    <artifactId>NetworkExplorer</artifactId>

    <version>0.0.1-SNAPSHOT</version>

    <packaging>apk</packaging>

    <name>NetworkExplorer</name>


    <properties>

        <platform.version>2.3.2</platform.version>

    </properties>


    <dependencies>

        <dependency>

            <groupId>com.google.android</groupId>

            <artifactId>android</artifactId>

            <version>${platform.version}</version>

            <scope>provided</scope>

        </dependency>

    </dependencies>


    <build>

        <plugins>

            <plugin>

                <groupId>com.jayway.maven.plugins.android.generation2</groupId>

                <artifactId>android-maven-plugin</artifactId>

                <version>3.1.1</version>

                <configuration>

                    <androidManifestFile>${project.basedir}/AndroidManifest.xml</androidManifestFile>

                    <assetsDirectory>${project.basedir}/assets</assetsDirectory>

                    <resourceDirectory>${project.basedir}/res</resourceDirectory>

                    <nativeLibrariesDirectory>${project.basedir}/src/main/native</nativeLibrariesDirectory>

                    <sdk>

                        <platform>10</platform>

                    </sdk>

                    <undeployBeforeDeploy>true</undeployBeforeDeploy>

                </configuration>

                <extensions>true</extensions>

            </plugin>


            <plugin>

                <artifactId>maven-compiler-plugin</artifactId>

                <version>2.3.2</version>

                <configuration>

                    <source>1.6</source>

                    <target>1.6</target>

                </configuration>

            </plugin>

        </plugins>

    </build>

</project>




참고로, Android Connector와 android-maven-plugin인에 대한 보다 자세한 내용이 궁금하면 아래 사이트를 방문해서 확인하면 된다.


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

거의 모든 객체 지향 관련 서적에서 설명하는 기본 객체 지향 설계 원칙이 있는데, 객체 지향 기초의 마지막 이야기로 이들 원칙들을 살펴보도록 하자.


단일 책임 원칙(SRP)


첫 번째 원칙은 '단일 책임 원칙(SRP; Single Responsibility Principle)'이다. 이는 객체는 단 한 개의 책임(역할)만을 가져야 한다는 내용으로, 객체를 변경해야 하는 이유는 단 하나여야 한다는 원칙이다. 예를 들어, Book 클래스가 JSON으로의 변환을 처리한다고 해 보자. 이 경우 클래스는 다음과 같이 정의될 것이다.


public class Book {

    public int calculatePrice() { ... }

    public String toJson() { ... }

}


만약 Book를 XML이나 HTML로도 표현해야 한다면? 이런 식으로 변환해야 할 대상이 늘어날수록 Book 클래스의 함께 변하게 된다. 이 외에도 JSON이나 XML의 구조가 변경되더라도 Book 클래스의 코드가 변경된다. Book 클래스의 코드가 표현 방식의 추가나 변경에 따라서 함께 바뀌는 증상이 발생하는데, 그 이유는 Book 클래스가 많은 책임을 지고 있기 때문이다.


책임을 많이 질수록 클래스 내부에서 서로 다른 역할을 위한 코드들 간에 강하게 결합될 가능성이 높아진다. 예를 들어, JSON으로 변환하는 코드와 HTML로 변환하는 코드와 계산을 위한 코드 중 어딘가가 연결되어 있을 수 있고, 이런 경우 계산 공식의 변화가 JSON 변환 코드에 변화를 불러 일으킬 수도 있다.


따라서, 한 객체는 하나의 책임만 갖도록 설계해야 한다. 예를 들어, 앞서 Book 클래스의 경우 책 자체의 정보를 처리하는 Book 클래스와 JSON이나 XML로 표현을 변경해주는 JsonConverter, XMLConverter 클래스로 분리해주어야 한다.


이렇게 한 객체가 하나의 책임만을 갖도록 함으로써 각 객체를 변경시키는 이유는 한 가지로 좁혀진다. 책과 관련된 계산 변경이 필요하면 Book만 변경되며, JSON이나 XML 형식이 변경되면 JsonConverter 클래스와 XMLConverter 클래스에만 변화가 생긴다. (물론, Book의 정보 자체에 변화가 생기면 그 변화는 두 Converter에 영향을 준다.)


의존 역전 원칙(DIP)


의존 역전 원칙(Dependency Inversion Principle)은 구현 클래스가 아닌 인터페이스 타입을 사용하라는 규칙이다. 이는 이미 앞서 '객체 지향 기초 3, 유연함 http://javacan.tistory.com/entry/OO-Basic-3-Flexibility'에서 말했던 내용과 동일하다. 구현 클래스는 자주 변경될 가능성이 높기 때문에, 변화 가능성이 상대적으로 적은 추상 타입이나 메서드에 의존하면 변화에 따른 영향을 최소화할 수 있다.



예를 들어, 위 그림에서 보듯이 SomeClient 클래스는 구현 클래스인 FtpLogCollector를 사용(의존)하는 것 보다, 추상 타입인 LogCollector를 사용하는 것이 변화에 영향을 덜 받게 된다. 이 경우 FtpLogCollector 자체의 구현이 변경되거나 심지어 DBLogCollector로 구현 클래스가 바뀌더라도 SomeClient는 거의 영향을 받지 않게 된다.


개방 폐쇄 원칙(OCP)


개방 폐쇄 원칙(Open-Closed Principle)은 특정 클래스(또는 모듈 등)는 그 클래스를 수정하지 않으면서 확장(extension) 가능해야 한다라는 원칙이다. 객체 지향에서 OCP는 크게 두 가지 방법으로 구현할 수 있다.

  • 상속을 통한 구현
  • 조립을 통한 구현

상속을 통한 구현은 스프링 2 버전의 MVC 컨트롤러 구성에서 확인할 수 있다. 아래 그림은 스프링 MVC의 Controller 타입의 계층 구조이다. 이 구조를 보면 AbstractController 클래스를 수정하지 않고, 상속을 통해 기능을 확장한 것을 알 수 있다. 예를 들어, 파라미터를 커맨드 객체로 받기 위해 AbstractController 클래스를 수정하지 않고 이 클래스를 상속받은 BaseCommandController 클래스를 만들었다. 즉, 변경을 하지 않으면서(Closed for modification) 확장에는 열려(open for extension)있는 것이다. 추가로 아래 클래스들은 각각 한 가지 역할만을 갖도록 설계되어 있다. 상속을 통해 기능을 확장하는 경우에도 SRP를 잘 지키고 있다.



OCP를 실현하는 두 번째 방법은 조립(composition)이다. 조립에 대한 내용은 '객체 지향 기초 4, 상속 보단 조립 http://javacan.tistory.com/entry/OO-Basic-4-Composition-Over-Inheritance)에서 살펴봤었는데, 조립과 DIP 그리고 상속이 함께 맞물리는 방법으로 OCP를 구현하게 된다. 아래 그림은 의존 역전 원칙을 설명할 때 사용한 클래스 다이어그램인데, 이 구조를 OCP에도 그대로 사용할 수 있다.



위 그림에서 SomeClient는 로그를 수집하는 기능을 조립하는 방식으로 구현하고 있다. SomeClient를 사용하는 또 다른 코드는 SomeClient가 로그 수집을 직접 수행하는지 조립을 통해 수행하는지는 모를 것이다. SomeClient는 조립을 통해 로그 수집 기능을 구현하고 있기 때문에, 로그 수집 방법을 개선할 필요가 있을 때 SomeClient는 수정하지 않으면 기능을 확장할 수 있다.


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.