WebSocket, STOMP로 구현하는 실시간 채팅 이해하기

seobin·2025년 4월 15일

✅ 1. WebSocket이란?

기존 웹 통신은 클라이언트가 요청(request)을 보내면 서버가 응답(response)을 하는 구조로 되어 있다.
하지만 WebSocket은 한 번 연결을 맺으면 서버와 클라이언트가 계속 연결된 상태에서 양방향 통신을 할 수 있다.
즉, 실시간으로 메시지를 주고받을 수 있다.

✅ 2. STOMP란?

WebSocket은 기본적으로 메시지를 어떻게 주고받을지 정해진 구조가 없다.
그래서 STOMP(Simple Text Oriented Message Protocol) 라는 텍스트 기반의 메시징 프로토콜을 활용하여 메시지 전송의 규칙(어디에 보내고, 어디서 받을지)을 정한다.

/pub/... : 메시지를 보내는(Publish) 경로
/sub/... : 메시지를 받기 위해 구독(Subscribe)하는 경로

✅ 3. 메시지 브로드캐스트란?

브로드캐스트란 메시지를 여러 사용자에게 동시에 전달하는 동작을 말한다.

예를 들어, "1번 채팅방에 있는 사람 전부에게 이 메시지를 보여줘!"와 같이 한 방의 구독자 모두에게 메시지를 보내는 것이다.


실시간 채팅 흐름 단계별로 이해하기


(이미지 출처: http://xn--velog-h1u.io/@qkrtkdwns3410/Websocket-MessageMapping-%EC%9D%B4%EB%9E%80)

Step 1. 클라이언트가 채팅방에 입장

- 구독(Subscribe):
클라이언트가 특정 채팅방에 입장할 때, 예를 들어 Client2는 /sub/chat/room/1 경로를 구독한다.
이는 "1번 채팅방의 모든 메시지를 실시간으로 받고 싶다"는 의미이다.

client.subscribe("/sub/chat/room/1", callback)

Step 2. 메시지 전송

- 전송(Publish):
Client1이 채팅 메시지를 전송할 때, 메시지를 /pub/chat/message 경로로 보내게 된다.

client.send("/pub/chat/message", headers, body)

이때, 메시지의 내용은 JSON 형식과 같은 직렬화된 포맷으로 서버에 전달된다.

Step 3. 서버는 메시지를 가공 후 브로커에 전달

- 메시지 처리 및 브로드캐스트:
백엔드에서는 @MessageMapping("/chat/message")을 사용하여 해당 경로의 메시지를 받고, 내부 로직이나 필요한 가공 작업을 수행한 후, 메시지를 Message Broker를 통해 해당 채팅방을 구독 중인 클라이언트에게 브로드캐스트한다.

예를 들어, Spring Boot에서는 @SendTo("/sub/chat/room/{roomId}") 혹은 SimpMessagingTemplate.convertAndSend("/sub/chat/room/" + roomId, message) 를 사용한다.

Step 4. 모든 구독자에게 메시지 전송

1번 채팅방에 속한 모든 클라이언트(사용자)는 /sub/chat/room/1 경로로 전송되는 메시지를 실시간으로 받아볼 수 있다.


Step 5. 클라이언트와 서버 간 메시지 포맷, 구독 및 전송 방식

STOMP를 활용한 실시간 채팅 시스템은 아래와 같이 구체적인 메시지 포맷과 전송, 구독(pub/sub) 방식을 가진다.

{
  "roomId": "room1",
  "chatType": "group",
  "senderId": "user123",
  "message": "안녕하세요!",
  "attachment": null
}

위의 데이터는 서버에서 ChatMessageRequestDto 객체로 매핑되어 사용된다.

- 응답 메시지 (Receive):
서버는 처리 후, 저장된 메시지의 정보(메시지 ID, 전송 시간, 채팅방 ID 등)를 포함한 DTO를 클라이언트에게 반환한다. 응답 DTO도 JSON으로 직렬화되어 전송된다.

구독 및 전송 경로

- 구독 경로 (Subscribe):
클라이언트는 특정 채팅방의 메시지를 받기 위해 아래 경로를 구독한다.

/sub/chat/room/{roomId}

예를 들어, 1번 채팅방의 경우 /sub/chat/room/1을 구독하게 된다.

- 전송 경로 (Publish):
클라이언트는 메시지를 전송할 때, 아래 경로로 메시지를 보낸다.

/pub/chat/message

서버에서는 @MessageMapping("/chat/message")로 해당 경로의 메시지를 받고 처리한 후, 적절한 경로(/sub/chat/room/{roomId})로 전송한다.

클라이언트 코드

// 1. SockJS와 STOMP 라이브러리 사용
const socket = new SockJS('/ws-endpoint');
const stompClient = Stomp.over(socket);

// 2. STOMP 연결
stompClient.connect({}, function (frame) {
    console.log('Connected: ' + frame);

    // 3. 채팅방 구독
    stompClient.subscribe('/sub/chat/room/1', function (message) {
        console.log("Received: " + message.body);
    });

    // 4. 메시지 전송
    stompClient.send("/pub/chat/message", {}, JSON.stringify({
        roomId: 'room1',
        chatType: 'group',
        senderId: 'user123',
        message: '안녕하세요!',
        attachment: null
    }));
});

서버 코드

@Controller
@RequiredArgsConstructor
@Slf4j
public class WebSocketController {

    private final ChatMessageService chatMessageService;
    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/chat/sendMessage")
    public void sendMessage(ChatMessageRequestDto messageDto, Principal principal) {
        String senderId = principal != null && !"anonymousUser".equals(principal.getName()) 
                          ? principal.getName() 
                          : "defaultUser";

        messageDto.setSenderId(senderId);

        ChatMessage savedMessage = chatMessageService.saveChatMessage(
            messageDto.getRoomId(),
            messageDto.getChatType(),
            senderId,
            messageDto.getMessage(),
            messageDto.getAttachment()
        );

        ChatMessageResponseDto response = ChatMessageResponseDto.builder()
                .messageId(savedMessage.getId())
                .roomId(savedMessage.getRoomId())
                .senderId(savedMessage.getSenderId())
                .message(savedMessage.getMessage())
                .attachment(savedMessage.getAttachment())
                .timestamp(savedMessage.getCreatedDate() != null ? savedMessage.getCreatedDate() : LocalDateTime.now())
                .build();

        messagingTemplate.convertAndSend("/topic/chatroom/" + messageDto.getRoomId(), response);
    }
}

이렇게 하면 클라이언트가 1번 채팅방을 구독하면(pub) 1번 채팅방의 메시지가 실시간으로 전송(sub)된다.

0개의 댓글