Python concurrent.futures Executor.map: 하나의 작업이 예외를 던지면 나머지 결과가 사라지는 문제
1. 문제 정의
concurrent.futures.ProcessPoolExecutor.map 또는 ThreadPoolExecutor.map으로 여러 작업을 병렬 처리할 때, 전달한 함수 중 하나라도 예외를 던지면 그 예외를 결과 반복자에서 꺼낸 순간 반복자가 종료되어, 이후에 정상적으로 완료된 나머지 작업들의 결과를 더 이상 얻을 수 없습니다.
Python 공식 문서(concurrent.futures)에 따르면 Executor.map은 내장 map(func, *iterables)과 “유사"하며, 함수가 비동기로 실행되고 여러 호출이 동시에 일어난다는 점만 다릅니다. 따라서 한 요소에서 예외가 나더라도 내장 map처럼 이후 요소의 결과는 계속 얻을 수 있어야 합니다. 그러나 실제 동작은 그렇지 않습니다.
관련 코드 / 재현 예시
| |
실제 출력 (버그가 있는 3.11 이하)
| |
기대 출력
| |
시퀀스 (1, 2, 3)을 add_one에 넘기면 결과는 2, 3, 4가 되어야 합니다. 여기서 value == 2일 때 ValueError가 발생합니다. 내장 map은 그 뒤에 있는 value: 4(3 → 4)까지 정상 출력하지만, Executor.map은 value error 다음에 바로 종료되어 value: 4가 출력되지 않습니다. 즉 하나의 예외가 나머지 정상 결과를 모두 삼켜버립니다.
- 테스트 환경: Python 3.11.5 (Linux), 문제는 사실상 모든 지원 버전에서 발생.
2. 원인 탐구
왜 이런 일이 발생할까요? 가장 먼저 떠오르는 의심은 “예외의 전파 방식"입니다.
- 내장
map은 지연(lazy) 평가 — 각 요소를 순회하며 즉시 함수를 호출합니다. 한 요소에서 예외가 나도 상위 레벨에서try/except로 잡으면 다음next()호출에서 이어서 진행할 수 있습니다. Executor.map은 비동기 실행 — 내장map과 달리 모든 입력을 먼저 수집하고, 각 함수 호출을 별도의 쓰레드/프로세스에 제출한 뒤, 결과를 저장해 둡니다. 따라서 원리적으로는 예외가 나더라도 나머지 이미 완료된 결과를 꺼낼 수 있어야 합니다.
이 발상에 착안해, 원인은 “예외가 발생한 작업 자체"가 아니라 결과를 순회하는 반복자(result_iterator)의 구현 방식에 있을 것으로 의심할 수 있습니다.
3. 근본 원인 분석
근본 원인은 concurrent/futures/_base.py의 result_iterator가 제너레이터 함수(generator function)로 구현되어 있다는 점입니다 (참고: 이슈에서 가리킨 지점은 Lib/concurrent/futures/_base.py L612-L625 부근).
Python에서 제너레이터 함수 내부에서 어떤 예외가 발생하면 그 제너레이터는 그 상태에서 더 이상 재개(resume)할 수 없습니다. next()를 다시 호출하면 StopIteration이 발생하고, 남아 있던 코드는 실행되지 않습니다.
Executor.map의 result_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 — 콜러블 내부에서 예외 자체를 처리 (권장)
가장 간단하고 확실한 방법입니다. 반복자가 예외를 던지지 않도록, 작업 함수 안에서 예외를 직접 처리하고 반환 값으로 상태를 돌려줍니다.
| |
방법 B — executor.submit + Future.result()로 개별 제어
submit()으로 각 작업을 개별 Future로 제출하면, 한 Future의 예외가 다른 Future에 영향을 주지 않습니다. 예외는 필요할 때 Future.result()에서만 발생합니다.
| |
방법 C — (Option) 결과를 한 번에 수집
list(executor.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. 향후 예방 조치
Executor.map의 반복자를 예외로부터 보호 — 병렬 작업의 함수가 예외를 던질 수 있다면, 함수 내부에서 예외를 잡고 상태를 반환 값으로 표현하는 패턴(방법 A)을 기본으로 사용합니다.- 예외를 “값"으로 다루기 —
("ok", value)/("error", msg)같은 구조로 반환해 반복자 단계에서는 예외가 발생하지 않도록 설계합니다. - 개별 제어가 필요하면
submit()사용 — 각 작업의 성공/실패를 독립적으로 처리해야 한다면map대신submit()+future.result()를 사용합니다. - 버전 확인 — 대상 런타임의 Python 버전이 3.16 미만인지 확인하고, 그렇다면 위 우회 방법을 적용합니다. 3.16 이상이면 이 문제는 이미 수정되었습니다.
- 문서와 일치하는지 점검 — 표준 라이브러리라고 해서 문서가 항상 실제 동작과 같다고 단정하지 말고, 내장 함수와의 차이(지연 평가 vs. 비동기 실행)를 인지하고 테스트로 검증합니다.
요약
| 항목 | 내용 |
|---|---|
| 문제 | Executor.map에서 하나의 작업이 예외를 던지면 결과 반복자가 종료되어 나머지 결과를 얻지 못함 |
| 근본 원인 | result_iterator가 제너레이터 함수 → 내부 예외 발생 후 재개 불가, 문서와 모순 |
| 실제 영향 | ProcessPoolExecutor·ThreadPoolExecutor 모두, Python 3.11 이하 모든 버전 |
| 우회 방법 | 함수 내부 예외 처리 / submit + result() 개별 제어 / 리스트 수집 |
| 공식 수정 | Python 3.16에서 내장 map과 일치하도록 변경 (CPython #108518) |
출처