본문으로 건너뛰기

[Uvicorn] bytes에서 bytearray로 변경하여 HTTP 바디 누적 O(n²) → O(n) 개선

PR 링크: encode/uvicorn#2845 상태: Merged | 변경: +6 / -6

들어가며

Uvicorn은 Python의 대표적인 ASGI 서버입니다. HTTP 요청 바디가 chunked transfer encoding으로 분할 전송될 때, 각 청크를 수신하면서 바디를 누적합니다. 기존 구현은 bytes += chunk 패턴을 사용했는데, Python의 bytes는 불변(immutable) 타입이므로 매번 새 객체를 할당하고 전체 내용을 복사해야 합니다. 이 PR은 가변(mutable) 타입인 bytearray로 변경하여 in-place 확장이 가능하도록 합니다.

핵심 코드 분석

Before: bytes += — O(n²)

# h11_impl.py
class RequestResponseCycle:
    def __init__(self, ...):
        self.body = b""       # 불변 bytes
        self.more_body = True

    async def receive(self) -> ASGIReceiveEvent:
        message: HTTPRequestEvent = {
            "type": "http.request",
            "body": self.body,           # 그대로 전달
            "more_body": self.more_body,
        }
        self.body = b""                  # 초기화
        return message

bytes는 불변이므로 self.body += chunk가 실행될 때마다:

  1. 기존 self.body 크기 + chunk 크기의 새 bytes 객체 할당
  2. 기존 내용 전체 복사
  3. 새 chunk 복사
  4. 이전 bytes 객체 GC 대상으로 전환

n개의 청크가 각각 k바이트라면, 총 복사량은 k + 2k + 3k + ... + nk = O(n²k)입니다.

After: bytearray += — amortized O(1)

# h11_impl.py
class RequestResponseCycle:
    def __init__(self, ...):
        self.body = bytearray()   # 가변 bytearray
        self.more_body = True

    async def receive(self) -> ASGIReceiveEvent:
        message: HTTPRequestEvent = {
            "type": "http.request",
            "body": bytes(self.body),    # ASGI 인터페이스를 위해 변환
            "more_body": self.more_body,
        }
        self.body = bytearray()          # 초기화
        return message

bytearray는 내부적으로 over-allocation 전략을 사용합니다. bytearray += chunk는 여유 공간이 있으면 memcpy만 수행하고, 공간이 부족하면 현재 크기의 약 1.125배로 재할당합니다. amortized O(1) per append입니다.

httptools_impl.py도 동일하게 변경

# httptools_impl.py
self.body = bytearray()  # b"" → bytearray()
message: HTTPRequestEvent = {
    "type": "http.request",
    "body": bytes(self.body),  # self.body → bytes(self.body)
    "more_body": self.more_body
}
self.body = bytearray()  # b"" → bytearray()

h11과 httptools 두 프로토콜 구현 모두에 적용됩니다.

왜 이게 좋은가

1. 대용량 요청에서의 실질적 효과

파일 업로드나 대규모 JSON 페이로드를 chunked transfer로 받을 때 효과가 극대화됩니다. 10MB 파일을 8KB 청크로 받으면 약 1,250개 청크이며:

  • bytes: 약 6.25GB의 누적 복사 (1,250 × 1,251 / 2 × 8KB)
  • bytearray: 약 10MB의 총 복사 + 몇 번의 재할당

2. ASGI 인터페이스 호환성

ASGI 명세에서 http.request 메시지의 bodybytes 타입이어야 합니다. bytes(self.body)로 최종 변환하는 비용은 O(n)이지만, receive()는 누적이 완료된 후 한 번만 호출되므로 전체 비용에 영향이 미미합니다.

3. CPython의 bytes 최적화 한계

CPython에는 bytes +=에 대한 특별 최적화(참조 카운트가 1일 때 in-place 확장 시도)가 있지만, 이는 보장되지 않으며 다른 Python 구현체(PyPy 등)에서는 적용되지 않습니다. bytearray는 모든 구현체에서 일관되게 효율적입니다.

4. 6줄의 변경, 984개 테스트 통과

b""bytearray(), self.bodybytes(self.body) 변경만으로 전체 테스트 984개가 통과합니다. 최소한의 변경으로 최대의 효과를 얻는 전형적인 성능 개선입니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글