[BE-04] 실시간 전송 계층 (WebSocket/STOMP)

작업 내용 (설계 의도)

변경 사항

근거 TDD: 20260704-채팅시스템고도화-tdd.md (FR-6, 방안 비교 Simple Broker·AFTER_COMMIT 브로드캐스트, STOMP 계약).

실시간 송수신 계층을 신설한다. in-memory Simple Broker, JWT handshake, AFTER_COMMIT 브로드캐스트.

  • build.gradle.kts: spring-boot-starter-websocket 추가.
  • infrastructure/realtime/WebSocketConfig.kt (신규 파일): @EnableWebSocketMessageBroker. /ws endpoint 등록, enableSimpleBroker("/topic","/queue"), setApplicationDestinationPrefixes("/app"). clientInboundChannel core8/max16, clientOutboundChannel core8/max16 명시(TDD 용량 산정). chat.realtime.enabled 플래그로 조건부 등록.
  • infrastructure/realtime/StompAuthChannelInterceptor.kt: CONNECT 시 Authorization: Bearer 파싱을 JwtIssuer(=JwtTokenProvider)로 검증, Principal=userId 세팅. 미인증 CONNECT 거부.
  • domain/message/gateway/MessageBroadcastGateway.kt (신규 interface): broadcast(roomId, message), broadcastTyping(roomId, event), broadcastRead(roomId, event). payload는 data class(BroadcastMessage/TypingEvent/ReadEvent) — raw String/Map 금지.
  • infrastructure/realtime/MessageBroadcastGatewayImpl.kt: SimpMessagingTemplate/topic/rooms/{id}[/typing|/read] 발행.
  • presentation/message/stomp/ChatStompController.kt: @MessageMapping("/rooms/{roomId}/send")SendMessageUseCase, .../typing → 타이핑 브로드캐스트 UseCase. Consumer/EventListener는 presentation 규칙 준수.
  • domain/message/event/MessageSentEvent.kt (신규, AbstractDomainEvent, topic=null): MessageDomainService.sendMessage가 저장 후 DomainEventPublisher.publish. → MessageDomainService.kt에 DomainEventPublisher 주입 + publish 1줄 추가(본 티켓 단독 수정).
  • presentation/message/worker/MessageBroadcastEventWorker.kt: @TransactionalEventListener(AFTER_COMMIT)MessageSentEvent 수신 → MessageBroadcastGateway.broadcast.
  • SecurityConfig.kt: /ws/** handshake 경로 permitAll(인증은 STOMP interceptor가 수행) + /communities/** authenticated() 규칙 등록(컨트롤러는 BE-08이 wave3에 추가, 규칙 선등록은 무해). — 본 티켓만 wave2에서 SecurityConfig 수정. /rooms/** 인증 승격 + 기존 컨트롤러 JWT 전환은 BE-12(wave3)가 담당(다른 wave라 충돌 없음).
  • 인증 통일: STOMP·신규 엔드포인트 모두 Authorization: Bearer <JWT> 기준. WebSocket은 StompAuthChannelInterceptorJwtIssuer로 검증해 Principal=userId 세팅(X-User-Id 미사용).
  • 롤백: chat.realtime.enabled=false로 WebSocket 미등록, REST 발화(POST 경로) 정상 동작.

의존

  • BE-02 (없어도 무방하나 상수 참조 대비)
  • BE-03a/BE-03b는 발화 가드(validateCanSpeak/NotExpired)를 위해 참조 — 발화 검증은 BE-05/06 이후 강화, 본 티켓은 전송 파이프라인 확립

다이어그램

처리 흐름

sequenceDiagram
    participant C as Client
    participant I as StompAuthInterceptor
    participant Ctl as ChatStompController
    participant UC as SendMessageUseCase
    participant Pub as EventPublisher
    participant W as BroadcastEventWorker
    participant G as BroadcastGateway
    C->>I: CONNECT Authorization Bearer
    I->>I: JWT 검증, Principal=userId
    C->>Ctl: SEND /app/rooms/1/send
    Ctl->>UC: execute
    UC-->>Pub: MessageSentEvent
    Pub->>W: AFTER_COMMIT
    W->>G: broadcast(1, payload)
    G-->>C: /topic/rooms/1

테스트 케이스

  • 유효 JWT로 CONNECT하면 세션이 수립되고 Principal=userId가 설정된다.
  • Authorization 헤더 없는 CONNECT는 거부된다.
  • /app/rooms/{id}/send 발화 시 커밋 후 /topic/rooms/{id} 구독자가 메시지를 수신한다.
  • 트랜잭션 롤백 시(저장 실패) 브로드캐스트가 발생하지 않는다 (AFTER_COMMIT).
  • chat.realtime.enabled=false/ws endpoint가 등록되지 않고 REST 경로만 동작한다.
  • 타이핑 SEND는 /topic/rooms/{id}/typing 구독자에게 전달되고 DB에 저장되지 않는다.