우리가 매일 사용하는 카카오톡이나 주식 거래 앱의 호가창은 어떻게 실시간으로 데이터가 번쩍번쩍 업데이트될까요? 초보 개발자들은 프론트엔드에서 setInterval() 함수를 써서 1초마다 백엔드에 "새로운 채팅 온 거 있어?"라고 묻는 폴링(Polling) 방식을 떠올립니다. 하지만 1,000명의 유저가 1초마다 빈 요청(HTTP)을 보내면 서버는 채팅을 하기도 전에 트래픽 폭탄을 맞고 터져버릴 것입니다. 웹의 근간인 HTTP는 본래 '단방향(클라이언트가 물어보면 서버가 대답하고 끊음)' 통신이기 때문입니다.
이러한 HTTP의 단방향 통신 한계를 극복하고, "클라이언트와 서버가 한 번 손을 잡으면(Handshake) 연결을 끊지 않고, 서버가 원할 때 언제든 클라이언트에게 데이터를 쏴줄 수 있는 고속도로"를 뚫어주는 혁명적인 프로토콜이 바로 WebSocket(웹소켓)입니다.
이번 포스트에서는 "현대 실시간 서비스의 심장!" Spring Boot WebSocket의 기초 원리와 순수 웹소켓을 활용한 실시간 채팅 서버 구현에 대하여 다뤄보겠습니다.
1. 웹소켓 핸드셰이크(Handshake): HTTP의 화려한 변신
웹소켓은 완전히 새로운 프로토콜(ws://)이지만, 방화벽(80, 443 포트)을 무사히 통과하기 위해 영리한 꼼수를 씁니다. 처음 만날 때는 평범한 HTTP 요청인 척하며 서버에 접근합니다. 헤더에 Connection: Upgrade와 Upgrade: websocket이라는 비밀 암호를 적어서 보내면, 서버가 "오케이, 지금부터 HTTP 버리고 웹소켓 고속도로로 갈아타자!" (101 Switching Protocols)라고 응답하며 둘 사이의 영구적인 파이프가 뚫리게 됩니다. 이 과정을 핸드셰이크라고 부릅니다.
2. Spring Boot 순수 WebSocket 세팅하기
스프링 부트에서 웹소켓을 여는 것은 너무나도 쉽습니다. spring-boot-starter-websocket 의존성을 추가하고, 문지기(Handler)와 주소(Endpoint)를 설정해주면 됩니다.
// 1. WebSocket Handler: 실제 메시지가 도착했을 때 처리하는 로직
@Component
@Slf4j
public class ChatWebSocketHandler extends TextWebSocketHandler {
// 접속한 클라이언트(브라우저)들의 세션을 모아두는 리스트 (단톡방 역할)
private static final List<WebSocketSession> sessions = new ArrayList<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session); // 누군가 접속하면 리스트에 추가
log.info("새로운 클라이언트 접속! 세션 ID: {}", session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload(); // 클라이언트가 보낸 텍스트 메시지
log.info("수신된 메시지: {}", payload);
// 브로드캐스팅(Broadcasting): 리스트에 있는 '모든' 사람에게 메시지를 똑같이 뿌려줍니다!
for (WebSocketSession s : sessions) {
s.sendMessage(new TextMessage("누군가의 말: " + payload));
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session); // 접속을 끊으면 리스트에서 제거
log.info("클라이언트 접속 해제: {}", session.getId());
}
}
// 2. WebSocket Config: "/ws/chat" 주소로 들어오면 위 핸들러로 연결해 주는 설정
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatWebSocketHandler chatWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatWebSocketHandler, "/ws/chat").setAllowedOrigins("*");
}
}
3. 순수 WebSocket의 치명적인 한계
와! 이제 채팅이 잘 됩니다. 그런데 방금 짠 코드를 실무에 배포하면 어떤 일이 벌어질까요?
- "A번 방에만 메시지 보내줘"가 안 됩니다: 위 코드는 그냥
List에 단순 리스트에 모두 담아 관리했기 때문에, 카카오톡처럼 단톡방을 1,000개로 쪼개려면 개발자가 직접 Map 자료구조로Map<String, List<Session>>처럼 방 관리 로직을 하드코딩해야 합니다. - 메시지 규격(Format)이 없습니다: 그냥 단순한 String(문자열)만 날아다닙니다. "이게 입장 메시지인지, 채팅인지, 퇴장인지" 구분하려면 JSON을 뜯어서
if(type.equals("ENTER"))같은 노가다를 해야 합니다. - 서버가 2대면 채팅이 안 됩니다 (가장 치명적): 만약 서버를 2대로 늘렸을 때, 철수는 1번 서버에 접속하고 영희는 2번 서버에 접속했다면? 1번 서버의
sessions리스트에는 철수만 있으므로 철수가 아무리 떠들어도 영희에게 전달되지 않습니다!
4. 마무리 및 주요 발전 방향
지금까지 HTTP의 무거운 족쇄를 끊어내고 서버와 클라이언트 간의 실시간 양방향 고속도로를 개통하는 순수 WebSocket의 원리와 구현, 그리고 그 한계점에 대해 상세히 다루어 보았습니다. 웹소켓은 고속도로(전송 계층)일 뿐, 그 위로 어떤 자동차(메시지)가 다녀야 하는지 규칙은 없습니다.
방금 본 치명적인 문제점들을 해결하려면 어떻게 해야 할까요? 우편 배달부처럼 명확한 '봉투(규격)'와 '수신처(목적지)'를 가진 메시징 프로토콜을 웹소켓 위에 얹어야 합니다. 나아가 10대의 서버가 켜져 있어도 1번 서버의 채팅이 10번 서버까지 날아가게 해주는 거대한 '메시지 브로커(Message Broker)'가 필요합니다. 본 설명에서는 "실무형 엔터프라이즈 채팅 아키텍처의 완성!" STOMP 프로토콜의 웅장한 라우팅 마법과 외부 브로커인 RabbitMQ 연동 실무 가이드에 대해 아주 심도 있게 살펴보겠습니다.
앞선 기술 개요에서 우리는 순수 WebSocket을 사용해 양방향 통신을 뚫어보았지만, "단톡방 구분이 안 된다", "메시지 포맷이 중구난방이다", "서버가 여러 대면 통신이 끊긴다"는 치명적인 세 가지 장벽에 부딪혔습니다. 실무 카카오톡이나 디스코드 같은 대규모 실시간 서비스는 이 문제를 어떻게 해결할까요? 바로 웹소켓이라는 텅 빈 고속도로 위에, STOMP(Simple Text Oriented Messaging Protocol)라는 엄격한 교통 규칙(신호등과 차선)을 세우고, 여러 서버 간의 메시지 배달을 책임지는 RabbitMQ(메시지 브로커)라는 거대한 우체국을 도입하는 것입니다.
본 글에서는 "실무 실시간 서비스 아키텍처의 절대 표준!" STOMP 프로토콜의 Pub/Sub 패턴과 외부 메시지 브로커(RabbitMQ) 연동을 통한 분산 채팅 시스템 구축에 대하여 다뤄보겠습니다.
1. STOMP의 마법: Pub/Sub (발행/구독) 패턴
STOMP는 우편 배달 프로토콜입니다. 단순한 텍스트 덩어리가 아니라, COMMAND, header, body를 갖춘 완벽한 편지 봉투 규격을 제공합니다. STOMP를 도입하면 우리는 더 이상 List<Session>을 만들어서 for문으로 메시지를 뿌리는 노가다를 할 필요가 없습니다. 모든 것은 구독(Subscribe)과 발행(Publish)이라는 우아한 개념으로 바뀝니다.
사용자 A가 '1번 방'에 들어오면 서버에 SUBSCRIBE /sub/chat/room/1 이라는 명령을 보냅니다. 이제 사용자 B가 PUBLISH /pub/chat/message (body: 안녕!)을 보내면, 스프링 서버(내장 브로커)가 알아서 1번 방을 구독(Subscribe) 중인 모든 사람을 찾아내어 안녕!이라는 메시지를 뿌려줍니다. 스프링 부트에서는 이를 @MessageMapping 어노테이션 하나로 놀랍도록 쉽게 처리합니다.
2. STOMP 서버 세팅 및 컨트롤러 구현
@Configuration
@EnableWebSocketMessageBroker // STOMP 메시지 브로커 활성화
public class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 프론트엔드가 최초로 웹소켓 Handshake를 맺을 주소 (ws://localhost:8080/ws-stomp)
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 1. 서버가 클라이언트에게 메시지를 뿌릴 때(구독) 앞에 붙일 Prefix
registry.enableSimpleBroker("/sub");
// 2. 클라이언트가 서버로 메시지를 보낼 때(발행) 앞에 붙일 Prefix
registry.setApplicationDestinationPrefixes("/pub");
}
}
@RestController
@RequiredArgsConstructor
public class ChatController {
private final SimpMessageSendingOperations messagingTemplate; // 메시지를 원하는 방에 뿌려주는 핵심 마법 지팡이
// 클라이언트가 "/pub/chat/message" 로 메시지를 날리면 이곳으로 들어옵니다!
@MessageMapping("/chat/message")
public void sendMessage(ChatMessage message) {
// 비즈니스 로직 처리 (DB 저장 등...)
// "/sub/chat/room/{roomId}" 를 구독 중인 모든 클라이언트에게 메시지 발사! (브로드캐스팅)
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
}
3. 인메모리 브로커의 한계와 RabbitMQ 도입
위 코드에서 사용한 enableSimpleBroker는 스프링 부트 메모리(RAM) 내부에서 돌아가는 장난감(?) 브로커입니다. 서버를 2대, 3대로 늘리면(Scale-out), 1번 서버의 내장 브로커와 2번 서버의 내장 브로커는 서로 단절됩니다. 1번 서버의 철수가 백날 떠들어도 2번 서버의 영희는 들을 수 없죠.
이 장벽을 허물기 위해, 1번 서버와 2번 서버 외부에 거대한 공용 우체국(External Message Broker)인 RabbitMQ를 띄웁니다. 스프링 부트의 설정을 enableStompBrokerRelay()로 딱 한 줄 바꾸면, 이제 1번 서버가 메시지를 받으면 무조건 RabbitMQ에게 던집니다. 그럼 RabbitMQ가 1번 서버와 2번 서버 양쪽 모두에 메시지를 쫙 뿌려주어, 영희의 화면에도 철수의 메시지가 실시간으로 나타나게 됩니다. 이것이 초당 수만 개의 메시지를 감당하는 실무 스케일 아웃 채팅 아키텍처의 정석입니다.
4. 마무리 및 주요 발전 방향
지금까지 웹소켓의 빈 껍데기를 채워주는 STOMP 프로토콜의 우아한 Pub/Sub 메시징 규격과, 여러 대의 서버 환경에서도 메시지 유실 없이 100% 통신을 보장해 주는 외부 브로커 RabbitMQ의 웅장한 분산 연동 아키텍처에 대해 상세히 다루어 보았습니다. 이제 여러분은 천만 명이 접속하는 채팅 시스템도 설계할 수 있습니다.
실시간 서비스는 완벽합니다. 그런데 어느 날, 회사 재무팀에서 "매월 1일 새벽 4시마다 우리 회원의 지난달 결제 내역 1,000만 건을 전부 뒤져서 VIP 등급을 업데이트하고 쿠폰을 발급해 줘"라는 무시무시한 요구사항을 던집니다. 1,000만 건을 일반 for문으로 돌리며 @Transactional을 걸었다간 3초 만에 메모리가 터져(OOM) 뻗어버릴 것입니다. 이런 무식하게 거대한 '대용량 데이터 일괄 처리'를 위해 백엔드 진영은 어떻게 대처할까요?
다음 설명에서는 "수천만 건의 데이터를 쪼개고, 씹고, 뱉어내는 백그라운드의 지배자!" 엔터프라이즈 환경의 필수품, Spring Batch(스프링 배치)의 아키텍처와 기본 원리에 대해 아주 심도 있게 살펴보겠습니다.
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] Spring WebFlux 한계 돌파 - 비동기 논블로킹(Non Blocking) (0) | 2026.10.16 |
|---|---|
| [Spring Boot] 힙 덤프(Heap Dump) 분석 및 메모리 누수 (0) | 2026.10.16 |
| [Spring Boot] 무중단 배포 전략(Blue-Green) (0) | 2026.10.15 |
| [Spring Boot] Apache Kafka 연동 MSA의 이벤트 주도 비동기 통신 (0) | 2026.10.14 |
| [Spring Boot] 파일 업로드와 AWS S3 클라우드 사용하기 (0) | 2026.10.13 |