AST 파싱과 구조 변환 — 텍스트 편집 대신 트리 변환으로 코드 자동화하기

Python ast 모듈을 이용한 구조적 코드 생성 원리와 실전 적용

정규식으로 코드를 긁어내는 대신, 언어가 파싱한 추상 구문 트리(AST)를 직접 다뤄 구조적 코드 변환을 수행하는 방법을 정리한다. AST의 정의, 텍스트 대비 우월성, Python ast 모듈 기본 API, 구조 변환의 6단계 파이프라인, 그리고 .py 클래스를 디버깅 노트북으로 자동 생성하는 구체 사례를 다룬다.

Engineering
Python
저자

Kwangmin Kim

공개

2026년 04월 13일

반복적 코드 편집 작업을 만나면 “사람이 일일이 타이핑할 일이 아니라 스크립트가 할 일”이라는 생각이 먼저 떠올라야 한다. 다만 정규식이나 단순 문자열 치환으로 접근하면 들여쓰기·주석·문자열 리터럴 같은 예외에 걸려 부정확해진다. 이럴 때 꺼내야 할 도구가 추상 구문 트리(Abstract Syntax Tree, AST)다. 이 포스트는 AST의 원리부터 Python ast 모듈의 실전 활용, 그리고 실제 프로젝트에서 .py 클래스를 두 종류의 디버깅 노트북으로 자동 변환한 사례까지 다룬다.

1 정의

정의: Abstract Syntax Tree (AST)

AST는 소스 코드의 문법 구조를 트리 형태로 표현한 자료구조다. 파서(parser)가 소스 텍스트를 읽어 각 문법 요소(클래스 정의, 함수, 식, 문, 리터럴 등)를 노드로 만들고, 노드 간 포함 관계를 간선으로 연결한다.

  • 핵심: 텍스트가 아니라 구조화된 트리다 (각 노드에 타입·속성·자식 노드 정보)
  • 특징: 문법이 보장하는 정확한 구조 — 주석·공백·문자열 리터럴 내부 등의 노이즈가 제거된 본질만 남는다
  • 용도: 컴파일러·린터·포매터·정적 분석 도구·리팩토링 도구가 모두 AST 위에서 동작한다

Python은 표준 라이브러리 ast 모듈로 이 기능을 그대로 노출한다. ast.parse(source_text)를 호출하면 루트 노드 Module을 반환하고, 자식 노드를 순회하면 최상위 선언(import, 클래스, 함수)을 타입별로 분류할 수 있다.

2 개념 및 원리

2.1 AST 노드의 기본 구조

Python ast 모듈의 주요 노드 타입과 속성은 다음과 같다.

노드 타입 의미 주요 속성
ast.Module 파일 전체 (루트) body (최상위 선언 리스트)
ast.ClassDef 클래스 정의 name, bases, body, decorator_list, lineno, end_lineno
ast.FunctionDef 함수/메서드 정의 name, args, body, decorator_list, lineno, end_lineno
ast.Assign 할당문 targets, value, lineno, end_lineno
ast.Import / ast.ImportFrom import 문 names, module, lineno
ast.Call 함수 호출 func, args, keywords
ast.Attribute 속성 접근 (self.x) value, attr

라인 번호 속성이 핵심이다. lineno(시작 줄)와 end_lineno(끝 줄)를 이용하면 원본 소스에서 해당 노드의 텍스트 조각을 들여쓰기까지 그대로 추출할 수 있다.

import ast

source = open("my_module.py").read()
lines = source.splitlines(keepends=True)
tree = ast.parse(source)

for node in tree.body:
    if isinstance(node, ast.ClassDef):
        # 해당 클래스의 원본 텍스트 조각
        snippet = "".join(lines[node.lineno - 1 : node.end_lineno])
        print(f"Class {node.name}:")
        print(snippet)

2.2 구조 변환 (Structural Transformation)의 개념

AST 기반 코드 자동화 작업의 본질은 “텍스트 편집”이 아니라 “구조 변환”이다. 소스 코드를 트리로 파싱 → 노드별로 원하는 조각만 추출 → 조각에 단순 치환 규칙 적용 → 원하는 형식으로 재조립한다. 이 관점으로 보면 파이프라인이 명확해진다.

소스 코드 (.py)
    ↓  ast.parse
AST (트리)
    ↓  노드 필터링 (ClassDef, FunctionDef, Assign 등)
관심 노드 목록
    ↓  lineno / end_lineno로 원본 슬라이스
원본 텍스트 조각들
    ↓  단순 치환 규칙 (self.X → state.X 등)
변환된 조각들
    ↓  템플릿에 조립
출력물 (.ipynb, .md, .py 등)

3 왜 필요한가

3.1 정규식·텍스트 치환의 한계

“클래스 메서드 찾기”를 정규식으로 하려면 “4칸 들여쓰기로 시작하는 def 같은 휴리스틱을 써야 한다. 이 접근은 다음 상황에서 모두 깨진다.

  • 문자열 리터럴 안의 def (예: msg = "def some_method():")
  • 여러 줄에 걸친 데코레이터 (@dataclass 다음 줄의 class)
  • 조건문 아래 들어간 메서드 (들여쓰기 깊이가 달라짐)
  • 주석 처리된 코드
  • 들여쓰기가 탭/스페이스 혼재

AST는 문법이 보장하는 정확한 구조를 제공하므로 이런 코너 케이스가 애초에 발생하지 않는다. 문자열 리터럴 안의 defast.Constant 노드일 뿐 FunctionDef가 아니다. 데코레이터는 decorator_list에 명시적으로 구분되어 있다.

3.2 구체적 비교

# 정규식 접근 — 부정확
import re
methods = re.findall(r"^    def (\w+)", source, re.MULTILINE)
# 문제: 문자열 리터럴, 중첩 클래스 등에서 오작동

# AST 접근 — 정확
import ast
tree = ast.parse(source)
for cls in ast.walk(tree):
    if isinstance(cls, ast.ClassDef):
        methods = [m.name for m in cls.body if isinstance(m, ast.FunctionDef)]

AST 접근은 “메서드는 ClassDef의 body에 있는 FunctionDef 노드”라는 문법적 사실을 직접 표현한다. 휴리스틱이 없으니 오작동할 여지도 없다.

4 응용 분야

AST 기반 구조 변환은 다음 작업에서 특히 강력하다.

  • 코드 생성(codegen): 템플릿 소스에서 변형된 새 코드 자동 생성
  • 리팩토링 도구: 변수명 일괄 변경, API 시그니처 변경, 메서드 추출·인라인
  • 정적 분석: 코드 스멜 검출, 복잡도 측정, 보안 취약점 스캔
  • 문서화 자동화: 함수 시그니처·docstring 추출하여 API 문서 생성
  • 테스트 생성: 함수 정의로부터 테스트 스텁 자동 생성
  • 코드 마이그레이션: 라이브러리 API 변경 대응 일괄 업데이트 (printprint() Python 2→3 전환 등)
  • 노트북 빌더: .py 모듈을 셀 단위 .ipynb로 분해 (이 포스트의 사례)

공통점은 “사람이 읽기 위한 텍스트 편집이 아니라, 기계가 다루기 좋은 구조에서 기계가 다루기 좋은 또 다른 구조로 옮기는 작업”이라는 점이다. 이 프레이밍이 떠오르면 AST가 정답일 가능성이 높다.

5 예시: .py 클래스를 디버깅 노트북으로 자동 변환

5.1 문제 상황

src/data_preparation/02_rule_based_domain_data_generator.py848줄 거대 클래스(EnhancedDomainGenerator)가 있다고 하자. 메서드가 40개가 넘고, 각 메서드는 서로 호출하며 self.existing_domains, self.standard_words, self.domain_groups 같은 인스턴스 상태를 공유한다.

바이브 코딩으로 .py부터 만든 뒤 디버깅 과정에서 .ipynb를 작성하다 보니, 노트북은 전체 로직을 담지 않고 .py의 클래스를 import해 호출하는 래퍼만 됐다. 결과적으로 각 메서드를 셀 단위로 쪼개 실행·수정·재시도하는 Jupyter 디버깅 플로우가 불가능한 상태다.

요구사항은 다음과 같다.

  1. 원본 .py 클래스는 그대로 유지 (이후 자동화 파이프라인에서 그대로 사용)
  2. 동일 로직을 두 가지 형태 노트북으로 재구성
    • (A) 클래스 분해판: 클래스 껍데기는 유지하되, 각 메서드가 독립 셀에 들어가 개별 재실행 가능
    • (B) 독립 함수판: 클래스 없이 모든 메서드를 모듈 레벨 함수로 풀어놓고, 상태는 state: SimpleNamespace로 통합

손으로 하면 80개 이상의 셀을 한 땀 한 땀 붙여넣어야 한다. 그것도 두 가지 형태로. 복사하면서 selfstate 치환, 클래스 상수 환원, 데코레이터 누락 처리 등 자잘한 실수가 누적된다.

5.2 6단계 파이프라인

앞에서 다룬 구조 변환 파이프라인을 이 구체 사례에 적용한다.

5.2.1 1단계: 소스 파싱

import ast
source = open("02_rule_based_domain_data_generator.py").read()
lines = source.splitlines(keepends=True)
tree = ast.parse(source)

트리의 자식들을 순회하면 최상위 선언(import, 클래스, 함수)을 타입별로 분류할 수 있다.

imports = []
class_defs = {}
for node in tree.body:
    if isinstance(node, (ast.Import, ast.ImportFrom)):
        imports.append(node)
    elif isinstance(node, ast.ClassDef):
        class_defs[node.name] = node

클래스 본문(class_node.body)을 다시 순회해 메서드·상수를 분리한다.

main_class = class_defs["EnhancedDomainGenerator"]
methods = [n for n in main_class.body if isinstance(n, ast.FunctionDef)]
class_constants = [n for n in main_class.body if isinstance(n, ast.Assign)]
init_method = next(m for m in methods if m.name == "__init__")
other_methods = [m for m in methods if m.name != "__init__"]

5.2.2 2단계: 원본 소스 조각 추출

각 AST 노드의 lineno / end_lineno로 원본 텍스트를 슬라이스한다.

def extract_source(node, lines):
    # 데코레이터가 있으면 시작 줄을 보정한다
    start = node.lineno
    if node.decorator_list:
        start = min(start, min(d.lineno for d in node.decorator_list))
    return "".join(lines[start - 1 : node.end_lineno])
주의: 데코레이터 라인 보정

ast.ClassDef.linenoclass 키워드가 있는 줄을 가리킨다. @dataclass 같은 데코레이터는 decorator_list에 별도로 들어 있어 그대로 슬라이스하면 누락된다.

# WRONG: 데코레이터가 누락됨
snippet = "".join(lines[node.lineno - 1 : node.end_lineno])

# CORRECT: 데코레이터 시작 줄로 보정
start = node.lineno
if node.decorator_list:
    start = min(start, min(d.lineno for d in node.decorator_list))
snippet = "".join(lines[start - 1 : node.end_lineno])

이 디테일을 놓치면 StandardWord@dataclass가 누락되어 노트북에서 StandardWord() takes no arguments 에러로 조용히 실패한다.

5.2.3 3단계: 클래스 분해판(A) 생성

전략은 “클래스 껍데기는 한 셀에서 정의하고, 메서드는 이후 셀에서 monkey-patch로 주입한다”이다. 파이썬의 동적 속성 바인딩 특성을 이용하면, 클래스 정의 후에도 외부에서 메서드를 덧붙일 수 있다.

# 셀 1 — 클래스 껍데기
class EnhancedDomainGenerator:
    DIVERSE_FORMAT_WORDS = ["..."]  # 클래스 상수

    def __init__(self, ...):
        # init 본문
        ...

# 셀 2 — 메서드 monkey-patch
def _extract_last_word(self, term):
    return term.split()[-1]

EnhancedDomainGenerator._extract_last_word = _extract_last_word

# 셀 3 — 다음 메서드 monkey-patch
def generate_description(self, ...):
    ...

EnhancedDomainGenerator.generate_description = generate_description

이 패턴의 장점:

  • 각 메서드가 완전히 독립된 셀에 들어간다
  • 특정 메서드 로직을 수정한 뒤 그 셀만 재실행하면 클래스 전체가 즉시 업데이트된다
  • 원본 .py와 시맨틱이 100% 동일 (문자 그대로 같은 메서드 본문)

추출한 메서드 소스를 그대로 넣으면 클래스 본문의 4칸 들여쓰기가 남아 모듈 레벨에서 SyntaxError가 난다. 간단한 dedent 유틸로 전체를 4칸 왼쪽으로 밀어주면 해결된다.

import textwrap
method_source = extract_source(method_node, lines)
method_source_dedented = textwrap.dedent(method_source)

5.2.4 4단계: 독립 함수판(B) 생성

흥미로운 부분이다. 목표는 self를 없애고 모든 상태를 state: SimpleNamespace에 통합하는 것. 수작업으로 40개 메서드를 한 줄씩 고치는 대신, 치환 규칙 3개로 압축된다.

메서드 본문에서 self.X 사용 패턴은 사실상 세 가지뿐이다.

  1. 인스턴스 속성 참조self.existing_domains, self.standard_words
  2. 클래스 상수 참조self.DIVERSE_FORMAT_WORDS (대문자 상수)
  3. 다른 메서드 호출self._extract_last_word(term)

각각에 대응하는 변환 규칙:

패턴 변환 근거
self.X state.X 인스턴스 상태를 state 네임스페이스에 통합
self.CONSTANT CONSTANT 클래스 상수를 모듈 레벨로 환원
self.method_name(args) method_name(state, args) 메서드를 모듈 함수로 변환, state를 첫 인자로 주입

어떤 이름이 클래스 상수인지는 2단계에서 ast.Assign 노드로 이미 수집했다. 메서드 이름도 FunctionDef 노드로 수집했다. 따라서 치환 시점에는 이 두 집합을 참조만 하면 된다.

constants = {c.targets[0].id for c in class_constants if isinstance(c.targets[0], ast.Name)}
method_names = {m.name for m in methods}

def transform_body(src):
    # 순서 중요: 상수/메서드 → state 우선 환원
    for const in constants:
        src = src.replace(f"self.{const}", const)
    for m in method_names:
        src = re.sub(rf"self\.{m}\(", f"{m}(state, ", src)
    src = src.replace("self.", "state.")
    src = src.replace(", )", ")")  # 빈 인자 정리
    return src
치환 순서가 중요하다

self.state. 를 먼저 적용한 뒤 state.CONSTANT 중 클래스 상수인 것만 되돌리는 방식도 가능하나, 메서드 이름이 대문자로 시작하는 경우(드물지만 가능) 상수 오판 위험이 있다. 상수·메서드를 먼저 처리한 뒤 남은 self.state. 로 바꾸는 순서가 안전하다.

메서드 시그니처의 def method(self, ...)def method(state, ...) 치환은 첫 줄에서만 selfstate 로 한 번 바꾸면 된다. 본문과 시그니처를 분리해서 처리하는 이유는 본문 안에 문자열 리터럴 "self" 같은 다른 패턴이 드물게 존재할 수 있기 때문이다.

__init__ 본문도 같은 규칙을 적용해 _init_state(state) 모듈 함수로 감싼다. 단, 클래스 블록에서 모듈 함수로 이동하는 것이므로 들여쓰기를 한 단계 더 조정한다 — dedent로 8칸을 벗겨 모듈 레벨로 만든 뒤 다시 4칸 들여 함수 본문으로 밀어넣는다.

5.2.5 5단계: 노트북 JSON 조립

.ipynb 파일은 단순한 JSON이다. 셀 리스트를 직접 만들어 json.dumps하면 된다.

import json

def make_code_cell(source_text):
    return {
        "cell_type": "code",
        "metadata": {},
        "source": source_text.splitlines(keepends=True),
        "outputs": [],
        "execution_count": None,
    }

def make_markdown_cell(text):
    return {
        "cell_type": "markdown",
        "metadata": {},
        "source": text.splitlines(keepends=True),
    }

notebook = {
    "cells": [...],
    "metadata": {"kernelspec": {"name": "python3", "display_name": "Python 3"}},
    "nbformat": 4,
    "nbformat_minor": 5,
}
with open("debug.ipynb", "w") as f:
    json.dump(notebook, f, indent=2)

핵심은 source“문자열”이 아니라 “줄별 문자열의 리스트”라는 점. text.splitlines(keepends=True) 한 줄이면 해결된다.

5.2.6 6단계: 노트북 실행 컨텍스트 보정

원본 .py 클래스는 데이터 파일 경로를 Path(__file__).parent.parent.parent / "domain_dictionary" / ...로 계산한다. 이 코드가 노트북에서 동작하려면 __file__이 원본 .py 경로를 가리켜야 한다. 노트북에는 __file__이 정의돼 있지 않거나 엉뚱한 경로이므로, 노트북 초입에 한 줄만 주입한다.

# 노트북 첫 셀에 추가
from pathlib import Path
__file__ = str((Path.cwd().parents[1] / "src" / "data_preparation" / "02_rule_based_domain_data_generator.py").resolve())

이 한 줄이 있으면 클래스 내부의 파일 경로 계산 코드가 스크립트로 실행할 때와 똑같이 작동한다. 원본 클래스를 수정하지 않은 채로 실행 환경만 맞춰주는 방식이다.

6 이 접근이 왜 안전한가

6.1 구조 정보를 텍스트에서 재구성하지 않는다

정규식 “4칸 들여쓰기 def” 같은 휴리스틱은 문자열 리터럴·데코레이터·조건부 메서드 등에서 깨진다. AST는 문법이 보장하는 정확한 구조를 제공하므로 이런 코너 케이스가 애초에 발생하지 않는다.

6.2 로직은 건드리지 않고 이동만 한다

치환 규칙(self.X → state.X, self.method(...) → method(state, ...))은 의미 보존 변환이다. 클래스 기반 코드를 함수 기반으로 옮긴 뒤에도 각 연산이 읽고 쓰는 데이터는 물리적으로 동일한 객체다 (state.existing_domainsself.existing_domains와 같은 set 인스턴스). 따라서 두 노트북 실행 결과가 원본 .py와 수치로 일치하는지 검증 가능하다. 실제 프로젝트에서 세 곳(원본 .py, 분해판 노트북, 함수판 노트북) 모두 “총 2,800개 도메인, 14그룹 × 200, 재로드 검증 통과”를 얻었다.

6.3 원본은 불변

빌더 스크립트는 .py를 읽기만 한다. 쓰기는 .ipynb에만 일어난다. 따라서 빌더를 몇 번 다시 돌려도 원본이 드리프트할 위험이 없고, 원본을 수정한 뒤 빌더를 재실행하면 노트북도 자동으로 최신 상태가 된다. 이것이 “코드 생성기(generator)의 단방향 불변성”이라는 속성이며, 리팩토링과 디버깅이 반복되는 상황에서 가장 강력한 특성이다.

7 재사용 가능한 패턴

이 기법은 “클래스 → 셀 분해 노트북” 변환에 국한되지 않는다. 동일한 뼈대로 수행 가능한 작업들:

  • 긴 테스트 파일을 개별 셀 테스트로 풀기
  • .py 모듈의 함수 시그니처·docstring을 Markdown API 문서로 추출
  • 여러 모듈에 흩어진 함수를 AST로 찾아 요약 테이블로 정리
  • 리팩토링 전후의 함수 목록·시그니처 diff
  • 라이브러리 API 변경 대응 일괄 업데이트 (예: deprecated 함수 이름 일괄 치환)
  • 코드 스멜 탐지 (너무 긴 함수, 중복 로직 패턴)

공통점: 입력이 문법적으로 구조화되어 있으므로 기계가 다룰 수 있다. “내가 일일이 타이핑할 것”이 아니라 “입력이 구조화되어 있으니 생성 가능한 것”으로 프레임을 전환하면 작업 시간이 극적으로 단축된다. 사람의 시간은 규칙을 설계하는 데 쓰고, 실행은 기계에 맡긴다.

8 회고

처음 .py → .ipynb 변환 요구를 받았을 때는 “메서드 80개를 셀로 나누고 또 독립 함수로도 만들어야 한다”는 작업량에 눌려 “하루 종일 걸릴 수 있다”고 범위를 좁히려 들었다. 그러나 실제로는 빌더 스크립트 하나가 10분 만에 두 노트북을 동시에 생성했고, @dataclass 누락·__file__ 미정의·들여쓰기 오류 같은 실수도 빌더를 수정해 재실행하는 것으로 수 초 안에 복구됐다.

반복되는 구조적 작업을 만났을 때 “내가 일일이 타이핑해야 할 것”이 아니라 “입력이 구조화되어 있으니 생성 가능한 것”으로 프레임을 전환하는 습관이 필요하다. 정규식이 깨질 조짐이 보이면 AST를 꺼내자.

9 관련 주제

10 참고 자료

  • Python 공식 문서: ast — Abstract Syntax Trees
  • PEP 617: New PEG parser for CPython (파서 내부 동작 이해)
  • astor, libcst 등 AST 조작 고수준 라이브러리 (복잡한 리팩토링 시 검토)

Subscribe

Enjoy this blog? Get notified of new posts by email: