파이썬 톺아보기 2화 - Ast 와 바이트코드
식(Expression) 과 문장(Statement)
프로그래밍을 공부하다보면 위 두 단어를 반드시 마주하게 된다. 가끔 헷갈려하는 경우가 많은데 오늘은 python 에서 기본 모듈인 ast 모듈을 공부하며 이를 알아보도록 하자.
식(Expression)
기본적으로 식(Expression) 이란 평가되면 값이 나오는 코드 조각을 뜻한다. 파이썬에서는 어떠한 부분들이 있을까?
| 노드 타입 | 설명 | 예시 |
BinOp | 이항 연산 | a + b, x * y |
UnaryOp | 단항 연산 | -x, not flag |
BoolOp | 논리 연산 | a and b, x or y |
Compare | 비교 연산 | x > 0, a == b |
Call | 함수 호출 | print("hi") |
Name | 변수 이름 | x, foo |
Constant | 상수 | 42, "hello" |
Attribute | 속성 접근 | obj.method |
Subscript | 첨자 접근 | lst[0], dict["key"] |
바로 위와 같은 코드 조각들이 존재한다. 특징 들을 보면 1 + 2 를 실행시키면 바로 3 이라는 값이 나오듯. 코드 조각들이 평가되는 순간에 바로 **값(valude)**이 나오게 된다. 이를 한번 ast 모듈을 통하여 파싱해보자.
ast 모듈의 parse 함수에는 mode 라는 값이 존재하는데, eval 로 하게 되면 단일 표현식만 파싱이 가능하다
expressions = {
'BinOp': '1 + 2',
'UnaryOp': '-x',
'BoolOp': 'a and b',
'Compare': 'x > 0',
'Call': 'print("hello")',
'Name': 'x',
'Constant': '42',
'Attribute': 'obj.method',
'Subscript': 'lst[0]',
}
for expr_type, code in expressions.items():
print(f"\n{'='*40}")
print(f"{expr_type}: {code}")
print(f"{'='*40}")
tree = ast.parse(code, mode='eval') # 표현식 모드로 파싱
print(ast.dump(tree, indent=2))
이를 파싱하면 아래와 같은 출력값이 나온다.
========================================
BinOp: 1 + 2
========================================
Expression(
body=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)))
========================================
UnaryOp: -x
========================================
Expression(
body=UnaryOp(
op=USub(),
operand=Name(id='x', ctx=Load())))
(생략...)
보면 전부 Expression 이라는 큰 그룹으로 묶여 있음을 알 수 있다. 즉, AST 가 이 코드 조각들을 식으로 인식하고 있음을 알 수 있다. 이제 대략적으로 식(Expression) 에 대한 감은 왔을 것이다. 그렇다면 문장은 또 어떤 것이 있을까? 한번 알아보도록 하자.
문장(Statement)
| 노드 타입 | 설명 | 예시 |
FunctionDef | 함수 정의 | def foo(): ... |
ClassDef | 클래스 정의 | class Foo: ... |
If | 조건문 | if x > 0: ... |
For | for 루프 | for i in range(10): ... |
While | while 루프 | while x < 10: ... |
Return | 반환문 | return x + 1 |
Assign | 할당문 | x = 1 |
AugAssign | 복합 할당 | x += 1 |
Import | 임포트 | import os |
ImportFrom | from 임포트 | from os import path |
문장(Statement) 는 위와 같이 “무언가를 한다/흐름을 만든다” 에 가까운 하나의 실행 단위이다. 뭐 분기 흐름을 만든다, 클래스를 정의한다 등등과 같은 무언가 특정 행위를 만들거나 정의하는 코드 조각의 모음이다. 이 코드 조각들 또한 ast 를 이용해서 parsing 하는 것이 가능하다.
statements = {
'FunctionDef': '''
def greet(name):
return f"Hello, {name}!"
''',
'If': '''
if x > 0:
print("positive")
else:
print("non-positive")
''',
'For': '''
for i in range(5):
print(i)
''',
'Return': '''
return x + y
''',
}
for stmt_type, code in statements.items():
print(f"\n{'='*50}")
print(f"{stmt_type} 예제:")
print(f"{'='*50}")
tree = ast.parse(code)
# 첫 번째 문장의 타입 확인
first_stmt = tree.body[0]
print(f"첫 번째 문장 타입: {type(first_stmt).__name__}")
print(f"\nAST 구조:")
print(ast.dump(first_stmt, indent=2))
==================================================
FunctionDef 예제:
==================================================
첫 번째 문장 타입: FunctionDef
AST 구조:
FunctionDef(
name='greet',
args=arguments(
args=[
arg(arg='name')]),
body=[
Return(
value=JoinedStr(
values=[
Constant(value='Hello, '),
FormattedValue(
value=Name(id='name', ctx=Load()),
conversion=-1),
Constant(value='!')]))])
==================================================
If 예제:
==================================================
첫 번째 문장 타입: If
AST 구조:
If(
test=Compare(
left=Name(id='x', ctx=Load()),
ops=[
Gt()],
comparators=[
Constant(value=0)]),
body=[
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
Constant(value='positive')]))],
orelse=[
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
Constant(value='non-positive')]))])
(생략 ...)
도중에 생략하긴 했는데 위와 같이 나오게 된다. If 와 같은 문장들은 식(Expression) 과 다르게 Statement로 감싸져 있지 않음을 확인할 수 있다. 이는 자리가 중요하기 때문이다. 문장 자리(stmt position) 에서는 Expression 이 들어갈 수 없기 때문에 ast.Expr 로 감싸게 된다.
def f():
return 1 + 2 # ← Return(value=BinOp(...)) (BinOp를 Expr로 감싸지 않음)
하지만, 만약 문장 자리가 아닌 표현식 자리(expr position) 이라면 위와 같이 Expr 로 감싼 상태로 나오지 않게 된다.
바이트코드
이렇게 AST 로 해석되고 나면 어떻게 될까? 바로 컴파일 되게 된다. 파이썬도 Java 처럼 플랫폼 독립적이기 위해 이를 파이썬 가상 머신(PVM) 이 해석할 수 있는 구조인 바이트코드로 해석한다. 이를 코드로 확인해보기 위해서는 dis 모듈을 사용해보면 된다.
def add(a, b):
return a + b
print("=== dis.dis() 출력 ===")
dis.dis(add)
=== dis.dis() 출력 ===
2 RESUME 0
3 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUE
위와 같이 첫번째로 2 와 3 같은 소스코드의 줄 번호가 나오고, RESUME, LOAD_FAST, BINARY_OP, RETURN_VALUE 와 같은 opcode(명령어) 그리고 0,1,0 과 같은 피연산자 인덱스가 나오게 된다. 위와 같이 dis 모듈을 통해 코드의 바이트 코드를 출력할 수 있다는 사실을 알 수 있다.
바이트 코드 예시
몇가지 바이트 코드를 한번 알아보도록 하자.
LOAD_CONST: 상수를 스택에 푸시BINARY_OP: 이항 연산 수행STORE_FAST: 스택에서 값을 꺼내 지역변수에 저장
def simple_math():
x = 1 + 2
return x
print("=== x = 1 + 2 의 바이트코드 ===")
dis.dis(simple_math)
print("\n=== 상수 테이블 ===")
print(f"co_consts: {simple_math.__code__.co_consts}")
이 코드를 실행하면 어떻게 될까? 일단 결과를 보기보다 예측해보자.
LOAD_CONST 1 (1) → 스택 = [1]
LOAD_CONST 2 (2) → 스택 = [1, 2]
BINARY_OP 0 (+) → 스택 = [3] (1과 2를 팝하고 3을 푸시)
STORE_FAST 0 (x) → 스택 = [] (3을 팝하여 x에 저장)
LOAD_FAST 0 (x) → 스택 = [3] (x의 값을 푸시)
RETURN_VALUE → 스택 = [] (3을 반환)
위와 같이 생각해 볼수 있다. 가장 첫번째로 1 과 2 를 스택에 넣어두고 BINARY_OP 를 통해 Pop 해서 3을 밀어넣고 이 값을 지역변수에 저장하는 것들을 생각해볼 수 있다. 실제로 실행하면 어떨까?
=== x = 1 + 2 의 바이트코드 ===
2 RESUME 0
3 LOAD_CONST 1 (3)
STORE_FAST 0 (x)
4 LOAD_FAST 0 (x)
RETURN_VALUE
=== 상수 테이블 ===
co_consts: (None, 3)
실제로 실행하게 되면 위와 같은 결과를 얻게 된다. 그 이유는 Cpython 의 상수 폴딩(constant folding) 때문인데 1+2 같이 사실상 컴파일시점에 값을 알 수 있는 식(Expression) 들은 3 하나만 상수테이블에 넣고 바이트 코드는 LOAD_CONST 3 만 남기게 된다.
Bytecode tracer
위와 같이 다른 바이트코드들도 많지만 굳이 다뤄야 할 정도로 유익하진 않다고 생각해서 bytecode_tracer 라는 tool 을 소개하고 이글을 마치려고 한다. 만약 스택 상태를 추적하고 싶다거나, 강의 목적으로 스택이 변화하는걸 보여주고 싶다면 아래와 같이 bytecode_tracer 를 이용하면 쉽게 시각화 할 수 있다.
import sys
sys.path.insert(0, '/home/roach/python-debug')
from tools.bytecode_tracer import trace_execution
# 간단한 함수 추적
def add(a, b):
return a + b
print("=== 스택 상태 추적: add(1, 2) ===")
trace_execution(add, (1, 2))
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Offset ┃ Opcode ┃ Arg ┃ Stack Before ┃ Stack After ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 0 │ RESUME │ │ [] │ [] │
│ 2 │ LOAD_FAST_LOAD_FAST │ a, b │ [] │ [1, 2] │
│ 4 │ BINARY_OP │ + │ [1, 2] │ [3] │
│ 8 │ RETURN_VALUE │ │ [3] │ [] │
└────────┴───────────────────────┴────────────────┴───────────────────────────────┴───────────────────────────────┘
CFG

from tools.cfg_visualizer import visualize_cfg
def test_loop(n):
total = 0
for i in range(n):
total += i
return total
print("=== for 루프의 CFG 생성 ===")
output_path = visualize_cfg(test_loop, 'outputs/cfg/test_loop.png')
print(f"CFG 저장됨: {output_path}")
cfg 라는 tool 을 설치하면 위와 같이 바이트 코드의 흐름도 또한 확인해볼 수 있다.
연습 문제
def loop_with_range(n):
total = 0
for i in range(n):
total += i
return total
def loop_with_while(n):
total = 0
i = 0
while i < n:
total += i
i += 1
return total
n 회 기준으로 for-loop 와 while 루프가 위 처럼 코드가 존재할때 과연 바이트 코드가 같을까? 아니면 누가 더 빠를까? 한번 바이트 코드를 보면 아래와 같이 컴파일된다 (python 3.13 기준이다)
=== for + range ===
2 RESUME 0
3 LOAD_CONST 1 (0)
STORE_FAST 1 (total)
4 LOAD_GLOBAL 1 (range + NULL)
LOAD_FAST 0 (n)
CALL 1
GET_ITER
L1: FOR_ITER 7 (to L2)
STORE_FAST 2 (i)
5 LOAD_FAST_LOAD_FAST 18 (total, i)
BINARY_OP 13 (+=)
STORE_FAST 1 (total)
JUMP_BACKWARD 9 (to L1)
4 L2: END_FOR
POP_TOP
6 LOAD_FAST 1 (total)
RETURN_VALUE
=== while ===
8 RESUME 0
9 LOAD_CONST 1 (0)
STORE_FAST 1 (total)
10 LOAD_CONST 1 (0)
STORE_FAST 2 (i)
11 LOAD_FAST_LOAD_FAST 32 (i, n)
COMPARE_OP 18 (bool(<))
POP_JUMP_IF_FALSE 16 (to L2)
12 L1: LOAD_FAST_LOAD_FAST 18 (total, i)
BINARY_OP 13 (+=)
STORE_FAST 1 (total)
13 LOAD_FAST 2 (i)
LOAD_CONST 2 (1)
BINARY_OP 13 (+=)
STORE_FAST 2 (i)
11 LOAD_FAST_LOAD_FAST 32 (i, n)
COMPARE_OP 18 (bool(<))
POP_JUMP_IF_FALSE 2 (to L2)
JUMP_BACKWARD 16 (to L1)
14 L2: LOAD_FAST 1 (total)
RETURN_VALUE
바이트 코드의 양만 봐도 알 수 있듯이 while 문에 조금 더 많은 바이트 코드가 존재한다. 그 이유는 아래 연산이 매 반복의 분기마다 이뤄지기 때문이다.
비교(COMPARE_OP) + 분기(POP_JUMP_IF_FALSE)
증가를 위한(LOAD_CONST/BINARY_OP/STORE_FAST)
실제 어느정도 크지 않다면 비슷하겠지만 바이트 코드를 보게 된다면 위와 같이 미세한 차이들도 발견해볼 수 있다. 이러한 지식은 언젠가 알아두면 도움이 되니 파이썬을 사용하고 있다면 한번정도는 공부해보면 좋은 것 같다.

