에이전트는 왜 Async가 필요한가: 기다리는 동안 다른 일을 시키는 법

LLM, DB, 검색 API를 기다리는 시간을 동시성으로 바꾸는 실행 모델

Agent backend에서 async가 필요한 이유를 blocking, coroutine, task, event loop, await와 구조화된 동시성을 통해 설명합니다.

이전 글 Agent Backend 한 장 지도에서 API·queue·worker·PostgreSQL·Redis·SSE의 역할을 한 번에 봤다. 그 전제 위에서, worker 안에서 Agent가 LLM·DB·검색 API를 기다리는 실행 모델이 async가 되는 이유를 다룬다. 에이전트 패턴 설계에서 정리한 병렬 패턴을 Python으로 쓰면 이런 코드가 나온다.

research, data = await asyncio.gather(
    research_agent(query),
    data_agent(query),
)

코드는 짧다. 하지만 이 두 줄을 이해하려면 문법보다 실행 모델을 먼저 알아야 한다.

  • 무엇이 기다리는 작업이고, 무엇이 계산하는 작업인가?
  • await에서 멈춘 coroutine 대신 누가 다른 일을 진행하는가?
  • 독립적인 작업과 순서가 필요한 작업을 어떻게 구분하는가?
  • async 함수 안에서 blocking SDK를 부르면 왜 다른 run까지 느려지는가?

운영 환경의 Agent는 한 번의 요청 안에서도 여러 I/O를 기다리고, 하나의 backend process 안에서도 여러 요청을 진행한다. Async를 모르면 어디서 시간이 사라지는지, 왜 한 blocking 호출이 관계없는 요청까지 멈추는지 설명하기 어렵다.

Agent는 계산보다 기다리는 시간이 길다

“NVIDIA의 최근 실적과 기업 가치를 분석해줘”라는 요청을 생각해 보자. Agent는 사내 문서 검색, 웹 검색, 데이터베이스 질의를 호출할 수 있다. 각 호출이 단순하게 2초씩 걸린다고 가정하자.

async def collect_context(query: str):
    docs = await search_documents(query)  # 약 2초
    web = await search_web(query)         # 약 2초
    db = await query_database(query)      # 약 2초
    return docs, web, db

이 코드는 async def와 await를 사용하지만 실행은 순차적이다. 문서 검색이 끝난 뒤 웹 검색을 시작하고, 그것이 끝난 뒤 데이터베이스를 조회한다. 각 작업이 2초라면 전체는 대략 6초다.

중요한 오해가 여기서 나온다.

await가 있다고 자동으로 동시에 실행되는 것은 아니다.

독립적인 세 작업이라면 동시에 진행하도록 명시해야 한다.

import asyncio

async def collect_context(query: str):
    docs, web, db = await asyncio.gather(
        search_documents(query),
        search_web(query),
        query_database(query),
    )
    return docs, web, db

가장 느린 작업도 2초라면 전체 시간은 약 2초에 스케줄링 부가 비용을 더한 정도가 된다. 일을 세 배 빨리 계산한 것이 아니다. 세 요청을 먼저 보내고, 각각의 응답을 기다리는 구간을 겹쳤다.

순차 실행

문서 검색  [ 요청 ───── 대기 ───── 응답 ]
웹 검색                                         [ 요청 ── 대기 ── 응답 ]
데이터베이스                                                        [ 요청 ── 대기 ── 응답 ]
time       0s                         2s                         4s                         6s

동시 진행

문서 검색     [ 요청 ───── 대기 ───── 응답 ]
웹 검색       [ 요청 ───── 대기 ───── 응답 ]
데이터베이스  [ 요청 ───── 대기 ───── 응답 ]
time       0s                         2s

이것이 한 run 안에서 얻는 첫 번째 효과다. 독립적인 도구 호출의 대기 시간을 겹쳐 종단 간 지연 시간을 줄인다.

한 run의 지연 시간보다 더 중요한 것: 여러 run의 처리량

Async를 gather로 API 세 개를 동시에 부르는 기법으로만 이해하면 절반을 놓친다. 운영 backend에는 서로 다른 사용자의 run이 계속 들어온다.

Run A가 LLM 응답을 20초 기다리는 동안 process 전체가 멈춰 있어야 할 이유는 없다. 그 사이 Run B는 데이터베이스 질의를 보낼 수 있고, Run C는 Redis에서 상태를 읽을 수 있다.

Run A  LLM 요청 ──────────────── 대기 ──────────────── 재개
                                 │
Run B                            └─ DB 질의 ── 대기 ── 재개
                                                │
Run C                                           └─ Redis ─ 대기 ─ 재개

두 효과를 구분해 두면 async의 역할이 선명해진다.

범위async가 만드는 변화
한 run 내부독립적인 I/O 대기를 겹쳐 지연 시간을 줄인다
여러 run 사이한 run이 기다릴 때 다른 run을 진행해 처리량을 높인다

Async는 외부 API 하나를 더 빨리 응답하게 만들지 않는다. 대신 그 응답을 기다리는 동안 다른 일을 진행하게 한다. 따라서 async가 개선하는 것은 CPU 성능이 아니라 실행 자원의 이용률과 I/O 중심 backend의 처리량이다.

물론 event loop가 여러 run을 진행할 수 있다는 사실이 그 run을 지속 가능하게 소유한다는 뜻은 아니다. API process가 재시작되어도 살아남아야 하는 장시간 작업은 queue와 worker라는 별도의 수명 주기 경계가 필요하다. 그 문제는 5편 Worker는 Thread가 아니다에서 다룬다.

Blocking과 non-blocking은 무엇이 다른가

Blocking 호출은 결과가 돌아올 때까지 현재 thread를 붙잡는다. 같은 event loop thread에 놓인 다른 coroutine도 그동안 진행할 수 없다.

외부 서비스 호출
        ↓
thread 대기 ───────────────────────── 응답
        ↓
계속 실행

Non-blocking I/O는 운영체제나 런타임에 “이 작업이 준비되면 알려달라”고 맡기고 현재 실행권을 돌려준다. event loop는 그 사이 실행 가능한 다른 coroutine을 고른다.

Task A: LLM 요청 전송 ───────── 응답 await ──────────── 재개
                               │
Task B:                        └─ DB 질의 ─ await ───── 재개
                                              │
Task C:                                       └─ Redis 읽기 ─ await ─ 재개

한 thread가 같은 순간에 Python 코드 세 줄을 실행하는 것은 아니다. 한 coroutine이 I/O에서 실행권을 양보하면 event loop가 다른 실행 가능한 coroutine을 고른다. Python 공식 문서는 asyncio를 async/await 문법으로 동시성 코드를 작성하는 라이브러리로 설명하며, 특히 I/O-bound 작업과 고수준 네트워크 코드에 잘 맞는다고 정리한다. (asyncio 문서)

여기서 “양보”가 중요하다. Async는 협력적으로 동작한다. 실행 중인 coroutine이 await 가능한 I/O에 도달해야 event loop가 다른 작업을 진행할 기회를 얻는다.

Coroutine, task, event loop를 분리해서 보자

async def는 coroutine 함수를 정의한다. 이 함수를 호출하면 결과가 바로 실행되는 것이 아니라 coroutine 객체가 만들어진다. 공식 문서도 coroutine 함수를 단순히 호출하는 것만으로는 실행 일정이 잡히지 않는다고 설명한다. Coroutine을 실제로 진행하려면 직접 await하거나, 최상위에서 asyncio.run()으로 event loop를 구동하거나, task로 등록해야 한다. (Coroutines and Tasks)

세 개념을 이렇게 나누면 쉽다.

개념역할
Coroutine일시 중단하고 다시 이어갈 수 있는 작업의 실행 단위
Taskcoroutine을 event loop에 등록하고 상태·결과·취소를 추적하는 래퍼
Event loop실행 가능한 task를 고르고 I/O 완료·타이머 같은 이벤트에 맞춰 다시 깨우는 스케줄러

asyncio.create_task()는 coroutine을 task로 감싸 현재 event loop에 등록한다. 아래에서 첫 번째 줄은 coroutine 객체만 만들고, 두 번째 줄은 실제로 진행될 수 있는 task를 만든다.

async def collect_context(query: str):
    docs_coroutine = search_documents(query)  # 아직 schedule되지 않았다.
    docs_task = asyncio.create_task(docs_coroutine)
    web_task = asyncio.create_task(search_web(query))

    # 두 task는 이 지점부터 진행될 수 있다.
    docs = await docs_task
    web = await web_task
    return docs, web

Coroutine 객체만 만들고 await하지 않으면 작업은 완료되지 않는다. Task를 만들어 놓고 잊어버리는 방식도 안전하지 않다. 누가 결과와 오류를 회수하는지 불분명해지고, API process가 종료되면 작업도 함께 사라질 수 있다. 장시간 Agent run의 수명 주기를 누가 소유해야 하는지는 5편에서 worker와 함께 다시 다룬다.

await는 thread를 잠재우는 명령이 아니다

await를 “여기서 멈춘다”로만 이해하면 절반만 맞다. 더 정확히는 다음 의미에 가깝다.

현재 coroutine은 이 awaitable이 준비될 때까지 진행할 수 없다. 아직 준비되지 않았다면 실행권을 돌려주고, event loop는 다른 task를 진행할 수 있다.

예를 들어 asyncio.sleep()은 현재 task를 suspend하고 다른 task가 실행될 수 있게 한다. 반면 time.sleep()은 thread 자체를 막는다.

import asyncio
import time

async def cooperative_wait():
    await asyncio.sleep(1)  # 다른 task가 실행될 수 있다.

async def blocking_wait():
    time.sleep(1)           # event-loop thread 전체가 멈춘다.

함수 앞에 async def를 붙였다고 내부의 blocking 코드가 non-blocking으로 변하지 않는다. 이 차이는 Agent에서 특히 위험하다. LLM SDK는 async인데 추적 정보 내보내기, 데이터베이스 드라이버, 파일 작업 중 하나가 동기식이라면 그 호출 동안 같은 event loop의 다른 run도 지연될 수 있다.

외부 HTTP API에서 blocking과 awaitable I/O를 본다

Agent run은 CRM, 검색, 결제 게이트웨이처럼 HTTP API를 자주 호출한다. 동기식 requests 클라이언트의 get()은 소켓 응답을 기다리는 동안 현재 thread—event loop thread—를 붙잡는다. coroutine 안에 있어도 await 지점이 없으면 실행권을 양보하지 않는다.

import requests

async def fetch_quote_bad(symbol: str) -> dict:
    # DNS, TLS, 응답 body를 읽는 동안 다른 coroutine은 진행하지 못한다.
    response = requests.get(
        f"https://api.example.com/quotes/{symbol}",
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

같은 process에서 Run B가 Redis를 읽으려 해도 Run A가 requests.get() 안에 머무는 동안 event loop는 Run B를 깨울 기회를 얻지 못한다.

httpx.AsyncClient와 await client.get()은 네트워크 대기를 awaitable로 만든다. 응답이 준비될 때까지 coroutine은 실행권을 돌려주고, event loop는 그동안 다른 task를 진행한다. (HTTPX async 문서)

import httpx

async def fetch_quote(symbol: str, client: httpx.AsyncClient) -> dict:
    response = await client.get(
        f"https://api.example.com/quotes/{symbol}",
        timeout=10.0,
    )
    response.raise_for_status()
    return response.json()

이 글에서는 원리와 최소 예시까지만 다룬다. Connection pool 재사용, 여러 호출을 asyncio.gather()로 겹치기, 동시 요청 상한, timeout과 재시도 정책은 다음 글 Async, Thread, Process에서 HTTPX를 중심으로 정리한다.

Concurrency와 parallelism은 같은 말이 아니다

둘은 자주 섞여 쓰이지만 다른 질문에 답한다.

Concurrency
= 여러 작업이 같은 시간 구간 안에서 번갈아 진행되는가?

Parallelism
= 여러 작업이 실제 같은 순간에 서로 다른 연산 자원에서 실행되는가?

Asyncio의 중심은 동시성이다. Task A가 네트워크 응답을 기다리는 동안 Task B가 요청을 만들고, B가 기다리면 Task C가 상태를 갱신한다. 한 event loop thread만으로도 많은 I/O 대기를 겹칠 수 있다.

Parallelism은 CPU 코어나 별도 process, GPU처럼 실제 계산 자원을 여러 개 쓰는 문제다. Async 코드가 자동으로 CPU-bound 계산을 여러 코어에 나누지는 않는다.

작업 부하대부분의 시간자연스러운 첫 선택
LLM API 호출네트워크 응답 대기async I/O
PostgreSQL / Redis소켓 I/O 대기async 클라이언트
벡터 DB / 웹 검색네트워크 I/O 대기async I/O
큰 순수 Python 계산CPU 실행process 또는 별도 연산 worker
로컬 모델 추론GPU 연산추론 서비스

이 글은 첫 번째 부류를 다룬다. Thread와 process가 각각 어디에 필요한지는 다음 글의 주제다.

gather의 결과와 실패 의미까지 설계해야 한다

asyncio.gather()는 여러 awaitable을 동시에 실행하고, 모두 성공하면 입력 순서에 맞춰 결과를 돌려준다. 그러나 “동시에 시작한다”만 보고 실패 의미를 지나치면 안 된다.

기본값인 return_exceptions=False에서는 첫 예외가 await하는 호출자에게 전달된다. 그렇다고 나머지 awaitable을 자동으로 모두 취소하는 것은 아니다. 반대로 return_exceptions=True를 쓰면 예외도 결과 목록 안에 들어온다. (공식 문서)

Agent orchestration에서는 어느 쪽이 맞을까? 정답은 작업 관계에 달려 있다.

  • 조사가 실패해도 데이터 결과만으로 부분 답변을 만들 수 있다면 실패 격리가 필요하다.
  • 한 분기가 실패하면 나머지 결과가 의미 없어지는 작업이라면 함께 실행되는 작업의 취소가 더 자연스럽다.
  • 부수 효과가 섞여 있다면 “다른 작업을 취소했다”와 “원격 행동이 실제로 취소됐다”를 구분해야 한다.

Python 3.11+에서는 관련 task를 렉시컬 스코프 안에서 관리하는 TaskGroup을 사용할 수 있다.

import asyncio

async def collect_context(query: str):
    async with asyncio.TaskGroup() as group:
        docs_task = group.create_task(search_documents(query))
        web_task = group.create_task(search_web(query))
        db_task = group.create_task(query_database(query))

    # context를 빠져나오면 세 task는 모두 완료된 상태다.
    return docs_task.result(), web_task.result(), db_task.result()

TaskGroup은 하위 task 중 하나가 일반 예외로 실패하면 나머지를 취소하고 모두 정리한 뒤 예외 그룹을 올린다. 이것은 gather보다 무조건 낫다는 뜻이 아니다. 관련 작업을 한 범위의 수명 주기로 묶고 싶은 경우에 실패 전파가 더 분명하다는 뜻이다.

Async가 있어도 concurrency는 무한하지 않다

독립적인 작업을 동시에 실행할 수 있다는 말과 전부 동시에 실행해야 한다는 말은 다르다. Agent 하나가 100개의 URL을 발견했다고 100개 request를 즉시 시작하면 다음 문제가 생긴다.

  • 상위 서비스의 호출량 제한
  • 데이터베이스 연결 풀 고갈
  • 소켓과 메모리 사용량 증가
  • 재시도가 겹치며 더 큰 요청 폭증 발생
  • 한 테넌트가 worker 용량 독점

따라서 동시성에는 상한이 있어야 한다. 작은 범위에서는 세마포어로 동시에 진행 중인 작업 수를 제한할 수 있다. 하지만 8이나 100 같은 숫자가 보편적인 정답은 아니다. 외부 API 할당량, 연결 풀, 메모리, 지연 시간 SLO, 테넌트 간 공정성을 보고 정해야 한다. 훗날 다룰 역압과 유입 제어는 이 문제를 worker와 시스템 전체 수준으로 확장한다.

Blocking 라이브러리는 event loop 안에서 그대로 부르면 안 된다

현실의 코드베이스에는 동기식 SDK가 많다. Async 함수에서 그것을 직접 호출하면 해당 thread가 막힌다.

async def search_bad(query: str):
    return blocking_search_client.search(query)  # event loop를 막을 수 있다.

async def search_with_bridge(query: str):
    return await asyncio.to_thread(
        blocking_search_client.search,
        query,
    )

당장 async 클라이언트로 바꾸기 어렵고 호출이 주로 blocking I/O라면 asyncio.to_thread()가 전환을 위한 다리가 될 수 있다. 하지만 blocking 호출이 사라진 것은 아니다. 다른 thread로 옮겼을 뿐이다. Thread pool의 크기, 취소, SDK의 thread 안전성을 여전히 생각해야 한다. 특히 CPU 사용량이 많은 Python 함수를 to_thread()로 보냈다고 자동으로 좋은 병렬성이 생기는 것은 아니다.

Agent run 하나를 다시 보면

Async는 Agent의 제어 흐름을 대신 설계하지 않는다. 독립성과 의존성을 먼저 정한 뒤 실행 모델을 적용한다.

async def analyze_company(query: str):
    # 독립적인 I/O는 동시에 진행한다.
    research, metrics = await asyncio.gather(
        research_agent(query),
        data_agent(query),
    )

    # synthesis는 두 결과에 의존하므로 그 다음에 실행한다.
    draft = await synthesis_agent(
        query=query,
        research=research,
        metrics=metrics,
    )

    # persistence 역시 I/O지만, 완료 semantics가 필요하다.
    await save_result(query=query, answer=draft)
    return draft

여기서 조사와 데이터 분석은 동시에 진행할 수 있다. 종합은 두 의존 작업이 끝나야 한다. 결과 저장은 클라이언트에게 완료를 알리기 전에 끝나야 하는지, 별도 작업으로 넘겨도 되는지 제품 계약에 따라 달라진다. asyncio.gather는 이 관계를 발견해 주지 않는다. 1편의 제어 흐름 설계가 먼저이고, async는 그 설계를 기다림에 강한 실행으로 옮긴다.

운영 환경에서 확인할 질문

Agent backend에 async를 적용할 때는 문법보다 다음 질문이 더 중요하다.

질문확인할 것
실제로 I/O-bound인가?네트워크, DB, 저장소 대기 비율
클라이언트가 non-blocking인가?async SDK나 드라이버인지, 내부에서 막지 않는지
무엇이 독립적인가?의존성이 없는 작업만 동시에 실행
실패는 서로 격리되는가?부분 결과, 함께 실행되는 작업의 취소, 재시도 정책
동시 실행 상한은?호출량 제한, 풀, 메모리, 테넌트 간 공정성
수명 주기는 누가 소유하는가?HTTP 요청, 백그라운드 task, queue worker
취소가 실제로 어디까지 전달되는가?로컬 task와 원격 작업의 차이

마지막 두 질문은 async만으로 해결되지 않는다. Event loop에서 task를 만들었다고 영속 작업이 되지 않고, 로컬 task를 취소했다고 이미 전달된 원격 요청이 반드시 멈추는 것도 아니다.

Async가 해결하지 못하는 것

이 글의 예제는 대부분 기다리는 일이다. 그런데 task가 기다리지 않고 CPU를 오래 사용하면 어떻게 될까?

async def misleading_async_job(rows):
    return expensive_python_calculation(rows)  # await 지점 없이 CPU를 계속 점유

함수 선언만 async일 뿐, 계산이 끝날 때까지 event loop thread를 놓지 않는다. 그동안 다른 coroutine은 진행할 기회를 얻지 못한다. Async는 CPU-bound 작업을 빠르게 만들지도, 여러 코어에서 자동으로 병렬화하지도 않는다.

다음 글 Async, Thread, Process에서는 기다리는 작업과 계산하는 작업을 나누고, blocking SDK를 thread로 옮기는 경우, CPU-bound 계산을 process나 별도 worker에 두는 경우를 비교한다. Async는 시작점이지만 모든 동시성 문제의 답은 아니다.

출처 / 데이터 / 코드

참고문헌

  1. asyncio — Asynchronous I/O — Python Documentation
  2. Coroutines and Tasks — Python Documentation
  3. Developing with asyncio — Python Documentation
  4. Async Support — HTTPX

Discussion

토론

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