라떼군 이야기


파이썬 타입 힌트: dict[int, int]와 dict[int, int | str]이 호환되지 않는 이유와 해결법

Problem

파이썬에서 타입 힌트를 사용할 때, 더 좁은 타입인 dict[int, int]를 더 넓은 타입인 dict[int, int | str]에 할당하려고 하면 Pylance나 mypy 같은 타입 체커에서 에러가 발생합니다. 직관적으로는 정수(int)가 ‘정수 또는 문자열(int | str)‘에 포함되므로 문제가 없을 것 같지만, 실제로는 "dict[int, int]" is incompatible with "dict[int, int | str]"라는 에러를 뱉습니다. 이는 함수 매개변수로 딕셔너리를 전달하거나 변수를 재할당할 때 개발자들이 매우 흔하게 겪는 당혹스러운 상황입니다.

Background

이 문제가 발생하는 근본적인 원인은 파이썬의 딕셔너리가 ‘가변(Mutable)’ 객체이며, 변수 할당이 ‘참조(Reference)‘로 이루어진다는 점에 있습니다. 타입 이론에서 dict는 키와 값 모두에 대해 **무공변성(Invariant)**을 가집니다. 즉, 상속 관계나 타입의 포함 관계와 상관없이 정확히 명시된 타입만 허용합니다. 만약 타입 시스템이 이를 허용한다면, 참조된 딕셔너리를 통해 원래 허용되지 않는 타입의 값을 몰래 집어넣을 수 있게 되어 타입 안정성이 완전히 무너지게 됩니다.

Solution

이 문제를 해결하고 타입 시스템이 왜 이렇게 설계되었는지 이해하기 위해, 문제 상황의 재현부터 올바른 해결책까지 단계별로 살펴보겠습니다.

1. 왜 에러를 발생시켜야만 하는가? (문제의 원인)

만약 타입 체커가 dict[int, int]dict[int, int | str]에 할당하는 것을 허용한다면 다음과 같은 치명적인 문제가 발생합니다.

# 타입 체커가 에러를 내지 않는다고 가정해 봅시다.
a: dict[int, int] = {0: 0}
b: dict[int, int | str] = a  # a와 b는 메모리상 같은 딕셔너리를 참조합니다.

# b는 값으로 문자열을 허용하므로 아래 코드는 합법적입니다.
b[1] = "문자열 값"

# 하지만 a는 오직 정수 값만 가져야 합니다!
# a와 b는 같은 객체이므로, a[1]을 읽으면 문자열이 나옵니다.
x: int = a[1]  
assert isinstance(x, int)  # 여기서 AssertionError가 발생하거나 런타임 에러가 터집니다.

이처럼 가변 객체의 참조 특성 때문에, dict 타입은 값의 타입이 조금이라도 다르면 호환을 거부하도록(무공변성) 설계되었습니다.

2. 해결책: 읽기 전용 인터페이스인 typing.Mapping 사용

딕셔너리를 수정할 목적이 아니라 단순히 데이터를 읽기만 할 것이라면, dict 대신 typing.Mapping을 사용해야 합니다. Mapping은 읽기 전용(Read-only) 인터페이스이므로 값을 수정할 수 없고, 따라서 값(Value) 타입에 대해 **공변성(Covariant)**을 지원합니다.

import typing

a: dict[int, int] = {1: 100}

# Mapping은 값을 수정할 수 없으므로, 더 넓은 타입으로의 할당이 안전합니다.
# 타입 체커가 에러를 발생시키지 않습니다.
c: typing.Mapping[int, int | str] = a

# c[2] = "test"  # Mapping 타입이므로 수정하려고 하면 타입 에러가 발생하여 안전합니다.

함수의 매개변수를 정의할 때도 동일하게 적용할 수 있습니다.

from typing import Mapping

# 딕셔너리를 읽기만 하는 함수라면 Mapping을 사용하세요.
def print_values(data: Mapping[int, int | str]) -> None:
    for key, value in data.items():
        print(f"{key}: {value}")

my_dict: dict[int, int] = {1: 10, 2: 20}
print_values(my_dict)  # 정상적으로 동작하며 타입 에러도 없습니다.

3. 주의: 키(Key) 타입은 여전히 무공변성

Mapping을 사용하더라도 키(Key) 타입에 대해서는 여전히 무공변성(Invariant)을 가집니다. 따라서 키 타입을 확장하려고 하면 에러가 발생합니다.

import typing

a: dict[int, int] = {1: 100}

# 에러 발생! Mapping의 키 타입은 공변성을 지원하지 않습니다.
d: typing.Mapping[int | str, int] = a 

키 타입이 공변성을 가지지 않는 이유는, 만약 허용될 경우 d["문자열키"] 와 같이 원래 딕셔너리 a에 존재할 수 없는 타입의 키로 조회를 시도하는 것을 타입 시스템이 막을 수 없기 때문입니다.

Deep Dive

실무에서 파이썬 딕셔너리의 타입을 지정할 때는 해당 딕셔너리의 용도에 따라 적절한 추상 기저 클래스(ABC)를 사용하는 것이 베스트 프랙티스입니다. 단순히 데이터를 읽기만 하는(Read-only) 용도라면 typing.Mapping을 사용하여 유연성을 극대화하세요. 반면, 함수 내부에서 딕셔너리를 수정해야 한다면 typing.MutableMapping이나 dict를 사용하되, 전달받는 인자의 타입이 정확히 일치하도록 강제해야 합니다. 이러한 타입 이론(공변성, 반공변성, 무공변성)을 이해하면 mypy나 Pylance가 뱉는 난해한 에러 메시지의 의도를 정확히 파악하고 더 견고한 코드를 작성할 수 있습니다.

Conclusion

파이썬의 dict 타입이 더 넓은 타입(int | str)과 호환되지 않는 것은 가변 객체의 참조로 인한 런타임 타입 오류를 방지하기 위한 의도적이고 안전한 설계입니다. 딕셔너리를 다룰 때 읽기 전용이라면 Mapping을 사용하여 타입의 유연성을 확보하고, 수정이 필요하다면 정확한 타입을 명시하여 의도치 않은 사이드 이펙트를 예방하세요.

References

협업 및 후원 연락하기 →