스포츠 앱(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
용어
정의
STOMP
Simple Text Oriented Messaging Protocol — WebSocket 위 pub/sub 서브프로토콜
Simple Broker
Spring 내장 in-memory STOMP 브로커. 단일 JVM 내 /topic/** 구독자에 팬아웃
Read Cursor
참여자별 “마지막으로 읽은 메시지 id”(lastReadMessageId). 안읽은 수 계산 기준
Unread Count
특정 참여자가 안 읽은 메시지 수 = id > lastReadMessageId 이고 내가 보낸 게 아닌 메시지 수
Guest
전역 롤이 아닌, 특정 방(및 그 커뮤니티 컨텍스트)에 한시 참여하는 정회원(userId 보유)의 참여 스코프 속성
Context Room
contextType(COMMUNITY/GOODS_PRODUCT)·contextId로 외부 엔티티에 연결된 방
Backfill
WebSocket 재연결 후 끊긴 구간(id > lastReceivedId)의 메시지를 REST로 채우는 보정
Community
동아리·모임 신규 도메인. 개설·가입·멤버십·역할(HOST/MEMBER) 보유
Define Problem
AS-IS (실제 코드 근거)
실시간 계층 0건: message presentation/infrastructure에 WebSocket/STOMP 없음. build.gradle.kts에 spring-boot-starter-websocket 미포함(security·kafka·data-redis·hypersistence-utils는 존재). 클라이언트는 MessageApiController의 REST 커서 조회(MessageDomainService.kt#listMessages, PAGE_SIZE=30)로 폴링.
읽음/안읽은 수 없음: RoomParticipant.kt는 room, userId, joinedAt만 보유. 마지막 읽은 시점·발화권한·만료 필드 없음. Room.kt는 type/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 인스턴스를 공유.
신규 도메인 domain/community: Community/CommunityMember, HOST/MEMBER 역할, 공개=즉시가입/비공개=승인.
도메인 간 연동은 ID + 이벤트: 커뮤니티 이벤트(Created/MemberJoined/MemberLeft) → presentation EventWorker → message 컨텍스트 룸 UseCase. goods 연동은 message 도메인의 GoodsProductGateway로 판매자 id 조회.
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로 쓰지 않음
서버 분리/단일 판단 근거: 워커·소켓·스케줄러 서버를 분리하는 후보를 검토했으나 — ① 목표 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): Longfun 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): RoomInvitationfun 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): Communityfun 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): CommunityMemberfun findActiveBy(communityId: Long, userId: Long): CommunityMember?fun findActiveByCommunityId(communityId: Long): List<CommunityMember>
인증 계약 (확정) — REST·WebSocket 모두 Authorization: Bearer <JWT> 통일. 기존 X-User-Id 헤더(AUTH-04 TODO)는 이번 채팅 고도화 범위에서 제거한다.
GET /rooms/{roomId}/messages/backfill?afterMessageId=
—
List<MessageResponse>
FR-10
POST /products/{productId}/chat
—
RoomResponse (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 집계를 결합하지 않아 쿼리 단순 유지).
방 단위 순서 = 단일 인스턴스 Simple Broker가 /topic/rooms/{id} 수신 순서대로 팬아웃. 클라이언트는 messageId(DB auto-increment) 오름차순 정렬로 최종 정렬 보장
브로드캐스트 durability
sendMessage 커밋 후 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로 복구
규칙 준수: FK 컬럼 금지·ENUM→VARCHAR·BOOLEAN→TINYINT(1)·DATETIME(6)·COMMENT 필수·인덱스 ALGORITHM=INPLACE, LOCK=NONE. NOT NULL 신규 컬럼(participant_type/can_speak)은 DEFAULT로 기존 행 백필.
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-04
FE 설계 역제안 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.