2011년 1월 25일 화요일

Servlet3.0 - Annotation 및 Nonblocking 서블릿 구현

서블릿 스펙 3.0이 발표된지도 꽤 시간이 지났는데, 한참동안을 살펴보지 못했다.
요즘은 스프링 프레임워크를 이용하여 개발하다보니 Controller 가 웹 어플리케이션의 전부인것처럼 느껴질때도 있다.

그동안 관심을 가지고 있었던 Nonblocking 서블릿과 함께 어노테이션 기반의 서블릿 개발을 함께 살펴 보도록 하자.

서블릿 요청은 서블릿 스펙 2.5 까지만해도 thread for request 모델이었다. 모든 요청당 하나의 쓰레드가 할당되어 처리되도록 구현된 것이다.
Nonblocking 서블릿이란 처리 시간이 많이 소요되는 요청이라든지 메시지 큐를 이용하여 응답을 전송해야 하는 경우에 비동기적으로 요청을 처리하는 방식을 의미한다. HTTP1.1에서 연결을 지속적으로 유지할 수 있는 특징을 이용하여 서블릿 요청과 응답을 큐에 저장해두고 쓰레드는 반환 시킨 후 응답을 처리할 수 있을 때 다시 처리하는 것이다. 이렇게 하면 처리시간이 많이 소요되는 서블릿이라 하더라도 서블릿 요청이 쓰레드를 독점하지 않기 때문에 쓰레드를 고갈시키지 않는다.

Jetty나 Tomcat6.x 에서 자체적으로 Nonblocking 서블릿을 구현하기 위해 컨테이너에 의존적인 방법들을 소개하였지만 이는 이식성을 결여된 코드를 작성하는 것이기 때문에 추천할 만한 방법은 결코 아니다.

서블릿 3.0에서 Asyncronous processing 이라 하여 요청을 비동기적으로 처리할 수 있는 방법을 스펙에 포함시켰다.
Tomcat7.x에서 서블릿3.0을 지원하기 때문에 Tomcat7.x 에서 테스트 하도록 한다.
JDK는 1.6 이상의 버전을 사용한다.

Annotation을 이용한 웹 어플리케이션 개발


web.xml
<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
  version="3.0"
  metadata-complete="false">  

  <display-name>Servlet3.0 Web Application</display-name>
</web-app>




web.xml 파일은 web-app_3_0.xsd 스키마를 사용하도록 한다.
web-app 속성중에서 metadata-complete 를 false로 지정해야 서블릿 컨테이너가 로드되는 시점에 클래스 패스를 검사하여 Annotation이 설정된 클래스들을 서블릿으로 등록시킨다.

javax.servlet.annotation 패키지에 서블릿 개발 시 사용할 수 있는 annotation 클래스들이 있으면, WebServlet, WebFilter, WebListener등이 자주 사용된다.

살펴 볼 소스 코드는 glassfish 3.0x 버전의 샘플코드로 제공되는 소스다.
@WebServlet annotation의 asyncSupported 값을 true로 지정해야 한다.
doGet() 메서드를 살펴보면 AsyncContext를 생성한 후 queue에 추가시키고 요청을 끝낸다.
이 시점에 해당 서블릿에 할당된 쓰레드는 쓰레드 풀에 반환된다.
doPost() 메서드에는 doGet에서 생성된 asyncContext를 이용하여 각각의 명령에 따라서 결과를 클라이언트에게 전송해 준다.
아래의 소스코드에서는 Comet에서 즐겨 사용하는 방식으로 보이지 않는 iframe과 연결을 시켜두고 javascript로 iframe이 포함되어 있는 페이지의 javascript function을 호출하는 방식을 사용하였다.

package web.servlet.async_request_war;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet(urlPatterns = {"/chat"}, asyncSupported = true)
public class AjaxCometServlet extends HttpServlet {

    private static final Queue<AsyncContext> queue = new ConcurrentLinkedQueue<AsyncContext>();

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

    private static final String BEGIN_SCRIPT_TAG = "<script type='text/javascript'>\n";

    private static final String END_SCRIPT_TAG = "</script>\n";

    private static final long serialVersionUID = -2919167206889576860L;

    private Thread notifierThread = null;

    @Override
    public void init(ServletConfig config) throws ServletException {
        Runnable notifierRunnable = new Runnable() {
            public void run() {
                boolean done = false;
                while (!done) {
                    String cMessage = null;
                    try {
                        cMessage = messageQueue.take();
                        for (AsyncContext ac : queue) {
                            try {
                                PrintWriter acWriter = ac.getResponse().getWriter();
                                acWriter.println(cMessage);
                                acWriter.flush();
                            } catch(IOException ex) {
                                System.out.println(ex);
                                queue.remove(ac);
                            }
                        }
                    } catch(InterruptedException iex) {
                        done = true;
                        System.out.println(iex);
                    }
                }
            }
        };
        notifierThread = new Thread(notifierRunnable);
        notifierThread.start();
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        res.setContentType("text/html");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");
        
        PrintWriter writer = res.getWriter();
        // for IE
        writer.println("<!-- Comet is a programming technique that enables web servers to send data to the client without having any need for the client to request it. -->\n");
        writer.flush();

        final AsyncContext ac = req.startAsync();
//        ac.setTimeout(10  * 60 * 1000);
        ac.setTimeout(20 * 1000);
        ac.addListener(new AsyncListener() {
            public void onComplete(AsyncEvent event) throws IOException {
              System.out.println("## onComplete");
                queue.remove(ac);
            }

            public void onTimeout(AsyncEvent event) throws IOException {
              System.out.println("## onTimeout");
                queue.remove(ac);
            }

            public void onError(AsyncEvent event) throws IOException {
              System.out.println("## onError");
                queue.remove(ac);
            }

            public void onStartAsync(AsyncEvent event) throws IOException {
              System.out.println("## onStartAsync");
            }
        });
        queue.add(ac);
    }

    @Override
    @SuppressWarnings("unchecked")
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        res.setContentType("text/plain");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");

        req.setCharacterEncoding("UTF-8");
        String action = req.getParameter("action");
        String name = req.getParameter("name");

        if ("login".equals(action)) {
            String cMessage = BEGIN_SCRIPT_TAG + toJsonp("System Message", name + " has joined.") + END_SCRIPT_TAG;
            notify(cMessage);

            res.getWriter().println("success");
        } else if ("post".equals(action)) {
            String message = req.getParameter("message");
            String cMessage = BEGIN_SCRIPT_TAG + toJsonp(name, message) + END_SCRIPT_TAG;
            notify(cMessage);

            res.getWriter().println("success");
        } else {
            res.sendError(422, "Unprocessable Entity");
        }
    }

    @Override
    public void destroy() {
        queue.clear();
        notifierThread.interrupt();
    }

    private void notify(String cMessage) throws IOException {
        try {
            messageQueue.put(cMessage);
        } catch(Exception ex) {
            IOException t = new IOException();
            t.initCause(ex);
            throw t;
        }
    }

    private String escape(String orig) {
        StringBuffer buffer = new StringBuffer(orig.length());

        for (int i = 0; i < orig.length(); i++) {
            char c = orig.charAt(i);
            switch (c) {
            case '\b':
                buffer.append("\\b");
                break;
            case '\f':
                buffer.append("\\f");
                break;
            case '\n':
                buffer.append("<br />");
                break;
            case '\r':
                // ignore
                break;
            case '\t':
                buffer.append("\\t");
                break;
            case '\'':
                buffer.append("\\'");
                break;
            case '\"':
                buffer.append("\\\"");
                break;
            case '\\':
                buffer.append("\\\\");
                break;
            case '<':
                buffer.append("<");
                break;
            case '>':
                buffer.append(">");
                break;
            case '&':
                buffer.append("&");
                break;
            default:
                buffer.append(c);
            }
        }

        return buffer.toString();
    }

    private String toJsonp(String name, String message) {
        return "window.parent.app.update({ name: \"" + escape(name) + "\", message: \"" + escape(message) + "\" });\n";
    }
}


chat.html
원래 소스코드는 prototype을 이용하여 작성되었으나 jquery로 변경하여 테스트 하였다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <title>Glassfish Chat</title>
        <link rel="stylesheet" href="stylesheets/default.css" type="text/css" />
        <script type="text/javascript" src="javascripts/jquery.js"></script>
    <script type="text/javascript">
var count = 0;    
var app = {
  url: '/async-request-war/chat',
  initialize: function() {
    $('#login-name').focus();
    app.listen();
  },
  listen: function() {
    $('#comet-frame').attr('src', app.url + '?' + count);
    count++;
  },
  login: function() {
    alert("login");
    var name = $('#login-name').val();
    if(! name.length > 0) {
      $('#system-message').css('color', 'red');
      $('#login-name').focus();
      return;
    }
    $('#system-message').css('color','#2d2b2d');
    $('#system-message').html(name + ":");
    $('#login-button').attr('disabled', 'true');
    $('#login-form').hide();
    $('#message-form').show();

    var query = 'action=login' + '&name=' + encodeURI(name);

    app.callAjaxPost(query, 
      function(data){
        $('#message').focus();
      }, function() {
      });
  },
  callAjaxPost: function(query, successFunc, completeFunc) {
    jQuery.ajax({  
      type: "post",
      async: false,
      url: app.url,
        data: query,
      error:?function(){
        alert('Occur Error');
      },
      success:?successFunc,
      complete: completeFunc
    });   
  },
  post: function() {
    var name = $('#login-name').val();
    var message = $('#message').val();
    if(!message.length > 0) {
      return;
    }
    $('#message').attr('disabled', 'true');
    $('post-button').attr('disabled', 'true');
    var query = 'action=post' + '&name=' + encodeURI(name) + '&message=' + encodeURI(message);
    app.callAjaxPost(query, 
        function() {},
        function() {
          $('#message').removeAttr('disabled');
          $('#post-button').removeAttr('disabled');
          $('#message').focus();
          $('#message').val('');
        });
  },
  update: function(data) {
    var name = data.name;
    var message = data.message;
    var p = $('<p>' + name + ':<br/>' + message + '</p>');
    $('#display').append(p);

    $('#display').attr('scrollTop', $('#display').attr('scrollHeight'));
  } 
};

    
$(document).ready(function() {
  $('#login-name').keydown(function(event) {
    if(event.keyCode == 13) {
      $('#login-button').focus();
    }
  });
  $('#message').keydown(function(event) {
    if(event.shiftKey && event.keyCode == 13) {
      $('#post-button').focus();
    }
  });
  $('#login-button').click(app.login);
  $('#post-button').click(app.post);

  app.initialize();
  
});

    </script>

    </head>
    <body>
        <div id="container">
            <div id="container-inner">
                <div id="header">
                    <h1>Glassfish Chat</h1>
                </div>
                <div id="main">
                    <div id="display">
                    </div>
                    <div id="form">
                        <div id="system-message">Please input your name:</div>
                        <div id="login-form">
                            <input id="login-name" type="text" />
                            <br />
                            <input id="login-button" type="button" value="Login" />
                        </div>
                        <div id="message-form" style="display: none;">
                            <div>
                                <textarea id="message" name="message" rows="2" cols="40"></textarea>
                                <br />
                                <input id="post-button" type="button" value="Post Message" />
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <iframe id="comet-frame" name='cometFrame' style="display: none;"></iframe>
    </body>
</html>


브라우저에서 위의 소스코드를 테스트하고자 한다면 http://localhost:8080/async-request-war/chat 라고 URL을 입력하면 테스트 할 수 있다.

CSS파일과 jquery.js 파일은 생략하도록 한다.

2011년 1월 17일 월요일

Spring에서 JUnit 테스트 시 Transaction 처리

http://viralpatel.net/blogs/2010/11/spring3-mvc-hibernate-maven-tutorial-eclipse-example.html

위의 블로그에 방문하면 spring3 기반에서 hibernate를 이용해서 dao를 개발하는 방법을 비교적 친절하고 상세하게 잘 정리해 두었다. 참고하기 바란다.

이번 포스트에서 이야기하고자 하는 내용은 DAO 컴포넌트를 JUnit으로 테스트 할 때 Transaction 처리에 관한 내용이다.

위의 블로그는 WEB-INF 폴더에 있는 spring-servlet.xml 파일과 jdbc.properties 파일을 조금 수정해 주어야 한다.

위의 두 파일을 src/main/resources 소스 폴더에 spring 이란 패키지를 만들어서 복사한다.
spring-servlet.xml 파일에서 jdbc.properties 파일을 참조하는 부분을 아래와 같이 수정한다.

<bean id="propertyConfigurer" 
      class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"
      p:location="spring/jdbc.properties">
</bean> 

이제서야 JUnit TestCase를 작성할 사전 준비가 완료되었다.

package net.viralpatel.contact.dao;

import java.util.List;

import junit.framework.Assert;

import net.viralpatel.contact.form.Contact;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.transaction.annotation.Transactional;


@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={"classpath*:spring/spring-servlet.xml"})
public class ContactDAOTest {

  @Autowired
  private ContactDAO contactDAO;
  
  @Test
  @Transactional
  public void testListContact() throws Exception {
    List contactList = contactDAO.listContact();
    
    Assert.assertNotNull(contactList);
  }
}


위의 소스를 살펴보자.
@RunWith(SpringJUnit4ClassRunner.class)
@RunWith Annotation을 사용해야 JUnit에서 Spring을 설정을 그대로 사용할 수 있다. 테스트케이스에서 스프링에서 설정한 Bean을 Injection 받아서 사용할 수 있다는 의미다.
그런데 SpringJUnit4ClassRunner.class 사용시 주의해야 할 사항이 있다.
Spring 2.5.x 버전과 Spring 3.0.x 에서의 상속받는 상위 클래스가 다르다.
2.5.x 버전의 SpringJUnit4ClassRunner는 org.junit.internal.runners.JUnit4ClassRunner 클래스를 상속받아 구현되었는데 이는 JUnit4.3 이상에서 제공하는 클래스다.
3.0.x 버전의 SpringJUnit4ClassRunner는 org.junit.runners.BlockJUnit4ClassRunner 클래스를 상속받아 구현되었는데 이는 JUnit4.5 이상에서 제공하는 클래스다.
따라서 spring 버전에 따라서 사용해야하는 junit 버전이 달라짐을 유의해야 한다.

@ContextConfiguration(locations={"classpath*:spring/spring-servlet.xml"})
WEB-INF/spring-servlet.xml 파일을 리소스 폴더로 옮기고 그 클래스패스를 설정해 주었다.

먼저 DAO 구현 클래스를 살펴보자.
package net.viralpatel.contact.dao;


import java.util.List;

import org.hibernate.SessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import net.viralpatel.contact.form.Contact;

@Repository()
public class ContactDAOImpl implements ContactDAO {

  @Autowired
  private SessionFactory sessionFactory;
  
  
  public void addContact(Contact contact) {
    sessionFactory.getCurrentSession().save(contact);
  }

  @SuppressWarnings("unchecked")
  public List listContact() {
    return sessionFactory.getCurrentSession().createQuery("from Contact").list();
  }

  public void removeContact(Integer id) {
    Contact contact = (Contact) sessionFactory.getCurrentSession().load(Contact.class, id);
    
    if(contact != null) {
      sessionFactory.getCurrentSession().delete(contact);
    }
  }
}


소스코드를 살펴보면 "sessionFactory.getCurrentSession()" 부분이 있다.
sessionFactory는 Hibernate의 세션을 관리하는 팩토리로
getCurrentSession()은 트랜잭션을 가지고 있는 현재 세션을 리턴한다.
따라서 트랜잭션에 없는 상황에서는 "No Hibernate Session bound to thread, and configuration does not allow creation of non-transactional one here"라는 에러가 발생한다.
위의 ContactDAOTest에서 @Transactional annotation을 제거하고 실행하면 위와 같은 에러가 발생하는 것을 확인할 수 있다.

일반적인 경우라면 DAO에 Transactional annotation을 붙이지는 않는다.
보통 DAO를 사용하고 있는 서비스에 붙히는데, 전에는 서비스 인터페이스에 설정하는 걸 즐겨했다. 하지만 인터페이스에 @Transactional을 설정하는 것보다 구현 클래스에 설정하는게 더 적합하다. 인터페이스는 Transaction 설정과 같은 세부사항에 관여하면 안 되기 때문이다.

어쨋거나 이 글에서 이야기하고자 하는 내용은 지금부터다.

JUnit의 @Test annotation을 설정하여 DAO에 대한 테스트를 실행하는 경우 Insert, Update, Delete를 테스트할때마다 매번 DB에 반영되는게 번거롭고 문제도 가끔씩 발생시킨다.

테스트 자동화시에는 대부분의 경우 이렇게 데이터에 영향을 미치는 연산을 제외시키기 위해서 @Ignore annotation을 테스트 메서드에 달아 두곤 했다.

하지만 스프링에서 @Test annotation과 함께 설정된 @Transactional은 항상 rollback된다.
따라서 create, update, delete의 로직이 정상적으로 수행됨은 확인하면서도 실제로 DB에 반영되지는 않는다. 롤백이 되는 것과 에러가 발생하는것은 분명한 차이가 있다. PK가 중복된다면 rollback이 아니라 에러가 발생한 경우다. 데이터베이스에 데이터가 없다고 정상처리되지 않았다고 판단하면 안된다. JUnit이 초록색 불을 밝히는 것과 붉은색 불을 밝히는 것은 엄청난 차이가 있다. JUnit이 초록불이라면 데이터베이스에 값이 반영되지 않는다 하더라도 대부분의 경우에는 믿어도 된다.
매번 실행한다고 해도 데이터에 영향이 미치지 않는다.

하지만 눈으로 직접 추가, 변경, 삭제를 확인해보고 싶다면 어떻게 해야 할까. JUnit의 @Test 메서드를 실행하면서도 트랜잭션이 롤백되지 않도록 설정하면 된다.
첫번째 방법은 org.springframework.test.annotation.Rollback annotation을 사용하면 된다.

@Test
  @Transactional
  @Rollback(false)
  public void testAddContact() throws Exception {
    Contact contact = new Contact();
    contact.setFirstname("YG");
    contact.setLastname("Kim");
    
    contactDAO.addContact(contact);
    
    Assert.assertNotSame(0, contact.getId());
  }

위와 같이 @Rollback(false)로 설정하면 테스트 메서드라 하더라도 롤백되지 않는다.

다른 방법으로는 테스트 클래스에 지정하는 방식이다.
org.springframework.test.context.transaction.TransactionConfiguration annotation을 사용한다.
@TransactionConfiguration(transactionManager="transactionManager", defaultRollback=false)
public class ContactDAOTest {
...
}

위와 같이 설정하면 모든 테스트 메서드의 롤백이 실행되지 않고 데이터베이스에 반영된다.
만약 위와 같이 설정하고 특정 메서드에서 롤백이 되기를 원한다면 @Rollback(true)로 지정하면 된다.

스프링의 테스팅을 상세히 살펴보고자 한다면 http://static.springsource.org/spring/docs/3.0.x/spring-framework-reference/html/testing.html 사이트에 방문하기를 권한다.