Python 비동기 코드를 수년째 쓰면서도 "왜 async 함수인데 다른 요청이 막히지?" 라는 질문을 받으면 막히는 경우가 있다. 이 글은 그 질문의 답을 실제 프로덕션 장애 사례와 함께 설명한다.
사건의 발단
어느 날 B2B SaaS 팀에서 이런 보고를 받았다.
"보고서 버튼 클릭하면 CPU가 90%까지 올라가요. 동시에 3명이 누르면 서비스가 죽어요."
코드를 열어보면 전형적인 FastAPI 패턴이 보인다.
# 언뜻 보면 멀쩡한 비동기 코드
async def create_excel_report(self, params):
data = await self.repository.get_data(params)
# 데이터 가공 — pandas + openpyxl
df = pd.DataFrame(data)
for _, row in df.iterrows():
for _, item in other_df.iterrows():
ws.cell(row=r, column=c, value=calculate(row, item))
return excel_buffer
async def다. await도 있다. 왜 문제일까?
Python 비동기의 실제 구조
FastAPI(그리고 대부분의 Python 비동기 프레임워크)는 단일 스레드 + Event Loop 모델이다.
[Python 프로세스]
└─ 스레드 1개
└─ Event Loop
├─ 요청 A 처리
├─ 요청 B 대기
└─ 요청 C 대기
스레드가 하나이므로 한 번에 하나의 작업만 CPU를 점유한다. "비동기"의 핵심은 CPU를 점유하는 게 아니라 대기하는 방식에 있다.
await의 진짜 의미
data = await db.execute(query)
이 코드가 실행되면:
- DB에 쿼리를 전송
- "나는 결과 올 때까지 기다릴게, Event Loop야 다른 거 처리해" — 제어권 반환
- OS가 네트워크 I/O를 처리하는 동안 Event Loop는 다른 요청을 처리
- DB 결과가 도착하면 Event Loop가 다시 이 코루틴을 깨움
Event Loop 타임라인:
요청 A: ──► DB 쿼리 전송 ──[I/O 대기]──────────► 결과 수신 → 처리 완료
↓
요청 B: ──► 처리 시작 → 완료
요청 C: ──► 처리 시작 → 완료
여기서 핵심은 DB/네트워크 I/O는 OS가 처리한다는 것이다. Python CPU는 그 시간에 놀고 있고, Event Loop는 그 유휴 시간을 다른 요청에 쓴다.
CPU 연산은 다르다
for _, row in df.iterrows(): # CPU가 직접 반복
for _, item in other_df.iterrows(): # CPU가 직접 반복
ws.cell(...) # CPU가 직접 계산
이 코드에는 await가 없다. 즉 "나 잠깐 쉴게"라는 신호를 한 번도 보내지 않는다.
Event Loop 타임라인:
요청 A: ──► pandas 루프 시작 ──────────────────────── 30초 ──► 완료
↑ CPU 독점 ↑
이 30초 동안 Event Loop는 아무것도 못 함
요청 B: ████████████████████████████ 30초 대기
요청 C: ████████████████████████████████████████ 60초 대기
async def로 감싸도 내부에 CPU 연산만 있으면 사실상 동기 함수와 동일하게 동작한다.
왜 CPU 사용률이 90%까지 오르나
DB I/O는 Python이 CPU를 쓰지 않는다. OS와 네트워크 카드가 처리한다.
반면 pandas 루프와 openpyxl 셀 쓰기는 Python 인터프리터가 직접 계산한다.
실제 사례에서 문제가 된 코드의 복잡도:
for month in all_months: # 12회
for combo in unique_combinations.itertuples(): # N 조합
if not (
(df["col_a"] == val_a)
& (df["col_b"] == val_b)
& ...
).any(): # 매번 전체 df 스캔
results.append(zero_record)
비교 횟수 = 12 × N(조합) × rows(데이터 행 수)
조합이 50개, 데이터가 600행이면 단순 계산으로 360,000번 비교가 일어난다. 이 전체가 CPU를 독점하는 구간이다.
동시 요청이 3건이면?
요청 A: 30초 CPU 독점
요청 B: 30초 CPU 독점 ← A와 동시에 시작
요청 C: 30초 CPU 독점 ← A, B와 동시에 시작
→ CPU는 세 루프를 번갈아가며 처리 (OS 레벨 time-slicing)
→ 각 요청이 30초 → 90초로 늘어남 + CPU 90%+ 고착
단일 요청이 문제 없어 보여도 동시성 상황에서 폭발한다.
흔한 오해 세 가지
오해 1: async def를 쓰면 자동으로 비동기가 된다
async def는 "이 함수는 코루틴입니다"라는 선언일 뿐이다. 내부에 await나 실제 비동기 포인트가 없으면 동기 함수와 동일하게 Event Loop를 차단한다.
오해 2: await를 쓰면 병렬 실행된다
# 이건 병렬이 아니다 — 직렬이다
result1 = await query_1(params)
result2 = await query_2(params)
result3 = await query_3(params)
await는 "이 작업이 끝날 때까지 기다린다"는 뜻이다. 위 코드는 query_1이 완전히 끝난 뒤 query_2가 시작된다. 병렬화하려면 asyncio.gather()가 필요하다.
# 실제 병렬 — 세 쿼리가 동시에 날아간다
result1, result2, result3 = await asyncio.gather(
query_1(params),
query_2(params),
query_3(params),
)
오해 3: I/O bound와 CPU bound를 구분 안 해도 된다
asyncio가 효과적인 건 I/O bound 작업에 한해서다. DB 쿼리, 네트워크 호출, 파일 읽기는 OS가 처리하는 동안 Python이 쉴 수 있다. 반면 pandas 연산, 이미지 처리, 암호화, 수치 계산 같은 CPU bound 작업은 전략이 다르다.
해결책과 트레이드오프
1. 단기: asyncio.to_thread()
result = await asyncio.to_thread(pandas_heavy_work, data)
CPU 연산을 스레드 풀에 위임한다. Event Loop는 await로 기다리는 동안 다른 요청을 처리할 수 있다.
요청 A: ──► to_thread(pandas) 위임 ──[대기]──────► 결과 수신 → 완료
↓
요청 B: ──► 처리 시작 → 완료
요청 C: ──► 처리 시작 → 완료
[별도 스레드]: pandas 루프 30초 실행 중...
장점: 코드 변경 최소화.
한계: 사용자가 여전히 30초를 기다려야 한다. 동시 요청이 많으면 스레드 풀도 고갈된다.
2. 중기: 알고리즘 개선
CPU 연산 자체를 줄이는 게 근본적이다. 위 O(12 × N × rows) 코드는 pd.merge() 한 줄로 교체 가능하다.
# 변경 전: 360,000번 비교
for month in all_months:
for combo in combinations.itertuples():
if not (df[...]).any():
results.append(zero_record)
# 변경 후: 1회 merge 연산
all_keys = pd.MultiIndex.from_product([all_months, unique_combinations])
result = existing_data.reindex(all_keys).fillna(0)
실측 기준 33배 성능 차이. 알고리즘 개선이 인프라 확장보다 항상 먼저다.
3. 장기: 비동기 아키텍처 (Celery + Redis)
보고서처럼 수십 초 걸리는 작업은 구조 자체를 바꾸는 게 맞다.
[변경 전]
사용자 요청 → 30초 HTTP 연결 유지 → 파일 다운로드
[변경 후]
사용자 요청 → 202 Accepted 즉시 반환
↓
Celery Worker가 백그라운드 처리
↓
완료 알림 → 사용자가 다운로드
HTTP 연결을 끊어서 Event Loop를 완전히 해방한다. 동시 요청 수 제한, 재시도, 우선순위 큐도 자연스럽게 따라온다.
정리
| 작업 종류 | CPU 점유 | await 가능 | Event Loop 차단 |
|---|---|---|---|
| DB 쿼리 / HTTP 호출 | OS | ✅ | 없음 |
| 파일 읽기/쓰기 (aiofiles) | OS | ✅ | 없음 |
| pandas / numpy 연산 | Python | ❌ | 차단 |
| openpyxl 셀 쓰기 | Python | ❌ | 차단 |
| 암호화 / 해시 | Python | ❌ | 차단 |
핵심 원칙 하나: async def 안에 CPU 연산이 있으면 반드시 asyncio.to_thread()로 분리하거나, 연산 자체를 줄이거나, 비동기 Job Queue로 위임하라.
await는 마법이 아니다. 단지 "나 잠깐 쉴게"라는 신호다. 쉴 틈을 주지 않으면 Event Loop도 쉬지 못한다.