
이 글에서는 파이썬 멀티프로세싱에서 pickle 에러가 나는 정확한 원인, 직접 재현한 코드와 출력, 상황별 우회법 네 가지를 순서대로 다룹니다. 결론부터 짧게 말씀드리면, 이 에러는 대부분 대상 함수를 모듈 최상위가 아닌 곳(함수 안의 함수, 람다, 클래스 메서드)에 정의했을 때 나타나고, 함수를 최상위로 옮기거나 dill 기반 도구로 바꾸면 대부분 해결됩니다. 다만 이 방법들이 항상 만능은 아니라서, 어디서 안 통하는지도 함께 짚어드립니다.
파이썬 멀티프로세싱에서 pickle 에러가 나는 근본 원인
파이썬은 GIL 때문에 스레드만으로는 CPU 바운드 작업을 진짜 병렬로 돌리기 어렵습니다. 그래서 별도 프로세스를 여러 개 띄우는 멀티프로세싱을 쓰게 되는데, 프로세스는 메모리 공간이 서로 분리돼 있어서 함수와 데이터를 바이트로 바꿔 주고받아야 합니다. 이 직렬화 작업을 표준 라이브러리 pickle이 담당합니다.
문제는 pickle이 함수를 값 자체가 아니라 “이 모듈의 이 이름”이라는 참조 경로로 저장한다는 점입니다. 워커 프로세스가 그 경로로 다시 import해서 함수를 찾아야 하는데, 함수가 다른 함수 안에 중첩돼 있거나 람다로 만들어졌으면 참조 경로 자체가 없어서 실패합니다. 파이썬 공식 pickle 모듈 문서에서도 모듈 최상위에서 def로 정의한 함수만 피클링 가능하다고 명시하고 있고, 람다나 중첩 함수는 이 직렬화 대상에서 제외돼 있습니다.
AttributeError로 직접 확인하는 재현 코드
말로만 설명하면 감이 잘 안 오니 Python 3.11 기준으로 직접 에러를 내보겠습니다. 함수 안에서 함수를 만들어 반환하는 클로저 패턴을 Pool.map에 넘기면 어떻게 되는지 보겠습니다.
import multiprocessing as mp
def make_worker(power):
def inner(x):
return x ** power
return inner
if __name__ == "__main__":
squarer = make_worker(2)
with mp.Pool(processes=4) as pool:
result = pool.map(squarer, range(5))
print(result)
이 코드를 실행하면 다음과 같은 에러가 그대로 재현됩니다.

Traceback (most recent call last):
...
_pickle.PicklingError: Can't pickle .inner at 0x000001F3A1B2C4C0>:
it's not the same object as __main__.make_worker..inner
AttributeError: Can't pickle local object 'make_worker..inner'
원인 파악이 됐으면 고치는 건 간단합니다. inner를 모듈 최상위로 꺼내고, power 값은 functools.partial로 미리 고정해서 넘기면 됩니다.
import multiprocessing as mp
from functools import partial
def inner(x, power):
return x ** power
if __name__ == "__main__":
squarer = partial(inner, power=2)
with mp.Pool(processes=4) as pool:
result = pool.map(squarer, range(5))
print(result)
[0, 1, 4, 9, 16]
functools.partial 객체는 원본 함수와 인자를 속성으로 들고 있다가 그대로 피클링되도록 만들어져 있어서, 감싸는 함수만 최상위에 있으면 문제없이 통과합니다.
우회법 네 가지, 상황에 맞게 적용해 보세요
코드를 얼마나 건드릴 수 있는지에 따라 고를 수 있는 방법이 달라집니다. 아래 표는 각 방법을 적용하기 좋은 상황과 트레이드오프를 정리한 것입니다.
| 상황 | 우회법 | 트레이드오프 |
|---|---|---|
| 클로저를 직접 만든 코드 | 중첩 함수를 모듈 최상위로 이동 | 함수 시그니처와 호출부를 같이 손봐야 함 |
| 람다를 꼭 써야 하는 코드 | dill·cloudpickle로 직렬화 계층 교체 | 표준 pickle보다 느리고 결과물 용량이 커짐 |
| Pool 부분만 바꿀 여유가 있을 때 | multiprocess 패키지로 import만 교체 | 표준 라이브러리보다 커뮤니티 유지보수에 의존 |
| 인스턴스 상태를 넘겨야 할 때 | __call__을 구현한 콜러블 클래스로 감싸기 |
클래스 안에 다시 중첩 함수를 두면 똑같이 실패 |

첫 번째 방법이 가장 근본적이고 의존성 추가가 없어서 우선적으로 시도해 볼 만합니다. 반면 외부 라이브러리 코드라 손댈 수 없는 콜백이 람다로 짜여 있다면, 두세 번째 방법으로 넘어가는 게 현실적입니다.
dill 기반 라이브러리로 넘어가면 정말 다 해결될까요?
dill(pypi.org/project/dill)은 pickle이 값이 아니라 참조로만 저장하는 함수·클래스·람다를 실제 바이트코드 수준까지 캡처해서 직렬화합니다. multiprocess(pypi.org/project/multiprocess) 패키지는 표준 multiprocessing을 그대로 포크해서 내부 직렬화 엔진만 dill로 바꿔놓은 것이라, import 이름만 바꾸면 대부분 코드가 그대로 돌아갑니다. dask나 joblib 계열에서 자주 보이는 cloudpickle도 같은 목적으로, 함수를 원격 워커에 “값으로” 실어 보내기 위해 만들어진 도구입니다.
다만 공짜는 아닙니다. 함수 전체를 바이트코드째로 담다 보니 직렬화 결과물이 커지고, 매 작업마다 이 과정을 반복하면 표준 pickle 대비 오버헤드가 눈에 띄게 늘어납니다. 또한 multiprocess는 표준 라이브러리가 아니라서 사내 배포 환경에 새 의존성을 추가해야 하고, 표준 multiprocessing과 100% 동일한 API를 보장하지는 않습니다. 데이터 자체가 큰 numpy 배열이라면 직렬화 엔진을 바꿔도 프로세스 간 복사 비용은 그대로 남는다는 점도 염두에 둬야 합니다.
Windows와 Linux에서 증상이 다르게 나타나는 이유
같은 코드를 Windows에서 돌리면 에러가 나는데 Linux에서는 멀쩡한 경우가 종종 보고됩니다. 이건 두 운영체제가 자식 프로세스를 만드는 방식이 달라서 생기는 차이입니다. Windows는 fork 시스템 콜이 없어서 항상 spawn 방식을 쓰는데, 이 방식은 자식 프로세스를 아예 새 인터프리터로 띄운 뒤 필요한 객체를 전부 pickle로 실어 보냅니다.
반면 Linux 기본값인 fork 방식은 부모 프로세스의 메모리를 그대로 복제해서 자식을 만들기 때문에, mp.Process(target=…)에 넘긴 로컬 함수가 이미 자식 메모리 안에 살아있는 채로 실행됩니다. 다만 이 차이는 Process 객체에 한정된 이야기이고, Pool.map처럼 작업을 큐에 실어 워커로 계속 흘려보내는 구조에서는 fork든 spawn이든 상관없이 매 작업마다 pickle을 거치기 때문에 Linux에서도 로컬 함수를 넘기면 똑같이 실패합니다. “우리 서버에서는 됐는데 내 컴퓨터에서는 안 된다”는 보고를 볼 때는 Process를 썼는지 Pool을 썼는지부터 확인해 볼 필요가 있습니다.
지금 코드에 바로 적용할 수 있는 점검 순서
정리하면, 에러 메시지에 <locals>가 보이면 중첩 함수나 람다가 범인이니 먼저 최상위로 옮길 수 있는지부터 확인해 보세요. 리팩터링이 어렵다면 dill 기반의 multiprocess 패키지로 import를 바꾸는 게 가장 빠른 우회법이고, 성능이 걸린다면 데이터 크기부터 줄이는 방향을 같이 고민하는 게 낫습니다. mp.Process와 Pool 중 어느 쪽을 쓰는지도 다시 한 번 확인해서, 필요 이상으로 무거운 우회법을 고르지 않도록 하시길 바랍니다.
