배경
서비스의 ML 추론 파이프라인은 STL 파일을 입력으로 받아 교합 평면을 예측하는 구조다. 이 파이프라인은 두 가지 실행 환경을 지원한다.
- AWS Lambda — SQS 이벤트 트리거 방식
- 로컬 서버 — SQS 폴링 방식
로컬 서버 환경에서는 sqs_worker.py가 SQS 큐를 폴링하며 메시지를 소비하고, 실제 추론은 inference_runner.py를 subprocess로 호출해 처리하는 구조였다.
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 병렬화는 오히려 역효과였다.