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)

이 코드가 실행되면:

  1. DB에 쿼리를 전송
  2. "나는 결과 올 때까지 기다릴게, Event Loop야 다른 거 처리해" — 제어권 반환
  3. OS가 네트워크 I/O를 처리하는 동안 Event Loop는 다른 요청을 처리
  4. 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도 쉬지 못한다.