[파이썬 100강] 98강. ast로 파이썬 코드를 분석하고 자동 점검 규칙 만들기

[파이썬 100강] 98강. ast로 파이썬 코드를 분석하고 자동 점검 규칙 만들기

코드를 사람이 눈으로만 리뷰하면 놓치는 패턴이 반드시 생깁니다. 특히 팀 규모가 커질수록 “이 금지 API는 왜 또 들어갔지?”, “print 디버깅 코드가 왜 운영 브랜치까지 왔지?” 같은 문제가 반복됩니다. 이때 **문자열 검색(grep)**만으로는 한계가 있습니다. 같은 단어라도 주석인지, 문자열인지, 실제 함수 호출인지 구분해야 하기 때문입니다.
이번 강의에서는 파이썬 표준 라이브러리 ast를 이용해 코드를 구문 트리 수준에서 분석하고, 실무에서 바로 쓸 수 있는 자동 점검 규칙을 만드는 방법을 다룹니다.

핵심 목표는 세 가지입니다. 첫째, AST가 무엇인지 감을 잡는다. 둘째, NodeVisitor/NodeTransformer로 검사·수정 자동화를 구현한다. 셋째, CI에 붙일 수 있을 정도로 재현 가능한 형태로 마무리한다.


핵심 개념

ast(Abstract Syntax Tree)는 파이썬 코드를 “문자열”이 아니라 “구조”로 해석한 결과입니다. 예를 들어 foo(1)은 단순 텍스트가 아니라 “함수 호출(Call) 노드”, “함수 이름(Name)”, “인자(Constant)”로 분해됩니다. 이 구조를 이용하면 코드 분석 정확도가 크게 올라갑니다.

실무에서 기억할 핵심은 아래 4가지입니다.

  • 파싱(parse): ast.parse(source)로 문자열 코드를 트리로 변환합니다.
  • 순회(visit): ast.NodeVisitor로 원하는 노드를 찾아 규칙 위반 여부를 검사합니다.
  • 변환(transform): ast.NodeTransformer로 특정 패턴을 자동 수정할 수 있습니다.
  • 재출력(unparse): Python 3.9+에서는 ast.unparse(tree)로 수정된 트리를 코드로 되돌릴 수 있습니다.

여기서 중요한 포인트는 “AST는 문법적으로 유효한 코드만 다룬다”는 점입니다. 즉, 구문 에러가 있는 소스는 파싱 단계에서 바로 실패합니다. 이 성질 덕분에 점검 도구를 만들 때도 “먼저 파싱 성공 여부 검사 → 규칙 검사” 순서로 설계하면 실패 지점을 명확히 나눌 수 있습니다.

기본 사용

예제 1) AST 트리 맛보기: 코드가 어떤 노드로 보이는지 확인

>>> import ast
>>> src = "x = 10\nprint(x)"
>>> tree = ast.parse(src)
>>> type(tree).__name__
'Module'
>>> [type(node).__name__ for node in tree.body]
['Assign', 'Expr']

해설: Module 아래에 대입문(Assign)과 표현식(Expr)이 들어 있습니다. 여기서 print(x)는 내부적으로 Expr(Call(...)) 구조를 가집니다. 문자열 검색으로는 “진짜 호출인지” 구분이 어렵지만 AST에서는 노드 타입으로 정확히 식별할 수 있습니다.

예제 2) 함수 호출 찾기: NodeVisitor로 금지 API 탐지

>>> import ast
>>> code = """
... import os
... os.system('ls')
... print('ok')
... """
>>> class CallCollector(ast.NodeVisitor):
...     def __init__(self):
...         self.calls = []
...     def visit_Call(self, node):
...         if isinstance(node.func, ast.Attribute):
...             name = f"{ast.unparse(node.func.value)}.{node.func.attr}"
...         elif isinstance(node.func, ast.Name):
...             name = node.func.id
...         else:
...             name = "<unknown>"
...         self.calls.append((name, node.lineno))
...         self.generic_visit(node)
...
>>> t = ast.parse(code)
>>> v = CallCollector(); v.visit(t)
>>> v.calls
[('os.system', 3), ('print', 4)]

해설: visit_Call은 모든 함수 호출 노드를 잡습니다. 이 정보를 기반으로 os.system, eval, exec 같은 금지 호출 규칙을 추가할 수 있습니다. lineno를 함께 기록해 리포트 품질을 높이는 것이 실무에서 중요합니다.

예제 3) 자동 수정: print를 logger.info로 바꾸기

>>> import ast
>>> source = "print('hello')\nprint(user_id)"
>>> class PrintToLogger(ast.NodeTransformer):
...     def visit_Call(self, node):
...         self.generic_visit(node)
...         if isinstance(node.func, ast.Name) and node.func.id == 'print':
...             node.func = ast.Attribute(
...                 value=ast.Name(id='logger', ctx=ast.Load()),
...                 attr='info',
...                 ctx=ast.Load(),
...             )
...         return node
...
>>> tree = ast.parse(source)
>>> new_tree = PrintToLogger().visit(tree)
>>> ast.fix_missing_locations(new_tree)
<ast.Module object at ...>
>>> ast.unparse(new_tree)
"logger.info('hello')\nlogger.info(user_id)"

해설: 규칙 위반 코드를 “검출”하는 수준을 넘어 “자동 교정”까지 가능합니다. 다만 실무에서는 바로 덮어쓰기보다 --fix 옵션으로 명시적 실행하도록 분리하는 것이 안전합니다.

예제 4) 실전형 미니 룰 엔진

>>> import ast
>>> sample = """
... import os
... eval("2+2")
... print('debug')
... """
>>> class RuleEngine(ast.NodeVisitor):
...     def __init__(self):
...         self.issues = []
...     def visit_Call(self, node):
...         name = ast.unparse(node.func)
...         if name in {'eval', 'exec', 'os.system'}:
...             self.issues.append((node.lineno, f"금지 호출: {name}"))
...         if name == 'print':
...             self.issues.append((node.lineno, "운영 코드 print 사용"))
...         self.generic_visit(node)
...
>>> tree = ast.parse(sample)
>>> engine = RuleEngine(); engine.visit(tree)
>>> engine.issues
[(3, '금지 호출: eval'), (4, '운영 코드 print 사용')]

해설: 단일 파일이라도 이렇게 구조화해 두면 CLI 도구로 확장하기 쉽습니다. 나중에는 파일 경로, 규칙 코드, 심각도(severity), 자동수정 가능 여부까지 함께 리턴하도록 발전시키면 됩니다.

자주 하는 실수

실수 1) 문자열 검색으로 함수 호출 판정하기

>>> text = """
... # eval("x") 는 금지
... s = "eval('1+1')"
... """
>>> 'eval(' in text
True

원인: 문자열 검색은 주석/문자열 리터럴/실제 코드 호출을 구분하지 못합니다. 그래서 오탐(False Positive)이 많아지고, 팀원들이 경고를 무시하게 됩니다.

해결:

>>> import ast
>>> src = "# eval('x')\ns = \"eval('1+1')\"\n"
>>> tree = ast.parse(src)
>>> found_real_eval = []
>>> class EvalFinder(ast.NodeVisitor):
...     def visit_Call(self, node):
...         if isinstance(node.func, ast.Name) and node.func.id == 'eval':
...             found_real_eval.append(node.lineno)
...         self.generic_visit(node)
...
>>> EvalFinder().visit(tree)
>>> found_real_eval
[]

AST 기반으로 검사하면 “실제 호출”만 정확히 잡을 수 있습니다.

실수 2) visit_* 내부에서 generic_visit를 호출하지 않음

>>> import ast
>>> src = "print(func(a))"
>>> class BrokenVisitor(ast.NodeVisitor):
...     def __init__(self):
...         self.count = 0
...     def visit_Call(self, node):
...         self.count += 1
...         # self.generic_visit(node) 누락
...
>>> t = ast.parse(src)
>>> b = BrokenVisitor(); b.visit(t)
>>> b.count
1

원인: 상위 호출만 세고 내부 중첩 호출(func(a))은 놓칩니다.

해결:

>>> class FixedVisitor(ast.NodeVisitor):
...     def __init__(self):
...         self.count = 0
...     def visit_Call(self, node):
...         self.count += 1
...         self.generic_visit(node)
...
>>> f = FixedVisitor(); f.visit(t)
>>> f.count
2

중첩 구조를 놓치지 않으려면 generic_visit를 습관적으로 호출해야 합니다.

실수 3) 파싱 실패를 규칙 위반으로 착각

  • 증상: 점검 도구가 “규칙 위반”으로 표시했는데 실제로는 파일 자체가 문법 에러였습니다.
  • 원인: SyntaxError를 분리 처리하지 않고 한 덩어리로 실패 처리했습니다.
  • 해결: 파싱 단계 오류는 별도 타입(PARSE_ERROR)로 리포트해 수정 우선순위를 명확히 합니다.

실무 패턴

실제 팀 환경에서 AST 점검기를 운영하려면 “룰 함수 몇 개”보다 운영 구조가 더 중요합니다.

  • 입력 검증 규칙

    • 파일 인코딩(UTF-8), 확장자(.py), 파일 크기 상한을 먼저 검사합니다.
    • 파싱 실패(SyntaxError)를 룰 위반과 분리해 결과 스키마를 다르게 저장합니다.
  • 로그/예외 처리 규칙

    • 최소 필드: file, line, rule_id, message, severity를 고정합니다.
    • 예외는 “파일 단위로 격리”해서 한 파일 실패가 전체 분석 중단으로 이어지지 않게 설계합니다.
  • 재사용 함수/구조화 팁

    • analyze_file(path) -> list[Issue]처럼 순수 함수 형태로 만들면 테스트가 쉬워집니다.
    • 룰마다 클래스를 나누기보다 초기에 rule_id + predicate 구조로 시작하고, 복잡도가 올라가면 클래스로 분리하세요.
  • 성능/메모리 체크 포인트

    • 수천 파일 분석 시 AST 객체를 오래 잡고 있지 말고 파일 단위로 바로 폐기합니다.
    • 멀티프로세싱/스레딩은 I/O 병목인지 CPU 병목인지 측정 후 적용합니다. (97강 내용과 연결)
  • CI 연결 패턴

    • 경고 개수 임계치(예: high severity 1개라도 있으면 실패)를 정책으로 명시합니다.
    • 개발 초반에는 “경고만 출력”, 안정화 후 “실패 처리”로 단계적 도입이 반발이 적습니다.

핵심은 점검기가 팀 생산성을 올려야 한다는 점입니다. 오탐이 많으면 아무도 신뢰하지 않고, 메시지가 불친절하면 수정 시간이 늘어납니다. 정확도와 설명 가능성을 같이 챙겨야 “실무에서 계속 쓰는 도구”가 됩니다.

오늘의 결론

한 줄 요약: ast는 코드 문자열을 구조로 바꿔, 자동 점검의 정확도와 유지보수성을 동시에 끌어올리는 가장 현실적인 표준 도구입니다.

기억할 것:

  • 문자열 검색보다 AST 노드 기반 판정이 정확합니다.
  • NodeVisitor는 검사, NodeTransformer는 자동 수정에 적합합니다.
  • 파싱 오류와 규칙 위반을 분리해야 리포트 품질이 올라갑니다.

연습문제

  1. subprocess.run(..., shell=True) 호출을 탐지하는 AST 규칙을 만들어 보세요. 탐지 시 파일 경로/라인/메시지를 출력하세요.
  2. print(...)를 찾되, 테스트 파일(test_*.py)에서는 허용하고 운영 코드에서만 경고가 나가도록 파일 경로 조건을 추가해 보세요.
  3. 하나의 디렉터리를 순회하면서 모든 .py 파일에 대해 점검을 수행하고, 최종 요약(총 파일 수/오류 수/경고 수)을 출력하는 CLI를 작성해 보세요.

이전 강의 정답

  1. ThreadPoolExecutor로 I/O 성격 작업 병렬 실행
>>> from concurrent.futures import ThreadPoolExecutor
>>> def fetch(x):
...     return f"done-{x}"
...
>>> with ThreadPoolExecutor(max_workers=3) as ex:
...     results = list(ex.map(fetch, [1, 2, 3]))
...
>>> results
['done-1', 'done-2', 'done-3']
  1. as_completed로 완료 순서대로 결과 수집
>>> from concurrent.futures import ThreadPoolExecutor, as_completed
>>> def work(x):
...     return x * x
...
>>> out = []
>>> with ThreadPoolExecutor(max_workers=2) as ex:
...     futures = [ex.submit(work, n) for n in [3, 1, 2]]
...     for f in as_completed(futures):
...         out.append(f.result())
...
>>> sorted(out)
[1, 4, 9]
  1. 실패 작업 예외를 수집해 전체 파이프라인 중단 방지
>>> from concurrent.futures import ThreadPoolExecutor
>>> def risky(x):
...     if x == 0:
...         raise ValueError('zero not allowed')
...     return 10 / x
...
>>> ok, fail = [], []
>>> with ThreadPoolExecutor(max_workers=3) as ex:
...     futures = [ex.submit(risky, n) for n in [2, 0, 5]]
...     for fu in futures:
...         try:
...             ok.append(fu.result())
...         except ValueError as e:
...             fail.append(str(e))
...
>>> ok
[5.0, 2.0]
>>> fail
['zero not allowed']

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 검증 명령 예시:
    • python - <<'PY' 블록으로 예제 코드 실행
    • python -m py_compile <대상파일>로 구문 검증
  • 재현 체크리스트:
    • ast.parse가 예제 문자열에서 정상 동작하는지
    • visit_Call 기준 탐지 결과(라인 번호 포함)가 문서와 일치하는지
    • NodeTransformer 적용 후 ast.unparse 출력이 기대 문자열과 일치하는지