httpx AsyncClient를 매번 새로 만들면 커넥션 풀이 사라지는 이유

httpx AsyncClient를 매번

이 글에서는 요청마다 httpx.AsyncClient()를 새로 생성하는 코드가 실제로 어떤 비용을 치르는지, 그리고 트래픽이 늘어날 때 왜 소켓 고갈로 이어지는지 확인할 수 있습니다. 핵심만 먼저 말씀드리면, httpx AsyncClient를 매번 새로 만들면 매 요청마다 TCP 연결과 TLS 협상을 처음부터 다시 진행하게 되고, 연결을 재사용할 기회 자체가 사라집니다. 요청량이 늘어나면 운영체제가 열어줄 수 있는 소켓 수를 넘어서면서 타임아웃이나 연결 오류가 생기는 상황까지 갈 수 있습니다.

매 요청마다 AsyncClient()를 새로 만들면 내부에서 무슨 일이 벌어질까요

httpx의 AsyncClient는 내부적으로 httpcore 기반 커넥션 풀을 들고 있습니다. 기본값은 httpx.Limits(max_keepalive_connections=20, max_connections=100, keepalive_expiry=5.0)으로, 최대 20개의 연결을 유지(keep-alive)하면서 재사용하도록 설계돼 있습니다. 문제는 아래처럼 요청 함수 안에서 클라이언트를 만들고 닫는 패턴입니다.

async def fetch(url: str):
    async with httpx.AsyncClient() as client:
        r = await client.get(url)
        return r.json()

async with 블록을 벗어나는 순간 client.aclose()가 호출되면서 방금 만든 풀과 그 안의 keep-alive 연결이 통째로 정리됩니다. 즉 연결을 재사용할 시점이 오기도 전에 풀 자체가 사라지는 셈입니다. 다음 요청이 들어오면 완전히 새로운 풀에서 완전히 새로운 연결을 다시 맺어야 합니다. 이 동작 방식은 httpx 공식 문서의 Advanced Usage 페이지에서 Limits와 클라이언트 인스턴스 관리 부분에 설명돼 있습니다.

커넥션 풀이 없으면 TCP 핸드셰이크와 TLS 협상 비용이 요청마다 쌓입니다

새 연결을 맺을 때마다 클라이언트와 서버는 전송 제어 프로토콜(TCP)의 3-way handshake(SYN → SYN/ACK → ACK)를 거칩니다. 대상이 https라면 여기에 TLS 핸드셰이크가 추가로 얹혀서 인증서 검증과 키 교환 과정을 한 번 더 거치게 됩니다. 연결을 재사용하는 구조라면 이 과정을 요청 하나당 한 번만 치르고 그다음부터는 이미 열린 소켓으로 데이터만 주고받으면 되는데, 클라이언트를 매번 새로 만들면 이 절차가 요청 수만큼 그대로 반복됩니다.

Python 비동기 HTTP 커넥션 풀 네트워크 다이어그램

수치를 단정하긴 어렵지만, 외부 API를 초당 수십~수백 회 호출하는 백엔드라면 이 반복 비용이 누적되면서 응답 지연이 늘어나고 CPU도 TLS 협상에 더 소모됩니다. 특히 같은 호스트를 반복 호출하는 프록시성 엔드포인트나 웹훅 릴레이 서버에서 체감이 큽니다.

소켓 고갈과 TIME_WAIT 누적, file descriptor 한도까지 가는 경로

연결을 닫을 때 능동적으로 종료(active close)를 시작한 쪽 소켓은 RFC 9293(TCP 명세)에 정의된 대로 일정 시간 TIME_WAIT 상태로 남습니다. 리눅스 기본 설정에서는 이 상태가 수십 초 단위로 유지되는 경우가 많은데, 짧게 살고 바로 죽는 클라이언트 연결을 초당 수백 개씩 만들면 TIME_WAIT 소켓이 빠르게 쌓입니다. 로컬 포트(에페메럴 포트) 범위는 유한하기 때문에, 쌓인 TIME_WAIT 소켓이 가용 포트를 다 차지하면 새 연결을 맺을 포트 자체가 부족해지는 소켓 고갈 상태에 이릅니다.

리눅스 환경이라면 아래 명령으로 현재 TIME_WAIT 소켓 수를 직접 확인해 볼 수 있습니다.

ss -tan state time-wait | wc -l

동시에 열린 파일 디스크립터 수도 ulimit -n으로 제한돼 있어서, 소켓 하나가 파일 디스크립터 하나를 차지하는 리눅스 구조상 이 한도에 먼저 걸릴 수도 있습니다. 클라이언트를 매번 새로 만드는 코드가 부하 테스트에서만 갑자기 Connection refusedCannot assign requested address 오류를 뿜는다면 이 경로를 의심해 볼 만합니다.

클라이언트를 앱 생명주기에 맞춰 한 번만 만들어 재사용해 보세요

FastAPI를 쓴다면 lifespan 이벤트로 앱 시작 시 클라이언트를 하나 만들고, 요청마다 그 인스턴스를 꺼내 쓰는 방식이 표준적인 해법입니다.

from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http_client = httpx.AsyncClient(
        limits=httpx.Limits(max_keepalive_connections=20, max_connections=100),
        timeout=10.0,
    )
    yield
    await app.state.http_client.aclose()

app = FastAPI(lifespan=lifespan)

@app.get("/proxy")
async def proxy(request: Request):
    client: httpx.AsyncClient = request.app.state.http_client
    r = await client.get("https://example.com")
    return r.json()

서버 소켓 네트워크 트래픽 데이터센터

이렇게 바꾸면 앱이 살아있는 동안 커넥션 풀 하나가 계속 유지되고, 같은 호스트로 가는 요청들이 keep-alive 연결을 나눠 쓰게 됩니다. FastAPI가 아닌 일반 스크립트나 워커라면 클라이언트를 모듈 레벨 싱글턴으로 두고 프로세스 종료 시 aclose()를 호출하는 방식으로도 같은 효과를 낼 수 있습니다.

재사용 클라이언트도 만능은 아닙니다 — 트레이드오프 짚기

싱글턴 클라이언트가 모든 상황에 정답인 건 아닙니다. 아래 표로 두 방식의 차이를 정리해 봤습니다.

구분 매번 새로 생성 앱 시작 시 한 번 생성해 재사용
TCP/TLS 핸드셰이크 요청마다 반복 최초 연결 이후 keep-alive로 생략
TIME_WAIT 소켓 발생 요청 수만큼 누적 연결 유지 중엔 발생 없음
멀티프로세스(워커 여러 개) 프로세스별 문제 없음 워커마다 별도 클라이언트 필요
asyncio 이벤트 루프 매번 새 루프에서도 무방 클라이언트를 만든 루프에서만 사용 가능

주의할 지점이 세 가지 있습니다. 첫 번째로, uvicorn --workers 4처럼 여러 프로세스로 띄우면 app.state는 프로세스마다 따로 존재하므로 클라이언트도 워커별로 하나씩 생깁니다. 이건 자연스러운 동작이고 문제는 아닙니다. 두 번째로, httpx의 AsyncClient는 생성된 이벤트 루프에 묶이기 때문에 asyncio.run()을 반복 호출하는 스크립트에서 클라이언트를 전역 변수로 재사용하면 “attached to a different loop” 계열 오류가 날 수 있습니다. 세 번째로, 기본 keepalive_expiry가 5.0초이기 때문에 5초 이상 요청이 뜸하면 idle 상태였던 연결이 자동으로 닫히고, 다음 요청은 다시 새 연결을 맺어야 합니다. 즉 재사용 구조로 바꿔도 트래픽이 뜨문뜨문 들어오는 서비스에서는 핸드셰이크를 완전히 없애지는 못합니다.

커넥션 풀을 재사용하도록 바꿨는데 왜 5초 넘게 쉬면 다시 느려지나요

바로 위에서 언급한 keepalive_expiry=5.0 기본값 때문입니다. httpx는 이 값을 넘겨서 아무 통신이 없던 keep-alive 연결을 풀에서 제거하도록 설계돼 있습니다. 요청 간격이 짧은 API 프록시나 배치성 크롤러라면 체감 효과가 크지만, 사용자 트래픽이 드문드문 들어오는 엔드포인트라면 매번 핸드셰이크가 새로 발생할 수 있습니다. 이 값을 늘리고 싶다면 httpx.Limits(keepalive_expiry=30.0)처럼 직접 지정할 수 있지만, 대상 서버나 로드밸런서가 유휴 연결을 얼마나 오래 허용하는지도 함께 고려해야 합니다. 서버 쪽 idle timeout보다 클라이언트의 keepalive_expiry를 길게 잡으면, 서버가 이미 끊은 연결을 클라이언트가 살아있다고 착각하고 재사용하려다 오류를 만나는 경우도 있습니다.

결국 httpx AsyncClient를 매번 새로 만드는 코드를 발견했다면, 요청 핸들러 안이 아니라 애플리케이션 생명주기 레벨에서 클라이언트 하나를 관리하도록 옮기는 것이 첫 번째 조치입니다. 그다음으로 트래픽 패턴에 맞춰 max_connections, max_keepalive_connections, keepalive_expiry 세 값을 실제 부하 테스트로 조정해 보는 것을 권합니다.

프롬프트를 코드에서 분리해 버전 관리하기, 배포 없이 고치면 생기는 문제들

Leave a Comment