LLM으로 PDF 재무제표를 자동 추출하기

Posted on November 18, 2025
LLM으로 PDF 재무제표를 자동 추출하기

LLM으로 PDF 재무제표를 자동 추출하기

들어가며

기업의 재무제표는 회사의 건강 상태를 보여주는 중요한 문서입니다. 하지만 수많은 기업의 재무제표를 분석해야 할 때, 각각의 PDF 문서를 일일이 열어보고 필요한 숫자를 찾아내는 작업은 매우 지루하고 시간이 오래 걸립니다.

더 큰 문제는 재무제표마다 형식이 조금씩 다르다는 것입니다. 어떤 회사는 "당기순이익"이라고 표기하고, 다른 회사는 "당기순손실"이라고 표기합니다. 같은 의미의 항목도 "자산총계"와 "자산"처럼 다르게 표현되기도 합니다.

이런 문제를 해결하기 위해, 최근 주목받고 있는 대규모 언어 모델(LLM)을 활용하여 PDF 재무제표를 자동으로 추출하고 정규화하는 시스템을 구축했습니다. 이 글에서는 그 과정을 소개합니다.

왜 LLM인가?

전통적인 방식으로 PDF에서 데이터를 추출하려면 다음과 같은 작업이 필요했습니다:

  • PDF를 텍스트로 변환
  • 정규 표현식이나 규칙 기반으로 데이터 파싱
  • 표 형식 인식 및 데이터 추출
  • 예외 상황 처리를 위한 수많은 조건문 작성

하지만 재무제표는 회사마다 형식이 다르고, 때로는 스캔된 이미지 PDF도 있어서 이런 방식으로는 한계가 있었습니다.

재무제표 샘플

LLM은 이런 문제를 유연하게 해결해줍니다:

  • 문맥 이해: LLM은 "재무상태표"가 무엇인지, "자산"과 "부채"가 어떻게 구성되는지 이미 알고 있습니다
  • 유연성: 표 형식이 조금 다르거나, 항목명이 달라도 의미를 파악하여 추출할 수 있습니다
  • 이미지 처리: 최신 LLM은 이미지를 직접 읽을 수 있어, 스캔된 PDF도 처리 가능합니다
  • 구조화된 출력: Structured Output 기능을 사용하면 원하는 형식의 데이터로 바로 받을 수 있습니다

기존 OCR 방식과의 비교

많은 분들이 "OCR로도 PDF에서 텍스트를 추출할 수 있는데, 굳이 LLM을 써야 하나?"라고 궁금해하실 것 같습니다. 실제로 두 방식을 비교해보면 큰 차이가 있습니다.

OCR 방식의 작업 흐름

OCR 방식의 작업 흐름

LLM 방식의 작업 흐름

LLM 방식의 작업 흐름

구체적인 차이점

1. 문맥 이해 능력

OCR은 단순히 글자를 인식할 뿐, 의미를 이해하지 못합니다:

OCR 결과: "자산총계 1,234,567 부채총계 789,012" → 이게 무엇을 의미하는지 모름 → 자산과 부채의 관계를 모름 → 별도의 파싱 로직 필요
  • 물론 OCR에 Layout Analysis를 결합하여 문서 구조를 인식하거나, 후처리 단계에서 언어 모델을 활용하여 신뢰도를 평가할 수도 있습니다. 하지만 이 방식은 각 단계(Layout Analysis → OCR → 후처리)가 독립적으로 작동하는 파이프라인 구조입니다. 따라서 앞 단계에서 발생한 오류가 뒤 단계로 전파되어 누적되는 문제가 있습니다.

LLM은 의미를 이해합니다:

LLM 결과: { "type": "재무상태표", "items": [ {"account": "자산", "amount": 1234567}, {"account": "부채", "amount": 789012} ] } → 이것이 재무상태표임을 인식 → 자산과 부채의 계층 관계 파악 → 바로 사용 가능한 구조화된 데이터

2. 표 구조 인식

재무제표는 복잡한 표 형식입니다. OCR은:

자산 당기 전기 유동자산 1,000,000 900,000 현금 500,000 400,000 매출채권 500,000 500,000 비유동자산 500,000 450,000 → OCR은 이를 단순 텍스트 나열로 인식 → 들여쓰기가 "계층"을 의미한다는 것을 모름 → "당기"와 "전기"가 다른 컬럼임을 파악하기 어려움

LLM은 자동으로 표 구조를 파악합니다:

{ "periods": [ { "period_name": "당기", "items": [ { "account": "유동자산", "amount": "1,000,000", "sub_accounts": [ {"account": "현금", "amount": "500,000"}, {"account": "매출채권", "amount": "500,000"} ] } ] }, { "period_name": "전기", "items": [...] } ] }

3. 비정형 데이터 처리

실제 재무제표는 천차만별입니다:

회사 A: "자산총계" 회사 B: "자 산 총 계" (공백이 많음) 회사 C: "I. 자산총계" 회사 D: "총자산" 회사 E: "Total Assets" (영문)

OCR 방식이라면 각각에 대한 처리 로직이 필요:

# OCR 후처리 예시 if "자산" in text and "총계" in text: account = "자산" elif "총자산" in text: account = "자산" elif "Total Assets" in text: account = "자산" # ... 수십 개의 조건문

LLM은 이런 변형들을 자동으로 이해하고 통일된 형식으로 추출합니다.

4. 오류 복원력

스캔 품질이 나쁜 PDF의 경우:

OCR 결과: "자산흥계 1,Z34,567" (총→흥, 2→Z로 잘못 인식) → 파싱 실패 → 수작업 수정 필요

LLM은 문맥으로 오류를 복원:

LLM: "흥계"는 문맥상 "총계"일 것이고, "Z34"는 "234"일 가능성이 높음 → {"account": "자산총계", "amount": "1,234,567"} → 올바르게 추출

5. 계층 관계 이해

재무제표는 계층 구조를 가집니다:

계층 관계

OCR의 특징:

  • 들여쓰기 파싱 로직 구현 필요
  • 표 레이아웃 분석 필요
  • 각 회사의 형식마다 다른 처리 필요

LLM의 특징:

  • 자동으로 계층 관계 파악
  • 프롬프트로 원하는 깊이만큼 추출 가능
  • 추가 코드 불필요
  • 일반적인 상황을 벗어난 특수한 케이스에 대해서는 제어가 힘듦

6. 개발 및 유지보수 복잡도

OCR 기반 시스템:

- OCR 전처리 (이미지 보정, 노이즈 제거) - 표 영역 감지 - 셀 분리 및 텍스트 추출 - 계층 구조 파싱 - 숫자 형식 정규화 - 예외 케이스 처리 - 회사별 특수 형식 처리 ...

LLM 기반 시스템:

프로젝트 전체: 약 200줄

- 데이터 모델 정의 - LLM API 호출 - 기본 정규화 - 유틸리티

결론: 언제 어떤 방식을 사용해야 할까?

OCR 방식이 적합한 경우:

  • 형식이 완전히 고정되어 있는 문서, 이미 잘 작동하는 템플릿이 있는 경우
  • 단순히 텍스트만 추출하면 되는 경우
  • 문서의 수, 처리량이 매우 많아 비용이 중요한 경우
  • 오프라인 환경에서 처리해야 하는 경우

LLM 방식이 적합한 경우:

  • 형식이 다양한 문서
  • 의미 이해와 구조화가 필요한 경우
  • 빠른 개발과 프로토타입이 필요한 경우
  • 재무제표, 계약서, 보고서 같은 복잡한 문서

우리의 재무제표 추출 케이스는 전형적인 후자에 해당했습니다.

시스템 구성

우리 시스템은 크게 세 단계로 구성됩니다:

시스템 구성

1단계: 데이터 모델 설계

재무제표 추출 시스템을 설계할 때 가장 먼저 결정해야 할 것은 "LLM의 출력을 어떻게 받을 것인가?"입니다. 멀티모달 LLM은 이미지를 입력받을 수 있지만, 출력은 여전히 텍스트 기반입니다. 우리는 PDF로부터 재무제표를 추출하여 정형 데이터로 만들어야 합니다. 만약 아무런 제약 조건 없는 자유 형식의 텍스트를 받았다면 파싱 과정에서 많은 어려움이 발생할 수 있습니다.

왜 Structured Output을 선택했는가?

LLM에서 구조화된 데이터를 얻는 방법은 크게 두 가지가 있습니다:

방법 1: 프롬프트로 JSON 생성 요청

response = llm.generate("재무제표를 JSON으로 추출해줘") result = json.loads(response.text)

방법 2: Structured Output 사용

response = client.generate( config={"response_schema": Account} )

두 방법 중 우리는 방법 2(Structured Output)를 선택했습니다. 그 이유는 다음과 같습니다.

방법 1의 문제점:

  1. 형식 불일치: LLM이 때때로 Markdown 코드 블록으로 감싸거나, 설명 텍스트를 추가

    "재무제표 데이터입니다: {"company": "ABC"} 이상입니다."
  2. 필드명 변동: 같은 프롬프트인데도 company_namecompanyName을 혼용

  3. 타입 불일치: 숫자를 때로는 문자열로, 때로는 숫자로 반환

  4. 파싱 에러: JSON 형식이 깨지는 경우 발생

Structured Output을 사용하면 이런 문제를 해결할 수 있습니다:

Structured Output은 LLM이 토큰을 생성할 때 제약 조건(constraint)으로 작동합니다. LLM은 다음 토큰을 선택할 때 정의된 스키마에 부합하는 후보만 고려하게 됩니다. 스키마를 위반하는 출력은 생성 단계에서 원천적으로 차단되므로, 항상 유효한 JSON이 보장됩니다. 이를 통해 출력의 일관성이 보장되고 파싱 에러가 발생하지 않습니다.

from pydantic import BaseModel # Python의 데이터 검증 라이브러리 class Account(BaseModel): account: str amount: str | None # LLM이 반드시 이 스키마를 따름 response = client.generate( config={"response_schema": Account} )

결과:

  • 항상 올바른 JSON 형식
  • 필드명 일관성 보장
  • 필수/선택 필드 명확

왜 느슨한 제약조건을 사용했는가?

Structured Output을 사용하기로 했다면, 다음 질문은 "데이터 타입을 얼마나 엄격하게 정의할 것인가?"입니다.

숫자 필드를 int가 아닌 str로 정의한 이유:

class BaseAccount(BaseModel): account: str = Field(description="계정의 이름") amount: str | None = Field(description="계정의 값") # int가 아닌 str

숫자 표기의 다양성:

회사 A: "1,234,567" (쉼표 구분) 회사 B: "1.234.567" (점 구분, 유럽식) 회사 C: "(1,234,567)" (괄호는 음수 의미) 회사 D: "1,234,567.00" (소수점 포함) 회사 E: "△1,234,567" (△는 음수) 회사 F: "해당없음" (값 없음을 텍스트로 표현)

만약 amount: int로 정의했다면 LLM은:

  • 쉼표를 어떻게 처리해야 할지 혼란
  • 괄호나 특수기호를 int로 변환하다 실패
  • "해당없음"을 0으로 잘못 해석할 수 있음

LLM에게 숫자 포맷 정규화까지 함께 요청할 수도 있습니다. 그러나 실험 결과, LLM이 "(1,234)"를 "-1234"로, "△567"을 "-567"로 변환할 때 일관성이 떨어지는 것을 확인했습니다. 명확한 규칙 기반 정규화 로직을 Python 코드로 구현하는 것이 더 안정적이었습니다.

후처리에서 정확하게 변환:

def _to_int(text: str | None) -> int | None: if text in {"NA", "해당없음", "null"}: return None # 괄호는 음수 is_negative = text.startswith("(") text = text.replace("(", "").replace(")", "") # △ 기호 제거 text = text.replace("△", "") # 쉼표 제거 text = text.replace(",", "") # 소수점은 버림 if "." in text: text = text.split(".")[0] return -int(text) if is_negative else int(text)

이 방식의 장점:

  • LLM은 "있는 그대로" 추출만 하면 됨 (단순 작업)
  • 복잡한 변환 로직은 Python 코드로 명확하게 처리
  • 원본 데이터 보존으로 디버깅 용이
  • 새로운 형식 발견 시 정규화 로직만 수정

재무제표 구조 설계

Structured Output을 사용하기로 했으므로, 이제 재무제표를 어떤 구조로 표현할지 정의해야 합니다. 우리는 Pydantic을 사용하여 다음과 같은 계층적 구조를 설계했습니다:

재무제표 구조

이렇게 구조를 정의하면 LLM에게 "이런 형식으로 데이터를 추출해줘"라고 명확하게 지시할 수 있습니다.

Structured Output의 제약사항 (Gemini API의 경우)

하지만 Gemini API는 JSON Schema의 모든 기능을 지원하지 않습니다. 특히 다음과 같은 제약이 있었습니다:

지원하지 않는 기능:

  1. Self-Reference (자기 참조)

    • 재귀적 구조를 정의할 수 없음
    • 예: 트리 구조에서 노드가 자신을 참조하는 경우
  2. $ref 키워드 제한

    • JSON Schema의 참조 기능이 제한적
  3. anyOf, oneOf, allOf

    • 복잡한 조건부 스키마 불가
  4. 스키마 복잡도 제한

    • 너무 깊은 중첩이나 많은 속성은 거부될 수 있음

우리가 직면한 문제:

처음에는 계층 구조를 재귀적으로 정의하고 싶었습니다:

# 이상적이지만 Gemini에서 불가능한 방식 class Account(BaseModel): account: str amount: str | None sub_accounts: list['Account'] | None # Self-reference 불가

이렇게 하면 무한 깊이의 계층을 표현할 수 있지만, Gemini는 self-reference를 지원하지 않아 에러가 발생합니다.

해결 방법:

계층을 고정된 깊이로 제한하고 별도 클래스를 만들었습니다:

# 실제 사용한 방식 class SubAccount(BaseAccount): # 하위 계정은 더 이상 sub_accounts를 가지지 않음 pass class Account(BaseAccount): # 2단계까지만 허용 sub_accounts: list[SubAccount] | None = Field( description="계정의 하위 항목", default=None )

2단계: LLM 프롬프트 설계

LLM에게 어떤 작업을 시킬지 명확하게 알려주는 것이 중요합니다. 우리가 사용한 프롬프트의 핵심 내용은:

기업의 재무제표를 추출합니다. - 재무제표는 재무상태표와 손익계산서 등으로 구성 - 입력되는 파일은 이미지로 구성된 PDF 일 수 있음 - 추출은 JSON 형태로 계층적으로 추출해야 함 - 일반적으로 2개의 기수(전기, 당기)로 이루어져 있음 재무상태표는 2단계까지만 추출 - 예: 자산(1단계), 유동자산(2단계), 비유동자산(2단계) 손익계산서는 1단계까지만 추출 - 예: 매출액(1단계), 매출원가(1단계), 매출총이익(1단계)

여기서 중요한 점은:

  1. 명확한 범위 설정: "2단계까지만" 같은 구체적인 제약을 제시
  2. 예시 제공: 어떤 항목을 추출해야 하는지 예시로 명확히 함
  3. 예외 상황 고려: "이미지 PDF일 수 있음"처럼 다양한 경우를 언급

3단계: 추출 프로세스

실제 추출은 Google Gemini API를 사용하여 다음과 같이 진행됩니다:

  1. PDF 파일을 Gemini에 업로드
  2. 파일이 처리될 때까지 대기
  3. 미리 정의한 데이터 모델과 프롬프트로 추출 요청
  4. JSON 형식으로 결과 수신

핵심 코드는 매우 간단합니다:

# PDF 파일 업로드 handle = client.files.upload(file=pdf_file) # LLM에 추출 요청 response = client.models.generate_content( model="gemini-2.5-pro", config={ "system_instruction": 프롬프트, "response_schema": 데이터모델, "response_mime_type": "application/json" }, contents=[handle] )

여기서 response_schema에 1단계에서 정의한 Pydantic 모델을 지정하면, LLM이 항상 일관된 구조의 JSON을 생성합니다.

4단계: 데이터 정규화

LLM이 추출한 데이터는 아직 완벽하지 않습니다. 예를 들어:

  • "자산총계"와 "자산"은 같은 의미지만 다르게 표현됨
  • "당기순손실"은 "당기순이익"의 음수 값으로 표현되어야 함
  • 각 회사마다 다른 계정 체계를 사용함

따라서 별도의 정규화 단계가 필요합니다:

정규화_규칙 = { "자산총계": ("자산", False), "부채총계": ("부채", False), "당기순손실": ("당기순이익", True), # True는 부호 반전 "영업손실": ("영업이익", True), ... }

이 규칙을 적용하면:

  • "당기순손실 1,000만원" → "당기순이익 -1,000만원"으로 변환
  • 모든 회사의 데이터가 동일한 계정 체계로 통일됨

5단계: 계층 구조 재계산

재무제표는 계층적 구조를 가지고 있습니다. 예를 들어:

자산 = 유동자산 + 비유동자산 유동자산 = 현금및현금성자산 + 매출채권 + ...

만약 "유동자산" 값이 누락되었다면, 하위 항목들의 합으로 계산할 수 있습니다. 이런 로직을 구현하여 데이터의 완성도를 높였습니다.

실제 적용 결과

이 시스템을 사용하여 수백 개의 재무제표 PDF를 처리한 결과:

  1. 시간 절약: 수작업으로 하면 며칠 걸릴 작업을 몇 시간 안에 완료
  2. 정확도: 대부분의 경우 95% 이상의 정확도로 추출
  3. 일관성: 모든 데이터가 동일한 형식으로 정규화되어 분석이 용이

물론 완벽하지는 않습니다. 가끔 LLM이:

  • 특이한 형식의 재무제표를 잘못 해석하는 경우
  • 숫자의 자릿수를 잘못 인식하는 경우

등이 있습니다. 하지만 전체 작업량의 10% 정도만 수작업으로 검토하면 되니, 여전히 큰 효율 개선입니다.

이 방식의 한계

물론 이 방식이 완벽한 것은 아닙니다:

  1. 비용: 대량 처리 시 API 비용이 발생
  2. 의존성: 외부 API 서비스에 의존
  3. 검증 필요: 100% 신뢰할 수는 없어서 샘플 검증 필요
  4. 특수 케이스: 매우 비정형적인 재무제표는 여전히 어려움

마치며

LLM을 활용하면 과거에는 상상도 못했던 방식으로 문서 처리를 자동화할 수 있습니다. 특히 재무제표처럼:

  • 구조는 비슷하지만 세부 형식이 다양하고
  • 전문 지식이 필요하며
  • 대량 처리가 필요한

작업에 매우 효과적입니다.

중요한 것은 LLM을 "마법의 도구"로 생각하지 말고, 적절한 도구로 활용하는 것입니다:

  • 명확한 데이터 모델 설계
  • 구체적인 프롬프트 작성
  • 체계적인 후처리 및 검증

이 세 가지가 결합되면 실무에서 실제로 사용할 수 있는 시스템을 만들 수 있습니다.

기술 스택

참고로 이 프로젝트에서 사용한 기술:

  • LLM: Google Gemini 2.5 Pro
  • 언어: Python 3.11+
  • 주요 라이브러리:
    • google-genai: Gemini API 클라이언트
    • pydantic: 데이터 모델 정의 및 검증
    • pandas: 데이터 처리 및 분석

전체 코드는 200줄 남짓으로 매우 간결합니다. LLM이 복잡한 로직을 대신 처리해주기 때문입니다.

Copyright © 2024 Cognica, Inc.

Made with ☕️ and 😽 in San Francisco, CA.