라떼군 이야기


Python 3.12에서 'Öl'이 'Ö'보다 메모리를 적게 차지하는 이유

Problem

Python 개발 중 메모리 최적화를 위해 sys.getsizeof()를 사용할 때 직관에 어긋나는 결과를 마주할 수 있습니다. 일반적으로 문자열의 길이가 길어지면 메모리 사용량도 늘어난다고 생각하기 쉽습니다. 하지만 Python 3.12 환경에서 단일 비 ASCII 문자(예: 'Ö')와 두 글자 문자열(예: 'Öl')의 크기를 비교해보면, 오히려 길이가 긴 'Öl'(59바이트)의 메모리 크기가 단일 문자 'Ö'(61바이트)보다 작게 나오는 기이한 현상이 발생합니다. 이는 단순한 버그가 아니며, Python 내부의 문자열 처리 및 메모리 할당 방식의 변화로 인해 발생하는 현상입니다.

Background

이 현상을 이해하려면 PEP 393(Flexible String Representation)을 알아야 합니다. Python 3.3부터 도입된 이 스펙에 따라, Python은 문자열에 포함된 문자의 종류(ASCII, Latin-1, UCS-2, UCS-4)에 따라 메모리 인코딩 방식을 동적으로 변경하여 메모리를 절약합니다. 따라서 비 ASCII 문자인 'Ö'가 포함되면 기본 ASCII 문자열보다 더 많은 공간을 차지하는 것은 정상적인 동작입니다.

하지만 여기에 CPython의 성능 최적화 기법이 추가로 개입합니다. Python은 자주 사용되는 작은 객체들을 매번 새로 생성하지 않고 미리 메모리에 만들어두는 ‘정적 할당(Static Allocation)‘과 ‘인터닝(Interning)’ 기법을 사용합니다. Python 3.12에서는 내부 구조가 변경되어 모든 Latin-1 단일 문자가 런타임 초기화 시 정적으로 할당되도록 업데이트되었습니다.

Solution

이 기이한 현상의 원인을 파악하기 위해 ctypes 모듈을 사용하여 Python 내부의 PyUnicodeObject 구조체를 직접 들여다볼 수 있습니다.

1. 현상 재현하기

먼저 sys.getsizeof()를 통해 실제 메모리 할당량을 확인해 봅니다.

import sys

print(sys.getsizeof('H'))   # 42 (ASCII 1글자)
print(sys.getsizeof('Ö'))   # 61 (비 ASCII 1글자)
print(sys.getsizeof('Öl'))  # 59 (비 ASCII 포함 2글자 - 더 작음!)

2. ctypes를 활용한 내부 구조 분석

CPython의 메모리 구조를 읽어와 각 문자열 객체의 상태(State) 비트 필드를 분석하는 코드입니다.

import ctypes
import sys

# CPython 3.12 기준 PyUnicodeObject 구조체 정의
class PyUnicodeObject(ctypes.Structure):
    _fields_ = [
        ("ob_refcnt", ctypes.c_ssize_t),
        ("ob_type", ctypes.c_void_p),
        ("length", ctypes.c_ssize_t),
        ("hash", ctypes.c_ssize_t),
        ("state", ctypes.c_uint64),
    ]

# 문자열의 상태를 나타내는 비트 필드 정의
class StateBitField(ctypes.LittleEndianStructure):
    _fields_ = [
        ("interned", ctypes.c_uint, 2),
        ("kind", ctypes.c_uint, 3),
        ("compact", ctypes.c_uint, 1),
        ("ascii", ctypes.c_uint, 1),
        ("statically_allocated", ctypes.c_uint, 1), # 핵심 포인트!
        ("_padding", ctypes.c_uint, 24),
    ]

    def __repr__(self):
        return ", ".join(f"{k}: {getattr(self, k)}" for k, *_ in self._fields_ if not k.startswith("_"))

def dump_s(s: str):
    # 문자열 객체의 메모리 주소를 가져와 구조체로 매핑
    o = PyUnicodeObject.from_address(id(s))
    state_int = o.state
    state = StateBitField.from_buffer(ctypes.c_uint64(state_int))
    print(f"{s!r}".ljust(8), f"{o.length=}, {sys.getsizeof(s)=}, {state}")

# 테스트 실행
dump_s('Ö')
dump_s('Öl')

실행 결과:

'Ö'      o.length=1, sys.getsizeof(s)=61, interned: 0, kind: 1, compact: 1, ascii: 0, statically_allocated: 1
'Öl'     o.length=2, sys.getsizeof(s)=59, interned: 0, kind: 1, compact: 1, ascii: 0, statically_allocated: 0

3. 원인 분석: 정적 할당과 UTF-8 캐싱

결과를 보면 결정적인 차이점인 statically_allocated 플래그를 발견할 수 있습니다. 'Ö'1(True)이고, 'Öl'0(False)입니다.

Python 3.12의 런타임 초기화 과정에서 모든 단일 Latin-1 문자(예: 'Ö')는 메모리에 **정적으로 할당(Statically Allocated)**됩니다. 최근 CPython 업데이트에서 성능 향상을 위해 이러한 정적 할당 문자열에 대해 UTF-8 표현을 미리 계산하여 함께 캐싱하도록 코드가 수정되었습니다.

즉, 'Ö' 객체는 내부적으로 Latin-1 표현(1바이트)뿐만 아니라 UTF-8 표현(2바이트)까지 메모리에 함께 품고 있습니다. 반면, 동적으로 생성된 길이가 2인 문자열 'Öl'은 정적 할당 대상이 아니므로 UTF-8 캐시 없이 Latin-1 배열만 유지합니다. sys.getsizeof()는 이 캐시된 UTF-8 데이터의 크기까지 모두 합산하여 반환하기 때문에, 결과적으로 단일 문자 'Ö''Öl'보다 메모리를 더 많이 차지하게 된 것입니다.

Deep Dive

sys.getsizeof() 함수는 단순히 C 구조체의 기본 크기만 반환하는 것이 아니라, 객체의 __sizeof__ 메서드(C 레벨의 unicode_sizeof_impl)를 호출하여 객체가 참조하는 가변 데이터(이 경우 캐시된 UTF-8 바이트 배열)까지 모두 합산한 실제 메모리 사용량을 계산합니다.

이러한 정적 할당 및 캐싱 메커니즘은 단일 문자를 반복적으로 생성하고 소멸시킬 때 발생하는 오버헤드를 줄여 전반적인 실행 속도를 높이기 위한 CPython의 의도적인 설계입니다. 메모리를 약간 더 희생하더라도 CPU 사이클을 아끼겠다는 트레이드오프(Trade-off)인 셈입니다. 따라서 프로덕션 환경에서 메모리 프로파일링을 할 때, 작은 문자열들이 예상보다 큰 메모리를 차지한다고 해서 섣불리 메모리 누수나 비효율로 판단해서는 안 됩니다.

Conclusion

Python 3.12에서 'Ö''Öl'보다 메모리를 더 차지하는 이유는 CPython이 단일 Latin-1 문자를 메모리에 정적으로 할당하고, 성능 최적화를 위해 그 문자의 UTF-8 인코딩 결과까지 객체 내부에 함께 캐싱해 두기 때문입니다. 이는 “문자열의 길이가 짧을수록 메모리를 적게 차지한다"는 직관을 깨는 흥미로운 사례로, 언어 내부의 최적화 메커니즘을 이해하는 것이 정밀한 메모리 분석에 얼마나 중요한지 보여줍니다.

References

프리랜서로 제품 기획과 개발을 맡길 파트너가 필요하신가요? 개인, 팀, 기업 누구나 의뢰할 수 있으며 문제 정의부터 출시까지 함께합니다.