1. 도입부 (Introduction)
현대의 웹 서비스에서 실시간성(Real-time)은 사용자 경험을 결정짓는 핵심 요소 중 하나다. 사용자가 브라우저를 새로고침하지 않고도 새로운 메시지를 즉시 수신하는 채팅 서비스는 가장 대표적인 실시간 기능이다. 일반적으로 무상태(Stateless) 방식의 HTTP 단방향 통신만으로는 지속적인 실시간 양방향 데이터 송수신을 구현하는 데 큰 제약이 따른다. 매번 TCP 연결을 맺고 끊는 폴링(Polling)이나 롱폴링(Long-Polling)은 불필요한 네트워크 오버헤드와 심각한 레이턴시(Latency)를 유발하기 때문이다.
이러한 한계를 극복하기 위해 등장한 것이 바로 웹소켓(WebSocket)이다. 웹소켓은 최초 1회 HTTP 핸드셰이크를 통해 TCP 연결을 성공적으로 확립한 후, ws:// 프로토콜로 전환하여 연결 상태를 유지하며 가볍고 효율적으로 프레임을 주고받는다.
하지만 순수 웹소켓은 단순한 텍스트나 바이너리 바이트 스트림만 보낼 수 있을 뿐, 메시지의 성격이나 수신 대상(라우팅)을 정의하는 규격이 없다. 그래서 우리는 메시징 서브 프로토콜인 STOMP(Simple Text Oriented Messaging Protocol)를 도입한다. STOMP는 CONNECT, SUBSCRIBE, SEND, MESSAGE 등의 프레임 규격을 제공하여 클라이언트와 서버가 '발행/구독(Pub/Sub)' 모델로 목적지(destination)를 지정해 정제된 통신을 할 수 있게 돕는다.
여기서 백엔드 엔지니어에게 가장 치열한 고민을 남기는 지점이 발생한다. "영구적으로 유지되는 웹소켓 연결 위에서, 개별 STOMP 프레임이 유입될 때 보안 검증(Authentication)과 비즈니스 권한 검증(Authorization)을 어떻게 실행할 것인가?"이다.
Spring Security 필터 체인은 전통적인 서블릿 기반 HTTP 요청 전처리에는 특화되어 있으나, 이미 맺어진 지속적 웹소켓 채널 내부의 개별 프레임 검증에는 개입하지 못한다. 또한 사용자가 속하지 않은 채팅방에 불법적으로 구독 요청(SUBSCRIBE)을 보낼 때, 이를 채널 진입 직전에 차단해야 하는 비즈니스 요구사항도 존재한다.
[Stomp] 스프링 부트 STOMP 웹소켓 채팅 서버: 기본 설정부터 메시지 브로커 라우팅까지
1. 도입부: 왜 단순 웹소켓(WebSocket)이 아니라 STOMP인가?우리는 이전 포스팅에서 HTTP 프로토콜의 단방향 통신 한계를 극복하기 위해 웹소켓(WebSocket)을 도입했다. 웹소켓은 최초 1회 핸드셰이크(Hands
myblog01150.tistory.com
이 글에서는 제공된 실제 코드를 분석하며, Spring STOMP의 ChannelInterceptor를 구현한 StompHandler를 통해 JWT 토큰을 매 프레임마다 검증하는 보안 아키텍처와, 다양한 형태의 채팅방을 관리하고 다중 참여자의 실시간 읽음 상태(ReadStatus)를 엔티티 레벨에서 추적하는 ChatService 비즈니스 계층의 완벽한 유기적 결합 방식을 심층적으로 분석한다.
2. 주요 특징 및 핵심 로직 (Main Features & Logic)
전체 시스템은 보안 게이트웨이 역할을 하는 인터셉터 계층(StompHandler)과 실제 DB 데이터 정합성 및 생명주기를 주관하는 서비스 계층(ChatService)의 구조화된 결합으로 완성된다.
2.1 아키텍처적 메시지 가로채기(Interception) 흐름
클라이언트가 서버로 송신하는 모든 STOMP 프레임은 스프링이 제공하는 InboundChannel을 통과하기 전 인터셉터단에서 분기 통제된다.

3. 상세 가이드 및 심층 분석 (Detailed Guide)
실제 백엔드 시스템에 적용된 구체적인 자바 코드를 살펴보며 보안 인터셉터와 비즈니스 레이어의 구조적 동작 원리를 파헤쳐 보자.
3.1 STOMP 보안의 최전선: StompHandler
StompHandler는 ChannelInterceptor의 preSend 라이프사이클 훅을 오버라이딩하여, STOMP 프레임이 실제로 컨트롤러나 메시지 브로커로 전달되기 직전 동기적으로 가로챈다.
/**
* STOMP 메시지 송신 전 가로채어 JWT 토큰 검증 및 비즈니스 인가를 처리하는 인터셉터 클래스다.
* 클라이언트가 보내는 모든 커맨드 패킷은 이 핸들러의 통제를 받는다.
*/
@Component
@Slf4j
public class StompHandler implements ChannelInterceptor {
@Value("${jwt.secretKey}")
private String secretKey;
private final ChatService chatService;
public StompHandler(ChatService chatService) {
this.chatService = chatService;
}
/**
* 메시지가 채널로 전송되기 직전에 호출되는 인터셉터 메서드다.
* 여기서 JWT 유효성 검증 및 채팅방 참여 권한 여부를 체크한다.
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 메시지 헤더 및 프레임 정보를 편리하게 가공하기 위해 StompHeaderAccessor로 랩핑한다.
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// 1. 최초 웹소켓 세션 맺기 직후 STOMP 연결(CONNECT) 요청 검증
if(StompCommand.CONNECT == accessor.getCommand()) {
log.info("connect 요청시 토큰 유효성 검증");
// 헤더에서 Authorization 속성을 가져온다. (형식: Bearer xxx.xxx.xxx)
String bearerToken = accessor.getFirstNativeHeader("Authorization");
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
throw new AuthenticationServiceException("토큰이 존재하지 않거나 유효하지 않은 포맷입니다.");
}
String token = bearerToken.substring(7);
// 토큰 검증 및 claims 추출 (서명 오류, 만료 발생 시 Jwts 자체에서 내장 예외가 던져짐)
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
log.info("토큰 검증 완료");
}
// 2. 특정 목적지 채널 구독(SUBSCRIBE) 요청 시 검증
if(StompCommand.SUBSCRIBE == accessor.getCommand()) {
log.info("subscribe 검증");
String bearerToken = accessor.getFirstNativeHeader("Authorization");
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
throw new AuthenticationServiceException("구독 요청에 Authorization 토큰이 필요합니다.");
}
String token = bearerToken.substring(7);
// 토큰 파싱을 수행하여 주체(Subject) 즉, 유저의 고유 이메일을 획득한다.
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
String email = claims.getSubject();
// 클라이언트가 구독하려는 Destination 정보 획득 (예: /sub/chat/{roomId})
String destination = accessor.getDestination();
if (destination == null) {
throw new AuthenticationServiceException("구독 목적지가 누락되었습니다.");
}
// 구독 목적지 경로에서 roomId를 추출하기 위한 파싱 로직
// 예시 목적지 규격이 "/sub/chat/15" 형태일 경우, '/' 기준 분할 시
// ["", "sub", "chat", "15"] 배열이 생성되어 3번째 인덱스(값: "15")를 방 ID로 간주한다.
// 본 프로젝트에서는 split("/") 시 [2]번째 인덱스를 roomId로 매핑하고 있다.
String[] splitDestination = destination.split("/");
if (splitDestination.length < 3) {
throw new AuthenticationServiceException("잘못된 구독 목적지 규격입니다.");
}
String roomId = splitDestination[2];
// 비즈니스 서비스 계층을 활용하여 이메일의 유저가 해당 방의 실제 멤버(ChatParticipant)인지 검증한다.
if(!chatService.isRoomParticipant(email, Long.parseLong(roomId))){
throw new AuthenticationServiceException("해당 Room에 권한이 없습니다.");
}
log.info("채팅방 구독 권한 통과: 유저={}, 방ID={}", email, roomId);
}
return message;
}
}
🔍 StompHandler 핵심 분석 표
| 분석 관점 | 상세 구현 특징 | 설계상 주의점 및 보안 가치 |
| Command 분기 처리 | CONNECT와 SUBSCRIBE 시점만 격리하여 상이한 검증 흐름을 유도한다. | 연결 비용 최소화: 모든 SEND 패킷마다 매번 검증하는 것은 DB 커넥션 부하를 높일 수 있으므로 진입점인 CONNECT와 리소스 접근점인 SUBSCRIBE만 격리 필터링한다. |
| Destination Parsing | destination.split("/")을 사용하여 런타임에 dynamic path 파싱을 수행한다. | 프론트엔드의 구독 엔드포인트 설계와 백엔드 split 인덱스가 정밀하게 일치해야 오작동(NumberFormatException 등)을 예방할 수 있다. |
| Security Context 격리 | 스프링 시큐리티 Context 홀더에 의존하지 않고 JWT Claims에서 주체(이메일)를 로컬 추출한다. | 웹소켓 스레드는 HTTP WAS 스레드 풀과 다른 생명주기를 가지므로 ThreadLocal 기반 SecurityContext에 직접 액세스할 수 없는 멀티스레드 환경을 돌파하는 정석적인 구조다. |
3.2 실시간 비즈니스 엔진: ChatService
ChatService는 방 개설, 참여자 추가, 메시지 보존 및 대다수 비즈니스 기능을 담고 있으며, 특히 실시간 채팅에서 매우 복잡한 데이터 정합성이 요구되는 읽음 상태 처리 로직을 극도로 정밀하게 가공하여 담고 있다.
/**
* 실시간 메시지 영속화, 1:1 및 그룹 채팅방 라이프사이클 관리, 읽음 추적(ReadStatus)을 책임지는
* 도메인 오케스트레이션 서비스 클래스다.
*/
@Service
@Transactional
public class ChatService {
private final ChatRoomRepository chatRoomRepository;
private final ChatParticipantRepository chatParticipantRepository;
private final ChatMessageRepository chatMessageRepository;
private final ReadStatusRepository readStatusRepository;
private final MemberRepository memberRepository;
public ChatService(ChatRoomRepository chatRoomRepository,
ChatParticipantRepository chatParticipantRepository,
ChatMessageRepository chatMessageRepository,
ReadStatusRepository readStatusRepository,
MemberRepository memberRepository) {
this.chatRoomRepository = chatRoomRepository;
this.chatParticipantRepository = chatParticipantRepository;
this.chatMessageRepository = chatMessageRepository;
this.readStatusRepository = readStatusRepository;
this.memberRepository = memberRepository;
}
/**
* 클라이언트로부터 전달받은 신규 메시지를 영속화하고,
* 해당 방의 모든 참여자에 대한 읽음 매핑 정보(ReadStatus)를 적재한다.
*/
public void saveMessage(Long roomId , ChatMessageDto chatMessageDto) {
// 1. 타겟 채팅방 식별
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
// 2. 메시지 발신 회원 조회
Member sender = memberRepository.findByEmail(chatMessageDto.getSenderEmail())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 3. 채팅 메시지 영속 인스턴스 생성 및 보존
ChatMessage chatMessage = ChatMessage.builder()
.chatRoom(chatRoom)
.member(sender)
.content(chatMessageDto.getMessage())
.build();
chatMessageRepository.save(chatMessage);
// 4. 실무형 읽음 상태(ReadStatus) 벌크성 적재 로직
// 채팅방의 참여자(ChatParticipant) 리스트를 순회하며 각 참여자당 메시지 읽음 튜플을 한 장씩 생성한다.
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
for(ChatParticipant c : chatParticipants) {
ReadStatus readStatus = ReadStatus.builder()
.chatRoom(chatRoom)
.member(c.getMember())
.chatMessage(chatMessage)
// 발신자 본인인 경우 이미 읽은 상태(true)로 마킹하고, 다른 타인들은 아직 안읽은 상태(false)로 마킹한다.
.isRead(c.getMember().equals(sender))
.build();
readStatusRepository.save(readStatus);
}
}
/**
* 신규 그룹 채팅방을 신설하고 요청 유저를 최초 방장/참여자로 등록한다.
*/
public void createGroupRoom(String chatRoomName) {
// 시큐리티 컨텍스트 영역에서 요청 유저의 식별키인 Email을 추출한다.
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 단체 채팅방 플래그("Y") 설정 후 채팅방 저장
ChatRoom chatRoom = ChatRoom.builder()
.name(chatRoomName)
.isGroupChat("Y")
.build();
chatRoomRepository.save(chatRoom);
// 채팅방 참여 관계 엔티티 생성 후 영속화
ChatParticipant chatParticipant = ChatParticipant.builder()
.chatRoom(chatRoom)
.member(member)
.build();
chatParticipantRepository.save(chatParticipant);
}
/**
* 전체 서비스 내에 개설된 모든 공개 그룹 채팅방("Y")을 조회하여 반환한다.
*/
@Transactional(readOnly = true)
public List<ChatRoomListResDto> getGroupChatRooms() {
List<ChatRoom> chatRooms = chatRoomRepository.findByIsGroupChat("Y");
List<ChatRoomListResDto> dtos = new ArrayList<>();
for(ChatRoom c : chatRooms) {
ChatRoomListResDto dto = ChatRoomListResDto.builder()
.roomId(c.getId())
.roomName(c.getName())
.build();
dtos.add(dto);
}
return dtos;
}
/**
* 특정 그룹 채팅방에 사용자가 동적으로 신규 진입할 때 참여자 정보(Participant)를 기록한다.
*/
public void addParticipantToGroupChat(Long roomId){
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 해당 방이 1:1 방인데 비정상적으로 조인 접근을 한 경우 1차 예외 통제
if(chatRoom.getIsGroupChat().equals("N")){
throw new IllegalArgumentException("그룹 채팅이 아닙니다.");
}
// 중복 참여를 원천 방어하기 위해 JPA Optional 필터링 후 없을 때만 데이터 저장
Optional<ChatParticipant> participant = chatParticipantRepository.findByChatRoomAndMember(chatRoom, member);
if(!participant.isPresent()) {
addParticipantToRoom(chatRoom, member);
}
}
/**
* 실제 다대다(N:M) 연관 테이블 매핑을 해소하기 위한 내부 위임 헬퍼 메서드다.
*/
public void addParticipantToRoom(ChatRoom chatRoom, Member member){
ChatParticipant chatParticipant = ChatParticipant.builder()
.chatRoom(chatRoom)
.member(member)
.build();
chatParticipantRepository.save(chatParticipant);
}
/**
* 채팅방 입장 시 보안 검증을 수행한 뒤 과거의 전체 대화 히스토리를 오름차순으로 추출해 제공한다.
*/
@Transactional(readOnly = true)
public List<ChatMessageDto> getChatHistory(Long roomId) {
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 인가 확인: 내가 속한 방인가?
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
boolean check = false;
for(ChatParticipant c : chatParticipants) {
if(c.getMember().equals(member)) {
check = true;
break;
}
}
if(!check) throw new IllegalArgumentException("본인이 속하지 않은 채팅방입니다.");
// 특정 방에 대한 메시지 기록을 과거 순서부터 역추적(Ascending)하여 인출한다.
List<ChatMessage> chatMessages = chatMessageRepository.findByChatRoomOrderByCreateTimeAsc(chatRoom);
List<ChatMessageDto> chatMessageDtos = new ArrayList<>();
for(ChatMessage c : chatMessages) {
ChatMessageDto dto = ChatMessageDto.builder()
.message(c.getContent())
.senderEmail(c.getMember().getEmail())
.build();
chatMessageDtos.add(dto);
}
return chatMessageDtos;
}
/**
* StompHandler 및 인가 인터셉터 단에서 호출할 실시간 소켓 구독 권한 판단 전용 메서드다.
*/
@Transactional(readOnly = true)
public boolean isRoomParticipant(String email, Long roomId) {
// 객체 격리 조회 단계
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
for(ChatParticipant c : chatParticipants) {
if(c.getMember().equals(member)) {
return true;
}
}
return false;
}
/**
* 방 입장 시 해당 사용자가 아직 읽지 않은 방의 모든 메시지들의 읽음 여부를
* 벌크 업데이트(Dirty Checking)한다.
*/
public void messageRead(Long roomId) {
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 내게 할당된 이 방의 모든 ReadStatus 튜플을 조회한다.
List<ReadStatus> readStatuses = readStatusRepository.findByChatRoomAndMember(chatRoom, member);
// 변경 감지(Dirty Checking) 메커니즘을 타고 벌크 수정 상태로 전이시킨다.
for(ReadStatus r : readStatuses) {
r.updateIsRead(true);
}
}
/**
* 사용자가 참가하고 있는 모든 대화방 리스트를 수집하고,
* 실시간으로 미읽은 메시지의 정확한 수량(Badge Count)을 결합하여 반환한다.
*/
@Transactional(readOnly = true)
public List<MyChatListResDto> getMyChatRooms() {
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 내가 속한 방 참여 목록 로드
List<ChatParticipant> chatParticipants = chatParticipantRepository.findAllByMember(member);
List<MyChatListResDto> dtos = new ArrayList<>();
for(ChatParticipant c : chatParticipants) {
// 이 방에서 이 멤버가 읽지 않은(isRead가 False) 메시지의 총 갯수를 쿼리 카운팅한다.
Long count = readStatusRepository.countByChatRoomAndMemberAndIsReadFalse(c.getChatRoom(), member);
MyChatListResDto dto = MyChatListResDto.builder()
.roomId(c.getChatRoom().getId())
.roomName(c.getChatRoom().getName())
.isGroupChat(c.getChatRoom().getIsGroupChat())
.unReadCount(count)
.build();
dtos.add(dto);
}
return dtos;
}
/**
* 그룹 채팅방 퇴장 처리를 집행하고, 만약 방에 아무도 남지 않게 되었을 경우
* 고아(Orphan) 채팅방 데이터를 DB 보존성 차원에서 안전하게 영구 소멸시킨다.
*/
public void leaveGroupChatRoom(Long roomId) {
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new EntityNotFoundException("room cannot be found"));
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
if(chatRoom.getIsGroupChat().equals("N")){
throw new IllegalArgumentException("단체 채팅방이 아닙니다");
}
// 해당 방의 나에 해당하는 참여 레코드를 발굴 후 영구 격하 삭제
ChatParticipant c = chatParticipantRepository.findByChatRoomAndMember(chatRoom, member)
.orElseThrow(() -> new EntityNotFoundException("참여자를 찾을 수 없습니다"));
chatParticipantRepository.delete(c);
// 방에 생존 참여자가 단 1명도 존재하지 않는다면 메타 데이터 전체를 드랍 삭제한다.
List<ChatParticipant> chatParticipants = chatParticipantRepository.findByChatRoom(chatRoom);
if(chatParticipants.isEmpty()){
chatRoomRepository.delete(chatRoom);
}
}
/**
* 대상 상대방과 나 사이의 1:1 Private 소통 공간을 탐색하고,
* 미존재 시 완전히 독립된 무상태 지향 신규 1:1 채팅방을 개설해 ID를 제공한다.
*/
public Long getOrCreatePrivateRoom(Long otherMemberId) {
Member member = memberRepository.findByEmail(SecurityContextHolder.getContext().getAuthentication().getName())
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
Member otherMember = memberRepository.findById(otherMemberId)
.orElseThrow(() -> new EntityNotFoundException("member cannot be found"));
// 나와 상대방이 고유 1:1 대화방에 교차 조인되어 참여중인지 리포지토리 레벨에서 통합 탐색한다.
Optional<ChatRoom> chatRoom = chatParticipantRepository.findExistingPrivateRoom(member.getId(), otherMember.getId());
if(chatRoom.isPresent()) {
return chatRoom.get().getId();
}
// 과거 1:1 방 이력이 전무할 경우 신형 방 객체를 직조한다.
ChatRoom newRoom = ChatRoom.builder()
.isGroupChat("N")
.name(member.getName() + "-" + otherMember.getName())
.build();
chatRoomRepository.save(newRoom);
// 두 행위 참여자 모두를 Participant 테이블에 맵 바인딩 등록한다.
addParticipantToRoom(newRoom, member);
addParticipantToRoom(newRoom, otherMember);
return newRoom.getId();
}
}
3.3 대화 참여 상태와 실시간 '안 읽음' 추적(ReadStatus) 작동 메커니즘
많은 개발자가 실시간 멀티플레이어 채팅방을 제작할 때, "각 참여자 별로 어떤 메시지를 안 읽었는지 알아내는 로직"을 만지면서 설계상의 큰 병목에 직면하곤 한다.
제공된 이 비즈니스 구조의 비결은 바로 ReadStatus라는 명시적 관계 정의 엔티티에 있다. 이를 구체적인 시각 모델로 뜯어보자.

4. 실무형 대규모 채팅 시스템 설계 패러다임 (Production-Grade Architecture)
단일 WAS와 단일 관계형 데이터베이스(RDB) 환경에서의 소켓 구현은 소규모 동시 접속자 상태에서는 잘 동작하나, 실제 프로덕션 환경의 고부하 시나리오에서는 치명적인 아키텍처적 병목을 피할 수 없다. 실무 메신저 인프라가 설계되는 핵심 컴포넌트들을 심층 분석해 보겠다.
4.1 다중 서버 스케일 아웃(Scale-Out)의 핵심: Redis Pub/Sub
서버의 부하를 분산하기 위해 WAS 인스턴스를 여러 대로 다중화하는 순간, 세션 단절(Session Disconnection) 문제가 일어난다. WAS 1번에 핸드셰이크를 맺고 연결된 유저A는 WAS 2번에 소켓 세션이 바인딩된 유저B에게 직접적으로 데이터를 전송할 수 없기 때문이다.
이 문제를 우아하게 극복하기 위해 실무에서는 인메모리 브로커 레이어(Redis Pub/Sub)를 중심에 둔다.

- 이벤트 전파 흐름: 유저A가 방에 메시지를 발행하면, 요청을 수신한 WAS 1은 자사 로컬 메모리에 상주 중인 세션 유무를 따지는 동시에, Redis의 room:{roomId} 채널로 직렬화된 JSON 패킷을 전송($O(1)$)한다.
- 세션 바인딩 리졸브: Redis로부터 메시지를 실시간 수신(Subscribe) 중인 백엔드 전 서버 인스턴스(WAS 1, WAS 2 등)는 이벤트를 통보받는 즉시 본인들이 장비 내 메모리에 갖고 있는 소켓 커넥션 세션 테이블을 수색하여 해당 방의 타겟 클라이언트가 있을 시 로컬 소켓 다운스트림 스레드로 즉시 패킷을 다운로드한다.

4.2 데이터 영속화(Storage) 이원화 전략: Kafka 버퍼링과 NoSQL
채팅 메시지는 쓰기 트래픽이 압도적으로 높고 영구 보존되어야 하는 대표적인 데이터 필드다. saveMessage 로직처럼 메시지가 송신될 때마다 동기적인 JPA 관계 매핑 루프를 타게 설계하면, 동시 다발적인 채팅 전송 시 RDB 커넥션 고갈 및 I/O 락(Lock)에 의해 시스템 전체가 뻗어버리는 장애가 초래된다.
따라서 대규모 시스템은 통신 채널과 저장소 파이프라인을 완전히 비동기식으로 분리(Decoupling)한다.

- 실시간 통신과 저장 트랜잭션 격리: 사용자에게 대화가 노출되는 실시간 루트는 Redis Pub/Sub을 태워 밀리초($ms$) 단위의 극한의 속도로 패킷을 전송한다.
- 비동기 버퍼 메시지 큐(Kafka, RabbitMQ) 탑재: 전송된 데이터는 사용자 화면에 찍힘과 동시에 비동기 이벤트 핸들러를 통해 Kafka 토픽으로 발행된다.
- NoSQL 분산 데이터베이스 저장: 대형 컨슈머 워커(Consumer Workers)가 기동하여 큐 내부의 메시지를 $1,000$ 단위의 대량 청크(Chunk)로 모아서 MongoDB나 Cassandra 등 분산 쓰기 성능이 강력한 비관계형(NoSQL) 데이터베이스에 벌크 저장한다. RDB의 원자적 트랜잭션 비용과 디스크 헤더 회전 오버헤드를 우회하는 극적인 설계 방식이다.
4.3 실시간 미읽음 메시지(Badge Count) 계산 최적화: 체크포인트 커서 시스템
제공해주신 코드의 ReadStatus 다대다 매핑 엔티티 방식은 소수 단인 대화방에서는 완벽하고 직관적으로 읽음 상태를 판정하지만, 만약 참여자가 $1,000$명인 대형 단체 소통방에서 메시지가 $1$건 올라오면, 루프를 타고 매번 $1,000$개의 ReadStatus 로우(Row)를 DB에 밀어 넣어야 한다. 대화 10건만 오가도 $10,000$개의 정적 관계 튜플이 생성되어 디스크 공간 소모와 영속화 병목이 기하급수적으로 터져 나간다.
실무에서 이를 해소하는 정석 방식은 체크포인트 기반 마지막 읽은 메시지 ID(Last Read Message ID) 커서 메커니즘이다.

- 작동 방식: 개별 메시지당 인스턴스를 한 장씩 찍지 않고, ChatParticipant 테이블에 lastReadMessageId (유저가 마지막으로 본 메시지의 고유 PK 값) 컬럼만 둔다.
- 성능 상의 이점: 유저가 메시지를 보낼 때 DB 쓰기 오버헤드는 단 $1$건의 메시지 영속화로 종결된다. 사용자가 대방화에 입장하여 messageRead를 트리거하는 시점에는 본인의 lastReadMessageId 값만 해당 방의 가장 최신 메시지 ID로 딱 $1$번 단일 갱신(Single Update)한다.
- 배지 카운트 연산: 내 전체 채팅방 리스트를 조회해 배지를 그릴 때도 복잡한 다중 관계 조인 없이, 복합 인덱스(roomId + id)만을 활용해 사용자의 저장된 마지막 커서값 초과의 메시지 개수만 초고속 수량 산정(COUNT)해 낼 수 있어 성능이 압도적으로 향상된다.
5. 실전 트러블슈팅 및 예외 상황 대응 (Troubleshooting)
실전 프로덕션 운영 중 웹소켓 인터셉터와 비즈니스 레이어에서 마주칠 수 있는 치명적인 병목과 인덱스 에러 방어 전략을 정리한다.
5.1 토큰 슬라이싱 유실 방어: StringIndexOutOfBoundsException
StompHandler 내에서 헤더 토큰을 추출할 때 아래의 원시 처리는 악의적이거나 잘못 가공된 프레임이 유입되는 순간 서버 스레드를 전면 파괴(Crash)시킨다.
String bearerToken = accessor.getFirstNativeHeader("Authorization");
String token = bearerToken.substring(7); // bearerToken이 null이거나 7글자 미만일 때 터짐!
🛡️ 무결성 강화 패치 코드
String bearerToken = accessor.getFirstNativeHeader("Authorization");
if (bearerToken == null || bearerToken.length() < 7 || !bearerToken.startsWith("Bearer ")) {
log.warn("인증 실패: 유효하지 않은 Authorization 헤더 유입");
throw new AuthenticationServiceException("유효하지 않거나 누락된 Authorization 토큰 형식입니다.");
}
String token = bearerToken.substring(7);
5.2 Dynamic Destination 구독 파싱 누수: ArrayIndexOutOfBoundsException
@ Destination 파싱 시 항상 엄격한 슬래시(/) 규격이 보장되는 것은 아니다. 클라이언트 개발자의 실수로 경로를 축약해 호출할 경우 문자열 인덱스 범위를 초과하는 에러가 발생해 소켓 인스턴스가 꼬인다.
String roomId = accessor.getDestination().split("/")[2]; // 인덱스 범위를 초과할 때 크래시 유발!
🛡️ 무결성 강화 패치 코드
String destination = accessor.getDestination();
if (destination == null || !destination.startsWith("/")) {
throw new AuthenticationServiceException("잘못된 구독 목적지 정보입니다.");
}
String[] splitDestination = destination.split("/");
if (splitDestination.length < 3) {
throw new AuthenticationServiceException("방 정보 식별 파라미터가 누락된 목적지 규격입니다.");
}
String roomId = splitDestination[2];
5.3 데이터베이스 커넥션 소멸 및 JPA N+1 방어전
ChatService.saveMessage() 및 getChatHistory() 실행 도중 연관 관계 엔티티(Member, ChatRoom)가 JPA 프록시(Proxy) 지연 로딩 모드로 세팅되어 있다면 루프를 도는 과정에서 상상을 초월하는 양의 서브 쿼리가 동시다발적으로 격발되는 JPA N+1 병목 현상이 일어난다.
// 예: ChatParticipant 엔티티 조회 시 member가 지연 로딩(LAZY) 상태라면
// c.getMember()를 호출해 비교하는 순간마다 매번 member 조회 쿼리가 격발됨!
for(ChatParticipant c : chatParticipants) {
if(c.getMember().equals(member)) { ... }
}
🛡️ 성능 튜닝 가이드
- Fetch Join 선언: ChatParticipantRepository 내부 쿼리에 JOIN FETCH 혹은 @EntityGraph 설정을 반드시 걸어 관계 참여자를 가져올 때 연관된 Member 인스턴스 정보까지 단 1회 쿼리 결합으로 일괄 적재해야 전방위적 커넥션 풀 누수를 막을 수 있다.
@Query("SELECT cp FROM ChatParticipant cp JOIN FETCH cp.member WHERE cp.chatRoom = :chatRoom")
List<ChatParticipant> findByChatRoomWithMember(@Param("chatRoom") ChatRoom chatRoom);
6. 결론 (Conclusion)
안전하고 고성능을 유지하는 실시간 채팅 아키텍처는 클라이언트와의 첫 웹소켓 통로인 채널 가로채기(ChannelInterceptor)의 철저한 문지기 역할과 도메인 규칙을 수호하는 서비스 계층의 완벽한 캡슐화가 만났을 때 비로소 완성된다.
StompHandler는 비동기 멀티스레드 기반 STOMP 메시지 브로커가 동작하기 전에 JWT 검증 프로세스를 개입시켜 원천적인 리소스 오염을 차단했다. 한편 ChatService는 1:1 비밀 소통 및 다중 그룹 간의 소통 방 생성 로직을 데이터적으로 완벽히 묶고, 실전 운영상의 가장 까다로운 요구사항인 '실시간 읽음 여부(ReadStatus)' 처리를 정량적 관계 모델로 설계해 냈다.
나아가 다중 서버 분산 환경(Scale-Out)에서 메시지 공유를 위한 Redis Pub/Sub, 대용량 트래픽에 대응하는 Kafka 기반 NoSQL 이원화 적재 정책, 그리고 데이터 복잡성을 $O(1)$ 수준으로 격하시킨 체크포인트 커서 기반 배지 계산 최적화 등 실전 인프라 관점의 깊이 있는 설계안을 유기적으로 연결함으로써 단순 동작 원리를 초월한 초고성능 실시간 메신저 엔진의 근간을 확립할 수 있을 것이다.
'Spring > WebSocket&STOMP' 카테고리의 다른 글
| [Stomp] 다중 서버 웹소켓 환경을 지원할 Redis Pub/Sub 기반 실시간 채팅 서버 구축 가이드 (0) | 2026.06.10 |
|---|---|
| [Stomp] 스프링 부트 STOMP 웹소켓 채팅 서버: 기본 설정부터 메시지 브로커 라우팅까지 (1) | 2026.06.06 |
| [Spring WebSocket] 웹소켓(WebSocket)과 STOMP, 그리고 Redis로 구현하는 실시간 채팅 정리 (1) | 2026.05.31 |