기존 웹 통신은 클라이언트가 요청(request)을 보내면 서버가 응답(response)을 하는 구조로 되어 있다.
하지만 WebSocket은 한 번 연결을 맺으면 서버와 클라이언트가 계속 연결된 상태에서 양방향 통신을 할 수 있다.
즉, 실시간으로 메시지를 주고받을 수 있다.
WebSocket은 기본적으로 메시지를 어떻게 주고받을지 정해진 구조가 없다.
그래서 STOMP(Simple Text Oriented Message Protocol) 라는 텍스트 기반의 메시징 프로토콜을 활용하여 메시지 전송의 규칙(어디에 보내고, 어디서 받을지)을 정한다.
/pub/... : 메시지를 보내는(Publish) 경로
/sub/... : 메시지를 받기 위해 구독(Subscribe)하는 경로
브로드캐스트란 메시지를 여러 사용자에게 동시에 전달하는 동작을 말한다.
예를 들어, "1번 채팅방에 있는 사람 전부에게 이 메시지를 보여줘!"와 같이 한 방의 구독자 모두에게 메시지를 보내는 것이다.

(이미지 출처: http://xn--velog-h1u.io/@qkrtkdwns3410/Websocket-MessageMapping-%EC%9D%B4%EB%9E%80)
- 구독(Subscribe):
클라이언트가 특정 채팅방에 입장할 때, 예를 들어 Client2는 /sub/chat/room/1 경로를 구독한다.
이는 "1번 채팅방의 모든 메시지를 실시간으로 받고 싶다"는 의미이다.
client.subscribe("/sub/chat/room/1", callback)
- 전송(Publish):
Client1이 채팅 메시지를 전송할 때, 메시지를 /pub/chat/message 경로로 보내게 된다.
client.send("/pub/chat/message", headers, body)
이때, 메시지의 내용은 JSON 형식과 같은 직렬화된 포맷으로 서버에 전달된다.
- 메시지 처리 및 브로드캐스트:
백엔드에서는 @MessageMapping("/chat/message")을 사용하여 해당 경로의 메시지를 받고, 내부 로직이나 필요한 가공 작업을 수행한 후, 메시지를 Message Broker를 통해 해당 채팅방을 구독 중인 클라이언트에게 브로드캐스트한다.
예를 들어, Spring Boot에서는 @SendTo("/sub/chat/room/{roomId}") 혹은 SimpMessagingTemplate.convertAndSend("/sub/chat/room/" + roomId, message) 를 사용한다.
1번 채팅방에 속한 모든 클라이언트(사용자)는 /sub/chat/room/1 경로로 전송되는 메시지를 실시간으로 받아볼 수 있다.
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)된다.