파이썬 코드가 내부적으로 어떻게 실행되는지 CPython 구조를 깊이 이해한다.
🎯 이 강의에서 배우는 것
1강에서 파이썬 코드가 바이트코드를 거쳐 PVM에서 실행된다는 큰 그림을 봤습니다. 이 강의에서는 그 과정을 실제 코드로 직접 들여다봅니다. 바이트코드가 어떻게 생겼는지, 함수 호출 시 내부에서 무슨 일이 일어나는지, 그리고 파이썬의 멀티스레딩이 왜 기대만큼 빠르지 않은지 — GIL의 정체까지 완전히 이해합니다.
📦 .py → .pyc: 바이트코드 컴파일
파이썬 소스 파일(.py)을 처음 실행하면 CPython은 먼저 코드를 바이트코드(bytecode)로 컴파일합니다. 이 바이트코드는 __pycache__ 폴더에 .pyc 확장자로 저장됩니다.
# hello.py
def greet(name):
return f"안녕하세요, {name}!"
print(greet("파이썬"))
이 파일을 실행하면 __pycache__/hello.cpython-312.pyc 같은 파일이 생깁니다. 파일명에 파이썬 버전이 포함된 이유는 서로 다른 CPython 버전의 바이트코드가 호환되지 않을 수 있기 때문입니다.
바이트코드를 재사용하는 조건은 두 가지입니다: 소스 파일의 수정 시각이 변하지 않았고, 파이썬 버전이 동일해야 합니다. 이 조건이 맞으면 소스 파싱 단계를 건너뛰어 시작 속도가 빨라집니다.
import py_compile
import os
# .py 파일을 .pyc로 명시적 컴파일
py_compile.compile("hello.py")
# __pycache__ 내용 확인
for f in os.listdir("__pycache__"):
print(f)
# 출력 예: hello.cpython-312.pyc
바이트코드는 특정 CPU 아키텍처에 종속되지 않는 플랫폼 독립적 중간 표현입니다. 같은 .pyc 파일을 Windows에서 만들어 macOS에서 실행할 수 있습니다(단, 파이썬 버전이 같아야 함). 이것이 Java의 "Write Once, Run Anywhere"와 동일한 원리입니다.
🔬 dis 모듈: 바이트코드를 눈으로 보기
표준 라이브러리의 dis 모듈로 바이트코드를 직접 확인할 수 있습니다. 이것이 파이썬 코드가 실제로 어떻게 실행되는지를 이해하는 가장 직접적인 방법입니다.
import dis
def add(a, b):
result = a + b
return result
dis.dis(add)
출력(Python 3.12 기준):
4 RESUME 0
5 LOAD_FAST 0 (a) # 로컬 변수 a를 평가 스택에 올림
LOAD_FAST 1 (b) # 로컬 변수 b를 평가 스택에 올림
BINARY_OP 0 (+) # 스택 상위 2개를 꺼내 더하고 결과를 올림
STORE_FAST 2 (result) # 스택 최상위 값을 로컬 변수 result에 저장
6 LOAD_FAST 2 (result) # result를 스택에 올림
RETURN_VALUE # 스택 최상위 값을 반환
파이썬 PVM은 스택 기반 가상 머신입니다. 계산에 사용되는 임시 값들을 평가 스택(evaluation stack)에 쌓았다가 꺼냅니다. 주요 opcode를 이해해봅시다:
| opcode | 의미 |
|---|---|
LOAD_FAST | 로컬 변수를 스택에 올림 |
LOAD_CONST | 상수 값(리터럴)을 스택에 올림 |
LOAD_GLOBAL | 전역 변수 또는 내장 함수를 스택에 올림 |
STORE_FAST | 스택 최상위 값을 로컬 변수에 저장 |
BINARY_OP | 스택 상위 두 값으로 산술/비교 연산 |
CALL | 함수 호출 |
RETURN_VALUE | 함수 반환값을 호출자에게 전달 |
POP_TOP | 스택 최상위 값 제거 (사용하지 않는 결과 버림) |
# 더 복잡한 예: 조건문이 있는 함수
import dis
def abs_val(x):
if x < 0:
return -x
return x
dis.dis(abs_val)
# 조건 분기가 JUMP_FORWARD 또는 POP_JUMP_IF_FALSE 같은 opcode로 표현됨을 확인할 수 있음
🏗️ 동적 타이핑: 타입은 값에 붙는다
파이썬은 동적 타입(dynamic typing) 언어입니다. 이 말이 내부적으로 어떤 의미인지 C 언어와 비교해 이해해봅시다.
# C 언어에서 (파이썬 코드가 아닌 개념 설명):
# int x = 5;
# → 스택 메모리에 4바이트 공간 확보
# → 그 공간에 5를 저장
# → x라는 이름은 그 메모리 위치(주소)를 의미
# → x는 int 타입을 영구적으로 고정
# 파이썬에서:
x = 5
# → 힙 메모리에 정수 객체 5 생성 (타입 정보 포함)
# → x라는 이름표(참조)가 그 객체를 가리킴
print(type(x)) # <class 'int'>
print(id(x)) # 메모리 주소 (예: 140234567890)
x = "hello" # 같은 이름 x가 이제 문자열 객체를 가리킴
print(type(x)) # <class 'str'>
print(id(x)) # 다른 메모리 주소
# 타입 정보는 '이름(변수)'에 없고 '값(객체)'에 있다
y = x # y도 같은 "hello" 객체를 가리킴
print(y is x) # True - 같은 객체
이 차이가 중요한 이유: 파이썬에서 type(x)는 런타임에만 알 수 있습니다. 컴파일 시점에는 x가 어떤 타입인지 알 수 없습니다. 이것이 파이썬이 실행 속도 면에서 C보다 느린 근본 원인 중 하나입니다 — 모든 연산에서 타입을 확인하는 overhead가 발생합니다.
📚 프레임 객체와 콜 스택
파이썬에서 함수가 호출될 때마다 스택 프레임(stack frame)이 생성됩니다. 프레임은 함수 실행에 필요한 모든 정보를 담고 있습니다:
- 로컬 변수들
- 현재 실행 중인 바이트코드의 위치(인스트럭션 포인터)
- 호출한 함수의 프레임(호출자 프레임, 반환 후 돌아갈 위치)
- 전역 변수들의 참조
import inspect
def inner():
frame = inspect.currentframe()
print(f"현재 함수: {frame.f_code.co_name}")
print(f"로컬 변수들: {frame.f_locals}")
# 호출 스택 전체 출력
for info in inspect.stack():
print(f" → {info.function} (줄 {info.lineno})")
def outer():
x = 42
inner()
outer()
# 출력:
# 현재 함수: inner
# 로컬 변수들: {'frame': <frame object>}
# → inner (줄 3)
# → outer (줄 10)
# → <module> (줄 13)
함수가 중첩 호출될수록 스택 프레임이 쌓입니다. CPython의 기본 재귀 한도는 1000입니다(sys.getrecursionlimit()). 이 한도를 초과하면 RecursionError가 발생합니다. 22강 '재귀' 강의에서 이것이 실제로 어떤 의미인지 깊이 다룹니다.
🔒 GIL — 파이썬 멀티스레딩의 현실
GIL(Global Interpreter Lock, 전역 인터프리터 락)은 CPython에서 한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 강제하는 뮤텍스(mutex)입니다.
왜 GIL이 존재하는가? CPython의 메모리 관리는 참조 카운팅(reference counting)을 기반으로 합니다. 각 객체는 자신을 참조하는 이름표의 수(참조 카운트)를 유지합니다. 참조 카운트가 0이 되면 메모리를 해제합니다. 이 카운터 증감 연산이 원자적(atomic)이지 않기 때문에, 여러 스레드가 동시에 같은 객체의 참조 카운트를 수정하면 카운트가 잘못되어 메모리 오류가 발생합니다. GIL은 이 문제를 예방합니다.
import threading
import time
# CPU 집약적 작업 (GIL 영향을 크게 받음)
def count_up(n):
total = 0
for i in range(n):
total += i
return total
N = 50_000_000
# 단일 스레드
start = time.perf_counter()
count_up(N)
single_time = time.perf_counter() - start
# 2개 스레드 (CPU-bound 작업 → GIL로 인해 빠르지 않음)
start = time.perf_counter()
t1 = threading.Thread(target=count_up, args=(N // 2,))
t2 = threading.Thread(target=count_up, args=(N // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
thread_time = time.perf_counter() - start
print(f"단일 스레드: {single_time:.2f}초")
print(f"2스레드: {thread_time:.2f}초")
# 결과: 2스레드가 오히려 더 느리거나 비슷함 (GIL 경합 오버헤드)
GIL의 영향은 작업 종류에 따라 크게 다릅니다:
| 작업 유형 | GIL 영향 | 해결책 |
|---|---|---|
| CPU-bound (계산 집약) | 크다 — 스레드를 써도 속도 향상 없음 | multiprocessing, ProcessPoolExecutor |
| I/O-bound (파일/네트워크 대기) | 작다 — I/O 대기 중 GIL 해제됨 | threading, asyncio 모두 효과적 |
import concurrent.futures
# CPU-bound 작업에는 multiprocessing (각 프로세스가 독립적 GIL 보유)
def cpu_task(n):
return sum(range(n))
with concurrent.futures.ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_task, [10**7, 10**7, 10**7, 10**7]))
print(results) # 4개 프로세스가 병렬 실행 → 실제로 빠름
Python 3.13에서는 No-GIL 모드가 실험적으로 도입됐습니다(--disable-gil 빌드 옵션). 기존 C 확장과의 호환성 등 여러 과제가 남아 있지만, 언젠가 파이썬 멀티스레딩의 성능이 크게 향상될 것입니다.
⚠️ 자주 하는 오해
- "파이썬은 인터프리터 언어라 컴파일이 없다": 틀렸습니다. 파이썬도 바이트코드로 컴파일합니다. 다만 이 과정이 자동으로 실행되고 중간 결과물이 실행 파일이 아닌 바이트코드라는 점이 다릅니다.
- "GIL 때문에 파이썬은 병렬 처리를 못한다": I/O 작업에는
asyncio나threading으로 효과적인 동시 처리가 가능합니다. CPU 병렬 처리는multiprocessing을 사용하면 됩니다. - "__pycache__ 폴더를 삭제하면 성능이 좋아진다": 반대입니다. __pycache__는 재실행 시 빠른 시작을 위한 캐시입니다. 삭제하면 매번 다시 컴파일해야 합니다.
📝 정리 및 다음 강의 예고
이번 강의에서 배운 핵심 내용을 정리합니다:
- 파이썬 소스 코드는 실행 전에 바이트코드(.pyc)로 컴파일되어 __pycache__에 저장됩니다.
dis모듈로 함수의 바이트코드를 직접 확인할 수 있습니다.- 파이썬은 동적 타입 언어 — 타입 정보는 변수(이름)가 아닌 값(객체)에 저장됩니다.
- 함수 호출마다 스택 프레임이 생성되고, 함수 반환 시 제거됩니다.
- GIL은 참조 카운팅 기반 메모리 관리를 보호하는 락입니다. CPU-bound 작업의 멀티스레딩 성능에 영향을 미칩니다.
다음 강의: 드디어 첫 파이썬 프로그램을 작성합니다.
print()와input()의 모든 매개변수를 분석하고, 표준 입출력 스트림의 원리를 배웁니다.
관련 주제
- .pyc 바이트코드 컴파일
- dis 모듈 opcode 분석
- 동적 타이핑
- 스택 프레임
- GIL(전역 인터프리터 락)
- CPython 내부구조
- 개발·프로그래밍
- 개발·프로그래밍 강의
- 파이썬 기초 40강 — 처음 배우는 프로그래밍
- 무료강의
- 무료 온라인 강의
- NUGUNA
- 누구나
