CPython 내부로 이해하는 Python: 바이트코드와 인터프리터, GIL, 가비지 컬렉션, 임포트 시스템

이 글의 핵심

문법 요약을 넘어 CPython이 소스를 바이트코드로 만드는 과정, GIL이 CPU·I/O 워크로드에 미치는 영향, 참조·세대별 GC, importlib 중심의 임포트 경로, 그리고 서비스 운영에 통하는 프로덕션 패턴을 연결해 설명합니다.

이 글의 핵심

이 문서는 파이썬 문법 사전이 아니라, CPython이 코드를 실행하는 방식과 서비스에 올렸을 때의 실무 패턴을 연결해 설명합니다. 표준 구현인 CPython을 기준으로 하며, 다른 구현(PyPy 등)과의 차이는 필요한 곳에서만 짚습니다. 기초 문법과 표준 라이브러리 개요는 파이썬 시리즈 목차와 모듈·패키지를 참고하십시오.


CPython 인터프리터 아키텍처

CPython은 인터프리터이지만 실행 전에 소스를 바이트코드로 컴파일합니다. 컴파일 결과는 __pycache__의 .pyc 파일로 캐시되므로, 소스가 바뀌지 않았다면 다음 실행에서는 파싱과 컴파일을 건너뜁니다.

파이프라인 개요

대략적인 흐름은 다음과 같습니다.

  1. 소스 읽기: .py 파일 또는 문자열을 읽습니다.
  2. 파싱: 토큰화와 구문 분석(3.9부터는 PEG 파서)을 거쳐 AST(Abstract Syntax Tree)를 만듭니다.
  3. 컴파일: AST를 바이트코드 명령(opcode)의 나열로 바꿉니다.
  4. 실행: 평가 루프(ceval.c)가 프레임 위에서 opcode를 차례로 실행합니다.

REPL에서 한 줄을 입력하든 파일을 실행하든, 실제로 돌아가는 것은 바이트코드와 프레임 스택이라는 점은 같습니다.

코드 객체와 프레임

함수를 정의하면 코드 객체(types.CodeType)가 만들어집니다. 여기에는 로컬 변수 이름, 상수 테이블, 바이트코드, 줄 번호 정보 등이 들어 있습니다. 실행 시에는 프레임이 코드 객체를 참조하면서 지역 변수와 값 스택 상태를 유지합니다. 3.11부터는 내부적으로 가벼운 _PyInterpreterFrame 구조체를 쓰고, 파이썬에서 보이는 프레임 객체(sys._getframe(), 트레이스백의 tb_frame)는 필요할 때만 만들어집니다. 예전 버전의 “블록 스택”도 3.11에서 예외 테이블(zero-cost exception)로 대체되었습니다.

def add(a, b):
    return a + b

print(add.__code__.co_name)       # 'add'
print(add.__code__.co_varnames)   # ('a', 'b')
print(add.__code__.co_code[:10])  # 바이트코드(바이너리) 일부

프로파일러·트레이서·디버거는 대부분 이 프레임과 코드 객체를 따라가며 동작합니다. 스택 트레이스에 파일명과 줄 번호가 찍히는 것도 코드 객체에 저장된 위치 정보 덕분입니다.

dis 모듈로 바이트코드 엿보기

표준 라이브러리 dis는 opcode를 사람이 읽을 수 있는 형태로 풀어 줍니다.

import dis

def f(x):
    return x * 2

dis.dis(f)

3.11 이상에서는 LOAD_FAST, LOAD_CONST, BINARY_OP, RETURN_VALUE 같은 이름이 보입니다(버전마다 opcode 이름과 구성이 조금씩 다릅니다). 피연산자를 값 스택에 올리고 연산 결과를 다시 스택에 남기는 형태인데, CPython 가상 머신이 스택 머신이기 때문입니다.

객체 모델과 PyObject

CPython에서 “모든 것이 객체”라는 말은, C 레벨에서 모든 값이 PyObject 구조체 헤더(참조 카운트와 타입 포인터)를 공유한다는 뜻에 가깝습니다. 타입마다 PyTypeObject가 있으며, 연산은 타입의 슬롯(slot)과 특수 메서드로 분기됩니다. 개발자가 a + b를 쓰면 인터프리터는 a의 __add__를 먼저 시도하고, 지원하지 않으면 b의 __radd__를 시도합니다.

이 구조 때문에 작은 연산이 아주 많이 반복되는 핫 루프는 순수 파이썬만으로는 opcode 디스패치와 타입 분기 비용이 누적됩니다. 이런 구간은 NumPy 같은 벡터화, C 확장이나 Cython, 또는 자료구조 자체를 바꾸는 쪽을 검토합니다.

평가 루프와 디스패치

바이트코드 실행의 핵심은 C로 구현된 평가 루프입니다. opcode마다 분기하는 디스패치 루프가 있고, 3.11부터는 PEP 659의 특수화 적응형 인터프리터가 실행 중에 자주 쓰이는 opcode를 타입에 특화된 버전(예: 정수끼리의 덧셈)으로 바꿔 끼웁니다. 개발자 관점에서는 한 줄의 파이썬이 여러 opcode로 쪼개진다는 사실이 프로파일에 보이는 비용의 근원이 됩니다.

PEP 523은 프레임 평가 함수를 교체할 수 있는 C API 훅을 제공합니다. 일반 애플리케이션 코드에서는 쓸 일이 거의 없지만, 디버거나 JIT 실험 도구가 인터프리터에 붙는 방식을 이해하는 데 도움이 됩니다.


GIL(Global Interpreter Lock)

GIL이 하는 일

표준 CPython 빌드의 GIL은 한 번에 하나의 스레드만 파이썬 바이트코드를 실행하도록 만드는 전역 잠금입니다. 스레드가 여러 개 있어도 동시에 파이썬 바이트코드를 실행하는 스레드는 하나뿐입니다. 다만 블로킹 I/O나 일부 C 확장 코드처럼 GIL을 명시적으로 내려놓는 구간에서는 다른 스레드가 진행할 수 있습니다.

왜 존재하는가

역사적으로는 C API와 객체 모델을 단순하게 유지하고, 참조 카운트 갱신을 스레드 안전하게 만드는 방법으로 GIL이 선택되었습니다. 모든 객체의 참조 카운트를 원자적 연산이나 객체별 잠금으로 보호하는 것보다, 전역 잠금 하나를 두는 편이 단일 스레드 성능과 구현 복잡도 면에서 유리했습니다.

언제 GIL이 풀리는가

대표적으로 소켓 recv, 파일 읽기 같은 블로킹 I/O를 수행하는 C 코드와, 일부 수치·암호·압축 라이브러리처럼 내부 계산 동안 GIL을 해제하는 확장 모듈이 있습니다. 그 사이 다른 스레드가 실행될 수 있으므로 I/O 대기는 스레드로도 겹칠 수 있습니다.

반면 CPU를 오래 쓰는 순수 파이썬 연산은 스레드를 늘려도 바이트코드가 번갈아 실행될 뿐이라 전체 처리량이 늘지 않고, 잠금 경합 때문에 오히려 느려질 수도 있습니다.

threading vs multiprocessing

목적흔한 선택
I/O 동시성(다수 소켓, HTTP 클라이언트)asyncio, threading, 또는 둘의 조합
CPU 바운드 병렬 연산multiprocessing, concurrent.futures.ProcessPoolExecutor, 작업 큐(Celery 등)
네이티브 코드가 GIL을 해제하는 경우스레드로도 병렬에 가깝게 동작할 수 있음(라이브러리 의존)

멀티프로세싱은 프로세스마다 별도의 인터프리터와 GIL을 가지므로 CPU 병렬성을 얻는 대신, 인자와 결과를 pickle로 직렬화해 프로세스 간에 넘기는 비용과 프로세스별 메모리 사용량을 감수해야 합니다.

free-threaded 빌드

PEP 703에 따라 CPython 3.13에서 GIL 없이 동작하는 free-threaded 빌드(python3.13t)가 실험적으로 도입되었고, 3.14에서는 공식 지원 단계로 올라갔습니다. 다만 여전히 별도 빌드이며 기본 설치는 GIL이 있는 빌드입니다. 단일 스레드 성능이 다소 떨어지고, C 확장 모듈이 free-threading을 지원한다고 선언하지 않으면 해당 모듈을 임포트할 때 GIL이 다시 켜질 수 있으므로, 쓰는 확장 모듈의 지원 여부를 먼저 확인해야 합니다.

스레드 전환과 sys.setswitchinterval

GIL을 잡은 스레드는 다른 스레드가 GIL을 기다리고 있으면 일정 간격마다 GIL을 내려놓도록 요청받습니다. 이 간격은 sys.getswitchinterval()로 확인할 수 있고 기본값은 0.005초입니다. 이 값은 CPU 바운드 스레드 하나가 GIL을 너무 오래 붙잡지 않게 하는 장치일 뿐, 병렬 실행을 보장하지는 않습니다. 줄이면 응답성이 좋아지는 대신 전환 비용이 늘어납니다.


메모리 관리와 가비지 컬렉션

참조 카운트

CPython은 기본적으로 참조 카운트로 객체 수명을 관리합니다. 참조가 0이 되는 순간 객체가 해제되므로, with 없이 연 파일도 마지막 참조가 사라지면 곧바로 닫히는 식의 결정적인 동작을 보입니다(다른 구현에서는 보장되지 않으므로 의존하면 안 됩니다). 대신 순환 참조는 참조 카운트만으로 회수할 수 없습니다.

순환 참조와 세대별 GC

서로가 서로를 참조하는 객체 묶음은 외부에서 더 이상 접근할 수 없어도 참조 카운트가 0이 되지 않습니다. 이를 위해 CPython은 컨테이너 객체(리스트, 딕셔너리, 사용자 클래스 인스턴스 등)를 추적하는 순환 GC를 둡니다. 3.13까지는 세 세대(0, 1, 2)로 나뉘어, 새 객체는 세대 0에 들어갔다가 수집에서 살아남으면 상위 세대로 옮겨지고, 상위 세대일수록 덜 자주 검사됩니다. 3.14에서는 GC가 증분 방식으로 바뀌어 세대 구성이 young/old 두 단계로 단순해졌으므로, 세대 번호를 직접 다루는 코드는 버전별 문서를 확인하십시오.

import gc

class Node:
    def __init__(self):
        self.ref = None

a, b = Node(), Node()
a.ref, b.ref = b, a  # 순환

del a, b         # 참조 카운트는 1씩 남아 즉시 해제되지 않음
print(gc.collect())  # 수집된 객체 수(순환 묶음 포함)를 반환

__slots__와 메모리

인스턴스를 아주 많이 만드는 클래스는 __slots__로 속성을 고정해 인스턴스별 __dict__를 없애면 메모리를 줄일 수 있습니다. 대신 정의하지 않은 속성을 동적으로 추가할 수 없고, 여러 부모가 각자 비어 있지 않은 __slots__를 가지면 다중 상속 시 레이아웃 충돌로 TypeError가 나므로 설계와 함께 결정합니다.

pymalloc과 객체 할당기

CPython은 512바이트 이하의 작은 객체를 위해 전용 할당기(pymalloc)를 사용합니다. 애플리케이션 코드에서 직접 건드리지는 않지만, pymalloc이 확보한 아레나는 그 안의 객체가 모두 해제되어야 OS에 반환되므로, 짧게 쓰고 버리는 객체를 대량으로 만들었다가 일부만 오래 붙잡으면 프로세스 RSS가 기대만큼 줄지 않을 수 있습니다.

weakref와 순환 끊기

캐시, 부모-자식 그래프, 콜백 등록처럼 순환이 생기기 쉬운 구조에서는 weakref로 한쪽 방향을 약한 참조로 두면 참조 카운트만으로 회수되게 만들 수 있습니다. 순환 GC가 처리할 객체 자체가 줄어드는 효과도 있습니다.

GC 임계값과 gc 모듈

순환 GC는 할당 수와 해제 수의 차이가 임계값을 넘을 때 동작합니다. gc.get_threshold()와 gc.set_threshold()로 관찰·조정할 수 있지만, 메모리 압박이나 GC 일시 정지가 프로파일에서 실제로 확인될 때만 손대고 바꾼 뒤에는 벤치마크로 검증해야 합니다. 시작 시 대량으로 만든 장수 객체를 GC 대상에서 빼고 싶다면 gc.freeze()가 더 직접적인 도구입니다(pre-fork 서버에서 copy-on-write를 지키는 용도로 쓰입니다).


임포트 시스템과 모듈

sys.modules 캐시

모듈이 한 번 임포트되면 sys.modules에 캐시됩니다. 같은 프로세스에서 import foo를 여러 번 해도 모듈 코드는 최초 한 번만 실행되고, 이후에는 캐시된 모듈 객체가 반환됩니다.

sys.path와 검색 경로

임포트는 sys.path에 나열된 디렉터리(및 zip 파일)를 순서대로 탐색합니다. 스크립트가 있는 디렉터리, PYTHONPATH, 가상환경의 site-packages, editable 설치 경로가 여기에 반영됩니다. 프로젝트 안에 json.py나 logging.py 같은 파일을 만들면 표준 라이브러리 대신 그 파일이 잡히는 일이 흔한데, 이런 문제는 경로 우선순위를 먼저 의심합니다.

importlib로 동적 로딩

표준 라이브러리 importlib는 임포트 시스템의 파인더와 로더를 파이썬 코드로 노출합니다. 이름 문자열로 모듈을 불러오는 플러그인 구조를 만들 때 유용합니다.

import importlib

m = importlib.import_module("json")
importlib.reload(m)  # 개발 중 핫 리로드에만 제한적으로

reload는 모듈 코드를 다시 실행해 모듈 객체의 이름을 갱신할 뿐, 다른 모듈이 from mod import name으로 가져간 참조나 이미 만들어진 인스턴스의 클래스는 바꾸지 않습니다. 상태를 가진 모듈에서는 예상과 다른 결과가 나오기 쉬우므로 프로덕션에서는 쓰지 않는 것이 좋습니다.

패키지와 네임스페이스 패키지

__init__.py가 있는 일반 패키지는 한 디렉터리에 묶이지만, __init__.py가 없는 네임스페이스 패키지(PEP 420)는 sys.path의 여러 위치에 흩어진 같은 이름의 디렉터리가 하나의 패키지로 합쳐집니다. 모노레포나 플러그인 레이아웃을 설계할 때는 이 차이 때문에 의도치 않은 디렉터리가 패키지로 잡히지 않는지 확인해야 합니다.

순환 임포트

모듈 A가 B를, B가 다시 A를 임포트하면 초기화 순서에 따라 일부 이름이 아직 정의되지 않았을 수 있고, 이때 ImportError: cannot import name ... (most likely due to a circular import)가 납니다. 의존 방향을 정리하거나, 공통 부분을 하위 모듈로 분리하거나, 함수 안에서 지연 임포트하는 구조적 해결이 우선입니다. 타입 힌트 때문에만 생기는 순환이라면 typing.TYPE_CHECKING 블록 안에서 임포트하는 방법도 있습니다.

importlib.metadata와 배포 패키지

애플리케이션이 런타임에 자신의 버전이나 엔트리 포인트를 알아야 할 때 importlib.metadata.version("패키지명"), importlib.metadata.entry_points()로 설치된 배포 패키지 정보를 읽을 수 있습니다. CLI 도구와 플러그인 시스템에서 흔한 패턴입니다.


서비스에 올릴 때: 로깅, 설정, 배포, 관측, 보안

로깅: print가 아니라 logging

서비스에서는 로그 레벨과 구조화 로그(JSON), 요청 단위 상관관계 ID를 기본으로 둡니다. print는 레벨 구분이나 출력 대상 전환이 어렵고, 표준 출력이 파이프로 연결되면 버퍼링 때문에 로그가 늦게 보이기도 합니다.

import logging
import json

STANDARD_ATTRS = set(vars(logging.makeLogRecord({})))

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            "level": record.levelname,
            "msg": record.getMessage(),
            "logger": record.name,
        }
        # extra=로 넘긴 값은 LogRecord 속성으로 붙으므로 직접 꺼내야 출력됩니다
        for key, value in vars(record).items():
            if key not in STANDARD_ATTRS:
                payload[key] = value
        return json.dumps(payload, ensure_ascii=False, default=str)

h = logging.StreamHandler()
h.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[h])

logging.getLogger("app").info("user_login", extra={"user_id": 123})

extra로 넘긴 키는 LogRecord의 속성이 될 뿐 포매터가 자동으로 출력하지 않습니다. 위처럼 표준 속성을 제외한 나머지를 직접 담아야 user_id가 로그에 남습니다.

설정과 시크릿

12-Factor 관점에서 설정은 환경 변수로 주입하는 것이 일반적입니다. 민감한 값은 시크릿 매니저에 두고, 애플리케이션은 Pydantic Settings처럼 타입을 검증하는 스키마로 읽어 잘못된 설정이 시작 시점에 바로 드러나게 합니다.

WSGI / ASGI 배포

동기 웹 애플리케이션은 Gunicorn이나 uWSGI 같은 WSGI 서버로, 비동기 애플리케이션은 Uvicorn이나 Hypercorn 같은 ASGI 서버로 띄우는 것이 일반적입니다(Gunicorn이 Uvicorn 워커를 관리하는 조합도 흔합니다). GIL 때문에 CPU 병렬성은 워커 프로세스 수로 확보하므로, 워커 수는 CPU 코어 수, 워커당 메모리, 동시 연결 수를 함께 보고 정합니다.

관측: 메트릭, 트레이스, 프로파일

메트릭으로는 요청 수, 지연 시간, 에러율, 큐 깊이를 수집하고, 서비스 간 호출은 OpenTelemetry로 분산 추적합니다. CPU 병목은 cProfile로 개발 환경에서, 실행 중인 프로세스는 코드를 고치지 않고 붙을 수 있는 샘플링 프로파일러 py-spy로 확인합니다.

의존성과 재현 빌드

uv, Poetry, pip-tools 등이 만드는 잠금 파일로 전이 의존성까지 버전을 고정해야 같은 이미지를 다시 빌드할 수 있습니다. 컨테이너에서는 root가 아닌 사용자로 실행하는 것이 기본입니다.

보안 기본기

TLS 종료는 리버스 프록시나 인그레스에서 일관되게 처리합니다. subprocess는 shell=True에 사용자 입력을 문자열로 넘기지 말고 인자 리스트로 호출해야 셸 인젝션을 피할 수 있습니다. 그리고 pickle.loads는 역직렬화 과정에서 임의 코드를 실행할 수 있으므로 신뢰할 수 없는 입력에는 절대 쓰지 않습니다.


같이 보면 좋은 글