슬롯을 사용하면 Pydantic 및 ORM 성능이 실제로 향상될까요? 벤치마크 연구
Grace Collins
Solutions Engineer · Leapcell

소개
Python의 세계에서 메모리 사용량과 실행 속도를 최적화하는 것은 끊임없는 추구이며, 특히 데이터 집약적인 애플리케이션을 다룰 때 더욱 그렇습니다. 이 분야에서 중요한 두 가지 라이브러리는 데이터 유효성 검사 및 구문 분석에 널리 사용되는 Pydantic과 데이터베이스 상호 작용을 추상화하는 객체 관계형 매퍼(ORM)입니다. 개발자들은 성능을 최대한 끌어내기 위한 방법을 자주 찾으며, __slots__는 자주 언급되는 최적화 기법입니다.
하지만 Pydantic 모델과 ORM 객체에 __slots__를 적용하는 것이 실제로 약속된 메모리 및 성능 이점을 제공할까요? 이 질문은 간단해 보이지만 Python의 내부 메커니즘을 이해해야 하며 단순한 가정보다는 경험적 검증이 필요합니다. 이 글에서는 세부 사항을 파헤치고, 벤치마크를 수행하고, 명확한 답변을 제공할 것입니다.
슬롯, Pydantic 및 ORM 살펴보기
벤치마크를 시작하기 전에 관련 핵심 개념을 명확히 합시다.
__slots__란 무엇인가요?
Python에서 클래스의 인스턴스는 일반적으로 __dict__라는 사전에 속성을 저장합니다. 이 사전은 런타임에 속성을 동적으로 추가하거나 제거할 수 있어 엄청난 유연성을 제공합니다. 그러나 이 유연성에는 비용이 따릅니다. 각 인스턴스는 이 사전의 오버헤드를 가지므로 더 많은 메모리를 소비합니다.
클래스에 정의된 __slots__ 속성은 해당 클래스의 객체에 대한 인스턴스 __dict__를 생성하지 않도록 Python에 지시합니다. 대신 미리 정의된 일련의 속성을 위한 고정된 공간을 미리 할당합니다. 동적 속성 할당을 잃는 대신 메모리 사용량을 줄이고 잠재적으로 더 빠른 속성 액세스를 얻는 이러한 절충이 __slots__의 핵심 약속입니다.
# __slots__가 없는 클래스의 예 class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(1, 2) # print(p.__dict__) # {'x': 1, 'y': 2} # print(f"Memory size without slots: {sys.getsizeof(p)}") # __slots__가 있는 클래스의 예 class SlottedPoint: __slots__ = ('x', 'y') def __init__(self, x, y): self.x = x self.y = y sp = SlottedPoint(1, 2) # print(hasattr(sp, '__dict__')) # False # print(f"Memory size with slots: {sys.getsizeof(sp)}")
Pydantic 모델
Pydantic은 Python 유형 힌트를 사용하여 데이터 스키마를 정의하는 데이터 유효성 검사 및 구문 분석 라이브러리입니다. 강력한 유효성 검사, 직렬화 및 역직렬화 기능으로 유명합니다. Pydantic 모델은 기본적으로 데이터를 내부 __dict__에 저장하는 일반 Python 클래스입니다. 이를 통해 다른 Python 기능과 원활하게 통합되고 유연한 데이터 구조를 제공할 수 있습니다.
from pydantic import BaseModel class User(BaseModel): id: int name: str email: str user = User(id=1, name="Alice", email="alice@example.com") # print(user.__dict__)
ORM 객체
객체 관계형 매퍼(ORM)는 데이터베이스와 상호 작용하는 객체 지향적인 방법을 제공합니다. SQLAlchemy, Django ORM, Peewee와 같은 라이브러리는 데이터베이스 테이블을 Python 클래스로, 행을 Python 객체로 매핑합니다. 이러한 ORM 객체는 종종 상당한 양의 메타데이터를 포함하며, 속성, 관계 및 데이터베이스 세션 정보를 포함하며, 일반적으로 인스턴스 __dict__ 또는 유사한 내부 구조에 저장됩니다.
가설: __slots__가 어떻게 도움이 될 수 있는가
이론은 Pydantic 모델 또는 ORM 객체에 __slots__를 적용하여 다음을 수행할 수 있다는 것입니다.
- 메모리 소비 감소: 각 인스턴스는
__dict__오버헤드가 제거되므로 더 적은 메모리가 필요합니다. 이는 백만 개의 객체를 다룰 때 특히 관련이 있습니다. - 속성 액세스 속도 향상: 고정된 슬롯에서 직접 속성에 액세스하는 것이 사전 조회보다 빠를 수 있습니다.
벤치마킹 설정
가설을 테스트하기 위해 다음을 벤치마킹할 것입니다.
- 메모리 사용량: 많은 수의 인스턴스가 소비하는 메모리 양입니다.
- 객체 인스턴스화 시간: 많은 수의 객체를 만드는 데 걸리는 시간입니다.
- 속성 액세스 시간: 객체에서 속성을 읽는 데 걸리는 시간입니다.
다음과 같은 항목을 비교할 것입니다.
- 표준 Pydantic 모델 vs.
__slots__가 있는 Pydantic 모델 - 표준 ORM 유사 객체 vs.
__slots__가 있는 ORM 유사 객체 (__slots__자체와 직접 관련이 없는 변수를 추가하므로 전체 ORM 설정은 시뮬레이션합니다.)
객체 크기의 대략적인 추정에는 sys.getsizeof 함수를 사용하고 성능 측정에는 timeit 모듈을 사용합니다.
import sys import timeit from pydantic import BaseModel, ConfigDict from memory_profiler import profile # 1. 슬롯이 없는 Pydantic 모델 class UserNoSlots(BaseModel): id: int name: str email: str # 2. 슬롯이 있는 Pydantic 모델 class UserWithSlots(BaseModel): model_config = ConfigDict(slots=True) # Pydantic v2 방식 id: int name: str email: str # Pydantic v1의 경우 __slots__를 직접 정의해야 합니다. # __slots__ = ('id', 'name', 'email', '__pydantic_fields_set__', '__pydantic_extra__', ..., etc.) # 참고: Pydantic v1은 슬롯의 내부 속성을 더 주의 깊게 관리해야 했습니다. # Pydantic v2는 ConfigDict를 통해 __slots__를 더 능숙하게 처리합니다. # 3. 슬롯이 없는 간단한 ORM 유사 객체 class ProductNoSlots: def __init__(self, item_id: int, name: str, price: float): self.item_id = item_id self.name = name self.price = price # 4. 슬롯이 있는 간단한 ORM 유사 객체 class ProductWithSlots: __slots__ = ('item_id', 'name', 'price') def __init__(self, item_id: int, name: str, price: float): self.item_id = item_id self.name = name self.price = price # 메모리 비교를 위한 헬퍼 (근사치) def get_total_memory_usage(objects): return sum(sys.getsizeof(obj) for obj in objects) NUM_OBJECTS = 100000 print("---"-" Pydantic 벤치마크 ---") # 인스턴스화 및 메모리 - Pydantic print("\n[Pydantic 인스턴스화 및 메모리]") setup_pydantic_noslots = f""" from __main__ import UserNoSlots objects = [UserNoSlots(id=i, name=f"User {{i}}", email=f"user{{i}}@example.com") for i in range({NUM_OBJECTS})] """ time_noslots = timeit.timeit(setup_pydantic_noslots, number=1) print(f"UserNoSlots 인스턴스화 ({NUM_OBJECTS} 객체): {time_noslots:.4f} 초") setup_pydantic_withslots = f""" from __main__ import UserWithSlots objects = [UserWithSlots(id=i, name=f"User {{i}}", email=f"user{{i}}@example.com") for i in range({NUM_OBJECTS})] """ time_withslots = timeit.timeit(setup_pydantic_withslots, number=1) print(f"UserWithSlots 인스턴스화 ({NUM_OBJECTS} 객체): {time_withslots:.4f} 초") # 메모리 (측정하려면 timeit 외부에서 객체를 생성해야 합니다) user_noslots_list = [UserNoSlots(id=i, name=f"User {i}", email=f"user{i}@example.com") for i in range(NUM_OBJECTS)] user_withslots_list = [UserWithSlots(id=i, name=f"User {i}", email=f"user{i}@example.com") for i in range(NUM_OBJECTS)] print(f"UserNoSlots 총 메모리 ({NUM_OBJECTS} 객체): {get_total_memory_usage(user_noslots_list) / (1024*1024):.2f} MB") print(f"UserWithSlots 총 메모리 ({NUM_OBJECTS} 객체): {get_total_memory_usage(user_withslots_list) / (1024*1024):.2f} MB") # 속성 액세스 - Pydantic print("\n[Pydantic 속성 액세스]") access_pydantic_noslots = f""" for user in user_noslots_list: _ = user.id _ = user.name _ = user.email """ time_access_noslots = timeit.timeit(access_pydantic_noslots, globals=globals(), number=10) print(f"UserNoSlots 속성 액세스 ({NUM_OBJECTS*3*10} 액세스): {time_access_noslots:.4f} 초") access_pydantic_withslots = f""" for user in user_withslots_list: _ = user.id _ = user.name _ = user.email """ time_access_withslots = timeit.timeit(access_pydantic_withslots, globals=globals(), number=10) print(f"UserWithSlots 속성 액세스 ({NUM_OBJECTS*3*10} 액세스): {time_access_withslots:.4f} 초") print("\n---"-" ORM 유사 객체 벤치마크 ---") # 인스턴스화 및 메모리 - ORM 유사 print("\n[ORM 유사 인스턴스화 및 메모리]") setup_orm_noslots = f""" from __main__ import ProductNoSlots objects = [ProductNoSlots(item_id=i, name=f"Product {{i}}", price=float(i)/100) for i in range({NUM_OBJECTS})] """ time_orm_noslots = timeit.timeit(setup_orm_noslots, number=1) print(f"ProductNoSlots 인스턴스화 ({NUM_OBJECTS} 객체): {time_orm_noslots:.4f} 초") setup_orm_withslots = f""" from __main__ import ProductWithSlots objects = [ProductWithSlots(item_id=i, name=f"Product {{i}}", price=float(i)/100) for i in range({NUM_OBJECTS})] """ time_orm_withslots = timeit.timeit(setup_orm_withslots, number=1) print(f"ProductWithSlots 인스턴스화 ({NUM_OBJECTS} 객체): {time_orm_withslots:.4f} 초") # 메모리 (측정하려면 timeit 외부에서 객체를 생성해야 합니다) product_noslots_list = [ProductNoSlots(item_id=i, name=f"Product {i}", price=float(i)/100) for i in range(NUM_OBJECTS)] product_withslots_list = [ProductWithSlots(item_id=i, name=f"Product {i}", price=float(i)/100) for i in range(NUM_OBJECTS)] print(f"ProductNoSlots 총 메모리 ({NUM_OBJECTS} 객체): {get_total_memory_usage(product_noslots_list) / (1024*1024):.2f} MB") print(f"ProductWithSlots 총 메모리 ({NUM_OBJECTS} 객체): {get_total_memory_usage(product_withslots_list) / (1024*1024):.2f} MB") # 속성 액세스 - ORM 유사 print("\n[ORM 유사 속성 액세스]") access_orm_noslots = f""" for product in product_noslots_list: _ = product.item_id _ = product.name _ = product.price """ time_access_orm_noslots = timeit.timeit(access_orm_noslots, globals=globals(), number=10) print(f"ProductNoSlots 속성 액세스 ({NUM_OBJECTS*3*10} 액세스): {time_access_orm_noslots:.4f} 초") access_orm_withslots = f""" for product in product_withslots_list: _ = product.item_id _ = product.name _ = product.price """ time_access_orm_withslots = timeit.timeit(access_orm_withslots, globals=globals(), number=10) print(f"ProductWithSlots 속성 액세스 ({NUM_OBJECTS*3*10} 액세스): {time_access_orm_withslots:.4f} 초")
벤치마크 결과 분석
(참고: 정확한 수치는 하드웨어 및 Python 버전에 따라 달라질 수 있지만, 추세는 일관될 것입니다.)
Pydantic 모델:
- 메모리 사용량: Pydantic 모델이
slots=True로 구성되면 (특히 v2부터는 내부적으로__slots__정의를 처리하여 자체적으로 필요한 내부 속성과 선언된 필드를 포함하므로) 메모리 사용량이 눈에 띄게 줄어들 수 있습니다. 예를 들어__pydantic_fields_set__,__pydantic_extra__등입니다. 간단한 Pydantic 모델의 경우slots를 활성화하면 객체당 오버헤드가 줄어듭니다. - 인스턴스화 시간:
__slots__를 사용한 인스턴스화는 때때로 약간 느리거나 무시할 수 있을 정도로 빠를 수 있습니다. 클래스 생성 중__slots__설정 오버헤드와 동적 사전이 아닌 고정된 슬롯에 값을 할당하는 프로세스가 약간의 차이를 유발할 수 있습니다. Pydantic의 내부 유효성 검사 및 구문 분석 로직도 인스턴스화 시간에 지배적입니다. - 속성 액세스 시간: 슬롯이 있는 Pydantic 모델의 속성 액세스는 종종 무시할 수 있을 정도로 빠르거나 거의 동일합니다. 다시 말하지만, Pydantic의 내부 메커니즘이 여기서 직접적인
__slots__이점의 일부를 추상화할 수 있습니다.
Pydantic의 핵심 결과는 __slots__가 효과적이려면 Pydantic의 내부 속성과 함께 신중하게 구현되어야 한다는 것입니다. 다행히 Pydantic v2의 model_config = ConfigDict(slots=True)는 복잡한 부분을 처리하여 훨씬 더 실용적이고 유익합니다.
ORM 유사 객체:
- 메모리 사용량: 간단한 일반 Python 객체(당사의 ORM 유사 객체가 시뮬레이션하는)의 경우
__slots__는 상당한 메모리 절약을 제공합니다. 각 인스턴스에 대한__dict__오버헤드가 완전히 제거되어 특히NUM_OBJECTS가 클 때 메모리 사용량이 직접적으로 줄어듭니다. - 인스턴스화 시간:
__slots__를 사용한 객체 생성은 인터프리터가 각 객체에 대한 인스턴스__dict__를 생성하고 초기화할 필요가 없기 때문에 종종 약간 더 빠릅니다. - 속성 액세스 시간: 슬롯이 있는 객체에서 속성을 액세스하는 것은 일반적으로 더 빠릅니다. 사전 조회 대신 Python은 고정 크기 배열과 유사한 구조에서 직접 조회를 수행합니다.
중요 고려 사항:
- 불변성:
__slots__를 사용할 때 일반적으로 생성 후 인스턴스에 새 속성을 동적으로 추가할 수 없습니다. 이것은 ORM이 때때로 프록시 속성 또는 지연 로드 관계를 추가하여 모든 사용 사례에 적합하지 않을 수 있는 핵심적인 절충점입니다. - 상속:
__slots__는 상속과 복잡성을 야기할 수 있습니다. 슬롯이 있는 클래스의 하위 클래스는 자체__dict__를 가질 수 있습니다. 하위 클래스가__slots__를 정의하고 자체__slots__에__dict__항목을 포함하거나 부모 클래스가__dict__항목으로__slots__를 정의하지 않는 한 그렇습니다. - Pydantic의 내부 작업: Pydantic 모델은 일반 Python 객체보다 복잡합니다. 내부 상태(예:
__pydantic_fields_set__, 유효성 검사기, 계산된 속성)가 있습니다.__slots__가 효과적으로 작동하려면 Pydantic도 이러한 내부 속성을slot해야 합니다. 언급했듯이 Pydantic v2는ConfigDict(slots=True)옵션으로 이를 수용하여 Pydantic v1에서__slots__를 수동으로 정의하는 것보다 훨씬 더 실용적이고 유익합니다. - ORM 복잡성: 실제 ORM 객체(SQLAlchemy 모델 등)는 매우 동적이며 서술자 프로토콜, 프록시 객체 및 지연 로드를 사용하여 복잡한 방식으로 상태를 관리합니다. ORM 클래스에 직접
__slots__를 적용하면 내부 메커니즘이 중단되거나 예기치 않은 동작을 유발할 수 있습니다. ORM 설계자는 이러한 이유로__slots__를 구성 가능한 옵션으로 거의 노출하지 않습니다.__slots__의 이점은 ORM의 동적 속성 관리 요구 사항으로 인해 완전히 무효화되거나 심지어 해로울 수도 있습니다.
결론
일반 Python 객체 또는 사용자 정의 데이터 구조의 경우 __slots__를 사용하면 상당한 메모리 절약과 속성 액세스 및 인스턴스화에서 적당한 성능 향상을 얻을 수 있어 간단하고 불변적인 객체의 대규모 컬렉션에 대한 유용한 최적화 기법이 됩니다. Pydantic 모델, 특히 Pydantic v2의 ConfigDict(slots=True)를 사용하면 상당한 성능 저하 없이 실제 메모리 최적화를 제공하여 메모리 제약이 있는 애플리케이션에 대한 실행 가능한 옵션이 됩니다. 그러나 ORM 객체에 직접 __slots__를 적용하는 것은 일반적으로 ORM의 복잡한 내부 상태 관리 및 동적 특성으로 인해 권장되지 않으며, 이러한 경우 이점은 중단 가능성과 장점을 능가할 가능성이 낮습니다.

