Introduction

부트캠프에서 모델 추론 서버를 다루다 만난 질문에서 출발한 글이다. GPU가 열심히 연산하는 동안 CPU와 웹 서버의 스레드는 무엇을 하고 있는가? 이 글은 추론 요청이 일반적인 웹 요청과 어떻게 다른지 해부한 뒤, 스트리밍 응답, 동적 배칭, RAG 워크플로우라는 세 가지 실전 시나리오를 통해 모델 추론 API가 왜 비동기로 설계되어야 하는지 보인다. 전제가 되는 파이썬의 비동기 동작 원리 — GIL, 이벤트 루프, FastAPI의 def vs async def — 는 앞선 글인 파이썬의 비동기와 GIL에서 다뤘다.

모델 추론 API

이제 모델 추론 API가 무엇을 의미하는지 살펴보자.

그림 1. 여러 입력과 출력 단계를 거쳐 완성되는 추론 모델.

추론 모델이란?

추론 모델(reasoning model)이란 답을 내기 전에 생각의 사슬(chain of thought)을 길게 이어 답변을 생성하는 인공지능을 의미한다. 추론 모델은 위 그림처럼 여러 가지 입력과 출력 단계를 거쳐 완성된다. 이 과정에서 모델 추론 요청은 일반적인 웹 요청 처리와 다른 단계를 가지게 된다.

  1. 요청 수신: 클라이언트로부터 이미지나 텍스트 데이터를 수신하는 과정이다. 고화질 이미지나 대용량 문서가 있다면 전송에 상당한 시간이 소요된다.
  2. 전처리: 이미지의 크기를 조정하거나 텍스트를 인공지능이 이해하기 쉽게 바꾸는 과정을 거치는데, 이는 주로 CPU를 사용한다.
  3. 데이터 전송: 전송된 데이터는 주 메모리로 들어오는데, 인공지능 연산은 GPU가 더 잘 처리하기 때문에 이 데이터를 GPU의 메모리(VRAM)1로 복사하는 과정이 필요하다.
  4. 모델 연산: 데이터가 준비되면 GPU를 통해 인공지능이 답변을 생성하기 위한 연산을 수행한다. 이때 CPU는 이 작업이 끝나기를 기다린다.
  5. 후처리: 모델 연산 결과를 JSON처럼 사람이 읽을 수 있는 형식으로 변환한다.
  6. 응답 전송: 네트워크를 통해 결과를 클라이언트에게 반환한다.
from openai import OpenAI

client = OpenAI()

prompt = """
Write a bash script that takes a matrix represented as a string with
format '[1,2],[3,4],[5,6]' and prints the transpose in the same format.
"""

response = client.responses.create(
    model="gpt-5",
    reasoning={"effort": "medium"},
    input=[
        {
            "role": "user",
            "content": prompt
        }
    ]
)

print(response.output_text)

실제로 클라이언트에서 GPT-5를 사용하기 위한 API 호출을 보면, 먼저 요청을 생성한 뒤 응답이 올 때 response로 받는 것을 확인할 수 있다.

이때 중요한 것은 GPU가 연산하고 있을 때 CPU가 그냥 놀고 있다는 것이다. 그래서 동기 방식(def)을 사용하면 요청을 처리하는 스레드는 아무것도 하지 않고 대기하며, 비동기 방식(async def)을 사용하면 CPU가 그동안 다른 요청의 전처리를 수행하거나 새로운 요청을 수신할 수 있다. 모델 추론 API는 이런 추론 모델을 이용해 추론을 하기 위해, API를 통해 서버에서 실제로 작동 중인 추론 모델에게 요청을 보내고 응답을 받는 역할을 수행하게 된다.

GIL이 모델 추론 과정에 비효율적인 이유

GIL은 네트워크 요청이나 파일 입출력과 같은 I/O 작업 중에는 해제되기 때문에, I/O 중심 작업에서는 멀티스레딩이 유효한 동시성 확보 수단이 될 수 있다. 그러나 복잡한 수학 연산이나 이미지 처리처럼 CPU 작업이 주가 되는 경우 — 인공지능이 바로 그렇다 — 스레드가 지속적으로 GIL을 점유하려고 시도한다. 그래서 여러 스레드를 잘 배치해도 실제로는 GIL을 획득하기 위한 경합으로 인해 컨텍스트 스위칭이 자주 발생한다.

모델 추론 과정에서 입력 데이터를 다듬는 전처리나 모델이 제공한 결과 데이터를 다듬는 후처리는 GIL의 영향을 강하게 받기 때문에 성능이 잘 나오기 힘들다. 다행히 PyTorch나 TensorFlow와 같은 주요 딥러닝 라이브러리는 핵심 연산을 C++로 구현한 다음 그 함수를 파이썬 API로 호출하는 구조라 GIL로부터 자유롭지만, 전처리·후처리·데이터 로딩 등 파이썬 수준에서 실행되는 코드는 여전히 GIL의 지배를 받으며 여기서 병목이 발생한다. 그리고 asyncio를 통해 이러한 비동기성을 웹 서버로 끌어올 수 있다. GPU 연산을 기다리는 동안 await를 통해 이벤트 루프에 제어권을 넘겨 다른 작업을 처리할 수 있는 것이다.

모델 추론 API 구축 시 비동기 처리가 필요한 이유

비동기 처리에 비해 동기 처리는 더 큰 규모로 확장하기 어렵다. 서버로 들어오는 요청이 늘어날수록 스레드 풀이 고갈될 가능성도 크고, 각 스레드가 고유의 메모리를 가지고 있기 때문에 메모리 부족으로 인한 오류가 발생하기도 쉽다. 동기 방식에서 스레드에서 스레드로 넘어가기 위해서는 컨텍스트 스위칭이 필요한데, 이는 상대적으로 무거운 작업이기 때문에 많은 스레드가 GIL을 두고 경쟁할 때 발생하는 잦은 컨텍스트 스위칭은 CPU를 과도하게 사용하게 만든다.

반면 비동기 작업 간의 전환은 상대적으로 매우 가볍고, await 키워드를 만날 때에만 전환이 발생하기 때문에 예측 가능하고 효율적이다. 모델 추론 API에서는 I/O 대기 시간을 활용하는 것이 매우 중요하기 때문에 비동기 구조로 설계하는 것이 더 좋다. 실제 시나리오를 생각해 보자.

시나리오 A: LLM의 스트리밍 응답

ChatGPT와 같은 대화형 AI 서비스에서 사용자는 모델의 전체 응답이 완성될 때까지 기다리지 않고, 대신 생성되는 단어가 실시간으로 화면에 찍히는 스트리밍 경험을 선호한다. 그게 더 빨라 보이니까 (이 경험을 프론트엔드에서 어떻게 구현하는지는 렌더링 방식의 차이 참고).

만약 여기서 동기 방식을 사용할 경우 응답이 완료될 때까지 다른 작업을 못한다. LLM(Large Language Model, 대형 언어 모델)이 긴 답변을 생성하는 데 총 30초가 걸린다고 하면, 동기 방식에서는 이 30초 동안 1개의 스레드가 이 연결을 유지하기 위해 묶여 있다. 게다가 만약 스레드 풀에 총 40개의 스레드가 있다고 가정하면, 40명의 사용자가 동시에 긴 질문을 던지는 순간 서버의 모든 스레드가 소진되고 41번째 사용자는 접속조차 할 수 없다. 이 문제를 해결하려면 비동기 방식을 사용해야 한다.

@app.post("/chat")
async def chat(query: str):
    async def event_generator():
        async for token in llm_engine.generate_stream(query):
            yield token
    return StreamingResponse(event_generator(), media_type="text/event-stream")

LLM이 다음 단어를 생각하는(GPU 연산 중인) 찰나의 시간 동안, 이벤트 루프는 다른 사용자의 요청을 받고 다른 스트리밍 연결에 데이터를 쏘아줄 수 있다. 덕분에 단일 프로세스로 수백, 수천 명의 사용자에게 동시에 스트리밍 응답을 제공할 수 있게 되며, 웹 서버의 스레드는 더 이상 병목이 아니게 된다.

시나리오 B: 동적 배칭(Dynamic Batching)

GPU는 여러 개의 데이터를 동시에 처리하는 작업에 특화되어 있다. 그래서 GPU를 효율적으로 활용하기 위해서는 개별적으로 들어오는 요청을 잠시 모았다가 한꺼번에 GPU로 보내는 동적 배칭이 필수적이다. 하지만 동기 방식에서 배칭을 구현하려면 요청을 받은 스레드가 배치가 찰 때까지 대기해야 한다. GPU로 데이터를 보내기 위한 요청의 수가 32라고 가정하면, 요청이 하나 들어왔을 때 이 스레드는 나머지 31개의 요청이 들어올 때까지 기다리게 되고, 그만큼 스레드 자원이 낭비된다.

비동기 환경에서는 asyncio를 활용해 이 문제를 해결할 수 있다. 요청이 들어오면 데이터를 큐에 넣고, 결과가 담길 asyncio.Future 객체2를 생성하여 await한다. 백그라운드에서는 워커가 큐를 감시하다가 데이터가 32개 모이거나 일정 시간이 지나면 데이터를 꺼내서 GPU로 보낸다. GPU 연산이 끝나면 그 결과가 Future 객체로 도착하고, 대기 중이던 각 요청들이 깨어나 응답을 반환하게 된다. 대기하느라 소모된 스레드는 단 하나도 없으며, GPU의 처리 능력은 효율적으로 사용되었다.

시나리오 C: RAG 및 복합 에이전트 워크플로우

AI 애플리케이션은 단순히 모델 하나만 호출하지 않는다. 질문을 변형하고, 메타데이터를 조회하고, 외부 API를 호출한 뒤, 이 모든 정보를 조합해서 LLM에 입력하는 복잡한 과정을 거친다. RAG(Retrieval-Augmented Generation, 검색 증강 생성)가 대표적이다.

동기 방식에서는 이 모든 작업을 순차적으로 실행해야 하고, 그만큼 네트워크 지연 시간이 그대로 누적되어 사용자 응답 시간이 길어진다. 반면 비동기 방식에서는 서로 기다릴 필요가 없는 I/O 작업들을 동시에 실행할 수 있다.

# 벡터 검색과 SQL 조회를 동시에 실행
vector_results, sql_results = await asyncio.gather(
    vector_db.search(query_embedding),
    sql_db.fetch_user_profile(user_id)
)

이렇게 하면 총 소요 시간을 획기적으로 단축할 수 있을 뿐만 아니라, 외부 API 호출과 같이 대기 시간이 긴 작업 동안 서버가 다른 요청을 처리할 수 있어 전체적인 처리량도 향상된다.

결론

모델 추론 API의 병목은 GPU가 아니라 GPU를 둘러싼 대기 시간이다. 요청 수신과 전처리, VRAM 복사, 연산 대기, 후처리로 이어지는 파이프라인에서 CPU와 웹 서버의 스레드는 대부분의 시간을 기다리며 보내고, 동기 방식은 그 기다림에 스레드를 하나씩 볼모로 잡힌다. 비동기 설계는 그 대기 시간을 되찾아 오는 방법이다. 스트리밍 응답은 단일 프로세스로 수천 개의 연결을 유지하게 하고, 동적 배칭은 스레드를 소모하지 않으면서 GPU를 가득 채우며, RAG의 병렬 I/O는 누적될 지연을 겹쳐서 없앤다. GIL이라는 제약 위에서도 파이썬이 추론 서버의 표준 언어로 살아남은 것은, 이 비동기 구조가 추론 워크로드의 대기 패턴과 정확히 맞물리기 때문이다.


출처

*[LLM]: Large Language Model *[RAG]: Retrieval-Augmented Generation *[GIL]: Global Interpreter Lock

Footnotes

  1. VRAM(Video RAM)은 GPU에 직접 연결된 전용 메모리다. 모델 가중치와 입력 데이터가 이곳에 올라와 있어야 GPU가 연산할 수 있으므로, 주 메모리에서 VRAM으로의 복사가 추론 파이프라인의 필수 단계가 된다.

  2. asyncio.Future는 아직 완료되지 않은 작업의 결과를 담을 자리를 미리 만들어 두는 객체다. 나중에 결과가 채워지면 그 Future를 await하며 기다리던 코루틴이 깨어난다.