Skip to main content
채팅창은 새로고침하지 않았는데도 새 메시지가 도착해요. 이때 서버는 어떻게 브라우저에게 먼저 말을 걸 수 있을까요?
지난 글에서는 Spring MVC와 WebFlux를 비교하면서 요청을 처리하는 thread와 I/O 대기 방식을 봤어요. 둘 다 결국 HTTP 요청을 받고 HTTP 응답을 돌려주는 웹 stack이었죠. 그런데 어떤 화면은 요청-응답만으로는 감각이 잘 안 맞아요.
“상대가 메시지를 보냈는데, 내 브라우저가 왜 바로 알죠?"
"주문 상태가 바뀌면 서버가 화면에 먼저 알려줄 수 있나요?"
"실시간 알림은 REST API를 1초마다 호출하면 되는 건가요?"
"WebSocket을 열면 controller도 똑같이 쓰나요?"
"STOMP는 WebSocket이랑 같은 말인가요?”
오늘은 이 질문을 볼 거예요. WebSocket은 한 번 연결한 뒤 양쪽이 계속 메시지를 주고받을 수 있게 해주는 전송 통로이고, STOMP는 그 통로 위에서 메시지의 목적지와 의미를 정해주는 약속이에요. Spring Boot는 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는 보통 클라이언트가 먼저 묻고, 서버가 한 번 답하는 흐름이에요.
이 방식은 단순하고 강해요. 목록 조회, 상세 조회, 생성, 수정, 삭제처럼 사용자가 행동한 뒤 결과를 받는 API에는 잘 맞아요. 하지만 “서버 쪽에서 언제 일이 생길지 모르는” 화면에서는 고민이 생겨요. 예를 들어 알림 화면이 있다고 해볼게요. 이 그림에서 중요한 건 HTTP 응답이 끝나면 그 대화도 끝난다는 점이에요. 새 알림이 생겼는지 알고 싶으면 브라우저가 다시 물어봐야 해요. 그래서 가장 쉬운 해결은 polling이에요. 1초마다, 5초마다, 30초마다 다시 요청하는 방식이죠. 그러니까 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를 넣어요.
그다음 STOMP message broker를 켜는 설정을 만들 수 있어요.
여기서 설정이 하는 일은 세 가지예요. withSockJS()는 “브라우저 코드에서 SockJS client를 쓰면, WebSocket이 어려운 환경에서 대체 전송 방식도 시도할 수 있게 하겠다”는 뜻이에요. 모든 앱에 반드시 필요한 것은 아니지만, 공개 인터넷 환경에서 오래 열린 연결이 proxy나 네트워크 정책 때문에 막히는 경우를 고려할 때 선택지가 될 수 있어요.
WebSocket은 연결을 오래 유지하므로 CORS, allowed origins, 인증, 권한, 연결 제한을 REST API보다 더 느슨하게 두면 위험해져요. 이 글은 메시지 흐름을 먼저 보는 글이라 보안 설정은 깊게 다루지 않지만, 실제 서비스에서는 Spring Security와 origin 정책을 반드시 같이 설계해야 해요.

메시지를 받는 쪽은 @MessageMapping으로 읽어요

이제 클라이언트가 /app/chat.send destination으로 메시지를 보낸다고 해볼게요. setApplicationDestinationPrefixes("/app")를 설정했으므로 Spring은 /app 뒤의 chat.send를 message handler 쪽에서 찾을 수 있어요.
이 코드를 HTTP controller처럼 읽으면 조금 이상해요. @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 하나를 추가하는 일이 아니라, 오래 열린 연결 위에서 메시지 주소, 구독자, 권한, 전달 경계를 설계하는 일이에요.

참고한 링크


자, 정리해볼까요?

  • 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, 재연결, 관측까지 함께 설계해야 해요.
다음 글에서는 외부 HTTP API를 호출하는 client 경계를 볼 거예요. 서버가 다른 서버에게 요청을 보낼 때 RestClient, WebClient, HTTP interface를 어떻게 고르고, timeout과 retry 같은 실패 경계를 어디에 둬야 하는지 살펴볼게요.