회사 보안상 실제 API 응답 구조, 도메인 데이터, 문서 유형, 필드명은 그대로 드러내지 않았다.
이 글의 예시는 모두 설명을 위한 더미 데이터이며, 실제 구현의 핵심 아이디어만 일반화하여 다뤘다.
들어가며
일반적인 채팅 AI와 달리,
문서 AI는 다양한 문서 구조를 표현하기 위해 비결정적인 구조의 데이터를 뱉는다.
이번 글에서는 불확실한 데이터 구조를 UI로 표현하기 위해 어떤 API 인터페이스를 제안했고, 어떻게 해결했는지 설명하려 한다.
배경
최근 고객사 대상 벤치마크 테스트를 준비하면서 OCR 시스템의 API 인터페이스를 전면적으로 개편했다.
우리 회사 OCR 제품의 시스템은 다음과 같은 구조다.
AI Model
→ Backend
→ Frontend
→ OCR Rendering
Plain Text
복사
기존에는 AI 모델이 OCR 결과를 table, checkbox, group 등의 JSON 구조로 반환하면,
백엔드는 이를 그대로 프론트엔드에 bypass하고
프론트는 해당 구조를 기반으로 Table, Group, Checkbox UI를 렌더링하는 방식이었다.
이미 AI 모델이 정형화된 구조로 데이터를 주기 때문에 프론트에서는 해당 구조에 맞는 UI를 매핑해서 주면 됐다.
예시) AI 모델 쪽 Response
{
"TABLE_01": {
"COLUMNS": ["key1", "key2", ...],
"ROWS": [["val1", "val2"], ["val3", "val4"], ...]
},
"GROUP_01": {
"key": "value",
...
}
}
JSON
복사
예씨) 프론트 쪽 Response
function renderEntity(rawData: OCRRawData) {
const entity = parseEntity(rawData)
switch (entity.type) {
case "table":
return <TableSection entity={entity} />;
case "group":
return <GroupSection entity={entity} />;
case "field":
return <FieldSection entity={entity} />;
default:
return <UnknownSection entity={entity} />;
}
}
TypeScript
복사
문제는 실제 사용자의 문서가 개발자의 상상보다 훨씬 복잡했다는 점이다.
•
표 안의 표
•
그룹 안의 그룹
•
배열 안의 객체
•
객체 안의 배열
•
문서마다 달라지는 중첩 구조
•
같은 의미지만 매번 달라지는 key 구조
AI 모델은 복잡한 문서 구조를 표현하기 위해 비결정적인 데이터 구조로 응답했고, 프론트엔드는 그 비결정적인 구조가 발견될 때마다 파싱 로직을 변경해야했다.
이런 문제로 프론트가 AI 모델의 응답 구조에 강하게 결합되기 시작했다.
Q: 왜 이런 구조가 되었나?
A: 프로젝트 초기에는 실제 사용자 수가 많지 않았고, 제한된 종류의 문서만 대상으로 OCR 기능을 검증하고 있었다.
당시에는 실제 운영 데이터가 거의 없었기 때문에, AI 모델이 반환하는 응답 구조 역시 비교적 단순하고 안정적으로 유지되고 있었다.
또한 제품의 핵심 목표가 “빠른 기능 검증과 사용자 피드백 확보”였고, 당시에는 백엔드 개발까지 함께 담당하고 있었기 때문에 AI 응답을 그대로 프론트엔드에서 렌더링하는 구조가 개발 속도 측면에서 가장 효율적이었다.
초기 테스트 환경에서는 대부분의 문서가 제한된 패턴 안에서 동작했기 때문에 큰 문제가 드러나지 않았지만, 실제 서비스 운영이 시작되자 상황이 달라졌다.
사용자가 업로드하는 문서는 예상보다 훨씬 다양한 구조를 가지고 있었고, 비결정적이고 복잡한 데이터 구조가 지속적으로 등장하기 시작했다.
결국 프론트엔드는 AI 모델의 structure variation을 직접 처리해야 했고, 예상하지 못한 구조가 들어오는 경우 렌더링 파싱 오류가 발생했다.
혹시나 entity가 파싱되지 못하는 일이 발생했을 때 프론트쪽에서 에러를 대응할 수 있게 sentry로 모니터링하고 있었는데, OCR 템플릿 문서 생성 시 하루에 적게는 2개, 많게는 60개 값이 파싱에 실패하기 시작했다.
기존 구조의 가장 큰 문제
1. AI 모델의 변경이 프론트까지 전파됐다
당시 프론트는 AI 모델이 반환하는 구조를 그대로 사용하고 있었다.
문제는 AI 모델 쪽에서 데이터 구조가 조금만 달라져도 프론트 쪽에서 해당 데이터를 파싱하는 로직을 수정해야했다.
예를 들어 이런 구조였다고 해보자.
{
"employment_history": {
"joined_at": "2022-01-01"
}
}
JSON
복사
이 구조가 어느 날 아래처럼 변경되면, 프론트 파싱 로직과 데이터 구조에도 영향이 생긴다.
{
"employment_history": [
{
"joined_at": "2022-01-01"
}
]
}
JSON
복사
"AI 모델의 출력 구조 변경"이라는 외부 변화가 그대로 사용자 UI 오류로 이어지는 구조다.
서비스가 강하게 결합된 구조때문에 AI 엔지니어 역시 프론트의 UI 매핑이 깨지지 않도록 고민하게 됐다.
서비스 경계가 사실상 존재하지 않는 상태였다.
2. 비결정적 구조로 인한 파싱 오류 발생
더 큰 문제는 AI 응답 구조 자체가 문서마다 달랐다는 점이다.
예를 들어 어떤 문서는
{
"family": {
"kim": {
"relation": "father",
"phone": "010-1111-2222"
}
}
}
JSON
복사
형태로 오지만,
어떤 문서는
{
"family": {
"kim": {
"relation": "father",
"documents": [
{
"type": "id_card"
}
]
}
}
}
JSON
복사
처럼 예상치 못한 구조로 올 때가 있었다.
프론트는 primitive 값만 예상하고 있었는데 객체가 들어오면 어떻게 될까?
사용자는 화면에서 이런 걸 보게 된다.
지금은 발생하지 않는 문제긴 하지만, 실제 발생했던 케이스에 맞게 더미 데이터를 넣었다
사용자 입장에서는 당연히:
•
"OCR이 잘못 추출됐네?"
•
"서비스가 깨졌네?"
라고 생각하게 된다.
AI는 틀리지 않았다. 그저 복잡한 문서 구조를 표현했을 뿐이다.
프론트는 확실한 데이터 구조를 원하지만, AI는 비결정적이기 때문에 두 레이어가 묶임으로 인해 유지보수가 점점 힘들어졌다.
벤치마크 테스트: ‘정확도’와 ‘속도’
이번에는 고객사의 벤치마크 테스트를 준비하면서 정확도와 속도에 더 신경써야 했다.
기존 구조는 OCR 모델의 실제 성능 때문이 아니라 "렌더링 구조" 때문에 품질이 낮게 평가될 수 있는 상황이었다.
동시에 AI 엔지니어 쪽에서도 고민이 있었다.
기존 구조는 AI가:
•
table인지
•
group인지
•
checkbox인지
•
nested table인지
•
header 구조가 무엇인지
같은 UI 관점의 구조까지 모두 추론해야 했다.
즉 AI가 사실상, OCR + Parser + UI Schema Generator의 역할까지 하고 있었던 셈이었다.
당연히
•
Prompt complexity 증가
•
Output token 증가
•
추론 비용 증가
•
Latency 증가
•
구조 variation 증가
문제가 발생했다.
AI 엔지니어는 속도와 정확도를 위해 최대한 단순한 key-value 기반 구조로 가고 싶어했다.
예를 들면 AI가 이런 데이터를 준다고 해보자.
{
"document type": "resume",
"title": "resume_01",
"description": {
"k": "v",
...
},
"footer": "..."
}
JSON
복사
프론트는
•
어떤 게 table인지
•
어떤 게 group인지
•
어떤 게 field인지
판단할 수 없게 된다.
결국 누군가는 "구조 추론"을 해야 했다.
그래서 SchemaDocument를 제안했다
당시 내가 제안한 방향은 다음이었다.
AI Raw Output
≠
Frontend Render Schema
Plain Text
복사
즉, AI 응답 구조와 프론트 렌더링 구조를 분리하자는 것이었다.
그리고 그 사이에 Backend Normalize Layer를 두자고 제안했다.
구조는 이렇게 바뀌었다.
AI
↓
Key-Value JSON
↓
Backend Normalize
↓
SchemaDocument
↓
Frontend Rendering
Plain Text
복사
핵심은 "프론트는 이제 AI 응답을 직접 사용하지 않는다"였다.
SchemaDocument 설계
SchemaDocument는 프론트가 렌더링하기 위한 안정적인 UI 스키마다.
export type SchemaDocument = {
sections: SchemaSection[];
};
export type SchemaSection =
| GroupSection
| TableSection
| FieldSection;
TypeScript
복사
핵심은 기존 UI 기준으로 section을 명확하게 분리한 것이다.
Group Section
export type GroupSection = {
id: string;
type: "group";
key: string;
path: string[];
children: SchemaSection[];
};
TypeScript
복사
Table Section
export type TableSection = {
id: string;
type: "table";
key: string;
columns: TableColumn[];
rows: TableRow[];
path: string[];
};
TypeScript
복사
Field Section
export type FieldSection = {
id: string;
type: "field";
key: string;
value: string | number | boolean | null;
valueType: string;
path: string[];
};
TypeScript
복사
중요한 포인트는 AI 응답이 아니라 "렌더링 목적"으로 설계했다는 점이다.
Table 추론 규칙을 어떻게 만들었는가
가장 어려웠던 부분은 어떤 값이 table인가?를 판단하는 문제였다.
AI 응답 예시를 계속 분석하다 보니 흥미로운 패턴이 있었다.
동일 depth에 있는 객체들이 같은 key 구조를 반복하면 사실상 table이라는 점이었다.
예를 들면, 아래와 같다.
{
"members": {
"kim": {
"relation": "father",
"phone": "010"
},
"lee": {
"relation": "mother",
"phone": "011"
}
}
}
JSON
복사
relation | phone |
father | 010 |
mother | 011 |
이 구조는 결국 이와 동일하다.
그래서 백엔드 normalize 단계에서 같은 deterministic rule을 제안했다.
function inferSchemaNode(value: unknown) {
if (isPrimitive(value)) {
return createFieldSection(value);
}
if (isRepeatedObjectShape(value)) {
return createTableSection(value);
}
return createGroupSection(value);
}
TypeScript
복사
이게 왜 중요하냐면, AI는 비결정적이지만, Backend rule은 결정적으로 만들 수 있기 때문이다.
프론트에서 SSE Stream Parsing도 제거할 수 있게 됐다.
기존에는 프론트가 stream chunk를 직접 누적하고 있었다.
JSON.parse를 사용하면, 스트리밍이 다 올 때까지 기다려야하기 때문에, SSE 스트리밍으로 오는 불안정한 JSON 구조를 파싱하여 키-값, 또는 테이블 형태의 컴포넌트 표현했다.
SSE Chunk
→ prefixSum
→ partial JSON parse
→ merge
→ OcrResult 생성
Plain Text
복사
솔직히 말하면 이건 프론트가 할 일이 아니다. (vite 코드에서 lexical하게 파싱하는 법을 보고 배워서, partial parse에 적용하는 경험도 할 수 있었지만…)
partial JSON parsing, 복구 로직, merge conflict, stream corruption 대응까지 전부 프론트 책임이 된다.
레이어의 분리 이후, 이제 구조는 이렇게 바꿀 수 있다.
SSE
→ 백엔드에서 주는 스트리밍 데이터를 그대로 보여주거나 매핑하여 스트리밍 UI 노출
→ 최종 응답 수신 시 전체 교체
Plain Text
복사
if (event.type === "done") {
setOcrResult(event.data);
}
TypeScript
복사
authoritative source를 하나로 통일한 것이다.
덕분에
•
partial parsing 제거
•
chunk merge 제거
•
ordering 문제 제거
•
stream recovery 단순화
•
상태 관리 단순화
가 가능해졌다.
왜 백엔드가 구조 추론을 담당해야 했는가
이 결정은 꽤 중요했다.
초기에는 "AI가 table인지 group인지 다 판단하면 되는 거 아닌가?"라는 의견도 있었다.
어차피 AI가 문서를 분석할 때 인식이 가능하니, 굳이 백엔드에서 한번 더 정규화할 필요가 없었던 것이다.
하지만 장기적으로 보면 백엔드가 normalize layer 역할을 하는 게 훨씬 안정적이었다.
백엔드는
•
schema version 관리 가능
•
backward compatibility 관리 가능
•
migration 가능
•
deterministic transform 가능
•
rendering consistency 보장 가능
하지만 AI는 이렇다.
•
동일 문서에도 output variation 발생 가능
•
prompt 영향 큼
•
모델 버전 영향 큼
•
구조 drift 발생 가능
즉, AI는 추론에 집중 / Backend는 계약에 집중하는 구조가 더 건강했다.
결과
이 구조로 변경한 뒤 가장 큰 변화는 "변경 전파 제거"였다.
기존에는 AI 구조 변경 → Frontend 수정이 필요했다.
하지만 이후에는 AI 구조 변경 → Backend normalize 수정 → Frontend 영향 없음
즉 서비스 간 abstraction layer가 생긴 것이다.
Q: 그냥 프론트 파싱 로직을 백엔드로 옮긴 거 아닌가? 
Q:
프론트가 직접 구조를 해석하면, 결국 UI 안정성이 AI output variation에 종속된다.
그래서 단순히 “파싱 위치”를 옮긴 것이 아니라
•
AI는 extraction에 집중
•
Backend는 schema contract에 집중
•
Frontend는 rendering에 집중
하도록 역할을 분리했다.
AI 측면
•
Prompt 복잡도 감소
•
Output token 감소
•
추론 속도 감소
•
구조 추론 부담 감소
백엔드 측면
•
api의 schema version 관리 가능
•
일관성 있는 출력값으로 응답
•
하위호환성 유지 가능
프론트 측면
•
stream parsing 제거
•
OCR rendering 안정화
•
AI output 변경 영향 감소
•
UI state 분리 가능
•
유지보수성 향상
무엇보다 좋았던 점은, 프론트가 이제 AI 모델의 내부 구조를 몰라도 된다는 점이었다.
서비스 경계가 드디어 생겼다. (야호~)
마무리
이번 작업은 단순히 OCR 응답 구조를 바꾼 작업이 아니었다.
실제로는 AI Output ≠ Frontend Render Schema 라는 서비스 경계를 시스템에 명확하게 도입한 작업에 가까웠다.
초기 MVP에서는 빠르게 개발하는 게 중요하기 때문에 AI 응답을 그대로 사용하는 구조가 충분히 가능하다.
하지만 제품이 커지고
•
AI 모델이 고도화되고
•
문서 종류가 다양해지고
•
서비스 안정성이 중요해지고
•
여러 팀이 동시에 개발하기 시작하면
반드시 abstraction layer가 필요해진다.
특히 AI 시스템에서는 "AI output은 안정적인 계약이 아니다"라는 점을 항상 염두에 둬야 한다.
이번 경험을 통해 프론트엔드 개발자도 단순 UI 구현을 넘어
•
서비스 경계 설계
•
데이터 계약 설계
•
시스템 안정성
•
팀 간 변경 전파 제어
까지 고민해야 한다는 걸 다시 크게 느꼈다.
_(1).jpeg&blockId=0e552736-74f0-4f5a-89e1-328d4931ca7c)
