요약: 장부 입력 화면에 영수증·세금계산서 사진을 올리면 Claude Vision이 판독해 폼을 채우는 기능(AICassa 이슈 #12)을 만들었다. 서비스 하나에 호출을 두는 실용주의 설계를 택하고, AI 응답을 DTO에서 검증하며 계정과목은 닫힌 어휘로 제한했다. 코드 리뷰에서 CSRF 토큰 재생성 함정과
receipt_path주입 취약점을 잡았다.
무엇을 만들었나
AICassa(CodeIgniter 4 기반 간편장부 서비스)의 이슈 #12 요구사항은 이랬다.
- 장부 입력 화면에서 영수증/세금계산서 사진을 업로드한다.
- AI가 판독해 일자, 공급가액, 거래처, 증빙유형, 거래내용을 추출한다.
- 사용자가 사진과 대조해 확인하고 이상 없으면 기존 장부등록으로 저장한다.
탐색해 보니 이 프로젝트에는 외부 API 호출 선례도, 이미지 업로드 코드도 없었다. 이 기능이 첫 외부 HTTP 연동이자 첫 파일 저장이 되는 셈이었다.

설계: 실용주의 안을 고른 이유
두 가지 설계안을 비교했다.
| A. 실용주의 | B. 클린 아키텍처 | |
|---|---|---|
| Claude HTTP 호출 | 서비스 내부 private 메서드 | 별도 AnthropicClient 라이브러리 |
| 설정값 | 서비스 const + env() |
Config/Ocr.php |
| 엔진 교체 | 불가(필요 시 추출) | OcrProviderInterface |
| 신규 파일 | 5개 | 8~9개 |
A안을 골랐다. 엔진은 이미 Claude로 확정이라 교체 가능성을 구조로 대비할 이유가 없었고, 테스트 용이성은 생성자로 CURLRequest를 주입받으면 A안에서도 똑같이 확보된다. 두 번째 AI 연동이 생기면 그때 공통 부분을 뽑기로 했다. 실제로 이후 계정과목 AI 분류 기능(이슈 #21)이 붙으면서 AnthropicClient가 분리되어 나왔다.
계층은 CodeIgniter 관례를 그대로 따랐다.
ReceiptOcrController 입력 받기 → 서비스 위임 → JSON 응답만
ReceiptOcrService 파일 검증 → 저장 → Claude 호출 → 파싱 → 매핑 (하나의 유스케이스)
OcrResult (DTO) AI 응답 검증
AccountModel / PartnerModel 기존 findByCategoryName() / findByName() 재사용
별도 Repository 계층은 두지 않았다. 이 프로젝트에서는 Model이 데이터 접근 계층이기 때문이다. 부가세도 AI에게 뽑게 하지 않는다. 저장 시 LedgerService가 항상 재계산하므로 단일 진실 소스를 유지한다.
Claude Vision 호출
별도 SDK 없이 CI4 내장 curlrequest로 Messages API를 직접 호출한다. 이미지는 base64로 싣는다.
$client->request('POST', self::API_ENDPOINT, [
'timeout' => self::TIMEOUT, // 25초. Vision은 5초를 넘길 수 있다
'http_errors' => false, // 상태코드를 직접 판정
'headers' => [
'x-api-key' => $apiKey,
'anthropic-version' => self::API_VERSION,
],
'json' => [
'model' => (string) (env('ANTHROPIC_MODEL') ?: self::DEFAULT_MODEL),
'max_tokens' => self::MAX_TOKENS,
'messages' => [[
'role' => 'user',
'content' => [
['type' => 'image', 'source' => [
'type' => 'base64', 'media_type' => $mediaType, 'data' => $imageBase64,
]],
['type' => 'text', 'text' => $prompt],
],
]],
],
]);
모델은 기본값을 claude-sonnet-5로 두고 ANTHROPIC_MODEL 환경변수로 배포 없이 바꿀 수 있게 했다. 세무 데이터라 정확도를 우선해 기본 모델을 골랐다.
계정과목은 닫힌 어휘로
프롬프트에 실제 DB의 계정과목·증빙유형 목록을 주입하고 그 안에서만 고르게 했다. 모델이 "복리후생비용" 같은 비슷한 이름을 지어내면 이름 정확일치 매핑이 실패하기 때문이다. 매핑은 정확일치만 허용한다. 일치하지 않으면 select를 비우고 이름만 화면에 보여 준다. 잘못 채우는 것보다 비워 두는 편이 사용자가 고치기 쉽다.
AI 응답은 믿지 않는다
모델 출력은 신뢰 경계 밖의 입력이다. OcrResult::fromArray()가 그 검문소 역할을 한다.
$entryType = EntryType::tryFrom((string) ($json['entry_type'] ?? ''));
if ($entryType !== EntryType::Income && $entryType !== EntryType::Expense) {
throw new OcrProcessingException('AI 응답의 거래 구분을 해석할 수 없습니다.');
}
$entryDate = trim((string) ($json['entry_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $entryDate) !== 1) {
throw new OcrProcessingException('AI 응답의 일자 형식이 올바르지 않습니다.');
}
enum과 날짜 정규식으로 걸러 내고, 통과한 값만 서비스가 매핑한다. 응답에 붙은 ```json 코드펜스도 파싱 전에 제거한다. 판독이 실패하면 이미 저장한 업로드 파일은 즉시 지워 고아 파일이 남지 않게 한다.
코드 리뷰에서 나온 문제 둘
구현 후 리뷰 에이전트 3개를 병렬로 돌렸고 실제 결함이 나왔다.
1. CSRF 토큰 재생성 함정
이 프로젝트는 CSRF regenerate = true다. AJAX 판독 POST가 토큰을 소비하고 새로 만들기 때문에, 응답으로 새 토큰을 내려주지 않으면 이어지는 최종 저장 제출이 CSRF 오류로 실패한다. 성공 응답에는 csrf_hash()를 실어 보냈지만, 리뷰에서 에러 응답에는 토큰이 빠져 있다는 점이 지적됐다. 판독이 한 번 실패하면 서버 토큰은 이미 바뀌었는데 폼은 옛 토큰을 들고 있게 된다. 성공과 실패 양쪽 응답에 새 토큰을 담도록 고쳤다.

2. receipt_path 임의 주입
저장된 영수증 경로는 hidden input으로 폼에 실려 create/update까지 간다. 사용자가 이 값을 임의 경로로 바꿔 제출할 수 있었다. 당장 파일을 내려주는 기능이 없어 즉시 유출은 아니었지만, 나중에 "영수증 보기"를 붙이면 경로 조작 문제가 된다. store()가 만드는 파일명 패턴은 {timestamp}_{20 hex}.{ext}이므로, 저장 지점인 LedgerService::validateReferences에서 정규식과 실제 파일 존재를 함께 검증했다. CI4의 regex_match[...] 규칙 문자열은 패턴 안의 ], |, ,에서 깨져서 preg_match로 직접 검증했다. 생성·수정 경로가 모두 이 지점을 지나므로 한 곳에서 막힌다. 회귀 테스트도 추가했다.
검증 결과
- PHPStan level 6: 124개 파일 오류 없음
- 전체 테스트 101개 통과 (OcrResult 단위, 서비스 기능, 엔드포인트 인증·CSRF·주입 거부 테스트 포함)
- PHP CS Fixer 통과
서비스 테스트는 생성자로 가짜 CURLRequest를 주입해 실제 API 호출 없이 돌렸다. 다만 실제 Claude를 호출하는 E2E는 검증하지 못했다. ANTHROPIC_API_KEY가 있어야 하므로, 키를 설정한 뒤 실제 영수증으로 확인하는 일이 남아 있다.
배운 점
- 첫 외부 연동은 단순하게 시작하고 추상화는 두 번째 사례가 생길 때 뽑는다. 실제로 두 번째(계정 분류) 때
AnthropicClient가 자연스럽게 분리됐다. - 모델 출력은 외부 입력이다. 타입과 형식을 DTO 경계에서 검증하고, 프롬프트에는 닫힌 어휘를 줘서 후단 매핑이 깨지지 않게 한다.
- AJAX 호출이 끼는 폼에서는 CSRF 재생성 설정을 확인하고, 실패 응답까지 새 토큰을 내려준다.
- hidden input으로 왕복하는 값은 저장 지점에서 반드시 다시 검증한다.

댓글 0개