WebSocket 종료 코드 1006은 누가 보낸 걸까: Close 프레임과 1000·1001·1011, 보내면 안 되는 코드

WebSocket 로그의 1006은 상대가 보낸 값이 아니라 Close 프레임 없이 연결이 끝났다는 표시다. RFC 6455의 닫기 핸드셰이크, 종료 코드가 정해지는 규칙, 코드 범위와 재연결 백오프를 정리했다.

websocketrfc-6455networkprotocol

WebSocket 연결이 끊긴 뒤 로그에 1006이 남아 있으면, 서버가 1006이라는 코드를 보내서 연결을 끊었다고 읽기 쉽다. 하지만 RFC 6455에서 1006은 어느 쪽도 프레임에 실어 보낼 수 없는 값이다. 연결이 끝났는데 Close 프레임을 한 번도 받지 못했을 때, 그 상황을 숫자로 표시하려고 비워 둔 자리다.

이 글은 RFC 6455(2011년 12월)의 5.5.1절과 7장, 그리고 IANA의 WebSocket Close Code Number Registry(2026-10-05 갱신본, 2026-10-15 확인)를 기준으로 한다. 브라우저나 특정 라이브러리가 이 값을 어떻게 노출하는지는 다루지 않는다.

Close 프레임 안에 들어가는 것

Close는 opcode 0x8인 제어 프레임이다. 본문(application data)은 넣어도 되고 빼도 된다(MAY). 본문이 있다면 처음 2바이트는 네트워크 바이트 순서의 부호 없는 16비트 정수, 즉 상태 코드여야 한다(MUST). 그 뒤에 UTF-8로 된 이유(reason) 문자열이 올 수 있다.

Close 프레임 본문
+-----------------+------------------------------+
| 상태 코드 2바이트 | 이유: UTF-8 바이트 (선택)      |
| 예: 0x03E8 = 1000 |                              |
+-----------------+------------------------------+

모든 제어 프레임은 페이로드가 125바이트 이하여야 하고 쪼개서(fragment) 보낼 수 없다(MUST). 코드가 2바이트를 차지하므로 이유 문자열에 남는 공간은 최대 123바이트다. UTF-8에서 한 글자(코드 포인트)가 3바이트를 차지한다면 41자로 123바이트가 다 찬다. 제한은 글자 수가 아니라 인코딩된 바이트 수에 걸리므로, 구현에서는 UTF-8로 인코딩한 뒤의 바이트 길이를 확인해야 한다.

이유 문자열의 해석은 RFC가 정하지 않는다. 사람이 읽을 수 있다는 보장도 없어서, 클라이언트는 이 문자열을 최종 사용자에게 보여 주면 안 된다(MUST NOT). 디버깅이나 연결을 연 스크립트에 정보를 넘기는 용도로 생각하면 된다.

닫기 핸드셰이크의 순서

RFC는 WebSocket 수준의 닫기 절차를 TCP의 닫기(FIN/ACK)와 별도로 둔다. TCP의 닫기는 끝에서 끝까지 늘 믿을 수 있는 것이 아니고, 중간에서 가로채는 프록시가 있으면 특히 그렇다는 이유다(1.4절, 비규범). 순서는 다음과 같다.

A                                   B
|--- Close(1000, "done") --------->|   A는 이후 데이터 프레임을 보내지 않는다
|                                  |   B: 아직 Close를 안 보냈으므로 응답해야 한다
|<-------------- Close(1000) ------|   보통 받은 코드를 그대로 돌려준다
|                                  |
양쪽 모두 Close를 보내고 받음 → 기반 TCP 연결을 닫는다
(서버는 즉시 닫는다. 클라이언트는 서버가 닫기를 기다리는 것이 원칙)

각 단계에 걸린 요구 수준은 이렇다(5.5.1절).

  • Close를 보낸 쪽은 그 뒤로 데이터 프레임을 보내면 안 된다(MUST NOT).
  • Close를 받았는데 아직 보낸 적이 없으면 Close로 응답해야 한다(MUST). 가능한 한 빨리 보내야 하고(SHOULD), 보내던 메시지가 있으면 그것을 마저 보낸 뒤로 미뤄도 된다(MAY). 응답할 때는 받은 코드를 그대로 돌려주는 경우가 많다고 RFC는 적고 있다. 의무 조항은 아니다.
  • 다만 이미 Close를 보낸 쪽이 그 뒤에 오는 데이터를 계속 처리한다는 보장은 없다.
  • Close를 보내고 받은 뒤에는 기반 TCP 연결을 닫아야 한다(MUST). 서버는 즉시 닫아야 하고(MUST), 클라이언트는 서버가 닫기를 기다리는 것이 원칙이지만(SHOULD) 적당한 시간 안에 서버의 TCP 종료가 오지 않으면 언제든 닫아도 된다(MAY).
  • 양쪽이 동시에 Close를 보내도 문제없다. 둘 다 보내고 받은 상태가 되므로 연결을 닫으면 된다.

서버가 TCP를 먼저 닫게 하는 이유도 7.1.1절에 적혀 있다. 먼저 닫는 쪽이 TIME_WAIT 상태를 떠안는데, 이 상태가 클라이언트에 남으면 2MSL 동안 같은 연결을 다시 열 수 없기 때문이다. TIME_WAIT 자체는 TIME-WAIT 소켓이 쌓이는 이유에서 따로 다뤘다.

Close 프레임을 주고받은 뒤 TCP 연결이 닫히면 RFC는 이 연결이 “깨끗하게(cleanly)” 닫혔다고 부른다(7.1.4절). 연결 수립 자체에 실패한 경우도 닫힌 것으로 치지만 깨끗한 종료는 아니다.

로그에 찍히는 종료 코드는 어떻게 정해지나

여기서 1005와 1006의 정체가 나온다. RFC 7.1.5절은 “WebSocket 연결 종료 코드”를 이렇게 정의한다.

상황 이 끝점의 종료 코드
처음 받은 Close 프레임에 상태 코드가 있음 그 코드
처음 받은 Close 프레임에 상태 코드가 없음 1005
연결이 닫혔는데 Close 프레임을 하나도 받지 못함 1006

기준은 내가 보낸 Close가 아니라 내가 처음 받은 Close다. 종료 이유 문자열도 같은 규칙을 따르고, 받은 프레임에 문자열이 없으면 빈 문자열이 된다(7.1.6절).

그래서 1006은 “Close 프레임을 받기 전에 연결이 끝났다”는 사실만 알려 준다. RFC가 드는 예는 기반 전송 연결이 끊긴 경우다. 상대 프로세스가 Close 없이 종료됐는지, 중간 네트워크 장비가 연결을 끊었는지 같은 원인은 이 숫자만으로는 구분할 수 없다. 원인을 찾으려면 반대편 끝점의 기록도 함께 봐야 한다.

양쪽이 같은 코드를 본다는 보장도 없다. RFC가 직접 드는 예가 있다. 상대가 보낸 Close가 아직 내 수신 버퍼에 있는데, 내 쪽 애플리케이션이 그것을 읽기 전에 따로 닫기로 하고 Close를 보낸다. 그러면 두 끝점 모두 Close를 보내고 받은 상태가 되고, 각자 상대가 보낸 코드를 종료 코드로 기록한다. 거의 동시에 서로 닫기를 시작하면 두 쪽 로그의 코드가 다를 수 있다는 뜻이다.

RFC 6455가 정의한 코드

7.4.1절의 코드는 Close 프레임을 보낼 때 쓸 수 있는(MAY) 값이다. 이 중 세 개는 프레임에 넣으면 안 되는(MUST NOT) 예약값이다.

코드 IANA 이름 RFC 6455의 뜻 프레임에 넣어도 되나
1000 Normal Closure 연결을 연 목적을 다 이룬 정상 종료 예
1001 Going Away 서버가 내려가거나 브라우저가 페이지를 떠나는 등 끝점이 사라짐 예
1002 Protocol error 프로토콜 오류로 끊음 예
1003 Unsupported Data 받을 수 없는 종류의 데이터를 받음 (예: 텍스트만 처리하는 끝점이 바이너리를 받음) 예
1004 Reserved 예약. 뜻은 나중에 정해질 수 있음 정의된 뜻 없음
1005 No Status Rcvd 상태 코드가 없었다는 표시용 아니오
1006 Abnormal Closure Close 프레임 없이 비정상 종료됐다는 표시용 아니오
1007 Invalid frame payload data 메시지 종류와 맞지 않는 데이터 (예: 텍스트 메시지 안의 UTF-8이 아닌 바이트) 예
1008 Policy Violation 정책 위반. 더 맞는 코드가 없거나 정책의 세부를 숨길 때 쓰는 일반 코드 예
1009 Message Too Big 처리하기에 너무 큰 메시지 예
1010 Mandatory Ext. 클라이언트가 기대한 확장을 서버가 협상해 주지 않음. 필요한 확장 목록은 이유 문자열에 넣는 것이 원칙(SHOULD) 예 (클라이언트만 씀)
1011 Internal Error 서버가 예상치 못한 상황 때문에 요청을 처리할 수 없음 예
1015 TLS handshake TLS 핸드셰이크 실패(예: 서버 인증서를 검증할 수 없음) 표시용 아니오

몇 가지는 덧붙일 만하다.

  • 1010을 서버가 쓰지 않는 이유를 RFC는 이렇게 설명한다. 서버는 이 경우 WebSocket 핸드셰이크 자체를 실패시키면 되기 때문이다.
  • 1011의 이름은 RFC 6455의 IANA 초기 표(11.7절)에는 “Internal Server Error”로, 현재 IANA 레지스트리에는 “Internal Error”로 적혀 있다. 레지스트리의 이 항목에는 RFC 6455와 함께 정오표(errata) 3227이 참조로 붙어 있다. 본문 7.4.1절의 설명은 “서버가”로 시작한다.
  • 1012(Service Restart), 1013(Try Again Later), 1014는 RFC 6455 본문에는 없고 IANA 레지스트리에만 있다. 참조는 IETF hybi 메일링 리스트의 글이다. 1014의 설명은 “서버가 게이트웨이나 프록시로 동작하다 업스트림 서버로부터 잘못된 응답을 받음. HTTP 502와 비슷함”이다. 1012와 1013은 레지스트리에 이름만 있으므로, 이 글에서는 이름 이상의 의미를 붙이지 않는다.

그리고 7.4절은 받는 쪽이 이 코드를 어떻게 해석하고 무엇을 해야 하는지는 정하지 않는다고 분명히 적는다. 1001을 받으면 재연결한다거나 1008이면 다시 로그인시킨다는 동작은 애플리케이션이 정해야 하는 몫이다.

코드 범위: 직접 만든 코드는 어디에 둘까

7.4.2절과 11.7절이 범위를 나눈다.

범위 용도 등록
0–999 쓰지 않음 —
1000–2999 이 프로토콜, 향후 개정판, 공개 명세가 있는 확장 Standards Action, Specification Required, IESG Review 중 하나
3000–3999 라이브러리, 프레임워크, 애플리케이션 IANA에 선착순(First Come First Served) 등록
4000–4999 사적 사용(private use) 등록할 수 없음. 애플리케이션 사이의 사전 합의로 사용

3000번대와 4000번대 코드의 해석은 RFC가 정하지 않는다. 2026-10-05 갱신된 IANA 레지스트리에서 3000번대에 등록된 값은 3000(Unauthorized), 3003(Forbidden), 3008(Timeout) 세 개이고 나머지는 미할당이다. 1016–2999도 미할당이다.

자기 서비스의 클라이언트와 서버끼리만 쓰는 코드라면 4000–4999에서 고르고 의미를 양쪽 코드에 함께 정의해 두는 것이 범위의 취지에 맞는다. 여러 서비스가 함께 쓰는 라이브러리라면 3000번대를 IANA에 등록하는 길이 있다.

1006 뒤의 재연결: 백오프를 넣어야 하는 이유

RFC 7.2.3절은 비정상 종료 뒤 재연결에 대해 꽤 구체적으로 권고한다. 장애가 일시적이지 않은데 배포된 모든 클라이언트가 곧바로, 끈질기게 재연결을 시도하면 서버는 사실상 서비스 거부 공격을 받는 것과 같은 상황이 되고 회복이 늦어질 수 있다는 이유다.

  • 재연결에는 어떤 형태든 백오프를 써야 한다(SHOULD).
  • 첫 재연결 시도는 무작위 시간만큼 늦춰야 한다(SHOULD). 0초에서 5초 사이의 무작위 값이 적당한 초기 지연이라고 RFC는 적고 있고, 다른 구간을 골라도 된다(MAY).
  • 첫 시도가 실패하면 그 뒤의 시도는 점점 더 길게 늦춰야 한다(SHOULD). 예로 든 방법은 상한을 둔 이진 지수 백오프(truncated binary exponential backoff)다.

이 권고를 코드로 옮긴 예시다. 첫 시도의 0~5초는 RFC가 든 값이고, 그 뒤의 계산식과 60초 상한은 RFC가 정한 것이 아니라 이 글에서 고른 것이다. 두 번째 시도부터는 지연이 고정값이다. 5초, 10초, 20초, 40초로 두 배씩 늘고, 그 뒤로는 매번 60초다.

// attempt: 0이면 첫 재연결 시도
function reconnectDelayMs(attempt: number): number {
  if (attempt === 0) {
    return Math.random() * 5_000; // 0~5초 사이 무작위
  }
  // 5초, 10초, 20초, 40초, 이후 60초에서 멈춤
  return Math.min(60_000, 5_000 * 2 ** (attempt - 1));
}

무작위로 정한 첫 지연은 같은 순간에 끊긴 클라이언트들이 같은 순간에 다시 몰리지 않게 흩어 놓는 역할을 한다. 이 예시에서 그 뒤의 지연은 고정값이라 무작위성을 더하지 않는다.

정리: 구현할 때 확인할 것

RFC 문구에서 바로 나오는 규칙과, 그것을 바탕으로 한 이 글의 제안을 나눠 적는다.

RFC 6455의 규칙

  • 1005, 1006, 1015는 Close 프레임에 넣지 않는다.
  • 이유 문자열은 123바이트 안에 넣는다(제어 프레임 125바이트 제한에서 계산).
  • 클라이언트는 이유 문자열을 사용자 화면에 보여 주지 않는다.
  • Close를 받으면 Close로 응답하고, 양쪽이 주고받은 뒤에는 서버가 TCP를 바로 닫는다.
  • 비정상 종료 뒤 재연결은 무작위 초기 지연과 점점 늘어나는 지연을 둔다.

이 글의 제안

  • 정상 종료에는 1000을, 서버 배포나 종료로 연결을 정리할 때는 1001을 보낸다.
  • 서비스 고유의 종료 이유는 4000–4999에서 정하고 클라이언트와 서버 양쪽에 같은 표를 둔다.
  • 로그에는 코드만 남기지 말고 Close 프레임을 실제로 받았는지도 함께 남긴다. 1006을 조사할 때는 반대편 끝점의 로그와 맞춰 본다.

블로그로