WebSocket
프로젝트 목적
웹소켓에 관한 이해
깃허브 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가지 작업이 진행된다고 가정한다.
- 어떤 queue(혹은 topic) 에 참여할 것인가.
- 메시지를 보내는데, 어떤 엔드포인트로 보낼 것인가.
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
를 생성할 수 있고, 특정broker
의Queue
에 저장한다.STOMP
은In-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
로 메세지를 보내면 된다.