Python에 대해 놀랐던 점들을 모아둔 글입니다. `bool`이 `int`의 하위 타입이라는 사실부터 싱글턴, 인터닝, 순환 가비지 컬렉션, GIL, `__slots__`, 기본 매개변수의 가변 객체, 메타클래스까지 다룹니다.
Python에 대해 나를 놀라게 했던 것들을 모아봤다. 아마 이미 알고 있는 것도 있겠지만, 몇몇은 당신도 놀라게 하길 바란다.
Python은 최근 몇 년 사이 폭발적으로 인기를 얻었다. 심지어 Poznań University of Technology에서도 Python을 가르치기 위해 Delphi 교육을 (거의) 중단했다. 이로 인해 Python이 꽤 새로운 언어라는 흔한 오해가 생긴다. 실제로 Python은 꽤 오래됐고, 1991년 2월 20일에 처음 등장했다. 참고로 소련은 1991년 12월 26일에 해체됐다. 즉 Python은 러시아 연방보다 거의 꼬박 1년 더 오래됐다.
bool은 말 그대로 int다Python에서는 모든 것이 객체다. 여기에는 str, int, float, bool 같은 단순한 자료형도 포함된다. 다시 말해 원시 타입은 없다. 이 특징은 Python만의 것은 아니지만, Java, C++ 또는 JavaScript 같은 언어에서 온 사람이라면 직관적이지 않을 수 있다. 그런 언어에서는 “객체”가 “단순한 자료형”을 묶어서 “복합 자료형”을 만드는 방식으로 여겨지고, “단순한 자료형”의 인스턴스는 어떤 “객체”와도 연결되지 않은 채 존재할 수 있기 때문이다.
이 설계 결정은 몇 가지 흥미로운 결과를 낳는다.
IDE에서 bool에 대해 go to definition을 했을 때 builtins.pyi에서 볼 수 있는 정의는 다음과 같다.
@final
class bool(int):
def __new__(cls, o: object = False, /) -> Self: ...
# The following overloads could be represented more elegantly with a TypeVar("_B", bool, int),
# however mypy has a bug regarding TypeVar constraints (https://github.com/python/mypy/issues/11880).
@overload
def __and__(self, value: bool, /) -> bool: ...
@overload
def __and__(self, value: int, /) -> int: ...
@overload
def __or__(self, value: bool, /) -> bool: ...
@overload
def __or__(self, value: int, /) -> int: ...
@overload
def __xor__(self, value: bool, /) -> bool: ...
@overload
def __xor__(self, value: int, /) -> int: ...
@overload
def __rand__(self, value: bool, /) -> bool: ...
@overload
def __rand__(self, value: int, /) -> int: ...
@overload
def __ror__(self, value: bool, /) -> bool: ...
@overload
def __ror__(self, value: int, /) -> int: ...
@overload
def __rxor__(self, value: bool, /) -> bool: ...
@overload
def __rxor__(self, value: int, /) -> int: ...
def __getnewargs__(self) -> tuple[int]: ...
@deprecated("Will throw an error in Python 3.16. Use `not` for logical negation of bools instead.")
def __invert__(self) -> int: ...
즉 Python에서 bool은 int의 하위 타입이다. 예상할 수 있듯이 True는 1, False는 0처럼 다룰 수 있다.
>>> True + True
2
Java는 Python처럼 객체지향 언어임에도 이것을 허용하지 않는다.
jshell> true + true
| Error:
| bad operand types for binary operator '+'
| first type: boolean
| second type: boolean
| true + true
| ^---------^
JavaScript는 이것을 허용하지만 이유는 다르다. 즉 암시적 형 변환 때문이다. 개인적으로는 이 경우 Python의 다형성이 더 우아하다고 느낀다.
> true + true
2
C++도 이를 허용하는데, 이는 암시적 정수 승격 덕분이다. JavaScript의 형 변환과 비슷하지만 더 공격적이지 않고 정수 계열 타입에만 적용된다.
하지만 Python에서 더 독특한 점은, 이런 설계 덕분에 다음과 같이 자신만의 숫자 클래스를 만들 수 있다는 것이다. 물론 아마 그러지 않는 편이 좋겠지만.
class modulo10(int):
def __add__(self, other):
return super().__add__(other) % 10
x: int = modulo10(5) # no errors, types match
assert x + 6 == 1 # passes
None은 싱글턴이고, int는 인터닝된다이건 사실 이상하다기보다는 흥미로운 자잘한 사실이다.
GoF의 Design Patterns를 읽어봤다면, 널리 알려진 singleton과 flyweight 패턴을 이미 잘 알고 있을 것이다. 아직 읽지 않았다면 꼭 읽어보길 강력히 추천한다.
Python에 대한 흥미로운 관찰 하나는, 디자인 패턴이 단지 당신이 작성하는 코드를 넘어 언어 자체에서도 관찰된다는 점이다.
Python의 None, True, False 객체는 immortal이다. 즉 GC가 이들을 관리하지 않는다. 각 객체의 인스턴스는 인터프리터 시작 시 정확히 하나만 생성되고, 인터프리터가 종료될 때까지 “살아 있다”. 이는 id(None), id(True), id(False)를 반복해서 실행했을 때 매번 같은 객체 ID가 나오는 것으로 확인할 수 있다.
>>> id(None), id(True), id(False)
(139746692948272, 139746692983840, 139746692983392)
>>> id(None), id(True), id(False)
(139746692948272, 139746692983840, 139746692983392)
>>> id(None), id(True), id(False)
(139746692948272, 139746692983840, 139746692983392)
Python에는 이런 객체의 예가 더 있지만, 이 셋이 가장 대표적이다.
더 흥미로운 것은 int다. 이 글을 쓰는 시점의 현재 CPython 구현은 -5부터 256까지의 정수를 미리 할당해 둔다.
>>> id(256), id(257)
(140071046923016, 140071025485360)
>>> id(256), id(257)
(140071046923016, 140071025482000)
>>> id(256), id(257)
(140071046923016, 140071025482032)
이것은 본질적으로 소위 인터닝 패턴이다. 이는 flyweight 패턴의 단순화된 버전으로, flyweight 객체가 외부 상태를 처리하지 않는다. 클래스에서 __new__ 메서드를 오버라이드하거나 interning factory를 만들어서 당신의 코드에서도 같은 최적화를 할 수 있다.
한번 패턴으로 생각하기 시작하면, 다시는 돌아갈 수 없다.
Python에서는 단 4줄의 코드만으로 메모리 누수를 만들 수 있다. ;를 쓰면 더 적게도 가능하다.
import gc
gc.disable()
a = []
a.append(a)
Python의 주된 GC 메커니즘은 참조 카운팅이지만, 참조 순환이 있는 가비지 객체를 찾기 위해 그래프 순회도 수행한다. 위 예시에서 a는 자기 자신에 대한 참조를 들고 있으므로 결코 가비지 컬렉션되지 않는다.
그래도 실수로 이렇게 만들기는 어렵다.
Python에서 멀티스레드 프로그램을 작성할 수는 있지만, 한 번에 실행될 수 있는 스레드는 오직 하나뿐이다. CPU에 코어가 몇 개 있든 상관없이, 가장 잘해봐야 한 시점에 하나만 사용하게 된다. IO 바운드 작업을 스레드로 나누는 것은 여전히 의미가 있지만, CPU 바운드 작업에서는 스레드가 사실상 무의미하거나 오히려 역효과를 낸다. 이 제한은 소위 Global Interpreter Lock, 즉 GIL에 의해 강제된다.
처음 보면 왜 GIL이 필요한지 분명하지 않다. Java는 Python과 비슷하게 멀티스레딩을 지원하고, 바이트코드로 컴파일되며, 가비지 컬렉터도 있지만 GIL은 없다. Java는 코어 수만큼의 스레드를 동시에 실행할 수 있다.
GIL이 존재하는 이유와 그 미래는, 특히 실험적인 free-threaded mode를 도입한 Python 3.13 이후로, 매우 흥미로운 주제이며 그 자체로 별도의 블로그 글이 필요하다. 그래서 여기서는 더 자세히 설명하지 않겠다.
반면 JavaScript는 설계상 스레드를 아예 지원하지 않고, process 기반 병렬성에 더 가까운 worker만 지원한다.
__slots__언급할 만한 것.
__slots__는 Python의 꽤 잘 알려진 기능이며 문서에 자세히 설명되어 있다.
요약: 동적으로 속성을 설정할 수 있는 가능성을 포기하는 대신 더 작은 메모리 사용량을 얻는다.
나는 이걸 쓰는 걸 좋아하지 않는데, 성능에 대해 과하게 의식하게 만들기 때문이다.
언급할 만한 것.
아마 이것도 이미 익숙할 것이다. 그래서 길게 설명하지는 않겠다. 만약 익숙하지 않다면 Fredrik Lundh의 이 글이 훌륭한 설명을 제공한다.
이것 때문에 몇 번 헷갈린 적이 있지만, Python에서는 모든 것이 객체라는 점을 내가 완전히 체화하고 나서는, 이게 전혀 바보 같은 게 아니라는 입장이다. 오히려 논리적이고 언어 전체의 설계와도 일관된다.
언급할 만한 것.
메타클래스 역시 Python에서 모든 것이 객체라는 사실의 직접적인 결과다. 여기서 내가 직접 설명하지는 않겠다. 이유는 a) 꽤 혼란스러워진다. b) 이 StackOverflow 글 (RIP [*])보다 더 잘 설명할 자신이 없기 때문이다.
게시일: 2026-05-11