Published on

Python multiprocessing PicklingError 해결 가이드

Authors

서버/배치 작업에서 CPU 바운드 처리를 위해 multiprocessing를 붙이는 순간, 가장 흔하게 마주치는 예외가 바로 pickling(직렬화) 관련 오류입니다. 특히 macOS/Windows(기본 spawn)나 Jupyter 환경에서는 “로컬 함수는 못 피클링한다”, “lambda는 못 보낸다” 같은 메시지가 빈번히 발생합니다.

이 글에서는 에러 메시지 유형별 원인, OS별 프로세스 시작 방식(start method) 차이, 그리고 실제 현장에서 가장 많이 쓰는 수정 패턴을 코드로 정리합니다. 비슷한 맥락으로 런타임/실행 환경 차이에서 생기는 오류는 Python asyncio RuntimeError - Event loop is closed 해결도 함께 참고하면 좋습니다.

1) Pickling error가 왜 생기나: multiprocessing의 기본 전제

multiprocessing는 부모 프로세스의 함수를/데이터를 자식 프로세스로 “전달”해야 합니다. 이때 전달 방식이 OS/Start method에 따라 달라집니다.

  • fork (주로 Linux 기본): 부모 프로세스를 포크해 메모리 상태를 복제(COW)합니다. 전달해야 할 것이 상대적으로 적어 pickling 문제가 덜 드러납니다.
  • spawn (Windows 기본, macOS Python 3.8+ 기본): 새 파이썬 인터프리터를 띄우고, 작업 대상(함수/인자)을 pickle로 직렬화해 전달합니다. 따라서 pickle 가능한 것만 넘길 수 있습니다.
  • forkserver (일부 환경에서 선택): fork를 쓰되 서버 프로세스를 통해 관리합니다.

즉, 개발 PC(macOS/Windows)에서 터지는 pickling 에러가 리눅스 서버에서는 “우연히” 안 터지는 경우가 많고, 그 반대도 가능합니다. 이 차이를 이해하는 것이 첫 단추입니다.

2) 대표 에러 메시지와 원인 매핑

아래 메시지들은 사실상 같은 범주의 문제를 가리킵니다.

2.1 Can't pickle local object '...<locals>...'

  • 원인: 함수가 모듈 최상단(top-level)이 아니라, 다른 함수 내부에 정의된 로컬 함수/클로저인 경우
  • 자주 나오는 패턴: 내부 함수, 데코레이터가 반환한 래퍼 함수, functools.partial에 로컬 함수가 섞인 경우

2.2 Can't pickle <function <lambda> ...>

  • 원인: lambda는 일반적으로 pickle 대상이 아닙니다.

2.3 AttributeError: Can't get attribute 'X' on <module '__main__'>

  • 원인: spawn 방식에서 자식 프로세스가 __main__을 재-import 하는 과정에서, 대상 함수/클래스를 찾지 못함
  • 자주 나오는 패턴: if __name__ == '__main__': 가드 누락, REPL/Jupyter에서 실행, 파일 구조/모듈 경로 문제

2.4 PicklingError: Can't pickle <class ...>: it's not the same object as ...

  • 원인: 동일한 이름의 클래스가 서로 다른 모듈 로딩 경로로 두 번 로드되었거나, 동적 생성 타입(type), import 꼬임

3) 가장 먼저 확인할 체크리스트(실전 순서)

  1. if __name__ == "__main__": 가드가 있는가? (Windows/macOS에서 필수)
  2. 프로세스에 넘기는 함수가 모듈 최상단에 정의되어 있는가?
  3. 인자로 넘기는 객체(클래스 인스턴스, 커넥션, 락, 파일 핸들)가 pickle 가능한가?
  4. Jupyter/Notebook에서 실행 중인가? → 스크립트로 분리 권장
  5. start method를 명시했는가? (spawn/fork 차이로 재현성 확보)

4) 해결 패턴 A: main 가드 + top-level 함수로 올리기

가장 정석적인 해결입니다.

문제 코드(로컬 함수/가드 없음)

import multiprocessing as mp

def run():
    def worker(x):  # 로컬 함수 -> spawn에서 pickle 불가
        return x * x

    with mp.Pool(4) as pool:
        print(pool.map(worker, [1, 2, 3, 4]))

run()

해결 코드(최상단 함수 + main 가드)

import multiprocessing as mp


def worker(x):
    return x * x


def run():
    with mp.Pool(4) as pool:
        print(pool.map(worker, [1, 2, 3, 4]))


if __name__ == "__main__":
    run()

이 한 가지 수정만으로 상당수의 pickling 에러가 사라집니다.

5) 해결 패턴 B: lambda/closure 대신 named function, partial은 신중히

lambda를 쓰고 싶어도 multiprocessing에서는 named function으로 바꾸는 편이 안전합니다.

문제 코드(lambda)

import multiprocessing as mp

if __name__ == "__main__":
    with mp.Pool(2) as pool:
        print(pool.map(lambda x: x + 10, [1, 2, 3]))

해결 코드

import multiprocessing as mp


def add10(x):
    return x + 10


if __name__ == "__main__":
    with mp.Pool(2) as pool:
        print(pool.map(add10, [1, 2, 3]))

functools.partial은 top-level 함수에 대해서는 대체로 동작하지만, 내부적으로 캡처한 객체가 pickle 불가면 동일하게 실패합니다.

6) 해결 패턴 C: 인스턴스 메서드/상태(state)를 넘기지 말고, 초기화는 각 프로세스에서

가장 흔한 실수 중 하나가 “DB 커넥션/세션/클라이언트” 같은 프로세스 경계 밖 리소스를 그대로 워커로 넘기는 것입니다.

안티패턴: 커넥션을 부모에서 만들고 자식으로 전달

import multiprocessing as mp

class Client:
    def __init__(self):
        self.fp = open("data.txt", "r")  # 파일 핸들은 pickle 불가

    def work(self, x):
        return x


def run():
    c = Client()
    with mp.Pool(2) as pool:
        # bound method + 내부에 파일 핸들 -> pickling 실패 가능
        print(pool.map(c.work, [1, 2, 3]))


if __name__ == "__main__":
    run()

권장: initializer로 프로세스별 초기화

import multiprocessing as mp

_client = None


class Client:
    def __init__(self, path: str):
        self.path = path

    def work(self, x):
        # 필요 시 여기서 파일을 열고 닫는 방식도 가능
        return f"{x}:{self.path}"


def init_worker(path: str):
    global _client
    _client = Client(path)


def worker(x):
    return _client.work(x)


if __name__ == "__main__":
    with mp.Pool(2, initializer=init_worker, initargs=("data.txt",)) as pool:
        print(pool.map(worker, [1, 2, 3]))

핵심은 부모의 상태를 자식으로 ‘운반’하지 말고, 자식 프로세스가 시작될 때 자기 내부에서 생성하게 만드는 것입니다.

7) 해결 패턴 D: start method를 명시해 재현성 확보

개발/운영 환경이 다르면 버그가 “재현이 안 된다”가 가장 큰 비용입니다. start method를 명시하면 재현성이 좋아집니다.

import multiprocessing as mp


def worker(x):
    return x * 2


if __name__ == "__main__":
    mp.set_start_method("spawn", force=True)  # macOS/Windows와 동일 조건
    with mp.Pool(2) as pool:
        print(pool.map(worker, [1, 2, 3]))
  • Linux 서버에서만 돌릴 거라도, CI나 로컬에서 spawn으로 테스트하면 pickling 문제를 조기에 발견할 수 있습니다.
  • 단, 이미 start method가 설정된 상태에서 다시 설정하면 에러가 날 수 있어 force=True를 쓰거나, 앱 엔트리포인트 초기에만 설정하세요.

8) 해결 패턴 E: Pool.map 대신 concurrent.futures로 단순화(그리고 여전히 pickle 규칙은 동일)

multiprocessing의 저수준 API보다 concurrent.futures.ProcessPoolExecutor가 예외 전파/구조가 깔끔한 경우가 많습니다.

from concurrent.futures import ProcessPoolExecutor


def worker(x):
    return x ** 2


if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as ex:
        print(list(ex.map(worker, [1, 2, 3, 4])))

다만 pickle 제약은 동일합니다. top-level 함수/클래스, pickle 가능한 인자라는 원칙은 변하지 않습니다.

9) 그래도 안 되면: "pickle 가능한 데이터"로 경계를 재설계

pickling 에러를 “우회”하려고 dill 같은 대체 직렬화 라이브러리를 쓰는 방법도 있지만, 운영 관점에서는 다음 접근이 더 안전합니다.

  • 프로세스에 넘기는 인자는 기본 타입(int/float/str/bytes/list/dict/tuple) 중심으로 제한
  • 복잡한 객체는 ID/경로/설정값만 넘기고, 워커 내부에서 재구성
  • 전역 캐시/싱글톤/세션 객체를 공유하려 하지 말기(프로세스는 메모리를 공유하지 않음)

이 관점은 장애 대응에도 유리합니다. 예를 들어 외부 API 장애 시 재시도/폴백을 설계하듯이(OpenAI Responses API 503 멈춤 - 재시도·폴백 설계), 병렬 처리에서도 “경계에서 교환되는 데이터 형태”를 단순하게 유지하면 디버깅과 복구가 쉬워집니다.

10) 자주 묻는 질문(FAQ)

Q1. Linux에서는 되는데 macOS/Windows에서만 깨져요

대부분 spawn 때문입니다. 로컬에서도 mp.set_start_method("spawn")로 맞춰 테스트하고, top-level 함수 + __main__ 가드를 적용하세요.

Q2. Jupyter Notebook에서 multiprocessing이 계속 실패해요

Notebook은 __main__/모듈 로딩이 스크립트와 다르게 동작해 pickling 문제가 더 자주 발생합니다. 핵심 로직을 .py 모듈로 분리하고, 엔트리포인트 스크립트에서 실행하는 것을 권장합니다.

Q3. 클래스 메서드를 꼭 써야 해요

가능하지만, 인스턴스 자체가 pickle 가능해야 하고(내부에 파일/락/소켓 등 금지), import 경로가 명확해야 합니다. 실무에서는 initializer로 전역(프로세스 로컬) 인스턴스를 만들고 top-level worker가 호출하는 패턴이 가장 예측 가능합니다.

11) 마무리: PicklingError는 “버그”가 아니라 “설계 신호”

multiprocessing의 pickling 에러는 단순한 예외가 아니라, “프로세스 경계를 넘는 데이터/코드가 너무 복잡하다”는 신호인 경우가 많습니다.

  • 함수/클래스는 모듈 최상단에
  • if __name__ == "__main__": 가드 필수
  • 워커 인자는 단순 타입으로, 리소스 초기화는 프로세스 내부에서
  • start method를 명시해 환경 차이를 제거

위 원칙대로 정리하면, 운영 환경에서의 재현 불가/간헐 실패를 크게 줄일 수 있습니다. 병렬 처리에서 문제가 “가끔” 발생한다면, 먼저 start method와 pickling 경계를 의심해 보세요.