요즘 GPT 같은 LLM 모델을 배치로 대량 호출하려면 비동기 처리는 거의 필수다.
그런데 파이썬에선 비동기 쪽 선택지가 은근히 많은데 Spark를 사용하다보니 두가지의 동작방식이 헷갈려서 기록해 두려고 한다.
- nest_asyncio
- concurrent.futures.ThreadPoolExecutor
이 글에서는 각각의 개념과 실제로 LLM 요청을 병렬로 처리할 때 어떤 차이가 있는지 정리해본다.
nest_asyncio — 이벤트 루프 중첩 허용하기
파이썬에서 비동기를 쓸 땐 asyncio의 이벤트 루프를 돌려야 한다.
이벤트 루프란, 비동기 함수들(async def)을 실행해주는 스케줄러를 말한다.
await 걸린 작업들을 기다렸다가 다시 실행해주는 감독 같은 역할을 하는 것이라고 생각하면 된다.
이미 이벤트 루프가 돌고 있는 환경(예: Jupyter Notebook, FastAPI 내부 등)에서 asyncio.run() 같은 걸 또 쓰면 에러가 난다.
이걸 해결하는 게 nest_asyncio이다. 이미 실행 중인 이벤트 루프에 다시 await를 붙일 수 있도록 허용하는 기능이다.
import nest_asyncio
nest_asyncio.apply() # 현재 실행 중인 루프를 "중첩 허용"으로 바꿔줌
비동기 코드를 지금 실행 중인 루프 안에 끼워 넣기 위해 필요한 설정 도구
ThreadPoolExecutor — 병렬 처리
반면에 ThreadPoolExecutor는 동기 함수라도 백그라운드에서 병렬 처리가 되게 만들어주는 기능이다.
멀티스레드로 작업을 나눠서 여러 개 동시에 돌릴 수 있게 되는 거다.
executor 10개를 설정하고 data_list에서 한개씩 뽑아서 수행하는데 이를 10개가 동시에 처리를 함으로써
병렬화로 속도를 엄청 줄일 수 있는 기능이다.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as executor: # max_work = 10개
results = list(executor.map(sync_function, data_list))
비동기 vs 병렬 용어 정리
- 비동기(asyncio): 한 루프 안에서 동시에 여러 작업을 왔다 갔다 하며 처리
- 병렬(ThreadPoolExecutor): 여러 스레드를 만들어서 작업을 동시에 수행
비동기는 CPU가 놀고 있을 때 다른 작업으로 context switch
병렬은 아예 여러 CPU 코어를 동시에 사용하는 구조
예시) LLM 요청 분산 처리
예를 들어 LLM(GPT 등)에 데이터 5,000개를 보내서 요약하고 싶다고 해보자.
데이터가 많으면 많을수록 (토큰이 길어지기 때문) 요청이 무겁고 응답이 느리기 때문에, 그냥 for문 돌리면 며칠 걸릴 수도 있다.
이때 이렇게 구성하면 된다:
# 1. 여러 GPT 클라이언트 인스턴스를 생성
# 2. 인스턴스마다 분산 처리 (API 한도)
# 3. 각 요청을 비동기(async)로 실행
# 4. asyncio.gather로 여러 비동기 함수 생성
# 5. 세마포어로 동시 작업 개수 제한
결론 : 어떤 상황에 뭘 쓰면 되냐?
상황 | nest_asyncio | ThreadPoolExecutor |
Jupyter/FastAPI에서 비동기(async) 함수 실행 | ✅ 꼭 필요함 | ❌ |
동기 작업 및 함수 병렬 처리 | ❌ | ✅ |
LLM API 다수 요청 (async 기반) | ✅ | ❌ |
쿼리 및 연산 위주 병렬 처리 | ❌ | ✅ |
마무리
nest_asyncio는 이미 돌아가는 이벤트 루프에 비동기 코드를 끼워넣어 쉬지않게 실행 시키는 것이며
ThreadPoolExecutor는 병렬 실행이 필요한 상황에서의 선택지라고 생각하면 된다.
LLM이나 OpenAI, Azure 등의 API 호출이 많은 요즘은 비동기 구조 + 세마포어 + 분산 클라이언트 조합이 거의 필수다. 이 구조만 잘 짜두면 하루 수천 건도 깔끔하게 처리 가능할 것 같다.