안녕하세요, 스코리아입니다.
오늘은 STOMP의 개념 및 구조를 알아보고 스프링에서 STOMP를 활용하여 실시간 기능을 구현하는 방법에 대해 알아보겠습니다.
STOMP를 접하게 된 계기
저는 스프링 STOMP를 대학교 팀 프로젝트 과제를 통해 처음 알게 되었습니다.
대학교 2학년 과목 중 '네트워크 및 프로그래밍' 과목이 있는데요. 팀 프로젝트로 실시간 소켓을 이용한 서비스 하나를 만드는 과제가 있었는데, 저희 조는 웹소켓을 이용하여 실시간 플레이리스트를 만들 수 있는 웹사이트를 개발하였습니다.
과제의 목적이 '실시간 소켓 통신'이었기 때문에 제가 기존에 공부했던 스프링을 사용하되, DB의 활용 (JPA)은 줄이고, 웹소켓의 비중을 높이는 게 목표였습니다. 처음에는 기본적인 DB 통신은 스프링에서 처리하되 (API로), 웹소켓은 Node.js로 개발하여 분리하려고 하였으나 서비스의 복잡도가 매우 올라갈 것 같다는 판단을 하였기 때문에 과감히 포기했습니다. (다른 과목 팀플도 많아 시간이 없었기 때문에..)
그래서 찾아본 게 스프링의 웹소켓 기술이었고, 유저가 실시간 플레이리스트를 만들 때 방을 하나 파서 음악을 추가하는 형식으로 진행되어야 했기 때문에 PUB, SUB이 지원되면 좋겠다는 생각을 하였습니다. 즉, 방 ID를 SUBSCRIBE 하여 그 방에서 진행되는 이벤트만 실시간으로 받아볼 수 있게 말이죠.
결국 최종적으로 스프링에는 STOMP가 PUB,SUB 기능뿐만 아니라 다양한 메시지 형식(JSON)을 지원하고 JPA Repository, Service 계층 등을 자유롭게 호출할 수 있다는 점에서 매력적이라고 생각하여 선택하였습니다.
Github 주소 및 Demo 페이지 주소는 포스팅 하단에 남겨두겠습니다.
Websocket & STOMP 개념 및 차이점
Websocket과 STOMP 기술은 실시간 웹 애플리케이션을 구축하는 데 매우 유용합니다. 특히, 채팅 애플리케이션이나 실시간 알림 시스템을 만들 때 많이 사용됩니다. 그럼 하나씩 살펴보겠습니다.
- WebSocket은 클라이언트와 서버 간의 양방향 통신을 가능하게 하는 프로토콜입니다.
- STOMP는 Simple Text Oriented Messaging Protocol의 약자로, 텍스트 기반의 메시징 프로토콜입니다. (PUB/SUB 기반)
WebSocket은 HTTP 프로토콜을 통해 연결을 설정한 후, 지속적인 연결을 유지하면서 데이터를 주고받을 수 있습니다.
반면, STOMP는 이러한 WebSocket 연결 위에서 메시지를 전송하는 프로토콜로, 메시지의 형식과 라우팅을 정의합니다.
STOMP와 WebSocket의 가장 큰 차이점은 프로토콜의 목적입니다.
WebSocket은 연결을 유지하고 양방향 통신을 가능하게 하는 프로토콜인 반면, STOMP는 이러한 WebSocket 연결 위에서 메시지를 전송하기 위한 프로토콜입니다. 즉, STOMP는 PUB/SUB 구조로 작동하며 경로 지정이 가능하고 메시지의 형식, 유형, 내용 등을 명확하게 정의해 줄 수 있다는 장점이 있습니다.
스프링에서 STOMP를 사용하게 되면 @MessageMapping과 같은 어노테이션을 이용하여 메시지 발행 시 엔드포인트를 별도로 분리해서 관리할 수 있습니다.
STOMP 구조
STOMP는 메시지 기반의 통신을 위한 프로토콜로, 주로 다음과 같은 형식을 가집니다:
- CONNECT : 클라이언트가 서버에 연결 요청을 보냅니다.
- SEND : 메시지를 특정 목적지로 전송합니다.
- SUBSCRIBE : 특정 주제를 구독하여 메시지를 수신합니다.
- UNSUBSCRIBE : 구독을 해제합니다.
- DISCONNECT : 연결을 종료합니다.
메시지는 명령어(Command), 헤더(Header), 본문(Body)으로 구성됩니다. STOMP의 핵심 동작 원리와 메시지 흐름은 다음과 같습니다.
1. Topic 기반 메시징 (Publish-Subscribe, 1:N)
STOMP의 /topic 경로는 여러 클라이언트가 동일한 주제를 구독하고, 서버가 해당 주제의 메시지를 모든 구독자에게 Broadcasting 하는 데 사용됩니다. 이를 통해 다수의 수신자가 메시지를 동시에 받을 수 있습니다.
- 사용자 A가 스포츠 뉴스 주제 구독
SUBSCRIBE
destination: /topic/news/sports
id: sub-sports-01
- 사용자 B가 스포츠 관련 뉴스를 발행
SEND
destination: /pub/news
content-type: application/json
{"category": "sports", "headline": "Team X wins championship!"}
- STOMP 서버가 구독자들에게 메시지를 Broadcasting
MESSAGE
destination: /topic/news/sports
message-id: msg-001
subscription: sub-sports-01
{"category": "sports", "headline": "Team X wins championship!"}
서버는 메시지의 destination을 기준으로 구독 경로를 식별하고 메시지를 전달합니다. subscription 헤더는 메시지가 특정 클라이언트의 구독 요청과 연결되었음을 보장해 줍니다.
2. Queue 기반 메시징 (Point-to-Point, 1:1)
/queue 경로는 단일 클라이언트에게 메시지를 전달하는 방식으로 사용됩니다. 메시지는 대기열에 저장되고, 해당 대기열을 구독한 클라이언트가 메시지를 처리합니다.
- 사용자 A가 고객 지원 채팅 대기열을 구독
SUBSCRIBE
destination: /queue/support/agent-101
id: sub-agent-101
- 사용자 B가 고객 지원 요청을 보냄
SEND
destination: /queue/support/agent-101
content-type: application/json
{"customerId": 202, "message": "I need help with my account."}
하나의 클라이언트만 메시지를 수신하며, 다른 클라이언트는 동일한 메시지를 받지 않습니다.
스프링에서 STOMP WebSocket 사용하기
아래는 Spring에서 STOMP와 WebSocket을 사용하는 간단한 코드 예제입니다.
1. build.gradle에 의존성 추가
Gradle을 사용하여 Spring WebSocket과 STOMP 관련 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
2. Websocket Config 설정
Spring에서는 @EnableWebSocketMessageBroker 어노테이션을 사용하여 WebSocket 메시지 브로커를 활성화할 수 있습니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketHandshakeInterceptor handshakeInterceptor;
public WebSocketConfig(WebSocketHandshakeInterceptor handshakeInterceptor) {
this.handshakeInterceptor = handshakeInterceptor;
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue"); // 클라이언트가 구독할 주소
config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 메시지를 보낼 때 접두사
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/ws")
.setAllowedOriginPatterns("http://localhost:8080")
.addInterceptors(handshakeInterceptor)
.withSockJS(); // WebSocket 연결 엔드포인트
}
}
- enableSimpleBroker: 내장 메시지 Brocker 활성화.
- /topic → Publish-Subscribe 모델.
- /queue → Point-to-Point 모델.
- /ws: WebSocket 엔드포인트.
- withSockJS(): SockJS 폴백(fallback) 지원. WebSocket을 사용할 수 없는 경우 Long Polling 등 다른 방식을 사용.
다음은 WebsocketHandshakeInterceptor를 이용하여 SpringContext에서 Authentication을 가져오고 Websocket 세션에 저장하는 방식입니다. 이 예제에서는 간단한 사용을 위해서 이러한 과정으로 진행합니다.
@Component
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// 현재 SecurityContext에서 Authentication 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
attributes.put("authentication", authentication); // WebSocket 세션에 저장
}
return true; // 핸드셰이크 허용
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
// Do nothing
}
}
3. DTO 구현
웹소켓 메시지 성공 시에 데이터가 포함되는 WebsocketMessage DTO와 에러가 발생했을 때 에러 메시지가 담기는 WebsocketErrorMessage DTO를 만들어보겠습니다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage<T> {
private String type;
private T data;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketErrorMessage {
private String type; // 메시지 유형 (예: "ERROR")
private String message; // 에러 메시지
private String roomId; // 발생한 Room ID
}
4. Controller 구현
먼저 messageingTemplate은 특정한 Topic에 Publish를 해주는 convertAndSend 메소드가 존재합니다.
이를 생성자 주입을 통해 적어주겠습니다.
@Controller
public class WebSocketMusicController {
private final SimpMessagingTemplate messagingTemplate;
public WebSocketMusicController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/music/add")
public void addMusic(@Payload MusicAddDtoRequest request, StompHeaderAccessor accessor) {
handleWebSocketAction(request.getCode(), accessor, () -> {
checkIsLogined(accessor);
String memberUserId = getMemberUserId(accessor);
MusicDetailDtoResponse response = musicService.addMusic(memberUserId, request); // 서비스 계층 호출
WebSocketMessage<MusicDetailDtoResponse> webSocketMessage = new WebSocketMessage<>("ADD_MUSIC", response);
messagingTemplate.convertAndSend("/topic/room/" + request.getCode(), webSocketMessage);
});
}
private Object getAuthentication(StompHeaderAccessor accessor) {
Authentication authentication = (Authentication) accessor.getSessionAttributes().get("authentication");
return authentication.getPrincipal();
}
private SnapPrincipal getPrincipal(StompHeaderAccessor accessor) {
Object principal = getAuthentication(accessor);
if (principal instanceof SnapPrincipal) {
return ((SnapPrincipal) principal);
} else {
return null;
}
}
private String getMemberUserId(StompHeaderAccessor accessor) {
SnapPrincipal principal = getPrincipal(accessor);
if (principal != null) {
return principal.getUsername();
} else {
return null;
}
}
private void checkIsLogined(StompHeaderAccessor accessor){
if(getMemberUserId(accessor) == null){
throw new IllegalArgumentException("로그인 후 이용 가능합니다.");
}
}
private void handleWebSocketAction(String roomId, StompHeaderAccessor accessor, Runnable action) {
try {
action.run(); // 비즈니스 로직 실행
} catch (IllegalArgumentException e) {
WebSocketErrorMessage errorMessage = new WebSocketErrorMessage(
"ERROR",
e.getMessage(),
roomId
);
String memberUserId = getMemberUserId(accessor);
messagingTemplate.convertAndSend("/queue/errors-user-" + memberUserId, errorMessage);
}
}
}
- @MessageMapping: 클라이언트가 서버로 전송하는 메시지를 처리할 경로 지정. (예: 클라이언트는 /app/music/add로 메시지를 전송. 중요한 건 client에서 메시지 전송시에는 앞에 접두사 /app을 붙여야 합니다!)
addMusic 메소드를 자세히 확인해 보겠습니다.
우선 서비스 계층에서 발생하는 에러를 잡아서 웹소켓 에러 메시지로 전달하기 위해서 handleWebSocketAction 메소드를 먼저 수행하게 됩니다. try, catch 문을 이용하여 에러 발생 시에 WebSocketErrorMessage DTO에 에러메시지를 담아서 queue를 사용하여 특정 유저에게 메시지를 전달합니다. 이 예제에서는 간단하게 이렇게 사용했지만, 실제 프로덕션에서는 접속한 유저마다 다른 임의의 토큰이나 세션 코드를 넘기는 것이 좋습니다.
그러고 나서 WebsocketHandshakeInterceptor에서 세션에 Authentication 저장했다는 것을 불러와, 로그인이 되어있는 사용자인지 체크하고 유저 아이디를 가져오게 됩니다. (이 내용은 Spring Security와 관련된 내용이므로 설명을 생략하겠습니다)
이후 서비스 계층(DB 작업 등)을 호출하게 되고 여기에 반환되는 데이터를 WebSocketMessage DTO에 담아서 messageTemplate의 convertAndSend 메소드를 이용하여 특정한 Topic으로 Broadcast 하게 됩니다.
제가 작성한 이 예제는 어디까지나 '예제' 그 자체이며, 프로덕션 Level에서는 좀 더 리펙토링이 가능할 것이고 일부 기능이 개선될 수 있습니다.
5. Client - Javascript STOMP 예제
먼저 Script 파일을 추가합니다.
<script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>
아래는 Websocket 연결 및 Subscribe, Message Send 기능이 담긴 예제입니다.
주의하실 점은 Message Send시 경로에 접두사 '/app'을 꼭 붙이셔야 합니다.
<script>
var stompClient = null;
// WebSocket 연결 설정
function connect() {
var socket = new SockJS('/ws'); // '/ws'는 서버의 WebSocket 엔드포인트
stompClient = Stomp.over(socket);
// WebSocket 연결
stompClient.connect({}, function (frame) {
// 메시지 구독
stompClient.subscribe('/topic/room/1', function (message) {
const data = JSON.parse(message.body);
});
}, function (error) {
console.error('Connection error: ', error);
});
}
// WebSocket 연결 종료
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
}
console.log("Disconnected");
}
// 서버로 메시지 전송
function sendMessage() {
if (stompClient) {
// 서버의 메시지 핸들러 경로로 메시지 전송
stompClient.send("/app/music/add", {}, JSON.stringify({
code: "Test"
}));
}
}
connect();
</script>
마무리
스프링에서 STOMP 웹소켓을 처음 사용해 보았는데, 너무나도 편하다는 것을 느꼈습니다. 특히 Pub, Sub 지원 및 JSON Format 지원, 스프링 자바 코드와의 연동성이 너무 좋았습니다.
대규모 프로젝트라면 STOMP 사용을 고려해봐야 할 문제지만, 사이드 프로젝트나 대학교 팀플 과제에서 사용하기에는 매우 적합합니다. 코드가 직관적이고 세팅이 쉬워, 개발 속도가 매우 빠르기 때문이죠.
위에 'STOMP를 접하게 된 계기' 파트에서 소개드렸던 제 팀 프로젝트 과제 ('실시간 플레이리스트 함께 만들기')에 대한 코드 및 DEMO 페이지를 제공해 드리겠습니다.
Github 코드: https://github.com/skorea6/music-playlist
Demo 페이지: https://playlist.abz.kr
이상으로 STOMP 개념 및 구조, 스프링에서 실시간 서비스를 구현하는 방법에 대해서 소개드렸습니다.
읽어주셔서, 감사합니다.
'Spring Framework > 스프링' 카테고리의 다른 글
스프링 AWS SES 이메일 인증 시스템 구축 방법 (+Redis) (7) | 2024.09.03 |
---|---|
스프링 application.yml 파일 dev, prod, secret 환경 설정 분리방법 (1) | 2024.08.26 |
스프링 IP주소 Auditing 적용/구현 (Spring Data JPA) (32) | 2024.08.16 |
스프링 MVC 패턴 의미와 구조 [정리] (35) | 2024.08.09 |
스프링 AWS S3 이미지/파일 업로드 방법 (30) | 2024.07.30 |