채팅창은 새로고침하지 않았는데도 새 메시지가 도착해요. 이때 서버는 어떻게 브라우저에게 먼저 말을 걸 수 있을까요?지난 글에서는 Spring MVC와 WebFlux를 비교하면서 요청을 처리하는 thread와 I/O 대기 방식을 봤어요. 둘 다 결국 HTTP 요청을 받고 HTTP 응답을 돌려주는 웹 stack이었죠. 그런데 어떤 화면은 요청-응답만으로는 감각이 잘 안 맞아요.
“상대가 메시지를 보냈는데, 내 브라우저가 왜 바로 알죠?"오늘은 이 질문을 볼 거예요. WebSocket은 한 번 연결한 뒤 양쪽이 계속 메시지를 주고받을 수 있게 해주는 전송 통로이고, STOMP는 그 통로 위에서 메시지의 목적지와 의미를 정해주는 약속이에요. Spring Boot는
"주문 상태가 바뀌면 서버가 화면에 먼저 알려줄 수 있나요?"
"실시간 알림은 REST API를 1초마다 호출하면 되는 건가요?"
"WebSocket을 열면 controller도 똑같이 쓰나요?"
"STOMP는 WebSocket이랑 같은 말인가요?”
spring-boot-starter-websocket으로 Spring Framework의 WebSocket과 STOMP 지원을 쉽게 쓸 수 있게 해줘요.
이 글은 Spring Boot 4.1.0과 Spring Framework 7.0.x 공식 문서의 WebSocket, STOMP, SockJS fallback 설명을 기준으로 작성했어요. 예제는 Spring MVC 기반 앱에서
spring-boot-starter-websocket을 쓰는 흐름으로 읽어주세요.REST API는 “물어보면 답하는” 흐름이에요
REST API는 보통 클라이언트가 먼저 묻고, 서버가 한 번 답하는 흐름이에요.
그러니까 WebSocket은 “실시간이면 무조건 써야 하는 기술”이 아니에요. 변화가 드물고 몇 초 늦어도 괜찮다면 polling이 더 단순할 수 있어요. 반대로 낮은 지연, 잦은 메시지, 양방향 상호작용이 중요하면 WebSocket을 검토할 이유가 생겨요.
WebSocket은 HTTP와 다른 대화 방식이에요
WebSocket 연결은 처음에는 HTTP 요청으로 시작해요. 브라우저가 “이 연결을 WebSocket으로 바꿔도 되나요?”라고 묻고, 서버가 받아들이면 같은 연결 위에서 양쪽이 계속 메시지를 주고받아요. 여기서 HTTP 요청-응답과 감각이 갈라져요. REST API는 보통GET /orders/1, POST /orders처럼 요청마다 URL과 method가 의미를 가져요. WebSocket은 연결을 열고 나면 보통 하나의 연결 위로 여러 메시지가 지나가요.
처음에는 이 정도만 잡아도 충분해요. WebSocket은 REST API의 더 빠른 버전이 아니라, 연결을 오래 열어두고 메시지를 주고받는 다른 통신 방식이에요.
그런데 WebSocket만으로는 메시지 의미가 부족해요
여기서 한 가지 함정이 있어요.
“WebSocket을 열었으니 이제 /chat/send 같은 URL로 메시지를 보내면 되나요?”
사실은 아니에요. WebSocket 자체는 메시지의 내용이 무엇인지 정하지 않아요. 텍스트나 바이너리 메시지를 보낼 수 있는 통로를 줄 뿐이에요.
예를 들어 브라우저가 이런 문자열을 보냈다고 해볼게요.
- 이 메시지는 채팅방에 보내는 건가요?
roomId가 없으면 에러인가요?- 어느 구독자에게 다시 보내야 하나요?
- 개인 메시지와 전체 메시지는 어떻게 구분하나요?
- 연결이 끊겼다가 다시 붙으면 구독 상태는 어떻게 되나요?
WebSocketHandler를 구현해서 들어온 문자열을 읽고, JSON으로 파싱하고, 어떤 session에 다시 보낼지 직접 정할 수 있죠.
하지만 채팅, 알림, 주식 가격, 협업 편집처럼 메시지 종류와 구독자가 늘어나면 직접 약속을 만들기 시작한 순간부터 새로운 미니 프로토콜을 설계하게 돼요.
여기서 STOMP가 등장해요.
STOMP는 WebSocket 위의 메시지 약속이에요
STOMP(Simple Text Oriented Messaging Protocol)는 메시지를 어디로 보내고, 어디를 구독하고, 어떤 frame인지 표현하는 간단한 메시징 프로토콜이에요. WebSocket과 같은 말이 아니라, WebSocket 위에서 쓸 수 있는 상위 약속이라고 보면 돼요. STOMP에서는 이런 단어들이 중요해져요.
Spring에서 STOMP를 쓰면 흐름이 이렇게 나뉘어요.
이 그림에서
/ws는 처음 WebSocket을 연결하는 endpoint예요. 반면 /app/chat.send나 /topic/rooms.spring은 HTTP URL이 아니라 STOMP destination이에요. 이 둘을 섞어 읽으면 많이 헷갈려요.
@MessageMapping은 HTTP controller의 @PostMapping과 닮아 보이지만 같은 것은 아니에요. HTTP request path를 매핑하는 게 아니라, STOMP destination으로 들어온 message를 처리하는 handler를 찾는 거예요.Spring Boot에서는 starter와 설정 class에서 시작해요
MVC 기반 Spring Boot 앱에서는 먼저 WebSocket starter를 넣어요.withSockJS()는 “브라우저 코드에서 SockJS client를 쓰면, WebSocket이 어려운 환경에서 대체 전송 방식도 시도할 수 있게 하겠다”는 뜻이에요. 모든 앱에 반드시 필요한 것은 아니지만, 공개 인터넷 환경에서 오래 열린 연결이 proxy나 네트워크 정책 때문에 막히는 경우를 고려할 때 선택지가 될 수 있어요.
메시지를 받는 쪽은 @MessageMapping으로 읽어요
이제 클라이언트가 /app/chat.send destination으로 메시지를 보낸다고 해볼게요. setApplicationDestinationPrefixes("/app")를 설정했으므로 Spring은 /app 뒤의 chat.send를 message handler 쪽에서 찾을 수 있어요.
@Controller인데 @ResponseBody가 없고, @MessageMapping은 path가 아니고, @SendTo는 HTTP response status도 아니죠.
Spring STOMP 흐름으로 다시 읽으면 이래요.
핵심은 controller가 직접 HTTP 응답을 돌려주는 게 아니라는 점이에요. message handler가 메시지를 처리하고, 그 결과가 broker destination으로 보내지고, 그 destination을 구독한 클라이언트들이 메시지를 받아요.
/topic, /queue, /user는 URL이 아니라 메시지 주소예요
처음 STOMP를 보면 destination prefix가 URL처럼 보여서 헷갈려요.
예를 들어 모든 사용자가 보는 공지라면
/topic/announcements가 자연스러워요. 특정 사용자에게만 주문 상태를 알려주고 싶다면 /user/queue/order-updates 같은 형태를 검토할 수 있어요.
사용자 destination은 겉으로는 같은 주소를 구독하는 것처럼 보이지만, Spring이 실제 session별 destination으로 바꿔줘요. 덕분에 클라이언트는 일반적인 이름을 구독하면서도 다른 사용자의 메시지와 섞이지 않게 받을 수 있어요.
/user/queue/order-updates를 구독하는 식으로 읽어요. 실제 destination 변환은 Spring의 user destination 처리 흐름이 맡아요.
simple broker와 외부 broker는 목적이 달라요
위 설정에서는enableSimpleBroker("/topic", "/queue")를 썼어요. 이름 그대로 Spring 애플리케이션 안의 간단한 broker예요. 예제, 작은 서비스, 단일 인스턴스에서 메시지 구독과 전달을 이해하기에는 좋죠.
하지만 운영 규모가 커지면 질문이 바뀌어요.
“서버 인스턴스가 3대면 어느 서버에 연결된 사용자가 메시지를 받나요?"이런 경우에는 외부 message broker를 relay로 붙이는 선택을 검토해요. Spring STOMP 설정에서는
"메시지를 더 안정적으로 쌓거나 라우팅해야 하나요?"
"서버가 재시작되면 구독과 메시지는 어떻게 되나요?"
"채팅과 알림이 서비스 경계를 넘어가나요?”
enableStompBrokerRelay(...)를 통해 RabbitMQ나 ActiveMQ 같은 STOMP broker로 메시지를 전달하는 구조를 만들 수 있어요.
처음부터 외부 broker를 붙여야 한다는 뜻은 아니에요. 다만 WebSocket/STOMP 기능이 “화면 편의 기능”을 넘어 서비스의 핵심 경로가 되면, 연결 수와 메시지 전달 경계를 별도로 설계해야 해요.
WebSocket 기능은 API보다 운영 질문이 더 빨리 따라와요
REST API는 요청이 오고 응답이 나가면 일단 연결이 끝나요. 반면 WebSocket은 연결 자체가 자원이에요. 사용자가 많아질수록 열려 있는 연결 수, 메시지 빈도, heartbeat, 재연결, 인증 만료 같은 문제가 빨리 드러나요. 실무에서는 이런 질문을 미리 적어두는 편이 좋아요.
Spring의 STOMP 지원은 이런 문제를 모두 자동으로 “정답 처리”해주는 마법이 아니에요. 대신 message channel, broker, controller, user destination, event 같은 경계를 제공해요. 개발자는 그 경계 위에서 기능 요구와 운영 요구를 맞춰야 해요.
이 그림의 순서가 중요해요. WebSocket 코드를 먼저 붙이기 전에 연결 정책과 메시지 주소를 같이 정해야 나중에 “누가 어떤 메시지를 받아야 하는지”가 흔들리지 않아요.
처음에는 여기까지만 잡아도 충분해요
WebSocket과 STOMP는 단어가 같이 나오지만 같은 층의 개념이 아니에요.
조금 더 깊게 보면 이런 원칙이 남아요.
WebSocket 기능은 controller 하나를 추가하는 일이 아니라, 오래 열린 연결 위에서 메시지 주소, 구독자, 권한, 전달 경계를 설계하는 일이에요.
참고한 링크
- Spring Boot Reference - WebSockets
- Spring Boot Reference - Messaging
- Spring Framework Reference - WebSockets
- Spring Framework Reference - STOMP
- Spring Framework Reference - Enable STOMP
- Spring Framework Reference - Flow of Messages
- Spring Framework Reference - User Destinations
- Spring Framework Reference - SockJS Fallback
자, 정리해볼까요?
- REST API는 클라이언트가 물어보고 서버가 답하는 흐름에 잘 맞아요.
- WebSocket은 처음 handshake 뒤 연결을 열어두고 클라이언트와 서버가 양방향으로 메시지를 주고받게 해줘요.
- WebSocket 자체는 메시지 의미를 정하지 않으므로, Spring에서는 STOMP로 destination, 구독, 전송 흐름을 표현할 수 있어요.
/ws는 연결 endpoint이고,/app,/topic,/queue,/user는 STOMP 메시지 destination으로 읽어야 해요.@MessageMapping은 HTTP 요청 path가 아니라 application destination으로 들어온 message를 처리해요.- simple broker는 시작하기 쉽지만, 다중 인스턴스와 운영 규모에서는 external broker relay, 인증, origin, 재연결, 관측까지 함께 설계해야 해요.
RestClient, WebClient, HTTP interface를 어떻게 고르고, timeout과 retry 같은 실패 경계를 어디에 둬야 하는지 살펴볼게요.