feat(conversation): 실시간 subscription — graphql-ws + Redis PubSub - #270
Conversation
figma 알림센터 화면 대응 4/4. 채팅 신규 메시지와 대화 목록/배지 갱신을 실시간 구독으로 제공한다(구매자·판매자 양측, 사용자 확정 정책). 변경점 - 인프라: graphql-ws 전송 + Redis PubSub(graphql-redis-subscriptions, ioredis). 전역 PubSubModule(PUB_SUB 토큰 — cross-cutting 포트라 토큰 주입, spec은 in-memory PubSub 대체). REDIS_URL 미설정 시 redis://localhost:6379 폴백(개발 단계 로컬 DX 우선, README·compose 갱신) - ws 인증: connectionParams.authorization을 upgrade 요청 헤더로 이식하는 buildGraphqlContext로 기존 JwtAuthGuard/passport 경로 재사용(가드 이원화 방지) - Subscription 3종: conversationMessageAdded(대화 소유 구매자/해당 매장 판매자만, 존재 여부 비노출), myConversationUpdated(구매자 목록·배지), sellerConversationUpdated(판매자 목록) - 발행 지점: 구매자 텍스트/FAQ 전송·판매자 답장 서비스에서 저장 후 발행 (트랜잭션 밖 부수효과 — 실패해도 전송은 성공, 구독자는 재조회 폴백). 이벤트 payload는 Redis JSON 왕복을 고려해 날짜를 ISO 문자열로 나른다 - 토픽 조립은 ConversationEventsService 단일 소스(발행자·구독자 공유) 회귀 테스트 - events service 실 Redis(testcontainers) 왕복 2케이스(토픽 격리·payload 보존), subscription service 실DB 4케이스(권한 3종 + 발행 경로 통합 수신), 이벤트 매퍼 2케이스, ws 컨텍스트 헬퍼 3케이스 - 로컬 스모크: 인증 ws 구독 → HTTP mutation → Redis 경유 이벤트 수신 확인
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
🧹 knip — dead-code 리포트전체 리포트
|
🩺 NestJS Doctor — 90/100 (Excellent)진단 302건 (error 0).
architecture / security 상위 항목
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Coverage report
Test suite run success1867 tests passing in 225 suites. Report generated by 🧪jest coverage report action from e028c61 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d01ea4dfbd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const options: RedisOptions = { | ||
| // Redis 미기동 시 부팅을 막지 않고 재시도만 한다(로컬 DX) | ||
| retryStrategy: (times: number) => Math.min(times * 500, 5000), | ||
| maxRetriesPerRequest: null, |
There was a problem hiding this comment.
Bound Redis retries for mutation-side publishes
When Redis is unavailable, maxRetriesPerRequest: null keeps an offline PUBLISH queued indefinitely. Both saveBuyerMessages and sellerSendConversationMessage await these publishes after their database transactions have committed, so a Redis outage causes message mutations to hang or time out despite persisting the message; client retries can then create duplicates. Bound the retries and handle publication failure without failing the already-committed mutation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: maxRetriesPerRequest 2 + enableOfflineQueue false로 빠른 실패, 발행 실패는 events service에서 경고 로그 후 삼킴(커밋된 전송 보호). 실패 spec 추가.
| }); | ||
|
|
||
| const messages = result.messages.map(toConversationMessageOutput); | ||
| await this.publishBuyerSendEvents({ |
There was a problem hiding this comment.
Preserve commit order when publishing conversation events
When two requests send messages to the same conversation concurrently, the repository lock orders their commits but is released before this publication starts. The later request can therefore publish first while the earlier request is performing its post-commit queries; subscribers then receive messages out of order, and the earlier list-update event can arrive last and regress the displayed preview and timestamp. The seller reply path has the same post-commit race, so publication needs ordering tied to the persisted message order, such as a transactional outbox or per-conversation serialization.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 구매자/판매자 목록 이벤트를 발행 시점의 최신 커밋 상태(대화 재조회+최신 메시지)로 조립 — 늦은 발행이 과거 상태로 화면을 되돌리지 않음. 메시지 스트림은 id 기반 정렬 전제(payload에 포함).
Codex 지적 2건 반영. - P1: maxRetriesPerRequest null이면 Redis 장애 시 커밋 완료된 mutation이 발행 대기로 매달려 클라이언트 재시도 → 중복 전송 위험. 재시도를 2회로 제한(enableOfflineQueue false)하고, 발행 실패는 events service에서 경고 로그 후 삼킨다(구독자는 재조회 폴백) — 발행 실패 spec 추가 - P2: 동시 전송에서 잠금 해제 후 발행 순서가 커밋 순서와 어긋나면 늦은 목록 이벤트가 과거 미리보기/시각으로 화면을 되돌릴 수 있다. 목록 이벤트를 "발행 시점의 최신 커밋 상태"(대화 재조회 + 최신 메시지)로 조립해 회귀를 차단 — 메시지 스트림 이벤트는 id를 실어 구독자 정렬
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17884c9b32
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const conversation = await this.repo.findConversationByAccountAndStore({ | ||
| accountId: args.accountId, | ||
| storeId: args.storeId, | ||
| }); |
There was a problem hiding this comment.
Read the list-event snapshot atomically
When send A reads the conversation here before concurrent send B commits, but the subsequent getConversationListExtras runs after B commits, the event combines B's preview/unread count with A's older last_message_at. If A publishes last, clients can regress the displayed timestamp or list ordering despite showing B's message. The fresh evidence after the earlier ordering fix is that the “latest state” requery is split across independent reads; publishSellerReplyEvents has the same split, so derive the timestamp from the same latest-message query or read the complete payload from one consistent snapshot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: getConversationEventSnapshot(단일 트랜잭션 스냅샷)으로 대화·매장명·최신 메시지·안읽음 수를 원자적으로 읽어 이벤트 조립. 구매자·판매자 발행 경로 공용, 독립 조회 제거.
Codex 지적 반영: 발행 시점 재조회가 대화 row·최신 메시지·안읽음 수의 독립 조회로 쪼개져 있어, 경쟁 커밋이 사이에 끼면 "남의 미리보기 + 내 시각" 혼합 상태가 이벤트로 나갈 수 있었다. 대화·매장명·최신 메시지· 안읽음 수를 한 트랜잭션(단일 REPEATABLE READ 스냅샷)에서 읽는 getConversationEventSnapshot으로 통합 — 구매자·판매자 발행 경로 공용. findStoreNameById는 스냅샷에 흡수돼 제거.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3d3e42e87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await this.events.publishMessagesAdded(args.messages); | ||
| await this.events.publishBuyerListUpdate(args.accountId.toString(), { |
There was a problem hiding this comment.
Publish list snapshots in commit order
With concurrent sends A and B for the same conversation, A can capture its snapshot here and stall in publishMessagesAdded, while B commits, captures the newer snapshot, and publishes its list update first; A then publishes its older preview, timestamp, and unread count. Fresh evidence after the prior ordering response is that the atomic snapshot is still captured before the independently awaited publish chain, so the fix prevents mixed snapshots but not stale snapshots arriving last; serialize snapshot-and-publication per conversation or include a monotonic version that clients reject when stale.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
부분 반영: 완전한 커밋 순서 발행은 outbox/시퀀서가 필요해 본 PR 범위와 비례하지 않음. 이벤트가 이미 lastMessageAt(목록)·id(메시지)를 실어 단조 비교로 stale 폐기 가능 — 이 구독자 계약을 SDL docstring에 명시함. 필요 시 별도 이슈로.
| const snapshot = await this.repo.getConversationEventSnapshot( | ||
| args.conversationId, | ||
| ); |
There was a problem hiding this comment.
Isolate snapshot failures after committing the message
If this new post-commit transaction fails—for example because the pool is exhausted or the database connection drops immediately after createBuyerMessages commits—the exception propagates from publishBuyerSendEvents, so the mutation reports failure even though the message was persisted. Unlike Redis publication failures, this snapshot read is outside safePublish; a client retry can therefore create duplicate messages, so the entire post-commit event-building path must be failure-isolated or moved to an outbox.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 스냅샷 조회 포함 커밋 후 발행 경로 전체를 try/catch 격리(구매자·판매자 공통), 실패는 경고 로그.
Codex 지적 2건 대응. - P1(반영): 스냅샷 조회를 포함한 커밋 후 발행 경로 전체를 try/catch로 격리(구매자·판매자) — DB 순단이 이미 저장된 전송을 실패로 둔갑시켜 재시도 중복을 만들지 않도록. 실패는 경고 로그만 - P2(부분 반영): 완전한 커밋 순서 발행은 outbox 패턴이 필요해 범위와 비례하지 않음. 이벤트에 이미 실린 lastMessageAt(목록)·id(메시지)로 구독자가 stale 이벤트를 폐기하는 계약을 SDL에 명시
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2c5e13f5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| storeName, | ||
| lastMessagePreview: preview, | ||
| lastMessageAt: lastMessageAtIso, | ||
| unreadCount: snapshot?.unreadCount ?? 0, |
There was a problem hiding this comment.
Version unread updates against read-state changes
When a seller reply snapshots a nonzero unreadCount, publication can be delayed while listBuyerMessagesAndMarkRead advances last_read_at; this stale event then arrives after the read query and makes the badge reappear. The documented lastMessageAt comparison cannot reject it because marking a conversation read does not change last_message_at, and the read path publishes no corrective event. Include a monotonic read-state version or publish/serialize read transitions so clients can discard this snapshot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 이벤트에 스냅샷 시점 lastReadAt 추가, 구독자 폐기 규칙을 (lastMessageAt, lastReadAt) 사전식 비교로 확장(SDL 계약 명시). 읽음 처리가 lastReadAt을 전진시키므로 지연 이벤트의 stale 배지 부활이 걸러짐.
| await this.events.publishBuyerListUpdate(args.accountId.toString(), { | ||
| conversationId: args.conversationId.toString(), | ||
| storeId: args.storeId.toString(), | ||
| storeName: args.storeName, |
There was a problem hiding this comment.
Use the snapshotted store name in buyer updates
If the seller renames the store after sendConversationMessage performs its initial store lookup but before this post-commit snapshot, the event combines the latest message state with the old args.storeName. Since this event carries the newest lastMessageAt, the buyer can retain the stale name until a full refetch. The snapshot already selects conversation.store.store_name, so the event should use that value when available.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 구매자 이벤트 매장명을 스냅샷 값 우선으로 변경(폴백만 초기 조회값).
…리뷰 반영) Codex 지적 2건 반영. - P2: 읽음 처리는 last_message_at을 바꾸지 않아, 읽음 이후 도착한 지연 이벤트의 stale unreadCount를 lastMessageAt 비교로 걸러낼 수 없었다. 이벤트에 스냅샷 시점 lastReadAt을 추가하고 구독자 폐기 규칙을 (lastMessageAt, lastReadAt) 사전식 비교로 확장(SDL 계약 갱신) — 읽음은 lastReadAt을 전진시키므로 stale 배지 부활이 걸러진다 - P2: 구매자 이벤트 매장명도 스냅샷 값 우선 — 최초 조회 후 개명 시 최신 상태에 옛 이름이 섞여 나가던 문제
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e028c61239
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 (lastMessageAt, lastReadAt)을 | ||
| 사전식으로 비교해 이미 표시 중인 상태보다 오래된 이벤트를 폐기해야 한다. |
There was a problem hiding this comment.
Add a tie-breaker to list-event versions
When two sends for the same conversation are committed within one millisecond, both can have identical lastMessageAt values because new Date() and StoreConversation.last_message_at use millisecond precision (prisma/schema.prisma:1373); buyer sends can also assign the same lastReadAt. If their list events arrive out of order, the documented tuple is therefore identical for the old and new previews, so the subscriber cannot discard the stale event. This is fresh evidence for the ordering case: the timestamp-based mitigation is not strictly monotonic. Include the latest message ID or another sequence in both list-update payloads and comparison rules.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
미반영(한도 내 수용): PR #269의 ms 동률 엣지와 동일 계열 — DateTime(3) 동일 밀리초에 커밋된 두 전송의 목록 이벤트가 역순 도착하는 극소 확률 케이스로, 영향은 미리보기 한 건의 일시적 흔들림이며 다음 이벤트/재조회로 자가 수복됨. 엄밀한 해소는 이벤트 시퀀스 채번(outbox/버전 컬럼)이 필요해 범위와 비례하지 않음 — 필요 시 별도 이슈로. 라운드 상한 도달로 머지 진행.
배경
figma 알림센터 화면 대응 4/4. 채팅 신규 메시지와 대화 목록/배지 갱신을 실시간 구독으로 제공한다(구매자·판매자 양측).
변경점
PubSubModule(PUB_SUB토큰 — cross-cutting 포트라 토큰 주입, spec은 in-memory PubSub 대체).REDIS_URL미설정 시redis://localhost:6379폴백(개발 단계 로컬 DX 우선), docker-compose에 redis 서비스·README 키 추가.connectionParams.authorization을 upgrade 요청 헤더로 이식하는buildGraphqlContext로 기존 JwtAuthGuard/passport 경로 재사용(가드 이원화 방지).conversationMessageAdded(conversationId)— 대화 소유 구매자/해당 매장 판매자만(존재 여부 비노출)myConversationUpdated— 구매자 목록·배지 갱신(storeName·preview·unreadCount)sellerConversationUpdated— 판매자 목록 갱신ConversationEventsService단일 소스.테스트
yarn validate전체 통과