
결론부터 말씀드리면, LLM 비동기 호출을 여러 개 동시에 보낼 때 응답이 뒤섞이는 문제는 asyncio.gather만으로는 해결되지 않는 경우가 있고, 이럴 땐 순번을 매긴 뒤 재정렬 버퍼(reorder buffer)를 두는 큐를 직접 만들어야 합니다. 특히 응답을 모아서 한 번에 처리하는 게 아니라 도착하는 즉시 화면에 순서대로 뿌려야 하는 스트리밍 UI라면 더욱 그렇습니다. 이 글에서는 Python 3.11 기준 asyncio와 heapq만으로 순서 보장 큐를 만드는 방법과, 이 방식이 오히려 발목을 잡는 상황을 코드와 함께 정리해 드립니다.
비동기 호출인데 왜 응답 순서가 뒤바뀔까요?
LLM 비동기 호출을 5개 동시에 보내면, 짧은 답변을 요구하는 요청이 긴 답변을 요구하는 요청보다 먼저 끝나는 일이 흔합니다. 특히 서빙 엔진이 동적 배치(continuous batching) 방식을 쓰는 경우, 생성해야 할 토큰 수가 요청마다 달라서 먼저 보낸 요청이 나중에 끝나는 일이 자연스럽게 벌어집니다.
여기서 헷갈리는 지점이 하나 있습니다. asyncio.gather(*tasks)는 내부적으로 각 태스크가 완료되는 시점은 제각각이어도, 반환값 리스트는 넘긴 태스크의 순서 그대로 돌려줍니다. 즉 gather 자체는 이미 순서를 보장하고 있어서, “결과를 모아서 한 번에 쓴다”면 별도 큐가 필요 없습니다.
문제는 asyncio.as_completed()를 쓰거나, 결과가 도착하는 대로 즉시 다음 단계(예: 프론트엔드 스트리밍, 파일 순차 저장)로 넘겨야 할 때입니다. as_completed는 이름 그대로 완료된 순서로 반환하기 때문에, 요청 순서와 응답 순서가 다르면 그대로 뒤섞인 채 넘어갑니다.
세 가지 방식의 순서 보장 범위가 다릅니다
먼저 상황별로 어떤 도구를 써야 하는지 표로 정리했습니다.

| 방식 | 순서 보장 시점 | 스트리밍 가능 여부 | 구현 난이도 | 적합한 상황 |
|---|---|---|---|---|
| asyncio.gather | 전체 요청 완료 후 한 번에 | 불가 (다 끝날 때까지 대기) | 낮음 | 결과를 한꺼번에 모아 후처리할 때 |
| asyncio.as_completed | 보장 안 됨 (완료된 순서) | 가능하지만 순서 뒤섞임 | 낮음 | 순서와 무관하게 빨리 처리해야 할 때 |
| 재정렬 버퍼 직접 구현 | 순번대로 즉시 방출 | 가능 | 중간 | 순서 그대로 실시간으로 내보내야 할 때 |
정리하면, 순서가 필요 없거나 결과를 다 모은 뒤 처리한다면 앞의 두 가지로 충분합니다. 순서를 지키면서 동시에 “도착하는 대로 바로바로” 내보내야 할 때만 세 번째 방식이 필요합니다.
재정렬 버퍼로 순서 보장 큐 만들기
핵심 아이디어는 단순합니다. 각 요청에 순번(idx)을 매겨서 보내고, 응답이 도착하면 asyncio.Queue에 (순번, 결과)를 집어넣습니다. 그리고 소비자 쪽에서는 다음에 내보내야 할 순번이 준비됐는지 우선순위 큐 구조(파이썬에서는 heapq)로 관리하다가, 순서가 맞을 때만 꺼내 줍니다.
import asyncio
import heapq
import random
class OrderedResultQueue:
"""비동기로 뒤섞여 도착하는 (순번, 결과)를 순번대로 꺼내주는 큐"""
def __init__(self):
self._raw = asyncio.Queue()
self._buffer = [] # heapq로 관리하는 재정렬 버퍼
self._next_idx = 0
async def put(self, idx: int, value):
await self._raw.put((idx, value))
async def get_in_order(self):
while not (self._buffer and self._buffer[0][0] == self._next_idx):
idx, value = await self._raw.get()
heapq.heappush(self._buffer, (idx, value))
idx, value = heapq.heappop(self._buffer)
self._next_idx += 1
return value
async def call_llm(idx: int, prompt: str, oq: OrderedResultQueue):
delay = random.uniform(0.1, 1.0)
await asyncio.sleep(delay) # 실제로는 LLM API 호출 지연 시간
await oq.put(idx, f"{prompt} -> 응답 (지연 {delay:.2f}s)")
async def main():
prompts = [f"질문{i}" for i in range(5)]
oq = OrderedResultQueue()
producers = [
asyncio.create_task(call_llm(i, p, oq)) for i, p in enumerate(prompts)
]
for _ in prompts:
print(await oq.get_in_order())
await asyncio.gather(*producers)
asyncio.run(main())
실제 응답이 도착하는 순서는 매 실행마다 무작위로 바뀌지만(예: 질문3, 질문0, 질문4, 질문1, 질문2 순으로 완료), get_in_order()가 반환하는 순서는 항상 아래처럼 고정됩니다.
질문0 -> 응답 (지연 0.42s)
질문1 -> 응답 (지연 0.88s)
질문2 -> 응답 (지연 0.15s)
질문3 -> 응답 (지연 0.71s)
질문4 -> 응답 (지연 0.33s)
버퍼가 다음 순번을 기다리는 동안 이미 도착한 뒤 순번들은 heap 안에서 대기하고 있다가, 앞 순번이 채워지는 즉시 연쇄적으로 방출됩니다. 이 부분이 LLM 비동기 호출 결과를 순서대로 스트리밍할 때 실질적으로 동작하는 지점입니다.

이 방식이 오히려 발목을 잡는 경우
재정렬 버퍼는 공짜가 아닙니다. 가장 큰 위험은 헤드 오브 라인 블로킹(head-of-line blocking)입니다. 위 코드에서 idx=0 요청이 타임아웃이나 네트워크 오류로 영영 끝나지 않으면, 나머지 4개 응답이 모두 _raw 큐 안에 도착해 있어도 get_in_order()는 순번 0을 기다리며 영원히 멈춥니다.
실무에서는 이런 이유로 두 가지를 같이 걸어야 합니다.
- 프로듀서 쪽에
asyncio.wait_for(call_llm(...), timeout=N)을 씌워서, 타임아웃이 나도 실패를 알리는 값을 순번과 함께 큐에 반드시 넣어줍니다. 그래야 소비자가 무한 대기하지 않습니다. - 동시 요청 개수를
asyncio.Semaphore로 제한합니다. 재정렬 버퍼는 앞 순번이 막히면 뒤 순번 결과를 계속 메모리에 쌓아두는 구조라서, 동시 요청 수가 많을수록 버퍼가 무한정 커질 수 있습니다.
또한 요청 개수가 적고(예: 5개 이하) 순서 없이 처리해도 되는 배치 작업이라면, 이런 재정렬 로직을 넣는 게 오히려 코드 복잡도만 높이는 과한 설계입니다. 이럴 땐 asyncio.gather로 끝내는 편이 유지보수에 유리합니다.
asyncio.gather로도 순서는 지켜지는데, 왜 굳이 큐가 필요한가요?
asyncio.gather가 순서를 지켜주는 건 맞지만, 그건 모든 요청이 끝난 뒤 한꺼번에 순서대로 반환한다는 뜻입니다. 5개 중 4개가 0.2초 만에 끝나도, 가장 느린 1개가 3초 걸리면 나머지 4개 결과도 3초 뒤에야 받게 됩니다. 반면 재정렬 버퍼 방식은 순번 0번 결과가 준비되는 즉시 내보내고, 그 다음 순번이 오면 바로 이어서 내보냅니다. 챗봇 UI에서 답변을 순서대로 실시간 표시하거나, 여러 문서를 순서대로 파일에 이어 써야 하는 파이프라인이라면 이 차이가 체감됩니다.
정리하면, LLM 비동기 호출 결과를 다루는 큐를 설계할 때는 “결과를 다 모아야 하는가, 아니면 도착하는 대로 순서대로 흘려보내야 하는가”부터 먼저 정하는 게 순서입니다. 전자라면 asyncio.gather 한 줄로 끝나고, 후자라면 이번 글의 OrderedResultQueue 같은 재정렬 버퍼가 필요하되 타임아웃과 동시성 제한을 반드시 같이 설계해야 실제 서비스에서 안전하게 씁니다. 지금 프로젝트에 순서 보장이 필요한 비동기 호출 구간이 있다면, 우선 요청 개수가 몇 개인지, 응답 지연 편차가 얼마나 큰지부터 확인해 보시는 걸 추천드립니다.
