Skip to main content

Command Palette

Search for a command to run...

파이썬 톺아보기 2화 - Ast 와 바이트코드

Updated
7 min read

식(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: ...
Forfor 루프for i in range(10): ...
Whilewhile 루프while x < 10: ...
Return반환문return x + 1
Assign할당문x = 1
AugAssign복합 할당x += 1
Import임포트import os
ImportFromfrom 임포트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

위와 같이 첫번째로 23 같은 소스코드의 줄 번호가 나오고, 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을 반환)

위와 같이 생각해 볼수 있다. 가장 첫번째로 12 를 스택에 넣어두고 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-loopwhile 루프가 위 처럼 코드가 존재할때 과연 바이트 코드가 같을까? 아니면 누가 더 빠를까? 한번 바이트 코드를 보면 아래와 같이 컴파일된다 (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)

실제 어느정도 크지 않다면 비슷하겠지만 바이트 코드를 보게 된다면 위와 같이 미세한 차이들도 발견해볼 수 있다. 이러한 지식은 언젠가 알아두면 도움이 되니 파이썬을 사용하고 있다면 한번정도는 공부해보면 좋은 것 같다.

30 views

More from this blog

RDB 에서 큰 컬럼을 인덱스로 잡으면 안되는 이유

B-Tree 는 기본적으로 페이지 사이즈 와 저장할 수 있는 원소의 개수를 고정값으로 사용한다. 하지만 우리가 실제로 페이지에 저장하는 값은 가변적인 크기를 가지고 있기 때문에 필연적으로 물리적으로 저장해야할 개수가 다 차기도 전에 페이지가 넘치는 상황에 부딪히게 된다. 예를 들어 100KB 를 저장하는 페이지에 위와 같이 데이터를 저장한 상태이다. 여

Feb 26, 20262 min read49

Slotted Page

데이터베이스와 관련된 기술을 보다보면 어떻게 데이터를 관리하고 저장하지? 특히 단편화(Fragmentation) 이 일어나는 것을 어떻게 통제하고 관리할까? 혹은 정렬된 자료구조 내부에서 데이터의 순서를 보존하기 위해 어떠한 행위들을 할까? 궁금해집니다. 오늘은 조금 더 데이터베이스 내부에 쓰이는 자료구조를 들여다보며 연관된 행위를 공부해보려고 합니다. F

Feb 22, 20264 min read63
Slotted Page

MCP 를 통한 workflow 자동화

AI native 최근에 LinkedIn 이나 여러 소셜 플랫폼들의 글을 보면 AI native 회사 라는 워딩들이 많이 보입니다. IBM 의 정의에 따르면 AI native 를 아래와 같이 정의한다고 하는데요. “AI를 사고와 업무 방식에 끊임없이 내재화하는 상태” 그렇다면 팀원들이 계속해서 AI 를 사고와 업무 방식에 끊임 없이 내재화 하려면 어떻게 해야할까요? 개발자들은 이미 Claude code 나 Codex 등 여러 AI Tool...

Feb 14, 20263 min read100
D

dev_roach

41 posts

AST와 바이트코드: 파이썬의 이면 세계