STOMP 1:1 메시지 라우팅 — HTTP 인증과 분리된 WebSocket 컨텍스트에서 userId 기반 Principal 통합

오리구이·2026년 5월 1일
post-thumbnail

또마니또(ttomanito)는 마니또(주는 사람) ↔ 마니띠(받는 사람)가 1:1로 매칭되어 익명 채팅으로만 서로를 도와주는 모바일 웹 게임입니다. 채팅 메시지가 두 사람 사이에서만 오가야 하는 구조이기 때문에, STOMP 라우팅이 사용자를 잘못 식별하면 게임성 자체가 무너집니다. HTTP 인증(SecurityContext, ThreadLocal)과 WebSocket 채널(비동기)의 컨텍스트 분리에서 출발해, 2단계 인터셉터 + 커스텀 Principal(JwtUserDetailsUserDetailsPrincipal을 동시에 구현)Principal.getName()을 userId로 통일한 과정을 정리합니다.


STOMP란?

Spring Boot에서 WebSocket을 구현할 때 자주 등장하는 STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 서브프로토콜입니다. WebSocket 자체는 단순한 양방향 소켓 연결만 제공하지만, STOMP는 그 위에 구독(Subscribe) / 발행(Publish) 구조와 목적지(destination) 기반 라우팅을 더합니다.

프레임방향역할
CONNECT클라이언트 → 서버WebSocket 연결 후 STOMP 세션을 초기화
SUBSCRIBE클라이언트 → 서버특정 destination 구독. 예: /user/queue/chat.5
SEND클라이언트 → 서버메시지 발행. 예: /app/chat.send.5
MESSAGE서버 → 클라이언트서버가 구독 채널로 메시지를 전달

Spring Boot에서는 @MessageMapping으로 SEND를 처리하고, SimpMessagingTemplate.convertAndSendToUser(userId, dest, payload)로 특정 사용자에게만 메시지를 보낼 수 있습니다. 이 API가 사용자를 찾는 기준이 바로 Principal.getName()입니다. 이 글의 핵심 문제는 WebSocket 채널에서 이 Principal을 어떻게 일관되게 주입하느냐입니다.


1. 들어가며

또마니또는 게임이 시작되면 참가자 간에 1:1 익명 채팅방이 자동 생성됩니다. 메시지는 두 사람 사이에서만 오가야 하고, 다른 방으로 새면 게임성이 무너집니다.

익명 채팅방 — 마니또/마니띠 양방향

채팅 도메인을 구현하면서 두 가지를 동시에 풀어야 했습니다.

  1. 메시지 저장 — 채팅 메시지를 어디에 저장할 것인가 (MongoDB vs MySQL)
  2. 인증·라우팅 — STOMP 채널이 사용자를 어떻게 식별해서 1:1 메시지를 정확히 전달할 것인가

본문은 다음 흐름으로 정리합니다.

  1. 채팅 도메인 구조 — STOMP + MongoDB + MySQL의 역할 분담
  2. 왜 메시지 저장소는 MongoDB인가
  3. 문제 — WebSocket 인증의 비동기 컨텍스트
  4. 결정적 해결 — 2단계 인터셉터 + 커스텀 Principal (userId 기반)
  5. 1:1 메시지 라우팅 검증

2. 채팅 도메인 구조 — STOMP + MongoDB + MySQL

채팅 도메인의 책임을 다음과 같이 세 축으로 나눴습니다.

영역저장소역할
채팅방 메타MySQL (chatting_room 테이블)채팅방 ID · 매칭(ManitoAssignment) FK · 마지막 메시지 미리보기·시각
채팅 메시지MongoDB (chat_message 컬렉션)메시지 본문 · 발신자 · 시각 · 타입
실시간 전송STOMP / WebSocketCONNECT / SEND / SUBSCRIBE — 인메모리 브로커
// ChatMessage.java
@Document(collection = "chat_message")        // ★ MongoDB
public class ChatMessage {
    private Long senderUserId;
    private Long senderRoomUserId;
    private Long chattingRoomId;
    private MessageType type;
    private String content;
    private LocalDateTime sentAt;
}
// ChattingRoom.java
@Entity
@Table(name = "chatting_room")                // ★ MySQL
public class ChattingRoom {
    @ManyToOne private ManitoAssignment manitoAssignment;
    private String lastMessage;
    private LocalDateTime lastMessageTime;
}

같은 "채팅" 단어로 묶이지만, 메타데이터(ChattingRoom)메시지(ChatMessage)는 요구가 완전히 다릅니다. 메타는 강한 일관성과 외래 키가 필요하고, 메시지는 빠른 append와 시간 역순 페이지네이션이 필요합니다. 그래서 저장소도 분리했습니다.

WebSocket 측 설정의 핵심은 다음과 같습니다.

// WebSocketConfig.java (요약)
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/queue", "/topic/chat", "/topic/waiting");
    config.setApplicationDestinationPrefixes("/app");
    config.setUserDestinationPrefix("/user");           // ★ 1:1 메시지 라우팅 prefix
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws/chat")
            .addInterceptors(jwtHandshakeInterceptor)    // HTTP 업그레이드 단계
            .setAllowedOriginPatterns("...");
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(wsAuthChannelInterceptor); // STOMP CONNECT 단계
}

여기서 config.setUserDestinationPrefix("/user") 한 줄이 본 트러블슈팅의 출발점입니다. 이 설정이 켜져 있으면 서버에서 convertAndSendToUser(userId, "/queue/chat.{roomId}", payload)를 호출했을 때 STOMP 프레임워크가 자동으로 /user/{userId}/queue/chat.{roomId} 패턴으로 변환해 그 사용자의 세션에만 전달합니다.

문제는 "그 사용자가 누구인가"를 STOMP가 어떻게 결정하느냐 였습니다.


3. 왜 메시지 저장소는 MongoDB인가

이 글의 메인 주제는 인증이지만, MongoDB 선택 이유를 짚고 가야 인증·라우팅 설계와의 결합이 자연스러워집니다. MySQL chat_message 테이블 단일 구조도 검토했지만, 다음 이유로 메시지만 MongoDB로 분리했습니다.

항목MySQL JPA 단일MongoDB 분리
쓰기 패턴INSERT 폭주 시 인덱스 페이지 분할 비용append-only 컬렉션, 자연스러운 시계열
읽기 패턴시간 역순 페이지네이션 시 sort + index 재정렬_id ObjectId가 시간 정보를 포함, sort 거의 0 비용
스키마 유연성메시지 타입(텍스트/이미지/시스템)마다 NULL 컬럼 누적문서별 다른 필드 자연스럽게
JPA 캐스케이드 부담ChattingRoomChatMessage 양방향 관리완전 분리, 채팅방 삭제 시 컬렉션만 별도 삭제
인증·라우팅 결합DB 트랜잭션 안에서 사용자 식별저장과 라우팅이 분리 — 인증 책임이 STOMP 채널로 명확히 떨어짐

마지막 항목이 본 트러블슈팅과 직결됩니다. 메시지를 MongoDB로 분리해두면 STOMP 채널의 책임이 "메시지를 받아 저장하고, 그 결과를 1:1로 다시 라우팅하는" 두 단계로 명확하게 정리됩니다. JPA 트랜잭션 경계와 WebSocket 비동기 처리가 같은 함수 안에 섞이는 위험이 사라지기 때문에, 인증·Principal 설계가 "누구에게 보낼 것인가" 한 가지에만 집중할 수 있습니다.

Mongo Express — ttomanito_chat.chat_message 컬렉션 (senderUserId·chattingRoomId·content 확인)


4. 문제 — WebSocket 인증의 비동기 컨텍스트

HTTP 요청은 다음 흐름으로 사용자를 식별합니다.

HTTP Request
   ↓ Servlet Filter (JwtAuthenticationFilter)
   ↓ JWT 파싱 → Authentication 객체
   ↓ SecurityContextHolder.getContext().setAuthentication(auth)   ★ ThreadLocal 저장
   ↓ Controller @AuthenticationPrincipal 주입

핵심은 SecurityContextHolder가 내부적으로 ThreadLocal이라는 점입니다. 한 HTTP 요청 = 한 스레드라는 가정 위에서만 동작합니다.

WebSocket은 이 가정이 깨집니다.

단계HTTP 인증WebSocket
1. 요청 도착Servlet Filter ChainHTTP 핸드셰이크 (1회성)
2. 토큰 검증JwtAuthenticationFilterJwtHandshakeInterceptor
3. 인증 저장SecurityContext (ThreadLocal)세션 attributes (Map)
4. 메시지 처리동기 — 같은 스레드비동기 — 다른 스레드
5. 사용자 식별SecurityContextHolder.getContext().getAuthentication()StompHeaderAccessor.getUser()

핸드셰이크가 끝나고 STOMP CONNECT / SEND / SUBSCRIBE가 들어올 때마다, 처리하는 스레드는 핸드셰이크 시점과 다른 스레드입니다. ThreadLocal인 SecurityContext는 텅 비어 있습니다. 즉 HTTP 인증 결과를 그대로 가져다 쓸 수가 없습니다.

따라서 WebSocket 채널은 자체적으로 사용자를 식별하는 방법이 필요하고, 그 방법이 STOMP CONNECT 시점에 acc.setUser(auth)로 세션에 Principal을 부착해 두는 것입니다. 이후 같은 세션의 모든 프레임에서는 StompHeaderAccessor.getUser()로 해당 Principal을 꺼낼 수 있습니다. 이것이 다음 절에서 다룰 2단계 인터셉터의 역할입니다.

또한 STOMP 1:1 메시지 라우팅(convertAndSendToUser(userId, ...))은 내부적으로 Principal.getName()으로 사용자를 찾기 때문에, "Principal의 name이 무엇이어야 하는가"가 명시적으로 정의돼야 합니다. 이메일? 로그인 ID? 데이터베이스 PK?

만약 어떤 곳은 Principal.getName() = email이고 어떤 곳은 Principal.getName() = userId.toString()으로 섞여 있으면, convertAndSendToUser("42", ...) 호출 시 그 세션의 Principal name이 "user42@example.com"이라 매칭이 안 되어 메시지가 사라지는 사고가 납니다. 이게 본 트러블슈팅의 본질이었습니다.

참고 — 자주 빠지는 함정
Spring Security의 기본 User 클래스에서 getUsername()이 반환하는 값은 보통 사용자가 로그인할 때 입력한 식별자(이메일·loginId)입니다. 반면 STOMP의 user-prefix 라우팅은 Principal.getName()을 키로 씁니다. 두 값이 같으면 문제 없지만, 어디선가 userId를 키로 쓰기 시작하면 즉시 어긋납니다.


5. 결정적 해결 — 2단계 인터셉터 + 커스텀 Principal (userId 기반)

해결은 다음 네 부분을 동시에 적용하는 형태로 진행했습니다.

  1. JwtHandshakeInterceptor — HTTP → WebSocket 업그레이드 시점에 JWT 추출 + 세션 attributes 저장 (검증 실패해도 핸드셰이크 자체는 통과)
  2. WebSocketAuthChannelInterceptor — STOMP CONNECT 시점에 JWT 재검증 + Principal 주입
  3. JwtUserDetailsUserDetailsPrincipal동시에 구현 + getName() = userId.toString()으로 통일
  4. ChatWebSocketControllerAuthentication 파라미터로 Principal 받아 convertAndSendToUser(userId.toString(), ...) 호출

5-1. JwtHandshakeInterceptor (HTTP 업그레이드 단계)

// JwtHandshakeInterceptor.java (요약)
@Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    public static final String ATTR_JWT = "JWT_TOKEN";

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ...,
                                   Map<String, Object> attributes) {
        String token = resolveFromHeader(request);            // Authorization: Bearer ...
        if (token == null) token = resolveFromCookie(request, "accessToken");

        if (token != null && jwtProvider.isTokenValid(token)
                          && !tokenRevocationService.isRevoked(token)) {
            attributes.put(ATTR_JWT, token);                  // ★ 세션 attributes에 저장
        }
        return true;                                          // ★ 항상 true — 검증은 다음 단계에서
    }
}

핵심은 return true입니다. 핸드셰이크 자체는 항상 성공시키고, 토큰만 세션 attributes에 옮겨 둡니다. 이렇게 분리한 이유는 두 가지입니다.

  • HTTP 핸드셰이크 단계에서 401 반환하면 클라이언트가 재시도 시 쿠키/헤더 흐름이 복잡해짐
  • 채널 인터셉터 단계에서 다시 검증할 거라, 굳이 두 번 거절할 필요가 없음

5-2. WebSocketAuthChannelInterceptor (STOMP CONNECT 단계)

// WebSocketAuthChannelInterceptor.java (요약)
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
    StompHeaderAccessor acc = MessageHeaderAccessor.getAccessor(
            message, StompHeaderAccessor.class);

    if (acc != null && StompCommand.CONNECT.equals(acc.getCommand())) {
        // ① 핸드셰이크 단계에서 저장한 토큰
        String token = resolveFromSession(acc.getSessionAttributes());
        // ② 폴백 — STOMP native header의 Authorization
        if (token == null) token = resolveFromNativeHeader(acc);

        if (token != null && jwtProvider.isTokenValid(token)) {
            UserDetails ud = jwtProvider.loadUserFromToken(token);    // ★ JwtUserDetails 로드
            var auth = new UsernamePasswordAuthenticationToken(
                    ud, null, ud.getAuthorities());
            acc.setUser(auth);                                        // ★ STOMP 사용자 등록
            log.debug("[WS] Authentication set for user={}", ud.getUsername());
        }
    }
    return message;
}

핵심은 acc.setUser(auth) 한 줄입니다. 이 시점부터 같은 STOMP 세션에서 들어오는 모든 메시지에는 Spring이 자동으로 Authentication을 주입해 줍니다.

5-3. JwtUserDetails — UserDetails + Principal 동시 구현

// JwtUserDetails.java (요약)
public class JwtUserDetails implements UserDetails, Principal {   // ★ 두 인터페이스 동시
    private final Long userId;
    private final String username;
    private final List<? extends GrantedAuthority> authorities;

    public JwtUserDetails(User user) {
        this.userId   = user.getUserId();
        this.username = String.valueOf(user.getUserId());          // ★ userId 문자열
        this.authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
    }

    @Override public String getUsername() { return username; }     // userId 문자열
    @Override public String getName()     { return userId.toString(); }  // ★ Principal.getName()
}

이 클래스 한 곳에서 "이 시스템에서 사용자를 식별하는 키는 userId 한 가지"를 못 박습니다.

  • UserDetails.getUsername()"42" (userId 문자열)
  • Principal.getName()"42" (userId 문자열)
  • HTTP 측 @AuthenticationPrincipal JwtUserDetails에서도 동일
  • WebSocket 측 Principal.getName()에서도 동일

이 일치 한 가지가 convertAndSendToUser(userId.toString(), ...)가 항상 같은 키로 매칭됨을 보장합니다.

5-4. ChatWebSocketController — Principal 주입 + 1:1 라우팅

// ChatWebSocketController.java (요약)
@MessageMapping("/chat.send.{chatRoomId}")
public void sendMessage(@DestinationVariable Long chatRoomId,
                        @Payload ChatMessageDto payload,
                        Authentication authentication) {           // ★ Principal 주입
    if (authentication == null
            || !(authentication.getPrincipal() instanceof JwtUserDetails jud)) {
        log.warn("[WS] 인증 없음 — 메시지 드롭");
        return;
    }
    Long senderUserId = jud.getUserId();

    // ① MongoDB에 저장
    ChatMessage saved = chatMessageRepository.save(
            ChatMessage.createText(chatRoomId, ..., senderUserId, payload.content()));

    // ② 두 사용자에게 1:1 라우팅
    List<Long> receivers = service.findReceivers(chatRoomId, senderRoomUserId);
    for (Long uid : receivers) {
        template.convertAndSendToUser(
                uid.toString(),                                    // ★ Principal.getName()과 매칭
                "/queue/chat." + chatRoomId,
                new SocketPayload<>("MESSAGE", saved));
    }
}

핵심 포인트:

  • Authentication 파라미터를 컨트롤러 메서드 시그니처에 명시 → Spring이 자동 주입
  • instanceof JwtUserDetails jud 패턴 매칭으로 타입 안전 추출
  • convertAndSendToUser(uid.toString(), ...)의 첫 인자가 Principal.getName()과 정확히 일치

6. 1:1 메시지 라우팅 검증

해결책 적용 후, 한 채팅방의 두 사용자 사이에서 STOMP 메시지가 다음과 같이 흘러갑니다.

[Manito Browser]                  [Spring]                       [Manitti Browser]
  │ STOMP CONNECT                   │
  │   Authorization: Bearer ...     │
  │ ──────────────────────────────▶ │ JwtHandshakeInterceptor       │
  │                                 │   → attributes.JWT_TOKEN      │
  │                                 │ WebSocketAuthChannelIntercept │
  │                                 │   → acc.setUser(auth)         │
  │                                 │   Principal.getName() = "42"  │
  │                                 │                               │
  │ SUBSCRIBE /user/queue/chat.5    │                               │
  │ ──────────────────────────────▶ │ (Principal "42"의 채널)       │
  │                                 │                               │
  │ SEND /app/chat.send.5  "안녕"   │                               │
  │ ──────────────────────────────▶ │ ChatWebSocketController       │
  │                                 │   senderUserId = 42           │
  │                                 │   MongoDB save                │
  │                                 │   convertAndSendToUser(       │
  │                                 │     "99", /queue/chat.5, ...) │
  │                                 │ ──────────────────────────▶  │
  │                                 │                               │  ◀── /user/queue/chat.5
  │                                 │                               │      "안녕"

convertAndSendToUser("99", ...)"99"가 마니띠의 Principal.getName()과 정확히 같은 값(userId.toString())이기 때문에 STOMP가 그 사용자의 세션 큐에만 메시지를 꽂아 줍니다.

마니또(uid=42)와 마니띠(uid=99) 두 브라우저를 동시에 열어 STOMP 연결 → 메시지 전송 → 수신까지 재현

capture-kit — 마니또(uid=42) · 마니띠(uid=99) STOMP 듀얼 클라이언트 테스트

서버 로그에서는 JWT 핸드셰이크 인터셉터 → STOMP CONNECT 인증 설정 → MongoDB 저장 → 수신자 큐 라우팅의 전 구간이 순서대로 확인됩니다.

cap-tt-ws-server 로그 — JWT 인증 → MongoDB 저장 → /user/{id}/queue 라우팅

1:1 채팅 — 마니띠에게만 도착한 메시지 + 스티커

찌르기·통화·알림까지 같은 채널 위에서

같은 인증 컨텍스트 위에서 채팅뿐 아니라 찌르기(/chat.poke)·통화 시그널링(/chat.call.start/accept/reject)까지 동일하게 동작합니다. Principal이 한 가지 키(userId)로 통일되어 있으니, 어떤 매핑에서도 "이 사용자에게만 알림" → convertAndSendToUser(userId.toString(), ...) 한 줄로 끝납니다.


7. 정리

Before vs After

항목BeforeAfter
인증 컨텍스트HTTP SecurityContext만 사용HTTP + WS 인증 분리 / 일관된 Principal
Principal 키이메일·loginId·userId 혼재userId.toString() 단일
1:1 메시지convertAndSendToUser 호출 시 매칭 실패 빈발Principal.getName() 정확 매칭
채팅방 격리다른 방으로 메시지 새는 케이스 발생 가능채팅방 단위 정확 라우팅
알림 통합푸시·찌르기·통화 각각 별도 식별자 필요userId 한 가지로 통합

인증 흐름 비교

                  HTTP 요청                     WebSocket 채널
                  ─────────                     ────────────
1. 토큰 검증     JwtAuthenticationFilter       JwtHandshakeInterceptor (1회)
                                              + WebSocketAuthChannelInterceptor (CONNECT)

2. 저장소        SecurityContext (ThreadLocal) StompHeaderAccessor.user (세션 단위)

3. 사용자 키     JwtUserDetails.getUsername()  JwtUserDetails.getName()
                = userId 문자열                = userId 문자열   ← 같은 값
4. 컨트롤러      @AuthenticationPrincipal      Authentication 파라미터
                JwtUserDetails ud              → ud.getUserId()

세 번째 행이 결론입니다. UserDetails.getUsername()Principal.getName()이 같은 클래스에서 같은 값(userId)을 반환하도록 통일한 것이 곧 HTTP·WS 두 컨텍스트를 묶는 접점이었습니다.


마무리

본 포스팅에서는 또마니또 프로젝트의 STOMP 1:1 채팅 라우팅이 의미를 가지기 위한 인증 인프라로서, HTTP 인증과 분리된 WebSocket 채널의 비동기 컨텍스트에서 사용자를 어떻게 식별할 것인가 문제를 풀었던 과정을 정리하였습니다.

구간책임핵심
HTTP 핸드셰이크JwtHandshakeInterceptor토큰 추출 + 세션 attributes 저장 (검증은 다음 단계)
STOMP CONNECTWebSocketAuthChannelInterceptorJWT 재검증 + acc.setUser(auth)
Principal 통일JwtUserDetailsUserDetails + Principal 동시 구현, getName() = userId.toString()
1:1 라우팅convertAndSendToUser(uid.toString(), ...)Principal.getName()과 정확 매칭

핵심 요약

  • WebSocket은 비동기 채널이므로 HTTP의 SecurityContext를 그대로 못 쓴다.
  • 1:1 메시지 라우팅의 키는 Principal.getName() — 이 값을 시스템 전반에서 하나로 통일해야 한다.
  • UserDetailsPrincipal을 같은 클래스에 두고 같은 값(userId)을 반환하게 하면 HTTP·WS 두 컨텍스트가 한 줄로 묶인다.

0개의 댓글