Python concurrent.futures Executor.map: 하나의 작업이 예외를 던지면 나머지 결과가 사라지는 문제

1. 문제 정의

concurrent.futures.ProcessPoolExecutor.map 또는 ThreadPoolExecutor.map으로 여러 작업을 병렬 처리할 때, 전달한 함수 중 하나라도 예외를 던지면 그 예외를 결과 반복자에서 꺼낸 순간 반복자가 종료되어, 이후에 정상적으로 완료된 나머지 작업들의 결과를 더 이상 얻을 수 없습니다.

Python 공식 문서(concurrent.futures)에 따르면 Executor.map은 내장 map(func, *iterables)과 “유사"하며, 함수가 비동기로 실행되고 여러 호출이 동시에 일어난다는 점만 다릅니다. 따라서 한 요소에서 예외가 나더라도 내장 map처럼 이후 요소의 결과는 계속 얻을 수 있어야 합니다. 그러나 실제 동작은 그렇지 않습니다.

관련 코드 / 재현 예시

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import concurrent.futures
import contextlib

def add_one(value):
    if value == 2:
        raise ValueError()
    return value + 1

def test(executor_cm):
    with executor_cm as executor:
        values = executor.map(add_one, (1, 2, 3))
        values_it = iter(values)
        try:
            while True:
                try:
                    value = next(values_it)
                except ValueError:
                    print("value error")
                else:
                    print(f"value: {value}")
        except StopIteration:
            pass

print("ProcessPoolExecutor")
test(concurrent.futures.ProcessPoolExecutor())
print("\nThreadPoolExecutor")
test(concurrent.futures.ThreadPoolExecutor())
print("\nBuilt-in map function")
class fake_executor:
    map = staticmethod(map)
test(contextlib.nullcontext(fake_executor))

실제 출력 (버그가 있는 3.11 이하)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ProcessPoolExecutor
value: 2
value error

ThreadPoolExecutor
value: 2
value error

Built-in map function
value: 2
value error
value: 4

기대 출력

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ProcessPoolExecutor
value: 2
value error
value: 4

ThreadPoolExecutor
value: 2
value error
value: 4

Built-in map function
value: 2
value error
value: 4

시퀀스 (1, 2, 3)add_one에 넘기면 결과는 2, 3, 4가 되어야 합니다. 여기서 value == 2일 때 ValueError가 발생합니다. 내장 map은 그 뒤에 있는 value: 4(3 → 4)까지 정상 출력하지만, Executor.mapvalue error 다음에 바로 종료되어 value: 4가 출력되지 않습니다. 즉 하나의 예외가 나머지 정상 결과를 모두 삼켜버립니다.

  • 테스트 환경: Python 3.11.5 (Linux), 문제는 사실상 모든 지원 버전에서 발생.

2. 원인 탐구

왜 이런 일이 발생할까요? 가장 먼저 떠오르는 의심은 “예외의 전파 방식"입니다.

  1. 내장 map은 지연(lazy) 평가 — 각 요소를 순회하며 즉시 함수를 호출합니다. 한 요소에서 예외가 나도 상위 레벨에서 try/except로 잡으면 다음 next() 호출에서 이어서 진행할 수 있습니다.
  2. Executor.map은 비동기 실행 — 내장 map과 달리 모든 입력을 먼저 수집하고, 각 함수 호출을 별도의 쓰레드/프로세스에 제출한 뒤, 결과를 저장해 둡니다. 따라서 원리적으로는 예외가 나더라도 나머지 이미 완료된 결과를 꺼낼 수 있어야 합니다.

이 발상에 착안해, 원인은 “예외가 발생한 작업 자체"가 아니라 결과를 순회하는 반복자(result_iterator)의 구현 방식에 있을 것으로 의심할 수 있습니다.

3. 근본 원인 분석

근본 원인은 concurrent/futures/_base.pyresult_iterator가 제너레이터 함수(generator function)로 구현되어 있다는 점입니다 (참고: 이슈에서 가리킨 지점은 Lib/concurrent/futures/_base.py L612-L625 부근).

Python에서 제너레이터 함수 내부에서 어떤 예외가 발생하면 그 제너레이터는 그 상태에서 더 이상 재개(resume)할 수 없습니다. next()를 다시 호출하면 StopIteration이 발생하고, 남아 있던 코드는 실행되지 않습니다.

Executor.mapresult_iterator도 동일한 패턴입니다. next()가 처리 중인 future의 결과를 꺼낼 때 해당 작업이 예외로 끝났다면, 그 예외를 호출자에게 전달하는 과정에서 제너레이터 내부에서 예외가 발생하고, 결과적으로 반복자 전체가 끝나버립니다. 따라서 이미 완료되어 정상 값을 가지고 있던 이후 future들의 결과에도 접근할 수 없게 됩니다.

이 동작은 명백히 문서화된 동작(“내장 map과 유사”)과 모순됩니다. 이슈(FYI)와 참여자 논의에서도 확인된 내용입니다:

  • 참여자(sterliakov)의 분석: “제너레이터 함수는 내부에서 예외가 발생하면 그 지점으로 돌아갈 수 없으며, 현재 동작은 문서와 분명히 모순된다. 문제는 모든 지원 버전에 적용된다.”
  • 제안된 해결책: result_iterator__next__ 메서드를 가진 클래스로 구현하는 것. (단, 이는 breaking change라서 concurrent.futures 전문가의 검토가 필요.)

종합하면 근본 원인은 다음과 같습니다.

구분내용
증상Executor.map 반복자에서 예외를 꺼낸 뒤 나머지 결과를 얻지 못함
근본 원인result_iterator가 제너레이터 함수로 구현됨 → 내부 예외 발생 시 재개 불가
내장 map과의 차이map은 지연 평가라 예외를 건너뛰고 다음 요소를 이어 나감
영향 범위ProcessPoolExecutor, ThreadPoolExecutor 모두 해당, 모든 지원 버전
공식 수정Python 3.16에서 내장 map과 일치하도록 동작 변경 (CPython #108518 / PR #109497)

4. 코드 해결책

이 문제는 표준 라이브러리의 동작이므로, 사용자가 직접 result_iterator를 고칠 수는 없습니다. Python 3.16부터는 내장 map()과 일치하는 동작으로 공식 수정되었습니다. 그 이전 버전에서는 아래와 같은 방법으로 우회할 수 있습니다.

방법 A — 콜러블 내부에서 예외 자체를 처리 (권장)

가장 간단하고 확실한 방법입니다. 반복자가 예외를 던지지 않도록, 작업 함수 안에서 예외를 직접 처리하고 반환 값으로 상태를 돌려줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import concurrent.futures

def safe_add_one(value):
    try:
        if value == 2:
            raise ValueError()
        return ("ok", value + 1)
    except ValueError as e:
        return ("error", str(e))

with concurrent.futures.ProcessPoolExecutor() as executor:
    results = list(executor.map(safe_add_one, (1, 2, 3)))

for status, payload in results:
    if status == "ok":
        print(f"value: {payload}")      # 2, 4
    else:
        print("value error")            # 2에 대한 오류

방법 B — executor.submit + Future.result()로 개별 제어

submit()으로 각 작업을 개별 Future로 제출하면, 한 Future의 예외가 다른 Future에 영향을 주지 않습니다. 예외는 필요할 때 Future.result()에서만 발생합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import concurrent.futures

def add_one(value):
    if value == 2:
        raise ValueError()
    return value + 1

with concurrent.futures.ProcessPoolExecutor() as executor:
    futures = [executor.submit(add_one, v) for v in (1, 2, 3)]
    for f in futures:
        try:
            print(f"value: {f.result()}")
        except ValueError:
            print("value error")
# 출력: value: 2 / value error / value: 4

방법 C — (Option) 결과를 한 번에 수집

list(executor.map(...))으로 결과를 한 번에 소비하면, 어느 한 요소에서 예외가 발생해도 그 예외 자체가 상위로 전파되며 다른 결과는 얻지 못합니다. 따라서 “예외를 무시하고 나머지 결과만 얻으려는” 경우에는 적합하지 않고, “아무 작업이라도 실패하면 전체 실패로 처리"하는 시맨틱이 필요할 때 사용합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import concurrent.futures

def add_one(value):
    if value == 2:
        raise ValueError()
    return value + 1

with concurrent.futures.ThreadPoolExecutor() as executor:
    try:
        results = list(executor.map(add_one, (1, 2, 3)))
    except ValueError:
        print("하나의 작업이 실패하여 전체 map 실패")

공식 수정 (Python 3.16+)

Python 3.16부터 Executor.map()은 내장 map()과 동일하게 동작하도록 변경되어, 예외가 발생해도 이후 결과를 계속 얻을 수 있습니다. 이 항목은 CPython 이슈 #108518와 병합된 PR #109497(Make concurrent.futures.Executor.map() consistent with built-in map())로 구현되었습니다. 이슈 댓글에서도 “behavior changed in 3.16"으로 확인됩니다.

가능하다면 Python 3.16 이상으로 업그레이드해 이 동작 차이 자체를 없애는 것이 가장 근본적인 해결책입니다.

5. 향후 예방 조치

  1. Executor.map의 반복자를 예외로부터 보호 — 병렬 작업의 함수가 예외를 던질 수 있다면, 함수 내부에서 예외를 잡고 상태를 반환 값으로 표현하는 패턴(방법 A)을 기본으로 사용합니다.
  2. 예외를 “값"으로 다루기("ok", value) / ("error", msg) 같은 구조로 반환해 반복자 단계에서는 예외가 발생하지 않도록 설계합니다.
  3. 개별 제어가 필요하면 submit() 사용 — 각 작업의 성공/실패를 독립적으로 처리해야 한다면 map 대신 submit() + future.result()를 사용합니다.
  4. 버전 확인 — 대상 런타임의 Python 버전이 3.16 미만인지 확인하고, 그렇다면 위 우회 방법을 적용합니다. 3.16 이상이면 이 문제는 이미 수정되었습니다.
  5. 문서와 일치하는지 점검 — 표준 라이브러리라고 해서 문서가 항상 실제 동작과 같다고 단정하지 말고, 내장 함수와의 차이(지연 평가 vs. 비동기 실행)를 인지하고 테스트로 검증합니다.

요약

항목내용
문제Executor.map에서 하나의 작업이 예외를 던지면 결과 반복자가 종료되어 나머지 결과를 얻지 못함
근본 원인result_iterator가 제너레이터 함수 → 내부 예외 발생 후 재개 불가, 문서와 모순
실제 영향ProcessPoolExecutor·ThreadPoolExecutor 모두, Python 3.11 이하 모든 버전
우회 방법함수 내부 예외 처리 / submit + result() 개별 제어 / 리스트 수집
공식 수정Python 3.16에서 내장 map과 일치하도록 변경 (CPython #108518)

출처