배경

서비스의 ML 추론 파이프라인은 STL 파일을 입력으로 받아 교합 평면을 예측하는 구조다. 이 파이프라인은 두 가지 실행 환경을 지원한다.

  • AWS Lambda — SQS 이벤트 트리거 방식
  • 로컬 서버 — SQS 폴링 방식

로컬 서버 환경에서는 sqs_worker.py가 SQS 큐를 폴링하며 메시지를 소비하고, 실제 추론은 inference_runner.pysubprocess로 호출해 처리하는 구조였다.

sqs_worker.py
    └─ subprocess → inference_runner.py (매 요청마다 새 프로세스)

subprocess를 선택한 이유 — bpy의 특성

추론 과정에서 3D 메시 데이터를 전처리할 때 bpy(Blender Python API)를 사용한다. bpy는 Blender의 전역 상태를 프로세스 단위로 관리하기 때문에, 동일 프로세스에서 여러 작업을 병렬로 실행하면 상태 충돌이 발생할 수 있다.

이 문제를 피하기 위해 각 요청을 독립된 subprocess로 격리하는 방식을 선택했다. ProcessPoolExecutor(max_workers=2)로 최대 2개의 요청을 동시에 처리하는 구조였다.

# sqs_worker.py (기존)
with ProcessPoolExecutor(max_workers=2) as executor:
    future = executor.submit(process_message, messages[0])
# process_message 내부 (기존)
subprocess.run(
    ['python', '/app/inference_runner.py', json.dumps(body)],
    capture_output=True,
    text=True,
    check=True,
)

논리적으로는 합당한 선택이었다.


문제 발견 — Lambda가 로컬보다 빠르다?

실제 측정 결과가 예상과 달랐다. 스펙이 더 좋은 로컬 서버가 Lambda보다 처리 시간이 약 5배 느렸다.

환경 처리 시간
AWS Lambda (Warm) ~2초
로컬 서버 (subprocess 방식) ~17초

처음엔 네트워크 레이턴시를 의심했다. 로컬 서버는 S3/SQS 호출 시 인터넷을 거치지만, Lambda는 AWS 내부 네트워크를 사용하기 때문이다. 하지만 파일 크기가 약 10MB 수준이었고, 네트워크 차이만으로 5배 격차를 설명하기는 무리였다.


원인 분석 — bpy 초기화 + 모델 로딩이 매 요청마다 반복

문제는 subprocess 구조에 있었다.

bpy는 단순한 Python 라이브러리가 아니다. import bpy 한 줄이 실행되는 순간 Blender 렌더 엔진, OpenGL 컨텍스트, 내장 애드온, 씬 데이터 구조가 모두 초기화된다. 사실상 Blender 애플리케이션 전체를 헤드리스 모드로 띄우는 것과 다름없다. 이 초기화 비용만으로도 수 초~수십 초가 소요된다.

여기에 더해 AI 모델(약 200MB) 로딩도 매 요청마다 발생했다. torch.load + load_state_dict 과정에서 디스크 I/O와 메모리 할당이 동시에 발생하기 때문에 이것만으로도 수 초가 걸린다.

subprocess 방식에서는 이 두 가지 비용이 매 요청마다 반복됐다.

요청 1: 새 프로세스 시작 → bpy 초기화(~11초) + 모델 로딩(~수초) → 추론 → ~17초
요청 2: 새 프로세스 시작 → bpy 초기화(~11초) + 모델 로딩(~수초) → 추론 → ~17초
요청 3: 새 프로세스 시작 → bpy 초기화(~11초) + 모델 로딩(~수초) → 추론 → ~17초

반면 Lambda(Warm Start)는 컨테이너가 유지되는 동안 bpy 초기화와 모델 로딩이 이미 완료된 상태다. 요청이 들어올 때는 추론만 실행하면 된다.

Lambda Warm Start: bpy 초기화 완료 + 모델 로딩 완료 → 추론만 → ~2초

병렬 처리로 throughput을 늘리려던 시도가, 오히려 매 요청마다 반복되는 초기화 오버헤드 때문에 단일 요청의 latency를 압도적으로 늘리는 결과를 낳았다.


로컬 서버가 더 빠를 거라는 착각

한 가지 짚고 넘어갈 부분이 있다. Lambda의 ~2초는 Warm Start 기준이다. bpy 초기화와 모델 로딩이 이미 완료된 상태에서 측정한 수치다.

단일 프로세스로 전환한 후 로컬 서버를 다시 측정해보니 결과가 달랐다.

환경 처리 시간
로컬 서버 첫 번째 요청 ~17초 (bpy 초기화 포함)
로컬 서버 두 번째 요청~ ~6초 (bpy 재사용)
Lambda Warm Start ~2초

두 번째 요청부터의 로컬 서버(~6초)와 Lambda(~2초)의 차이는 순수하게 네트워크 차이로 설명된다. 로컬은 S3를 인터넷을 통해 접근하고, Lambda는 AWS 내부 네트워크를 사용한다. 10MB 파일 기준으로 4초 차이는 충분히 납득 가능한 수치다.

결국 로컬 서버 스펙이 더 좋다는 전제 자체는 맞지만, 동일 조건(Warm 상태)에서는 네트워크 차이만큼만 느린 정상적인 상황이었다. subprocess 방식의 느림은 하드웨어 문제가 아니라 구조적 문제였다.


해결 — 단일 처리로 전환

bpy의 상태 충돌 문제는 그대로 있다. 다만 현재 서비스 요구사항 상 동시 요청이 많지 않고, 단일 처리로도 충분한 throughput을 확보할 수 있다는 판단 하에 구조를 단순화했다.

inference_runner.py를 별도 파일로 유지할 이유도 사라졌다. subprocess 호출을 위해 분리했던 파일이었기 때문에, sqs_worker.py에 모든 로직을 합쳤다.

# 변경 전
sqs_worker.py → subprocess → inference_runner.py

# 변경 후
sqs_worker.py (모델 사전 로딩 + 추론 + SQS 처리)

bpy 초기화와 모델 로딩은 프로세스 시작 시점에 한 번만 발생하고, 이후 요청은 이미 초기화된 상태를 재사용한다.

프로세스 시작: bpy 초기화 + 모델 로딩 (1회)

첫 번째 요청: bpy 초기화 포함 → ~17초
두 번째 요청~: 추론 + S3 I/O만 → ~6초
# sqs_worker.py — 시작 시점에 모델 사전 로딩
for pth_path in [PTH_UPPER, PTH_LOWER]:
    if os.path.exists(pth_path):
        model = MeshGNNTransformer(option).to(device)
        checkpoint = torch.load(pth_path, map_location='cpu', weights_only=False)
        model.load_state_dict(checkpoint['model_state_dict'])
        model.eval()
        _models[pth_path] = model
# 폴링 루프 — 단순 순차 처리
def polling_loop():
    while not terminate_signal:
        response = sqs.receive_message(...)
        messages = response.get('Messages', [])
        if messages:
            process_message(messages[0])  # 초기화된 상태 재사용

Lambda가 더 적합한 환경일 수 있다

이번 문제를 겪으면서 Lambda 구조가 이 워크로드에 꽤 잘 맞는 환경이라는 생각을 했다.

Lambda는 Warm Start 시 컨테이너가 그대로 유지되기 때문에 bpy 초기화와 모델 로딩이 init 단계에서 한 번만 발생한다. 요청이 들어올 때마다 이미 준비된 상태를 바로 사용할 수 있다.

또한 Lambda는 요청 단위로 격리된 실행 환경을 제공하기 때문에, bpy의 전역 상태 문제도 자연스럽게 해소된다. 동시 요청이 각각 별도의 Lambda 인스턴스에서 실행되므로, subprocess로 프로세스를 격리할 필요 없이 상태 충돌을 피할 수 있다.

로컬 서버에서 subprocess를 선택한 이유(bpy 상태 격리)를 Lambda는 구조적으로 이미 해결하고 있었던 셈이다.


정리

Lambda 로컬 (subprocess, 변경 전) 로컬 (단일 처리, 변경 후)
bpy 초기화 init 단계 1회 요청마다 반복 시작 시점 1회
모델 로딩 init 단계 1회 요청마다 반복 시작 시점 1회
bpy 상태 격리 인스턴스 단위 자연 격리 subprocess 격리 단일 프로세스 (순차 처리로 충돌 없음)
동시 처리 Lambda 동시성으로 자동 확장 ProcessPoolExecutor(2) 순차 처리
첫 번째 요청 latency ~2초 (Warm) ~17초 ~17초
두 번째 요청~ latency ~2초 ~17초 ~6초

병렬 처리를 고민하기 전에, 단일 요청의 비용 구조를 먼저 파악하는 게 맞았다. bpy 초기화와 모델 로딩이 지배적인 비용인 상황에서 subprocess 병렬화는 오히려 역효과였다.