마크다운 표 CSV 변환 — AI 답변 속 표를 파이썬으로 CSV·엑셀 파일로 바꾸기 (실측)

ChatGPT나 Claude에게 “표로 정리해 줘”라고 하면 답변은 | 지점 | 매출 | 같은 마크다운 표로 옵니다. 화면에서는 보기 좋지만 엑셀로 옮기려고 복사해 붙여 넣으면 한 칸에 줄 전체가 들어가거나 세로 막대가 그대로 남습니다. 이 글에서는 마크다운 표 CSV 변환 스크립트를 파이썬으로 만들어, AI 답변 텍스트를 통째로 넣으면 표만 골라 CSV와 엑셀(xlsx) 파일로 저장하는 과정을 직접 돌려 봅니다. 흔한 split('|') 방식은 제 테스트 답변에서 구분선이 데이터로 들어가고, 코드 속 파이프 때문에 2칸짜리 행이 3칸으로 쪼개졌습니다. 고친 버전도 처음엔 오른쪽 정렬 구분선 --:를 표로 알아보지 못해 10만 행 표를 통째로 놓쳤습니다. 최종 버전은 10만 행을 파싱 1.5초, CSV 저장 0.09초, 엑셀 저장 약 10초에 처리했습니다. 환경은 윈도우 11, Python 3.12.10, openpyxl 3.1.5입니다.

테스트용 AI 답변 — 일부러 까다롭게

실제 AI 답변에서 자주 보이는 모양을 섞어 answer.md를 직접 만들었습니다. 표 앞뒤에 일반 문장이 있고, 표가 두 개이며, 정렬 구분선(:---:, ---:), 굵게 표시, 0으로 시작하는 사원번호, 퍼센트, 그리고 코드 안의 파이프 문자가 들어 있습니다.

네, 요청하신 내용을 표로 정리했습니다.

### 1. 지점별 10월 매출

| 지점 | 담당자 | 매출(원) | 전월 대비 | 사원번호 |
|:-----|:------:|--------:|---------:|---------|
| 강남점 | **김영희** | 12,500,000 | +8.2% | 00731 |
| 홍대점 | 박철수 | 9,870,000 | -3.1% | 01205 |
| 판교점 | 이민수 | 15,020,000 | +12.0% | 00098 |

### 2. 자주 쓰는 명령어

| 명령 | 설명 |
| --- | --- |
| `ls -al \| grep py` | 파이썬 파일만 보기 |
| `git log --oneline` | 커밋 한 줄 요약 |
| `echo "a|b"` | 따옴표 안 파이프 출력 |

위 수치는 예시이며, 실제 데이터로 바꿔 쓰세요.

두 번째 표의 ls -al \| grep py는 파이프를 백슬래시로 이스케이프한 경우이고, echo "a|b"는 이스케이프 없이 코드 안에 파이프를 쓴 경우입니다. GFM(깃허브식 마크다운) 규칙대로라면 표 안의 파이프는 코드 안이라도 이스케이프해야 하지만, AI 답변에는 두 모양이 다 나옵니다.

흔한 방법: 줄마다 split(‘|’) — 4가지가 깨졌다

검색하면 가장 먼저 나오는 방식은 |로 시작하는 줄을 골라 split('|')하는 것입니다.

import csv
import sys

rows = []
with open(sys.argv[1], encoding='utf-8') as f:
    for line in f:
        line = line.strip()
        if line.startswith('|'):
            cells = [c.strip() for c in line.strip('|').split('|')]
            rows.append(cells)

with open('naive.csv', 'w', encoding='utf-8', newline='') as f:
    csv.writer(f).writerows(rows)

for r in rows:
    print(len(r), r)
$ python -X utf8 naive.py answer.md
5 ['지점', '담당자', '매출(원)', '전월 대비', '사원번호']
5 [':-----', ':------:', '--------:', '---------:', '---------']
5 ['강남점', '**김영희**', '12,500,000', '+8.2%', '00731']
5 ['홍대점', '박철수', '9,870,000', '-3.1%', '01205']
5 ['판교점', '이민수', '15,020,000', '+12.0%', '00098']
2 ['명령', '설명']
2 ['---', '---']
3 ['`ls -al \\', 'grep py`', '파이썬 파일만 보기']
2 ['`git log --oneline`', '커밋 한 줄 요약']
3 ['`echo "a', 'b"`', '따옴표 안 파이프 출력']

실행은 0.04초로 금방 끝났지만 결과는 그대로 쓸 수 없었습니다.

  • 구분선 행이 데이터로 들어갔습니다. :----- 같은 줄이 CSV 둘째 행에 그대로 남습니다.
  • 굵게 표시 **김영희**가 별표째 남았습니다.
  • 두 표가 한 CSV에 섞였습니다. 5칸 표 다음에 2칸 표가 이어 붙습니다.
  • 코드 속 파이프 때문에 칸이 쪼개졌습니다. 2칸이어야 할 행 두 개가 3칸이 됐습니다. 이스케이프한 \|도, 안 한 |도 똑같이 잘렸습니다.

고친 스크립트 전체

표는 “파이프가 있는 줄 바로 다음에 구분선이 오는 곳”에서 시작해 빈 줄이나 파이프 없는 줄에서 끝난다고 봤습니다. 칸을 나눌 때는 한 글자씩 읽으면서 \|는 글자로 바꾸고, 백틱(`) 사이에 있는 파이프는 칸 구분으로 보지 않습니다. CSV는 표준 라이브러리 csv로, 엑셀은 openpyxl로 저장합니다. openpyxl이 없다면 python -m pip install openpyxl로 설치하면 됩니다(제 노트북에는 이미 3.1.5가 있었습니다).

"""AI 답변 속 마크다운 표를 CSV·엑셀로 저장 (CSV는 표준 라이브러리, 엑셀은 openpyxl)"""
import csv
import re
import sys

SEP = re.compile(r'^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$')  # |---|:--:| 구분선 (대시 1개 이상)


def split_row(line):
    """| 로 칸을 나누되, \\| 와 `코드` 안의 | 는 칸 구분으로 보지 않는다"""
    line = line.strip()
    if line.startswith('|'):
        line = line[1:]
    if line.endswith('|') and not line.endswith('\\|'):
        line = line[:-1]
    cells, buf, in_code, i = [], '', False, 0
    while i < len(line):
        ch = line[i]
        if ch == '\\' and i + 1 < len(line) and line[i + 1] == '|':
            buf += '|'
            i += 2
            continue
        if ch == '`':
            in_code = not in_code
        if ch == '|' and not in_code:
            cells.append(buf)
            buf = ''
        else:
            buf += ch
        i += 1
    cells.append(buf)
    return [clean(c) for c in cells]


def clean(cell):
    """**굵게**, `코드` 표시와 <br>을 정리"""
    cell = cell.strip()
    cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell)
    cell = re.sub(r'`([^`]*)`', r'\1', cell)
    cell = re.sub(r'<br\s*/?>', '\n', cell)
    return cell


def find_tables(text):
    """헤더 줄 + 구분선이 붙어 있는 곳을 표로 보고, 표마다 행 목록을 돌려준다"""
    lines = text.splitlines()
    tables, i = [], 0
    while i < len(lines) - 1:
        nxt = lines[i + 1].strip()
        if '|' in lines[i] and '|' in nxt and SEP.match(nxt):
            header = split_row(lines[i])
            rows = [header]
            j = i + 2
            while j < len(lines) and '|' in lines[j] and lines[j].strip():
                row = split_row(lines[j])
                row = (row + [''] * len(header))[:len(header)]  # 칸 수를 헤더에 맞춤
                rows.append(row)
                j += 1
            tables.append(rows)
            i = j
        else:
            i += 1
    return tables


NUM = re.compile(r'^[+-]?\d{1,3}(,\d{3})*(\.\d+)?$|^[+-]?\d+(\.\d+)?$')


def to_value(cell):
    """엑셀용: 숫자처럼 생긴 건 숫자로. 단 0으로 시작하는 코드(00731)는 글자 그대로"""
    if cell.endswith('%') and NUM.match(cell[:-1]):
        return Percent(round(float(cell[:-1].replace(',', '')) / 100, 10))
    s = cell.replace(',', '')
    if NUM.match(cell) and not re.match(r'^[+-]?0\d', s):
        return float(s) if '.' in s else int(s)
    return cell


class Percent(float):
    """엑셀에 % 서식으로 넣을 숫자 표시용"""


def save_csv(rows, path):
    # utf-8-sig: 엑셀이 한글 CSV를 깨지지 않게 열도록 BOM을 붙인다
    with open(path, 'w', encoding='utf-8-sig', newline='') as f:
        csv.writer(f).writerows(rows)


def save_xlsx(tables, path):
    from openpyxl import Workbook
    wb = Workbook()
    wb.remove(wb.active)
    for n, rows in enumerate(tables, 1):
        ws = wb.create_sheet('표' + str(n))
        for r, row in enumerate(rows):
            ws.append(row if r == 0 else [to_value(c) for c in row])
        for row in ws.iter_rows(min_row=2):
            for c in row:
                if isinstance(c.value, Percent):
                    c.number_format = '0.0%'
    wb.save(path)


if __name__ == '__main__':
    # 사용법: python md2table.py 답변.md
    with open(sys.argv[1], encoding='utf-8') as f:
        tables = find_tables(f.read())
    for n, rows in enumerate(tables, 1):
        save_csv(rows, 'table' + str(n) + '.csv')
        print('표' + str(n) + ':', len(rows) - 1, '행 x', len(rows[0]), '열')
    save_xlsx(tables, 'tables.xlsx')
    print('저장: table1.csv ~ table' + str(len(tables)) + '.csv, tables.xlsx')
$ python -X utf8 md2table.py answer.md
표1: 3 행 x 5 열
표2: 3 행 x 2 열
저장: table1.csv ~ table2.csv, tables.xlsx

# table2.csv 를 다시 읽어 본 결과
2 ['명령', '설명']
2 ['ls -al | grep py', '파이썬 파일만 보기']
2 ['git log --oneline', '커밋 한 줄 요약']
2 ['echo "a|b"', '따옴표 안 파이프 출력']

표 두 개가 각각 table1.csv, table2.csv로 나뉘었고, 엑셀 파일에는 시트 “표1”, “표2″로 들어갔습니다. 구분선과 별표가 사라졌고, 코드 속 파이프가 있는 행도 모두 2칸으로 맞았습니다. CSV 파일 앞 3바이트를 확인해 보니 EF BB BF, 즉 BOM이 붙어 있었습니다. 엑셀은 BOM 없는 UTF-8 CSV를 한글 윈도우에서 다른 인코딩으로 읽어 한글이 깨질 수 있어서 utf-8-sig로 저장했습니다. 다만 이번에 엑셀 프로그램으로 직접 열어 보지는 않았고, 바이트만 확인했습니다.

엑셀용 숫자 변환 — 앞자리 0과 0.08199999999999999

CSV는 모든 값이 글자지만, 엑셀 파일에는 숫자를 숫자로 넣어야 합계나 정렬이 됩니다. 그래서 to_value()에서 12,500,000 같은 값은 쉼표를 빼고 정수로 바꿨습니다. 처음엔 “숫자처럼 보이면 다 int로” 바꾸려 했는데, 따로 돌려 보니 사원번호가 망가졌습니다.

$ python -X utf8 pitfall_int.py
'00731' -> 731
'01205' -> 1205
'00098' -> 98
'010-1234-5678' -> 변환 실패: invalid literal for int() with base 10: '010-1234-5678'
'12,500,000' -> 12500000

00731이 731이 됩니다. 사원번호·우편번호·상품코드처럼 0으로 시작하는 값은 숫자가 아니라 코드이므로 글자로 남기도록 조건을 넣었습니다.

퍼센트(+8.2%)는 처음 버전에서 글자로 남아 엑셀에서 계산이 안 됐습니다. 100으로 나눈 숫자에 0.0% 서식을 붙이도록 고친 뒤 결과 파일을 다시 열어 봤더니 이렇게 들어가 있었습니다.

# tables.xlsx 를 openpyxl로 다시 열어 (값, 형식 s=글자 n=숫자, 표시 서식)
('강남점', 's', 'General'), ('김영희', 's', 'General'), (12500000, 'n', 'General'), (0.08199999999999999, 'n', '0.0%'), ('00731', 's', 'General')
('홍대점', 's', 'General'), ('박철수', 's', 'General'), (9870000, 'n', 'General'), (-0.031, 'n', '0.0%'), ('01205', 's', 'General')
('판교점', 's', 'General'), ('이민수', 's', 'General'), (15020000, 'n', 'General'), (0.12, 'n', '0.0%'), ('00098', 's', 'General')

매출은 숫자(n), 사원번호는 글자(s)로 의도대로 들어갔는데, 8.2%가 0.08199999999999999로 저장됐습니다. 8.2 / 100을 컴퓨터가 2진수로 계산하면서 생기는 오차입니다. 엑셀 화면에서는 서식 덕분에 8.2%로 보이겠지만 값을 그대로 다른 곳에 쓰면 지저분해집니다. round(..., 10)으로 감싸자 0.082, -0.031, 0.12로 깔끔하게 저장됐습니다.

10만 행 실측 — 그리고 대시 2개 때문에 표를 통째로 놓친 일

답변이 아주 긴 경우도 보려고 5열짜리 표(날짜, 상품, 수량, 금액, 증감)를 n행 만들어 시간을 재는 bench.py를 짰습니다. 그런데 1,000행부터 바로 오류가 났습니다.

$ python -X utf8 bench.py 1000
입력: 1000 행, 50392 자
Traceback (most recent call last):
  File "...\bench.py", line 20, in <module>
    print('파싱: %.3f초' % (time.perf_counter() - t), len(tables[0]) - 1, '행')
IndexError: list index out of range

표를 하나도 못 찾았다는 뜻입니다. 원인은 구분선 정규식이었습니다. 첫 버전에서는 구분선을 “대시 3개 이상”(-{3,})으로 짰는데, 벤치마크용 표의 구분선이 |---|---|--:|--:|--:|였고 오른쪽 정렬 칸 --:는 대시가 2개뿐이었습니다. GFM 표 규칙은 대시가 1개 이상이면 구분선으로 인정합니다. 테스트 답변의 구분선은 다 길어서 이 문제가 안 보였던 겁니다. -+로 고치고, 표 밖의 --- 가로줄을 구분선으로 오인하지 않도록 구분선 줄에 |가 있는지도 함께 확인하게 했습니다. 위 전체 스크립트는 이미 고친 버전입니다.

$ python -X utf8 bench.py 100000
입력: 100000 행, 5036555 자
파싱: 1.498초 100000 행
CSV 저장: 0.086초
엑셀 저장: 10.024초
단계1,000행10만 행 1회차10만 행 2회차
표 찾기·칸 나누기0.022초1.498초1.554초
CSV 저장0.002초0.086초0.085초
엑셀 저장 (일반 모드)0.364초10.024초9.880초
엑셀 저장 (write_only 모드)–8.191초8.391초

입력은 10만 행에 약 504만 자였고, 결과 파일은 CSV 약 4.5MB, xlsx 약 3.3MB였습니다. 다시 열어 보니 헤더 포함 100,001행, 수량·금액·증감이 모두 숫자로 들어가 있었습니다. 시간 대부분은 엑셀 저장이었습니다. openpyxl의 write_only 모드(셀을 메모리에 다 들고 있지 않고 한 줄씩 쓰는 방식)로 바꿔 봤지만 8.2~8.4초로 약 17% 빨라지는 데 그쳤습니다. 퍼센트 서식을 넣으려면 이 모드에서는 WriteOnlyCell로 셀을 따로 만들어야 해서 코드가 길어집니다. AI 답변 속 표는 보통 수십 행이니 일반 모드로 충분하고, 수만 행을 다룬다면 CSV로 저장하는 편이 100배 이상 빠릅니다.

주의할 점

  • 구분선 대시 개수를 가정하지 마세요. --:처럼 짧은 구분선도 정상입니다. 저는 이 가정 하나로 10만 행 표를 통째로 놓쳤습니다.
  • 코드 속 파이프는 따로 처리해야 합니다. 단순 split은 이스케이프 여부와 상관없이 칸을 쪼갰습니다.
  • 0으로 시작하는 값은 글자로. 숫자로 바꾸면 00731이 731이 됩니다. 전화번호도 마찬가지입니다.
  • 퍼센트를 숫자로 바꿀 땐 round. 8.2 / 100이 0.08199999999999999로 저장됐습니다.
  • 칸 수가 모자라거나 넘치는 행은 헤더에 맞춰 자릅니다. 손으로 고친 답변 등에서 칸 수가 어긋난 행이 섞일 수 있어서, 모자라면 빈칸으로 채우고 넘치면 버리게 했습니다. 버려지는 값이 없는지 결과의 칸 수는 한 번 확인하세요.
  • AI가 만든 숫자는 출처가 아닙니다. 표를 파일로 옮기는 건 형식 변환일 뿐, 값이 맞는지는 원래 자료와 따로 대조해야 합니다.

마무리

구분선을 기준으로 표를 찾고, 코드 속 파이프를 지키며 칸을 나누고, 엑셀에는 숫자를 숫자로 — 단 앞자리 0은 글자로.

가장 작은 첫걸음은 이렇습니다.

  1. AI 답변을 통째로 복사해 answer.md로 저장합니다(앞뒤 설명 문장이 섞여 있어도 됩니다).
  2. 위 md2table.py를 같은 폴더에 두고 python -X utf8 md2table.py answer.md를 실행합니다.
  3. 출력된 “행 x 열” 수가 답변 속 표와 같은지 확인한 뒤 tables.xlsx를 엽니다.

다음 글에서는 jq 같은 별도 도구 없이 python -m json.tool로 명령줄에서 JSON을 보기 좋게 펼치고 필요한 값만 골라내는 방법을 직접 돌려 보겠습니다.

댓글 남기기