전체 흐름 → 구체 처리 → 이유/근거 형식
유저가 채팅방에 입장하면, 서버는 WebSocket 핸드셰이크 단계에서 JWT를 검증하고 사용자 정보를
WebSocketPrincipal로 세션에 매핑합니다. 이후 REST API를 통해 채팅방 멤버로 등록되며, SimpMessagingTemplate을 통해/sub/chat/rooms/{roomId}/members채널로 실시간 멤버 목록이 브로드캐스트됩니다.
클라이언트가
/pub/chat/message로 메시지를 보내면, 서버는 사용자 인증 및 채팅방 참여 여부를 검증한 후 메시지를 DB에 저장합니다. 저장된 메시지는 클라이언트에 WebSocket을 통해/sub/chat/rooms/{roomId}/messages채널로 전달됩니다.
사용자의
ChatParticipant를 비활성화 처리하고, 마지막 참여자라면 채팅방과 메시지를 삭제합니다. 이후 트랜잭션 커밋 후chatMemberEventService를 통해 남은 멤버 목록을 브로드캐스트합니다.
초대 또는 강퇴 시 서버는
broadcastMembers()를 호출하여/sub/chat/rooms/{roomId}/members채널로 실시간으로 멤버 목록을 전송합니다. 클라이언트는 이 채널을 구독하고 있어 자동으로 최신 멤버 정보를 받게 됩니다.
사용자가 메시지를 읽으면
lastReadMessageId가 업데이트되고,ChatReadService에서 메시지 ID 기준으로만 상향 갱신되도록 처리합니다. 이를 통해 읽음 동기화 및 서버 부하 방지를 동시에 고려했습니다.
마지막 사용자가
leaveChatRoom()을 호출하면ChatParticipant가 비활성화되고, 남은 인원이 0명이면 채팅방과 메시지를 삭제합니다. 이 삭제는 트랜잭션 내에서 일어나며, 이후 브로드캐스트로 멤버 정보가 업데이트됩니다.
WebSocket 연결은 세션 유지 및 인증된 사용자 식별을 담당하고, 메시지 전송은 STOMP 프레임을 통해 특정 채널로 전송되는 구조입니다. 연결은
HandshakeInterceptor와 CONNECT 프레임 검증으로 이루어지고, 메시지는 인증된 사용자의 요청만 허용됩니다.
WebSocket 연결 시
HandshakeInterceptor와DefaultHandshakeHandler에서 JWT를 검증하여 사용자 정보를 WebSocketPrincipal로 설정합니다. 이후 STOMP CONNECT 프레임에서도 추가로 토큰을 검증해 보안성을 강화했습니다.
WebSocket을 통해 특정 채널로 메시지를 전송하거나 브로드캐스트하기 위해 사용했습니다. 특히 채팅방 멤버 변경이나 메시지 도착 시 클라이언트에 실시간 반영하기에 적합합니다.
1차는
HandshakeInterceptor에서, 2차는StompCommand.CONNECT프레임에서 토큰을 추출하여JwtUtil로 검증합니다. 두 단계에서 실패하면 연결 자체를 차단합니다.
SockJS fallback의 경우 핸드셰이크 단계에서 JWT를 전달하지 못할 수 있기 때문에 CONNECT 프레임에서도 별도로 토큰을 검증해 보안 이슈를 방지합니다.
메시지를 보내기 전에 서버는 JWT 검증은 물론 해당 사용자가 채팅방의 활성화된 참여자인지도 확인합니다. 조건을 만족하지 않으면
USER_NOT_IN_CHAT_ROOM예외를 던집니다.
채팅은 읽기와 쓰기가 빈번히 분리되는 구조이므로, 명확한 책임 분리와 성능 최적화를 위해 CQRS를 적용했습니다. 유지보수성과 테스트 용이성이 크게 향상되었습니다.
ChatRoomCommandService와ChatRoomQueryService로 나누어, 쓰기 작업은 Command, 조회 작업은 Query에 명확히 분리했습니다. 퍼사드 레이어(ChatRoomServiceFacade)를 통해 외부에 일관된 API를 제공합니다.
TransactionSynchronizationManager.registerSynchronization()을 활용해 트랜잭션 커밋 이후에broadcastMembers()가 실행되도록 했습니다. 트랜잭션이 롤백될 경우 잘못된 데이터가 브로드캐스트되는 것을 방지하기 위함입니다.
도메인 책임 단위로 나누었습니다. 예: 메시지 처리, 읽음 처리, 멤버 관리 등을 각기
ChatMessageService,ChatReadService,ChatMemberEventService등으로 나누어 SRP 원칙을 지켰습니다.
Command/Query로 나뉜 내부 구조를 외부에 하나의 API처럼 제공하고, 트랜잭션 경계를 명확히 설정하는 역할을 합니다. 이로 인해 컨트롤러에서는 구현 세부를 몰라도 됩니다.
ChatMessageService.deleteMessageByRoom()에서 삭제 요청 시 현재 시간과sentAt을 비교하여 5분 이상 지난 메시지는MESSAGE_DELETE_TIME_EXPIRED예외를 던집니다.
ChatValidator.validateParticipant()를 통해USER_NOT_IN_CHAT_ROOM예외가 발생합니다. 이를 통해 무단 접근을 방지합니다.
DIRECT 타입은 참여자 조합을 기준으로 정렬 후, 동일한 참여자 수와 구성의 방이 이미 존재하면 DUPLICATE_DIRECT_ROOM 예외를 발생시켜 중복 생성을 방지합니다.
ChatMessageRepository.countByChatRoomAndIdGreaterThan()을 사용해, 참여자의lastReadMessageId보다 이후에 저장된 메시지 수를 집계합니다.
현재는 RDB 기반 구조지만, 메시지 큐(Redis Pub/Sub, Kafka)나 분산 캐시 도입을 고려하고 있습니다. 또한 메시지 조회에는 페이지네이션을 적용해 서버 부담을 줄였습니다.
네,
enableSimpleBroker()대신enableStompBrokerRelay()로 설정을 변경하면 손쉽게 RabbitMQ나 Redis 기반 브로커로 확장할 수 있습니다. 확장성 고려해 설계해두었습니다.
/pub은 클라이언트가 서버로 메시지를 보낼 때 사용하고,/sub은 서버에서 클라이언트에게 메시지를 브로드캐스트할 때 사용합니다. STOMP 표준 구조에 따랐습니다.
세션에 인증된 사용자 정보를 보존하고, 메시지 처리 시
simpUser로 식별하기 위함입니다. 이렇게 하면 WebSocket에서도Principal기반 권한 처리가 가능합니다.
클라이언트는
/pub/...경로로 STOMP 메시지를 보내고, 이 메시지는 컨트롤러 or 서비스에서 처리되어 DB 저장 및/sub/...채널로 브로드캐스트됩니다.
트랜잭션 이후 실시간 브로드캐스트 시점 조절이 가장 어려웠습니다. DB가 커밋되기 전에 메시지를 보내면 클라이언트와 불일치가 생기므로
afterCommit()을 이용해 시점을 제어했습니다.
- 장점: 역할별 분리가 명확해 유지보수성이 높고, CQRS 및 퍼사드 패턴으로 확장과 테스트가 쉽습니다.
- 단점: 클래스 수가 많아 초기 진입장벽이 높고, 퍼사드 도입 시 오히려 중복 코드가 늘 수 있습니다.
WebSocket 인증 처리 흐름, CQRS 구조 설명, WebSocket 브로커 경로(
/pub,/sub) 사용 방식, 메시지 흐름도(입장전송읽음~나감), 예외 응답 코드 구조 등을 문서화해야 협업 효율이 높아집니다.