logo
Nostrss
Published on

WebSocket 종료 핸드셰이크와 close code

Authors
WebSocket

WebSocket 프로토콜 시리즈의 세 번째 레슨. 직접 구현한 재연결 래퍼의 모든 판단 — "이 끊김은 재연결해야 하는 끊김인가?" — 의 스펙 근거가 이 레슨에 있다. 시작에 핸드셰이크가 있었듯, 끝에도 핸드셰이크가 있다.

핵심 답부터

WebSocket의 정상 종료는 TCP를 그냥 끊는 게 아니라 Close 프레임(opcode 0x8)을 서로 교환하는 절차다 (§7.1.2). 이 절차를 종료 핸드셰이크라 하며, 완료된 뒤 TCP가 닫히면 깨끗한 종료(clean close), 절차 없이 TCP가 끊기면 **비정상 종료(abnormal closure)**다 (§7.1.4). 래퍼에서 매일 보는 CloseEvent.wasClean이 정확히 이 구분이다.

왜 종료에도 핸드셰이크가 필요한가

TCP가 끊겼다는 사실만으로는 끊겼는지 알 수 없다. 서버가 의도적으로 닫은 건지, 네트워크가 죽은 건지, 프록시가 유휴 연결을 잘라버린 건지 — 받는 쪽에서는 전부 똑같은 "끊김"이다. Close 프레임 교환은 여기에 두 가지를 더한다:

  • 의도의 증명 — Close 프레임을 받았다면 상대가 의도적으로, 정상 절차로 종료한 것이다. 못 받았다면 무언가 잘못된 것이다.
  • 이유의 전달 — Close 프레임의 페이로드에 숫자 코드와 사유를 실어, "왜 닫는지"를 마지막 메시지로 전한다.

Close 프레임의 구조

Close도 컨트롤 프레임이므로 레슨 2의 제약(페이로드 ≤125바이트, 단편화 금지)을 그대로 받는다. 페이로드 구조는:

88 len [마스킹 키]  code (2바이트, 빅엔디안)  reason (UTF-8, 선택)

: 클라이언트가 close(1000, "bye")88 85 xx xx xx xx  03 e8  62 79 65
0x03e8 = 1000

여기서 실무에서 마주쳤을 수 있는 숫자 하나가 유도된다: reason이 최대 123바이트인 이유는 컨트롤 프레임 한도 125바이트에서 코드 2바이트를 뺀 나머지이기 때문이다. WHATWG 스펙은 123바이트 초과 시 SyntaxError 를 던지라고 규정한다.

절차와 상태 전이

내 쪽                                상대 쪽
──────                               ──────
Close 프레임 전송   ──────────────▶   Close 프레임 수신
      (이 순간부터 CLOSING 상태,          │ 응답으로 Close 프레임 전송
     │  더 이상 데이터 전송 불가)Close 프레임 수신   ◀──────────────      │
     │                                  │
     └── 양쪽 다 보내고+받음 → TCP 종료 → CLOSED 상태

Close를 보내거나 받는 순간 CLOSING 상태가 되고 (§7.1.3), TCP까지 닫히면 CLOSED다. 브라우저 API의 readyState(0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED)가 이 상태 기계를 그대로 노출한 것이다 — 래퍼에서 관리하던 그 상태들이 스펙의 상태였던 셈이다.

수업 중 질문: CONNECTING에서는 전송이 안 되나?

네 상태를 앞선 레슨들과 잇대면: CONNECTING = 레슨 1열림 핸드셰이크(+ 그 아래 TCP/TLS)가 진행 중인 구간이다. 생성자는 연결을 기다리지 않으므로 new WebSocket() 직후는 항상 CONNECTING이고, 101 검증까지 통과해 open 이벤트가 발생하면 OPEN — 프레임을 주고받을 수 있는 유일한 상태다. 전이의 통지는 이벤트로 받는다: WebSocket은 EventTarget이라 ws.onopen(또는 addEventListener('open'))이 그 통로이고, 스펙상 "readyState를 OPEN으로 변경 → open 이벤트 발화"가 한 태스크로 묶여 있어 onopen 안에서는 항상 send() 가능하다. 생성자와 같은 동기 블록에서 핸들러를 등록하는 한 이벤트를 놓칠 수 없다 — open은 태스크로 큐잉되므로 현재 실행 중인 코드가 끝나기 전에는 발화할 수 없기 때문이다(단일 스레드 이벤트 루프).

onopen과 addEventListener('open')의 타이밍은 동일하다. onopen 은 별도 메커니즘이 아니라 같은 리스너 목록에 슬롯 하나를 차지하는 문법 설탕이다. open 발화 시 두 방식 모두 같은 태스크 안에서 등록 순서대로 동기 호출된다 — 마이크로태스크 차이 같은 것은 없다. 실제 차이는 관리 방식뿐: onopen 은 핸들러 1개(재할당 시 덮어씀, 목록에서의 자리는 최초 할당 위치 유지), addEventListener 는 여러 개. DOM의 onclick vs addEventListener('click') 과 정확히 같은 규칙이다.

  • CONNECTING에서 send()InvalidStateError 예외 (WHATWG). 아직 와이어가 HTTP 모드라 실어 보낼 프레임이 존재할 수 없다. open 을 기다리면 100% 피할 수 있는 프로그래밍 오류라서 예외로 처리된다.
  • CLOSING/CLOSED에서 send() → 예외 없이 조용히 버려진다. 보내는 순간 상대가 닫는 것은 코드로 막을 수 없는 타이밍 문제이기 때문. 래퍼들이 "OPEN 전 메시지를 큐에 쌓았다가 open 이벤트에서 방류"하는 패턴이 여기서 나온다.

DevTools에서 어디까지 보이나. CONNECTING 구간의 산출물인 핸드셰이크는 Network 탭(WS 필터)에서 그대로 보인다 — Status 101, Headers 탭에 Sec-WebSocket-Key/Accept 전부. OPEN 이후는 Messages 탭에 조립된 메시지 단위로 보인다. 반면 TCP·TLS 핸드셰이크(Timing에 소요시간만)와 프레임 원시 바이트(opcode, FIN, 마스킹 키)는 층이 달라 보이지 않는다 — 프레임 층은 Wireshark의 영역이다. Messages 탭의 발신 메시지가 평문인 것은 마스킹 의 모습이기 때문.

TCP는 서버가 먼저 닫는다 (§7.1.1). Close 프레임 교환 후 TCP 종료는 서버가 먼저 시작하는 것이 SHOULD다. TCP를 먼저 닫는 쪽이 TIME_WAIT 상태(2MSL 동안 같은 포트 재사용 불가)를 떠안는데, 그 부담을 수많은 클라이언트가 아닌 서버가 지는 게 낫기 때문이다. 클라이언트는 서버의 TCP 종료를 기다리는 것이 SHOULD.

수업 중 질문: CLOSING 도중 서버의 뒤늦은 데이터가 도착할 수 있나?

프로토콜 수준에서는 그렇다 — 스펙이 명시적으로 허용하는 정상 상황이다. Close를 받은 쪽은 응답 Close를 "가능한 한 빨리" 보내야 하지만(SHOULD), 보내던 메시지를 마저 끝내는 것이 허용된다:

An endpoint MAY delay sending a Close frame until its current message is sent ... However, there is no guarantee that the endpoint that has already sent a Close frame will continue to process data.

RFC 6455 §5.5.1. 번역: 진행 중인 메시지를 다 보낸 뒤에 Close 응답을 보내도 된다. 다만 이미 Close를 보낸 쪽이 그 데이터를 계속 처리해준다는 보장은 없다.

브라우저는 "처리하지 않는 쪽"을 택했다. WHATWG 수신 알고리즘의 가드("readyState가 OPEN이 아니면 중단") 때문에 onmessage 는 OPEN에서만 발화하므로, 결과는 누가 먼저 닫았느냐로 갈린다:

  • 서버가 먼저 닫음 → 유실 없음. TCP가 순서를 보장하므로 서버가 Close 전에 보낸 메시지는 반드시 Close보다 먼저 도착하고, 그 시점 클라이언트는 아직 OPEN이라 정상 수신된다.
  • 클라이언트가 close() 호출 → 즉시 CLOSING이 되므로, 이후 도착하는 서버 메시지는 readyState 가드에 걸려 조용히 유실된다 (예외도, 이벤트도 없다).

래퍼 설계 함의. 마지막 응답까지 받아야 하는 종료라면 close() 를 바로 부르지 말 것. 앱 수준 메시지로 종료 의사를 알리고 → 대기 중인 응답을 모두 수신한 뒤 → close(1000) 을 호출하는 배수(drain) 절차가 필요하다. 프로토콜의 Close가 보장하는 것은 "우아한 종료"가 아니라 "합의된 종료"까지다.

close code — 끊김의 이유를 숫자로

close code는 16비트 정수이고, 범위별로 관할이 나뉜다 (§7.4.2):

범위관할
0–999사용하지 않음
1000–2999프로토콜 자신 (RFC 6455와 확장 스펙만 정의 가능)
3000–3999라이브러리·프레임워크용 (IANA 등록제)
4000–4999애플리케이션 자유 사용 (등록 불가, 당사자 간 합의) — 여러분의 앱이 쓸 수 있는 범위

정의된 코드 전체 (§7.4.1) — 와이어에 실을 수 있는지, 브라우저 close() 로 보낼 수 있는지를 함께 보면:

코드의미와이어브라우저 close()
1000정상 종료 — 연결의 목적을 다함가능가능
1001떠남(going away) — 서버 셧다운, 페이지 이탈가능불가
1002프로토콜 오류 (예: 마스킹 안 된 클라이언트 프레임)가능불가
1003수용 불가한 데이터 타입 (텍스트만 받는데 바이너리 등)가능불가
1004예약 — 의미 미정의불가불가
1005합성: Close 프레임에 코드가 없었음금지불가
1006합성: Close 프레임 없이 비정상 끊김 (모든 연결 실패 포함)금지불가
1007페이로드가 메시지 타입과 불일치 (예: Text인데 깨진 UTF-8)가능불가
1008정책 위반 (범용 — 구체적 이유를 숨기고 싶을 때도)가능불가
1009메시지가 너무 큼가능불가
1010확장 협상 실패 (클라이언트 전용 — 서버는 핸드셰이크를 거부하면 됨)가능불가
1011서버 내부 오류 (서버판 500)가능불가
1015합성: TLS 핸드셰이크 실패금지불가

1006 — 와이어에 존재하지 않는 코드

래퍼 구현자에게 가장 중요한 대목이다. 1005, 1006, 1015는 Close 프레임에 실어 보내는 것이 금지된(MUST NOT) 예약 코드다. 이들은 통신용이 아니라, 받지 못한 것을 표현하기 위해 로컬에서 합성되는 코드다:

1006 is a reserved value and MUST NOT be set as a status code in a Close control frame by an endpoint. It is designated for use in applications expecting a status code to indicate that the connection was closed abnormally, e.g., without sending or receiving a Close control frame.

RFC 6455 §7.4.1. 번역: 1006은 엔드포인트가 Close 프레임에 설정해서는 안 되는 예약값이다. Close 프레임을 주고받지 못한 채 연결이 비정상적으로 닫혔음을 나타내기 위한 용도다.

CloseEvent.code === 1006 은 "서버가 1006을 보냈다"가 아니라 "아무 Close 프레임도 받지 못하고 연결이 죽었다"는 브라우저의 보고다. 1005는 "Close 프레임은 받았는데 코드가 비어 있었다", 1015는 "TLS 핸드셰이크 실패"를 뜻하는 같은 부류의 합성 코드다. 추가로 WHATWG 스펙은 DNS 실패, 연결 거부, 핸드셰이크 실패 등 모든 연결 실패를 1006 하나로 뭉뚱그리도록 규정하는데, 실패 원인을 구분할 수 있게 하면 스크립트가 내부 네트워크를 탐색(probing)하는 공격에 쓰일 수 있기 때문이다.

브라우저 API의 제약 — 스펙이 계층별로 나뉘는 실물

레슨 1의 이중 표준 구조가 여기서 실무로 내려온다. WHATWG 스펙은 JS의 ws.close(code, reason) 에 강한 제약을 건다:

If code is present, but is neither an integer equal to 1000 nor an integer in the range 3000 to 4999, inclusive, throw an InvalidAccessError DOMException.

WHATWG WebSockets Standard. 번역: code가 1000도 아니고 3000~4999 범위도 아니면 InvalidAccessError를 던진다.

브라우저의 WebSocket API를 호출하는 코드는 1001, 1002, 1011 같은 프로토콜 코드를 보낼 수 없다. 앱에게 허용된 것은 정상 종료(1000)와 앱 전용 범위(4000–4999, 그리고 등록된 3000번대)뿐이다. 앱의 종료 사유(예: "세션 만료 = 4001", "중복 로그인 = 4002")를 4000번대로 설계하는 관행이 여기서 나온다.

제약이 붙는 곳은 언어가 아니라 API다. 이 제약은 WHATWG close() 알고리즘의 것이므로 브라우저 API를 통과하는 코드 에만 적용된다. 백엔드는 — Node로 짠 JS여도 — WHATWG가 아닌 RFC 6455 구현체(ws, gorilla 등)를 쓰므로, 와이어 규칙(1004·1005·1006·1015 금지)만 지키면 socket.close(1011, "internal error") 처럼 프로토콜 코드를 자유롭게 보낼 수 있다. 브라우저만 조이는 이유: 페이지의 스크립트는 신뢰할 수 없는 코드라 프로토콜 수준 의미를 가진 코드의 사칭을 막는 것이다. 따라서 클라이언트 래퍼는 보낼 수 없는 코드(1011 등)도 받을 수는 있으며, 분기 처리해야 한다.

재연결 래퍼는 스펙의 요구사항이다 (§7.2.3)

놀랍게도, 직접 구현한 재연결 로직의 핵심 전략이 RFC에 SHOULD로 명시돼 있다:

The first reconnect attempt SHOULD be delayed by a random amount of time ... a value chosen randomly between 0 and 5 seconds is a reasonable initial delay ... Should the first reconnect attempt fail, subsequent reconnect attempts SHOULD be delayed by increasingly longer amounts of time, using a method such as truncated binary exponential backoff.

RFC 6455 §7.2.3. 번역: 첫 재연결은 무작위 시간(0~5초가 합리적) 지연 후 시도해야 하고, 실패하면 이후 시도는 절단된 이진 지수 백오프 같은 방법으로 점점 길게 지연해야 한다.

이유도 스펙에 있다: 서버 장애로 모든 클라이언트가 동시에 끊긴 뒤 즉시·집요하게 재연결하면, 그 자체가 서버에 대한 DoS 공격이 되어 복구를 방해한다. 무작위 초기 지연은 재연결 쇄도를 시간축으로 흩뿌리는(jitter) 장치다. 이를 종합하면 래퍼의 재연결 판단표가 스펙 근거와 함께 완성된다:

이벤트해석래퍼의 행동
wasClean=false / code 1006비정상 끊김 (네트워크, 서버 급사)재연결 — 랜덤 지연 + 지수 백오프 (§7.2.3)
code 1000합의된 정상 종료재연결하지 않음
code 1001서버 셧다운/재배포정책에 따라 — 보통 백오프 재연결
code 1008, 4xxx (앱 정의)의도적 거부 (인증 만료, 강제 퇴장 등)재연결 대신 앱 로직 (재로그인 유도 등)

close code 판독기

콘솔에서 실행하면 코드의 의미·관할 범위·와이어 합법 여부·JS close() 가능 여부를 알려준다. 1006, 1000, 1011, 4001, 999를 넣어보라.

const DEFINED = {
  1000: '정상 종료', 1001: '떠남 (셧다운/페이지 이탈)', 1002: '프로토콜 오류',
  1003: '수용 불가한 데이터 타입', 1004: '예약 (미정의)', 1005: '코드 없음 (합성)',
  1006: '비정상 종료 — Close 프레임 없이 끊김 (합성)', 1007: '페이로드가 타입과 불일치 (예: 깨진 UTF-8)',
  1008: '정책 위반', 1009: '메시지가 너무 큼', 1010: '확장 협상 실패 (클라이언트)',
  1011: '서버 내부 오류', 1015: 'TLS 핸드셰이크 실패 (합성)',
};

function lookupCloseCode(n) {
  const range =
    n < 1000 ? '0–999: 사용하지 않는 범위'
    : n < 3000 ? '1000–2999: 프로토콜 관할 (RFC 6455와 확장만 정의 가능)'
    : n < 4000 ? '3000–3999: 라이브러리·프레임워크용 (IANA 등록제)'
    : n < 5000 ? '4000–4999: 애플리케이션 자유 사용'
    : '5000 이상: 정의되지 않은 영역';
  const wireBanned = [1005, 1006, 1015].includes(n);
  const wireLegal = !wireBanned && n >= 1000 && n < 5000 && n !== 1004;
  const jsLegal = n === 1000 || (n >= 3000 && n < 5000);
  return {
    의미: DEFINED[n] || '(개별 정의 없음)',
    범위: range,
    와이어: wireLegal ? '가능' : wireBanned ? '금지 (MUST NOT — 로컬 합성 전용)' : '불가',
    'JS close()': jsLegal ? '가능' : '불가 (InvalidAccessError)',
  };
}

lookupCloseCode(1006);

확인 퀴즈

Q1. 래퍼가 받은 CloseEvent.code 가 1006일 때, 실제로 일어난 일은? (Close 프레임 없이 연결이 끊겼다 / 서버가 1006 코드를 보내왔다 / 정책 위반으로 서버가 종료시켰다 / TLS 핸드셰이크에 실패했던 것이다)

정답 보기

Close 프레임 없이 연결이 끊겼다. 1006은 와이어 금지 코드다. Close 프레임을 받지 못한 채 끊겼을 때 브라우저가 로컬에서 합성한다 (§7.1.5, §7.4.1).

Q2. JS의 ws.close(code) 에 허용되는 코드 범위는? (1000 또는 30004999 / 1000 또는 10021015 / 0999 또는 30003999 / 1000~2999 사이 전부)

정답 보기

1000 또는 3000~4999. 그 외에는 InvalidAccessError. 프로토콜 코드(1001, 1011 등)는 구현체의 권한이고, 앱은 1000과 3000~4999만 쓴다 (WHATWG).

Q3. close reason이 최대 123바이트인 이유는? (컨트롤 프레임 한도에서 2바이트 제외 / UTF-8 문자열의 길이 상한이라서 / HTTP 헤더 크기 제한을 계승해서 / 브라우저 구현체들이 합의한 관례)

정답 보기

컨트롤 프레임 한도에서 2바이트 제외. Close는 컨트롤 프레임이라 페이로드 ≤125B이고(§5.5), 코드가 2바이트를 차지하므로 reason은 123B다. 레슨 2의 제약이 그대로 이어진다.

Q4. 스펙 §7.2.3이 비정상 종료 후 재연결에 SHOULD로 권고하는 것은? (랜덤 초기 지연 후 지수 백오프 / 고정된 1초 간격의 즉시 재시도 / 최대 3회까지만 재시도하고 중단 / 서버가 승인한 뒤에만 재연결 시도)

정답 보기

랜덤 초기 지연 후 지수 백오프. 동시 재연결 쇄도가 서버 복구를 막는 DoS가 되기 때문. 첫 시도는 0~5초 랜덤 지연, 이후는 지수 백오프가 스펙의 권고다.

다음 단계

1차 자료 읽기. 오늘의 원문: RFC 6455 §7.4 (Status Codes)§7.2.3 (Recovering from Abnormal Closure) — 특히 §7.2.3은 직접 만든 래퍼와 대조하며 읽어보라. 내 래퍼는 랜덤 초기 지연(jitter)이 있는가?

실무 확인: 직접 구현한 래퍼의 재연결 조건문을 열어보라. event.code === 1006 이나 !event.wasClean 분기가 있다면, 이제 그 한 줄의 스펙 근거(§7.1.5, §7.4.1)를 정확히 아는 것이다.


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