Post

WebSocket

프로젝트 목적

웹소켓에 관한 이해

깃허브 URL

깃허브 URL

웹소켓

TCP/IP 위에서 작동하며, 한번 연결하면 그 연결이 쭉 지속된다. 매번 요청, 응답에 많은 리소스가 들면 응답 완료 시 연결이 끊기는 HTTP 의 한계를 극복한다.

STOMP

TCP 처럼 메시지 전송을 효율적으로 하기 위한 WebSocket 위에서 작동하는 프로토콜이다. 클라이언트와 서버가 서로 전송할 메시지의 유형, 형식, 내용을 정의하는 규칙이다.

과정

웹소켓 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 주소 url -> /ws
        // https 통신인 경우 /wss
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 목적지를 정한다. -> /app
        registry.setApplicationDestinationPrefixes("/app");
        // 목적지의 prefix 를 정한다.
        registry.enableSimpleBroker("/topic");
    }
}

세션 이벤트 리스터 추가

사용자가 접속해 STOMP 가 생성되는 순간, 즉 세션이 만들어지는 순간은 js 에서 처리한다.
웹소켓을 종료하는 순간, 해당 세션도 사라지며 @EventListener 를 사용해 제어할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {

    private final SimpMessageSendingOperations messagingTemplate;

    /**
     *
     * @param event 세션이 종료되는 이벤트
     */
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) Objects.requireNonNull(headerAccessor.getSessionAttributes()).get("username");
        if (username != null) {
            log.info("{} 님이 접속을 종료했습니다.", username);
            var chatMessage = ChatMessage.builder()
                    .type(MessageType.LEAVE)
                    .sender(username)
                    .build();
            messagingTemplate.convertAndSend("/topic/public", chatMessage);
        }
    }
}

컨트롤러 설정

MVC 와 약간 다르지만, 결국 엔드포인트를 지정하는 건 동일하다. @MessageMapping 을 통해 엔드포인트를 지정한다. 그리고 @SendTo 를 통해서 어떤 큐에 subscribe 할 지 정한다. (여러 복잡한 과정을 어노테이션 덕분에 생략할 수 있다.)

따라서, js 를 통해 2가지 작업이 진행된다고 가정한다.

  1. 어떤 queue(혹은 topic) 에 참여할 것인가.
  2. 메시지를 보내는데, 어떤 엔드포인트로 보낼 것인가.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
     * {@code @MessageMapping} api 의 엔드포인트를 지정한다.
     * {@code @SendTo} 어떤 topic, 어떤 queue 로 보낼 지를 지정한다.
     * @param chatMessage
     * @return
     */
    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(
            @Payload ChatMessage chatMessage
    ) {
        return chatMessage;
    }

    /**
     * 사용자와 웹소켓간 연결을 맺는다.
     * @param chatMessage
     * @param headerAccessor
     * @return
     */
    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(
            @Payload ChatMessage chatMessage,
            SimpMessageHeaderAccessor headerAccessor
    ) {
        // 웹 소켓 세션에 참여한 사용자의 정보를 집어넣는다.
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        return chatMessage;
    }

위 컨트롤러와 맵핑되는 js 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
function onConnected() {
    // '/topic/public' 에 구독 시작
    stompClient.subscribe('/topic/public', onMessageReceived);

    // /chat.addUser 로 메시지를 보낸다.
    stompClient.send("/app/chat.addUser",
        {},
        JSON.stringify({sender: username, type: 'JOIN'})
    )

    connectingElement.classList.add('hidden');
}

위 코드를 보면 stompClient 라는 객체를 통해서 subscribe, send 가 이루어진다.

아래 코드는 STOMP 를 통해 소켓을 생성하고 연결하는 코드이다.

메세지를 보내는 함수, 그리고 메세지를 받는 함수를 나눠 작성한다.

해당 프로젝트에서는 /topic 으로 시작하는 broker 를 1개 생성했다. (registry.enableSimpleBroker("/topic"))

따라서, 위 js 코드를 다시 해석해보면,

  • 컨트롤러에 설정한 @MessageMapping 값으로 지정한 엔드포인트로 메세지를 보낸다.
  • 다만, STOMP 은 n개의 broker 를 생성할 수 있고, 특정 brokerQueue 에 저장한다.
    • STOMPIn-Memory broker 를 지원한다.
  • 컨트롤러에 설정한 @SendTo 값으로 지정한 url, 즉 /topic/public 으로 설정한 Queue 에 메세지를 담는다.
  • broker 는 웹소켓으로 연결된 client 에게 메세지를 전달한다.

물론, 중간에 로드밸런싱, 필터, 헤더 추가 및 삭제 등 여러 작업을 할 수 있다고 한다. 웹소켓 관련 라이브러리들이 다양한 편의 기능을 제공하기 때문에, 개발자가 할 일은 중간에서 원활한 교통정리인 듯 하다.

개선점 (향후계획)

현재는 간단한 학습 목적으로 /topic/public 단 1개만 존재한다. 만약, 여러 채팅방을 구현한다면 어떻게 할지 미리 계획을 세워본다.

/topic/${roomId}/public 처럼 유니크하게 각 채팅방을 구분짓는 구분값을 넣으면 간단하게 구현할 수 있을 것으로 생각된다. 이를 위해선, ChatRoom 객체가 필요하다.

기존 MVC 구조로 ChatRoom 을 여러 개를 만들고, 이를 Map<String, ChatRoom> 로 보관한다.

그리고 js 에서는, /topic/public 이 아니라 /topic/${roomId}/public 로 메세지를 보내면 된다.


출처

  1. https://dev-gorany.tistory.com/212
  2. https://mr-popo.tistory.com/234
  3. https://dev-gorany.tistory.com/325
This post is licensed under CC BY 4.0 by the author.