개요

bpy를 멀티프로세스 환경에서 사용할 때 spawn 방식에서는 _bpy 로드 실패, fork 방식에서는 초기화 충돌이 발생한다. spawn 방식의 실패는 비교적 이해하기 쉽지만, fork 방식에서 왜 충돌이 발생하는지는 직관적으로 이해하기 어렵다. 이 글에서는 구체적인 비유와 예시를 통해 이 문제를 정리한다.


fork는 무엇을 복제하는가

fork는 부모 프로세스의 메모리를 Copy-on-Write 방식으로 복제한다.

메인 프로세스 (PID 100)
    ├── 메모리 (변수, 객체 등)
    ├── 파일 디스크립터
    ├── bpy 로드 상태
    └── 전역 변수
        ↓ fork
자식 프로세스 (PID 101)
    ├── 메모리 복제
    ├── 파일 디스크립터 복제
    ├── bpy 로드 상태 복제
    └── 전역 변수 복제

얼핏 보면 bpy가 이미 로드된 상태로 복제되니 문제없을 것 같다. 하지만 여기서 중요한 포인트가 있다.


복제된 값 ≠ 유효한 값

복제된 것은 메모리에 저장된 값이지, 그 값이 가리키는 실제 OS 자원이 아니다.

열쇠 복사 비유

원본 열쇠 (메인 프로세스, PID 100)
    → 자물쇠 회사(OS)가 PID 100에게 발급한 열쇠
    → 이 열쇠로 문(OpenGL, 메모리 풀 등)을 열 수 있음

복사된 열쇠 (자식 프로세스, PID 101)
    → 열쇠 모양(메모리 값)은 동일하게 복사됨
    → 하지만 자물쇠 회사(OS)는 PID 101에게 이 열쇠를 발급한 적 없음
    → 문을 열려고 하면 OS가 거부

Blender 전역 상태가 복제될 때 무슨 일이 일어나는가

Blender 초기화 시 설정되는 것들

bpy 초기화 (메인 프로세스 PID 100)
    → Lock 파일 생성
        /tmp/blender_lock (PID 100이 실행 중임을 표시)
    → 전역 메모리 풀 할당
        메모리 주소 0x1234에 Blender 컨텍스트 저장
    → OpenGL 핸들 획득
        OS로부터 핸들 #001 발급 (소유자: PID 100)

fork 후 자식 프로세스 상태

자식 프로세스 (PID 101) — fork 직후
    → Lock 파일 내용 복제
        /tmp/blender_lock에 PID 100 기록되어 있음
    → 전역 메모리 주소 복제
        0x1234에 동일한 컨텍스트 값 있음
    → OpenGL 핸들 값 복제
        핸들 #001 값을 가지고 있음
        BUT 이 핸들의 소유자는 여전히 PID 100

자식 프로세스에서 bpy 함수 호출 시

자식 프로세스 (PID 101)에서 mesh_to_pyg() 호출
    → bpy 내부에서 전역 컨텍스트 확인
    → "이 컨텍스트는 PID 100이 만든 것"
    → "나(PID 101)는 초기화를 한 적이 없음"
    → bpy가 두 가지 중 하나를 시도

    [시도 1] 재초기화
        → Lock 파일 확인
        → "이미 PID 100이 실행 중" → 재초기화 거부 → 실패

    [시도 2] 기존 컨텍스트 그대로 사용
        → OpenGL 핸들 #001 사용 시도
        → OS: "이 핸들은 PID 100 소유, PID 101 사용 불가"
        → crash

spawn 방식과의 차이

spawn 방식
    새 프로세스 (PID 101)
        → 완전히 새로 시작
        → bpy 재초기화 시도
        → 컨테이너 환경에서 _bpy 네이티브 모듈 로드 실패
        → ModuleNotFoundError: No module named '_bpy'

fork 방식
    자식 프로세스 (PID 101)
        → 복제된 상태로 시작
        → bpy 재초기화 없이 기존 컨텍스트 사용 시도
        → 복제된 핸들/락이 유효하지 않아 충돌
        → bpy init 실패

두 방식 모두 결국 bpy가 단일 프로세스 전용으로 설계되어 있어서 발생하는 문제다. 단지 실패하는 시점과 방식이 다를 뿐이다.


왜 일반 라이브러리는 이 문제가 없는가

numpy, torch 같은 라이브러리는 처음부터 멀티프로세스 환경을 고려하여 설계됐다.

numpy, torch
    → 전역 상태 최소화
    → 프로세스별 독립적인 상태 관리
    → OS 자원(핸들, 락)을 프로세스 단위로 관리
    → fork 후 자식 프로세스에서 재초기화 가능

bpy
    → 전역 상태에 강하게 의존
    → 단일 프로세스 기준으로 설계
    → OS 자원을 프로세스 간 공유 불가
    → fork 후 자식 프로세스에서 재사용 불가

해결책 — subprocess로 완전 격리

이 문제의 해결책은 bpy를 완전히 독립된 새 프로세스에서만 실행하는 것이다.

fork / spawn (실패)
메인 프로세스
    └── 워커 프로세스 (부모로부터 파생)
          └── bpy 사용 시도 → 충돌

subprocess (성공)
메인 프로세스 (bpy 없음)
    └── 완전히 새로운 독립 프로세스
          └── bpy 초기화 → 성공
          └── STL 파일 처리 → 성공
          └── 프로세스 종료

subprocessfork / spawn과 달리 부모 프로세스와 완전히 독립된 새 프로세스를 생성한다. 따라서 bpy가 깨끗한 환경에서 초기화되어 정상 동작한다.

# sqs_consumer.py — bpy 임포트 없음
def process_message(message: dict):
    subprocess.run(
        ['python', '/app/inference_runner.py', json.dumps(body)],
        check=True
    )

# inference_runner.py — 완전히 독립된 프로세스에서 bpy 초기화
from libs.OcclusalplaneUtils import mesh_to_pyg  # bpy 사용 → 성공

정리

방식 동작 bpy 결과 실패 원인
spawn 새 인터프리터 시작 _bpy 네이티브 모듈 로드 실패
fork 부모 메모리 복제 복제된 OS 자원 핸들 무효
subprocess 완전히 독립 프로세스

핵심은 복제된 값과 유효한 값은 다르다는 점이다. fork로 메모리를 복제해도 OS가 관리하는 자원(OpenGL 핸들, 락 파일 등)은 원래 프로세스 소유로 남아 있어 자식 프로세스에서 사용할 수 없다. bpy는 이런 OS 자원에 강하게 의존하기 때문에 fork 후 자식 프로세스에서 정상 동작을 보장할 수 없다.