어느덧 국비 과정의 마지막 수업이 다가오고 있다. 시간이 이렇게 빠르다니. 오늘은 웹 소켓을 이용한 기본적인 채팅 프로그램을 만들어 보자.
채팅 프로그램엔 여러 유형이 있고, 그에 따라 난이도가 천차만별이다. 하지만 공통점은 클라이언트-서버가 채팅하는 게 아닌, 클라이언트 간의 채팅을 서버가 중개해주는 것이다.
근데 “요청이 있어야 응답이 있다”라는 HTTP 기반 웹 애플리케이션에서는 가능은 하지만 매우 어려운 얘기이다. 쉽게 말하면 채팅을 친 클라이언트와 서버 간에는 가능하지만, 그 채팅을 받아야 할 다른 클라이언트들은 요청하지 않았기에 응답을 받지 못한다.
이를 HTTP로 구현하려면 Ajax로 주기적으로 요청을 보내 서버에 갱신된 내용을 받아오는 법밖에 없다. 하지만 이 경우 서버가 주기적(n초)으로, 서버 접속자 수(n명)만큼, n^2의 요청을 받아 큰 서버 부하가 발생한다.
반대로 부하를 줄이려면, 주기적으로 요청을 보내는 시간을 늘리면 트래픽이 줄어들지만, 이 때문에 채팅의 ‘반응성’을 잃어버리게 된다. 이런 이유로 AJAX로 채팅을 만드는 것은 적합하지 않다.
이 문제를 해결하기 위해, HTTP를 사용하지 않고 Web Socket을 만들어 사용한다.
그럼 WebSocket은 무엇일까?
먼저 파일 전송 시스템에서 사용한 소켓은 ‘양방향 스트림’이 가능했다. 즉, 보내고 받는 것이 가능했다. 그리고 이 개념을 웹에 차용한 게 WebSocket이다.
여기서 WebSocket은 그 자체가 Http처럼 하나의 프로토콜이라 http와 호환이 되지 않아, http session의 저장된 로그인 정보를 확인할 수 없는 문제가 있는데, 이는 차차 해결해보자.
웹 소켓을 사용하려면 Maven에서 웹 소켓 라이브러리를 다운 받아야한다. 이 웹소켓 라이브러리는 지금 단계에선 스프링과 연계되어 사용되지 않는다,
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
일단 기본적인 UI들을 만들어 주고, 시작은 클라이언트에서 JS로 웹소켓 통신을 요청하면서 시작된다.
<script>
$(function(){
let ws = new WebSocket("url"); .
$("#input").on("keydown", function(e){
if(e.keyCode == 13){
let text = $("#input").html();
let line = $("<div>");
line.append(text);
$("#input").html("");
ws.send(text);
return false;
}
});
ws.onmessage = function(e){
let line = $("<div>");
line.append(e.data);
$("#msg_box").append(line);
}
})
</script>
요청이 들어오는 서버 클래스를 Endpoint라고 한다. 일차적으로 이 클래스들을 관리할 패키지와 각 요청에 맞는 클래스들을 만들어 주는데, 우리가 구현할 기능은 채팅 하나니까, ChatEndPoint
만 만들면 된다.
첫 번째 포인트는 WS로 들어온 요청은 스프링의 DS로 가지 않는다. 왜냐면 DS는 HTTP기반 요청을 받기 때문이고, 웹 소켓 자체는 스프링하고 연관이 없기 때문이다.
그래서 톰캣 서버가 구동되면 HTTP 요청을 받을 DS와 WS 요청을 받은 Endpoint가 메모리 안에 생성된다.
그럼 어떻게 EndPoint인지 알까? 이 역시 어노테이션으로 구분해준다.
@ServerEndpoint(“/chat") // 이 경로로 요청이 들어온다.
public class ChatEndpoint {}
// 이런 식으로 해당 엔드포인트가 따를 규칙을 설정할 수 있다
@ServerEndpoint(value = "/chat", configurator = WebSocketConfigurator.class)
public class ChatEndpoint {}
두 번째 포인트는 이런 엔드포인트 인스턴스는 접속자 한 명당 같은 종류의 인스턴스가 생성된다는 것이다. 그래서 결합성을 따지는 Spring 측면의 접근이 의미없다.
이렇게 만들어진 엔드포인트들은 내부의 어노테이션으로 각 작업을 할당해놓는다. 이제부터는 각 어노테이션의 할당된 역할을 알아보자.
클라에서 요청이 들어와 연결이 만들어 질 때, 실행되는 어노테이션이다. 해당 메서드들로 접속 시, 이전 대화목록을 불러올 수도 있다.
@OnOpen
public void onConnect(Session session, EndpointConfig config) {
this.hSession = (HttpSession)config.getUserProperties().get("hSession");
clients.add(session);
System.out.println(hSession.getAttribute("loginID"));
}
기본적으로 웹 소켓 세션을 가져올 수 있다. 이건 HTTP 세션과 다른 세션이며, 클라이언트 하나당 한 개씩 존재한다. 위 코드는 hSession은 HTTP 세션인데, 이건 뒤쪽에서 설명하겠다.
클라이언트의 .send 메서드가 실행되면, 입력된 내용이 서버로 전달된다. 그러면 서버에선 @onMessage 어노테이션으로 정의된 메서드로 그 내용을 처리한다.
@OnMessage
public void onMessage(String message) {
synchronized (clients) {
for(Session client : clients) { .
try {
client.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
위에는 동기화 처리가 되어 있긴 한데, 그건 잠깐 넘어가자. 여기서 중요한 것은 받은 메시지를 처리하는 로직이다. 다시 말하면 서버로 보낸 메시지를 채팅 참여자들이 볼 수 있도록 서버가 전달해줘야 한다는 것이다.
일단 메시지 자체는 String messge
매개변수로 받아줄 수 있다. 근데 문제는 각자 다른 엔드포인트 인스턴스이기 때문에 이를 연결해줄 수 없다는 것이다. 따라서 우리가 첫째로 할 것은 같은 종류의 엔드 포인트들이 자신들의 위치를 공유할 수 있는 저장소를 만들어주는 것이다.
① private static Set clients
세션에는 각 클라이언트의 정보가 담겨있다. 그래서 이것만 알면 우리는 세션을 통해서 서로 연락을 주고받을 수 있다. 일단 참조 변수를 적어주긴 했는데, 중요한 건 그 변수가 Set 컬렉션의 static, 그리고 private라는 것이다.
일단 Set 컬렉션은 리스트와 비슷하지만, 중복되는 값을 저장할 수 없어서 사용한다. 이 자료형 안에 각 세션 정보를 저장해두는 것이다.
그리고 이를 static으로 해서 별다른 인스턴스 생성 없이 항상 존재하게 만든다. 또한 static의 특성상 모든 인스턴스는 하나의 클래스 변수를 공유하게 한다. 마지막으로 private를 통해서 같은 종류의 인스턴스들 끼리만 공유하도록 한다.
private static Set<Session> clients = new HashSet<>();
② @onOpen
여기서 @onOpen이 다시 재등장한다. 일단 메모리의 영역에서만 본다면, 연결되면서 내가 해당 채팅에 참여한다는 행위로 보는 게 좋을 것 같다.
clients.add(session);
자 이제 메시지를 받을 참여자들의 세션을 한 군데 취합하였다. 그래서 다음 단계는 그 저장소에 적힌 연락처들을 하나씩 보내서 메시지를 보내면 된다. 이게 바로 @onMessage의 주 역할이다.
for(Session client : clients) { // 연락처를 한 개씩 꺼내온다.
try {
client.getBasicRemote().sendText(message); // 메시지를 보내는 메서드
} catch (Exception e) {
e.printStackTrace();
}
}
앞선 두 단계까지만 하면 충분할 것 같지만, 문제는 저 상태에서 누군가 채팅방을 나가거나, 브라우저를 닫아버리면 clients set의 길이가 갑자기 줄어들면서 에러가 발생한다.
이는 자바의 멀티쓰레드에 의한 문제인데, 여기서 쓰레드는 일하는 공간? 정도로 생각하는게 편하겠다. 무튼 채팅을 보내고, 클라이언트가 나가는 상황이 동시에 일어날 수 있다는 것이다. 왜? 작업 공간이 여러개라서
이런 문제를 극복하기 위해서 ‘안정화 작업’이 필요하다.
① 동기화 Set 생성
세션 정보를 저장할 Set 자체를 동기화 상태로 만들어 생성한다. 그렇게 되면 이 Set의 작업 중일 때는 다른 작업은 못하게 된다.
private static Set<Session> clients = Collections.synchronizedSet(new HashSet<>());
② 동기화 블록으로 감싸기
그렇지만 메시지를 보내는 행위 자체는 막을 수 없다. 그래서 반복문으로 메시지를 보내는 작업도 동기화 블록으로 감싸서, 해당 작업이 끝날 때까지 다른 작업이 진행되지 못하게 한다.
// 동기화 블럭 : 인자에 대한 작업이 끝나기 전까지 그 다음 작업은 대기 상태가 된다.
synchronized (clients) {
for(Session client : clients) { .
try {
client.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
근데 안정화 문제보다 앞서는 것은 현 상태에서 브라우저를 꺼버리게 되면 클라이언트의 WS의 세션은 사라지는데, 서버의 세션은 사라지지 않고 저장소에 남아있게 된다는 것이다.
그래서 두 세션의 차이로 인해서 메시지를 보내게 되면 이미 클라이언트에선 사라진 세션에 메시지를 보내려다 보니 에러가 난다.
때문에 @OnClose / @OnError 어노테이션으로 클라이언트가 세션을 종료하거나, 에러가 날 경우 저장소에서 해당 세션을 제거해줘야 한다. ( 엔드포인트 하나당 세션 한 개라는 것을 기억하자 )
@OnClose // 연결을 클라이언트에서 끊었을 때 동작
public void onClose(Session session) {
clients.remove(session); // 이 세션은 이 엔드포인트에서만 사용한다.
}
@OnError
public void onError(Session session, Throwable t) {
t.printStackTrace();
clients.remove(session);
}
기본적인 채팅은 지금 다 가능하다. 이걸 좀 더 발전시키면, 메시지 전송자에 대한 정보를 알고 싶다. 근데 문제는 웹소켓 세션은 HTTP Session하고 달라서, 사용자 정보가 안 담긴다.
그러니까 HTTP에서 하던 (String)session.getAttribute("loginID");
이런게 안된다는 것이다.
따라서 이를 우리가 직접 구현 해줘야 한다.
일단 세션 정보를 가져올 대상은 WS 세션이 아닌, HTTP 세션에서 가져온다. 그러기 위해선 WS 요청이 톰캣을 지나는 타이밍에 DS에서 사용되는 HTTP 세션 객체를 받은 다음에 EndPoint로 가야 한다.
이 설정을 위해선 먼저 패키지와 클래스를 추가해야 한다. 이걸 만든 이유는, 요청이 들어오면서 설정 정보를 톰캣의 Configurator 객체로 받기 때문인데, 우리는 이 객체를 상속받아서 커스텀 한 다음에 거기서 정보를 추출할 것이다.
네트워크 용어로 ‘프로토콜 성립 과정’을 의미한다. A라는 장치가 B라는 장치에 네트워크 통신을 하자고 요청하고, B는 그에 응답하는 과정을 말한다.
이 프로토콜 과정은 단순히, 요청-응답이 끝나는게 아니다. 앞으로의 A와 B의 통신 규약에 대해서 두 장치가 통신 정보를 주고받는 것이다. 이 주고받는 과정이 3번 정도기 때문에 Three way Handshake라 한다.
우리가 상속 받을 톰캣의 Configurator에 이 HandShake의 통식 규칙이 담겨 있다. 결국 우리가 한다는 건 Handshake 과정을 오버라이딩하여 서로의 통신 규칙을 커스텀하는 것이다.
public class ChatConfigurator extends Configurator{
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { // 통신 규칙 정하기
HttpSession session = (HttpSession)request.getHttpSession(); // 1) 세션 정보를 꺼내라
sec.getUserProperties().put("hSession", session); // 2) 엔드포인트 전달하고 싶은 사용자 정보를 담음 ServerEndpointConfig는 엔드포인트에 접근 가능하다.
}
}
여기서 우리가 매개변수로 받는 ServerEndpointConfig sec
를 엔드포인트에서 사용하는 EndpointConfig
를 상속받으며, 엔드포인트의 어노테이션으로 해당 규칙을 따른다는 것을 명시해줘야 한다.
[ ChatEndPoint ]
@ServerEndpoint(value = "/chat", configurator = ChatConfigurator.class)
public class ChatEndpoint {
}
private HttpSession hSession;
@OnOpen //
public void onConnect(Session session, EndpointConfig config) {
this.hSession = (HttpSession)config.getUserProperties().get("hSession"); // 넣어놨던 HTTP Session을 꺼낸다.
clients.add(session);
System.out.println(hSession.getAttribute("loginID")); // 세션 안의 키를 통해 값을 꺼낸다.
}
이렇게 받은 정보는 JSON으로 클라이언트로 보내준다. 나는 GSON 라이브러리를 사용해서 JSON으로 쉽게 변환한다.
@OnMessage
public void onMessage(String message) {
synchronized (clients) {
String longinID = (String)httpSession.getAttribute("loginID");
try {
for(Session client : clients) {
String nowTime = getTime();
JsonObject data = new JsonObject();
data.addProperty("sender", longinID);
data.addProperty("msg", message);
data.addProperty("time", nowTime);
JsonArray arr = new JsonArray();
arr.add(data);
client.getBasicRemote().sendText(arr.toString());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
클라이언트에선 JSP로 미리 로그인 ID를 세팅하여 다음과 같이 동적바인딩을 통해 구별해 출력한다.
ws.onmessage = function(e){
let data = JSON.parse(e.data)
for (let i = 0; i<data.length; i++){
if('${loginID}'== data[i].sender){
let box = $("<div style='text-align:right;'>")
let id = $("<div class='id'>");
let msg = $("<span class='msg' style='background-color:yellow;'>");
let time = $("<div class='time'>")
let line = $("<br>");
id.append(data[i].sender);
msg.append(data[i].msg);
time.append(data[i].time)
box.append(id);
box.append(msg);
box.append(time);
$("#msg_box").append(box);
$("#msg_box").append(line);
} else{
let box = $("<div>")
let id = $("<div class='id'>");
let msg = $("<span class='msg'>");
let time = $("<div class='time'>")
let line = $("<br>");
id.append(data[i].sender);
msg.append(data[i].msg);
time.append(data[i].time)
box.append(id);
box.append(msg);
box.append(time);
$("#msg_box").append(box);
$("#msg_box").append(line);
}
}
}