AI 답변 코드 추출 — 코드 블록만 뽑아 파일로 저장하기, 정규식 한 줄이 놓친 3가지 (실측)

ChatGPT나 Claude에게 스크립트를 부탁하면 답변 한 번에 코드 블록이 서너 개씩 섞여 나옵니다. 설치 명령, 본 코드, 설정 파일, 실행 예시가 설명 문단 사이사이에 끼어 있어서, 매번 블록마다 복사 버튼을 누르고 새 파일을 만들고 이름을 붙이는 일을 반복하게 됩니다. 이번 글에서는 AI 답변 코드 추출을 파이썬 표준 라이브러리만으로 자동화해, 마크다운으로 저장한 답변에서 코드 블록만 골라 언어에 맞는 확장자의 파일로 저장하는 스크립트를 만들어 봅니다.

먼저 고백하자면, 처음엔 정규식 한 줄이면 끝날 줄 알았습니다. 직접 만든 테스트 답변 파일로 돌려 보니 블록 개수는 4개로 정확히 맞았는데, 정작 내용을 열어 보니 4개 중 3개가 틀려 있었습니다. 들여쓰기가 붙은 코드 2개, 중간에 잘린 코드 1개, 그리고 존재하지도 않는 빈 블록 1개였고, 물결표(~~~)로 감싼 블록은 아예 찾지 못했습니다. 개수만 확인했다면 그대로 넘어갔을 오류라서, 그 과정을 그대로 정리했습니다.

실험 환경과 테스트 입력

Windows 11, Python 3.12.10에서 실행했고 외부 패키지는 쓰지 않았습니다(re, pathlib, py_compile만 사용). 입력은 실제 AI 서비스에서 받은 답변이 아니라, AI 답변에서 흔히 보이는 형태를 흉내 내 직접 만든 마크다운 파일 3개입니다.

  • answer1.md: python(바로 위 줄에 app.py라는 파일명), bash, json, 언어 표시가 없는 블록까지 4개
  • answer2.md: 번호 목록 안에 3칸 들여쓴 bash·python 블록, ~~~javascript 물결표 펜스, 그리고 README 예시처럼 백틱 4개 펜스 안에 백틱 3개 블록이 들어 있는 경우까지 4개
  • answer3_crlf.md: answer1과 같은 내용을 윈도우식 CRLF 줄바꿈으로 저장한 파일

1차 시도: 정규식 한 줄의 함정

인터넷에서 가장 흔히 보이는 방식은 백틱 3개 사이를 non-greedy로 잡는 정규식입니다.

import re
from pathlib import Path

PATTERN = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)

text = Path("answer.md").read_text(encoding="utf-8")
for lang, body in PATTERN.findall(text):
    print(lang, repr(body))

answer1에서는 4개 모두 정상으로 나왔습니다. 문제는 answer2였습니다. 블록 본문을 repr로 찍어 보니 이렇게 나왔습니다.

$ python naive_detail.py
--- 1 [bash]
'   pip install requests\n   '
--- 2 [python]
'   import requests\n   print(requests.get("https://example.com").status_code)\n   '
--- 3 [markdown]
'# 사용법\n\n'
--- 4 [-]
''
~~~ 포함 여부: True
  1. 목록 안 블록의 들여쓰기가 코드에 그대로 붙습니다. 3칸 공백이 줄마다 앞에 붙고, 끝에도 공백 찌꺼기가 남습니다. 파이썬 코드라면 그대로 저장했을 때 실행이 안 될 수 있습니다.
  2. 물결표 펜스(~~~)는 못 찾습니다. 파일에는 분명히 있는데(포함 여부 True) 결과에서 빠졌습니다.
  3. 백틱 4개로 감싼 블록이 안쪽 백틱 3개에서 잘립니다. “# 사용법” 두 줄만 남고, 남은 조각이 빈 블록 하나로 따로 잡혔습니다. 그 결과 개수는 4개로 똑같아서 개수만 봐서는 눈치챌 수 없었습니다.

CRLF도 확인했습니다. Path.read_text()는 기본 설정에서 줄바꿈을 \n으로 바꿔 읽기 때문에 문제가 없었지만, open(..., newline='')로 원본 그대로 읽자 같은 정규식이 블록을 하나도 찾지 못했습니다.

answer3 원본에 CRLF 있음: True
read_text 후 CRLF 남음: False
newline='' 로 읽으면 블록 수: 0

줄 단위 파서로 다시 만들기

그래서 정규식으로 블록 전체를 한 번에 잡는 대신, 한 줄씩 읽으면서 “여는 펜스 → 닫는 펜스”를 추적하는 방식으로 바꿨습니다. 규칙은 네 가지입니다.

  • 여는 펜스: 앞에 들여쓰기가 있어도 되고, 백틱 또는 물결표가 3개 이상, 뒤에 언어명
  • 닫는 펜스: 여는 펜스와 같은 문자이고 길이가 같거나 더 길며 뒤에 공백만 있는 줄 — 그래서 4개짜리 펜스 안의 3개짜리는 닫는 펜스로 보지 않습니다
  • 본문 각 줄에서 여는 펜스의 들여쓰기만큼만 제거
  • 펜스 바로 위 줄에 app.py 같은 파일명이 있으면 그 이름으로, 없으면 block01.py처럼 언어별 확장자로 저장. 이름이 겹치면 _2, _3을 붙임
# AI 답변(마크다운)에서 코드 블록만 뽑아 파일로 저장한다.
# 사용법: python extract_code.py 답변.md [출력폴더] [--dry-run]
import re
import sys
from pathlib import Path

sys.stdout.reconfigure(encoding="utf-8")

EXT = {
    "python": ".py", "py": ".py", "bash": ".sh", "sh": ".sh", "shell": ".sh",
    "powershell": ".ps1", "ps1": ".ps1", "javascript": ".js", "js": ".js",
    "typescript": ".ts", "ts": ".ts", "json": ".json", "yaml": ".yml", "yml": ".yml",
    "html": ".html", "css": ".css", "sql": ".sql", "markdown": ".md", "md": ".md",
}
# 여는 펜스: 들여쓰기 0~3칸(목록 안이면 더 깊을 수 있어 넉넉히), ``` 또는 ~~~ 3개 이상, 뒤에 언어
OPEN = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})\s*(?P<lang>[\w+#.-]*)")
NAME_HINT = re.compile(r"([\w.-]+\.(?:py|sh|ps1|js|ts|json|ya?ml|html|css|sql|md|txt))\b")


def extract_blocks(text):
    lines = text.splitlines()
    blocks, i = [], 0
    while i < len(lines):
        m = OPEN.match(lines[i])
        if not m:
            i += 1
            continue
        indent, fence, lang = m["indent"], m["fence"], m["lang"].lower()
        # 바로 위 비어 있지 않은 줄에서 파일 이름 힌트 찾기
        prev = next((lines[k] for k in range(i - 1, max(i - 3, -1), -1) if lines[k].strip()), "")
        hint = NAME_HINT.search(prev)
        body, j = [], i + 1
        # 닫는 펜스: 같은 문자, 길이가 여는 펜스 이상, 뒤에 공백만
        close = re.compile(r"^[ \t]*" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}\s*$")
        while j < len(lines) and not close.match(lines[j]):
            line = lines[j]
            body.append(line[len(indent):] if line.startswith(indent) else line.lstrip())
            j += 1
        blocks.append({"lang": lang, "code": "\n".join(body) + "\n", "hint": hint[1] if hint else None,
                       "line": i + 1, "closed": j < len(lines)})
        i = j + 1
    return blocks


def save_blocks(blocks, out_dir, dry_run=False):
    out_dir = Path(out_dir)
    used = set()
    for n, b in enumerate(blocks, 1):
        name = b["hint"] or f"block{n:02d}{EXT.get(b['lang'], '.txt')}"
        stem, suffix = Path(name).stem, Path(name).suffix
        k = 2
        while name in used:  # 같은 이름이 또 나오면 _2, _3 붙이기
            name = f"{stem}_{k}{suffix}"
            k += 1
        used.add(name)
        flag = "" if b["closed"] else "  (닫는 펜스 없음)"
        print(f"  {b['line']:>3}행 [{b['lang'] or '-'}] -> {name} ({len(b['code'].splitlines())}줄){flag}")
        if not dry_run:
            out_dir.mkdir(parents=True, exist_ok=True)
            (out_dir / name).write_text(b["code"], encoding="utf-8", newline="\n")


if __name__ == "__main__":
    args = [a for a in sys.argv[1:] if a != "--dry-run"]
    src = Path(args[0])
    out = args[1] if len(args) > 1 else f"out_{src.stem}"
    blocks = extract_blocks(src.read_text(encoding="utf-8"))
    print(f"{src.name}: 코드 블록 {len(blocks)}개 -> {out}/")
    save_blocks(blocks, out, dry_run="--dry-run" in sys.argv)

같은 입력으로 다시 돌린 결과입니다. answer2의 4개 블록이 모두 제대로 분리됐고, 위 줄의 fetch.py, app.py 표기도 파일 이름으로 쓰였습니다.

$ python extract_code.py answers/answer1.md
answer1.md: 코드 블록 4개 -> out_answer1/
    4행 [python] -> app.py (4줄)
   13행 [bash] -> block02.sh (1줄)
   19행 [json] -> block03.json (1줄)
   25행 [-] -> block04.txt (2줄)
$ python extract_code.py answers/answer2.md
answer2.md: 코드 블록 4개 -> out_answer2/
    5행 [bash] -> block01.sh (1줄)
   11행 [python] -> fetch.py (2줄)
   18행 [javascript] -> block03.js (1줄)
   24행 [markdown] -> block04.md (5줄)

저장된 fetch.py를 열어 보니 앞 공백 없이 import requests로 시작했고, block04.md에는 안쪽 파이썬 예시까지 펜스째로 온전히 들어 있었습니다. 뽑아낸 app.py도 그 폴더에서 바로 실행해 종료 코드 0을 확인했습니다. 줄 단위 파서는 splitlines()가 \r\n도 줄 끝으로 처리해 주기 때문에, CRLF 원본을 그대로 넣어도 4개를 모두 찾았고 저장된 코드에 \r이 남지 않았습니다.

경계 사례: 이름 충돌과 끊긴 답변

AI에게 “수정해 줘”를 반복하면 같은 파일명(main.py)이 한 대화에 여러 번 나옵니다. 또 답변이 길면 중간에 끊겨 닫는 펜스가 없는 경우도 있습니다. 이 두 상황을 흉내 낸 입력을 넣어 봤습니다.

== 5) 이름 충돌 + 닫히지 않은 펜스
    2행 [python] -> main.py (1줄)
    6행 [python] -> main_2.py (1줄)
   10행 [python] -> block03.py (2줄)  (닫는 펜스 없음)

두 번째 main.py는 덮어쓰지 않고 main_2.py로 저장됐고, 끊긴 블록은 파일 끝까지를 본문으로 저장하면서 “(닫는 펜스 없음)”이라고 표시했습니다. 끊긴 코드를 버리지 않는 대신, 사람이 확인해야 한다는 신호를 남기는 쪽을 택했습니다.

두 번째 실행에서 만난 SyntaxError

검증 스크립트를 한 번 더 돌리자 이번엔 시작하자마자 멈췄습니다.

$ python ../timed.py verify.py
...
  File "out_edge\block03.py", line 1
    def half(
            ^
SyntaxError: '(' was never closed

exit: 1, 소요 0.06초

원인은 앞 단계에서 일부러 만든 “끊긴 답변” 파일이었습니다. 첫 실행 때 저장된 out_edge/block03.py가 두 번째 실행에서는 문법 검사 대상(out_*/*.py)에 들어갔고, py_compile.compile(doraise=True)가 예외를 던진 것입니다. 검사를 try/except로 감싸서 실패를 보고만 하도록 고쳤습니다.

import py_compile
from pathlib import Path

for f in sorted(Path(".").glob("out_*/*.py")):
    try:
        py_compile.compile(str(f), doraise=True)
        print("  OK  ", f.as_posix())
    except py_compile.PyCompileError as e:
        print("  FAIL", f.as_posix(), "->", str(e).strip().splitlines()[-1])
  OK   out_answer1/app.py
  OK   out_answer2/fetch.py
  OK   out_answer3_crlf/app.py
  FAIL out_edge/block03.py -> SyntaxError: '(' was never closed
  OK   out_edge/main.py
  OK   out_edge/main_2.py

결과적으로 이 문법 검사가 꽤 쓸모 있다는 걸 알게 됐습니다. 추출 직후 py_compile을 한 번 돌리면, 답변이 끊겨서 미완성으로 저장된 코드를 실행하기 전에 바로 걸러낼 수 있습니다.

1,000개 답변을 한 번에 처리하면

답변을 계속 쌓아 두는 경우를 가정해, 테스트 답변 두 개를 3번씩 이어 붙인 파일(파일당 블록 12개) 1,000개를 만들어 돌렸습니다.

== 6) 답변 1,000개 처리 시간
  파싱만: 파일 1000개, 블록 12000개, 0.21초
  파싱+저장: 저장된 파일 12000개, 6.49초
항목정규식 한 줄줄 단위 파서
answer2 블록 4개들여쓰기 오염 2, 잘림 1, 빈 블록 1, ~~~ 누락4개 모두 정상
CRLF 원본(newline=”)0개 찾음4개 찾음
1,000개 파일 파싱–0.21초 (블록 12,000개)
파싱 + 파일 12,000개 저장–6.49초 (재실행 5.72초)

파싱 자체는 0.21초면 끝났고, 시간의 대부분은 파일 12,000개를 디스크에 쓰는 데 들었습니다. 파서를 더 빠르게 다듬을 이유는 거의 없다는 뜻입니다.

쓰기 전에 알아둘 점

  • 먼저 –dry-run으로 확인하세요. 어떤 블록이 어떤 이름으로 저장될지 목록만 보여 주고 파일은 만들지 않습니다.
  • 뽑아낸 코드를 바로 실행하지 마세요. AI가 준 코드에는 파일 삭제나 네트워크 요청이 들어 있을 수 있습니다. 문법 검사와 눈으로 읽기를 먼저 거치는 편이 안전합니다.
  • 파일명 힌트는 펜스 바로 위 두 줄만 봅니다. 설명 문단 속 다른 파일명을 잘못 가져오지 않게 일부러 범위를 좁혔습니다. 힌트가 없으면 block번호 이름이 됩니다.
  • 목록에 없는 언어는 .txt로 저장됩니다. 자주 쓰는 언어가 있다면 EXT 사전에 한 줄 추가하면 됩니다.

마무리

가장 작은 첫걸음은 다음 AI 답변을 복사 버튼 대신 “답변 전체 복사”로 answer.md에 붙여 넣고 python extract_code.py answer.md --dry-run을 한 번 돌려 보는 것입니다. 블록 목록과 저장될 이름이 기대와 맞는지 확인한 뒤 옵션을 빼고 저장하면 됩니다. 정규식 한 줄로 충분해 보이는 작업도, 개수가 아니라 내용까지 열어 봐야 오류가 보인다는 점이 이번 실험에서 얻은 가장 큰 교훈이었습니다.

다음 글에서는 스크립트가 아무 메시지 없이 조용히 죽을 때, 예외를 로그 파일에 남겨 원인을 추적하는 방법을 직접 돌려 보며 정리하겠습니다.

댓글 남기기