SaaS/Insight Paser

Actonix : 흩어진 정보를실행 가능한 행동으로 #2 - 아키텍쳐/로직

Aidengoldkr 2026. 2. 23. 02:20

들어가며

이 글은 Actonix의 기능을 소개하는 글이 아니다. 해당 내용은 https://blog.aidengoldkr.dev/13 여기서 다룬다.

Actonix를 왜 이렇게 설계했는가를 설명하는 글이다.


1. 전체 시스템 아키텍처

High-Level 구조

Client (Next.js)
   ↓
Signed Upload API          — 파일은 서버를 통하지 않는다
   ↓
Supabase Storage           — parse-temp 버킷 임시 보관
   ↓
OCR Layer                  — Vision API / pdf-parse
   ↓
Semantic Parsing Layer     — GPT-4o-mini, 텍스트만 전달
   ↓
Schema Validation Layer    — validateActionPlan 강제 통과
   ↓
Credit & Policy Layer      — 검증 완료 후 차감
   ↓
Database Persist           — action_plans 테이블 저장

각 계층은 독립적으로 교체 가능하도록 설계했다. LLM을 바꾸더라도 Schema Validation Layer 아래는 영향받지 않는다. OCR 엔진을 교체하더라도 Semantic Parsing Layer는 "정제된 텍스트"만 받으면 된다.

 

이것이 계층 분리의 목적이다. 특정 계층의 교체가 전체 파이프라인을 깨지 않는 구조.


2. 파일 업로드 설계 — 왜 Direct Upload인가

문제

Vercel 서버리스 환경은 요청 payload를 4.5MB로 제한한다. 이미지나 PDF를 서버로 직접 전송하면 이 제한에 즉시 걸린다.

해결

Presigned URL 방식을 선택했다. 흐름은 다음과 같다.

Client → POST /api/parse/upload-url   → Supabase Signed Upload URL 발급
Client → PUT [Signed URL]              → Supabase Storage 직접 업로드
Client → POST /api/parse { storagePaths } → 경로만 서버로 전달

파일 자체는 서버를 경유하지 않는다. 서버는 Storage 경로만 받아 처리한다. 이 구조로 서버 부하를 줄이고, Vercel payload 제한을 우회하며, 대용량 파일도 안정적으로 처리할 수 있다.

보안 고려

Supabase 클라이언트는 두 종류를 분리해서 사용한다. admin.tsSUPABASE_SERVICE_ROLE_KEY를 사용하는 서버 전용 클라이언트로, 브라우저에 절대 노출되지 않는다. Signed URL에는 만료 시간을 설정하고, 업로드 경로는 userId/UUID.ext 네임스페이스로 분리하여 다른 사용자의 경로와 충돌하지 않는다.

파싱이 완료되면 Storage에 임시 저장된 파일을 finally 블록에서 일괄 삭제한다. 성공과 실패 모두 정리된다.


3. OCR Layer — LLM과 분리한 이유

이미지와 PDF를 LLM에 직접 넘기지 않는다.

  • 이미지 → Google Vision API (DOCUMENT_TEXT_DETECTION) → 텍스트 추출
  • PDFpdf-parse 라이브러리 → 텍스트 추출
  • 이후 → 정제된 텍스트만 LLM으로 전달

이렇게 분리한 이유는 세 가지다.

첫째, 토큰 비용 절감. 이미지를 Vision으로 처리하면 LLM에는 텍스트만 전달하면 된다. 이미지를 GPT-4o에 직접 넘기는 방식보다 토큰 비용이 낮다.

둘째, 입력 정규화. LLM이 받는 입력의 형태를 "정제된 텍스트"로 고정한다. 입력이 일관될수록 출력이 안정적이다.

셋째, 엔진 교체 가능성. Google Vision을 다른 OCR 엔진으로 바꾸더라도 LLM 이후 계층은 변경이 없다.


4. Semantic Parsing Layer — 왜 단일 프롬프트 구조가 아닌가

lib/openai.ts의 시스템 프롬프트는 단계를 명시적으로 분리하고 있다.

Step 1: 문서 카테고리 8종 중 하나 판단
Step 2: 카테고리별 행동 네이밍 규칙 적용 (동사형, 톤 통일)
Step 3: 제목 생성
Step 4: 키워드 3~10개 추출
Step 5: keyPoints (문서 내부 정보 3~10개) 추출
Step 6: analysis (2~5문장 요약) 작성
Output: JSON only, 한국어

단일 "이 문서를 정리해줘" 프롬프트 방식은 출력 품질이 문서 유형에 따라 크게 흔들린다. 문서 유형 분류 → Intent 추출 → 구조화 생성의 논리 흐름을 프롬프트 안에서 명시적으로 분리하면 일관성이 높아진다.


5. Schema Validation Layer — 시스템의 중심

이 계층이 Actonix의 핵심이다.

왜 검증이 필수인가

LLM은 확률 기반 생성 모델이다. 동일한 입력에 대해서도 출력 변동이 발생한다. 자연어 요약에서는 허용 가능한 변동이지만, 스키마 기반 시스템에서는 단 하나의 필드 누락도 치명적이다. priority 필드가 없으면 UI가 깨진다. due가 ISO 형식이 아니면 일정 계산이 불가능하다.

response_format: { type: "json_object" } 옵션으로 JSON 출력을 강제하더라도, 필드 구조까지 보장하지는 않는다. 그래서 validateActionPlan을 반드시 거친다.

검증 항목 (lib/schema.ts)

  • category: 8가지 DocumentCategory enum 외의 값이면 "기타"로 강제
  • actions 각 항목: task(문자열), due(null 또는 문자열), priority(없으면 "medium" 기본값), done(boolean) 보장
  • requirements, unknowns, keywords, keyPoints: 배열이고 요소가 문자열인 것만 유지
  • analysis, title: 문자열이 아니면 빈 문자열로 초기화

날짜 보정 전략

마감일이 명시된 액션 중 가장 이른 날을 기준일로 삼는다. 마감이 없는 액션에는 "기준일 - 1일"을 fallbackDate로 채운다. 사용자가 이후 수정할 수 있다. 연도가 없는 날짜는 "오늘 기준 현재 연도, 6개월 초과 미래면 전년도" 규칙을 적용하고, 불확실한 경우 unknowns에 명시한다.

ActionPlan JSON 구조

{
  "category": "공지문",          // enum: 안내문|공지문|준비사항|논설문|보고서|회의록|체크리스트|기타
  "title": "수행평가 제출 일정",
  "analysis": "3월 중 제출 마감이 있는 수행평가 안내문으로, 보고서 작성과 발표 준비가 필요하다.",
  "keywords": ["수행평가", "제출", "마감", "보고서"],
  "keyPoints": ["제출 형식: A4 5페이지 이상", "참고문헌 필수 포함"],
  "actions": [
    {
      "task": "보고서 작성",
      "due": "2026-03-08",         // ISO 8601, 없으면 fallbackDate 또는 null
      "priority": "high",          // enum: high | medium | low, 기본값 medium
      "done": false
    }
  ],
  "requirements": ["A4 5페이지 이상", "참고문헌 필수"],
  "unknowns": ["제출 플랫폼 미명시"]
}

unknowns는 의도는 감지됐지만 정보가 불충분한 항목을 구조화한다. LLM이 모르는 것을 생략하는 것이 아니라, 명시적으로 기록한다.


6. Credit & Policy Layer — 왜 파싱 완료 후 차감하는가

크레딧 차감은 deductCredits가 호출되는 시점, 즉 parseToActionPlan이 성공하고 validateActionPlan까지 통과한 이후에 수행된다.

OCR 실패, LLM 오류, 스키마 검증 실패 — 이 중 어느 단계에서도 실패하면 크레딧은 차감되지 않는다.

실패한 요청에 비용이 소모되지 않아야 사용자 신뢰를 확보할 수 있다. 기술적 결정이기도 하지만 정책적 결정이기도 하다.

티어별 정책

항목 Free Pro
월 리필 100 (베타: 1000) 500
크레딧 캡 1000 무제한
이미지 최대 20장 30장
플랜 저장 10개 10개

이미지 5장까지는 기본 비용, 초과분은 장당 추가 비용이 발생한다. calculateParseCost가 타입과 이미지 수, 티어를 받아 비용을 계산한다.


7. 보안 설계

미들웨어 (middleware.ts)

모든 요청에 대해 보안 헤더를 설정한다.

X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera/microphone/geolocation 비활성
Strict-Transport-Security (프로덕션 HTTPS 환경만)

API 접근 제어

  • 세션 없는 요청 → 401
  • 이용약관 미동의 → 403
  • 플랜 조회·수정·삭제는 user_id 필터로 소유권 검증
  • Supabase 서버 클라이언트는 SUPABASE_SERVICE_ROLE_KEY 전용, 브라우저 클라이언트와 완전 분리

8. 확장성 고려

계층 분리 설계는 기능 추가를 위한 것이기도 하다.

LLM 교체 가능: lib/openai.ts만 교체하면 된다. Schema Validation Layer 이하는 변경 없다.

OCR 엔진 교체 가능: Google Vision을 다른 OCR 엔진으로 교체해도 "정제된 텍스트"를 출력하는 인터페이스만 유지하면 된다.

API화 가능: ActionPlan을 반환하는 /api/parse 엔드포인트는 그대로 REST API로 공개 가능하다. B2B 워크플로우 통합을 위한 추가 개발 없이 인증 레이어만 조정하면 된다.


9. 현재 한계

솔직하게 기록한다.

Zero-shot 의존: category 분류와 deadline 추출이 프롬프트 품질에 묶여 있다. 비정형 문서에서 오인식이 발생한다.

멀티 문서 미지원: 현재는 단일 문서 단위 처리만 가능하다. 여러 문서를 묶어 ActionPlan을 통합 생성하는 기능은 미구현이다.

로깅 체계 단순: API에서 500/502 발생 시 console.error 수준으로만 기록된다. 운영 환경에서의 추적을 위한 외부 로깅 연동이 필요하다.


10. 장기 구조 전환 계획

현재 Actonix는 완성형이 아니다.

 

지금 구조는 범용 LLM 기반 zero-shot 추론으로, 파이프라인의 핵심 추론을 외부 모델에 의존하고 있다. category classification의 정확도, deadline extraction의 안정성, unknowns 처리의 일관성 — 이 모든 것이 범용 모델의 한계에 묶여 있다.

 

장기적으로는 category classification과 deadline extraction을 multi-task로 분리 학습한 도메인 특화 fine-tuned 모델을 구축할 계획이다. 범용 모델 의존도를 단계적으로 축소하고, 도메인 특화 모델을 핵심 추론 계층으로 전환한다.

 

현재 소프트웨어 마에스트로 과정 지원을 준비하고 있다. 합격 시, 연수 과정을 통해 이 전환을 실제로 구현할 것이다. Actonix Execution Engine의 독립화가 목표다.


결론

Actonix는 단순 GPT 호출 SaaS가 아니다.

파일 업로드 설계에서 Direct Upload를 선택한 이유, OCR과 LLM을 분리한 이유, 스키마 검증을 파이프라인 중심에 배치한 이유, 크레딧 차감을 파싱 완료 후로 미룬 이유는 각 설계 판단은 안정성, 비용, 확장성을 기준으로 이루어졌다.

추론, 검증, 정책을 분리 설계한 Execution AI 시스템이다.