9 분 소요

📄 PDF 에서 DB 로, 틀리지 않게 3부작 — PDF 에서 값을 뽑는 두 갈래(구조체 파싱 vs OCR)를 정확도로 갈라 쓰는 법(1편), 그 값을 검증 게이트·멱등 적재·사람 확인 큐로 이어지는 적재 파이프라인에 꿰는 법(2편), 그리고 검토 큐에 쌓인 로그를 되먹여 양식별 전용 파서를 붙이고 회귀·드리프트로 지키는 법(3편)까지, "틀린 값을 DB 에 넣지 않는다"는 한 목표로 이어집니다. 전체 3편.
  1. PDF 에서 데이터를 뽑아 DB 에 넣기 — 구조체 파싱 vs OCR, 뭘 언제 쓰나지금 글
  2. 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐
  3. 검토 로그를 되먹여 양식별 파서 붙이기 — 양식 클러스터링·앵커 추출·회귀 테스트

Summary

PDF 뭉치를 받아서 DB 에 정형 데이터로 넣어야 하는 일이 생겼어요. 세금계산서, 명세서, 신청서 같은 문서에서 금액·수량·날짜를 뽑아 테이블에 적재하는 거죠. 여기서 제일 중요한 목표는 딱 하나였습니다 — 틀린 값을 넣지 않는 것. 8만 원짜리를 80만 원으로 넣거나, 0O 로 읽어서 숫자가 통째로 깨지면, 뒤에서 그 데이터를 믿고 도는 모든 게 같이 틀어져요.

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 정확도 관점에서 이게 구조체 파싱의 가장 큰 장점입니다.

장점

  • 문자 자체는 원본과 동일0O 로 읽는 일이 없어요. 금액·계좌·주민번호 같은 숫자에 특히 안전합니다.
  • 빠르고 저렴 — 외부 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,0008O,OOO 로 읽었고, confidence 도 0.61 로 뚝 떨어졌어요. 숫자 0 을 알파벳 O 로 오인한 거예요. OCR 이 틀리는 전형적인 모습이고, 동시에 confidence 가 그 위험을 미리 알려준다 는 것도 보여줘요. 이 신호를 검증 단계에서 붙잡는 게 정확도 방어의 핵심이에요.

OCR 이 자주 헷갈리는 짝을 정리하면 이래요.

원본 자주 오인식 위험한 이유
0 (숫자 영) O o (알파벳) 금액이 문자열로 깨져 숫자 파싱 실패
1 l I 수량·자리수 오류
5 S 금액 오류
, . 서로 뒤바뀜 1,0001.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

여기서 유혹이 하나 있어요 — O0 으로 자동 치환해서 억지로 살리고 싶어지죠. 하지만 그건 틀린 값을 그럴듯하게 만들어 조용히 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 을 골라도 검증이 없으면 언젠가 틀린 값이 새어 들어가고, 평범한 구조체 파싱이라도 검산을 걸면 잘못된 레코드를 문 앞에서 돌려보낼 수 있거든요.

일단 오늘은 여기까지…..
다음 글에서는 이 검증 레이어를 실제 적재 파이프라인(배치 인입 → 검증 → 적재 → 사람 확인 큐)에 어떻게 꿰는지 정리해볼게요.


다음 글 →: (2/3) 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐