logo
Nostrss
Published on

WebSocket ping/pong과 keepalive

Authors
WebSocket

WebSocket 프로토콜 시리즈의 네 번째 레슨. 연결은 "살아 있다"고 말해주지 않는다 — 침묵할 뿐이다. 그 침묵이 유휴인지 죽음인지 구분하는 프로토콜의 장치, 그리고 브라우저가 그 장치를 숨겨버린 탓에 래퍼가 직접 하트비트를 구현하게 된 사연.

핵심 답부터

Ping(0x9)과 Pong(0xA)상대가 아직 응답 가능한지 확인하는 컨트롤 프레임이다 (§5.5.2). Ping을 받으면 Pong으로 응답하는 것이 MUST다. 그런데 브라우저는 이 기능을 JS에 노출하지 않는다 — 그래서 프론트엔드는 앱 수준 하트비트를 직접 만든다. 직접 구현한 래퍼의 하트비트가 바로 그것이다.

왜 살아있음을 물어야 하는가 — TCP의 침묵 문제

TCP 연결은 트래픽이 없으면 완벽한 침묵이다. 문제는 죽은 연결도 똑같이 침묵한다는 것:

  • 반열림(half-open) 연결 — 상대 프로세스가 급사하거나(전원 차단, 커널 패닉), 중간 경로가 사라져도 내 쪽 TCP는 아무 통지도 받지 못한다. 다음 전송을 시도하고 한참 뒤에야 실패를 안다. 침묵하는 연결에서 "유휴"와 "죽음"은 구분 불가능하다.
  • 중간 장비의 유휴 타임아웃 — NAT, 로드밸런서, 리버스 프록시는 유휴 연결을 정리한다 (nginx proxy_read_timeout 은 기본 60초!). 조용한 WebSocket은 중간 장비에게 "버려도 되는 연결"로 보인다. 레슨 1·레슨 2에서 본 "프로토콜을 모르는 중간 장비" 문제의 세 번째 얼굴이다.

해법은 하나뿐이다: 주기적으로 트래픽을 만들어서 (1) 중간 장비에게 살아있음을 알리고 (2) 응답이 끊기면 죽음을 조기에 판정한다. 스펙의 표현으로:

NOTE: A Ping frame may serve either as a keepalive or as a means to verify that the remote endpoint is still responsive.

RFC 6455 §5.5.2. 번역: Ping은 keepalive(연결 유지 신호)로도, 상대가 아직 응답 가능한지 검증하는 수단으로도 쓸 수 있다.

프로토콜의 규칙 (§5.5.2–5.5.3)

규칙내용
응답 의무Ping을 받으면 Pong 전송이 MUST, "가능한 한 빨리"가 SHOULD. 유일한 면제: 이미 Close 프레임을 받은 경우
페이로드 메아리응답 Pong의 Application data는 대상 Ping의 것과 동일해야 한다 — 어느 ping에 대한 응답인지 대조할 수 있는 장치 (RTT 측정에도 쓰임)
밀린 pingPong을 못 보낸 사이 Ping이 여러 개 쌓이면, 가장 최근 것에만 응답해도 된다(MAY) — "살아있음" 확인엔 최신 하나면 충분하니까
자발적 pongPing 없이 Pong만 보내는 것도 허용 — 단방향 하트비트. 응답을 기대하지 않는다
전송 시점연결 수립 후 종료 전까지 언제든, 양쪽 모두 보낼 수 있다. 컨트롤 프레임이므로 단편화된 메시지 사이에도 끼어든다 (레슨 2)

예약해뒀던 경계 질문: CLOSING 중에 ping을 받으면? 레슨 3에서 "종료는 구간"임을 봤다. §5.5.2의 면제 조건은 정확히 "이미 Close 프레임을 받은 경우"뿐이다. 즉 내가 Close를 보내기만 한 상태(CLOSING)에서 ping이 오면 문면상 여전히 pong 의무가 있다 — §5.5.1의 전송 금지는 "데이터 프레임"에만 걸려 있고 컨트롤 프레임은 아니기 때문에 가능하다. 반대로 상대의 Close를 받은 뒤라면 pong 의무가 없다. 규칙의 주어와 조건을 따지는 읽기 — 레슨 2에서 연습한 그대로다.

브라우저의 비밀: JS에는 ping이 없다

WHATWG 표준WebSocket 인터페이스에는 ping을 보내는 API도, ping/pong을 관찰하는 이벤트도 없다. 프로토콜의 ping/pong은 존재하지만 전적으로 브라우저 내부의 일이다:

  • 서버가 ping을 보내면 → 브라우저가 프레임 층에서 자동으로 pong을 응답한다. JS는 아무것도 모른다 (onmessage 도 발화하지 않는다 — 데이터 프레임이 아니니까).
  • JS가 ping을 보내고 싶으면 → 방법이 없다. 끝.

반면 서버 측 라이브러리는 RFC 구현체답게 전부 노출한다 — Node wssocket.ping()'pong' 이벤트가 대표적이다. 이 비대칭이 실무 구도를 만든다:

방향수단구현 주체
서버 → 클라이언트 생존 확인프로토콜 ping/pong (socket.ping(), 응답은 브라우저 자동)백엔드
클라이언트 → 서버 생존 확인앱 수준 하트비트ws.send('{"type":"ping"}') 같은 일반 메시지 + 서버의 응답 메시지프론트엔드 (래퍼!)

즉 직접 구현한 래퍼의 하트비트는 우회책이 아니라 브라우저 API의 공백을 메우는 표준적인 실무 패턴이다. 프로토콜 ping과 달리 앱 하트비트는 opcode 0x1(Text)로 나가는 평범한 데이터 메시지라는 것 — 이제 프레임 층에서 정확히 구분할 수 있다.

하트비트 설계 — 죽음의 판정

하트비트의 산수는 단순하다: 주기 T 초로 ping을 보내고, 마지막 응답 후 타임아웃 W 초 안에 응답이 없으면 죽음으로 판정한다. 최악의 경우 감지 지연은 약 T + W초다. T를 줄이면 감지가 빨라지지만 트래픽·배터리 비용이 늘고, 중간 장비 타임아웃(60초류)보다는 짧아야 keepalive 효과가 있다. 판정 후의 행동은 레슨 3에서 배웠다: 연결을 버리고 §7.2.3의 백오프 재연결로 진입한다.

수업 중 질문: 그래서 몇 초가 좋은가

스펙은 이 값에 침묵한다 — 실무 지혜의 영역이다. 범위를 정하는 제약은 둘: 상한은 인프라 유휴 타임아웃(nginx·AWS ALB 기본 60초 → 안전하게 그 절반 이하), 하한은 트래픽·모바일 배터리 비용. 이 사이에서 업계가 수렴한 값이 주기 25초다 (Socket.IO 기본값: pingInterval 25s, pingTimeout 20s). 타임아웃은 "몇 번의 RTT까지 봐줄 것인가"의 문제라 5~20초 — 너무 짧으면 모바일의 일시 지연(터널 등)에 산 연결을 오판해 죽인다. 요구되는 감지 지연에서 T + W로 역산하라:

프로필주기타임아웃최악 감지비고
일반 웹앱 (채팅·알림·대시보드)25s15~20s~45s사실상 표준 — 특별한 이유 없으면 이것
실시간 민감 (게임·트레이딩·협업 편집)5~10s3~5s~15s빠른 재연결이 UX에 직결될 때
배터리 민감 (모바일 백그라운드)30~55s20s~75sNAT 상한에 근접 — 서버 주도 ping 권장

기본 전략은 서버 주도가 유리하다 — 서버의 프로토콜 ping에 브라우저가 자동 응답하므로 프론트 타이머·배터리 부담이 없다. 클라이언트 앱 하트비트는 "서버 죽음을 클라이언트가 먼저 감지해야 하는" 요구가 있을 때 더하는 것이다. 그리고 판정 직후 즉시 재연결하지 말 것 — 하트비트가 감지 라면, §7.2.3 백오프는 그 뒤의 회복 규약이다.

수업 중 질문: 죽음 판정 후 재연결 중의 readyState는?

반전부터 — pong이 안 와서 죽었다고 판정한 순간에도 readyState는 여전히 1(OPEN)이다. TCP는 침묵하므로 브라우저는 아무것도 모른다. 죽음을 아는 것은 래퍼뿐이다. 이후의 전개:

절단          readyState = 1  ← 브라우저는 모름 (half-open)
타임아웃 판정  readyState = 1  ← 여전히! 판정의 주체는 래퍼다
ws.close()    readyState = 2  ← 상대가 죽어 Close 응답은 안 옴
브라우저 포기  readyState = 3, close 이벤트 (wasClean=false, 1006)
백오프 대기    살아있는 객체가 아예 없는 구간
재연결        새 객체의 readyState = 0  ← 옛 객체는 영원히 3

여기서 두 가지 사실이 드러난다:

  • readyState는 논리적 연결이 아니라 객체의 상태다. WebSocket 인스턴스는 일회용이며 CLOSED를 다시 여는 API는 없다 — 재연결은 항상 새 객체 생성이고, 래퍼는 참조를 갈아끼운다.
  • "재연결 중"이라는 상태는 스펙에 없다. 백오프 대기 중엔 객체가 없을 수 있으니 당연하다. 래퍼가 자체 상태 기계(예: RECONNECT_WAIT)를 만드는 이유이며, UI에 보여줄 연결 상태는 옛 객체의 readyState(판정 직후엔 아직 OPEN이라 거짓말을 한다)가 아니라 래퍼의 상태 기계에서 나와야 한다.

하트비트 시뮬레이터

콘솔에서 실행하면 하트비트의 감지 지연을 관찰할 수 있다. cut() 을 호출해 네트워크 절단을 흉내 내고, 죽음이 감지되기까지 걸리는 시간을 보라 — 절단 시점에 따라 감지 지연이 달라지고, 최악의 경우 주기+타임아웃에 근접한다.

function heartbeatSim(T = 3, W = 2) {
  const t0 = performance.now();
  let cutAt = null;
  const stamp = () => ((performance.now() - t0) / 1000).toFixed(1) + 's';
  const log = (msg) => console.log(stamp(), msg);

  log(`연결됨. 하트비트 시작 (주기 ${T}s, 타임아웃 ${W}s)`);
  function cycle() {
    log('ping →');
    if (cutAt === null) {
      setTimeout(() => log('   ← pong (생존 확인)'), 120);
      setTimeout(cycle, T * 1000);
    } else {
      setTimeout(() => {
        log(`   … pong 없음. 타임아웃 ${W}s 초과`);
        log(`☠ 죽음 판정 — 절단 후 ${((performance.now() - cutAt) / 1000).toFixed(1)}s 만에 감지.`
          + ' close(1006 합성) → §7.2.3 백오프 재연결로');
      }, W * 1000);
    }
  }
  setTimeout(cycle, T * 1000);
  return { cut: () => { cutAt = performance.now(); log('⚡ 네트워크 절단됨 (양쪽 모두 아직 모른다 — TCP는 침묵할 뿐)'); } };
}

const sim = heartbeatSim(3, 2);
// 잠시 후 콘솔에서: sim.cut()

확인 퀴즈

Q1. 서버가 보낸 프로토콜 ping에 대한 pong 응답은 누가 처리하는가? (브라우저가 프레임 층에서 자동으로 / onmessage 핸들러가 수신하여 직접 / 개발자가 pong 전용 API를 호출해서 / 운영체제의 TCP 스택이 대신 응답)

정답 보기

브라우저가 프레임 층에서 자동으로. ping/pong은 JS에 노출되지 않는 브라우저 내부 동작이다. onmessage는 데이터 프레임에만 발화한다.

Q2. 응답 Pong의 Application data에 대한 규칙은? (응답 대상 Ping의 것과 동일해야 한다 / 항상 빈 페이로드로 보내야만 한다 / 매번 새로운 무작위 값이어야 한다 / 전송 시각 타임스탬프를 담아야 한다)

정답 보기

응답 대상 Ping의 것과 동일해야 한다. 페이로드 메아리(echo)가 MUST다 — 어느 ping에 대한 응답인지 대조하고 RTT를 잴 수 있는 장치다 (§5.5.3).

Q3. Pong을 못 보낸 사이 Ping이 여러 개 쌓였다면? (가장 최근 것에만 응답해도 된다 / 받은 순서대로 전부 응답해야 한다 / 프로토콜 오류로 연결을 끊어야 한다 / 쌓인 개수를 담아 한 번에 알린다)

정답 보기

가장 최근 것에만 응답해도 된다. MAY 규정 — 목적이 "살아있음 확인"이므로 최신 하나에 대한 응답이면 충분하다 (§5.5.3).

Q4. keepalive가 근본적으로 필요한 이유는? (침묵하는 TCP는 죽음을 알리지 않아서 / 프레임 헤더에 유효 기간이 있어서 / 서버가 유휴 메모리를 정리해야 해서 / TLS 세션이 주기적으로 만료되어서)

정답 보기

침묵하는 TCP는 죽음을 알리지 않아서. 트래픽 없는 TCP에서 유휴와 죽음(half-open)은 구분 불가능하고, 중간 장비는 유휴 연결을 잘라버린다. 트래픽을 만들어야만 둘 다 해결된다.

다음 단계

1차 자료 읽기. 오늘의 원문: RFC 6455 §5.5.2–5.5.3 — 이제 한 페이지도 안 되는 분량이 그대로 읽힐 것이다. 실무 해설은 HPBN Ch.17의 배포(infrastructure) 절 참고.

실무 확인: 래퍼의 하트비트 코드를 열어 세 가지를 대조해보라 — ① 주기가 인프라의 유휴 타임아웃(보통 60초)보다 짧은가 ② 죽음 판정 후 §7.2.3 백오프로 진입하는가 ③ 하트비트 메시지가 서버의 프로토콜 ping과 역할이 겹치고 있지는 않은가.


시리즈 참고: 레슨 1 — WebSocket은 HTTP인가? 열림 핸드셰이크 · 레슨 2 — 프레임 구조: opcode와 마스킹 · 레슨 3 — 종료 핸드셰이크와 close code · WebSocket 시리즈 용어집