Skip to content

SSE 양방향 하트비트와 연결 번호로 죽은 연결을 서버가 먼저 닫는다 - #1058

Open
m-a-king wants to merge 9 commits into
devfrom
feat/1057-sse-bidirectional-heartbeat
Open

SSE 양방향 하트비트와 연결 번호로 죽은 연결을 서버가 먼저 닫는다#1058
m-a-king wants to merge 9 commits into
devfrom
feat/1057-sse-bidirectional-heartbeat

Conversation

@m-a-king

@m-a-king m-a-king commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • SSE 양방향 하트비트와 연결 번호로 죽은 연결을 1분 안에 정리한다 #1057
  • 착수 전 prod 실측(2026-09-07): SSE 에러 콜백에서 complete 로 요청을 끝맺는다 #1031(onError 에 complete) 이 든 이미지에서도 서버 에러 알림 두 줄이 그대로 났다. 스택은 Tomcat 의 /error ERROR 디스패치였고, Spring 은 async 결과를 한 번만 받으므로 하트비트 write 실패 쪽이 먼저 결과를 설정하면 onError 의 complete() 는 무력화된다. 즉 "누가 complete 를 부르나" 로는 경합을 못 이긴다.
  • 서버 ping 은 앱과 같은 박스의 nginx 까지만 write 성공이 확인된다. 클라이언트가 비정상 종료돼도 nginx 커널 재전송(약 996초)이 끝나야 앱 소켓에 단절이 온다. 그 사이 서버는 죽은 연결을 살아 있는 것으로 취급한다.

Task

Action

계약 (client 팀과 합의)

방향 무엇 주기 판정 주체 결측 시
서버 → 클라 event: heartbeat / data: <connectionId> 30초 클라이언트 60초 안 오면 재연결
클라 → 서버 POST /api/v1/notifications/heartbeat {connectionId} 30초(연결 중·포그라운드) 서버 60초 안 오면 서버가 정상 종료
  • 연결 번호: 서버가 연결마다 UUID 를 부여해 connectheartbeat 이벤트 data 로 내린다. 클라이언트가 POST 에 되돌려 보내므로 유저가 아니라 연결 단위로 생존을 판정한다(탭·기기 여럿이어도 어느 연결인지 안다).
  • 주석 ping 폐기: 표준 EventSource 는 주석 라인을 노출하지 않아 클라이언트가 스트림 생존을 관측할 수 없었다. data 없는 event 는 표준상 디스패치되지 않으므로 data 를 반드시 싣는다.

하트비트 응답

케이스 status code 클라 동작
반영 200 없음 없음
connectionId 없음·UUID 아님 400 COMMON-INVALID-INPUT 클라 버그
미인증 401 COMMON-UNAUTHORIZED 토큰 갱신
서버에 없는 번호(배포로 서버 교체·이미 정리) 또는 남의 번호 409 NOTIFICATION-002 즉시 재연결
  • 남의 번호는 모르는 번호와 같게 409 로 수렴시켜 번호 존재 여부가 새지 않게 했다.
  • 규약대로 204 가 아니라 200 + ApiResponseBody.ok() 다. client 팀에 204 로 먼저 말했던 것은 정정이 필요하다.

결측 정리 설계

선택지 비용 채택
새 스케줄러로 lastSeen 순회 스케줄러 하나 추가 아니오
하트비트마다 유저별 타이머 취소·재등록 30초마다 유저 수만큼 취소·재등록 아니오
기존 30초 ping 순회에 얹기 추가 인프라 없음, 최악 감지 지연 = 임계값 + 30초
  • 임계값 60초: 클라 주기의 1.5배(45초)가 아니라 2배. 클라이언트의 오탐 재연결은 FIN 을 서버에 먼저 보내는 경로라 SSE 하트비트 write 실패가 Tomcat ERROR 디스패치를 일으켜 서버 에러 알림이 재발한다 #1029 경합의 재료가 된다. 서버가 먼저 닫는 경로는 조용하므로 클라 판정은 넉넉히, 서버 판정도 같은 숫자로 맞췄다.
  • 서버 선제 종료는 조용한 경로: 앱과 nginx 사이 소켓은 건강하므로 정상 ASYNC 종료로 끝난다. INFO 로 남기며 이 건수가 관측 지표다.
  • 판정 대상은 하트비트를 한 번이라도 보낸 연결뿐: 앱스토어 롤아웃 기간엔 구 버전 클라이언트가 섞여 있는데, 그 연결까지 끊으면 60초마다 재연결하는 루프가 된다. 하트비트를 안 보내는 연결은 이전처럼 write 실패·타임아웃으로만 정리된다.
  • 플래그 sse.client-heartbeat.eviction-enabled(기본 false): 위 조건 덕에 구 버전이 섞여 있어도 켜서 안전하다. 기본 off 는 클라이언트가 붙은 걸 확인한 뒤 켜는 관측 순서 때문이다.
  • 임계값 가드: 단위 없는 숫자는 초로 읽고, ping 주기(30초) 이하면 부팅에 실패한다. 주기 이하로 잡히면 매 순회마다 전 연결이 끊기는 루프라 설정 단계에서 막는다.

구현

  • SseConnection(id·userId·emitter·lastHeartbeatAt, null 이면 하트비트를 안 보낸 연결)이 레지스트리의 단일 키다. SseEmitterRegistryregister가 돌려준 연결로 unregister·touch·removeStale 을 하고, 번호 조회는 인증된 유저 파티션 안 선형 탐색이라 별도 색인이 없다. LocalSseDelivery.ping()·evictStale() 과 complete 헬퍼 하나, SsePingScheduler(옛 SseHeartbeatScheduler 개명, 임계값 > 주기 불변식 검사), SseClientHeartbeatProperties(notification.sse.client-heartbeat), ClientHeartbeatRequest, NotificationErrorCode.UNKNOWN_CONNECTION.
  • 하트비트 POST 는 접근 로그와 관측(span·http.server.requests 시리즈)에서 actuator 와 같이 뺐다. 연결마다 30초에 한 번 오는 인메모리 갱신이 가장 잦은 요청이 되는데 무료 한도가 빠듯하다.
  • 통합 테스트는 구독 응답의 connect 이벤트에서 번호를 정규식으로 뽑아 하트비트 POST 로 되돌리는 클라이언트 흐름을 그대로 밟는다. 400 detail 은 실측으로 example 과 잠갔다.

리뷰로 잡은 것

  • 레지스트리 등록 경합: computeIfAbsent 뒤 밖에서 add 하던 것을 compute 하나로 묶었다. 그 사이 unregister 가 빈 리스트를 키째 지우면 새 연결이 맵에서 떨어진 리스트에 붙어 알림·ping 을 못 받는데, 번호 색인엔 남아 하트비트가 200 을 돌려주고 종료 시 색인에서 영영 안 빠졌다. 이 PR 전부터 있던 경합이 번호 색인 때문에 누수로 커진 것.
  • connect 전송 실패 경로의 completeWithError: SSE 죽은 연결 정리가 ERROR 디스패치를 일으켜 서버 에러 알림을 내는 문제 #1024 가 고친 것과 같은 ERROR 디스패치 경로가 이 자리에만 남아 있었다. complete() 로 바꿨다.
  • 하트비트 POST 접근 로그 제외: 연결마다 30초에 한 번 오는 무내용 요청이라 /subscribe 처럼 뺐다.
  • 토큰 15분 대 SSE 30분: 연결 중 한 번은 하트비트가 401 을 만난다. 다른 API 와 같은 토큰 갱신으로 풀고 계약에 명시했다. 리뷰가 제안한 "write 성공 시 lastSeen 갱신" 은 write 성공을 생존 신호로 되돌리는 것이라 채택하지 않았다.
  • 채택하지 않은 지적 둘: ERROR 디스패치 permitAll 은 결정대로 보류. 단일 스레드 스케줄러에서의 블로킹 write 는 이 PR 전부터 있던 구조이고 파싱 스케줄러가 단일 스레드 전제를 가져 풀 확장이 안 되므로 별건(SSE 전용 executor)으로 남긴다.
  • 품질 리뷰(simplify)로 정리한 것: 연결 상태를 nullable 시각 하나로 접음, 번호 색인 제거, 종료 경로 세 벌을 헬퍼 하나로, tick 은 ping 을 먼저(keep-alive 예산 보호), 요청 DTO 는 non-null 로. 채택하지 않은 제안 하나는 409 를 200 무시로 바꾸는 것이다. 멀티 인스턴스가 되면 하트비트 POST 가 연결을 든 인스턴스와 무관하게 라우팅돼 정상 연결에도 409 가 난다는 지적은 맞다. 스케일아웃(배포 플랫폼 개선 검토 — 인스턴스 증설 / ECS Fargate / EKS (overlap-OOM 근본 해결) #439) 시점에 계약을 다시 본다.

Result

  • 클라이언트가 하트비트를 붙이고 플래그를 켜면, 비정상 종료된 클라이언트의 연결 정리가 약 17분에서 60초에서 90초 사이로 준다(검사가 30초 ping 순회에 얹혀 있어 임계값 60초에 순회 지연이 더해진다).
  • 배포 전 client 팀 확인 하나: 현재 클라이언트가 connect 의 data 가 "connected" 인지 검사하는지. 검사한다면 서버가 먼저 나가는 순간 연결 확인이 깨진다. 합의된 계약은 연결 번호이므로 확인만 받으면 된다.
  • 판정 지표는 둘이다. 서버 에러 알림 두 줄의 빈도, 그리고 "SSE 클라이언트 하트비트 결측으로 연결 종료" INFO 건수. 알림이 멈추고 결측 종료가 하루 한 건 안팎이면 매일의 알림이 비정상 종료 케이스였다는 것이 확정된다. 알림이 계속 나면 정상 종료·nginx reload 쪽 경합이고 그때 안전망을 다시 논의한다.
  • 후속: client 하트비트 배포 확인 후 SSE_CLIENT_HEARTBEAT_EVICTION_ENABLED=true. 읽음 주체를 클라이언트로 옮기는 SSE write 성공을 읽음으로 간주하는 서버 자동 읽음을 제거하고 읽음 주체를 클라이언트로 되돌린다 #1056 이 끝나면 이 작업은 정확성이 아니라 연결 위생만 다룬다.

연관 이슈

- 서버 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 예시 코드와 체크리스트를 새 계약으로 갱신
@m-a-king m-a-king added the feat 외부 가시적 새 기능 label Sep 8, 2026
@m-a-king m-a-king self-assigned this Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitai Bot commented Sep 8, 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.yml

Review profile: CHILL

Plan: Advanced

Run ID: 5ca3ffe0-03ca-417c-b3f6-6d78a8364318


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.

- 레지스트리 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
m-a-king force-pushed the feat/1057-sse-bidirectional-heartbeat branch from 1584424 to 4e1f629 Compare September 8, 2026 09:54
- attribution.commit·pr 을 비워 둬도 sessionUrl(기본 true)이 별개로 Cloud·Remote Control 세션 링크를 붙였다. 최근 7일 머지 커밋 31건·PR 14건에 세션 URL 이 남은 원인
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 양방향 하트비트와 연결 번호로 죽은 연결을 1분 안에 정리한다

1 participant