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

채팅 도메인을 구현하면서 두 가지를 동시에 풀어야 했습니다.
본문은 다음 흐름으로 정리합니다.
채팅 도메인의 책임을 다음과 같이 세 축으로 나눴습니다.
| 영역 | 저장소 | 역할 |
|---|---|---|
| 채팅방 메타 | MySQL (chatting_room 테이블) | 채팅방 ID · 매칭(ManitoAssignment) FK · 마지막 메시지 미리보기·시각 |
| 채팅 메시지 | MongoDB (chat_message 컬렉션) | 메시지 본문 · 발신자 · 시각 · 타입 |
| 실시간 전송 | STOMP / WebSocket | CONNECT / 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가 어떻게 결정하느냐 였습니다.
이 글의 메인 주제는 인증이지만, MongoDB 선택 이유를 짚고 가야 인증·라우팅 설계와의 결합이 자연스러워집니다. MySQL chat_message 테이블 단일 구조도 검토했지만, 다음 이유로 메시지만 MongoDB로 분리했습니다.
| 항목 | MySQL JPA 단일 | MongoDB 분리 |
|---|---|---|
| 쓰기 패턴 | INSERT 폭주 시 인덱스 페이지 분할 비용 | append-only 컬렉션, 자연스러운 시계열 |
| 읽기 패턴 | 시간 역순 페이지네이션 시 sort + index 재정렬 | _id ObjectId가 시간 정보를 포함, sort 거의 0 비용 |
| 스키마 유연성 | 메시지 타입(텍스트/이미지/시스템)마다 NULL 컬럼 누적 | 문서별 다른 필드 자연스럽게 |
| JPA 캐스케이드 부담 | ChattingRoom ↔ ChatMessage 양방향 관리 | 완전 분리, 채팅방 삭제 시 컬렉션만 별도 삭제 |
| 인증·라우팅 결합 | DB 트랜잭션 안에서 사용자 식별 | 저장과 라우팅이 분리 — 인증 책임이 STOMP 채널로 명확히 떨어짐 |
마지막 항목이 본 트러블슈팅과 직결됩니다. 메시지를 MongoDB로 분리해두면 STOMP 채널의 책임이 "메시지를 받아 저장하고, 그 결과를 1:1로 다시 라우팅하는" 두 단계로 명확하게 정리됩니다. JPA 트랜잭션 경계와 WebSocket 비동기 처리가 같은 함수 안에 섞이는 위험이 사라지기 때문에, 인증·Principal 설계가 "누구에게 보낼 것인가" 한 가지에만 집중할 수 있습니다.

HTTP 요청은 다음 흐름으로 사용자를 식별합니다.
HTTP Request
↓ Servlet Filter (JwtAuthenticationFilter)
↓ JWT 파싱 → Authentication 객체
↓ SecurityContextHolder.getContext().setAuthentication(auth) ★ ThreadLocal 저장
↓ Controller @AuthenticationPrincipal 주입
핵심은 SecurityContextHolder가 내부적으로 ThreadLocal이라는 점입니다. 한 HTTP 요청 = 한 스레드라는 가정 위에서만 동작합니다.
WebSocket은 이 가정이 깨집니다.
| 단계 | HTTP 인증 | WebSocket |
|---|---|---|
| 1. 요청 도착 | Servlet Filter Chain | HTTP 핸드셰이크 (1회성) |
| 2. 토큰 검증 | JwtAuthenticationFilter | JwtHandshakeInterceptor |
| 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를 키로 쓰기 시작하면 즉시 어긋납니다.
해결은 다음 네 부분을 동시에 적용하는 형태로 진행했습니다.
JwtUserDetails — UserDetails와 Principal을 동시에 구현 + getName() = userId.toString()으로 통일Authentication 파라미터로 Principal 받아 convertAndSendToUser(userId.toString(), ...) 호출// 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에 옮겨 둡니다. 이렇게 분리한 이유는 두 가지입니다.
// 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을 주입해 줍니다.
// 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 문자열)@AuthenticationPrincipal JwtUserDetails에서도 동일Principal.getName()에서도 동일이 일치 한 가지가 convertAndSendToUser(userId.toString(), ...)가 항상 같은 키로 매칭됨을 보장합니다.
// 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()과 정확히 일치해결책 적용 후, 한 채팅방의 두 사용자 사이에서 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 연결 → 메시지 전송 → 수신까지 재현

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


찌르기·통화·알림까지 같은 채널 위에서
같은 인증 컨텍스트 위에서 채팅뿐 아니라 찌르기(/chat.poke)·통화 시그널링(/chat.call.start/accept/reject)까지 동일하게 동작합니다. Principal이 한 가지 키(userId)로 통일되어 있으니, 어떤 매핑에서도 "이 사용자에게만 알림" → convertAndSendToUser(userId.toString(), ...) 한 줄로 끝납니다.
| 항목 | Before | After |
|---|---|---|
| 인증 컨텍스트 | 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 CONNECT | WebSocketAuthChannelInterceptor | JWT 재검증 + acc.setUser(auth) |
| Principal 통일 | JwtUserDetails | UserDetails + Principal 동시 구현, getName() = userId.toString() |
| 1:1 라우팅 | convertAndSendToUser(uid.toString(), ...) | Principal.getName()과 정확 매칭 |
핵심 요약
SecurityContext를 그대로 못 쓴다.Principal.getName() — 이 값을 시스템 전반에서 하나로 통일해야 한다.UserDetails와 Principal을 같은 클래스에 두고 같은 값(userId)을 반환하게 하면 HTTP·WS 두 컨텍스트가 한 줄로 묶인다.