설모의 기록

[Spring] WebSocket 구현하기 본문

언어/Spring

[Spring] WebSocket 구현하기

HA_Kwon 2018. 8. 26. 15:36


Web Socket?



웹에서 채팅을 구현할 때 사용하는 Web Socket 에 대해 알아보겠습니다. 

보통 서버에게 정보를 요청할 때 HTTP/HTTPS 통신을 거치게 되는데요. HTTP/HTTPS 통신은 클라이언트가 요청을 했을 때 서버가 해당하는 정보를 응답해주는 구조입니다. 

그러나 채팅은 누군가가 대화를 보내면 내가 서버에 요청을 보내지 않아도 서버가 저에게 정보를 주어야 합니다. 이럴 때 사용하는게 웹소켓입니다. 내가 원하는 정보에 대해 구독을 신청하고, 토픽에 대한 메세지를 발행하면 해당 토픽을 구독하고 있는 모든 사용자에게 보내주는 방식입니다. 

HTTP/HTTPS 는 같은 사용자가 서버에게 여러 번 자원이나 정보를 요청하는 경우에 매번 연결을 요청해야 하며 그 때마다 Header 에 요청 정보를 실어 보내야 합니다. 그러나 소켓통신은 한번 연결을 하면 연결이 유지되어 별다른 설정없이 정보를 주고 받을 수 있습니다.

일반적인 웹소켓은 ws 로 HTTP 에 해당하며, wss 는 데이터 보안을 위해 SSL 을 적용한 프로토콜로 HTTPS 로 볼 수 있습니다.


보통 Web Socket 은 Node.js 로 구현된 예제가 가장 많습니다. javascript 로 구현하기가 가장 편하기 때문이죠.

이 포스팅은 Springboot + Gradle + WebSocket + JavaScript 를 기반으로 구현된 코드를 바탕으로 설명하는 포스팅입니다.


구현에 앞서 WebSocket 의 기본 동작 원리는 클라이언트마다 원하는 토픽을 구독신청해놓고, 특정 사용자가 토픽에 해당하는 메세지를 보내면 그 토픽을 구독하는 모든 클라이언트에게 보내는 구조입니다. WebSocket 에서의 토픽은 HTTP 에서의 URI 로 생각하시면 편할 것 같습니다!



Gradle 설정 및 Config 파일 생성

compile('org.springframework.boot:spring-boot-starter-websocket')
compile("org.webjars:sockjs-client:1.1.2")
compile("org.webjars:stomp-websocket:2.3.3")

먼저 build.gradle 파일에 위의 3개를 추가합니다.


@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker(
"/topic", "/queue");
registry.setApplicationDestinationPrefixes("/");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("room1").getRoomName()).addInterceptors(new HttpHandshakeInterceptor()).withSockJS();     }
}

다음으로 config 파일을 생성해야 합니다. 코드 한 줄씩 살펴보겠습니다.

  • 1. registry.enableSimpleBroker("/topic", "/queue"); : 메세지브로커를 등록하는 코드
    -> 
    보통 /topic 과 /queue 를 사용하는데요, /topic 은 한명이 message 를 발행했을 때 해당 토픽을 구독하고 있는 n명에게 메세지를 뿌려야 하는 경우에 사용합니다. 반면에 /queue 는 한명이 message 를 발행했을 때 발행한 한 명에게 다시 정보를 보내는 경우에 사용합니다. 저는 두 개의 경우 모두 사용하기 때문에 /topic, /queue 를 모두 등록했습니다.


  • 2. registry.setApplicationDestinationPrefixes("/"); : 도착경로에 대한 prefix 를 설정
    -> 
    예를 들어, registry.setApplicationDestinationPrefixes("/app"); 이라고 설정해두면 /topic/hello 라는 토픽에 대해 구독을 신청했을 때 실제 경로는 /app/topic/hello 가 되는 것입니다.


3. registry.addEndpoint("/room1").addInterceptors(new HttpHandshakeInterceptor()).withSockJS(); : 엔드포인트 등록 -> 연결할 소켓 엔드포인트를 지정하는 코드입니다. room1 이라는 endpoint 에 interceptor 를 추가해 소켓을 등록합니다.




Interceptor 클래스

다음으로 소켓이 연결될 때 수행해야 할 작업을 해주는 interceptor 클래스 코드를 보겠습니다.

public class HttpHandshakeInterceptor implements HandshakeInterceptor {

@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map attributes) {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpSession session = servletRequest.getServletRequest().getSession();
attributes.put(SESSION, session);
}
return true;
}

@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception ex) {
}
}

위의 HttpHandshakeInterceptor 클래스는 HanshakeInterceptor 클래스를 상속받았습니다. 

웹소켓은 처음 connect 시점에 handshake 라는 작업이 수행됩니다. handshake 과정은 HTTP 통신을 기반으로 이루어지며 GET방식으로 통신을 하게 됩니다. 이때, HTTP 요청 헤더의 Connection 속성은 Upgrade 로 되어야 합니다.

웹소켓은 3번의 handshake 를 거쳐 연결을 확정합니다. 위의 beforeHandshake 는 클라이언트의 연결 요청이 들어오면 3번의 handshake 에서 호출됩니다. 결국 3번이 실행되는 것입니다. 제 코드에서는 HTTP 통신에 존재하는 Session을 웹소켓 세션으로 등록하는 코드입니다. SESSION 변수는 static 변수로 String 타입입니다.

afterHandshake 도 마찬가지로 hanshake 과정이 일어난 후 호출되는 함수입니다.



Message Controller 클래스

@Controller
public class ChatController {

@MessageMapping("info")
@SendToUser("/queue/info")
public String info(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);         return message;
}

@MessageMapping("chat")
@SendTo("/topic/message")
public String chat(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);         if(talker == null) throw new UnAuthenticationException("로그인한 사용자만 채팅에 참여할 수 있습니다."); return message;
}

@MessageMapping("bye")
@SendTo("/topic/bye")
public User bye(String message) { User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);
return talker;
}
}

위의 ChatController 는 웹소켓 통신을 위한 Message 컨트롤러입니다. 

@MessageMapping 어노테이션에 발행하는 경로를, @SendTo와 @SendToUser 어노테이션에 구독 경로를 작성합니다. 예를 들어, 특정 사용자가 chat 이라는 경로로 메세지를 보내면 /topic/message 라는 토픽을 구독하는 사용자들에게 모두 메세지를 뿌리는 것입니다.

여기서 주목할 것은 @SendTo와 @SendToUser 입니다. SendTo 는 1 : n 으로 메세지를 뿌릴 때 사용하는 구조이며 보통 경로가 /topic 으로 시작합니다. 반면에 SendToUser 는 1 : 1 으로 메세지를 보낼 때 사용하는 구조이며 보통 경로가 /queue 로 시작합니다.

또한 아까 Interceptor 에서 넣어준 session 을 이용할 수 있습니다. 파라미터에 SimpleMessageHeaderAccessor 를 추가한 후에 messageHeaderAccessor 의 세션에서 SESSION 이라는 key 값으로 등록한 세션을 꺼낼 수 있습니다. 저는 해당 세션에 저장해 둔 user 를 꺼내오도록 코드를 구현했습니다.

이런식으로 Controller 코드를 작성해주시면 클라이언트와 웹소켓 통신을 할 수 있습니다.



websocket 프론트엔드 코드

이번엔 클라이언트 코드를 작성해보겠습니다. 제 경우, 프론트엔드는 HTML + JavaScript 로 작성하였으며 별다른 프레임워크를 적용하지 않은 순수 자바스크립트를 이용했습니다.

먼저 HTML 에 아래 두 개의 스크립트를 추가해주세요. 맨 처음 Gradle 을 추가하셨다면 에러가 나지 않을 것입니다.

<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>


이제 소켓을 연결해보겠습니다. 

let socket = new SockJS("room1");
let stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log("소켓 연결 성공", frame);
});

먼저 SOCKJS 를 통해 소켓을 생성한 후 connect 메소드를 호출합니다. SockJS() 의 파라미터로는 위의 서버에서 추가했던 endpoint 명과 일치해야 합니다.

생성한 stompClient 를 이용해 소켓을 연결합니다. 연결 이후 콜백 메소드의 파라미터인 frame 은 연결 정보가 담겨있습니다.


다음은 토픽을 구독하는 방법입니다.

stompClient.subscribe("/topic/message", function (response) {
console.log(JSON.parse(response.body));
});

stompClient 의 subscribe 메소드는 두 개의 파라미터가 필요합니다. 

첫 번째는 구독할 토픽의 url 입니다. 위의 Message Controller 에서 @SendTo 또는 @SendToUser 어노테이션으로 등록한 url 을 작성하시면 됩니다. 

두 번째 파라미터는 콜백메소드입니다. 구독한 url 에 대한 발행이 이루어지면 해당 콜벡메소드가 호출됩니다. 파라미터로 들어오는 response 의 body 정보를 JSON 으로 파싱해서 사용하시면 됩니다.


이제 소켓을 통해 메세지를 발행해보겠습니다.

stompClient.send("/chat", {}, JSON.stringify({message: "hi"}));

stompClient 의 send 메소드는 3개의 파라미터가 필요합니다. 첫번째는 Message Controller 클래스에서 등록한 매핑 url 입니다. 두 번째는 헤더정보입니다. contentType 등을 기술하시면 됩니다. 세 번째 파라미터 바로 보낼 데이터인데요. 저는 message 로 hi 를 보내겠습니다.

이 메소드를 호출하면 서버의 Message Controller 에서 해당하는 url 의 메소드가 호출됩니다. 제 경우에는 chat 이라는 메소드가 호출될 것입니다. 그러면 @SendTo 어노테이션으로 등록한 "/topic/message" 를 구독하는 사용자에게 message 를 뿌리게 됩니다. 따라서 위에서 구독 신청을 통해 등록한 콜백메소드가 호출되는 것입니다.


소켓을 모두 사용했다면 소켓 연결을 끊어줘야 합니다. disconnect 하는 코드는 아래와 같습니다.

stompClient.disconnect();



서버와 클라이언트가 계속 연결을 유지하며 주기적으로 메세지를 주고 받아야 하는 경우에 웹소켓이 편리할 경우가 많을 것입니다. 특히 채팅의 경우가 대표적인데요. 위의 코드가 웹소켓 구현에 도움이 되었으면 좋겠습니다.

Comments