Skip to content

fix: [ALT-281] 채팅 미비점 보완 — 방 나가기 API·개인 큐 실시간 전파·WebSocket 보안 정리 - #101

Merged
hodoon merged 5 commits into
devfrom
fix/ALT-281
Sep 16, 2026
Merged

hodoon merged 5 commits into
devfrom
fix/ALT-281

Conversation

@hodoon

@hodoon hodoon commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

요약

채팅 미비점 보완 이슈에서 파생된 ALT-281~285 다섯 작업을 하나의 PR로 묶었습니다. 채팅방 나가기 API 추가, 실시간 전파를 방 토픽에서 개인 큐로 전환(Breaking), 그 과정에서 드러난 구독 인가 누락·STOMP principal 토큰 노출·AFTER_COMMIT 리스너 쓰기 유실 결함을 함께 고쳤습니다.

DB 스키마 변경 없음 (마이그레이션 파일 없음). ci.yml에 Redis 서비스 추가.

메시지 응답에 발신자 이름·프로필 이미지를 싣는 작업(ALT-281 항목 1)은 dev 핫픽스 59fa7da1로 먼저 반영돼 이 PR에서는 제외했고, 그 위에 최신 프로필 파일 선택 규칙만 맞췄습니다.

커밋 단위 변경

5ad512d2 [ALT-281] WebSocket Spec 문구 정정

  • UserChatWebSocketControllerSpec / ManagerChatWebSocketControllerSpec의 @Schema·@Operation 문구가 실제 경로와 달라 Swagger를 보고 붙으면 반드시 실패했습니다. /ws → /api/ws-connect(SockJS 미지원), /app/chat/send → /pub/app/send.{chatRoomId}로 정정하고 CONNECT 인증 헤더·Try it out 불가 문구를 추가했습니다.

a9faf3f4 [ALT-282] 채팅방 나가기 API 추가 및 FCM 발송 대상 정리

  • POST /app/chat/rooms/{chatRoomId}/leave · POST /manager/chat/rooms/{chatRoomId}/leave 신설 (LeaveChatRoomUseCase, App·Manager 공용). left_at 소프트 마킹이라 방과 대화는 남고, 이미 나간 방은 멱등 no-op.
  • GROUP(업장 단톡방)은 400 거부, DIRECT만 허용 — 그룹방 멤버십은 업장 소속에 종속돼 자동 동기화되므로 나가도 다음 동기화에 다시 들어옵니다. 검사 순서는 방 조회 → 멤버십 → GROUP 거부 (비멤버가 방의 존재·종류를 구분하지 못하도록).
  • createOrGetChatRoom이 기존 방을 찾으면 호출자 본인 멤버십만 rejoin → 기존 방·대화 내역 그대로 복귀.
  • FCM 발송의 DIRECT 분기가 participant 컬럼 기준이라 나간 사람에게도 푸시가 갔습니다. 활성 멤버 기반 단일 경로로 통합.

b6274b98 [ALT-283] STOMP 구독 인가 추가 및 재진입 시 읽음 포인터 정리

  • 인증만 통과하면 누구나 임의 /sub/chat.{roomId}를 구독해 남의 방 메시지를 받을 수 있었습니다. 구독을 그 방의 활성 멤버로 제한하는 인터셉터를 추가했습니다 (거부는 예외가 아니라 프레임 드롭 — 예외를 던지면 세션이 PROTOCOL_ERROR로 끊겨 정상 구독까지 죽음).
  • 재진입 멤버의 읽음 포인터가 stale하게 남아 상대 화면 unreadCount가 역행했습니다. rejoin 시 방 최신 메시지로 포인터를 당깁니다.

ce6df863 [ALT-284] 멤버십 단절 시 WebSocket 세션 회수 및 채팅 정리

  • 구독 인가는 SUBSCRIBE 시점 1회라 이미 구독 중인 세션은 멤버십이 끊겨도 계속 받았습니다. 멤버십 단절 시 해당 유저의 세션을 닫고, 다중 인스턴스는 Redis 채널로 전파.
  • 선재 결함 수정 — ChatMembershipSyncEventListener(AFTER_COMMIT) 안에서 부르는 join/leave가 클래스 레벨 REQUIRED라 이미 커밋된 트랜잭션에 참여만 하고 실제 커밋되지 않았습니다. 워커가 퇴사해도 chat_room_members.left_at이 DB에 안 남던 상태. 메서드 레벨 REQUIRES_NEW로 수정하고, mock으로는 안 잡히는 결함이라 H2 실 트랜잭션 통합 테스트를 남겼습니다.
  • "최신 프로필 URL" 스니펫 3중복을 FileUrlService.resolveLatestUrl(s)ByTarget 배치 메서드로 통합. dev 핫픽스의 전송 경로가 쓰던 resolveUrlByTarget은 fetchOne()이라 같은 유저에 ATTACHED 프로필이 2건이면 NonUniqueResultException이 나므로 이 메서드로 교체했습니다.

de4d0f22 [ALT-285] 채팅 실시간 전파를 토픽에서 개인 큐로 전환

  • 발송 시점에 활성 멤버만 골라 개인 큐로 보내면 ALT-283/284의 방어 기계장치(구독 인가·세션 회수, 13파일)가 구조적으로 불필요해져 삭제했습니다. 방을 나가면 다음 메시지부터 즉시 끊깁니다.
  • STOMP 유저 이름이 JWT 액세스 토큰이었습니다. 토큰이 목적지 이름이 되어 갱신 때마다 메시지가 유실되고 브로커 키·로그에 토큰이 박히는 구조. TokenScope.principalName으로 통일하고, CONNECT에서 미인증 결과를 거부합니다(이전엔 만료 토큰도 세션 성립).
  • SimpleBroker가 구독 destination을 Ant 패턴으로 취급해 /queue/** 직접 구독이 그 인스턴스의 모든 방 메시지를 받아가는 것을 통합 테스트로 재현하고, 목적지 정확 일치만 통과시키는 가드를 남겼습니다.

프론트 영향 (Breaking 주의)

  • 구독 경로 변경: /sub/chat.{roomId} → /user/queue/chat.messages 한 곳만 구독. 방 구분은 payload의 chatRoomId.
  • 서버·앱 동시 릴리스 필수 (롤백도 동시, 롤링 배포 금지). 구버전 앱은 STOMP ERROR 없이 조용히 무음이라 증상만으로 원인 파악이 안 됩니다.
  • 신규 API: POST /{app,manager}/chat/rooms/{chatRoomId}/leave.

테스트

./gradlew test 전체 통과 — 416건, 실패 0.

  • STOMP 통합 테스트 ChatStompUserQueueDeliveryIntegrationTest(실제 STOMP 클라이언트로 인증 → Redis 왕복 → 개인 큐 배달)는 Redis가 있어야 실행됩니다. 로컬 Redis로 2/2 PASSED 확인. CI에는 Redis 서비스를 붙였으니 첫 실행에서 SKIPPED가 아니라 PASSED로 뜨는지 확인 필요.
  • ChatMembershipSyncAfterCommitIntegrationTest — AFTER_COMMIT 리스너 쓰기 유실 재현·수정 고정
  • LeaveChatRoomTests / CreateOrGetChatRoomTests — 나가기·재진입 정책
  • FileUrlServiceTest — 최신 프로필 파일 선택 규칙

후속 과제

  • dev 환경 PERMIT_ALL_URLS에 /ws-connect/** 추가 (local엔 있고 dev엔 없어 헤더 없는 핸드셰이크가 401)
  • 앱 개발자에게 구독 경로 변경·동시 배포 안내

WebSocket Spec 어노테이션 문구가 실제 경로와 달라 Swagger를 보고 붙으면
반드시 실패했다. /ws → /api/ws-connect(SockJS 미지원), /app/chat/send →
/pub/app/send.{chatRoomId}로 정정하고 CONNECT 인증 헤더·Swagger 호출 불가
문구를 추가했다.

메시지 발신자 이름·프로필 추가는 dev 핫픽스(59fa7da)로 먼저 반영돼
이 커밋에서는 제외했다.
채팅방을 정리할 수단이 없어 1:1 방을 나갈 수 있게 했다. 업장 단톡방은
멤버십이 업장 소속에 종속돼 자동 동기화되므로 나가기를 막는다 — 허용해도
다음 동기화에 다시 들어와 사용자에겐 고장으로 보인다.

- POST /{app,manager}/chat/rooms/{chatRoomId}/leave 신설. left_at 소프트
  마킹이라 방과 대화는 남고, 이미 나간 방은 멱등 no-op
- 검사 순서는 방 조회 → 멤버십 → GROUP 거부. 멤버십을 먼저 봐야 비멤버가
  방의 존재나 종류를 구분할 수 없다
- createOrGetChatRoom이 기존 방을 찾으면 호출자 본인 멤버십만 rejoin해
  기존 방·대화 내역 그대로 복귀. 상대방 멤버십은 건드리지 않는다

FCM 발송 경로의 DIRECT 분기는 멤버십이 아니라 participant 컬럼으로 상대를
찾아 나간 사람에게도 푸시가 갔다. 활성 멤버 기반인 그룹 경로가 DIRECT에도
그대로 맞으므로 분기를 없애고 단일 경로로 합쳤다.
WebSocket 설정이 인증만 하고 구독 목적지를 검사하지 않아, 인증만 통과하면
누구나 임의 roomId의 /sub/chat.{roomId}를 구독해 남의 채팅방 메시지를
실시간으로 받을 수 있었다.

- ChatSubscribeAuthorizationChannelInterceptor 신설. /sub/chat.{roomId}
  구독을 그 방의 활성 멤버로 제한한다
- 거부는 예외가 아니라 프레임 드롭이다. 예외를 던지면 Spring이 세션을
  PROTOCOL_ERROR로 끊어 그 세션의 정상 구독까지 죽고, 클라이언트가 구독을
  재등록하며 재연결 루프에 빠진다. 방 단위 인가 실패는 국소적이므로
  세션은 살린다
- 인터셉터 순서는 인증 → 인가 → presence, 구독 목적지 prefix는 발행측과
  ChatConstants로 공유한다. 한쪽만 바뀌면 인가가 조용히 fail-open 된다

재진입한 멤버의 읽음 포인터가 stale하게 남아, 상대 화면의 unreadCount가
"읽음 → 안읽음"으로 역행했다. rejoin 시 방 최신 메시지로 포인터를 당긴다.
1:1 경로와 그룹방 재가입 경로 양쪽 모두 적용했다. 나가 있던 구간 메시지는
조회에서 계속 보이며, 안 읽음으로만 잡히지 않는다.
ALT-283의 구독 인가는 SUBSCRIBE 시점 1회뿐이라, 이미 구독 중인 세션은
멤버십이 끊긴 뒤에도 브로드캐스트를 계속 받았다. 업장에서 강제로 빠진
워커가 그룹방 메시지를 계속 보는 상태였다.

- 멤버십이 끊기면 그 유저의 WebSocket 세션을 닫는다. 세션 레지스트리는
  2단으로, sessionId->session은 핸드셰이크 데코레이터가, (scope,memberId)
  ->sessionIds는 SessionConnected/Disconnect 이벤트가 채운다. STOMP CONNECT
  에서 인증하므로 raw 세션의 principal로는 유저를 알 수 없다
- 다중 인스턴스는 Redis chat:session-revoke 채널로 전파해 각 인스턴스가
  자기 세션만 닫는다

구현 중 선재 결함이 드러났다. ChatMembershipSyncEventListener는
AFTER_COMMIT 리스너인데 그 안에서 부르는 join/leave가 클래스 레벨 REQUIRED라
이미 커밋된 트랜잭션에 참여만 하고 실제로 커밋되지 않았다. 워커가 퇴사해도
chat_room_members.left_at이 DB에 남지 않고 있었다.

- join/leave에 메서드 레벨 REQUIRES_NEW를 건다. 리스너가 아니라 UseCase에
  걸어야 커밋이 리스너의 try/catch 안에서 끝나고 UnexpectedRollbackException이
  바깥으로 새지 않는다
- 이 계열 결함은 mock 테스트로 안 잡혀 실제 트랜잭션·H2 통합 테스트를 남겼다

함께 정리한 것:
- FCM 발신자 이름 중복 조회 제거(브로드캐스트 payload 재사용), 호출자가
  사라진 sendNotificationOnly 삭제
- "최신 프로필 URL" 스니펫 3중복을 FileUrlService 배치 메서드로 통합
- 멤버십 행이 없는 방의 자가치유, leftAt 차단을 Repository 테스트로 고정
/sub/chat.{roomId} 토픽은 누구나 구독할 수 있어, 구독 인가 인터셉터와
멤버십 단절 시 세션 강제 종료를 계속 덧붙여 막아왔다. 발송 시점에 활성
멤버만 골라 개인 큐로 보내면 그 방어가 구조적으로 필요 없어진다.

- 클라이언트는 /user/queue/chat.messages 한 곳만 구독한다. 방 구분은
  payload의 chatRoomId로 한다 (Breaking: 앱 배포와 동시 릴리스 필요)
- 수신자는 발송 인스턴스가 1회 산정한다. FCM 폴백이 이미 쓰던 활성 멤버
  조회를 재사용하므로 쿼리가 늘지 않는다
- 판정이 구독 시점이 아니라 발송 시점이라 방을 나가면 다음 메시지부터
  즉시 끊긴다. ALT-283/284의 기계장치 13파일을 삭제했다

STOMP 유저 이름이 JWT 액세스 토큰이었다. 그대로 두면 토큰이 목적지 이름이
되어 갱신 때마다 메시지가 유실되고 브로커 키와 로그에 토큰이 박힌다.

- TokenScope.principalName으로 통일하고, 인증 쪽과 수신자 쪽이 같은
  메서드를 쓰게 했다. 갈리면 에러 없이 아무에게도 안 간다
- CONNECT에서 미인증 결과를 거부한다. 이전엔 만료 토큰도 세션이 성립했다

"유저 큐면 구독 인가가 불필요"는 절반만 참이었다. SimpleBroker가 구독
destination을 Ant 패턴으로 취급해 /queue/** 직접 구독이 그 인스턴스의 모든
방 메시지를 받아간다(통합 테스트로 재현). 목적지 정확 일치만 통과시키는
가드를 남겼다.

검증이 mock뿐이던 것도 보완했다. 실제 STOMP 클라이언트로 인증부터 Redis
왕복, 개인 큐 배달까지 태우는 통합 테스트를 추가하고 ci.yml에 Redis
서비스를 붙였다.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 5a5bae85-d19a-4999-8879-03321929d105


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hodoon
hodoon merged commit f6fe9a5 into dev Sep 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant