← 학습 카테고리

Learn

Python

5개 모듈 · 현재 2번째

Python 모듈 2/5 python-learn-02

성능 최적화와 동시성 — 제너레이터, GIL, 컨텍스트 매니저

50 Python Interview Questions for Data Engineers (2026) — DataCamp (blog) Section 2: Advanced Performance & Optimization (pp.4-7); FAQ 보충 (pp.16-17)

이 모듈을 다 읽으면

  • 제너레이터의 지연 평가가 대용량 로그/스트림 처리에서 OOM을 방지하는 원리를 설명할 수 있다
  • GIL이 CPU-bound와 I/O-bound 작업에 다르게 영향을 미치는 이유를 설명하고, 상황에 맞는 동시성 모델(threading/multiprocessing/asyncio)을 선택할 수 있다
  • Python 3.13+ free-threaded 빌드의 현재 상태와 실무 도입 시 고려사항을 판단할 수 있다
  • with문/컨텍스트 매니저를 자원 관리(DB 트랜잭션, 임시 자격 증명)에 적용할 수 있다

제너레이터, GIL과 동시성 모델, with문, 벡터화·프로파일링, 피클링과 객체지향 이디엄을 다룬다. GIL 관련 서술은 원문의 'Python 3.13+ 실험적 GIL-free 빌드' 언급을 2026년 현재 시점(3.14 릴리스 이후) 상태로 갱신하고, free-threaded 빌드의 성능 트레이드오프를 보충한다.

제너레이터와 지연 평가

제너레이터는 yield 키워드를 사용해 값을 한 번에 하나씩, 각 값 사이에 실행을 일시 중단하며 반환하는 함수다. 리스트와 달리 전체 시퀀스를 메모리에 저장하지 않는 이 '지연 평가(lazy evaluation)'는 수 기가바이트짜리 로그나 스트림을 다루는 데이터 엔지니어에게 핵심적이다 — 레코드를 순차적으로 처리하면서도 Out-Of-Memory(OOM) 오류로 시스템이 죽는 것을 막아준다.

제너레이터를 순회하면 내부적으로 __next__()가 한 번씩 호출되고, 함수가 끝나면 StopIteration이 발생해 순회가 종료된다.

— 정정/보완: FAQ 원문은 제너레이터가 리스트보다 '10~100배 메모리 효율적'이라고 언급하는데, 이는 상황마다 다른 경험적 수치일 뿐 정확한 배수가 아니다. 핵심은 배수가 아니라 구조적 차이다 — 제너레이터는 항상 '현재 처리 중인 원소 1개' 크기만큼만 메모리를 쓰는 반면, 리스트는 원소 개수에 비례해 메모리가 선형으로 증가한다는 점이 본질이다. 또한 원문에 없는 실무 함정 하나: 제너레이터는 한 번 소진되면 다시 순회할 수 없다(리스트와 달리 재사용 불가). 파이프라인에서 같은 제너레이터를 두 번 쓰면(예: 검증 단계와 적재 단계에서 각각 순회) 두 번째 순회는 조용히 빈 결과를 낸다 — 이런 경우 itertools.tee()를 쓰거나 처음부터 리스트로 구체화(materialize)해야 한다.

핵심 포인트

  • 제너레이터는 yield로 값을 한 번에 하나씩 지연 평가하며, 전체 시퀀스를 메모리에 두지 않는다.
  • 메모리 절감 효과는 고정 배수가 아니라 '현재 원소 1개 분량만 사용'하는 구조적 특성에서 나온다 — 정정.
  • 제너레이터는 한 번 소진되면 재사용할 수 없어, 파이프라인에서 두 번 순회하면 두 번째는 빈 결과가 되는 함정이 있다.

GIL과 동시성 모델 — 멀티스레딩 vs 멀티프로세싱

GIL(Global Interpreter Lock)은 여러 스레드가 동시에 Python 바이트코드를 실행하지 못하도록 막는 뮤텍스로, 한 번에 하나의 스레드만 실행된다. 메모리 관리를 단순하게 해주는 대신, 표준 멀티스레딩은 CPU-bound 작업에서 멀티코어 프로세서를 온전히 활용하지 못한다. 반면 네트워크 호출이나 파일 읽기 같은 I/O 대기 구간에서는 GIL이 해제되므로, I/O-bound 작업(다중 API 호출, S3 파일 다운로드)에는 스레딩이나 asyncio가 충분히 효과적이다.

CPU-bound 작업(복잡한 데이터 정제, 암호화)에는 multiprocessing 모듈이 필요하다 — 별도의 메모리 공간과 별도의 Python 인터프리터 인스턴스를 만들어 진짜 병렬 실행을 가능하게 하기 때문이다. 대규모 데이터 변환은 아예 PySpark 같은 분산 프레임워크로 GIL 자체를 우회하기도 한다.

— 정정/보완(버전 현황): FAQ 원문은 'Python 3.13+에서 실험적 GIL-free 빌드가 도입되었다'고만 언급하지만, 2026년 9월 현재 시점 기준으로 상황을 갱신하면 다음과 같다. PEP 703에 따라 Python 3.13(2024년 10월 릴리스)부터 free-threaded 빌드(python3.13t)가 실험적(experimental)으로 배포되기 시작했고, Python 3.14(2025년 10월 릴리스)에서는 이 빌드가 '실험적' 딱지를 떼고 정식 지원 대상으로 격상되었다 — 다만 여전히 기본 빌드는 아니며 별도로 선택해 설치해야 한다. free-threaded 빌드는 단일 스레드 코드에서 특화 적응형 인터프리터(specializing adaptive interpreter) 최적화 손실과 원자적 참조 카운팅 오버헤드로 인해 아직 수 퍼센트대의 단일 스레드 성능 저하가 있을 수 있고, NumPy/Pandas 같은 C 확장 라이브러리들도 완전한 스레드 안전성을 갖추기 전까지는 free-threaded 모드에서 GIL을 다시 활성화하는 폴백이 필요한 경우가 있다. 따라서 프로덕션 데이터 파이프라인에 바로 도입하기보다는 워크로드별 벤치마크 후 신중히 채택해야 한다.

PySpark와의 연결: PySpark에서 Python UDF는 스레드가 아니라 JVM 익스큐터가 띄운 별도의 Python 워커 프로세스에서 실행되므로, 이 경우 GIL 자체는 병목이 아니다. 실제 병목은 JVM과 Python 프로세스 사이의 직렬화 비용(py4j, 혹은 Arrow 기반 통신)이며, 이 때문에 행 단위 UDF보다 Arrow 기반 벡터화 pandas UDF가 훨씬 빠른 경우가 많다.

핵심 포인트

  • GIL은 CPU-bound 멀티스레딩의 병렬성을 막지만, I/O 대기 구간에서는 해제되어 I/O-bound 작업(스레딩/asyncio)에는 영향이 적다.
  • CPU-bound 작업은 별도 프로세스·메모리 공간을 쓰는 multiprocessing으로 진짜 병렬 실행을 얻는다.
  • Python 3.13에서 실험적으로 시작된 free-threaded 빌드는 3.14에서 정식 지원으로 격상됐지만 기본값은 아니며, 단일 스레드 성능 저하와 C 확장 호환성 문제가 아직 남아있다 — 정정/갱신.
  • PySpark의 Python UDF는 별도 프로세스에서 도는 구조라 GIL보다 JVM↔Python 직렬화 비용이 실제 병목이며, 이는 벡터화 pandas UDF로 완화할 수 있다.

with문과 컨텍스트 매니저

with문은 '준비(setup)'와 '정리(cleanup)' 동작이 자동으로 수행되도록 해 자원 관리를 단순화한다. with open('data.csv') as f: 를 쓰면 블록 안에서 예외가 발생하더라도 파일이 확실히 닫힌다. 데이터 엔지니어는 데이터베이스 트랜잭션이나 임시 클라우드 스토리지 자격 증명을 관리하기 위해 __enter__/__exit__ 메서드(또는 contextlib.contextmanager 데코레이터)를 구현한 커스텀 컨텍스트 매니저를 직접 작성하는 경우가 많다.

핵심 포인트

  • with문은 예외 발생 여부와 무관하게 자원의 준비와 정리를 자동으로 보장한다.
  • 커스텀 컨텍스트 매니저는 DB 트랜잭션 커밋/롤백이나 임시 자격 증명 발급/폐기 같은 반복적인 자원 관리 로직을 캡슐화하는 데 쓰인다.

벡터화, 프로파일링, __slots__를 통한 메모리 최적화

수백만 행을 처리할 때의 최적화는 행 단위 Python 반복문에서 벡터화된 연산(NumPy나 Pandas)으로 옮겨가는 것이다 — 이들은 내부적으로 최적화된 C/Fortran 코드에 루프를 위임한다. 그 밖의 전략으로는 클래스에 __slots__를 사용해 인스턴스당 메모리 사용량(기본적으로 매 인스턴스가 갖는 __dict__ 오버헤드)을 줄이는 것, 내장 함수를 활용하는 것, cProfile 같은 도구로 코드를 프로파일링해 변환 로직의 병목 지점을 구체적으로 찾아내는 것이 있다.

보충: __slots__는 pandas/NumPy의 벡터화 연산 자체에는 관여하지 않는다 — 이는 순수 Python으로 직접 정의한 커스텀 레코드 클래스를 수백만 개 인스턴스화해야 하는, 벡터화가 어려운 상황(예: 복잡한 상태를 가진 커스텀 파서 객체)에서 인스턴스당 메모리를 아끼는 별개의 기법이다.

핵심 포인트

  • 행 단위 for 루프를 NumPy/Pandas 벡터화 연산으로 바꾸면 C 레벨 최적화의 이득을 그대로 얻는다.
  • __slots__는 인스턴스별 __dict__를 없애 메모리 사용량을 줄이며, 벡터화가 어려운 커스텀 객체를 대량 생성할 때 유효하다.
  • cProfile로 실제 병목을 프로파일링한 뒤 최적화하는 것이 추측 기반 최적화보다 우선이다.

피클링, Dunder 메서드, 몽키패칭 — 직렬화와 객체지향 이디엄

피클링(pickling)은 Python 객체를 바이트 스트림으로 직렬화해 디스크에 저장하거나 네트워크로 전송할 수 있게 하는 과정이다. 모델 상태나 복잡한 설정 객체를 저장하는 데는 편리하지만, 신뢰할 수 없는 출처의 데이터를 언피클링하는 것은 안전하지 않다 — 로딩 과정에서 임의 코드가 실행될 수 있기 때문이다. 서로 다른 시스템 간 데이터 교환에는 보통 JSON이나 Parquet이 선호된다. 참고로 multiprocessing으로 프로세스 간에 큰 객체를 주고받을 때도(공유 메모리를 쓰지 않는 한) 내부적으로 피클링이 일어나므로, 대형 DataFrame을 워커 프로세스에 반복적으로 넘기는 구조는 피클링 자체가 성능 병목이 될 수 있다.

__init__은 클래스의 초기화 메서드로, 객체가 생성된 뒤 초기 상태를 설정한다. __new__는 인스턴스 자체를 실제로 만드는 생성자다. 데이터 엔지니어가 __new__를 오버라이드해야 하는 경우는 드물며, 싱글턴 패턴을 구현하거나 튜플·문자열 같은 불변 타입을 서브클래싱할 때 정도다.

몽키패칭은 런타임에 코드를 교체하거나 확장하는 관행이다(예: 서드파티 라이브러리의 메서드를 바꿔치기). 의존성의 버그에 대한 빠른 임시 대응이 될 수는 있지만, 실제 동작이 디스크 상의 소스 코드와 더 이상 일치하지 않게 되어 코드베이스를 예측 불가능하고 디버깅하기 매우 어렵게 만들기 때문에 일반적으로 지양된다.

__str__, __repr__, __len__ 같은 던더(Dunder, Double UNDERscore) 메서드는 커스텀 클래스가 내장 동작을 흉내 내게 해준다. 데이터 엔지니어에게는 __iter__와 __next__를 구현해 커스텀 데이터 로더 클래스를 for 루프에서 쓸 수 있게 하거나, __getitem__으로 데이터 레코드를 표현하는 클래스를 딕셔너리처럼 접근하게 만드는 것이 특히 유용하다.

핵심 포인트

  • 피클링은 편리하지만 신뢰할 수 없는 소스를 언피클링하면 임의 코드 실행 위험이 있어, 시스템 간 교환에는 JSON/Parquet이 더 안전하다.
  • multiprocessing으로 대형 객체를 프로세스 간에 주고받을 때도 내부적으로 피클링이 일어나 성능 병목이 될 수 있다.
  • __init__은 초기화, __new__는 인스턴스 생성 자체를 담당하며, __new__ 오버라이드는 싱글턴/불변 타입 서브클래싱 등 드문 경우에만 필요하다.
  • 몽키패칭은 실제 동작과 소스 코드를 불일치시켜 디버깅을 어렵게 만들므로 일반적으로 지양된다.
  • __iter__/__next__/__getitem__ 같은 던더 메서드로 커스텀 데이터 로더나 레코드 클래스를 내장 컨테이너처럼 동작시킬 수 있다.