(1/3) PDF 에서 데이터를 뽑아 DB 에 넣기 — 구조체 파싱 vs OCR, 뭘 언제 쓰나
- PDF 에서 데이터를 뽑아 DB 에 넣기 — 구조체 파싱 vs OCR, 뭘 언제 쓰나 ← 지금 글
- 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐
- 검토 로그를 되먹여 양식별 파서 붙이기 — 양식 클러스터링·앵커 추출·회귀 테스트
Summary
PDF 뭉치를 받아서 DB 에 정형 데이터로 넣어야 하는 일이 생겼어요. 세금계산서, 명세서, 신청서 같은 문서에서 금액·수량·날짜를 뽑아 테이블에 적재하는 거죠. 여기서 제일 중요한 목표는 딱 하나였습니다 — 틀린 값을 넣지 않는 것. 8만 원짜리를 80만 원으로 넣거나, 0 을 O 로 읽어서 숫자가 통째로 깨지면, 뒤에서 그 데이터를 믿고 도는 모든 게 같이 틀어져요.
PDF 에서 텍스트를 뽑는 방법은 크게 두 갈래예요. 하나는 PDF 안에 이미 박혀 있는 텍스트 레이어를 구조체로 읽어내는 방식(pdfplumber·PyMuPDF), 다른 하나는 페이지를 이미지로 렌더링해서 OCR 로 글자를 인식 하는 방식(Upstage·PaddleOCR·Tesseract)이에요. 둘은 정확도·비용·대응 범위가 완전히 다른데, 이걸 모르고 아무거나 쓰면 정확도에서 손해를 봅니다.
💡 이 글에서 다루는 것
- PDF 가 왜 “데이터 포맷” 이 아닌지, 그래서 두 갈래가 나뉘는 이유
- 텍스트 레이어가 있는지 먼저 판별 하는 법 (라우팅의 출발점)
- 구조체 파싱(pdfplumber·PyMuPDF) — 정확하지만 표에서 미끄러지는 지점
- OCR — Upstage Document Parse 와 오픈소스(PaddleOCR·Tesseract)
- OCR 이 어디서 틀리나 (
0↔O,1↔l, 자리수) 와 confidence 다루기- DB 에 넣기 직전 검증 레이어 — 타입·합계 재계산·사람 확인 라우팅
결론부터 말하면, “구조체 파싱이냐 OCR 이냐” 는 취향의 문제가 아니라 문서를 판별해서 자동으로 갈라야 하는 문제예요. 그 얘기를 순서대로 풀어볼게요.
1. PDF 는 데이터 포맷이 아니에요
먼저 이걸 받아들이는 게 시작이에요. PDF 는 인쇄를 재현하기 위한 포맷 이지, 데이터를 담기 위한 포맷이 아니에요. “이 글자를 이 좌표에 이 폰트로 찍어라” 는 지시의 모음이지, “이건 품목명, 이건 단가” 같은 의미 구조가 들어있지 않아요.
그래서 같은 겉모습의 PDF 라도 속은 두 종류로 갈립니다.
- 네이티브(born-digital) PDF — 워드·엑셀·회계 프로그램에서 바로 내보낸 것. 안에 텍스트 레이어 가 있어서, 글자 하나하나가 유니코드 문자 + 좌표로 박혀 있어요. 우리가 화면에서 드래그로 텍스트를 긁을 수 있으면 이 종류예요.
- 스캔 PDF — 종이를 스캐너·카메라로 찍어 넣은 것. 안에는 이미지 한 장 만 있어요. 사람 눈엔 글자로 보이지만 컴퓨터한텐 그냥 픽셀 덩어리라, 드래그해도 아무것도 안 잡혀요.
이 차이가 곧 두 갈래를 만듭니다.
| 구분 | 텍스트 레이어 | 접근 방식 | 정확도 성격 |
|---|---|---|---|
| 네이티브 PDF | 있음 | 구조체 파싱 — 박혀 있는 문자를 그대로 읽음 | 문자 자체는 원본과 동일 |
| 스캔 PDF | 없음 | OCR — 픽셀에서 글자를 추론 | 인식 오류가 확률적으로 섞임 |
핵심은 이거예요. 텍스트 레이어가 있는데도 OCR 을 돌리면 멀쩡한 원본을 굳이 이미지로 만들었다가 다시 추론 하는 셈이라, 공짜로 넣을 수 있는 오류를 스스로 만드는 거예요. 반대로 스캔본에 구조체 파싱을 돌리면 아무 글자도 안 나오죠. 그래서 뭘 쓸지 고르기 전에, 이 PDF 가 어느 쪽인지부터 판별 해야 합니다.
✅ 정확도를 지키는 첫 번째 원칙 — 텍스트 레이어가 있으면 무조건 구조체 파싱. OCR 은 그게 없을 때만 꺼내는 카드예요.
2. 먼저 판별한다 — 텍스트 레이어가 있나?
판별은 간단해요. 몇 페이지를 열어서 추출되는 텍스트 길이 를 보면 됩니다. PyMuPDF(fitz)가 빠르고 가벼워서 판별용으로 좋아요.
import fitz # PyMuPDF
def has_text_layer(pdf_path: str, sample_pages: int = 3, min_chars: int = 20) -> bool:
"""앞쪽 몇 페이지에서 뽑히는 글자 수로 텍스트 레이어 유무를 판별."""
doc = fitz.open(pdf_path)
checked = min(sample_pages, doc.page_count)
total = sum(len(doc[i].get_text().strip()) for i in range(checked))
doc.close()
return total >= min_chars
실제로 두 종류의 PDF 에 돌려보면 이렇게 갈려요.
print(has_text_layer("invoice_born_digital.pdf")) # 회계 프로그램에서 내보낸 세금계산서
print(has_text_layer("invoice_scanned.pdf")) # 종이를 스캔한 세금계산서
True
False
min_chars 를 20 정도로 둔 건, 스캔본인데도 워터마크나 페이지 번호 한두 글자가 텍스트로 박혀 있어서 살짝 잡히는 경우를 걸러내기 위해서예요. 문서 종류에 따라 이 임계값은 조정하세요.
이 판별 하나로 파이프라인의 뼈대가 잡혀요.
def extract(pdf_path: str) -> dict:
if has_text_layer(pdf_path):
return extract_structured(pdf_path) # 정확하고 저렴한 길
return extract_ocr(pdf_path) # 스캔본을 위한 길
💡 현실 문서 뭉치는 섞여 들어와요. 같은 폴더에 네이티브와 스캔본이 함께 있는 게 보통이라, 문서 단위로 자동 라우팅 하는 이 구조가 결국 정확도와 비용을 둘 다 챙겨줍니다.
3. 구조체 파싱 — 박혀 있는 글자를 그대로
텍스트 레이어가 있으면 여기가 정답이에요. 대표 도구는 두 개예요.
- pdfplumber — 표(table) 추출과 좌표 기반 접근이 강점. 명세서·계산서처럼 표가 있는 문서에 잘 맞아요.
- PyMuPDF(fitz) — 속도가 빠르고 단어 단위 좌표(
get_text("words"))를 잘 줘요. 대량 처리·레이아웃 분석에 유리.
pdfplumber 로 세금계산서 한 장에서 본문과 표를 뽑아볼게요.
import pdfplumber
with pdfplumber.open("invoice_born_digital.pdf") as pdf:
page = pdf.pages[0]
text = page.extract_text()
tables = page.extract_tables()
print(text.split("\n")[0]) # 첫 줄
print(tables[0][0]) # 표의 헤더 행
print(tables[0][1]) # 표의 첫 데이터 행
세금계산서
['품목', '수량', '단가', '공급가액']
['USB-C 케이블', '10', '8,000', '80,000']
여기서 나온 '80,000' 은 원본 PDF 에 박혀 있던 바로 그 문자열 이에요. 추론이 아니라 그대로 읽은 거라, 숫자가 뒤바뀔 일이 원리적으로 없어요. DB 정확도 관점에서 이게 구조체 파싱의 가장 큰 장점입니다.
장점
- 문자 자체는 원본과 동일 —
0을O로 읽는 일이 없어요. 금액·계좌·주민번호 같은 숫자에 특히 안전합니다. - 빠르고 저렴 — 외부 API·GPU 없이 CPU 로 수천 장을 순식간에.
- 좌표를 준다 — 어느 글자가 어디 있었는지 알 수 있어 표 구조 복원·감사(audit)에 유리.
약점 (여기서 미끄러져요)
- 표 셀 경계를 놓칠 때가 있음 — 괘선 없이 공백으로만 정렬된 표는
extract_tables()가 셀을 엉뚱하게 묶거나 쪼갤 수 있어요. 병합 셀이면 더 심해져요. - 읽기 순서(reading order) — 2단 편집·사이드바가 있으면 텍스트가 좌→우, 위→아래 순서로 안 나올 수 있어요.
- 띄어쓰기·붙임 — 폰트 커닝 때문에
공급가액이공급 가액처럼 벌어지거나, 반대로 두 값이 붙어 나오기도.
그래서 구조체 파싱이라고 검증을 건너뛰면 안 돼요. 문자는 정확하지만 “구조” 는 틀릴 수 있다 — 이게 핵심이에요. 표 경계가 잘못 잡히면 단가와 공급가액이 한 칸씩 밀려서, 문자 하나 안 틀리고도 완전히 틀린 레코드가 만들어져요.
⚠️ 괘선 없는 표는 pdfplumber 의
table_settings로"vertical_strategy": "text"를 주면 글자 정렬 기준으로 셀을 잡아줘서 나아질 때가 많아요. 그래도 문서 양식마다 튜닝이 필요합니다.
4. OCR — 픽셀에서 글자를 읽어낼 때
스캔본은 선택지가 없어요. 이미지를 글자로 바꾸는 OCR 이 유일한 길이에요. 그런데 OCR 은 본질적으로 추론 이라, 아무리 좋은 엔진이라도 확률적으로 틀립니다. 우리 목표가 “틀리지 않은 데이터” 인 만큼, OCR 은 틀린다는 전제 위에서 다뤄야 해요.
OCR 에 넣기 전에 페이지를 이미지로 렌더링하는데, 이때 해상도가 정확도를 크게 좌우 해요. 최소 300 DPI 를 권합니다.
import fitz
doc = fitz.open("invoice_scanned.pdf")
pix = doc[0].get_pixmap(dpi=300) # 150 이하면 작은 글씨가 뭉개져요
pix.save("page0.png")
4-1. Upstage Document Parse — 상용 API
한국어 문서·표가 섞인 문서라면 Upstage 가 강력해요. 단순히 글자만 뽑는 게 아니라 레이아웃(제목·문단·표)을 구조로 복원 해서 표를 HTML 로 돌려줘요. 표가 많은 계산서·명세서에서 셀 정렬이 잘 맞는 게 큰 장점이에요.
import os, requests
def upstage_parse(pdf_path: str) -> dict:
"""Upstage Document Parse — 레이아웃을 구조로 복원해서 표까지 살려줌."""
url = "https://api.upstage.ai/v1/document-digitization"
headers = {"Authorization": f"Bearer {os.environ['UPSTAGE_API_KEY']}"}
with open(pdf_path, "rb") as f:
files = {"document": f}
data = {"model": "document-parse", "ocr": "auto"}
resp = requests.post(url, headers=headers, files=files, data=data)
resp.raise_for_status()
return resp.json()
응답에서 표는 HTML 로 들어와서, 셀 구조가 그대로 살아 있어요.
result = upstage_parse("invoice_scanned.pdf")
table_html = [el for el in result["elements"] if el["category"] == "table"][0]
print(table_html["content"]["html"][:80])
<table><tr><td>품목</td><td>수량</td><td>단가</td><td>공급가액</td></tr>
ocr: "auto" 로 두면 텍스트 레이어가 있으면 그걸 쓰고, 없으면 OCR 을 돌리는 걸 Upstage 쪽에서 알아서 판단해요. 다만 API 호출 비용·외부 전송 이 붙으니, 민감 문서면 데이터 반출 정책을 먼저 확인하세요.
4-2. PaddleOCR — 오픈소스
외부로 문서를 보낼 수 없거나 비용을 아끼고 싶으면 오픈소스로 갑니다. 한국어는 PaddleOCR·EasyOCR 이 무난하고, Tesseract(pytesseract)는 가장 오래됐지만 한글 정확도는 앞 둘보다 떨어지는 편이에요.
PaddleOCR 은 인식 결과와 함께 글자별 confidence 를 줘서, 이게 검증에 정말 중요해요.
from paddleocr import PaddleOCR
ocr = PaddleOCR(lang="korean")
result = ocr.ocr("page0.png")
for box, (text, conf) in result[0][:3]:
print(f"{conf:.2f} {text}")
0.99 세금계산서
0.97 USB-C 케이블
0.61 8O,OOO
마지막 줄을 보세요. 80,000 을 8O,OOO 로 읽었고, confidence 도 0.61 로 뚝 떨어졌어요. 숫자 0 을 알파벳 O 로 오인한 거예요. OCR 이 틀리는 전형적인 모습이고, 동시에 confidence 가 그 위험을 미리 알려준다 는 것도 보여줘요. 이 신호를 검증 단계에서 붙잡는 게 정확도 방어의 핵심이에요.
OCR 이 자주 헷갈리는 짝을 정리하면 이래요.
| 원본 | 자주 오인식 | 위험한 이유 |
|---|---|---|
0 (숫자 영) |
O o (알파벳) |
금액이 문자열로 깨져 숫자 파싱 실패 |
1 |
l I |
수량·자리수 오류 |
5 |
S |
금액 오류 |
, . |
서로 뒤바뀜 | 1,000 ↔ 1.000 자리수·소수 오류 |
rn |
m |
품목명 손상 |
장점
- 스캔본을 다룰 수 있는 유일한 길 — 텍스트 레이어가 없으면 이거뿐.
- 오픈소스는 오프라인·무비용 — 민감 문서를 밖으로 안 보냄.
- confidence 를 준다 — 어디를 의심해야 할지 스스로 알려줌.
약점
- 인식 오류가 원천적으로 섞임 — 위 표처럼. 100% 는 없어요.
- 전처리에 민감 — 기울기·그림자·저해상도면 정확도가 급락. 300 DPI·기울기 보정이 사실상 필수.
- 느리고 무거움 — 상용 API 는 비용, 오픈소스는 GPU/CPU 시간.
5. DB 에 넣기 직전 — 검증 레이어
여기가 이 글에서 제일 하고 싶은 얘기예요. 구조체 파싱이든 OCR 이든, 뽑은 값을 바로 DB 에 넣지 않습니다. 그 사이에 검증 레이어를 하나 두는 게, “틀린 데이터를 안 넣는다” 는 목표를 지키는 실질적인 장치예요.
5-1. 타입·형식 검증
금액이라면 숫자로 깔끔하게 떨어져야 해요. 앞의 8O,OOO 같은 값은 여기서 걸러야 합니다.
import re
def parse_amount(raw: str) -> int | None:
"""콤마·공백만 정리하고, 숫자가 아니면 None(=사람에게 넘김)."""
cleaned = raw.replace(",", "").replace(" ", "")
if not re.fullmatch(r"\d+", cleaned):
return None # O·l 섞인 값은 여기서 걸림
return int(cleaned)
print(parse_amount("80,000")) # 구조체 파싱이 뽑은 값
print(parse_amount("8O,OOO")) # OCR 이 잘못 읽은 값
80000
None
여기서 유혹이 하나 있어요 — O 를 0 으로 자동 치환해서 억지로 살리고 싶어지죠. 하지만 그건 틀린 값을 그럴듯하게 만들어 조용히 DB 에 넣는 짓이라, 우리 목표랑 정반대예요. None 으로 떨어뜨려서 사람 확인 큐로 보내는 게 맞아요. 자동 보정은 정확도를 지키는 게 아니라 숨기는 거예요.
5-2. 교차 검증 — 합계를 다시 계산
문서 안에 자기 자신을 검산할 수 있는 값 이 있으면 꼭 쓰세요. 명세서라면 항목 금액의 합이 표시된 합계와 같아야 하죠.
def rows_consistent(line_items: list[dict], stated_total: int) -> bool:
"""항목 금액의 합이 문서에 적힌 합계와 일치하는지."""
return sum(r["amount"] for r in line_items) == stated_total
items = [{"name": "USB-C 케이블", "amount": 80000},
{"name": "HDMI 케이블", "amount": 45000}]
print(rows_consistent(items, stated_total=125000)) # 문서의 합계란 값
print(rows_consistent(items, stated_total=125060)) # 한 항목을 잘못 읽었다면
True
False
False 가 나오면 그 문서는 어딘가 한 글자가 틀린 거예요. 어떤 항목인지 몰라도 “이 문서는 통과시키면 안 된다” 는 건 확실하니, 자동 적재에서 빼고 사람에게 넘깁니다. OCR 뿐 아니라 구조체 파싱의 표 밀림도 이 검산에서 같이 잡혀요.
5-3. confidence 게이팅 (OCR 한정)
OCR 은 값마다 confidence 가 있으니, 임계값 밑은 무조건 사람 확인으로 라우팅해요.
def needs_review(fields: list[dict], threshold: float = 0.90) -> bool:
"""confidence 가 임계값 미만인 필드가 하나라도 있으면 검토 대상."""
return any(f["conf"] < threshold for f in fields)
fields = [{"text": "USB-C 케이블", "conf": 0.97},
{"text": "8O,OOO", "conf": 0.61}]
print(needs_review(fields)) # 0.61 필드 때문에
True
임계값은 문서 중요도로 정하세요. 금액·계좌처럼 틀리면 치명적인 필드는 0.95 이상으로 빡세게, 참고용 메모는 느슨하게. 필드별로 임계값을 다르게 두는 게 실전에선 잘 먹혀요.
6. 정리 — 언제 무엇을 쓰나
한 장으로 요약하면 이래요.
| 상황 | 1순위 | 이유 |
|---|---|---|
| 텍스트 레이어 있음 (네이티브 PDF) | 구조체 파싱(pdfplumber·PyMuPDF) | 문자 정확·빠름·저렴 |
| 표가 복잡한 네이티브 PDF | 구조체 파싱 + 표 튜닝, 안 되면 Upstage | 셀 경계·병합 셀 대응 |
| 스캔본, 한국어·표 많음 | Upstage Document Parse | 레이아웃·표 복원 강함 |
| 스캔본, 반출 불가·비용 민감 | 오픈소스 OCR(PaddleOCR) | 오프라인·무비용·confidence 제공 |
| 무엇이든 DB 적재 전 | 검증 레이어 필수 | 타입·합계 재계산·confidence 게이팅 |
가장 중요한 원칙 세 가지만 다시 짚을게요.
- 판별부터. 텍스트 레이어가 있으면 구조체 파싱, 없으면 OCR. 아무거나 쓰지 말고 문서 단위로 자동 라우팅.
- 구조체 파싱도 안심 금지. 문자는 정확해도 표 구조는 밀릴 수 있어요. 검산으로 잡아야 해요.
- 틀린 값은 숨기지 말고 걸러낸다. 자동 보정으로 그럴듯하게 만드는 대신,
None으로 떨어뜨려 사람 확인 큐로. 조용히 들어간 틀린 값 하나가 나중에 제일 비싸요.
PDF 에서 뽑은 데이터를 믿을 수 있게 만드는 건 결국 추출 도구의 성능 이 아니라 검증을 붙였느냐 의 문제였어요. 좋은 OCR 을 골라도 검증이 없으면 언젠가 틀린 값이 새어 들어가고, 평범한 구조체 파싱이라도 검산을 걸면 잘못된 레코드를 문 앞에서 돌려보낼 수 있거든요.
일단 오늘은 여기까지…..
다음 글에서는 이 검증 레이어를 실제 적재 파이프라인(배치 인입 → 검증 → 적재 → 사람 확인 큐)에 어떻게 꿰는지 정리해볼게요.