들어가며
저는 대학 행정 AI 에이전트 '슈메이트'를 만들고 있습니다. 교내에 흩어져 있는 데이터를 모아 실제 업무와 연결하는 서비스입니다.
그런데 그 데이터의 원본이 전부 PDF입니다. 동아리들이 매달 제출하는 활동보고서와 회의록이 그것입니다. 사람이 읽으라고 만든 문서라 그대로는 조회할 수도, 집계할 수도 없습니다. "문서가 있다"와 "데이터로 쓸 수 있다"는 다른 문제였습니다.
그래서 PDF를 구조화된 데이터로 바꾸는 파서를 만들기로 했습니다.
1. 어떻게 읽을까 - OCR? LLM?
처음 떠오른 선택지는 두 가지였습니다.
OCR : PDF를 이미지로 바꾼 뒤 글자를 인식하는 방식입니다. 스캔본이면 이 방법밖에 없습니다.
LLM : 문서를 통째로 모델에 넣고 "여기서 일시와 참여 인원을 뽑아줘"라고 시키는 방식입니다. AI 서비스를 만들고 있으니 자연스럽게 이쪽으로 기울었습니다.
그런데 둘 다 마음에 걸리는 점이 있었습니다.
OCR은 한국어 인식률이 들쭉날쭉하고, 표 안의 글자가 섞이면 순서가 엉킵니다. LLM은 편하지만 없는 값을 그럴듯하게 만들어낼 수 있습니다. 우리가 만드는 건 행정 문서의 근거로 쓰일 데이터인데, 참여 인원이 조용히 틀린 채로 사업계획서에 들어가면 곤란합니다.
그래서 "정말 이 방법들이 필요한가"부터 확인해보기로 했습니다.
2. 찾아보니 pdfplumber로 충분했다
확인할 건 하나였습니다. 이 PDF가 텍스트를 품고 있는가, 아니면 스캔한 이미지인가.
한글이나 워드에서 "PDF로 내보내기"를 하면 글자 정보가 그대로 남습니다. 이 경우 OCR이 필요 없습니다. 반대로 종이에 출력해 스캔한 파일이면 사진 한 장이라 OCR을 거쳐야 합니다.
pdfplumber로 한 줄 돌려봤습니다.
import pdfplumber
with pdfplumber.open("샘플.pdf") as pdf:
print(pdf.pages[0].extract_text())글자가 제대로 나왔습니다. 동아리들이 한글 파일로 작성해 PDF로 내보낸 것이라 텍스트 레이어가 살아 있었습니다.
OCR은 필요 없었습니다.
pdfplumber가 뭔가
PDF에서 텍스트·표·좌표를 뽑아내는 파이썬 라이브러리입니다. PDF 내부에는 "이 글자가 이 위치에 있다"는 정보가 들어 있는데, pdfplumber는 그걸 읽어서 사람이 쓰기 좋은 형태로 돌려줍니다.
주로 쓰는 건 두 가지입니다.
| 메서드 | 역할 |
|---|---|
extract_text() | 페이지 전체 텍스트를 문자열로 |
extract_tables() | 표를 [[셀, 셀], [셀, 셀]] 형태로 |
표 추출이 특히 강점입니다. PDF에 그려진 선의 위치를 분석해서 어디가 셀 경계인지 판단해줍니다. 비슷한 라이브러리로 PyMuPDF가 있는데 속도는 더 빠르지만 표 처리는 pdfplumber 쪽이 낫습니다.
우리 문서는 전부 표로 되어 있어서 이쪽이 맞았습니다.
3. 양식이 곧 스키마였다
extract_tables()를 돌려보고 나서 방향이 확실해졌습니다.
['일 시', '2026년 5월 6일']
['소 속', '동아리 명 : 슈메이트']
['활동 장소', 'PM 미래관 201호\nDE 미래관 202호\n...']
['참여 인원', '38명']
['활동 내용', '파트별 2주차 세션'][라벨, 값] 쌍으로 깔끔하게 나왔습니다. 그리고 이걸 보다가 깨달은 게 있습니다.
활동보고서와 회의록은 동아리연합회가 배포한 공통 양식을 씁니다. 동아리가 달라도 칸의 이름과 순서가 같습니다. 즉 양식의 라벨을 그대로 컬럼으로 삼고, 각 칸의 내용을 값으로 넣으면 그게 곧 테이블이 됩니다.
| 양식의 칸 | 컬럼 |
|---|---|
| 일시 | activity_date |
| 소속 | club_id |
| 활동 장소 | location |
| 참여 인원 | participant_count |
| 활동 내용 | activity_body |
LLM 없이 규칙만으로 파싱이 가능했습니다. 정확하고, 빠르고, 비용이 들지 않고, 무엇보다 없는 값을 지어내지 않습니다.
LLM은 나중에 활동 내용 본문에서 활동 분야를 해석하는 단계에만 쓰기로 했습니다. "파트별 2주차 세션"이 어떤 분야인지 판단하는 건 규칙으로 안 되니까요.
그런데 PDF 1개에 활동이 7건이었다
여기서 스키마를 고쳐야 하는 발견이 나왔습니다.
"5월 활동보고서" 파일 하나를 열어보니 7페이지였고, 페이지마다 다른 활동이 들어 있었습니다. 5월 6일 세션, 5월 18일 아이디어톤, 5월 11일 운영진 회의… 한 달치 활동이 한 파일에 모여 있던 것입니다.
원래 스키마는 activity_report의 기본키가 document_id였습니다. 문서 1건당 활동 1건이라는 가정이었는데, 이대로면 6건이 버려집니다.
CREATE TABLE activity_report (
report_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES document(document_id),
page_no INT NOT NULL,
...
UNIQUE (document_id, page_no)
);page_no를 넣은 건 두 가지 이유입니다.
중복 방지 - 파싱을 두 번 돌려도 같은 페이지가 두 번 들어가지 않습니다.
근거 링크 - 나중에 챗봇이 "5월에 아이디어톤에서 최우수상을 받았습니다"라고 답할 때 출처를 "5월 활동보고서 5페이지"까지 찍어줄 수 있습니다. 페이지가 없으면 담당자가 7장을 넘겨가며 찾아야 합니다.
설계는 실제 파일을 열어봐야 확정된다는 걸 여기서 배웠습니다.
회의록은 구조가 달랐다
활동보고서가 잘 되길래 회의록도 라벨만 바꾸면 되겠거니 했는데, 전부 실패했습니다.
뜯어보니 세 가지가 달랐습니다.
| 활동보고서 | 회의록 | |
|---|---|---|
| 레코드 단위 | 페이지당 1건 | 파일당 1건 (한 회의가 2페이지에 걸침) |
| 헤더 형태 | 세로형 표 (라벨-값 2열) | 가로형 표 1행에 일시·장소·작성자 나열 |
| 본문 위치 | 표 안 활동내용 셀 | 표 밖 텍스트 |
가장 곤란했던 건 헤더였습니다. 작성자 라벨은 셀로 잡히는데 값 칸이 `None`으로 나왔습니다. 표 추출에서 통째로 누락된 것입니다.
그래서 회의록 헤더는 표 대신 텍스트에서 정규식으로 뽑도록 바꿨습니다.
m = re.search(r"작성자\s*([가-힣]{2,4})", text)본문은 표 밖에 있어서 페이지 순서대로 텍스트를 모은 뒤, N. 보고안건 같은 섹션 헤드부터 잘라내는 방식으로 처리했습니다.
4. 코드 작성
두 문서가 이렇게 다르면 파서를 하나로 합칠지 나눌지 고민이 됩니다. 결론은 처리는 나누고, 인터페이스는 통일하는 것이었습니다.
parsers/
├── __init__.py # 진입점 / 문서 종류 디스패치
├── base.py # 공통 유틸 (날짜·인원·텍스트 정규화)
├── activity_report.py # 활동보고서
└── meeting_minutes.py # 회의록호출하는 쪽은 문서 종류를 신경 쓰지 않아도 됩니다.
from parsers import parse
result = parse("5월_활동보고서.pdf", doc_type="activity_report")doc_type을 주지 않으면 1페이지 제목으로 자동 판별합니다. 그리고 업로드 시 지정한 종류와 실제 내용이 다르면 `type_mismatch`를 붙여 검수 대상으로 넘깁니다. 회의록으로 등록된 파일이 실제로는 활동보고서였다면, 엉뚱한 테이블에 들어가기 전에 걸러집니다.
base.py에는 두 문서가 공통으로 쓰는 정규화 로직을 모았습니다.
| 함수 | 역할 |
|---|---|
norm_label | '활 동 장 소' → '활동장소' (양식별 자간 차이 흡수) |
parse_date | '2026년 5월 18일 ~ 5월 30일' → 시작일·종료일·연월로 분해 |
parse_participants | '38명' → 38, '10여 명'·'-' → None |
clean_text | 줄바꿈으로 잘린 URL 재결합, 빈 표기 → None |
양식이 같아 보여도 동아리마다 자간과 표기가 미묘하게 다릅니다. 이걸 파서마다 흩어두면 한 곳만 고치고 다른 곳을 빠뜨리게 됩니다.
실패를 버리지 않기
신경 쓴 게 하나 있습니다. 실패한 페이지를 조용히 넘기지 않는 것입니다.
처음에는 일시 라벨이 없는 페이지를 그냥 건너뛰게 짰습니다. 그런데 이러면 7페이지 중 2페이지가 안 들어가도 아무도 모릅니다.
그래서 반환값을 이렇게 바꿨습니다.
{
"doc_type": "activity_report",
"records": [ { "page_no": 1, "activity_date": "2026-05-06", ... } ],
"failures": [ { "page_no": 3, "reason": "활동 내용이 비어 있음" } ]
}일부가 실패해도 나머지는 정상 반환하되, 실패는 페이지 번호와 사유를 남깁니다.
같은 맥락에서 숫자로 못 바꾸는 값도 원문을 남깁니다.
| 원문 | participant_count | participant_raw |
|---|---|---|
38명 | 38 | 38명 |
10여 명 | null | 10여 명 |
- | null | - |
raw가 없으면 10여 명은 DB에서 그냥 NULL이 되어 정보가 통째로 사라집니다.
개인정보
제출 문서에 학생 실명이 들어 있어서 몇 가지를 지켰습니다.
- 소속 셀에서 동아리명만 뽑고 대표자 이름은 반환하지 않습니다.
- 회의록
author는 스키마 컬럼이라 유지하되, 답변에 노출하지 않도록 주석을 남겼습니다. - 참석자는 명단 대신 인원 수만 결과에 담습니다.
.gitignore에 샘플·업로드 PDF와.env를 넣었습니다.
5. 리뷰 - 샘플 PDF를 넣고 AI로 돌렸다
코드를 다 짜고 나서 샘플 PDF 몇 개를 넣어 실제로 돌려보면서, 코드 전체를 AI에게 리뷰받았습니다. 혼자 봤으면 몰랐을 것들이 나왔습니다. 세 가지가 특히 기억에 남습니다.
init.py → __init__.py
파일명에 언더스코어가 빠져 있었습니다. 그래서 패키지 초기화가 안 됐고, 정작 docstring에는 from parsers import parse라고 적혀 있었습니다. 그대로 따라 하면 ImportError가 납니다.
혼자 테스트할 때는 다른 방식으로 호출하고 있어서 몰랐습니다.
.gitignore 경로가 한 단계 어긋나 있었다
이게 가장 아찔했습니다.
Backend/.gitignore 안에 backend/samples/라고 적어뒀는데, .gitignore의 슬래시 경로는 그 파일이 있는 위치를 기준으로 해석됩니다. 즉 존재하지도 않는 Backend/backend/samples/를 가리키고 있었던 겁니다.
결과적으로 학생 이름과 사진이 든 샘플 PDF가 커밋 대상으로 잡혀 있었습니다. 다행히 실제로 커밋된 이력은 없었지만(git ls-files로 확인), 한 번만 올라갔어도 히스토리에 영구히 남을 뻔했습니다.
.gitignore를 쓰고 나서 git status로 실제로 제외됐는지 확인하는 습관이 필요하다는 걸 배웠습니다.
참석자 수가 한 명씩 모자랐던 이유
참석자 명단을 뽑을 때 작성자를 제외하는 로직을 넣어뒀습니다. 작성자가 명단에도 들어 있으면 중복이니까요.
taken = {head["author"]}그런데 이게 의도와 반대로 동작하고 있었습니다. 앞서 말했듯 작성자 셀은 표 추출에서 `None`으로 나오기 때문에 표에서 읽은 명단에는 애초에 작성자가 중복으로 들어올 수가 없었습니다. 이 코드는 중복 제거는 못 하면서, 성원확인 행에 있는 실제 참석자 한 명을 지우고 있었습니다.
제외 로직을 걷어내고 실제로 필요한 두 가지로 바꿨습니다.
- 병합 셀 때문에 같은 이름이 두 번 읽히는 것 제거
- 라벨이 이름으로 잘못 잡히는 것 차단 (
성원확인이 4글자 한글이라[가-힣]{2,4}패턴에 그대로 걸렸습니다)
attendee_count가 6에서 7로 바로잡혔고, 실제 명단과 일치합니다.
세 건 모두 "그럴듯하게 틀리는" 버그였습니다. 에러가 나면 바로 알지만, 값이 그냥 조금 다르면 눈으로는 안 걸립니다.
6. 남은 것
- 다른 동아리 회의록으로 미검증 — 활동보고서는 공통 양식이라 안전한데, 회의록은 동아리마다 자체 양식을 쓸 가능성이 있습니다.
read_table이tables[0]과row[1]만 참조해서, 표가 2개 이상이거나 값이 여러 열에 걸친 양식에서는 보완이 필요합니다.- 쪽번호 제거가 숫자만 있는 줄을 무조건 버려서, 본문에 숫자 단독 줄이 있으면 유실됩니다.
마치며
AI 서비스를 만들고 있으니 문서도 당연히 LLM으로 읽어야 한다고 생각했는데, 실제로 열어보니 라이브러리 하나로 충분했습니다. 양식이 정해진 문서라면 규칙 기반이 더 정확하고 빠릅니다.
그리고 만들면서 든 시간은 "어떻게 읽을까"보다 "틀렸을 때 어떻게 알까" 쪽이 훨씬 많았습니다. 실패한 페이지를 남기고, 숫자로 못 바꾼 값의 원문을 보존하고, 문서 종류가 어긋나면 표시하는 것들이 전부 거기서 나왔습니다.
행정 문서의 근거로 쓰일 데이터라 더 그랬던 것 같습니다. 숫자 하나가 틀리면 그게 사업계획서에 그대로 들어가기 때문입니다.
Comments