본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

청크 전송 — 길이를 미리 모르는 HTTP/1.1 본문

~9 min · streaming-async, chunked-transfer, http1

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"HTTP/1.1은 응답 본문이 어디서 끝나는지 분명히 표시해야 같은 연결을 다음 메시지에 다시 쓸 수 있어. 최종 길이를 미리 알 수 없다면 청크 전송 코딩이 각 조각의 길이와 마지막 지점을 본문 안에 기록해. 덕분에 연결을 닫아 끝을 알리지 않고도 생성되는 데이터를 곧바로 보낼 수 있어."

본문 경계가 필요한 이유

HTTP/1.1 연결에서 응답 본문을 읽는 클라이언트는 현재 응답의 끝을 알아야 뒤따르는 메시지를 올바르게 해석할 수 있어. 길이가 정해진 응답에는 주로 다음 두 방식이 보여:

  • Content-Length — 헤더에 정확한 바이트 수를 적어. 클라이언트는 그만큼 읽으면 본문이 끝났다고 판단해.
  • Connection: close — 연결 종료로 본문의 끝을 표시해. 동작은 하지만 그 연결을 다음 요청에 재사용할 수 없어.

AI 응답, 실시간 로그, 점진적으로 생성되는 쿼리 결과처럼 끝까지 만들어 보기 전에 전송을 시작해야 하는 본문은 Content-Length를 미리 계산할 수 없어. 연결 종료로 경계를 표시할 수도 있지만 연결 재사용을 포기해야 해. HTTP/1.1의 Transfer-Encoding: chunked는 최종 길이를 몰라도 본문 자체에 경계를 기록해 이 문제를 해결해.

청크의 전송 형식

각 청크는 데이터 바이트 수를 16진수로 적은 줄, CRLF, 해당 바이트, 다시 CRLF 순서로 구성돼. 크기가 0인 마지막 청크가 본문의 끝을 표시해:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked

2A\r\nevent: message\ndata: {"content":"Hi"}\n\n\r\n
2C\r\nevent: message\ndata: {"content":"아빠"}\n\n\r\n
0\r\n\r\n

2A2C는 16진수 크기 표기가 놓이는 자리를 보여 주는 값이야. 실제 메시지에서는 반드시 뒤따르는 데이터의 실제 바이트 수를 계산해 기록해야 하므로 표시된 숫자를 그대로 복사하면 안 돼. 0 뒤의 마지막 CRLF는 청크 본문이 끝났음을 나타내. 보통은 HTTP 서버와 클라이언트 라이브러리가 이 프레이밍을 처리하므로 애플리케이션이 청크 문법을 직접 만들지 않아.

길이를 모르는 스트림의 쓰임새

  • SSE — HTTP/1.1에서 개수와 총길이를 미리 알 수 없는 이벤트를 응답 본문에 이어 보낼 수 있어.
  • 대용량 다운로드 — 압축이나 변환 결과를 만들면서 보내는 등 최종 크기를 미리 계산하기 어려운 경우 전송을 먼저 시작할 수 있어.
  • 실시간 로그 — 새 로그 줄이 생길 때마다 끝나지 않은 응답에 이어 쓸 수 있어.
  • 점진적 쿼리 결과 — 데이터베이스에서 행을 읽는 대로 직렬화해 전체 결과를 메모리에 모으지 않고 전달할 수 있어.

HTTP/2와 HTTP/3는 Transfer-Encoding: chunked를 사용하지 않아. 대신 자체 DATA 프레임과 스트림 종료 표지를 사용해 최종 길이를 모르는 본문을 전달해. 애플리케이션에서 보이는 스트리밍 의미는 비슷하지만 전송선의 프레이밍은 서로 달라.

청크 전송 코딩은 HTTP/1.1의 요청과 응답 모델을 유지하면서 길이를 모르는 본문을 전달하는 프레이밍이야. SSE, 동적 다운로드, AI 응답 스트림은 HTTP/1.1 연결에서 이 방식을 사용할 수 있고, HTTP/2와 HTTP/3에서는 각 버전의 프레임이 같은 역할을 맡아. 애플리케이션은 보통 비동기 생성기나 스트리밍 API에 데이터 조각을 넘기고, 실제 전송 프레이밍은 HTTP 구현에 맡겨.

주의할 점

1. Trailer 필드. 청크 응답은 마지막 청크 뒤에 일부 필드를 보낼 수 있고, 보낼 필드 이름을 선두 헤더의 Trailer:로 알릴 수 있어. 스트림 전체를 읽은 뒤 계산되는 무결성 값 등에 쓸 수 있지만, 모든 중간 장치와 클라이언트 API가 trailer를 보존하거나 노출하는 것은 아니므로 호환성을 먼저 확인해야 해.

2. 버퍼링은 점진적 전달을 가려. 프록시나 압축 계층이 작은 조각을 모아 두면 HTTP/1.1이 chunked로 프레이밍되어 있어도 클라이언트에는 늦게 도착할 수 있어. X-Accel-Buffering: no는 Nginx에서 사용할 수 있는 설정 신호일 뿐이므로 실제 경로의 각 계층을 확인해야 해.

3. Content-Length와 함께 보내면 안 돼.송신자는 Transfer-Encoding이 있는 HTTP/1.1 메시지에 Content-Length를 함께 생성하면 안 돼. 두 헤더가 충돌하면 중간 장치마다 메시지 경계를 다르게 해석해 보안 문제로 이어질 수도 있으므로 프레임워크의 올바른 처리를 따르는 편이 안전해.

cwkPippa의 HTTP/1.1 스트리밍

cwkPippa의 FastAPI/Starlette StreamingResponse는 최종 Content-Length를 모르는 응답을 스트리밍해. Uvicorn이 HTTP/1.1로 응답할 때는 청크 전송 코딩을 사용할 수 있지만, 비동기 생성기의 yield 하나가 전송선의 청크 하나나 클라이언트의 읽기 한 번과 일치한다고 보장되지는 않아. 복구 계층의 핵심은 프레이밍 경계가 아니라 이벤트를 응답에 넘기기 전에 JSONL 로그에 기록하는 순서야. 따라서 클라이언트가 스트림 중간에 끊겨도 영속 기록에서 대화 상태를 복원할 수 있어.

Code

청크 구조 예시 — 16진수 크기는 실제 데이터 바이트 수와 일치해야 함·http
# Wire 위 chunked response (글자 그대로 CRLF 와 hex 크기 보여줌)
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

14\r\n
The first part body\r\n
B\r\n
 second part\r\n
0\r\n
\r\n

# Body 로 번역: 'The first part body second part'
# 14 hex = 20 byte; B hex = 11 byte; 0 가 끝 표시.
FastAPI — StreamingResponse로 길이를 모르는 본문 전달·python
# FastAPI — StreamingResponse 쓸 때 chunked encoding 자동
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI()

async def streamed_content():
    # 각 yield 가 wire 위 chunk 하나 됨 (Starlette 의 framing 후)
    for i in range(10):
        yield f'chunk {i}\n'
        await asyncio.sleep(0.5)

@app.get('/stream')
async def stream():
    # Body 길이 모르면 Starlette 가 자동 Transfer-Encoding: chunked 씀
    return StreamingResponse(streamed_content(), media_type='text/plain')

# curl -N (no buffering) 이 chunk 단위로 보게 해줌:
# curl -N http://localhost:8000/stream
# chunk 0   (0.5s 후 나타남)
# chunk 1   (1.0s 후 나타남)
# ... 등
클라이언트 — httpx.stream과 iter_text로 점진적 본문 소비·python
# Client 쪽 — httpx 와 curl 가 chunked 투명 처리
import httpx

with httpx.stream('GET', 'http://localhost:8000/stream') as resp:
    print('headers:', dict(resp.headers))  # Transfer-Encoding: chunked
    for chunk in resp.iter_text():
        print(f'받은 chunk: {chunk!r}')
# 받은 chunk: 'chunk 0\n'
# (0.5s)
# 받은 chunk: 'chunk 1\n'
# ...

# Curl: -N 가 curl 자체 buffering 비활성
# curl -N http://localhost:8000/stream

External links

Exercise

StreamingResponse를 사용해 1부터 100까지 초당 하나씩 보내는 FastAPI 엔드포인트 /count를 만들어 봐. 세 방식으로 수신 시각을 비교해: (1) curl -N http://localhost:8000/count, (2) -N을 뺀 curl http://localhost:8000/count, (3) Python의 httpx.stream과 iter_text. 마지막으로 dict(resp.headers)를 출력해 HTTP/1.1 연결에서 Content-Length 없이 Transfer-Encoding: chunked가 사용되는지 확인하고, 가능하다면 HTTP/2 경로에서는 헤더가 어떻게 달라지는지도 비교해.
Hint
-N은 curl이 받은 데이터를 표준 출력에 쓰는 버퍼링을 줄여 시간 간격을 보기 쉽게 해. httpx.stream의 iter_text가 돌려주는 문자열 조각도 애플리케이션의 yield나 HTTP 청크와 정확히 대응한다고 가정하면 안 돼. 로컬 Uvicorn의 일반 HTTP/1.1 응답에서는 최종 길이를 모를 때 chunked가 보일 수 있지만, HTTP/2에서는 Transfer-Encoding: chunked가 나타나지 않는 게 정상이다.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.