Python GIL과 동시성 모델 (2) — 프로덕션 두 사례

Published:

Python GIL 시리즈

  1. GIL과 동시성 모델
  2. 프로덕션 두 사례 ← 현재

Part 1에서 “작업 특성과 메모리 모델을 보고 Thread / Process / Uvicorn Workers 중에 고른다”는 공식을 정리했다. 이 글은 그 공식을 두 프로덕션 서비스에 실제로 적용한 기록.

  • Case 1 — 이미지 분석 API: OpenCV 기반 소변 스트립 분석. Uvicorn Workers 4개로 끝.
  • Case 2 — Prophet 예측 API: 시계열 지출 예측. Uvicorn Workers + ThreadPoolExecutor 3개.

둘 다 FastAPI + Python 조합이지만, 동시성 구조는 완전히 다르게 갔다.

Case 1 — 이미지 분석 API: Uvicorn Workers만으로 충분했다

구조

HTTP Request → Uvicorn Master
                 ├─ Worker 1 → 요청 A (전처리 → ROI → 색상보정 → KNN)
                 ├─ Worker 2 → 요청 B
                 ├─ Worker 3 → 요청 C
                 └─ Worker 4 → 요청 D

supervisord 설정은 한 줄이다.

[program:fastapi]
command=uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 4

애플리케이션 코드 안에는 ThreadPoolExecutorProcessPoolExecutor도 없다. 요청이 들어오면 순차적으로 처리한다. 그런데 서비스는 CPU 4개를 거의 100% 쓰면서 동시에 4개 요청을 처리한다.

왜 요청 내부는 순차로 뒀나

이미지 분석 파이프라인은 이렇게 생겼다.

이미지 업로드 → QR/BBox 검출 → ROI 추출 → Strip 인식 → 색상 보정 → KNN 분류 → JSON

다음 단계가 이전 단계의 결과를 입력으로 받는다. Strip을 찾아야 ROI를 자를 수 있고, 색상을 보정해야 KNN을 돌릴 수 있다. 요청 내부는 순차적 의존성이 있어서 병렬화 여지 자체가 거의 없다. 억지로 파이프라이닝 해봐야 이득이 미미하고 구조만 복잡해진다.

그리고 각 단계는 OpenCV/NumPy가 다 처리한다 — GIL은 이미 C 레벨에서 놓이고 있다. “요청 하나 안에 스레드 풀을 또 넣어서 병렬화” 같은 걸 할 필요가 없었다.

왜 Process 격리가 좋았나

이미지 파이프라인에서 실제로 장애가 났을 때 대응이 훨씬 쉬웠다. OpenCV 연산 중 세그폴트가 나는 드문 케이스가 있었는데, Uvicorn Workers 구조에서는:

  • 해당 요청의 Worker만 죽음
  • 다른 3개 Worker는 다른 요청을 계속 처리 중
  • Uvicorn이 죽은 Worker를 자동 재시작
  • 사용자 관점에서는 해당 요청만 500, 나머지는 정상

요청 내부에 Thread를 썼으면 같은 프로세스의 다른 요청까지 다 죽었다. Process 격리는 공짜로 얻은 안전망이었다.

리소스

Docker compose 리소스 한도는 워커 수에 맞춰서 잡았다.

deploy:
  resources:
    limits:
      cpus: '4.0'
      memory: 4G
    reservations:
      cpus: '4.0'
      memory: 2G

워커당 대략 1 CPU, 500MB~1GB. OpenCV가 이미지 하나 처리하는 동안 피크 메모리가 꽤 튀어서 여유를 좀 뒀다.

결론: 요청이 독립적이고 내부가 순차적이면 Uvicorn Workers 하나로 끝난다. 복잡한 Executor 구조는 넣지 않는 게 오히려 낫다.

Case 2 — Prophet 예측 API: ThreadPool 3개로 쪼갠 이유

이 서비스는 훨씬 복잡한 구조가 됐다. 이유: 하나의 요청이 내부적으로 10개 넘는 독립적인 예측 작업으로 쪼개진다.

요청의 구조

한 번의 요청이 이렇게 생겼다.

POST /predict
  body: CSV (거래 내역, ~100MB)

→ 카테고리 unique: ["식비", "카페", "교통", "주거", "여가", ...]  (13개)
→ 각 카테고리별로 Prophet 모델 fit → 30일 예측
→ 모든 카테고리 결과를 합쳐 JSON 반환

카테고리 13개가 서로 완전히 독립적이다. “식비” 예측이 “카페” 예측에 의존하지 않는다. 순차로 돌리면 카테고리당 2초 × 13 = 26초. 사용자 대기 시간으로는 너무 길다.

ThreadPool을 고른 이유

Prophet의 fit()은 내부에서 Stan(C++)을 호출한다. 학습하는 대부분의 시간 동안 GIL을 놓는다. 그 말은 Thread 여러 개로 묶어도 진짜 병렬로 돈다는 뜻이다.

그런데 Process가 아니라 Thread를 고른 진짜 이유는 Part 1에서 본 메모리 문제였다.

100MB CSV, 13개 카테고리 동시 처리

Thread:  100MB 공유 + 카테고리 slice 5MB × 13  ≈  165MB
Process: 100MB × 8 (워커마다 pickle 복사)      ≈  800MB  + pickle 시간

Thread는 원본 DataFrame을 한 번만 메모리에 로드하고, 각 카테고리 스레드는 거기서 필요한 행만 슬라이싱한다. Process는 워커마다 전체 CSV를 복사해야 한다 — 5배 이상 메모리 낭비.

3개의 풀로 쪼갠 구조

Prophet 서비스 생성 시점에 Executor를 세 개 만들었다.

class ProphetService:
    def __init__(self):
        self.main_executor = ThreadPoolExecutor(
            max_workers=PROPHET_MAIN_WORKERS,
            thread_name_prefix="prophet-main"
        )
        self.baseline_executor = ThreadPoolExecutor(
            max_workers=PROPHET_BASELINE_WORKERS,
            thread_name_prefix="prophet-baseline"
        )
        self.category_executor = ThreadPoolExecutor(
            max_workers=min(os.cpu_count() or 4, 8),
            thread_name_prefix="category-worker"
        )

각각 역할이 다르다.

  • main_executor — FastAPI의 async 엔드포인트가 동기 Prophet 코드를 떠넘길 때 쓰는 풀. 요청 한 건당 하나의 메인 태스크.
  • baseline_executor — 월별 baseline 수치를 백그라운드로 계산하는 작업. 메인 요청과 독립적으로 돌아야 함.
  • category_executor — 메인 태스크 안에서 13개 카테고리를 동시에 fit하는 공유 풀.

왜 풀을 쪼갰나

처음엔 풀을 하나만 만들고 거기에 다 밀어넣었다. 그러다 백그라운드 baseline이 메인 요청을 블로킹하는 문제가 생겼다. 둘이 같은 풀을 경합하니 어느 한쪽이 풀을 먹으면 반대쪽이 starvation.

목적별로 풀을 분리하면:

  • 메인 요청은 항상 main_executor 자리를 확보
  • 백그라운드는 baseline_executor에서만 돔 — 메인에 영향 없음
  • 카테고리 병렬화는 공유 풀에서 as_completed로 긁어옴
  • 로그에 prophet-main-3, category-worker-5 식으로 떠서 어느 풀이 몰리는지 추적하기 쉬움

카테고리별 병렬 처리

동기 함수 내부에서 카테고리를 공유 풀에 던진다.

def _predict_by_category_sync(self, csv_data: pd.DataFrame):
    categories = csv_data['category'].unique()

    future_to_category = {
        self.category_executor.submit(
            self._predict_single_category,
            category,
            csv_data  # 참조만 전달 — 복사 안 함
        ): category
        for category in categories
    }

    for future in as_completed(future_to_category):
        try:
            result = future.result()
            # 수집
        except Exception as e:
            logger.exception(f"category failed: {future_to_category[future]}: {e}")
            # 실패한 카테고리 하나가 나머지를 막지 않게

중요한 지점 몇 개:

  • csv_data는 참조로 넘긴다. 스레드들이 같은 DataFrame을 본다. 읽기만 하기 때문에 안전하다.
  • _predict_single_category는 내부에서 카테고리 데이터를 복사하고, Prophet 모델은 스레드마다 따로 생성한다. 공유 상태 없음.
  • as_completed로 끝나는 순서대로 수집. 한 카테고리가 느려도 먼저 끝난 건 먼저 합류.
  • 예외 처리는 카테고리 단위. 식비 예측이 터져도 다른 12개는 살린다.

async 엔드포인트에서 동기 세계로 건너가기

FastAPI는 async 함수 안에서 동기 블로킹 코드를 직접 돌리면 이벤트 루프를 막아버린다. Prophet fit은 무조건 블로킹이니까 run_in_executor로 감싼다.

async def predict_spending_by_category(self, csv_data: pd.DataFrame):
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        self.main_executor,
        self._predict_by_category_sync,
        csv_data
    )
    return result

흐름을 정리하면:

FastAPI async endpoint
  ↓  (loop.run_in_executor)
main_executor 스레드에서 _predict_by_category_sync()
  ↓  (category_executor.submit × 13)
category_executor 스레드에서 각 카테고리 Prophet fit (Stan이 GIL 해제)
  ↓  (as_completed)
결과 취합 → return
  ↓
FastAPI async endpoint가 await 반환

이벤트 루프는 run_in_executor가 끝나길 기다리는 동안 다른 요청을 계속 받는다.

결과

  • 순차 처리: 13 카테고리 × 2초 = 26초
  • ThreadPool (8 워커): 8개 먼저 + 나머지 5개 = 4초 전후

대략 5~6배. 체감상 “다음 달 예측” 버튼 누르고 돌아올 때까지 기다릴 만한 시간.

하이브리드 패턴 — Uvicorn × ThreadPool

두 사례를 합친 형태가 사실 가장 흔한 실전 구조다.

Uvicorn Worker 1 ─┐
Uvicorn Worker 2 ─┤  각 워커 내부에
Uvicorn Worker 3 ─┤   ├ io_executor (ThreadPool, 많은 워커)
Uvicorn Worker 4 ─┘   └ cpu_executor (ThreadPool, CPU 코어 수)

예를 들면 “S3에서 N장 이미지 받아서 OpenCV로 분석” 같은 엔드포인트.

class Service:
    def __init__(self):
        self.io_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="io")
        self.cpu_executor = ThreadPoolExecutor(
            max_workers=os.cpu_count() or 4,
            thread_name_prefix="cpu",
        )

    async def process_request(self, urls: list[str]):
        loop = asyncio.get_event_loop()

        # 1단계: I/O — 20개 동시 다운로드
        images = await asyncio.gather(*[
            loop.run_in_executor(self.io_executor, download_from_s3, u)
            for u in urls
        ])

        # 2단계: CPU — OpenCV (GIL 해제)
        results = await asyncio.gather(*[
            loop.run_in_executor(self.cpu_executor, analyze_image, img)
            for img in images
        ])
        return results

왜 이 구조가 자연스러운가:

  • Uvicorn Workers가 요청 단위 병렬 + 격리를 책임진다.
  • io_executor는 네트워크 대기 시간을 겹친다 (20개 소켓이 동시에 recv).
  • cpu_executor는 OpenCV가 GIL을 놓는 동안 여러 이미지를 동시에 돌린다.
  • 두 풀이 분리돼 있어 I/O 폭주가 CPU 작업을 막지 않는다.

순수 Python CPU 작업이 끼면 그제서야 ProcessPoolExecutor를 덧붙인다. 그 전에는 Thread로 버틸 수 있으면 버티는 게 낫다 — 메모리도, 데이터 전달 비용도, 디버깅 난이도도 전부 Thread 쪽이 편하다.

시나리오별 빠른 선택 가이드

시나리오선택이유
독립적 HTTP 요청 (이미지 분석 API)Uvicorn Workers요청 간 공유 없음, Process 격리 공짜
S3 다운로드 + OpenCV 분석ThreadPool (I/O용 + CPU용)I/O도 CPU도 GIL 해제 구간
Prophet/NumPy 계열 CPU 병렬ThreadPoolGIL 해제 + 큰 데이터 공유
순수 Python CPU (복잡한 변환)ProcessPoolThread는 GIL로 순차
웹 크롤링 1000개 → 파싱Thread(크롤링) + Process(파싱)단계별로 다름
큰 DataFrame (>1GB)을 쪼개 처리Thread 우선, 안 되면 Cython/Numba/RustProcess는 메모리 폭발

프로덕션 체크리스트

배포 전에 보통 이 정도는 확인한다.

  • Executor를 요청마다가 아니라 서비스 객체에 한 번만 만들었는가
  • max_workers가 CPU/메모리 한도에 비해 과하지 않은가 (os.cpu_count() 근처)
  • 목적별로 풀이 분리됐는가 (메인 요청 vs 백그라운드)
  • as_completed 루프 안에 try/except가 있어 한 작업 실패가 전체를 막지 않는가
  • future.result(timeout=...) 또는 그에 준하는 상한이 걸려 있는가
  • 스레드 이름을 thread_name_prefix로 구분해 로그에서 추적 가능한가
  • 부하 테스트로 동시 요청 시 스레드/메모리가 예상 범위 안에 있는지 확인했는가

정리

두 서비스를 거치면서 배운 건 결국 세 줄로 요약된다.

  1. 요청이 독립적이면 Uvicorn Workers로 끝. 그 이상은 보통 불필요하다.
  2. 요청 내부 병렬화가 필요하면 먼저 ThreadPool을 시도한다. 대부분의 과학 계산 라이브러리는 GIL을 놓는다.
  3. 풀은 목적별로 쪼갠다. 하나의 풀에 I/O와 CPU와 백그라운드 작업을 섞으면 반드시 어딘가에서 starvation이 난다.

ProcessPoolExecutor는 결국 순수 Python CPU 루프에서만 꺼내게 된다. 현업 Python 코드가 대부분 NumPy/Pandas/OpenCV/requests 위에 얇게 올라가 있는 한, 제일 먼저 손이 가는 도구는 ThreadPoolExecutor와 Uvicorn Workers 두 개다. 이 둘만 잘 써도 체감되는 성능 문제의 8할은 사라진다.