Async, Thread, Process: Agent Worker를 이해하기 전에 알아야 할 것

기다리는 작업, blocking SDK, CPU-bound 계산에는 서로 다른 실행 도구가 필요하다

Agent backend의 작업 부하를 non-blocking I/O, blocking I/O, CPU-bound 계산으로 나누고 async, thread, process의 선택 기준을 설명합니다.

이전 글 에이전트는 왜 Async가 필요한가에서는 LLM, 데이터베이스, 검색 API의 대기를 겹치는 실행 모델을 살펴봤다. Async는 한 run 안의 독립적인 I/O를 동시에 진행하고, 한 run이 기다리는 동안 다른 run을 진행하게 한다.

그런데 현실의 Agent backend에는 await할 수 없는 코드도 들어온다.

  • 오래된 Python SDK가 동기식 API만 제공한다.
  • PDF parser가 큰 문서를 변환하는 동안 CPU를 계속 사용한다.
  • 몬테카를로 시뮬레이션이 수백만 번의 계산을 수행한다.
  • 로컬 임베딩이나 LLM 추론이 GPU에서 실행된다.

이 작업들을 모두 async def 안에 넣는다고 async 작업 부하가 되지는 않는다. Event loop thread에서 blocking 함수를 그대로 호출하면 다른 coroutine까지 멈추고, CPU-bound 루프를 thread로 옮긴다고 멀티코어 병렬 계산이 자동으로 생기지도 않는다.

핵심은 문법이 아니라 작업 부하가 어디에서 시간을 쓰는지다.

실행 도구를 고르기 전에 병목을 세 가지로 나눠라

Agent backend의 작업은 먼저 다음 세 범주로 나눌 수 있다.

작업 부하시간이 사라지는 곳자연스러운 첫 선택
Non-blocking I/O네트워크, 데이터베이스, queue 응답 대기Async I/O
Blocking I/O동기 SDK가 현재 thread를 점유한 채 대기제한된 thread pool
CPU-bound 계산Python 코드 또는 네이티브 코드의 실제 계산Process, 별도 CPU/GPU 서비스

이를 실행 구조로 그리면 다음과 같다.

Agent run
   │
   ├─ LLM / PostgreSQL / Redis ── non-blocking 대기 ──> async task
   │
   ├─ 기존 동기식 SDK ─────────── blocking 대기 ──────> 제한된 thread pool
   │
   └─ 고부하 계산 ──────────────── CPU 실행 ──────────> process / 연산 서비스

분류 기준은 라이브러리 이름이 아니다. 같은 SDK도 async 클라이언트와 동기식 클라이언트를 모두 제공할 수 있고, 같은 함수 안에서도 파일 읽기는 I/O-bound이지만 파싱은 CPU-bound일 수 있다. 실제 호출 경로를 보고 무엇을 기다리고, 무엇을 계산하는지를 판단해야 한다.

Async: 기다리는 동안 실행권을 돌려준다

Asyncio는 협력적 동시성 모델이다. Coroutine이 non-blocking I/O를 await하면 event loop에 실행권을 돌려주고, event loop는 그동안 실행 가능한 다른 task를 진행한다.

import asyncio

async def build_context(query: str):
    llm_task = asyncio.create_task(call_llm(query))
    db_task = asyncio.create_task(query_postgres(query))
    search_task = asyncio.create_task(search_documents(query))

    llm, rows, documents = await asyncio.gather(
        llm_task,
        db_task,
        search_task,
    )
    return {"llm": llm, "rows": rows, "documents": documents}

여기서 동시 실행이 가능한 이유는 함수 앞에 async가 붙었기 때문이 아니다. call_llm, query_postgres, search_documents가 소켓 응답을 기다릴 때 thread를 붙잡지 않고 실행권을 양보하기 때문이다.

Async의 비용 모델은 I/O 중심 Agent에 잘 맞는다. Task는 OS thread보다 가볍고, 하나의 event loop가 많은 대기 작업을 관리할 수 있다. 하지만 협력적이라는 것은 약점이기도 하다. 실행 중인 코드가 실행권을 양보하지 않으면 event loop는 다른 task를 진행할 수 없다.

httpx.AsyncClient로 외부 HTTP를 운영한다

이전 글에서는 await client.get()이 네트워크 대기 중 실행권을 양보한다는 원리를 봤다. Agent backend에서는 같은 고객 컨텍스트를 여러 HTTP API에서 모으는 일이 흔하다. 이때는 클라이언트 수명 주기, connection pool, 동시성 상한, timeout, 오류 처리, 재시도를 한 패턴으로 묶는 편이 낫다.

AsyncClient를 요청마다 새로 만들면 TCP/TLS handshake와 pool 설정 비용이 반복되고, 상위 서비스 입장에서는 짧은 burst로 보일 수 있다. Run이나 process 수명 주기에 맞춰 하나의 클라이언트를 재사용하면 keep-alive connection pool이 실제로 의미를 갖는다. (HTTPX async 문서)

독립적인 프로필 조회는 asyncio.gather()로 겹칠 수 있다. 다만 gather의 실패 정책은 업무 의미에 맞게 고른다. 기본값(return_exceptions=False)에서는 첫 예외가 호출자에게 전파되지만, 나머지 awaitable이 자동으로 모두 취소되는 것은 아니다. 한 고객 API만 실패해도 나머지 컨텍스트로 부분 답변을 만들 수 있다면 return_exceptions=True로 실패를 결과 목록에 모으거나, 성공한 결과만 필터링하는 쪽이 낫다. 반대로 한 분기 실패가 전체 run을 무효로 만든다면 예외를 그대로 올려 전체를 중단하는 편이 맞다. (asyncio.gather)

외부 API rate limit과 연결 풀을 보호하려면 동시 in-flight HTTP 수에 상한을 둔다. asyncio.Semaphore는 coroutine 단위에서 그 상한을 강제하는 작은 도구다.

import asyncio
import httpx

api_slots = asyncio.Semaphore(10)

async def fetch_profile(client: httpx.AsyncClient, customer_id: str) -> dict:
    async with api_slots:
        response = await client.get(
            f"https://api.example.com/customers/{customer_id}",
            timeout=httpx.Timeout(10.0, connect=2.0),
        )
        response.raise_for_status()
        return response.json()

async def load_context(customer_ids: list[str]) -> list[dict]:
    async with httpx.AsyncClient() as client:
        return await asyncio.gather(
            *(fetch_profile(client, customer_id) for customer_id in customer_ids),
        )

httpx.Timeout(10.0, connect=2.0)은 전체 요청과 연결 수립에 각각 상한을 둔다. connect timeout이 없으면 DNS나 TLS에서 hang이 길어질 수 있고, 전체 timeout이 없으면 느린 upstream이 coroutine을 오래 붙잡는다. response.raise_for_status()는 2xx가 아니면 HTTPStatusError를 올려 “JSON 파싱까지 간 뒤에야 실패를 알게 되는” 실수를 줄인다. 401·403·404 같은 클라이언트 4xx는 재시도해도 성공하지 않는 경우가 많고, 429·502·503은 일시적일 수 있지만 무한 재시도는 upstream과 자신의 queue를 함께 망가뜨린다. 재시도는 명시적 정책—최대 횟수, backoff, jitter, idempotency—과 상한이 있어야 한다.

동기 전용 SDK만 있다면 async HTTP 클라이언트로 바꿀 수 없다. 그때는 asyncio.to_thread()로 blocking 호출을 event loop 밖으로 옮기고, thread pool과 semaphore로 동시 blocking 호출 수를 제한한다. 반대로 async API가 있는데도 아래처럼 요청마다 AsyncClient()를 열고 닫는 패턴은 pool 이점을 버리는 안티패턴이다.

async def load_context_bad(customer_ids: list[str]) -> list[dict]:
    results = []
    for customer_id in customer_ids:
        async with httpx.AsyncClient() as client:  # 매번 새 pool
            results.append(await fetch_profile(client, customer_id))
    return results

운영 환경에서는 애플리케이션 startup에서 AsyncClient를 만들고 shutdown에서 닫거나, run handler가 공유 클라이언트를 주입받는 구조가 더 낫다. 인증 헤더나 base URL은 환경 설정에서 읽되, 예시 코드에는 실제 API 키를 넣지 않는다.

async def 안의 blocking call은 더 위험하다

다음 함수는 겉으로는 async지만 내부의 SDK가 동기식이라면 event loop를 막는다.

async def enrich_customer(customer_id: str):
    # sync_crm_client.get()이 network response를 기다리는 동안
    # 같은 event loop의 다른 run도 진행하지 못한다.
    profile = sync_crm_client.get(customer_id)
    return await summarize_profile(profile)

한 번의 호출이 500ms 걸리면 그 500ms 동안 관계없는 요청의 타이머, WebSocket heartbeat, 데이터베이스 응답 처리까지 지연될 수 있다. 개발 환경에서 사용자가 한 명일 때는 잘 보이지 않지만, 동시 요청이 늘면 꼬리 지연 시간이 갑자기 나빠진다.

라이브러리가 async API를 제공한다면 그것을 사용하는 편이 우선이다. 연결 풀링, 취소, 시간 초과 같은 동작을 event loop와 일관되게 관리할 수 있기 때문이다. Async API가 없고 작업이 blocking I/O라면 thread가 호환성 다리가 될 수 있다.

Thread: blocking 코드를 event loop 밖으로 옮기는 호환성 도구

Thread는 한 process 안에서 여러 실행 흐름을 만든다. 같은 메모리를 공유하므로 객체를 process 사이로 직렬화할 필요가 없고, 동기식 라이브러리를 비교적 작은 변경으로 호출할 수 있다.

Asyncio 애플리케이션에서는 asyncio.to_thread()로 blocking 함수를 별도 thread에서 실행할 수 있다.

import asyncio

def fetch_customer_sync(customer_id: str) -> dict:
    return sync_crm_client.get(customer_id, timeout=5)

async def enrich_customer(customer_id: str):
    profile = await asyncio.to_thread(
        fetch_customer_sync,
        customer_id,
    )
    return await summarize_profile(profile)

이제 CRM 호출을 기다리는 동안 event loop thread는 다른 coroutine을 진행할 수 있다. Python 공식 문서도 asyncio.to_thread()를 event loop를 막는 blocking I/O 함수에 주로 사용하는 방법으로 설명한다. (Running in Threads)

하지만 to_thread()는 blocking 코드를 non-blocking 코드로 변환하지 않는다. Blocking은 여전히 일어나며, 단지 다른 thread가 그 비용을 맡는다. 그래서 다음 경계가 필요하다.

  • 호출 자체에 시간 초과를 둔다.
  • 동시에 실행할 blocking 호출 수를 제한한다.
  • Thread에서 완료된 뒤에는 요청별 변경 가능 상태를 함부로 공유하지 않는다.
  • SDK가 thread-safe한지 확인한다.
  • 취소 가능한 awaitable과 실제 thread 작업의 종료를 같은 것으로 가정하지 않는다.

특히 동시 실행 한도가 중요하다. 요청마다 무제한으로 thread를 만들면 외부 API의 호출량 제한, 연결 풀, 메모리를 먼저 소진한다.

import asyncio

blocking_sdk_slots = asyncio.Semaphore(8)

async def call_legacy_sdk(payload: dict) -> dict:
    async with blocking_sdk_slots:
        return await asyncio.to_thread(
            legacy_sdk.execute,
            payload,
        )

Thread pool은 압력을 없애지 않는다. Event loop에서 thread queue로 옮길 뿐이다. 풀 크기, 시간 초과, 상위 서비스 한도를 함께 정해야 역압이 생긴다.

공유 메모리는 편리하지만 공짜가 아니다

Thread는 같은 process의 메모리를 공유한다. 이 특성은 큰 읽기 전용 설정이나 이미 만들어진 클라이언트를 함께 사용할 때 편리하다. 반대로 여러 thread가 같은 변경 가능 객체를 수정하면 경쟁 상태가 생길 수 있다.

from concurrent.futures import ThreadPoolExecutor
from threading import Lock

results: list[dict] = []
results_lock = Lock()

def collect_one(item: str) -> None:
    result = blocking_lookup(item)
    with results_lock:
        results.append(result)

with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(collect_one, items))

이 예시는 잠금으로 results 수정을 보호하지만, 가능하면 worker thread가 값을 반환하고 한 곳에서 결과를 합치는 구조가 더 단순하다. 공유 상태가 많아질수록 잠금 순서, 교착 상태, 경쟁 상태를 추론해야 하기 때문이다.

Agent 상태를 Python 객체 하나에 모아 여러 thread가 함께 수정하는 설계는 특히 위험하다. Run 상태와 이벤트 이력은 명시적인 책임 주체와 저장소 경계를 가져야 한다. 이 시리즈의 상태 편에서 그 경계를 다시 다룬다.

GIL: Thread가 유용하지 않다는 뜻은 아니다

기본 GIL 활성화 CPython에서는 한 시점에 하나의 thread만 Python 바이트코드를 실행할 수 있다. 따라서 순수 Python CPU-bound 루프를 thread 여러 개에 나눠도 일반적으로 여러 CPU 코어를 동시에 활용하는 성능 향상을 기대하기 어렵다. 문맥 전환 비용 때문에 오히려 느려질 수도 있다. Python 공식 문서도 CPU 자원을 더 활용하려면 multiprocessing이나 ProcessPoolExecutor를 고려하고, thread는 I/O-bound task에 여전히 적합하다고 설명한다. (threading 문서)

그렇다고 “Python thread는 항상 하나씩만 실행된다”고 일반화하면 정확하지 않다.

  • 네트워크나 파일 I/O를 기다리는 동안에는 다른 thread가 진행할 수 있다.
  • NumPy 같은 일부 네이티브 확장은 계산 중 GIL을 해제할 수 있다.
  • Python 3.13부터 GIL을 비활성화할 수 있는 free-threaded 빌드가 제공되지만 기본 빌드는 아니며, 확장 기능의 호환성과 실제 작업 부하 측정이 필요하다. (Free-threading 문서)

실무 판단은 더 단순하게 시작해도 된다. 일반적인 GIL 활성화 CPython에서 blocking I/O 호환에는 thread, 무거운 순수 Python 계산에는 process를 첫 가설로 두고 벤치마크로 확인한다.

Process: 별도 interpreter와 memory로 CPU를 병렬 사용한다

Process는 독립된 Python 인터프리터와 메모리 공간을 갖는다. 여러 process는 여러 CPU 코어에서 실제로 동시에 계산할 수 있으므로 순수 Python CPU-bound 작업 부하에 적합하다.

다음 예시는 async Agent가 CPU 사용량이 많은 점수 계산을 process pool에 넘기는 구조다.

import asyncio
from concurrent.futures import ProcessPoolExecutor

PROCESS_POOL = ProcessPoolExecutor(max_workers=4)

def score_scenarios(scenarios: list[dict]) -> list[float]:
    # CPU-bound pure-Python calculation
    return [run_monte_carlo(scenario) for scenario in scenarios]

async def calculate_risk(scenarios: list[dict]) -> list[float]:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        PROCESS_POOL,
        score_scenarios,
        scenarios,
    )

Event loop는 process의 계산 결과를 기다리는 동안 다른 I/O task를 진행할 수 있고, CPU 계산은 별도 process에서 실행된다. 다만 process에는 분명한 비용이 있다.

  • Process를 시작하고 유지하는 비용이 thread보다 크다.
  • 인자와 결과는 process 경계를 통과할 수 있도록 직렬화 가능해야 한다.
  • 큰 객체를 넘기면 직렬화와 메모리 복사 비용이 계산 이익을 상쇄할 수 있다.
  • 메모리 내 클라이언트, 열린 연결, 잠금을 아무렇게나 공유할 수 없다.
  • 애플리케이션 수명 주기에서 풀 생성과 종료를 명시적으로 관리해야 한다.

따라서 작은 JSON 파싱 하나마다 새 process를 띄우는 것은 좋은 선택이 아니다. Process pool을 애플리케이션 수명 주기에 맞춰 재사용하거나, 계산이 충분히 크고 독립적이라면 전용 CPU worker나 서비스로 분리하는 편이 낫다. multiprocessing이 하위 process를 이용해 GIL을 우회할 수 있다는 점과 ProcessPoolExecutor의 제약은 Python 공식 문서에도 명시되어 있다. (multiprocessing, concurrent.futures)

Agent backend는 셋을 함께 쓸 수 있다

실제 run은 한 종류의 작업 부하로만 이루어지지 않는다. 하나의 요청 안에서 LLM과 데이터베이스는 async로 기다리고, 기존 SDK는 thread로 격리하며, 큰 계산은 process나 별도 서비스로 보낼 수 있다.

import asyncio

async def execute_agent_run(request: dict) -> dict:
    # 1. Native async I/O: event loop에서 함께 진행한다.
    llm_task = asyncio.create_task(call_llm(request["prompt"]))
    data_task = asyncio.create_task(query_postgres(request["account_id"]))

    # 2. Blocking I/O: 제한된 thread에서 격리한다.
    crm_task = asyncio.create_task(
        call_legacy_sdk({"account_id": request["account_id"]})
    )

    llm, data, crm = await asyncio.gather(
        llm_task,
        data_task,
        crm_task,
    )

    # 3. CPU-bound calculation: process pool에 맡긴다.
    risk = await calculate_risk(build_scenarios(data, crm))

    return compose_answer(llm, data, crm, risk)
                         ┌─ LLM API ────────────────┐
                         ├─ PostgreSQL ─────────────┤ async I/O
요청 ────> event loop ───┤                          ├─> 결과 구성
                         ├─ legacy SDK ─> thread ───┤
                         └─ risk model ─> process ──┘

이 구조에서 event loop가 중심 스케줄러 역할을 하지만 모든 일을 직접 실행하지는 않는다. 기다림은 async task로 겹치고, 불가피한 blocking I/O는 제한된 thread pool로 격리하며, CPU 사용량이 많은 작업은 process 경계 밖으로 보낸다.

로컬 LLM 추론은 또 다른 경우다. 모델 가중치, 배치 처리, GPU 메모리, 커널 스케줄링을 관리해야 하므로 웹 process 안에서 ProcessPoolExecutor로 호출하기보다 전용 추론 서비스가 더 자연스러운 경우가 많다. Async Agent는 그 서비스에 네트워크 요청을 보내고 결과를 기다린다. Agent 관점에서는 다시 I/O-bound 호출이 된다.

선택 기준: 라이브러리가 아니라 병목부터 본다

상황첫 선택이유주의할 점
LLM, 검색 API 호출Async 클라이언트네트워크 대기를 겹칠 수 있다시간 초과, 호출량 제한, 동시 실행 상한
PostgreSQL, Redis 접근Async 드라이버소켓 대기 중 다른 run 진행연결 풀 크기
동기식 전용 SaaS SDKThread 위임Event loop blocking을 격리Thread 안전성, 풀 포화
파일 읽기Async API 또는 threadOS와 파일 API 특성에 따라 blocking 가능파일 수와 파일 디스크립터 한도
큰 순수 Python 계산Process pool여러 코어에서 병렬 실행 가능직렬화, 시작 비용, 메모리
NumPy와 네이티브 계산측정 후 thread 또는 process확장이 GIL을 해제할 수 있다라이브러리별 동작과 자원 과다 할당
대규모 몬테카를로전용 CPU worker독립 확장과 자원 제어Queue, 결과 영속화, 재시도
로컬 LLM 추론GPU 추론 서비스배치 처리와 GPU 수명 주기를 분리유입 제어, 모델 용량

여기서 “첫 선택”은 최종 답이 아니다. 작업 부하를 측정하고, 실제 SDK가 GIL을 해제하는지, 데이터 전송량이 얼마나 큰지, 하위 서비스 한도가 무엇인지 확인해야 한다. 하지만 이 표는 잘못된 출발을 피하게 한다.

자주 생기는 여섯 가지 실패

1. Blocking SDK를 event loop에서 직접 호출한다

함수 바깥이 async def라는 이유로 내부도 non-blocking이라고 착각한다. 결과는 한 요청의 SDK 지연 시간이 같은 process의 다른 요청까지 지연시키는 것이다.

2. CPU-bound 작업을 thread로 옮기고 수평 확장을 기대한다

기본 CPython에서 순수 Python 계산이라면 GIL 때문에 원하는 멀티코어 병렬성이 나오지 않을 가능성이 크다. Thread 위임이 event loop 응답성은 지킬 수 있어도 계산량 자체를 빨리 끝내주지는 않는다.

3. 짧은 I/O마다 process를 사용한다

Process 경계의 시작, IPC, 직렬화 비용이 실제 작업보다 커진다. I/O 대기는 async나 thread가 더 자연스럽다.

4. 공유된 변경 가능 상태를 보호 없이 여러 thread가 수정한다

우연히 테스트가 통과해도 운영 환경의 동시 실행에서 상태 손상이나 재현하기 어려운 경쟁 상태가 나타날 수 있다. 값을 반환해 한 책임 주체가 합치거나, 불가피하면 명시적인 동기화 장치를 둔다.

5. Web request마다 process pool을 새로 만든다

트래픽과 함께 process 수가 폭증하고 메모리가 중복된다. 풀은 애플리케이션 수명 주기에서 관리하거나, 큰 작업은 queue 뒤의 전용 연산 worker가 소유하게 해야 한다.

6. HTTP 호출마다 AsyncClient를 새로 만든다

순차 for-loop와 결합하면 latency도 늘고 connection pool도 매번 버려진다. 공유 클라이언트, gather, semaphore를 함께 쓰거나, SDK가 동기식뿐이면 to_thread 경로를 의도적으로 선택한다.

Async, thread, process는 수명 주기의 책임 주체가 아니다

여기까지의 세 도구는 주로 어떻게 동시에 진행하거나 병렬 실행할지에 답한다.

  • Async task는 event loop 안에서 I/O 대기를 겹친다.
  • Thread는 같은 process 안에서 blocking 호출을 격리한다.
  • Process는 별도 인터프리터와 메모리에서 계산을 실행한다.

하지만 운영 환경의 Agent에는 다른 질문이 남는다.

  • HTTP request가 끊겨도 5분짜리 run은 계속되어야 하는가?
  • Process가 죽으면 누가 미완료 작업을 다시 가져가는가?
  • 재시도 중인 run의 상태와 진행 상황은 어디에 저장하는가?
  • 같은 session에서 동시에 들어온 두 run을 어떻게 제한하는가?
  • 배포 중에도 진행 중인 작업을 안전하게 끝낼 수 있는가?

이 질문은 async, thread, process 중 무엇을 고를지로 답할 수 없다. 이것은 실행 수명 주기를 누가 책임지는가의 문제다.

동시성 기본 요소                           수명 주기의 책임
──────────────────────────────────         ─────────────────────────────
async / thread / process                   API process / queue / worker
"어떻게 진행하고 계산하는가?"              "누가 끝까지 책임지는가?"

Worker는 thread의 다른 이름도, process의 동의어도 아니다. Worker process 하나가 내부에서 asyncio event loop를 실행하고, blocking SDK를 thread pool로 보내며, CPU 계산을 별도 process나 연산 서비스에 위임할 수 있다.

어떻게 동시에 실행하나와 누가 이 작업의 수명 주기를 책임지나는 다른 질문이다.

다음 글 Worker는 Thread가 아니다에서는 API process와 실행 process를 왜 분리하는지, worker가 왜 동시성 기본 요소가 아니라 수명 주기의 책임 경계인지 살펴본다.

출처 / 데이터 / 코드

참고문헌

  1. asyncio — Asynchronous I/O — Python Documentation
  2. Coroutines and Tasks — Python Documentation
  3. Async Support — HTTPX
  4. threading — Thread-based Parallelism — Python Documentation
  5. multiprocessing — Process-based Parallelism — Python Documentation
  6. concurrent.futures — Launching Parallel Tasks — Python Documentation
  7. Coroutines and Tasks: Running in Threads — Python Documentation
  8. Python Support for Free Threading — Python Documentation

Discussion

토론

질문, 정정, 반례, 다른 해석이 있다면 남겨 주세요.