SSE 양방향 하트비트와 연결 번호로 죽은 연결을 서버가 먼저 닫는다 - #1058
Open
m-a-king wants to merge 9 commits into
Open
Conversation
- 서버 ping 을 주석(: ping)에서 event: heartbeat / data: <connectionId> 로 바꾼다. 주석은 표준 EventSource 에 노출되지 않아 클라이언트가 스트림 생존을 관측할 수 없었다. connect 이벤트 data 도 "connected" 대신 같은 연결 번호를 내린다 - 연결마다 UUID 를 부여하는 SseConnection 을 두고 레지스트리가 번호 색인을 함께 든다. POST /api/v1/notifications/heartbeat 가 그 번호로 lastSeen 을 갱신하고, 모르는 번호(배포로 서버가 바뀌었거나 이미 정리됨)나 남의 번호는 409 NOTIFICATION-002 로 수렴시켜 클라이언트가 재연결하게 한다 - 결측 정리는 새 스케줄러 없이 기존 30초 ping 순회(SsePingScheduler 로 개명, 방향을 이름에 박음)에 얹는다. 60초 넘게 하트비트가 없으면 unregister + complete 로 정상 종료하고 INFO 로 남긴다. 이 로그 건수가 서버 에러 알림 빈도와 함께 #1057 의 효과 판정 지표다 - 임계값은 클라이언트 주기(30초)의 1.5배가 아니라 2배로 둔다. 클라이언트 오탐 재연결은 FIN 을 서버에 먼저 보내 #1029 의 ERROR 디스패치 경합을 만드는 경로라 넉넉히 두는 쪽을 택했다 - 결측 정리는 sse.client-heartbeat.eviction-enabled(기본 false) 뒤에 둔다. 클라이언트가 하트비트를 보내기 전에 켜면 모든 연결이 60초마다 끊기므로, 서버가 먼저 나가는 배포 순서를 플래그가 흡수한다 - 응답은 규약대로 204 가 아니라 200 + ApiResponseBody.ok() 다 - 통합 테스트는 구독 응답의 connect 이벤트에서 번호를 읽어 하트비트로 되돌리는 클라이언트 흐름을 그대로 밟고, 400 detail 은 실측으로 example 과 맞춘다
- connect·heartbeat 의 data 가 연결 번호임과 60초 결측 시 클라이언트 재연결 규칙, POST /heartbeat 의 조건(연결 중·포그라운드·30초)과 409 시 즉시 재연결을 4-1 절로 추가 - WEB·APP 예시 코드와 체크리스트를 새 계약으로 갱신
|
Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Advanced Run ID: 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 |
- 레지스트리 register 가 computeIfAbsent 뒤 밖에서 add 하던 것을 compute 하나로 묶는다. 그 사이 unregister 가 빈 리스트를 키째 지우면 새 연결이 맵에서 떨어진 리스트에 붙어 알림·ping 을 못 받는데, 번호 색인엔 남아 하트비트가 200 을 돌려주고 종료 시 색인에서 영영 안 빠지는 누수였다. 이 PR 전부터 있던 경합이 번호 색인 때문에 누수로 커진 것 - 결측 판정은 하트비트를 한 번이라도 보낸 연결(heartbeatSeen)에만 건다. 앱스토어 롤아웃 기간에 구 버전 클라이언트가 섞여 있는데 그 연결까지 끊으면 60초마다 재연결하는 루프가 된다. 플래그는 관측 스위치로 남긴다 - connect 전송 실패 경로의 completeWithError 를 complete 로 바꾼다. #1024 가 고친 것과 같은 ERROR 디스패치 경로가 이 자리에만 남아 있었다 - stale-after 를 초 단위로 읽고 ping 주기(30초) 이하면 부팅에 실패시킨다. 단위 없는 숫자가 밀리초로 읽히거나 주기 이하로 잡히면 매 순회마다 전 연결이 끊기는 루프라 설정 단계에서 막는다 - 하트비트 POST 를 접근 로그에서 제외한다. 연결마다 30초에 한 번 오는 무내용 요청이라 로그가 연결 수에 비례해 쌓인다 - 리뷰가 제안한 "write 성공 시 lastSeen 갱신" 은 채택하지 않았다. write 성공을 생존 신호로 되돌리는 것이라 이 작업의 전제와 어긋난다. 토큰(15분)이 SSE(30분)보다 짧아 연결 중 401 을 한 번 만나는 문제는 다른 API 와 같은 토큰 갱신으로 풀고 문서에 명시했다
- 클라이언트 하트비트의 401 은 다른 API 와 같은 토큰 갱신 대상임을 명시(연결 30분 > 토큰 15분) - 결측 판정이 하트비트를 보낸 연결에만 걸리고 실제 종료가 60초에서 90초 사이임을 적음 - WEB 예시의 미사용 변수와 미정의 reconnect 를 정리하고 서버 ping 결측 검사를 넣음
- 연결 상태를 nullable 최근 시각 하나로 접는다. 구독 시각 시드와 heartbeatSeen 불리언은 어느 판정에도 쓰이지 않는 파생 상태였고, 그걸 나르느라 register 에 시계 파라미터가 붙어 있었다. "하트비트를 안 보낸 연결은 판정 대상 아님" 이 불리언 가드가 아니라 타입(null)으로 표현된다 - 레지스트리 API 를 SseConnection 키로 통일한다(unregister(connection)·connectionsOf·removeAll/removeStale 이 연결을 돌려줌). 번호 색인(connectionsById)은 뺀다 - 하트비트는 인증된 userId 를 이미 갖고 오므로 유저 파티션 안 선형 탐색이 곧 O(1) 이고, 남의 번호 검사가 런타임 분기 대신 자료구조로 대체되며, 두 맵을 맞추던 코드와 그 사이 창이 사라진다. 컨트롤러는 등록을 먼저 하고 콜백이 연결 객체를 잡는다(리턴 전엔 async 가 시작되지 않아 창이 없다) - "레지스트리에서 빼고 조용히 complete" 가 세 벌(sendOrEvict·evictStale·closeAll)이던 것을 complete 헬퍼 하나로 모아 #1024 불변식(completeWithError 금지)이 한 자리에만 있게 한다. 컨트롤러의 세 종료 경로도 지역 람다 하나로 합친다 - 결측 판정과 제거를 registry.removeStale 한 순회로 끝내고 건수를 돌려준다(closeAll 의 removeAll 과 같은 모양). evictStale 이 판정 도중 unregister 를 부르던 형태가 없어진다 - tick 은 ping 을 먼저 보낸다. 결측 정리의 complete 가 느린 소켓에 막히면 전 연결의 keep-alive 예산(nginx 60초 - 주기 30초)을 갉아먹는다 - 임계값 > ping 주기 불변식을 프로퍼티에서 스케줄러 init 으로 옮긴다. 둘을 다 아는 쪽이 검사하고 프로퍼티는 소비자 상수를 모른다 - 하트비트 POST 를 관측(span·http.server.requests 시리즈)에서 actuator 와 같이 뺀다. 연결마다 30초에 한 번 오는 인메모리 갱신이 앱에서 가장 잦은 요청이 되는데 무료 한도가 이미 빠듯하다 - 요청 DTO 의 connectionId 를 non-null 로 바꾼다. 필수 필드 하나인 다른 요청(TokenRefreshRequest 등)과 같은 결이고, 누락·형식 오류가 같은 역직렬화 400 으로 수렴해 문서의 400 한 줄과 example 이 실제와 일치한다. 친화 문구 상수·orThrow·전용 테스트가 사라진다 - 프로퍼티 prefix 를 notification.sse.client-heartbeat 로 내려 알림 설정이 한 루트 아래 모이게 한다(env 이름은 그대로) - deliver·deliverSilentSync 가 연결을 직접 순회해 호출마다 emitter 리스트를 새로 만들던 할당이 없어진다. 테스트 정리는 removeAll 로 통일 - 채택하지 않은 제안: 409 NOTIFICATION-002 를 200 무시로 바꾸는 것(클라이언트와 합의된 계약이라 별도 결정), AccessLogFilter 가 컨트롤러 상수를 참조하는 것(common 이 도메인 패키지를 알게 됨), 정리 스위치 기본 true(관측 순서 유지)
- 코드로 드러나는 사실을 다시 적은 주석과 이슈 서사를 지우고, 코드만 봐서는 알 수 없는 것만 남긴다: completeWithError 금지 사유(#1024), register 를 compute 하나로 묶는 이유, null 최근 시각의 뜻, 임계값 2배와 ping 우선 순서의 근거, 관측·접근 로그 제외 사유
- 433줄에서 245줄로. kind 와 type 의 관계, 좌표 유무 규칙처럼 네 곳에 반복되던 설명을 한 곳으로 모으고, 변경 이력·내부 근거 서술을 걷어낸다 - 계약 자체(엔드포인트·이벤트 4종·클라이언트 하트비트·payload·type 표·수명·체크리스트)와 수치(30초·60초·30분·15분·409 code)는 그대로다 - 문서와 코드가 어긋나 있던 두 곳을 맞춘다: 위시 출처 파싱 알림도 wishId 를 싣는다(#933), ITEM_PARSING_INCOMPLETE 를 type 표에 넣는다
- 문서는 읽는 계약이지 작업 목록이 아니라 체크박스가 어울리지 않는다 - WEB 예시의 위시 출처 분기를 본문(wishId 로 위시 상세, null 이면 /archive/wish)과 맞춘다
m-a-king
force-pushed
the
feat/1057-sse-bidirectional-heartbeat
branch
from
September 8, 2026 09:54
1584424 to
4e1f629
Compare
- attribution.commit·pr 을 비워 둬도 sessionUrl(기본 true)이 별개로 Cloud·Remote Control 세션 링크를 붙였다. 최근 7일 머지 커밋 31건·PR 14건에 세션 URL 이 남은 원인
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Situation
/errorERROR 디스패치였고, Spring 은 async 결과를 한 번만 받으므로 하트비트 write 실패 쪽이 먼저 결과를 설정하면 onError 의 complete() 는 무력화된다. 즉 "누가 complete 를 부르나" 로는 경합을 못 이긴다.Task
Action
계약 (client 팀과 합의)
event: heartbeat/data: <connectionId>POST /api/v1/notifications/heartbeat{connectionId}connect와heartbeat이벤트 data 로 내린다. 클라이언트가 POST 에 되돌려 보내므로 유저가 아니라 연결 단위로 생존을 판정한다(탭·기기 여럿이어도 어느 연결인지 안다).하트비트 응답
ApiResponseBody.ok()다. client 팀에 204 로 먼저 말했던 것은 정정이 필요하다.결측 정리 설계
sse.client-heartbeat.eviction-enabled(기본 false): 위 조건 덕에 구 버전이 섞여 있어도 켜서 안전하다. 기본 off 는 클라이언트가 붙은 걸 확인한 뒤 켜는 관측 순서 때문이다.구현
SseConnection(id·userId·emitter·lastHeartbeatAt, null 이면 하트비트를 안 보낸 연결)이 레지스트리의 단일 키다.SseEmitterRegistry는register가 돌려준 연결로unregister·touch·removeStale을 하고, 번호 조회는 인증된 유저 파티션 안 선형 탐색이라 별도 색인이 없다.LocalSseDelivery.ping()·evictStale()과 complete 헬퍼 하나,SsePingScheduler(옛SseHeartbeatScheduler개명, 임계값 > 주기 불변식 검사),SseClientHeartbeatProperties(notification.sse.client-heartbeat),ClientHeartbeatRequest,NotificationErrorCode.UNKNOWN_CONNECTION.http.server.requests시리즈)에서 actuator 와 같이 뺐다. 연결마다 30초에 한 번 오는 인메모리 갱신이 가장 잦은 요청이 되는데 무료 한도가 빠듯하다.connect이벤트에서 번호를 정규식으로 뽑아 하트비트 POST 로 되돌리는 클라이언트 흐름을 그대로 밟는다. 400 detail 은 실측으로 example 과 잠갔다.리뷰로 잡은 것
computeIfAbsent뒤 밖에서add하던 것을compute하나로 묶었다. 그 사이 unregister 가 빈 리스트를 키째 지우면 새 연결이 맵에서 떨어진 리스트에 붙어 알림·ping 을 못 받는데, 번호 색인엔 남아 하트비트가 200 을 돌려주고 종료 시 색인에서 영영 안 빠졌다. 이 PR 전부터 있던 경합이 번호 색인 때문에 누수로 커진 것.completeWithError: SSE 죽은 연결 정리가 ERROR 디스패치를 일으켜 서버 에러 알림을 내는 문제 #1024 가 고친 것과 같은 ERROR 디스패치 경로가 이 자리에만 남아 있었다.complete()로 바꿨다./subscribe처럼 뺐다.Result
connect의 data 가"connected"인지 검사하는지. 검사한다면 서버가 먼저 나가는 순간 연결 확인이 깨진다. 합의된 계약은 연결 번호이므로 확인만 받으면 된다.SSE_CLIENT_HEARTBEAT_EVICTION_ENABLED=true. 읽음 주체를 클라이언트로 옮기는 SSE write 성공을 읽음으로 간주하는 서버 자동 읽음을 제거하고 읽음 주체를 클라이언트로 되돌린다 #1056 이 끝나면 이 작업은 정확성이 아니라 연결 위생만 다룬다.연관 이슈