본문 바로가기
빌드노트 검색

빌드노트

다운로드 이어받기에서 200 응답을 붙이면 파일이 중복된다

Range를 보냈어도 서버는 전체 내용을 200으로 반환할 수 있다. 고정 16바이트의 206·200·416 응답을 비교하고, Content-Range와 실제 바이트·같은 버전을 확인해 조각 결합과 전체 재시작을 결정한다.

다운로드를 이어받으려고 Range를 보냈는데 완성된 파일이 원본보다 커졌다면, 응답을 기존 파일 뒤에 무조건 붙이는 코드를 먼저 확인한다. 요청에 Range가 있었다는 사실은 응답이 부분 조각이라는 보장이 아니다. 206이면 응답의 위치와 길이를 검사한 뒤 결합하고, 200이면 처음부터 온 전체 응답으로 새로 처리해야 한다.

RFC 9110의 Range 규칙 (새 탭에서 열림)은 서버가 Range를 무시할 수 있도록 허용한다. 따라서 다운로드 코드의 분기는 요청 헤더가 아니라 실제 응답에서 시작해야 한다.

같은 요청인데 4바이트와 16바이트가 왔다

확인을 위해 원본을 ASCII 0123456789ABCDEF 16바이트로 고정한 로컬 서버를 만들었다. 범위를 처리하는 경로와 의도적으로 무시하는 경로를 나눴다. 기존 파일에는 원본의 처음 4바이트인 0123만 저장했다고 가정했다. 사용자 다운로드 파일은 사용하지 않았다.

아래는 실제로 받은 상태, Content-Range, 본문을 추린 결과다. 앞의 두 요청에는 모두 Range: bytes=4-7을 보냈다. 세 번째는 범위 밖인 bytes=20-23으로 바꿨다.

범위 처리: 206 | Content-Range: bytes 4-7/16 | 4바이트  | 4567
범위 무시: 200 | Content-Range 없음         | 16바이트 | 0123456789ABCDEF
범위 밖:   416 | Content-Range: bytes */16  | 22바이트 | range not satisfiable\n

마지막 줄의 \n은 오류 본문 끝의 줄바꿈이다. 이 서버는 416에도 설명 본문을 넣었다. 수신한 바이트가 있다는 이유만으로 파일 데이터라고 볼 수는 없다. 오류 설명 22바이트와 원본 길이 16바이트는 서로 다른 값이다.

bytes 4-7/16은 전체 16바이트 가운데 위치 4부터 7까지를 보냈다는 뜻이다. 바이트 위치는 0에서 시작하고 양 끝을 포함하므로 길이는 7 - 4 + 1 = 4다. 이 응답의 Content-Length: 4는 이번 본문의 길이다. 전체 파일 길이는 Content-Range의 /16에서 읽는다. 바이트 범위 (새 탭에서 열림), 단일 부분 응답 (새 탭에서 열림)

실제 바이트를 결합하니 차이가 드러났다. 기존 0123 뒤에 206의 4567을 붙이면 01234567 8바이트였다. 이어서 bytes=8- 요청의 206 응답인 89ABCDEF를 검증해 붙였고, 결과 16바이트는 원본과 바이트 단위로 같았다. 첫 조각을 받았다는 것만으로 다운로드가 끝난 것은 아니다.

반대로 200 본문을 같은 접두부 뒤에 붙인 결과는 01230123456789ABCDEF 20바이트였다. 전체 응답을 별도 파일로 저장한 결과는 원본과 같았다. 이 실험에서 파일을 망가뜨린 지점은 200을 받은 뒤에도 이어 붙이기를 선택한 부분이다.

파일을 열기 전에 응답으로 저장 방식을 고른다

기존 파일이 원본의 0번 위치부터 빈틈없이 저장된 접두부라는 전제에서, 다음 순서로 판단한다. 파일의 크기만 같고 중간에 빠진 구간이 있는 경우에는 이 방식으로 이어 붙일 수 없다.

  • 206: 단일 범위의 Content-Range를 해석한다. 시작 위치는 기존 파일의 바이트 수와 같아야 하고, 끝 위치는 요청한 범위 안에 있어야 한다. 전체 길이를 안다면 끝 위치가 그보다 작은지도 확인한다. 본문을 끝까지 받은 실제 길이는 끝 - 시작 + 1과 같아야 한다. 검사에 실패하면 기존 파일에 쓰지 않는다.
  • 200: 기존 파일 뒤에 붙이지 않는다. 새 임시 파일에 전체 응답을 처음부터 받고, 전송과 내용 검증이 끝난 뒤 교체한다. 실패한 전송 때문에 기존 조각까지 먼저 잃지 않게 한다.
  • 416: 오류 본문을 파일 조각으로 쓰지 않는다. bytes */16이라면 현재 서버 쪽 길이가 16바이트라는 정보다. 저장한 길이와 버전을 다시 확인하고 재요청을 결정한다. 길이가 같더라도 416만으로 완성됐다고 판정하지 않는다.

이 목록은 단일 범위 다운로드를 위한 저장 정책이다. Content-Range 규칙 (새 탭에서 열림)은 잘못된 범위 값의 결합을 금지한다. 전체 길이가 *인 유효한 응답도 있으므로, 이를 처리하지 않는 구현은 임의로 전체 길이를 추측하지 말고 별도 경로로 보낸다. 416이 나타내는 거절의 의미는 RFC 9110 §15.5.17 (새 탭에서 열림)에서 확인할 수 있다.

위치와 길이가 맞아도 서로 다른 버전의 조각이면 섞어서는 안 된다. 기존 조각과 함께 저장한 강한 ETag가 "ascii16-v1"이라면 재개 요청에 If-Range: "ascii16-v1"을 붙일 수 있다. W/로 시작하는 약한 ETag는 여기에 사용할 수 없다. 조건이 맞지 않으면 서버는 Range를 무시하고 전체 응답을 보낸다. 이때도 200을 새 파일로 처리하는 분기가 필요하다. If-Range 조건 (새 탭에서 열림), 같은 강한 검증자로 조각 결합 (새 탭에서 열림)

실험에서는 같은 강한 ETag를 가진 조각끼리 결합했다. 다른 ETag를 If-Range에 넣은 요청에서는 200과 전체 16바이트를 받았다. 이는 합성 서버의 조건 분기를 확인한 결과이며, 실제 파일 교체 중의 동시 요청을 재현한 것은 아니다. 저장한 조각의 버전을 입증할 수 없다면 전체를 다시 받는 쪽을 선택한다.

내 다운로드 응답을 확인하려면

Windows PowerShell 5.1 이상과 curl.exe가 있으면 아래처럼 헤더와 본문을 따로 받을 수 있다. 빈 진단 폴더에서 $url에 확인할 실제 다운로드 URL 문자열을 먼저 지정한다. 예를 들어 $url = 'https://example.com/archive.bin'에서 주소를 자신의 것으로 바꾼다. 이 명령은 처음 4바이트가 저장된 상황의 다음 구간을 검사한다. 저장된 길이가 다르면 4-7도 바꿔야 한다.

curl.exe --silent --show-error --connect-timeout 2 --max-time 5 `
  --range 4-7 --header 'Accept-Encoding: identity' `
  --dump-header range-headers.txt --output range-body.bin "$url"
if ($LASTEXITCODE -ne 0) { throw 'Transfer failed; do not append.' }
Get-Content -LiteralPath ./range-headers.txt
$part = [System.IO.File]::ReadAllBytes((Join-Path $PWD 'range-body.bin'))
'received bytes: ' + $part.Length
[BitConverter]::ToString($part)

--range는 범위를 요청하고 --dump-header는 본문과 별도로 응답 헤더를 저장한다. curl 공식 사용법 (새 탭에서 열림)에 나온 옵션이다. 여기서는 자동으로 기존 파일에 붙이는 기능을 사용하지 않는다. 위 명령을 실험 서버에도 그대로 실행했으며, 206 경로에서는 received bytes: 4와 34-35-36-37을 확인했다. 뒤의 값은 ASCII 4567의 16진수 표현이다. 200 경로에서는 16바이트를 받았다.

자신의 서버에서 200이 나오면 Range를 요청했다는 이유로 이 파일을 기존 다운로드에 붙이지 않는다. 206인데 시작 위치나 수신 길이가 다르면 결합을 중단한다. 3xx라면 먼저 Location과 실제 다운로드 주소를 확인한다. 전송 실패로 명령이 중단됐다면 남은 파일도 완성된 조각으로 취급하지 않는다. 큰 응답을 모두 메모리로 읽는 검사용 명령이므로 작은 진단 대상을 고른다.

Accept-Encoding: identity를 보냈더라도 응답의 Content-Encoding을 확인한다. 압축된 표현의 범위는 압축된 바이트를 기준으로 하므로, 자동 압축 해제한 파일 길이와 섞어 계산하면 안 된다. 이 글은 압축하지 않은 단일 범위만 다룬다. multipart/byteranges 응답은 각 부분을 해석해야 하며 본문 전체를 그대로 붙일 대상이 아니다. 바이트 범위의 적용 대상 (새 탭에서 열림), 206 응답 해석 (새 탭에서 열림)

실행 환경은 Windows 11, Python 3.12.10의 loopback 서버, Windows PowerShell 5.1.26100.9444, curl 8.21.0이었다. 고정 16바이트의 상태·범위·본문과 결합 결과를 검증했으며, TLS·CDN·프록시·브라우저의 재개 동작, 대용량 파일과 전송 도중 연결이 끊기는 경우는 검증하지 않았다.

← 전체 글 보기