라떼군 이야기
Python 예외 처리 중 dict() 중복 키워드 인자가 TypeError 대신 KeyError를 발생시키는 원인
Problem
파이썬에서 dict() 함수를 사용할 때 동일한 키워드 인자에 여러 값을 전달하면 일반적으로 TypeError가 발생합니다. 예를 들어 dict(id=1, **{'id': 2})를 실행하면 TypeError: dict() got multiple values for keyword argument 'id'라는 에러가 출력됩니다. 하지만 이 코드를 다른 예외를 처리하는 except 블록 내부에서 실행하면, 전혀 예상치 못한 KeyError: 'id'가 발생합니다. 완전히 무관한 예외를 처리하는 과정이 dict()의 에러 타입에 영향을 미치는 이 기이한 현상은 개발자들에게 혼란을 주며, 예외 처리에 의존하는 로직에서 예상치 못한 버그를 유발할 수 있습니다.
Background
이 문제를 이해하려면 CPython 내부에서 딕셔너리의 키워드 인자를 병합하는 과정을 알아야 합니다. 파이썬은 dict(id=1, **{'id': 2})와 같은 코드를 실행할 때 바이트코드 수준에서 DICT_MERGE라는 명령(Opcode)을 사용해 두 딕셔너리를 병합합니다. CPython 내부 구현에 따르면, 병합 과정에서 중복된 키가 발견되면 먼저 내부적으로 KeyError를 발생시킵니다. 그런 다음 format_kwargs_error라는 내부 C 함수가 이 KeyError를 가로채어 우리가 흔히 보는 사용자 친화적인 TypeError로 변환해 주는 방식으로 동작합니다.
Solution
이 현상은 파이썬 3.11 이하 버전에서 발생하는 CPython의 내부 버그입니다. 따라서 근본적인 해결책은 파이썬 버전을 업그레이드하거나, 중복 키워드 인자가 발생하지 않도록 코드를 작성하는 것입니다.
1. Python 3.12 이상으로 업그레이드
가장 확실한 해결책은 예외 처리 내부 구현이 개선된 Python 3.12 버전 이상을 사용하는 것입니다. 3.12 버전부터는 이 버그가 완전히 수정되어 어떤 상황에서든 정상적으로 TypeError를 발생시킵니다.
2. 안전한 딕셔너리 병합 방식 사용
키워드 인자 언패킹(**)에 의존하기보다, 중복 키를 안전하게 덮어쓰거나 처리할 수 있는 딕셔너리 병합 연산자를 사용하는 것이 좋습니다.
# 1. 딕셔너리 업데이트 메서드 사용
base_dict = {'id': 1}
base_dict.update({'id': 2}) # 에러 없이 'id'가 2로 덮어씌워짐
print(base_dict) # 출력: {'id': 2}
# 2. Python 3.9+ 딕셔너리 병합 연산자(|) 사용
dict1 = {'id': 1}
dict2 = {'id': 2}
merged_dict = dict1 | dict2 # 오른쪽 딕셔너리의 값으로 덮어씌워짐
print(merged_dict) # 출력: {'id': 2}
3. 예외 발생 시나리오 회피
만약 레거시 시스템(Python 3.11 이하)을 유지해야 한다면, except 블록 내부에서 동적으로 키워드 인자를 언패킹하여 dict()를 생성하는 패턴을 피해야 합니다. 대신 병합된 딕셔너리를 먼저 만든 후 함수에 전달하는 방식을 권장합니다.
try:
raise ValueError('에러 발생!')
except ValueError:
# 버그를 유발할 수 있는 안 좋은 패턴
# result = dict(id=1, **{'id': 2})
# 안전한 패턴: 딕셔너리를 명시적으로 병합 후 사용
kwargs = {'id': 1}
kwargs.update({'id': 2})
result = dict(**kwargs)
Deep Dive
이 버그의 근본 원인은 CPython 3.11 이하 버전의 ‘정규화되지 않은 예외(unnormalized exception)’ 처리 로직에 있습니다. C 코드 내부에서 예외가 발생하면 처음에는 예외 객체가 아닌 단순히 키 값만 포함된 튜플 형태(정규화되지 않은 상태)로 존재합니다. format_kwargs_error 함수는 바로 이 튜플 형태를 기대하고 KeyError를 TypeError로 변환합니다.
하지만 except 블록 내부에서 예외가 발생하면 파이썬의 ‘예외 체이닝(Exception Chaining)’ 기능이 작동합니다. 예외 체이닝을 위해 CPython은 내부적으로 발생한 예외를 강제로 완전한 예외 객체로 ‘정규화(normalize)’ 시켜버립니다. 결과적으로 format_kwargs_error 함수는 자신이 기대했던 튜플 형태가 아닌 정규화된 예외 객체를 마주하게 되고, 이를 인식하지 못해 변환 작업을 포기한 채 원래의 KeyError를 그대로 통과시켜 버리는 것입니다. Python 3.12에서는 내부 예외 표현 방식이 변경되어 모든 예외가 항상 정규화되도록 통일되었고, C 코드 역시 정규화된 예외를 확인하도록 수정되어 이 버그가 해결되었습니다.
Conclusion
dict(id=1, **{'id': 2})가 예외 처리 블록 안에서 TypeError 대신 KeyError를 발생시키는 현상은 예외 체이닝 과정에서 발생하는 CPython 3.11 이하 버전의 내부 버그 때문입니다. 이 문제를 근본적으로 피하려면 Python 3.12 이상으로 업그레이드하는 것이 좋으며, 하위 버전에서는 딕셔너리 병합 연산자(|)나 .update() 메서드를 활용하여 중복 키워드 인자 전달 상황 자체를 방지하는 것이 바람직합니다.