Python 코드는 어떻게 실행될까

src/content/documents/python/how-python-code-executes.json

결론부터 보기

일반적으로 가장 널리 쓰이는 Python 구현인 CPython은 소스 코드를 곧바로 한 줄씩 해석하는 것이 아니라, 먼저 code object와 bytecode로 컴파일한 뒤 평가 루프에서 명령을 실행한다. 문법 구조를 나타내는 AST는 이 컴파일 과정에서 중요한 중간 표현이다.

이 흐름을 흔히 ‘컴파일과 인터프리트가 결합된 방식’이라고 설명한다. 입문자가 전체 그림을 잡기에는 유용하지만, 컴파일 단계와 내부 표현은 Python 구현 및 버전에 따라 다를 수 있다. 따라서 이 글에서 AST 이후의 bytecode와 평가 과정은 특별히 CPython의 동작으로 한정한다.

Python 언어와 CPython 구현은 다르다

Python은 문법과 실행 의미를 정의하는 프로그래밍 언어다. CPython은 그 언어를 실행하는 대표 구현이며 C로 작성되어 있다. PyPy처럼 다른 구현도 Python 코드를 실행하지만, 내부 컴파일 단계나 실행 엔진이 CPython과 같다고 가정할 수는 없다.

이 글의 ‘bytecode’, ‘opcode’, ‘평가 루프’는 CPython 기준이다. Python 언어 자체가 특정 bytecode 형식을 보장하는 것은 아니다.

소스 코드에서 실행 결과까지

Python 소스 코드
  ↓ 구문 분석
AST(추상 구문 트리)
  ↓ 컴파일
code object(실행에 필요한 bytecode·상수·이름 등의 묶음)
  ↓ CPython 평가 루프
객체 연산과 함수 호출

실행 결과

1. CPython은 소스의 문법을 분석한다. 문법이 잘못되었다면 실행 전에 SyntaxError가 발생한다.

2. 구문 구조는 AST로 표현할 수 있다. AST는 괄호나 공백 같은 표면 형식보다 대입·이항 연산·함수 호출처럼 코드의 의미 구조에 초점을 둔다.

3. 컴파일러는 AST를 바탕으로 code object를 만든다. code object에는 CPython bytecode 외에도 상수, 참조하는 이름, 지역 변수, 소스 위치 정보 등이 담긴다.

4. CPython의 인터프리터는 bytecode 명령을 평가하며 필요한 Python 객체 연산을 수행한다. 문헌에서 이를 ‘Python Virtual Machine(PVM)’이라고 부르기도 하지만, 공식 문서를 읽을 때는 CPython 인터프리터·평가 루프·bytecode라는 더 구체적인 표현이 혼동을 줄인다.

이 그림은 학습을 위한 개념 모델이다. 실제 컴파일러에는 심볼 테이블 분석, 최적화, 예외·소스 위치 정보 생성 같은 단계가 더 있으며, 버전이 바뀌면 세부 구현도 달라질 수 있다.

AST로 코드의 구조 관찰하기

표준 라이브러리 ast를 사용하면 문자열로 된 소스를 AST로 바꿔 볼 수 있다. ast.parse()는 내부적으로 AST만 요청하는 방식의 compile()과 동등한 도우미다.

import ast

source = "total = 2 + 3"
tree = ast.parse(source)
print(ast.dump(tree, indent=2))
Module(
  body=[
    Assign(
      targets=[
        Name(id='total', ctx=Store())],
      value=BinOp(
        left=Constant(value=2),
        op=Add(),
        right=Constant(value=3)))])

가장 바깥의 Module 안에 대입을 뜻하는 Assign가 있고, 오른쪽 값은 덧셈을 나타내는 BinOp다. 변수 totalStore는 이 이름에 값을 저장한다는 문맥을 나타낸다. AST 형식도 Python 릴리스 사이에 달라질 수 있으므로 특정 출력 문자열에 의존하는 도구는 대상 버전을 명확히 해야 한다.

dis로 CPython bytecode 관찰하기

표준 라이브러리 dis는 함수나 code object를 역어셈블해 CPython bytecode 명령을 보여 준다. 다음 코드는 버전별 출력 형식 차이를 피하려고 명령 이름과 사람이 읽을 수 있는 인자만 골라 출력한다.

import dis

def add_tax(price):
    return price * 1.1

for instruction in dis.get_instructions(add_tax):
    print(instruction.opname, instruction.argrepr)

출력에는 지역 변수와 상수를 읽고 곱셈을 수행한 뒤 값을 반환하는 명령들이 나타난다. 예를 들어 현재 CPython 계열에서는 LOAD_FAST, LOAD_CONST, BINARY_OP, RETURN_VALUE와 같은 이름을 볼 수 있다. 시작·캐시 관련 명령이 함께 보일 수도 있다.

opcode의 종류, 인자, 오프셋과 dis 출력은 CPython 구현 세부사항이다. Python 버전 사이에서 추가·삭제·변경될 수 있으며 다른 Python 구현에서 그대로 동작한다고 보장되지 않는다.

opcode는 수행할 연산을 식별하는 숫자 코드이고, opname은 사람이 읽을 수 있는 이름이다. 명령에는 상수·지역 변수 번호·연산 종류 같은 인자가 붙을 수 있다. 하지만 bytecode를 외워야 Python을 잘 쓰는 것은 아니다. dis는 코드가 실제로 어떤 연산으로 컴파일되었는지 확인하는 관찰 도구로 사용하는 것이 적절하다.

.pyc와 __pycache__는 무엇을 저장할까

CPython은 모듈을 import할 때 컴파일한 code object를 __pycache__ 아래의 *.pyc 파일에 캐시할 수 있다. 다음 import에서 캐시가 유효하면 소스를 다시 컴파일하는 일을 줄일 수 있다. 이는 일반적으로 프로그램의 실행 연산 자체를 빠르게 만드는 최적화가 아니라, 모듈 로딩 중 컴파일 단계를 재사용하는 캐시다.

message = "hello"
print(message)
python -m py_compile hello.py
__pycache__/hello.cpython-314.pyc

파일명의 cpython-314 부분은 예시이며 실제 태그는 실행한 인터프리터 버전에 따라 달라진다. PDF에 나온 cpython-311도 Python 3.11을 사용했을 때의 예다.

기본 timestamp 방식의 캐시는 소스 파일의 수정 시각과 크기로 유효성을 확인한다. hash 기반 캐시도 있으며, 설정에 따라 소스 내용의 해시를 검사할 수 있다. 소스가 바뀌어 캐시가 유효하지 않으면 import 시스템은 다시 컴파일한다.

중요한 예외도 있다. python hello.py처럼 최상위 스크립트를 직접 실행하는 것은 import가 아니므로 해당 스크립트의 .pyc가 자동 생성되지 않는다. 반면 그 스크립트가 import한 다른 모듈은 캐시될 수 있다. 위의 py_compile 명령은 파일을 명시적으로 미리 컴파일하므로 별개의 경우다.

또한 .pyc는 모든 Python 구현과 버전에서 통하는 범용 배포 형식이 아니다. bytecode가 CPython 버전별 구현 세부사항이므로, 소스를 감추거나 장기 호환 배포를 보장하는 수단으로 간주해서는 안 된다.

흔한 오해 세 가지

오해 1. Python은 소스를 한 줄씩만 읽어 실행한다

CPython은 실행할 코드 단위를 먼저 파싱하고 컴파일한다. 오류 메시지와 추적 정보가 소스 줄에 연결되고 대화형 입력이 한 문장씩 피드백을 주기 때문에 그렇게 보일 수 있지만, 내부 모델은 AST·code object·bytecode를 포함한다.

오해 2. .pyc가 있으면 프로그램 계산이 모두 빨라진다

유효한 캐시는 주로 import 시 소스 컴파일을 생략하게 돕는다. 반복문의 알고리즘이나 데이터 처리량을 바꾸지는 않는다. 성능 문제는 측정한 뒤 알고리즘, I/O, 자료구조, 적절한 라이브러리를 우선 살펴야 한다.

오해 3. dis 출력은 모든 Python에서 같다

공식 문서는 CPython bytecode가 버전 사이에서 바뀔 수 있고 다른 Python VM과 호환된다고 볼 수 없다고 명시한다. 학습 자료의 dis 결과가 내 컴퓨터와 다르다면 먼저 Python 구현과 버전을 확인한다.

이 구조를 알면 무엇이 좋아질까

문법 오류가 실행 전에 발견되는 이유, import 후 __pycache__가 생기는 이유, 소스 한 줄이 여러 bytecode 명령으로 나뉠 수 있는 이유를 설명할 수 있다. AST는 린터·포매터·정적 분석·코드 변환 도구의 기반을 이해하는 출발점이 된다.

디버깅할 때는 먼저 소스와 traceback을 보고, 평범한 방법으로 설명되지 않는 동작을 확인할 때 astdis를 보조 도구로 쓴다. 성능 역시 bytecode 개수만으로 판단하지 말고 실제 입력으로 프로파일링해야 한다. 하나의 opcode가 호출하는 내부 연산 비용은 크게 다를 수 있기 때문이다.

핵심 체크리스트

☐ Python 언어와 CPython 구현을 구분해 설명할 수 있다.

☐ 소스 → AST → code object·bytecode → CPython 평가의 개념 흐름을 말할 수 있다.

☐ ast.parse()와 ast.dump()로 간단한 코드 구조를 확인할 수 있다.

☐ dis 출력이 CPython 버전별 구현 세부사항임을 알고 있다.

☐ .pyc가 import용 bytecode 캐시이며 최상위 스크립트 실행과 다름을 설명할 수 있다.

다음 글: Python의 객체와 메모리 관리

bytecode가 실행되면 상수·함수·리스트 같은 실제 Python 객체가 만들어지고 이름이 그 객체를 참조한다. 다음 글에서는 ‘모든 값이 객체’라는 말의 의미, 참조와 메모리 관리, 참조 횟수와 순환 가비지 컬렉션을 CPython 기준으로 살펴본다.

참고 자료

댓글 0

댓글을 불러오는 중…