Skip to content

SSE 재연결 시 놓친 알림 replay (Last-Event-ID 기반) - #751

Closed
m-a-king wants to merge 1 commit into
devfrom
feat/750-sse-last-event-id-replay
Closed

SSE 재연결 시 놓친 알림 replay (Last-Event-ID 기반)#751
m-a-king wants to merge 1 commit into
devfrom
feat/750-sse-last-event-id-replay

Conversation

@m-a-king

Copy link
Copy Markdown
Collaborator

Situation

  • SSE 알림 스트림의 이벤트에 id(seq)가 전혀 없어(name + data 만) 클라이언트가 재연결할 때 끊김 동안 발생한 알림을 스트림 차원에서 복구할 수단이 없었다.
  • 연결이 30분 타임아웃으로 주기적으로 강제 재연결되는 구조라 그 틈의 알림 유실이 구조적으로 일어난다. 지금까지는 "재연결 시 목록/배지 API 재조회"가 명문화된 유일한 복구 경로였다 (notification-sse-spec.md).

Task

  • SSE 이벤트에 순번(id)을 부여하고, 재연결 시 그 순번 기준으로 놓친 알림을 다시 흘려보내는 계약을 설계·구현한다.
  • 결정 포인트: 순번의 원천(별도 카운터 vs 알림 PK), id 를 어느 이벤트에 붙일지, 유실과 중복 중 무엇을 감수할지, 긴 공백의 처리.

Action

설계 결정

결정 선택 이유
순번 원천 알림 PK (별도 카운터 없음) DB 영속 값이라 재시작·blue-green·스케일아웃과 무관하게 단조 증가. payload 의 id 와 같은 값이라 클라 dedup 키를 겸한다
id 부여 대상 notification 이벤트만 SSE 프로토콜상 id 없는 이벤트는 클라 lastEventId 를 갱신하지 않는다. 비영속이라 replay 불가능한 connect·silent-sync·하트비트가 복구 기준점을 오염시키지 않게 자연 제외된다
등록·replay 순서 연결 등록 후 replay 조회 반대 순서면 조회와 등록 사이 발행분이 유실된다. 이 순서의 대가는 라이브·replay 중복 도착이고, 클라가 id 로 dedup 하는 계약으로 흡수한다 (유실보다 중복)
상한 초과 공백 replay 통째 생략 (상한 100건) 일부만 보내면 replay 구간 뒤에 조용한 구멍이 남는다. 상한을 넘는 공백은 장기 미접속이라 기존 목록/배지 재조회가 맞는 복구 경로다
잘못된 Last-Event-ID 첫 연결로 취급 (400 아님) SSE 에서 id 재개는 서버가 지원하지 않을 수도 있는 optional 계약이라, EventSource 자동 재연결 루프를 400 으로 깨지 않는다

구현

  • LocalSseDelivery 가 notification 이벤트 빌드를 한 곳(notificationEvent)에 모아 SSE id 필드에 알림 PK 를 싣고, 라이브(deliver)와 replay(replayTo)가 같은 셰입을 공유한다.
  • SseReconnectReplayer(신규)가 Last-Event-ID 초과분을 발생 순서(id asc)로 조회해 재연결한 그 연결에만 흘려보낸다. 조회는 리포지토리의 짧은 트랜잭션으로 끝나고 emitter write 는 트랜잭션 밖이다.
  • subscribeLast-Event-ID 헤더를 optional 로 받는다. 숫자가 아니거나 양수가 아니면 무시하고 첫 연결로 취급한다.
  • replay 조회는 기존 idx_notifications_user_id_id (user_id, id) 인덱스가 그대로 받친다. 마이그레이션 없음.
  • replay 실행 건수·상한 초과 생략을 info 로그로 남겨 상한 적정성을 추세로 볼 수 있게 했다.

계약 문서

  • notification-sse-spec.md: id 필드, Last-Event-ID replay, 상한, 중복 dedup, 희귀 race 한계(PK 발급 순서와 커밋 순서 어긋남)를 명문화. WEB 은 EventSource 가 자동으로 처리하고, APP 은 마지막 id 저장·전송 예시를 추가했다.
  • NotificationSseApi: Last-Event-ID 헤더 파라미터와 replay 계약을 OpenAPI 설명에 반영했다.

Result

  • 30분 타임아웃 재연결 같은 짧은 끊김의 알림 유실이 스트림 차원에서 메워진다. 웹(EventSource)은 클라 코드 변경 없이 동작하고, 앱은 마지막 id 저장·전송만 추가하면 된다.
  • additive 변경이라 서버 단독 선배포가 가능하다. 헤더를 보내지 않는 기존 클라이언트는 지금과 동일하게 동작한다.
  • 검증: 통합 테스트가 실제 와이어(text/event-stream 응답 버퍼)를 단언한다. replay 발생 순서, Last-Event-ID 그 자체는 재전송 안 됨, 상한 초과(101건) 시 통째 생략, 비숫자 헤더 무시, silent-sync 에 id 없음까지 5개 분기.
  • 남는 한계: PK 발급 순서와 커밋 순서가 어긋나는 희귀 race 는 replay 가 놓칠 수 있다. 기존 재조회 fallback 이 커버하며 스펙에 명시했다.

연관 이슈

- notification 이벤트에만 SSE id(알림 PK)를 싣는다. 별도 카운터 대신 DB 영속 값을 써 재시작·blue-green 과 무관하게 단조 증가하고, payload 의 id 와 같은 값이라 클라 dedup 키를 겸한다
- id 없는 이벤트(connect·silent-sync·하트비트)는 SSE 프로토콜상 클라 lastEventId 를 갱신하지 않으므로, 비영속이라 replay 불가능한 이벤트가 복구 기준점에서 자연 제외된다
- subscribe 가 Last-Event-ID 헤더를 받아 초과분을 발생 순서(id asc)로 그 연결에만 replay 한다. register 후 replay 순서로 "유실 대신 중복"을 택했고, 클라가 id 로 dedup 하는 계약을 스펙에 명시
- 상한(100건) 초과 공백은 일부만 보내면 replay 구간 뒤에 조용한 구멍이 남으므로 통째로 생략하고 기존 목록/배지 재조회 계약에 맡긴다
- 숫자가 아닌 Last-Event-ID 는 400 으로 끊지 않고 첫 연결로 취급한다 — EventSource 자동 재연결 루프를 깨지 않기 위함
- replay 조회는 기존 idx_notifications_user_id_id (user_id, id) 인덱스가 그대로 받쳐 마이그레이션이 없다
@m-a-king m-a-king added the feat 외부 가시적 새 기능 label Jul 15, 2026
@m-a-king m-a-king self-assigned this Jul 15, 2026
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@m-a-king

Copy link
Copy Markdown
Collaborator Author

설계를 통합 이벤트 로그(Redis Stream) 방식으로 전환하며 새 PR 로 대체합니다.

이 PR 의 replay 는 id 를 알림 PK 에 앵커해 notification 이벤트만 복구합니다. 그런데 이 작업의 실제 동인은 아이템 등록 화면의 silent-sync(TOURNAMENT_ITEM_PARSED) 유실이라, 비영속 이벤트가 복구 대상에서 빠지는 이 설계로는 목적이 닫히지 않습니다. 스트림의 절반만 신뢰 가능한 비대칭과 FE 재조회 계약 병행 부담도 남습니다.

후속 PR 에서 모든 SSE 데이터 이벤트(notification·silent-sync)를 유저별 Redis Stream 에 적재하고 Last-Event-ID 를 stream entry id 로 통일해 균일하게 replay 합니다. 이슈 #750 본문을 새 설계로 갱신했습니다.

@m-a-king m-a-king closed this Jul 17, 2026
@m-a-king
m-a-king deleted the feat/750-sse-last-event-id-replay branch July 17, 2026 04:55
m-a-king added a commit that referenced this pull request Jul 17, 2026
- 유저별 Redis Stream(sse:events:{userId})을 SSE 이벤트 로그로 두고 notification·silent-sync 를 emit 시점에 적재한다. stream entry id 를 SSE id 필드에 실어, 재연결 시 Last-Event-ID 초과분을 종류 구분 없이 원본 그대로 replay 한다
- 1차 설계(알림 PK 앵커, PR #751 닫음)는 비영속 silent-sync 가 복구 대상에서 빠져 이 작업의 동인(아이템 등록 화면 파싱 동기화 유실)을 못 닫았다. SSE 의 lastEventId 는 스트림당 스칼라 하나라 두 id 공간을 섞을 수 없어 통합 이벤트 로그로 전환
- 저장소를 인메모리가 아닌 Redis 에 둔 이유: blue-green 배포 전환이 곧 전 연결 동시 재연결 시점인데 인메모리 버퍼는 그 순간 비어 있고, 스케일아웃 시 유저별 단조 id 채번도 중앙 저장소가 필요하다
- payload 를 한 번 직렬화(JSON 문자열)해 live 전송·로그·replay 가 같은 바이트를 공유한다. Redis 객체 저장 규약대로 직렬화 호환성 테스트(payload 스냅샷 + 스트림 필드 키 고정)를 함께 둔다
- 상한 초과 공백은 replay 통째 생략(부분 replay 는 뒤에 조용한 구멍을 남긴다). MAX_LEN(200) > REPLAY_LIMIT(100) 관계로 trim 이 만든 구멍을 연속인 척 replay 하는 경우를 배제하고, 관계 자체를 테스트로 고정
- 적재 실패는 id 없이 live 전송만 하는 degrade — id 없는 이벤트는 클라 lastEventId 를 갱신하지 않아 복구 기준점을 오염시키지 않는다
- 스냅샷 실측 중 발견: createdAt 은 JacksonConfig 의 KST 변환(+09:00 오프셋)으로 나가는데 스펙 문서는 "오프셋 없음"으로 낡아 있었다. 실측대로 정정
- 파일별로 중복이던 recording emitter 4개를 support/RecordingSseEmitter 로 통합(#578 의 공유 test double 항목). 와이어 JSON 트리 단언으로 바뀌어 직렬화 회귀도 함께 잡는다
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 외부 가시적 새 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE 이벤트 통합 replay - Redis Stream 기반 Last-Event-ID 복구

1 participant