HTTP/2 GOAWAY 뒤에 남은 요청은 다시 보내도 될까: Last-Stream-ID, REFUSED_STREAM, HTTP/3과의 차이
GOAWAY의 Last-Stream-ID는 서버가 처리했을 수도 있는 마지막 스트림 번호다. 그보다 큰 번호의 요청은 POST라도 새 연결로 다시 보낼 수 있다. RFC 9113의 두 단계 종료, REFUSED_STREAM, HTTP/3에서 달라지는 경계를 정리했다.
서버를 재배포하거나 프록시를 재시작하면 클라이언트는 열려 있던 HTTP/2 연결에서 GOAWAY 프레임을 받는다. 이때 이미 보냈지만 응답을 받지 못한 요청이 몇 개 남아 있을 수 있다. 이 요청들을 새 연결로 다시 보내도 되는지는 메서드만으로 정해지지 않는다. GOAWAY에 들어 있는 스트림 번호 하나가 판단의 대부분을 정한다.
이 글은 RFC 9113(HTTP/2, 2022년 6월, RFC 7540과 8740을 대체)의 5장 일부, 6.4절, 6.8절, 7장, 8.7절, 9.1절과 RFC 9114(HTTP/3, 2022년 6월)의 2.2절, 4.1.1절, 5장, 6.1절, 7.2.6절, 8.1절, 부록 A, 그리고 RFC 9110의 9.2절을 기준으로 한다. nginx, Envoy, Go, 브라우저 같은 개별 구현이 GOAWAY를 받거나 보낼 때 실제로 어떻게 움직이는지는 버전과 설정에 따라 다르므로 다루지 않는다.
GOAWAY 프레임에 들어 있는 것
HTTP/2의 GOAWAY는 타입 0x07인 프레임이다(6.8절). 특정 스트림이 아니라 연결 전체에 적용되므로 프레임 헤더의 스트림 식별자는 0이어야 하고, 0이 아니면 받는 쪽은 PROTOCOL_ERROR 연결 오류로 처리해야 한다(MUST). 본문은 세 부분이다.
GOAWAY (type=0x07, stream=0)
+---+-------------------------------+
| R | Last-Stream-ID (31비트) |
+---+-------------------------------+
| Error Code (32비트) |
+-----------------------------------+
| Additional Debug Data (가변, 선택) |
+-----------------------------------+
- Last-Stream-ID: 이 프레임을 보낸 쪽이 처리했거나 앞으로 처리할 수도 있는, 상대가 연 스트림 중 가장 큰 번호. 아무 스트림도 처리하지 않았다면 0일 수 있다.
- Error Code: 연결을 닫는 이유. 정상적인 종료라면 NO_ERROR(
0x00)를 쓴다(7장). - Additional Debug Data: 진단용 불투명 데이터. 프로토콜상 의미가 없다.
여기서 “처리했다”는 말은 RFC가 따로 정의한다. 스트림의 데이터 일부라도 상위 계층 소프트웨어에 넘어갔고, 그 계층이 그 결과로 무언가를 했을 수 있다는 뜻이다. 애플리케이션 핸들러까지 요청이 전달됐다면 처리된 것으로 본다.
Last-Stream-ID가 나누는 두 구간
HTTP/2에서 클라이언트가 여는 스트림은 홀수 번호를 쓰고, 새 스트림의 번호는 그 끝점이 이전에 연 어떤 스트림보다도 커야 한다(5.1.1절). 그래서 번호 하나로 “여기까지”와 “여기부터”를 나눌 수 있다.
클라이언트가 스트림 1, 3, 5, 7, 9로 요청을 보냈고 아직 하나도 끝나지 않았는데, 서버가 GOAWAY(Last-Stream-ID=5, NO_ERROR)를 보냈다고 하자.
클라이언트 스트림: 1 3 5 | 7 9
처리됐을 수 있음 | 처리되지 않음
(끝까지 갈 수도) | (새 연결로 재시도 가능)
^
Last-Stream-ID = 5
- 7과 9: 서버는 GOAWAY를 보낸 뒤 Last-Stream-ID보다 큰 번호의 스트림에 온 프레임을 무시한다. 받는 쪽은 이 스트림들을 처음부터 만들어지지 않은 것처럼 다루고 새 연결에서 다시 보낼 수 있다(6.8절). 8.7절은 한 걸음 더 나간다. 처리되지 않은 요청은 실패한 것이 아니므로, POST 같은 멱등이 아닌 메서드라도 클라이언트가 자동으로 재시도해도 된다(MAY).
- 1, 3, 5: 처리됐을 수도 있다. GOAWAY가 곧 연결 종료를 뜻하지는 않아서, 서버는 연결을 열어 둔 채 진행 중인 스트림을 끝까지 마칠 수 있다. 응답이 정상적으로 오면 그것으로 끝이다. 문제는 이 스트림들이 끝나기 전에 연결이 닫힐 때다. 이 경우 다시 시도할 수 있는 것은 GET, PUT, DELETE 같은 멱등 동작뿐이다(6.8절).
그리고 GOAWAY를 받은 쪽은 그 연결에서 새 스트림을 열면 안 된다(MUST NOT). 새 요청은 새 연결로 보낸다.
이 보장이 성립하려면 서버가 번호를 정직하게 적어야 한다. 8.7절은 서버가 처리되지 않았다고 보장할 수 없는 스트림을 처리되지 않았다고 표시하면 안 된다고 정한다(MUST NOT). 어떤 스트림의 프레임이라도 애플리케이션 계층에 넘겼다면 GOAWAY의 Last-Stream-ID는 그 번호 이상이어야 한다(MUST).
GOAWAY 없이 끊기면
연결이 GOAWAY 없이 끝나면 Last-Stream-ID는 사실상 가능한 가장 큰 값으로 본다(6.8절). 끝나지 않은 모든 스트림이 “처리됐을 수 있음” 구간에 들어간다는 뜻이다. 5.4.3절도 스트림이 open이나 half-closed 상태로 남은 채 TCP 연결이 닫히거나 리셋되면 그 스트림들은 자동으로 재시도할 수 없다고 적는다.
RFC가 직접 드는 예가 POST다. 클라이언트가 POST를 보내는 순간 서버가 연결을 닫았고 GOAWAY가 없었다면, 클라이언트는 서버가 그 POST를 처리하기 시작했는지 알 수 없다. 그래서 끝점은 연결을 닫기 전에 항상 GOAWAY를 보내야 한다(SHOULD, 6.8절과 9.1절). 오동작하는 상대에게는 GOAWAY 없이 연결을 닫아도 된다는 예외도 6.8절에 함께 적혀 있다.
이 구간에 남은 요청을 어떻게 다룰지는 RFC 9110의 9.2.2절이 정한다. 멱등이 아닌 메서드는 요청의 의미가 실제로 멱등이라는 것을 알거나 원래 요청이 적용되지 않았음을 알아낼 방법이 없는 한 자동으로 재시도하면 안 된다(SHOULD NOT). 프록시는 멱등이 아닌 요청을 자동으로 재시도해서는 안 되고(MUST NOT), 자동 재시도가 실패했을 때 그것을 다시 자동으로 재시도하지 않아야 한다(SHOULD NOT). 메서드별 성질은 HTTP 메서드의 안전과 멱등에서 따로 정리했다.
두 단계로 닫기: 먼저 2^31-1, 그다음 실제 번호
GOAWAY를 보내는 순간에도 클라이언트는 새 스트림을 열고 있을 수 있다. 서버가 GOAWAY(5)를 보냈는데 그 프레임이 가는 도중에 클라이언트가 스트림 11을 보냈다면, 11은 처리되지 않은 채 버려진다. 클라이언트가 재시도할 수 있다면 손해는 없지만, 재시도하지 못하는 클라이언트는 그 요청을 잃는다. RFC는 HTTP/2로 클라이언트를 받지 않는 중간자에서 이 문제가 특히 크다고 적는다.
그래서 6.8절은 정상 종료를 하려는 서버에 다음 순서를 권한다.
- Last-Stream-ID를 2^31-1(스트림 번호의 최댓값)로, 오류 코드를 NO_ERROR로 한 첫 GOAWAY를 보낸다(SHOULD). 곧 종료할 테니 새 요청을 시작하지 말라는 신호다. 이 시점에는 아무 스트림도 버리지 않는다.
- 이미 날아오고 있는 스트림이 도착할 시간을 준다. RFC가 적은 기준은 최소 1 왕복 시간(RTT)이다.
- 실제 Last-Stream-ID를 적은 두 번째 GOAWAY를 보낸다(MAY).
- 그 번호 이하의 진행 중인 스트림이 끝날 때까지 연결을 열어 둔 뒤 닫는다.
GOAWAY는 여러 번 보낼 수 있지만(MAY), Last-Stream-ID를 이전에 보낸 값보다 키우면 안 된다(MUST NOT). 상대가 이미 처리되지 않은 요청을 다른 연결에서 다시 보냈을 수 있기 때문이다. 어떤 스트림이 처리됐을 수 있는지는 마지막으로 받은 GOAWAY의 값이 알려 준다.
GOAWAY를 보낸 뒤에도 연결 상태를 바꾸는 프레임은 완전히 무시할 수 없다. Last-Stream-ID보다 큰 스트림에 온 HEADERS, PUSH_PROMISE, CONTINUATION은 필드 압축 상태를 맞추기 위해 최소한으로 처리해야 하고, DATA 프레임은 연결 흐름 제어 창에 계산해야 한다(MUST). 그러지 않으면 압축 상태나 흐름 제어 상태가 양쪽에서 어긋난다.
GOAWAY를 보내는 쪽이 서버만은 아니다. 받은 GOAWAY 때문에 더는 그 연결을 쓸 일이 없어진 쪽도 연결을 끊기 전에 자기 GOAWAY를 보내야 한다(SHOULD). 스트림 번호를 다 써 버린 서버도 GOAWAY로 클라이언트를 새 연결로 보낼 수 있다(5.1.1절).
스트림 하나만 돌려보내기: REFUSED_STREAM
GOAWAY가 연결 단위의 경계라면, RST_STREAM 프레임의 REFUSED_STREAM(0x07)은 스트림 하나에 대한 같은 약속이다. 처리하기 전에 스트림을 닫는다는 뜻이고, 그 스트림의 요청은 안전하게 다시 보낼 수 있다(8.7절). GOAWAY와 마찬가지로 실제로 처리하지 않았다고 보장할 수 있을 때만 써야 한다.
이것이 쓰이는 대표적인 자리가 동시 스트림 제한이다. 상대가 SETTINGS_MAX_CONCURRENT_STREAMS를 넘겨 HEADERS를 보내면 받는 쪽은 PROTOCOL_ERROR나 REFUSED_STREAM 스트림 오류로 처리해야 한다(MUST). 5.1.2절은 어느 쪽 코드를 고르느냐가 자동 재시도를 허용할지를 정한다고 적는다.
참고로 RST_STREAM의 오류 코드 0x07과 GOAWAY의 프레임 타입 0x07은 숫자만 같을 뿐 서로 관계가 없다. 하나는 오류 코드 공간이고 다른 하나는 프레임 타입 공간이다.
오류로 닫을 때의 GOAWAY
GOAWAY는 정상 종료에만 쓰이지 않는다. 연결 오류를 만난 끝점은 상대에게서 마지막으로 제대로 받은 스트림 번호를 담아 GOAWAY를 먼저 보내고(SHOULD), 그 뒤 TCP 연결을 닫아야 한다(MUST, 5.4.1절). 다만 이 GOAWAY는 최선 노력일 뿐이고 상대에게 확실히 도착한다는 보장은 없다.
정상 종료 중에 오류가 생기면, NO_ERROR로 보낸 GOAWAY에 더해 새 오류 코드를 담은 GOAWAY를 하나 더 보낼 수 있다(5.4절). 이때도 Last-Stream-ID를 키우면 안 된다는 규칙은 그대로다.
로그에서 자주 보게 될 오류 코드 몇 개만 옮긴다(7장).
| 코드 | 이름 | 뜻 |
|---|---|---|
| 0x00 | NO_ERROR | 오류가 아님. 정상 종료의 GOAWAY에 쓰임 |
| 0x01 | PROTOCOL_ERROR | 더 구체적인 코드가 없는 프로토콜 오류 |
| 0x02 | INTERNAL_ERROR | 예상치 못한 내부 오류 |
| 0x07 | REFUSED_STREAM | 애플리케이션 처리 전에 스트림을 거절함 |
| 0x0b | ENHANCE_YOUR_CALM | 상대가 과도한 부하를 일으키는 것으로 보임 |
모르는 오류 코드를 받았다고 특별한 동작을 해서는 안 되고(MUST NOT), INTERNAL_ERROR와 같은 것으로 취급해도 된다(MAY).
Additional Debug Data는 사람이 원인을 찾을 때 도움이 될 수 있지만 의미를 붙여 동작을 바꾸는 근거로 쓰면 안 된다. 6.8절은 이 데이터에 보안이나 개인 정보에 민감한 내용이 들어 있을 수 있으므로, 로그 등에 저장한다면 무단 접근을 막을 적절한 보호 장치를 둬야 한다고 정한다(MUST).
HTTP/3에서 달라지는 것
HTTP/3에도 GOAWAY(타입 0x07)가 있지만 모양과 경계 규칙이 다르다.
| HTTP/2 (RFC 9113) | HTTP/3 (RFC 9114) | |
|---|---|---|
| 보내는 곳 | 스트림 0 | 제어 스트림(control stream). 다른 스트림에 오면 H3_FRAME_UNEXPECTED 연결 오류 |
| 값의 형식 | 31비트 Last-Stream-ID | 가변 길이 정수. 서버는 클라이언트가 연 양방향 스트림 ID, 클라이언트는 push ID |
| 오류 코드 | 프레임 안에 있음 | 없음. 연결을 닫을 때 QUIC CONNECTION_CLOSE에 담음 |
| 경계 | 값 이하는 처리됐을 수 있음, 값 초과는 처리 안 됨 | 값 미만은 처리됐을 수 있음, 값 이상은 거절됨 |
| 정상 종료의 첫 값 | 2^31-1 | 2^62-4(서버), 2^62-1(클라이언트) |
| 값을 키우면 | 금지(MUST NOT) | 금지. 받은 쪽은 H3_ID_ERROR 연결 오류로 처리 |
| 스트림 하나 거절 | RST_STREAM(REFUSED_STREAM) | H3_REQUEST_REJECTED(0x010b)로 스트림 중단 |
| GOAWAY 없이 끝남 | 마지막 번호를 최댓값으로 간주 | 보낸 요청은 전부 처리됐을 수 있다고 가정해야 함(MUST) |
가장 헷갈리기 쉬운 곳은 경계다. HTTP/2의 값은 “처리됐을 수 있는 마지막 번호”라 그 번호 자체가 처리 쪽에 들어간다. HTTP/3의 값은 “그 ID 이상은 거절”이라 그 ID 자체가 거절 쪽에 들어간다(5.2절).
HTTP/3에서 클라이언트의 첫 요청은 QUIC 스트림 0으로 가고, 그다음 요청은 4, 8, … 순서로 간다(6.1절). 클라이언트가 0, 4, 8, 12, 16으로 요청을 보냈는데 서버가 GOAWAY(12)를 보냈다면 이렇게 된다.
클라이언트 요청 스트림: 0 4 8 | 12 16
처리됐을 수 있음 | 처리되지 않음
^
GOAWAY ID = 12
12와 16은 처리되지 않으므로 다른 HTTP 연결에서 안전하게 다시 보낼 수 있다. 0, 4, 8의 결과는 응답을 받거나, 그 스트림이 따로 리셋되거나, 더 작은 ID의 GOAWAY가 오거나, 연결이 끝날 때까지 알 수 없다. 서버는 이 구간의 요청이라도 실제로 처리하지 않았다면 개별적으로 거절할 수 있다(MAY).
두 정의를 나란히 놓으면, 처리 쪽에 들어가는 마지막 요청을 HTTP/2는 그 요청의 스트림 번호로 적고 HTTP/3은 그다음 요청 스트림 ID로 적는 셈이다. HTTP/2와 HTTP/3을 함께 다루는 코드에서 비교 연산자 하나를 잘못 옮기면 경계에 있는 요청 하나가 잘못 분류된다.
요청 하나를 돌려보낼 때도 용어가 나뉜다. 애플리케이션 처리 없이 취소한 요청은 “거절(rejected)“이고 서버는 H3_REQUEST_REJECTED로 응답 스트림을 중단해야 한다(SHOULD). 일부라도 처리한 요청에 이 코드를 쓰면 안 되고(MUST NOT), 그런 경우에는 H3_REQUEST_CANCELLED(0x010c)를 쓴다(SHOULD, 4.1.1절). RFC 9114의 부록 A.4는 HTTP/2의 REFUSED_STREAM에 대응하는 코드로 H3_REQUEST_REJECTED를 든다.
GOAWAY 자체가 연결을 닫지 않는다는 점은 같다(7.2.6절). 받아들인 요청을 모두 처리한 뒤 연결을 유휴 상태로 두거나 바로 닫을 수 있고, 정상 종료를 마친 끝점은 연결을 닫을 때 H3_NO_ERROR를 써야 한다(SHOULD, 5.2절). 바로 닫아야 할 때는 QUIC CONNECTION_CLOSE 앞에 GOAWAY를 보낼 수 있고(MAY), 두 프레임을 같은 패킷에 넣으면 클라이언트가 GOAWAY를 받을 가능성이 높아진다(5.3절).
클라이언트에서 재시도를 판단하는 예
아래는 HTTP/2 연결이 끝났는데 응답을 다 받지 못한 요청에 대해, 새 연결로 자동 재시도해도 되는지 판단하는 함수다. 규칙은 위에서 인용한 RFC 9113 6.8절과 8.7절, RFC 9110 9.2.2절에서 가져왔고, 함수의 형태는 이 글에서 정한 것이다.
// RFC 9110이 정의한 메서드 중 멱등인 것
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"]);
function canRetryOnNewConnection(
streamId: number,
method: string,
lastStreamId: number | null, // 마지막으로 받은 GOAWAY의 값. 받지 못했으면 null
refused: boolean, // 이 스트림에 RST_STREAM(REFUSED_STREAM)을 받았는가
): boolean {
if (refused) return true; // 처리 전에 거절됨 (8.7절)
if (lastStreamId !== null && streamId > lastStreamId) return true; // 처리되지 않음 (6.8절)
return IDEMPOTENT.has(method); // 처리됐을 수 있음: 멱등일 때만 (6.8절, RFC 9110 9.2.2절)
}
HTTP/2의 스트림 번호는 2^31-1을 넘지 않으므로 number로 충분하다. HTTP/3은 다르다. 스트림 ID와 GOAWAY 값은 QUIC 가변 길이 정수이고(RFC 9114 2.2절), 앞에서 본 것처럼 서버가 보내는 첫 GOAWAY 값부터 2^62-4다. JavaScript number는 Number.MAX_SAFE_INTEGER(2^53-1)를 넘는 정수를 모두 정확히 나타내지 못하므로, 그 범위에서는 경계에 놓인 요청을 잘못 분류할 수 있다. HTTP/3용으로 옮길 때는 연산자만 바꾸지 말고 streamId와 goawayId를 둘 다 bigint처럼 값을 정확히 담는 형식으로 다뤄야 한다.
// HTTP/3: 스트림 ID와 GOAWAY 값은 최대 2^62-1인 QUIC 가변 길이 정수
function canRetryOnNewH3Connection(
streamId: bigint,
method: string,
goawayId: bigint | null, // 마지막으로 받은 GOAWAY의 값. 받지 못했으면 null
rejected: boolean, // 이 요청 스트림이 H3_REQUEST_REJECTED로 중단됐는가
): boolean {
if (rejected) return true; // 처리 전에 거절됨 (RFC 9114 4.1.1절)
if (goawayId !== null && streamId >= goawayId) return true; // 처리되지 않음 (5.2절)
return IDEMPOTENT.has(method); // 처리됐을 수 있음: 멱등일 때만 (RFC 9110 9.2.2절)
}
두 함수가 답하는 것은 프로토콜 규칙상 다시 보낼 자격이 있느냐까지다. 실제로 자동 재시도를 하려면 몇 가지가 더 맞아야 한다.
- 요청 본문을 처음부터 다시 보낼 수 있어야 한다. 스트림으로 흘려보내고 버린 본문은 새 연결에 다시 실을 수 없다. 이것은 RFC 규칙이 아니라 구현의 전제 조건이다.
- 이 코드를 프록시에서 쓴다면 멱등이 아닌 요청은 처리되지 않은 것이 확실해도 자동으로 재시도하지 않는다. RFC 9110 9.2.2절의 프록시 금지(MUST NOT)가 걸리기 때문이다.
- 함수는 몇 번째 시도인지 보지 않는다. 실패한 자동 재시도를 또 자동으로 재시도하지 않는다는 RFC 9110의 권고는 호출하는 쪽에서 시도 횟수를 세어 지켜야 한다.
정리
RFC 문구에서 바로 나오는 규칙과, 그것을 바탕으로 한 이 글의 제안을 나눠 적는다.
RFC의 규칙
- HTTP/2에서 Last-Stream-ID보다 큰 번호의 요청은 처리되지 않았다. 멱등이 아니어도 새 연결로 다시 보낼 수 있다.
- 그 번호 이하이면서 끝나지 않은 요청은 처리됐을 수 있다. 연결이 닫히면 멱등 요청만 다시 보낸다.
- GOAWAY 없이 끊긴 연결에서는 끝나지 않은 모든 요청이 처리됐을 수 있다고 본다.
- 정상 종료를 하는 서버는 2^31-1과 NO_ERROR로 첫 GOAWAY를 보내고, 최소 1 RTT 뒤에 실제 번호로 다시 보낼 수 있다. 번호는 키우지 않는다.
- HTTP/3은 경계가 “미만/이상”으로 바뀌고 GOAWAY에 오류 코드가 없다.
이 글의 제안
- 서버 배포 절차에서 프로세스를 내리기 전에 두 단계 GOAWAY를 보내고, 진행 중인 스트림이 끝나기를 기다리는 시간과 그 상한을 정해 둔다. 상한값은 RFC가 정하지 않으므로 서비스의 요청 처리 시간에 맞춰 고른다.
- 연결 종료를 기록할 때 GOAWAY를 받았는지, 그 값과 오류 코드가 무엇이었는지를 함께 남긴다. Additional Debug Data를 남긴다면 접근 권한을 제한한다.
- HTTP/2와 HTTP/3을 함께 다루는 코드에서는 경계 비교를 프로토콜별로 따로 테스트한다.
출처와 확인일
모두 2026-10-22에 확인했다.
- RFC 9113: HTTP/2 5.1.1절, 5.1.2절, 5.4절(5.4.1, 5.4.3 포함), 6.4절, 6.8절, 7장, 8.7절, 9.1절
- RFC 9114: HTTP/3 2.2절, 4.1.1절, 5.2절, 5.3절, 5.4절, 6.1절, 7.2.6절, 8.1절, 부록 A.1·A.2.5·A.4·A.4.1
- RFC 9110: HTTP Semantics 9.2.1절, 9.2.2절