ChatGPT나 Claude에게 “다운로드 폴더 정리 스크립트 짜 줘”라고 하면 몇 초 만에 그럴듯한 코드가 나옵니다. 문제는 그 코드를 끝까지 읽지 않고 바로 실행하는 습관입니다. 수십 줄 중 한 줄에 shutil.rmtree나 shell=True가 숨어 있으면, 실행하고 나서야 알게 됩니다. 이 글에서는 AI 코드 검증의 첫 단계로, 코드를 실행하지 않고 위험한 줄만 골라 줄 번호와 함께 알려 주는 점검 스크립트를 파이썬 표준 라이브러리 ast로 만들어 봅니다. 이 노트북(윈도우 11, Python 3.12.10)에서 직접 돌려 보니, 처음 시도한 단어 검색 방식은 from os import system as run으로 이름을 바꾼 셸 실행을 그대로 놓쳤고, ast 방식은 같은 파일을 0.06초 만에 잡아냈습니다.
시험 대상: 위험한 줄이 섞인 샘플 4개
먼저 밝혀 둘 점이 있습니다. 아래 샘플은 AI가 실제로 만든 코드가 아니라, AI에게 흔히 하는 요청(“다운로드 폴더 정리”, “백업 후 업로드”, “설정 파일 읽기”)에서 나올 법한 형태를 제가 직접 써서 만든 테스트 파일입니다. 점검기는 이 파일들을 읽기만 하고 실행하지 않습니다. 그중 하나는 이렇습니다.
# 테스트용 샘플: 백업 후 업로드하는 코드. 별칭 import로 위험 함수 이름이 바뀐 경우
# 점검기는 이 파일을 읽기만 하고 실행하지 않습니다.
import subprocess as sp
from os import system as run
import requests
folder = input("백업할 폴더: ")
run("tar -czf backup.tgz " + folder)
sp.run(f"robocopy {folder} D:\\backup /MIR", shell=True)
with open("backup.tgz", "rb") as f:
requests.post("https://example.com/upload", files={"file": f})
9행의 run(...)은 이름만 보면 평범해 보이지만 실제로는 os.system입니다. 10행은 사용자가 입력한 폴더 이름을 그대로 셸 명령에 이어 붙입니다. 나머지 샘플에는 shutil.rmtree와 os.remove가 든 정리 코드, eval과 pickle.load가 든 설정 읽기 코드, 위험한 동작이 전혀 없는 CSV 집계 코드를 넣었습니다.
첫 시도: 단어 검색은 엉뚱한 줄을 잡습니다
가장 쉬운 방법은 위험한 단어가 들어간 줄을 찾는 것입니다. 10줄짜리로 짰습니다.
"""첫 시도: 위험한 단어가 들어간 줄을 문자열 검색으로 찾기"""
import sys
from pathlib import Path
WORDS = ["rmtree", "os.remove", "os.system", "subprocess", "eval(", "exec(", "pickle.load"]
for path in sys.argv[1:]:
for no, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
hits = [w for w in WORDS if w in line]
if hits:
print(f"{path}:{no}: {', '.join(hits)} | {line.strip()}")
$ python check_grep.py samples/*.py
samples/cleanup_downloads.py:9: rmtree | # 주의: rmtree를 쓰면 폴더째 지워집니다 (주석 속 단어)
samples/cleanup_downloads.py:10: rmtree | print("rmtree 없이 안전하게 정리합니다")
samples/cleanup_downloads.py:14: rmtree | shutil.rmtree(item)
samples/cleanup_downloads.py:16: os.remove | os.remove(item)
samples/backup_and_upload.py:3: subprocess | import subprocess as sp
samples/config_loader.py:6: eval( | return eval(text)
samples/config_loader.py:10: pickle.load | return pickle.load(f)
결과는 기대와 달랐습니다. 9행 주석과 10행 print 문자열 안의 “rmtree”라는 글자가 걸렸고, 정작 별칭으로 부른 run("tar ..."), shell=True, requests.post는 하나도 잡지 못했습니다. subprocess는 import 줄에서만 걸렸습니다. 글자를 비교하는 방식으로는 “이 줄이 실제로 무엇을 호출하는가”를 알 수 없습니다.
ast로 만든 점검기 전체 코드
파이썬은 코드를 실행하기 전에 먼저 문법 트리(AST)로 바꿉니다. ast.parse로 그 트리만 만들면 코드를 실행하지 않고도 “함수 호출” 노드만 골라 볼 수 있습니다. 주석은 트리에 아예 없고 문자열은 문자열 노드라서 오탐이 생기지 않습니다. 아래가 최종본입니다.
"""파이썬 코드를 실행하지 않고 위험한 호출을 찾아 줄 번호와 함께 알려 줍니다.
사용법: python check_ast.py 파일또는폴더 [...]
종료 코드: 위험(HIGH)이 하나라도 있으면 1, 없으면 0
"""
import ast
import sys
from pathlib import Path
# 정식 이름 -> (등급, 설명)
RULES = {
"shutil.rmtree": ("HIGH", "폴더를 통째로 삭제"),
"os.remove": ("HIGH", "파일 삭제"),
"os.unlink": ("HIGH", "파일 삭제"),
"os.rmdir": ("MID", "빈 폴더 삭제"),
"os.system": ("HIGH", "셸 명령 실행"),
"os.popen": ("HIGH", "셸 명령 실행"),
"subprocess.run": ("MID", "외부 프로그램 실행"),
"subprocess.call": ("MID", "외부 프로그램 실행"),
"subprocess.Popen": ("MID", "외부 프로그램 실행"),
"subprocess.check_output": ("MID", "외부 프로그램 실행"),
"eval": ("HIGH", "문자열을 코드로 실행"),
"exec": ("HIGH", "문자열을 코드로 실행"),
"__import__": ("MID", "이름을 계산해서 모듈 import"),
"pickle.load": ("MID", "신뢰할 수 없는 파일이면 코드 실행 가능"),
"pickle.loads": ("MID", "신뢰할 수 없는 데이터면 코드 실행 가능"),
"requests.post": ("MID", "외부로 데이터 전송"),
"requests.put": ("MID", "외부로 데이터 전송"),
"shutil.move": ("MID", "이동(원래 위치에서 사라짐)"),
"os.rename": ("MID", "이름 변경(대상이 있으면 덮어쓰거나 오류)"),
"os.replace": ("MID", "대상 파일을 덮어씀"),
}
# 어떤 객체의 메서드든 이 이름이면 표시 (Path 객체 등은 정식 이름을 알 수 없어서)
METHOD_RULES = {
"unlink": ("HIGH", "파일 삭제(Path.unlink)"),
"rmdir": ("MID", "빈 폴더 삭제(Path.rmdir)"),
"write_text": ("LOW", "기존 파일을 덮어씀"),
"write_bytes": ("LOW", "기존 파일을 덮어씀"),
}
class Checker(ast.NodeVisitor):
def __init__(self):
self.alias = {} # 코드 안의 이름 -> 정식 이름 (import 별칭 추적)
self.found = []
def visit_Import(self, node):
for a in node.names:
self.alias[a.asname or a.name] = a.name
self.generic_visit(node)
def visit_ImportFrom(self, node):
for a in node.names:
self.alias[a.asname or a.name] = f"{node.module or ''}.{a.name}".lstrip(".")
self.generic_visit(node)
def visit_Assign(self, node):
# delete = os.remove 처럼 위험 함수를 변수에 담으면 그 변수도 별칭으로 기억
name = self.full_name(node.value)
if name in RULES:
for t in node.targets:
if isinstance(t, ast.Name):
self.alias[t.id] = name
self.generic_visit(node)
def full_name(self, func):
"""sp.run -> subprocess.run, run -> os.system 처럼 정식 이름으로 되돌림"""
if isinstance(func, ast.Name):
return self.alias.get(func.id, func.id)
if isinstance(func, ast.Attribute):
base = self.full_name(func.value)
return f"{base}.{func.attr}" if base else None
return None
def add(self, node, level, name, why):
self.found.append((node.lineno, level, name, why))
def visit_Call(self, node):
name = self.full_name(node.func)
if name in RULES:
level, why = RULES[name]
self.add(node, level, name, why)
elif isinstance(node.func, ast.Attribute) and node.func.attr in METHOD_RULES:
level, why = METHOD_RULES[node.func.attr]
self.add(node, level, "." + node.func.attr, why)
# shell=True 는 등급을 올림
for kw in node.keywords:
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
self.add(node, "HIGH", "shell=True", "명령 문자열을 셸이 해석")
# getattr(모듈, 계산된 이름) 은 무엇을 부르는지 정적으로 알 수 없음
if name == "getattr" and len(node.args) >= 2 and not isinstance(node.args[1], ast.Constant):
self.add(node, "MID", "getattr(동적 이름)", "어떤 함수를 부르는지 실행 전엔 모름")
# open(..., "w") 는 기존 파일을 덮어씀
if name == "open" and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
if "w" in str(node.args[1].value):
self.add(node, "LOW", "open(w)", "같은 이름 파일이 있으면 덮어씀")
self.generic_visit(node)
def check_file(path):
# 문자열이 아니라 바이트로 넘기면 파일 첫 줄의 '# -*- coding: ... -*-' 선언을 파서가 따릅니다
src = Path(path).read_bytes()
try:
tree = ast.parse(src, filename=str(path))
except (SyntaxError, ValueError) as e:
return [(getattr(e, "lineno", 0) or 0, "ERROR", type(e).__name__, str(e).splitlines()[0])]
c = Checker()
c.visit(tree)
return sorted(c.found)
def main(args):
files = []
for a in args:
p = Path(a)
files += sorted(p.rglob("*.py")) if p.is_dir() else [p]
count = {"HIGH": 0, "MID": 0, "LOW": 0, "ERROR": 0}
for f in files:
for lineno, level, name, why in check_file(f):
print(f"{f}:{lineno}: [{level}] {name} - {why}")
count[level] += 1
print(f"검사 {len(files)}개 파일, " + ", ".join(f"{k} {v}건" for k, v in count.items()))
return 1 if count["HIGH"] or count["ERROR"] else 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main(sys.argv[1:]))
핵심은 세 가지입니다.
visit_Import,visit_ImportFrom에서sp → subprocess,run → os.system처럼 별칭을 기록해 두고, 호출을 만날 때 정식 이름으로 되돌린 뒤 규칙표와 비교합니다.shell=True, 계산된 이름을 쓰는getattr, 쓰기 모드open처럼 함수 이름만으로 판단할 수 없는 경우는 인자까지 봅니다.- HIGH나 ERROR가 하나라도 있으면 종료 코드 1을 돌려줍니다. 배치 파일이나 다른 스크립트에서 이 값으로 실행 여부를 정할 수 있습니다.
$ python check_ast.py samples
samples\backup_and_upload.py:9: [HIGH] os.system - 셸 명령 실행
samples\backup_and_upload.py:10: [HIGH] shell=True - 명령 문자열을 셸이 해석
samples\backup_and_upload.py:10: [MID] subprocess.run - 외부 프로그램 실행
samples\backup_and_upload.py:13: [MID] requests.post - 외부로 데이터 전송
samples\cleanup_downloads.py:14: [HIGH] shutil.rmtree - 폴더를 통째로 삭제
samples\cleanup_downloads.py:16: [HIGH] os.remove - 파일 삭제
samples\cleanup_downloads.py:18: [LOW] open(w) - 같은 이름 파일이 있으면 덮어씀
samples\config_loader.py:6: [HIGH] eval - 문자열을 코드로 실행
samples\config_loader.py:10: [MID] pickle.load - 신뢰할 수 없는 파일이면 코드 실행 가능
samples\config_loader.py:12: [MID] __import__ - 이름을 계산해서 모듈 import
samples\config_loader.py:13: [MID] getattr(동적 이름) - 어떤 함수를 부르는지 실행 전엔 모름
검사 4개 파일, HIGH 5건, MID 5건, LOW 1건, ERROR 0건
exit: 1, 소요 0.06초
같은 샘플에서 주석·문자열 오탐이 사라졌고, 별칭으로 부른 os.system과 subprocess.run을 정식 이름으로 보여 줍니다. "o" + "s"처럼 이름을 계산하는 __import__, getattr은 무엇을 부르는지 정적으로 알 수 없으니 MID로 “사람이 볼 것”이라고 표시합니다. 위험한 동작이 없는 집계 코드는 0건이었습니다.
처음 난 오류: 인코딩 선언이 다른 파일에서 멈춤
샘플 4개로는 부족해서, 대량으로 돌려 보려고 이 노트북의 파이썬 설치 폴더 Lib 전체(.py 4,600개, 71.6MB)를 넣었습니다. 여기에는 표준 라이브러리와 함께 pandas·numpy·pip 같은 설치된 패키지(site-packages 2,793개)도 들어 있습니다. 26초쯤 돌다가 멈췄습니다.
$ python check_ast.py C:/Users/HK/AppData/Local/Programs/Python/Python312/Lib
...
src = Path(path).read_text(encoding="utf-8")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 89: invalid continuation byte
exit: 1, 소요 26.09초
# utf-8로 읽히지 않던 파일
Lib\test\encoded_modules\module_iso_8859_1.py (# -*- encoding: iso-8859-1 -*-)
Lib\test\encoded_modules\module_koi8_r.py (# -*- encoding: koi8-r -*-)
Lib\test\tokenizedata\badsyntax_pep3120.py (일부러 깨 둔 테스트 파일)
첫 버전은 모든 파일을 read_text(encoding="utf-8")로 읽었는데, 파이썬 파일은 첫 줄에 # -*- coding: ... -*-를 적어 다른 인코딩을 쓸 수 있습니다. 해결은 간단했습니다. 문자열 대신 read_bytes()로 읽은 바이트를 ast.parse에 그대로 넘기면, 파서가 그 선언을 보고 알아서 해석합니다. 문법 오류가 있는 파일은 SyntaxError를 잡아 ERROR 등급으로 보고하게 했습니다. 수정 후 ERROR가 9건 나왔는데, 전부 파이썬2 문법이나 잘못된 인코딩 이름처럼 일부러 깨 둔 테스트 데이터였습니다. AI가 준 코드에서 ERROR가 나오면 애초에 실행되지 않을 코드라는 뜻이니 그것도 실행 전에 아는 편이 낫습니다.
두 번째 문제: 함수를 변수에 담으면 못 잡음
점검기를 피해 가는 코드도 만들어 봤습니다.
# 점검기가 놓치는지 시험하는 샘플 (실행하지 않음)
import os
import shutil
from pathlib import Path
delete = os.remove # 함수를 변수에 담았다가 부르기
delete("old.txt")
shutil.move("data", "C:/Windows/Temp/data") # 이동(원본이 사라짐)
Path("settings.json").write_text("{}") # 기존 파일 덮어쓰기
os.rename("a.txt", "b.txt") # b.txt가 있으면 윈도우에선 오류, 리눅스에선 덮어씀
$ python check_ast.py samples_bypass
검사 1개 파일, HIGH 0건, MID 0건, LOW 0건, ERROR 0건
exit: 0, 소요 0.06초
네 줄 모두 파일을 지우거나 옮기거나 덮어쓰는데, 0건이었습니다. delete = os.remove 다음 delete("old.txt")는 호출 이름이 delete라서 규칙표에 없고, 나머지 세 함수는 규칙 자체가 없었습니다. 그래서 두 가지를 고쳤습니다. visit_Assign에서 위험 함수를 변수에 담으면 그 변수도 별칭으로 기억하게 했고, shutil.move·os.rename·os.replace를 규칙에 넣었습니다. Path 객체의 메서드처럼 객체 종류를 알 수 없는 경우는 unlink, write_text 같은 메서드 이름만 보고 표시합니다.
$ python check_ast.py samples_bypass
samples_bypass\sneaky.py:7: [HIGH] os.remove - 파일 삭제
samples_bypass\sneaky.py:9: [MID] shutil.move - 이동(원래 위치에서 사라짐)
samples_bypass\sneaky.py:10: [LOW] .write_text - 기존 파일을 덮어씀
samples_bypass\sneaky.py:11: [MID] os.rename - 이름 변경(대상이 있으면 덮어쓰거나 오류)
검사 1개 파일, HIGH 1건, MID 2건, LOW 1건, ERROR 0건
exit: 1, 소요 0.07초
이 경험이 이 점검기의 한계를 그대로 보여 줍니다. 규칙표에 없는 함수, 더 복잡하게 감춘 호출은 여전히 빠질 수 있습니다. 0건이 “안전하다”는 보증은 아닙니다.
점검을 통과한 코드만 실행하는 gate.py
매번 점검하고 실행하기를 따로 하면 결국 점검을 건너뛰게 됩니다. 그래서 점검 결과에 HIGH가 없을 때만 실행하는 작은 래퍼를 만들었습니다.
"""점검을 통과한 스크립트만 실행합니다.
사용법: python gate.py 스크립트.py [스크립트 인자...]
"""
import os
import subprocess
import sys
from pathlib import Path
import check_ast
sys.stdout.reconfigure(encoding="utf-8")
script, *rest = sys.argv[1:]
found = check_ast.check_file(script)
for lineno, level, name, why in found:
print(f" {lineno}행 [{level}] {name} - {why}")
if any(level in ("HIGH", "ERROR") for _, level, _, _ in found):
print(f"실행 중단: {Path(script).name}에 HIGH 항목이 있습니다. 코드를 직접 확인하세요.")
sys.exit(1)
print(f"점검 통과({len(found)}건 참고) -> 실행합니다", flush=True) # flush 없으면 자식 출력이 먼저 찍힘
env = dict(os.environ, PYTHONIOENCODING="utf-8") # 부모와 같은 인코딩으로 출력
sys.exit(subprocess.run([sys.executable, script, *rest], env=env).returncode)
$ python gate.py samples/safe_report.py
점검 통과(0건 참고) -> 실행합니다
키보드: 7
마우스: 7
USB 허브: 6
모니터: 1
$ python gate.py samples/backup_and_upload.py
9행 [HIGH] os.system - 셸 명령 실행
10행 [HIGH] shell=True - 명령 문자열을 셸이 해석
10행 [MID] subprocess.run - 외부 프로그램 실행
13행 [MID] requests.post - 외부로 데이터 전송
실행 중단: backup_and_upload.py에 HIGH 항목이 있습니다. 코드를 직접 확인하세요.
첫 버전에서는 이런 출력이 나왔습니다.
# gate.py 첫 버전
$ python gate.py samples/safe_report.py
����: 7
���콺: 7
USB ���: 6
�����: 1
점검 통과(0건 참고) -> 실행합니다
두 가지가 이상했습니다. 실행된 스크립트의 출력이 “점검 통과” 줄보다 먼저 찍혔고, 한글이 깨졌습니다. 앞의 것은 부모 프로세스의 출력이 버퍼에 머물러 있는 사이 자식이 먼저 출력했기 때문이라 print(..., flush=True)로 고쳤습니다. 뒤의 것은 자식 프로세스가 윈도우 기본 인코딩으로 출력했기 때문이라 PYTHONIOENCODING=utf-8을 넘겨 맞췄습니다. 고친 뒤 결과가 위의 정상 출력입니다.
주의: 라이브러리 코드에 돌리면 수천 건이 걸립니다
최종본으로 Lib 전체를 다시 돌린 결과입니다.
$ python check_ast.py .../Python312/Lib
검사 4600개 파일, HIGH 1396건, MID 3691건, LOW 875건, ERROR 9건
exit: 1, 소요 30.80초
# 테스트 폴더 밖만 집계
test 폴더 밖: 1264건, 파일 417개
[MID] getattr(동적 이름): 677
[LOW] open(w): 125
[MID] subprocess.Popen: 68
[MID] __import__: 58
[HIGH] eval: 48
[HIGH] os.unlink: 47
[HIGH] os.remove: 43
[HIGH] exec: 38
정상적인 라이브러리 코드에서도 수천 건이 걸렸습니다. 파일 복사·삭제를 담당하는 표준 모듈 shutil.py부터 os.unlink를 HIGH로 4건 품고 있습니다. 특히 getattr(동적 이름)은 테스트 폴더 밖 1,264건 중 677건으로 절반이 넘어서, 큰 코드에서는 잡음이 큽니다. 그래서 이 점검기는 AI에게 받은 작은 스크립트 하나를 처음 실행하기 전에 쓰는 용도로 생각하는 게 맞습니다. HIGH는 “금지”가 아니라 “이 줄은 사람이 직접 읽어라”라는 표시입니다.
속도는 파일 하나 기준 0.06~0.08초(파이썬 기동 포함)였습니다. Lib 4,600개 전체는 같은 조건에서도 실행마다 25초에서 31초까지 달랐고, 그중 파싱만 따로 재 보니 14.9초였습니다.
| 방식 | 주석·문자열 오탐 | 별칭 import | 변수에 담은 함수 | 속도(파일 1개) |
|---|---|---|---|---|
| 단어 검색 | 있음(2줄) | 놓침 | 놓침 | 0.07초 |
| ast 첫 버전 | 없음 | 잡음 | 놓침 | 0.06초 |
| ast 최종본 | 없음 | 잡음 | 잡음(단순 대입만) | 0.06~0.07초 |
마무리
AI가 준 코드를 믿을지 말지는 다 읽어 보고 정하는 게 원칙이지만, 적어도 지우고·옮기고·명령을 실행하는 줄이 어디 있는지는 실행 전에 기계적으로 알 수 있습니다.
가장 작은 첫걸음은 이렇습니다.
- 위
check_ast.py를 저장해 두고, AI에게 받은 스크립트를 처음 돌리기 전에python check_ast.py 받은파일.py를 한 번 실행합니다. - HIGH로 표시된 줄은 대상 경로가 어디인지, 사용자 입력이 그대로 들어가지 않는지 직접 확인합니다.
- 자주 쓰는 위험 함수가 더 있다면
RULES에 한 줄씩 추가합니다. 0건이 안전 보증이 아니라는 점은 잊지 않습니다.
다음 글에서는 큰 파일을 한 번에 읽다가 메모리가 부족해질 때, 나눠 읽는 방법별로 메모리 사용량이 실제로 얼마나 달라지는지 재 보겠습니다.