채팅 시스템 고도화 TDD (Technical Design Document)

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/20260704-채팅시스템고도화-prd.md

스포츠 앱(backend, Kotlin/Spring Boot, Hexagonal + Rich Domain)의 채팅은 현재 message 도메인 하나로 1:1(DIRECT)·그룹(GROUP) 방 생성과 REST 커서 조회만 제공합니다. 실시간 전송 계층·읽음/타이핑/안읽은 수·커뮤니티(동아리)·게스트 초대·외부 도메인 연동이 모두 없습니다. 본 문서는 이 5개 축을 무중단으로 추가하는 BE 설계를 확정합니다.

Overview

  • 무엇을: ① WebSocket/STOMP 실시간 송수신, ② 읽음 커서·안읽은 수·타이핑, ③ 커뮤니티 신규 도메인 + 전용 그룹 채팅 자동 연동, ④ 게스트 초대(수락/거절/만료/방출), ⑤ Room.contextType/contextId 확장 메커니즘과 커뮤니티·goods 2건 연동.
  • : 폴링 기반 REST의 지연·배터리 문제 해소, 동아리 단위 소통, 임시 참여 통제, 채팅 인프라의 도메인 간 재사용.
  • 어떻게: message 도메인을 additive하게 확장(Room/RoomParticipant 컬럼·신규 엔티티), 실시간 계층은 presentation(STOMP endpoint) + infrastructure(broker·session registry)로 신설, 커뮤니티는 신규 바운디드 컨텍스트(domain/community)로 분리하고 도메인 간 결합은 ID 참조 + 도메인 이벤트로만 연결. 단일 API 서버 + in-memory STOMP simple broker + @Scheduled 만료 배치로 300세션 규모를 충족.

Terminology

용어정의
STOMPSimple Text Oriented Messaging Protocol — WebSocket 위 pub/sub 서브프로토콜
Simple BrokerSpring 내장 in-memory STOMP 브로커. 단일 JVM 내 /topic/** 구독자에 팬아웃
Read Cursor참여자별 “마지막으로 읽은 메시지 id”(lastReadMessageId). 안읽은 수 계산 기준
Unread Count특정 참여자가 안 읽은 메시지 수 = id > lastReadMessageId 이고 내가 보낸 게 아닌 메시지 수
Guest전역 롤이 아닌, 특정 방(및 그 커뮤니티 컨텍스트)에 한시 참여하는 정회원(userId 보유)의 참여 스코프 속성
Context RoomcontextType(COMMUNITY/GOODS_PRODUCT)·contextId로 외부 엔티티에 연결된 방
BackfillWebSocket 재연결 후 끊긴 구간(id > lastReceivedId)의 메시지를 REST로 채우는 보정
Community동아리·모임 신규 도메인. 개설·가입·멤버십·역할(HOST/MEMBER) 보유

Define Problem

AS-IS (실제 코드 근거)

  • 실시간 계층 0건: message presentation/infrastructure에 WebSocket/STOMP 없음. build.gradle.ktsspring-boot-starter-websocket 미포함(security·kafka·data-redis·hypersistence-utils는 존재). 클라이언트는 MessageApiController의 REST 커서 조회(MessageDomainService.kt#listMessages, PAGE_SIZE=30)로 폴링.
  • 읽음/안읽은 수 없음: RoomParticipant.ktroom, userId, joinedAt만 보유. 마지막 읽은 시점·발화권한·만료 필드 없음. Room.kttype/name/lastMessageAt만.
  • 커뮤니티 도메인 없음: domain/ 하위에 community 부재(booking/common/facility/goods/mcp/message/notification/operator/payment/post/ticketing/user/weather).
  • 게스트 없음: 관련 코드 0건. MessageDomainService.kt#joinRoom은 임의 userId를 즉시 참여자로 추가(초대·수락·만료·권한 없음). UserRoleName(USER/ADMIN/FACILITY_OWNER/EVENT_HOST/GOODS_SELLER/OPERATIONS_MANAGER)에 GUEST 롤 없음.
  • Room 외부 연결 필드 없음: Room.kt에 contextType/contextId 부재.
  • 장기연결 부하 선점 상태: MCP 모듈이 동기 SseEmitter SSE(application.yml:20-21 sse-endpoint /mcp/sse)를 사용해 연결 1개당 Tomcat 스레드 1개를 점유하고, 이를 대응해 server.tomcat.threads.max=${MCP_TOMCAT_MAX_THREADS:400}(application.yml:4-11)로 튜닝됨. 신규 WebSocket도 동일 Tomcat 인스턴스를 공유.
  • 인증: JwtAuthenticationFilter.ktAuthorization: Bearer를 파싱해 UserPrincipal(id,email,roles)를 SecurityContext에 세팅. 단 /rooms/**SecurityConfig.kt:72-79에서 permitAll이고 컨트롤러는 X-User-Id 헤더 사용(AUTH-04 TODO). JwtTokenProvider(=JwtIssuer)로 토큰 파싱 재사용 가능.
  • 활용 가능한 기존 인프라: DomainEventPublisher(domain.common) + SpringDomainEventPublisher(in-process, @TransactionalEventListener(AFTER_COMMIT)) + KafkaDomainEventPublisher + RoutingDomainEventPublisher. 크로스 도메인은 presentation ~EventWorker.kt + @TransactionalEventListener(예: BookingRefundEventWorker.kt). 배치는 @Scheduled(예: McpAnomalyScheduler.kt). 알림은 NotificationChannelGateway(domain). soft-delete·audit은 JpaAuditingBase.

TO-BE

  • message 도메인을 additive 확장: Room에 contextType/contextId(nullable), RoomParticipant에 participantType/canSpeak/expiresAt/lastReadMessageId. 신규 엔티티 RoomInvitation.
  • 실시간 계층 신설: presentation /ws(STOMP endpoint) + ChatStompController + infrastructure MessageBroadcastGateway(in-memory simple broker) + WebSocketSessionRegistry. handshake는 JWT 인증.
  • 신규 도메인 domain/community: Community/CommunityMember, HOST/MEMBER 역할, 공개=즉시가입/비공개=승인.
  • 도메인 간 연동은 ID + 이벤트: 커뮤니티 이벤트(Created/MemberJoined/MemberLeft) → presentation EventWorker → message 컨텍스트 룸 UseCase. goods 연동은 message 도메인의 GoodsProductGateway로 판매자 id 조회.

Architecture Benchmarking

제품/사례해결 방식참고할 패턴미참고 사유
당근마켓 채팅 (byline, 바이라인 아키텍처 정리)채팅을 별도 MSA로 분리, DynamoDB + Redis Session Store + 인스턴스 간 gRPC 팬아웃, 거래 채팅을 단일 채팅 인프라로 통일해 여러 도메인 재사용contextType/contextId로 도메인 무관 단일 채팅 인프라 재사용(당근의 “거래 채팅 통일”과 동일 문제의식), ② 세션 레지스트리 개념MSA 분리·DynamoDB·gRPC·다중 인스턴스 팬아웃은 2200만 사용자용. 우리는 단일 인스턴스 300세션이라 in-memory broker로 충분(과한 방안=미채택)
Spring WebSocket STOMP (Spring Docs — External Broker, Scaling WebSockets in Spring)Simple Broker(단일 JVM in-memory) vs External Relay(RabbitMQ/ActiveMQ, 다중 인스턴스 팬아웃·ack/receipt). 메시지 흐름은 clientInboundChannel/clientOutboundChannel/brokerChannel 3채널 스레드풀로 이벤트 구동① in-memory Simple Broker 채택(단일 인스턴스), ② 3-채널 스레드풀 모델로 연결당 스레드 1:1 점유 회피 근거 확보(SSE와 대조)External Relay는 다중 인스턴스에서만 필요. 단일 인스턴스에선 외부 브로커 의존만 늘어 미채택(단순함 우선). Redis 의존성은 있으나 relay로 쓰지 않음
Slack 게스트 (Guest roles)Single-Channel/Multi-Channel Guest 구분, 발화·생성 권한 제한, 기간 후 자동 비활성화① 방 스코프 게스트(Single-Channel에 대응), ② canSpeak 발화권한, ③ expiresAt 자동 만료 방출Multi-Channel Guest·워크스페이스 관리자 콘솔은 범위 밖. 우리는 방 단위 초대만

Possible Solutions

방안 비교 — 실시간 브로커

방안설명왜 채택미채택 대안
A. In-memory Simple Broker (채택)Spring enableSimpleBroker("/topic","/queue"). 단일 JVM 내 /topic/rooms/{roomId} 구독자에 팬아웃. 메시지 처리는 inbound/outbound 채널 스레드풀로 이벤트 구동단일 인스턴스 300세션 목표에 충분. 외부 의존 0. 연결당 Tomcat 스레드 1:1 점유 없음(SSE와 달리) — 스레드풀 소진 위험 낮음
B. External Relay (RabbitMQ/ActiveMQ)STOMP 메시지를 외부 브로커로 relay, 다중 인스턴스 팬아웃·ack/receipt 지원다중 인스턴스 확장·전달보장 시 유리지금 단일 인스턴스엔 과함. 브로커 장애가 새 SPOF. 운영 복잡도↑ → 미채택(Open Question의 다중 인스턴스 전환 시 재검토)
C. Redis Pub/Sub 자체 팬아웃기존 data-redis로 인스턴스 간 브로드캐스트 자작의존성 이미 존재단일 인스턴스에 불필요. STOMP ack/구독 관리를 직접 구현해야 함 → 미채택

방안 비교 — 메시지 브로드캐스트 시점

방안설명왜 채택미채택 대안
AFTER_COMMIT 이벤트 브로드캐스트 (채택)sendMessage가 DB 저장 후 MessageSentEvent 발행 → presentation EventWorker가 @TransactionalEventListener(AFTER_COMMIT)에서 MessageBroadcastGateway.broadcast커밋된(durable) 메시지만 팬아웃 → 롤백 메시지 유령 표시 방지. 기존 이벤트 인프라 재사용STOMP 컨트롤러에서 저장 직후 즉시 브로드캐스트 → 트랜잭션 롤백 시 수신측 불일치 → 미채택

방안 비교 — 커뮤니티↔채팅 연동

방안설명왜 채택미채택 대안
도메인 이벤트 연동 (채택)community 도메인이 CommunityCreated/MemberJoined/MemberLeft 발행 → presentation CommunityChatIntegrationEventWorker → message 컨텍스트 룸 UseCase 호출도메인 패키지 교차 참조 금지 준수. community↔message 결합을 이벤트+ID로 최소화. 자동 연동(FR-4/5) 실현community가 message UseCase 직접 호출 → 도메인 경계 붕괴 → 미채택

방안 비교 — 게스트 만료 처리

방안설명왜 채택미채택 대안
배치 방출 + 매 요청 만료 가드 (채택)@ScheduledexpiresAt <= now 게스트를 주기 방출(soft-delete) + 발화/열람 시 validateNotExpired() 가드배치 지연 구간에도 만료 게스트 발화·열람 즉시 차단. 기존 @Scheduled 패턴 재사용배치만 → 배치 주기(예 1분) 사이 만료 게스트가 발화 가능 → 미채택

Detail Design

시스템 역할 경계 (서버 토폴로지 포함)

단위역할소유 데이터/책임노출 인터페이스의존
API 서버(단일, 기존)REST + WebSocket + 배치를 한 인스턴스에서 처리전 도메인HTTP /rooms,/communities,/ws(STOMP)MySQL/Redis/Kafka(compose)
WebSocket 계층(presentation)STOMP endpoint·handshake JWT 인증·구독·수신 라우팅세션 registry(in-memory)ChatStompController, /wsapplication UseCase, SessionRegistry
MessageBroadcastGateway(domain interface)방 구독자에게 실시간 메시지 팬아웃 추상화broadcast(roomId, payload), broadcastTyping(...)
MessageBroadcastGatewayImpl(infra)Simple Broker로 /topic/rooms/{id} 발행위 구현SimpMessagingTemplate
message 도메인방·참여자·메시지·읽음·게스트·컨텍스트 로직rooms, room_participants, messages, room_invitationsRepository/Gateway interface
community 도메인(신규)커뮤니티 개설·가입·역할·승인communities, community_membersRepository interface, 이벤트
크로스 도메인 EventWorker(presentation)community 이벤트 → message 컨텍스트 룸 UseCase@TransactionalEventListenermessage application
GuestExpiryScheduler(presentation)만료 게스트 주기 방출@Scheduledmessage application

서버 분리/단일 판단 근거: 워커·소켓·스케줄러 서버를 분리하는 후보를 검토했으나 — ① 목표 300세션은 단일 JVM Simple Broker로 충분, ② 만료 배치는 분 단위 저부하, ③ WebSocket은 연결당 스레드 1:1 점유가 아니라(이벤트 구동 채널) Tomcat 스레드 소진 위험 낮음 → 단일 API 서버에 통합. 분리 트리거(다중 인스턴스·세션 수 급증)는 Release Scenario·Open Questions에 이관.

스레드풀 용량 산정 (MCP SSE 공유 전제):

  • Tomcat threads.max=400요청 스레드(REST + 동기 SseEmitter SSE 연결 1:1). MCP SSE N개 = N 스레드 상주.
  • WebSocket 300세션은 Tomcat 요청 스레드를 상주 점유하지 않음 — STOMP 메시지 처리는 clientInboundChannel/clientOutboundChannel 스레드풀(별도, 유한)에서 이벤트 구동. 핸드셰이크 순간만 요청 스레드 잠깐 사용.
  • 설정: clientInboundChannel core 8 / max 16, clientOutboundChannel core 8 / max 16 (300세션 저빈도 텍스트 기준). Tomcat max는 400 유지 → MCP SSE 예상 최대 연결 + REST 버스트를 흡수. WebSocket 세션 증가가 SSE 스레드 예산을 갉지 않음이 핵심.

인터페이스 시그니처 (구현자 계약 — 확정)

// domain/message/repository/RoomRepository.kt (추가)
fun findByContext(contextType: RoomContextType, contextId: Long): Room?   // 컨텍스트 룸 조회
 
// domain/message/repository/RoomParticipantRepository.kt (추가)
fun findExpiredGuestsBefore(threshold: ZonedDateTime): List<RoomParticipant>
fun findActiveByUserId(userId: Long): List<RoomParticipant>
 
// domain/message/repository/MessageCustomRepository.kt (추가)
fun countUnread(roomId: Long, afterMessageId: Long?, excludeUserId: Long): Long
fun findAfter(roomId: Long, afterMessageId: Long, pageSize: Int): List<Message>   // backfill
 
// domain/message/repository/RoomCustomRepository.kt (변경 — 방목록 미리보기 N+1 회피)
// 기존 findMyRoomsByKeyword(userId, keyword): List<Room> 를 projection 반환으로 대체
fun findMyRoomViews(userId: Long, keyword: String?): List<RoomListView>   // 방 + 마지막 메시지 1쿼리 조인
 
// domain/message/vo/RoomListView.kt (신규 projection VO — domain)
// QueryDSL @QueryProjection 대상. content 전문 대신 미리보기 원본을 담고, 잘라내기는 Response에서.
data class RoomListView(
    val roomId: Long,
    val type: RoomType,
    val name: String?,
    val contextType: RoomContextType?,
    val lastMessageContent: String?,   // 마지막 메시지 원문 (없으면 null)
    val lastMessageAt: ZonedDateTime?,
)
 
// domain/message/repository/RoomInvitationRepository.kt (신규)
fun save(invitation: RoomInvitation): RoomInvitation
fun findById(id: Long): RoomInvitation?
fun findPendingBy(roomId: Long, inviteeUserId: Long): RoomInvitation?
fun findPendingByInvitee(inviteeUserId: Long): List<RoomInvitation>   // 초대 수신함
 
// domain/message/gateway/MessageBroadcastGateway.kt (신규)
fun broadcast(roomId: Long, message: BroadcastMessage)
fun broadcastTyping(roomId: Long, event: TypingEvent)
fun broadcastRead(roomId: Long, event: ReadEvent)
 
// domain/message/gateway/GoodsProductGateway.kt (신규, FR-18)
fun findOwnerId(productId: Long): Long   // infra가 goods ProductRepository로 구현
 
// domain/community/repository/CommunityRepository.kt (신규)
fun save(community: Community): Community
fun findById(id: Long): Community?
fun findPublicByKeyword(keyword: String?): List<Community>
fun findByMemberUserId(userId: Long): List<Community>   // 내 커뮤니티
 
// domain/community/repository/CommunityMemberRepository.kt (신규)
fun save(member: CommunityMember): CommunityMember
fun findActiveBy(communityId: Long, userId: Long): CommunityMember?
fun findActiveByCommunityId(communityId: Long): List<CommunityMember>

인증 계약 (확정) — REST·WebSocket 모두 Authorization: Bearer <JWT> 통일. 기존 X-User-Id 헤더(AUTH-04 TODO)는 이번 채팅 고도화 범위에서 제거한다.

  • 신규/변경 컨트롤러는 @AuthenticationPrincipal principal: UserPrincipalprincipal.id(userId)를 얻는다 (X-User-Id 헤더 파라미터 금지). JwtAuthenticationFilter가 이미 Bearer 토큰 → UserPrincipal을 SecurityContext에 세팅함.
  • WebSocket handshake는 STOMP CONNECT 헤더 Authorization: Bearer <JWT>StompAuthChannelInterceptorJwtIssuer로 검증(BE-04).
  • SecurityConfig: /ws/**는 permitAll(STOMP interceptor가 인증), /communities/**authenticated()(BE-04가 규칙 등록), /rooms/**는 permitAll → authenticated()로 승격하고 기존 RoomApiController/MessageApiControllerX-User-Id를 JWT로 전환(BE-12에서 SecurityConfig 승격 + 컨트롤러 전환을 원자적으로 수행해 과도기 노출 방지).

REST API 계약 (신규/변경, presentation) — 전 항목 Authorization: Bearer 필수, @AuthenticationPrincipal UserPrincipal로 userId.

Method · PathRequestResponseFR
POST /communitiesCreateCommunityRequest{name, description, visibility, sportCategory}CommunityResponseFR-1
GET /communities?keyword=List<CommunityResponse> (공개 목록·키워드, 인가 불요)FR-1
GET /communities/{id}CommunityResponse (공개=누구나, 비공개=ACTIVE 멤버만)FR-1
GET /communities/{id}/membersList<CommunityMemberResponse> (ACTIVE 멤버만 — FR-13 ② 멤버십 범위)FR-3/13
GET /communities/meList<CommunityResponse> (내가 ACTIVE 멤버인 커뮤니티)FR-3
POST /communities/{id}/joinCommunityMemberResponse{status: ACTIVE|PENDING_APPROVAL}FR-2
POST /communities/{id}/members/{userId}/approveCommunityMemberResponseFR-2
POST /communities/{id}/members/{userId}/kick204FR-3/5
POST /communities/{id}/host/transfer{newHostUserId}200FR-3
DELETE /communities/{id}/members/me204 (leave)FR-5
POST /rooms/{roomId}/invitationsInviteGuestRequest{inviteeUserId, canSpeak, expiresInDays}InvitationResponseFR-11/13/14
GET /rooms/invitations/meList<InvitationResponse> (내가 받은 PENDING 초대 수신함)FR-12
POST /rooms/invitations/{id}/acceptInvitationResponseFR-12
POST /rooms/invitations/{id}/rejectInvitationResponseFR-12
POST /rooms/{roomId}/guests/{userId}/evict204FR-15
POST /rooms/{roomId}/read{lastReadMessageId}RoomUnreadResponseFR-7/9
GET /rooms/me/unreadList<RoomUnreadResponse{roomId, unreadCount}>FR-9
GET /rooms/me (변경)List<RoomResponse> (미리보기·안읽은 수 필드 확장)FR-9
GET /rooms/{roomId}/messages/backfill?afterMessageId=List<MessageResponse>FR-10
POST /products/{productId}/chatRoomResponse (contextType=GOODS_PRODUCT)FR-18

방목록 미리보기 N+1 회피 (Detail Design): GET /rooms/me(ListMyRoomsUseCase)는 findMyRoomViews를 통해 rooms를 조회하면서 각 방의 마지막 메시지 1건을 단일 쿼리로 조인RoomListView(projection)로 반환한다 (방마다 메시지 재조회하는 N+1 금지). 조인은 QueryDSL 상관 서브쿼리 messages.id = (SELECT MAX(m.id) FROM messages m WHERE m.room_id = rooms.id AND m.deleted_at IS NULL) 또는 last_message_at 기준 조인. lastMessagePreview는 presentation RoomResponse.of에서 lastMessageContent를 최대 50자로 잘라 생성한다(잘라내기 로직은 Response 매핑에 위치, 원문 저장 불변). 안읽은 수는 GET /rooms/me/unread로 분리 제공하고 FE가 방목록과 조합(방목록 쿼리에 unread 집계를 결합하지 않아 쿼리 단순 유지).

STOMP 계약 (WebSocket)

구분DestinationPayload
연결CONNECT /wsheader Authorization: Bearer <JWT> → Principal=userId
발화(send)SEND /app/rooms/{roomId}/send{content}
구독(수신)SUBSCRIBE /topic/rooms/{roomId}BroadcastMessage{messageId,userId,content,createdAt}
타이핑SEND /app/rooms/{roomId}/typingSUBSCRIBE /topic/rooms/{roomId}/typingTypingEvent{userId,typing}
읽음SEND /app/rooms/{roomId}/read/topic/rooms/{roomId}/readReadEvent{userId,lastReadMessageId}

응답 DTO 필드 스키마 (FE-BE 계약 확정 — FE는 이 표에 타입을 맞춘다)

DTO 흐름 Request → Command → Entity/Projection → Response. 모든 Response는 application 레이어(~Response.kt), Controller가 그대로 반환. 시간은 ZonedDateTime(ISO-8601, offset 포함).

RoomResponse (확장, POST/GET /rooms*, POST /products/{id}/chat 공용)

필드타입Nullable설명
idLongN방 id
typeRoomType (DIRECT|GROUP)N기존
nameStringY그룹/컨텍스트 방 이름, DIRECT는 null
contextTypeRoomContextType (COMMUNITY|GOODS_PRODUCT)Y컨텍스트 없으면 null(기존 DIRECT/GROUP)
lastMessagePreviewStringY마지막 메시지 최대 50자. 메시지 없으면 null
lastMessageAtZonedDateTimeY마지막 메시지 시각. 없으면 null

안읽은 수는 RoomResponse에 포함하지 않고 GET /rooms/me/unread(RoomUnreadResponse)로 분리. FE가 roomId로 조합.

CommunityResponse (/communities*)

필드타입Nullable설명
idLongN커뮤니티 id
nameStringN이름
descriptionStringY설명
visibilityCommunityVisibility (PUBLIC|PRIVATE)N공개 여부
sportCategorySportCategoryN종목 카테고리
hostUserIdLongN방장 userId
memberCountIntN활성 멤버 수(조회 시 집계)
roomIdLongY연결된 전용 그룹 방 id(자동 생성 전/실패 시 null)
createdAtZonedDateTimeN개설 시각

CommunityMemberResponse (/communities/{id}/members, join/approve)

필드타입Nullable설명
idLongN멤버십 id
communityIdLongN커뮤니티 id
userIdLongN멤버 userId
roleCommunityRole (HOST|MEMBER)N역할
statusMembershipStatus (ACTIVE|PENDING_APPROVAL|LEFT|KICKED)N상태
joinedAtZonedDateTimeYACTIVE 전이 시각. PENDING은 null

InvitationResponse (/rooms/{id}/invitations, 수신함, accept/reject)

필드타입Nullable설명
idLongN초대 id
roomIdLongN대상 방 id
inviterUserIdLongN초대한 사람(방장)
inviteeUserIdLongN초대받은 사람
statusInvitationStatus (PENDING|ACCEPTED|REJECTED|REVOKED|EXPIRED)N상태
canSpeakBooleanN발화 권한(읽기 전용=false)
expiresAtZonedDateTimeN수락 시 부여될 참여 만료 시각
createdAtZonedDateTimeN초대 시각

RoomUnreadResponse (/rooms/me/unread, POST /rooms/{id}/read)

필드타입Nullable설명
roomIdLongN방 id
unreadCountLongN안읽은 메시지 수(본인 제외, soft-delete 제외)

클래스 역할 정의

도메인 모델

클래스역할핵심 책임
Room(확장)방 애그리거트createForContext(type,contextType,contextId,name) 팩토리, belongsToContext(). 기존 DIRECT/GROUP은 context null
RoomParticipant(확장)참여 스코프forGuest(room,userId,canSpeak,expiresInDays) 팩토리, markReadUpTo(messageId)(forward-only 단조 증가), validateCanSpeak(), validateNotExpired(), evict()
RoomInvitation(신규)게스트 초대 애그리거트create(...), accept()/reject()/revoke()/expire() 상태 전이. terminal 재전이 거부
Community(신규)커뮤니티 애그리거트create(...), isPublic(), transferHostTo(userId). 개설 시 CommunityCreatedEvent 적재
CommunityMember(신규)멤버십join(public 즉시/private PENDING), approve(), kick(), leave(). 상태·역할 전이 캡슐화. Join/Left 이벤트 적재

서비스 클래스 (application UseCase는 오케스트레이션만, 로직은 DomainService/Entity)

클래스역할입력 → 출력의존
ReadCursorDomainService읽음 커서·안읽은 수markRead(roomId,userId,msgId) → RoomParticipantRoomParticipantRepository, MessageRepository
GuestInvitationDomainService초대 수명주기invite/accept/reject/revokeRoomInvitationRepository, RoomParticipantRepository, DomainEventPublisher
GuestEvictionDomainService만료·수동 방출evictExpired(), evict(roomId,userId)RoomParticipantRepository
RoomContextDomainService컨텍스트 룸 provision/join/leaveprovision(type,id,name), joinContext(...), leaveContext(...)RoomRepository, RoomParticipantRepository
CommunityDomainService커뮤니티·멤버십create/join/approve/kick/transfer/leave + 조회 + requireActiveMember(communityId, requesterId)(FR-13 ② 인가 가드)CommunityRepository, CommunityMemberRepository, DomainEventPublisher
MessageBroadcastGateway실시간 팬아웃(interface)broadcast/typing/read
GoodsProductGatewaygoods 판매자 조회(interface)findOwnerId(productId) → userId

실패 경로 · 동시성 · 멱등

관심사설계
메시지 전달 순서방 단위 순서 = 단일 인스턴스 Simple Broker가 /topic/rooms/{id} 수신 순서대로 팬아웃. 클라이언트는 messageId(DB auto-increment) 오름차순 정렬로 최종 정렬 보장
브로드캐스트 durabilitysendMessage 커밋 후 AFTER_COMMIT에서만 팬아웃. 롤백 시 유령 메시지 없음
읽음 커서 경합(멀티 디바이스)markReadUpTo(messageId)forward-only: newId > current일 때만 갱신. last-write-wins + 역행 방지. Non-Goal(정교한 기기 동기화) 준수
발화/열람 권한·만료매 SEND/REST 요청에서 RoomParticipant.validateCanSpeak()(읽기전용 게스트 차단) + validateNotExpired()(만료 즉시 차단) 가드
커뮤니티 멤버십 범위 조회 인가 (FR-13 ②)멤버십 범위 조회(GET /communities/{id}/members, 비공개 커뮤니티 GET /communities/{id} 상세)는 CommunityDomainService.requireActiveMember(communityId, requesterId)요청자가 해당 커뮤니티 ACTIVE 멤버인지 서버 강제. 아니면 NotCommunityMemberException(403). 게스트는 컨텍스트 방(contextType=COMMUNITY) 참여자일 뿐 community_members에 ACTIVE 레코드가 없으므로 findActiveBy(communityId, userId)==null → 거부됨(contextId=communityId로 우회 조회 불가). FE-12 UI 게이팅에 의존하지 않음
게스트 초대 멱등동일 (roomId, inviteeUserId) PENDING 초대 존재 시 신규 생성 대신 기존 반환. eventId 기반 크로스 도메인 중복 소비 방지
커뮤니티 자동 가입 멱등MemberJoinedEvent 소비 시 이미 참여자면 skip(existsByRoomIdAndUserId). 중복 이벤트 정상 처리
WebSocket 연결 끊김클라이언트 지수 백오프 재시도, 3회 실패 시 REST 폴링(FR-10). 서버는 재구독 시 backfill?afterMessageId=로 끊긴 구간 반환(id 기준 dedup은 클라이언트)
handshake 인증 실패JWT 없음/무효 → CONNECT 거부(미인증 세션 미생성)
배치 부분 실패만료 방출 배치는 참여자별 독립 처리, 실패분만 로깅 후 계속. 배치 실패 시 NotificationChannelGateway 알림
브로드캐스트 실패팬아웃 실패는 메시지 저장 성공에 영향 없음(이미 커밋). 로깅 후 클라이언트 backfill로 복구

상태 전이 표

RoomInvitation

현재 × 이벤트다음거부 사유
PENDING × acceptACCEPTED (+ 참여자 추가)
PENDING × rejectREJECTED
PENDING × revoke(host)REVOKED
PENDING × expire(batch)EXPIRED
ACCEPTED/REJECTED/REVOKED/EXPIRED × any(거부)이미 종료된 초대

CommunityMember

현재 × 이벤트다음거부 사유
(없음) × join(public)ACTIVE (+ MemberJoined)
(없음) × join(private)PENDING_APPROVAL
PENDING_APPROVAL × approve(host)ACTIVE (+ MemberJoined)방장 아님이면 거부
ACTIVE × leaveLEFT (+ MemberLeft)HOST는 위임 전 탈퇴 거부
ACTIVE × kick(host)KICKED (+ MemberLeft)대상이 HOST면 거부

Guest RoomParticipant

현재 × 이벤트다음거부 사유
ACTIVE × expire(batch)방출(soft-delete), 읽은 이력 유지
ACTIVE × evict(host, FR-15)방출(soft-delete)호출자 방장 아님
만료됨 × send/read(거부)만료 게스트

Component Diagram

flowchart LR
    subgraph Presentation
        Stomp[ChatStompController]
        RestC[Community/Invitation/ReadApiController]
        Worker[CommunityChatIntegrationEventWorker]
        Sched[GuestExpiryScheduler]
    end
    subgraph Application
        UC[UseCases]
    end
    subgraph Domain
        MsgDS[Message/ReadCursor/Guest/RoomContext DS]
        ComDS[CommunityDomainService]
        BGW[MessageBroadcastGateway]
        GGW[GoodsProductGateway]
    end
    subgraph Infrastructure
        Broker[BroadcastGatewayImpl-SimpleBroker]
        GoodsImpl[GoodsProductGatewayImpl]
        Repo[Repositories-JPA/QueryDSL]
    end
    Stomp --> UC
    RestC --> UC
    Worker --> UC
    Sched --> UC
    UC --> MsgDS
    UC --> ComDS
    MsgDS --> BGW
    MsgDS --> GGW
    Broker -.implements.-> BGW
    GoodsImpl -.implements.-> GGW
    MsgDS --> Repo
    ComDS --> Repo

Sequence Diagram — 실시간 발화 + 브로드캐스트

sequenceDiagram
    participant Client
    participant Stomp as ChatStompController
    participant UC as SendMessageUseCase
    participant DS as MessageDomainService
    participant Pub as DomainEventPublisher
    participant W as MessageBroadcastEventWorker
    participant B as MessageBroadcastGateway
    Client->>Stomp: SEND /app/rooms/{id}/send
    Stomp->>UC: execute(command)
    UC->>DS: sendMessage(roomId,userId,content)
    DS->>DS: validateCanSpeak/NotExpired, save, publish MessageSentEvent
    DS-->>UC: message
    UC-->>Stomp: ack
    Pub->>W: MessageSentEvent (AFTER_COMMIT)
    W->>B: broadcast(roomId, payload)
    B-->>Client: /topic/rooms/{id}

ERD (요약 — DDL 전문은 마이그레이션, 상세는 senior-dba)

erDiagram
    COMMUNITIES ||--o{ COMMUNITY_MEMBERS : has
    ROOMS ||--o{ ROOM_PARTICIPANTS : has
    ROOMS ||--o{ MESSAGES : contains
    ROOMS ||--o{ ROOM_INVITATIONS : has
    ROOMS {
        bigint id PK
        varchar type
        varchar name
        varchar context_type "NULL, COMMUNITY|GOODS_PRODUCT"
        bigint context_id "NULL"
    }
    ROOM_PARTICIPANTS {
        bigint id PK
        bigint room_id
        bigint user_id
        varchar participant_type "MEMBER|GUEST"
        tinyint can_speak
        datetime expires_at "NULL"
        bigint last_read_message_id "NULL"
    }
    ROOM_INVITATIONS {
        bigint id PK
        bigint room_id
        bigint inviter_user_id
        bigint invitee_user_id
        varchar status
        tinyint can_speak
        datetime expires_at
    }
    COMMUNITIES {
        bigint id PK
        varchar name
        varchar visibility "PUBLIC|PRIVATE"
        varchar sport_category
        bigint host_user_id
    }
    COMMUNITY_MEMBERS {
        bigint id PK
        bigint community_id
        bigint user_id
        varchar role "HOST|MEMBER"
        varchar status
    }

테이블 변경 목록 (senior-dba 후속, 무중단 expand):

  • rooms: context_type VARCHAR(30) NULL, context_id BIGINT NULL 추가. 인덱스 idx_rooms_context(context_type, context_id).
  • room_participants: participant_type VARCHAR(20) NOT NULL DEFAULT 'MEMBER', can_speak TINYINT(1) NOT NULL DEFAULT 1, expires_at DATETIME(6) NULL, last_read_message_id BIGINT NULL 추가. 인덱스 idx_rp_expires(participant_type, expires_at, deleted_at).
  • room_invitations(신규), communities(신규), community_members(신규).
  • 규칙 준수: FK 컬럼 금지·ENUM→VARCHAR·BOOLEAN→TINYINT(1)·DATETIME(6)·COMMENT 필수·인덱스 ALGORITHM=INPLACE, LOCK=NONE. NOT NULL 신규 컬럼(participant_type/can_speak)은 DEFAULT로 기존 행 백필.

Testing Plan

레벨대상핵심 시나리오(실패 경로 포함)
domainRoom/RoomParticipant/RoomInvitation/Community/CommunityMember, DomainServiceforward-only 커서, 만료 게스트 발화 거부, 초대 terminal 재전이 거부, private 승인 흐름, HOST 위임 전 탈퇴 거부, 안읽은 수 경계(0건·본인 제외)
applicationUseCaseDomainService 모킹, execute 오케스트레이션·트랜잭션 경계
infrastructureRepository/Gateway (Testcontainers MySQL)findByContext·countUnread·findExpiredGuestsBefore·findAfter(backfill) QueryDSL, GoodsProductGatewayImpl 판매자 조회
presentationChatStompController/EventWorker/Controller/SchedulerSTOMP handshake JWT 인증 거부, MessageSentEvent→broadcast, community 이벤트→자동 가입·퇴장, 배치 만료 방출
scenarioE2E커뮤니티 개설→전용방 자동생성→멤버 가입→자동참여→실시간 발화→읽음/안읽은 수→게스트 초대·수락·만료 방출→goods “채팅하기”
  • 프레임워크 Kotest(BehaviorSpec/DescribeSpec) 강제, JUnit 금지. 통합은 Testcontainers 실 DB(Mock 전용 금지).

Release Scenario — 무중단 배포

배포 순서: 스키마 먼저 → 코드(spring.jpa.hibernate.ddl-auto=validate이므로 컬럼이 코드보다 먼저 존재해야 함).

  1. Phase 0 — 스키마 expand (additive, 코드 무영향): Flyway V38+로 rooms/room_participants 컬럼 추가(모두 nullable 또는 DEFAULT 백필) + room_invitations/communities/community_members 신규. 기존 코드는 새 컬럼을 모름 → 무영향. ALGORITHM=INPLACE, LOCK=NONE로 락 없음.
  2. Phase 1 — 코드 배포(피처 플래그 OFF): WebSocket config·커뮤니티·게스트 엔드포인트를 chat.realtime.enabled=false·chat.community.enabled=false로 배포. /ws STOMP endpoint 미등록, REST 기존 경로 무변경. 기존 DIRECT/GROUP 방은 context null로 정상 동작.
  3. Phase 2 — 점진 활성화: 플래그 ON. WebSocket endpoint 등록, 커뮤니티/게스트 API 노출. 실시간 지표(연결 실패율·전달 지연 P95) 관측.
  4. Phase 3 — 2단계 기능: backfill(FR-10)·수동 방출(FR-15)·goods 연동(FR-18)을 별도 배포.

롤백:

  • 코드 롤백: 플래그 OFF → WebSocket·커뮤니티·게스트 즉시 비활성, REST 채팅은 계속 동작. 추가된 nullable 컬럼·신규 테이블은 구코드가 참조 안 하므로 무해(contract 단계 없음 → drop 불필요).
  • 스키마 롤백(불가피 시): 역방향 DDL로 신규 컬럼/테이블 제거([private-db-schema-convention] 절차). 단 expand-only라 코드 롤백만으로 안전이 기본.

Observability

  • 지표: WebSocket 활성 연결 수(SessionRegistry 크기), 초당 발송 메시지 수, 발신~수신 지연 P95/P99, 읽음 반영 지연, 게스트 만료 배치 성공률, 커뮤니티별 활성 방 수.
  • 알림: WebSocket 연결 실패율 5% 초과, 게스트 만료 배치 실패(NotificationChannelGateway 재사용).
  • 로그: 커뮤니티별 활성 방 수·게스트 초대 수락률 주기 집계.

Open Questions

  • 300세션·P95 500ms는 추정치 — 실측 후 재조정. 다중 인스턴스 필요 시 External Relay(RabbitMQ) 또는 Redis pub/sub 팬아웃으로 전환(설계에 확장점 확보).
  • 게스트 초대 발신 권한: 초안은 방장만(Slack 기본). 일반 멤버 허용 여부 미결.
  • 메시지 무기한 보존의 스토리지 증가 대응 미결.

Document History

날짜변경 내용
2026-07-04최초 작성 — PRD FR-1~18 기반 설계 확정. 실시간(Simple Broker)·커뮤니티 신규 도메인·게스트 확장·contextType 확장 메커니즘. 무중단 expand-contract + 피처 플래그 롤백.
2026-07-04정합 검증 FR-13 ② 보완 — 커뮤니티 멤버십 범위 조회(GET /communities/{id}/members, 비공개 상세)에 CommunityDomainService.requireActiveMember 서버 인가 강제 추가. 게스트=방 스코프 참여자는 community_members ACTIVE 레코드가 없어 contextId 우회 조회 거부(NotCommunityMemberException 403). 실패 경로 표·REST 계약·BE-08 거부 케이스 테스트 반영.
2026-07-04FE 설계 역제안 5건 반영 — ① RoomResponse에 lastMessagePreview/lastMessageAt/contextType 추가 + 방목록 N+1 회피(RoomListView projection·상관 서브쿼리) 명시, ② 커뮤니티 조회 GET 4종(GET /communities·/{id}·/{id}/members·/communities/me) + repo findByMemberUserId 추가, ③ 초대 수신함 GET /rooms/invitations/me + findPendingByInvitee 추가, ④ 인증을 REST·WebSocket 모두 Authorization: Bearer JWT로 통일, X-User-Id 제거(@AuthenticationPrincipal), ⑤ CommunityResponse/CommunityMemberResponse/InvitationResponse/RoomResponse/RoomUnreadResponse 전체 필드·타입 표 확정. 전부 additive.