서블릿 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() 메서드를 호출해서 클라이언트와의 연결을 종료하게 된다.
웹 브라우저에서 위 서블릿에 연결하면, 전체 실행 흐름은 다음과 같이 흘러가게 된다.
- 클라이언트의 요청을 수신하는 쓰레드(T1)가 AsyncHelloWorldServlet의 doGet() 메서드를 실행한다.
- T1은 req.startAsync() 메서드를 이용해서 비동기 처리를 위한 AsyncContext 객체를 구한다.
- T1은 비동기로 응답을 처리할 쓰레드 T2를 생성하고 실행한다.
- T2는 5초간 실행을 중지한다.
- T1은 doGet() 메서드가 종료되고, 컨테이너의 쓰레드 풀에 반환된다.
- T2는 AsyncContext를 이용해서 클라이언트에 응답을 전송한다.
- T2는 complete()을 통해 클라이언트와의 연결을 종료한다.
- 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 프로젝트로 작성되었다. 다운로드 받은 뒤 압축을 풀고 다음의 명령을 실행하면 바로 예제를 테스트 해 볼 수 있다.
소스 코드는 아래 링크에서 다운로드 받을 수 있다.