AI‐1단계‐API 명세 - 100-hours-a-week/KTB4-5th-wiki GitHub Wiki
/ai/v1/analyses, 결과 조회, 모델 상태, health, 완료 콜백과 Worker/internal/v1/infer를 서비스 계약으로 사용합니다. 운영 GPU 경로는 RunPod를 사용하고 Colab은 호환성·성능 측정 환경으로만 사용합니다.
우리 서비스에서 사진 인식 결과는 단순한 이미지 설명이 아니라 냉장고 재고, 소비기한 알림, 레시피 추천의 입력 데이터가 됩니다. 이미지 분석 형식이 바뀔 때마다 백엔드와 프론트엔드도 함께 수정해야 한다면 모델 개선 속도가 느려지고, 잘못 읽은 날짜가 그대로 사용자 알림에 반영될 수 있습니다.
따라서 AI 모델을 하나의 독립된 내부 서비스로 두고 다음 계약을 고정합니다.
- 어떤 이미지가 들어왔는지 판별합니다.
- 이미지에서 직접 확인한 값과 AI가 추정한 값을 구분합니다.
- 제품 개수와 중량·용량을 ERD의
measure_type에 따라 배타적으로 표현하고, 확인된 측정량은G또는ML로 표준화합니다. - 서비스가 처리할 수 있는 고정된 카테고리와 상태값만 반환합니다.
- 불확실한 결과는 사용자 확인 대상으로 전달합니다.
- 레시피 추천은 백엔드가 전달한 재료로 후보를 검색하고 AI PostgreSQL의 레시피 원본을 사용해 규칙 점수의 근거를 함께 반환합니다.
이 계약을 지키면 문자 인식·분류 모델을 교체하더라도 앱은 같은 응답 형식을 사용할 수 있습니다. 자체 분석 모듈의 구현이 바뀌어도 서비스 코드의 변경 범위를 최소화할 수 있습니다.
flowchart TD
U[PWA 사용자] --> B[서비스 백엔드]
B -->|업로드 주소 발급| U
U -->|이미지 업로드| S[(S3)]
B -->|분석 요청| A[AI Model API]
A --> Q[FastAPI 인메모리 작업 큐]
Q --> W[분석 소비자 2개]
W -->|S3 서명 URL·SHA-256| G[원격 GPU Worker]
G -->|이미지 내려받기| S
G --> T{MobileNetV3 유형 판별}
T -- 영수증 --> R[PP-OCRv5·미확정 행만 Gemma]
T -- 식품 실물 --> P[PP-OCRv5·YOLO-World 영역 분리·Gemma 비전]
T -- 기타 --> N[빈 결과·재촬영·직접 입력]
R --> V[EC2 후처리<br/>PostgreSQL 카탈로그·소비기한 규칙·상태 판정]
P --> V
N --> X
V --> X[인메모리 임시 분석 결과]
X -->|완료 콜백| B
B -->|결과 확인·수정| U
U -->|사용자 확정| B
B --> I[(냉장고 재고)]
그림에서는 전체 서비스 경계와 유형별 분기만 보여줍니다. 분석 작업의 큐·상태·결과는 단일 FastAPI 인스턴스의 메모리에만 존재하며 외부 작업 저장소에 영속화하지 않습니다. 레시피 원본은 AI PostgreSQL에서 관리하고, 백엔드 MySQL의 레시피 테이블은 동기화된 사본으로 사용합니다. 상품 카탈로그·별칭·보관기간 보조 자료는 AI PostgreSQL의 receipt_ai 스키마에 둡니다. 모델 추론은 원격 GPU Worker가 맡고, EC2 FastAPI는 큐·상태·후처리·콜백을 맡습니다.
AI Model API는 외부 사용자에게 직접 공개하지 않습니다. 인증, 냉장고 소유권 확인, 요청량 제한은 서비스 백엔드가 담당하고 AI API는 내부 서비스 인증을 통과한 요청만 받습니다.
이미지 분석은 수 초가 걸리거나 재시도가 발생할 수 있으므로 비동기 작업으로 처리합니다. 사용자는 업로드 직후 처리 중 상태를 받고, 완료 후 결과 확인 화면으로 이동합니다.
| 영역 | 담당 기능 | 담당하지 않는 기능 |
|---|---|---|
| PWA | 촬영·파일 선택, 이미지 크기 조절·압축, S3 업로드, 작업 상태 조회, 결과 확인·수정 | 분류, 문자 인식, 상품 판별, 보관기간 계산, 추천 순위 계산 |
| 서비스 백엔드 | 인증·권한, Presigned URL 발급, MySQL 재료 관리, 사용자 확정 재고 저장, AI 작업 상태 중계, 레시피 동기화 실행 | 모델 추론, AI PostgreSQL 원본 수정 |
| FastAPI 분석 런타임(EC2) | 인메모리 접수 큐·상태·결과, 원격 추론 호출, PostgreSQL 카탈로그·소비기한 규칙 조회, 상태 판정, 완료 콜백 | 모델 추론, 영속 작업 복구, 사용자 인증과 냉장고 권한 판단 |
| GPU Worker(RunPod) | 서명 URL 이미지 다운로드·해시 검증, MobileNetV3·PP-OCRv5·YOLO-World·Gemma 추론 | DB 조회, 소비기한 정책, 최종 응답 생성 |
| 레시피 추천 모듈 | 전달받은 재료 검증, AI PostgreSQL 레시피 검색, 제외 재료 적용, 임박도·보유율 규칙 점수 계산 | 백엔드 MySQL 재료 수정 |
| 레시피 동기화 모듈 | AI PostgreSQL의 변경 레시피를 안정 식별자와 버전으로 제공 | 백엔드 MySQL 직접 접속·수정 |
브라우저 기기 성능에 따라 결과가 달라지지 않도록 모델 추론은 서버 측 GPU Worker에서 수행합니다. PWA의 이미지 리사이즈·압축은 추론이 아니라 전송량을 줄이기 위한 입력 준비 단계입니다.
EC2 CPU 단독 추론은 실측에서 OCR 메모리 증가와 Gemma 지연 때문에 목표 시간을 맞추지 못해, 모델 추론을 AWS 밖의 GPU Worker(RunPod, 검증은 Colab T4)로 분리했습니다. 단일 FastAPI 인스턴스가 asyncio.Queue 하나와 인메모리 작업 상태를 소유하고, 소비자 코루틴 2개(ANALYSIS_CONSUMER_COUNT)가 큐를 나눠 처리합니다. 소비자는 작업마다 S3 서명 URL(10분)과 SHA-256을 Worker의 POST /internal/v1/infer에 한 번 보내며 EC2가 이미지 바이트를 중계하지 않습니다. Worker는 요청 2개까지 받고 OCR·Gemma는 각각 1개씩 직렬로 실행합니다. 재시도 가능한 실패는 LangGraph 상태 그래프로 1회 재실행합니다. Uvicorn 웹 worker는 큐가 분리되지 않도록 1개로 제한합니다. 같은 코드에서 INFERENCE_BACKEND=local로 EC2 단독 추론도 가능하지만 운영 경로는 아닙니다.
프로세스 재시작·배포·EC2 중지 시 대기·처리·완료 작업이 모두 사라집니다. 이 동작을 MVP의 명시적 제약으로 수용하며 자동 복구를 보장하지 않습니다. 종료 시 신규 접수를 중단하고 소비자를 종료하므로 처리 중이던 작업도 유실될 수 있습니다. 서비스 제공 시간에는 AI 인스턴스를 실행 상태로 유지해야 하며, 중지된 동안 서비스 백엔드는 작업을 대신 보관하지 않고 503 AI_GATEWAY_UNAVAILABLE과 직접 입력 경로를 제공합니다.
| 순서 | 모델·기술 | 역할 | 호출 조건 |
|---|---|---|---|
| 1 | MobileNetV3-Small |
RECEIPT, PRODUCT, OTHER 입력 유형 판별 |
판독 가능한 모든 이미지 |
| 2 | PP-OCRv5 모바일 검출·한국어 모바일 인식 | 문자, 신뢰도와 좌표 추출 | 영수증 및 글자가 있는 포장 제품 |
| 3 | 키워드 규칙과 PostgreSQL 상품 카탈로그·별칭(정확 일치 → pg_trgm 유사도) |
표준 상품명, 식품 여부, 카테고리 확정 | OCR 후보가 있는 경우. 바코드 조회는 보류 |
| 4 | YOLO-World yolov8s-worldv2
|
실물 사진의 포장 상품 영역 분리. 상품명·카테고리는 확정하지 않음 | 실물 사진(PRODUCT) |
| 5 | Ollama gemma4:e2b (5.1B, Q4_K_M) |
영수증: 규칙·카탈로그로 못 정한 상품행의 식품 여부·카테고리 제안. 실물: 상품 영역 이미지와 OCR 힌트로 상품명·카테고리 판별 | 영수증 미확정 행, 실물 사진 |
| 6 | 결정 규칙 | 원문 일치, 비식품, 중복, 필수값, 날짜와 상태 검증 | 모든 자체 분석 결과 |
영수증에서 Gemma는 OCR에 없는 상품을 만들지 않습니다. 모델이 고른 줄 번호가 실제 입력 줄이고 원문과 글자가 겹칠 때만 인정하며, 응답의 name.value는 모델 이름이 아니라 OCR 상품명으로 채웁니다. 모델·카탈로그가 정한 표준 이름은 name.normalizedValue로만 전달합니다. 실물 사진은 비전 모델이 이미지에서 직접 읽은 이름을 name.value로 쓰고 출처를 VISION으로 표시합니다.
입력 유형(documentType.value)의 OTHER는 메뉴판·문서·사람 사진처럼 분석 대상이 아닌 이미지를 뜻합니다. 식재료 카테고리는 백엔드 API 명세와 같은 VEGETABLE, FRUIT, MEAT, SEAFOOD, DAIRY, TOFU_BEAN, GRAINS_NOODLE, PROCESSED_FOOD, SEASONING, BEVERAGE, OTHER만 반환합니다(2026-10-08 GRAIN_NOODLE·PROCESSED·ETC에서 변경). 카테고리 OTHER는 식재료이지만 앞의 10개에 속하지 않을 때만 쓰며, 이름이 같은 입력 유형 OTHER와는 다른 필드입니다.
이 API의 재고 후보는 ingredients에 저장하기 쉬운 이름과 타입을 사용합니다. 다만 분석 결과는 임시 후보이므로 ingredient_id, refrigerator_id, created_at, updated_at은 AI가 만들지 않고 서비스 백엔드가 재고 저장 시 채웁니다.
| API 필드 | ERD 열 | 규칙 |
|---|---|---|
name.value |
ingredients.name |
사용자 확인 전에는 임시 후보이며 최대 100자로 검증 |
category.value |
ingredients.category |
ERD의 11개 코드만 허용 |
storageType |
ingredients.storage_type |
문자열 REFRIGERATED 또는 FROZEN. 카테고리 기본 규칙으로 제안(MEAT·SEAFOOD는 FROZEN) |
measureType |
ingredients.measure_type |
COUNT 또는 WEIGHT
|
quantity.value |
ingredients.quantity |
COUNT일 때만 1~100 정수, WEIGHT일 때 null
|
weight.value |
ingredients.weight_value |
WEIGHT일 때만 양수, COUNT일 때 null
|
weight.unit |
ingredients.weight_unit |
COUNT이면 NONE, WEIGHT이면 G 또는 ML
|
expiration.date |
ingredients.expiration_date |
재고 저장 시 필수. AI가 확정하지 못하면 사용자가 날짜를 입력해야 저장 가능 |
image.objectKey |
ingredients.ingredient_image_key |
사용자 확정 후 서비스가 필요할 때 복사·연결하며 최대 512자로 검증 |
백엔드 MySQL ERD에는 분석 작업·피드백·상품·별칭·보관기간 테이블이 없습니다. 분석 작업·결과는 FastAPI 메모리에만 둡니다. 상품 카탈로그(식품안전나라 가공식품 DB 기반 약 23.8만 건과 큐레이션 항목)·별칭·보관기간 규칙(식약처 소비기한 참고값, USDA FoodKeeper)·레시피 원본은 AI PostgreSQL에서 관리합니다. 또한 백엔드 ERD에는 알레르기·비선호·취향 테이블이 없으므로 추천 API는 호출자가 전달한 제외 재료만 적용합니다.
GPU Worker 전환 이후(2026-10-05~10-07) EC2 FastAPI → GPU Worker(Colab T4) 전체 경로의 측정값은 다음과 같습니다. 모두 소규모 표본이며 일부는 규칙 보완에 사용한 자료를 포함하므로 출시 정확도로 해석하지 않습니다.
| 평가 | 결과 | 비고 |
|---|---|---|
| 정답을 작성한 영수증 8장·상품 28개 | 정밀도 91.3%, 재현율 75.0%, F1 82.3%, 일치 상품 수량 100%, 카테고리 81.0% | 앞의 3장은 규칙 보완에 사용, 뒤의 5장은 미사용 홀드아웃 |
| KORIE 한국 영수증 100장 | 성공 100/100. Worker 처리 평균 5.18초·p95 9.46초, API 평균 16.18초·p95 23.82초 | 로컬 평가용으로만 사용했고 이미지·원시 결과는 배포 Git에 포함하지 않음. 동시 요청 3건이라 API 시간에 큐 대기 포함. 상품 단위 정답이 없어 정확도는 미산출 |
| 실물 단일 포장 | 워밍 상태 약 3~6초, 상품명·용량 대체로 정상 | 정답 라벨셋 없음 |
| 실물 다중 포장 | YOLO-World로 11개 포장 11개 분리 | 상품명 정확도와 10초 목표는 미달 |
이전 CPU 단독 실험의 외부 영수증 21장 수치(반복 기준 정밀도 95.1%, 재현율 73.6%, 종합점수 83.0%, 전체 95% 완료시간 18.44초)는 2단계 문서에 기록으로만 남깁니다. 재현율·F1 목표 90%에 미달하므로 현재 파이프라인은 RECOGNIZED를 반환하지 않습니다. 어떤 상태든 사용자 확인 전에는 재고에 저장하지 않습니다.
따라서 API는 다음 원칙을 지킵니다.
- 모델 출력만으로 재고를 바로 저장하지 않고 항상 결과 확인 단계를 거칩니다.
- 긴 영수증, 상품행 누락 가능성, 비식품 후보, 깨진 상품명은
NEEDS_REVIEW로 반환합니다. -
RECOGNIZED는 별도 촬영 평가 자료와 실제 운영 환경 측정에서 승인 기준을 통과한 경로에만 허용합니다. 그 외 항목은NEEDS_REVIEW또는AI_ESTIMATED로 반환합니다. -
modelTrace에 파이프라인·입력 분류 모델·OCR·LLM·정규화·정책 버전을 남깁니다. - 처리시간을 넘긴 작업은 무한 재시도하지 않고 사용자 확인 또는 직접 입력으로 전환합니다.
| API 값 | 사용자 표시 | 결정 기준 | 사용자 행동 |
|---|---|---|---|
RECOGNIZED |
표시 없음 | 이미지에서 필수값을 직접 확인했고 검증 규칙을 통과함 | 결과를 그대로 등록하거나 수정 |
UNRECOGNIZED |
미인식 | 입력 유형 또는 식재료를 식별할 근거를 찾지 못함. 비대상 이미지 전체는 documentType=OTHER와 빈 items로 반환 |
다시 촬영하거나 직접 입력 |
NEEDS_REVIEW |
확인 필요 | 일부 값 누락, 낮은 화질, 모델 간 충돌, 비정상 날짜 등으로 자동 확정할 수 없음 | 표시된 필드를 확인·수정 |
AI_ESTIMATED |
AI 예상 | 식재료는 식별했지만 날짜가 이미지에 없어 승인된 보관기간 자료로 기간을 추정함 | 추정 근거를 보고 날짜를 입력·확정 |
한 항목에 여러 조건이 동시에 발생하면 다음 순서로 최종 상태를 결정합니다.
UNRECOGNIZED > NEEDS_REVIEW > AI_ESTIMATED > RECOGNIZED
예를 들어 우유는 식별했지만 소비기한이 보이지 않고 이미지도 심하게 흐리다면 AI_ESTIMATED가 아니라 NEEDS_REVIEW를 반환합니다. 품질 문제를 감춘 채 추정값을 제공하지 않기 위함입니다. 카탈로그 유사도(CATALOG_FUZZY)로만 카테고리를 정한 항목도 NEEDS_REVIEW입니다.
예외로, 실물 사진에서 비전 모델이 상품을 직접 식별한 항목은 포장 글자의 OCR 신뢰도가 낮다는 이유(LOW_OCR_CONFIDENCE)만으로 NEEDS_REVIEW로 내리지 않습니다. 이 경우 OCR 신뢰도는 상품 판정 근거가 아니기 때문입니다. 흐림·글자 없음 등 다른 품질 문제는 그대로 NEEDS_REVIEW로 처리합니다.
항목 전체 상태와 별개로 각 필드는 출처를 가집니다.
| 출처 | 의미 |
|---|---|
OCR |
이미지의 문자 영역에서 직접 읽음 |
VISION |
실물 형태 또는 패키지 특징으로 인식함 |
BARCODE |
바코드와 상품 DB로 확인함 |
RECEIPT_LINE |
영수증 상품 행에서 읽음 |
CATALOG |
승인된 상품·별칭 보조 자료에서 정확히 일치함 |
CATALOG_FUZZY |
정확 일치가 없어 상품 카탈로그 유사도(pg_trgm)로 근접 매칭함. 신뢰도를 유사도 값으로 낮추고 NEEDS_REVIEW로 처리 |
MODEL_INFERENCE |
이미지 또는 OCR 근거를 바탕으로 모델이 후보를 제안함 |
BUSINESS_RULE |
서비스의 명시적인 기본 규칙으로 채움 |
KNOWLEDGE_ESTIMATE |
승인된 보관기간 보조 자료에서 추정함 |
USER_CONFIRMED |
사용자가 확인하거나 수정함 |
소비기한 알림은 OCR, BARCODE, USER_CONFIRMED 값을 우선합니다. KNOWLEDGE_ESTIMATE는 실제 표시 날짜가 아니며 화면에서도 반드시 AI 예상으로 구분합니다.
| 필드 | 의미 | 예시 |
|---|---|---|
measureType |
ERD 측정 방식 |
COUNT 또는 WEIGHT
|
quantity.value |
COUNT 재료의 개수 |
달걀 10개이면 10
|
weight.value |
WEIGHT 재료의 총 중량·용량 |
우유 500ml이면 500
|
weight.unit |
측정 단위 |
COUNT이면 NONE, WEIGHT이면 G 또는 ML
|
source |
값을 확인한 근거 |
OCR, BARCODE, RECEIPT_LINE, MODEL_INFERENCE, USER_CONFIRMED 등 |
-
500g,1kg등 중량 표시는 각각500 G,1000 G로 저장합니다. -
500ml,1L등 부피 표시는 각각500 ML,1000 ML로 저장합니다. - 대소문자와 공백 차이는 정규화하지만 원문은
evidenceText에 보관합니다. - 단위 없는 숫자를 용량으로 추정하지 않습니다.
-
kg ↔ g,L ↔ ml이외의 밀도 기반 환산은 하지 않습니다. - 상품명이나 포장에 용량(
g,kg,ml,L)이 보이면 개수 표기보다 용량을 우선해measureType=WEIGHT로 만듭니다.2개입 500g은weight.value=500,weight.unit=G후보가 되고2개입을 포함한 원문은name.evidenceText에 남습니다. 별도로NEEDS_REVIEW를 강제하지는 않습니다. - 영수증 수량 열에서 구매 개수를 읽은 경우
WEIGHT항목에도quantity에 그 구매 개수를 함께 담습니다(예: 우유 1L 2개 →WEIGHT 1000 ML+quantity.value=2). ERD는 한 행에 개수와 중량을 동시에 저장하지 않으므로, 재고 저장 시 백엔드가measureType에 맞는 값만 저장하거나 구매 개수만큼 행을 나눕니다. - 용량이 없고 개수(
개,개입,EA)나 수량 열 값만 있으면COUNT, 둘 다 없으면COUNT 1을BUSINESS_RULE출처로 제안합니다. - 사용자가 확인 화면에서 개수 관리로 바꾸면
measureType=COUNT,quantity=2,weight=null,weight.unit=NONE으로 확정합니다.
기본 경로는 /ai/v1이며, 모델 변경이 아니라 API 계약이 깨질 때만 버전을 올립니다.
| Method | Endpoint | 기능 |
|---|---|---|
POST |
/ai/v1/analyses |
이미지 분석 작업 생성 |
GET |
/ai/v1/analyses/{analysis_id} |
작업 상태와 분석 결과 조회. Swagger 경로 변수명은 analysis_id
|
POST |
/ai/v1/analyses/{analysisId}/feedback |
사용자 확정값 전달 |
POST |
/ai/v1/recipe-recommendations |
ERD 형식의 재고와 호출자가 전달한 제외 재료를 이용해 레시피 추천 |
GET |
/ai/v1/recipe-sync/changes |
백엔드가 AI PostgreSQL의 변경 레시피를 커서 기반으로 조회 |
GET |
/ai/v1/models/status |
모델 준비 여부와 큐 상태 확인 |
GET |
/health/live |
프로세스 생존 확인. 인증 없음 |
GET |
/health/ready |
모델 포함 준비 상태. 준비되면 200, 아니면 503
|
/health/live를 제외한 모든 엔드포인트는 X-Internal-API-Key 헤더로 서비스 간 인증을 합니다.
이미지는 AI API에 Base64로 직접 전달하지 않습니다. 서비스 백엔드가 접근 시간이 제한된 저장소 참조를 전달하여 요청 크기와 메모리 사용량을 줄입니다.
분석이 끝나면(COMPLETED 또는 FAILED) AI 서버가 백엔드로 결과를 보냅니다. 이 콜백의 계약은 백엔드가 소유한 재고 인식 결과 콜백 API 명세를 기준으로 하며, 아래는 AI 쪽 구현이 그 명세를 어떻게 채우는지 정리한 것입니다. 주소는 환경설정(CALLBACK_URL)으로 고정하며 클라이언트가 요청마다 지정하지 않습니다.
운영(APP_ENV=production)에서는 CALLBACK_URL과 CALLBACK_API_KEY가 모두 필수이며, 하나라도 없으면 서버가 시작되지 않습니다. 콜백 생성 시 COMPLETED는 result만, FAILED는 error만 갖도록 검증합니다. 종료됐지만 결과가 없는 내부 비정상 상태는 FAILED·INTERNAL_ERROR로 정규화합니다. estimatedRange.from은 to보다 늦을 수 없고, weight.value가 있으면 0보다 커야 합니다.
| 항목 | 값 |
|---|---|
| 메서드·경로 | POST /api/v1/internal/image-analyses/callback |
| 헤더 |
Content-Type: application/json, X-Internal-API-Key: <CALLBACK_API_KEY>(AI→BE 서비스 인증 키) |
| 구현 | AI 레포 fastapi/app/adapters/callback.py의 build_callback_payload()
|
콜백 본문은 5.2절 GET 응답보다 단순한 평면 구조입니다. GET 응답의 출처·신뢰도·근거 문자열은 제외하지만, 항목별 확인 사유인 reviewReasons는 같은 구조로 포함합니다.
| 필드 | 타입 | null | AI가 채우는 값 |
|---|---|---|---|
analysisId |
String | X | 접수 시 발급한 분석 ID |
status |
String | X |
COMPLETED 또는 FAILED
|
completedAt |
String | X | 분석 종료 시각. 시간대를 포함한 ISO 8601이며 AI는 UTC(Z)로 보냄 |
result |
Object | O |
COMPLETED이면 결과, FAILED이면 null. 결과 없이 완료된 작업은 FAILED + INTERNAL_ERROR로 보냄 |
result.documentType |
String | X |
RECEIPT, PRODUCT, OTHER
|
result.items[] |
Array | X | 인식 항목. 없으면 빈 배열 |
items[].itemId |
String | X | 분석 안에서 항목을 구분하는 ID(item_001부터) |
items[].displayStatus |
String | X | GET 값 그대로. 단 측정 방식에 필요한 값이 없으면(COUNT인데 quantity=null, WEIGHT인데 weight.value=null) NEEDS_REVIEW
|
items[].name |
String | O |
name.value(원본 인식명) → 없으면 name.normalizedValue → 둘 다 없으면 null
|
items[].category |
String | O | 6절 목록의 11개 카테고리 값 |
items[].storageType |
String | O |
REFRIGERATED 또는 FROZEN
|
items[].measureType |
String | O |
COUNT 또는 WEIGHT
|
items[].quantity |
Integer | O |
COUNT이고 영수증·포장에서 읽은 개수(1~100)만. 규칙 기본값으로 제안한 수량과 WEIGHT 항목은 null
|
items[].weight.value |
Number | O |
WEIGHT의 중량·용량(양수). 0 이하이거나 COUNT이면 null. 트랜스지방 0g 같은 0 표기는 포장 용량으로 읽지 않음 |
items[].weight.unit |
String | O |
WEIGHT이면 G·ML(값이 없으면 null), COUNT이면 NONE
|
items[].expiration.date |
String | O | 이미지에서 읽은 소비기한 YYYY-MM-DD. 없으면 null
|
items[].expiration.dateType |
String | X |
USE_BY, UNKNOWN, ESTIMATED_CONSUMPTION_WINDOW. 백엔드 명세의 SELL_BY는 AI가 만들지 않음 |
items[].expiration.estimatedRange |
Object | O | 보관기간 규칙으로 추정한 {from, to}. 없으면 null
|
items[].reviewReasons |
Array | X | GET과 동일한 {field, code, messages} 객체 배열. 사유가 없으면 []
|
error |
Object | O |
COMPLETED이면 null, FAILED이면 {code, message, retryable, retryAfterMs}. 재시도할 수 없는 오류는 retryAfterMs=null
|
백엔드 명세에 따라 인식하지 못한 값을 기본값으로 채우지 않습니다. GET 응답은 영수증에 수량이 없을 때 quantity.value=1(source=BUSINESS_RULE)을 제안하지만, 콜백에서는 이 값을 null로 보내고 그 항목을 NEEDS_REVIEW로 표시합니다. AI 서버는 보내기 전에 status와 result·error 조합, estimatedRange.from ≤ to, 양수 중량을 스키마로 검증하며, 콜백 생성·전송 중 오류가 나도 분석 결과는 유지되고 다음 작업 처리는 계속됩니다.
{
"analysisId": "ana_02JEXAMPLE",
"status": "COMPLETED",
"completedAt": "2026-10-08T01:00:00Z",
"result": {
"documentType": "RECEIPT",
"items": [
{
"itemId": "item_001",
"displayStatus": "AI_ESTIMATED",
"name": "서울우유",
"category": "DAIRY",
"storageType": "REFRIGERATED",
"measureType": "WEIGHT",
"quantity": null,
"weight": {"value": 1000.0, "unit": "ML"},
"expiration": {
"date": null,
"dateType": "ESTIMATED_CONSUMPTION_WINDOW",
"estimatedRange": {"from": "2026-10-15", "to": "2026-10-31"}
},
"reviewReasons": [
{"field": "ITEM", "code": "ITEM_REVIEW_REQUIRED", "messages": ["현재 영수증 모델은 사용자 확인이 필요합니다."]},
{"field": "EXPIRATION", "code": "EXPIRATION_NOT_FOUND", "messages": ["영수증에서 확정 가능한 소비기한을 찾지 못했습니다."]},
{"field": "EXPIRATION", "code": "EXPIRATION_ESTIMATED", "messages": ["표시된 소비기한이 없어 보관 조건을 기준으로 예상했습니다."]}
]
},
{
"itemId": "item_002",
"displayStatus": "NEEDS_REVIEW",
"name": "친환경 애호박",
"category": "VEGETABLE",
"storageType": "REFRIGERATED",
"measureType": "COUNT",
"quantity": 1,
"weight": {"value": null, "unit": "NONE"},
"expiration": {"date": null, "dateType": "UNKNOWN", "estimatedRange": null},
"reviewReasons": [
{"field": "ITEM", "code": "ITEM_REVIEW_REQUIRED", "messages": ["현재 영수증 모델은 사용자 확인이 필요합니다."]},
{"field": "EXPIRATION", "code": "EXPIRATION_NOT_FOUND", "messages": ["영수증에서 확정 가능한 소비기한을 찾지 못했습니다."]}
]
}
]
},
"error": null
}{
"analysisId": "ana_04JEXAMPLE",
"status": "FAILED",
"completedAt": "2026-10-08T01:00:00Z",
"result": null,
"error": {
"code": "DEPENDENCY_UNAVAILABLE",
"message": "원격 GPU 추론 작업자를 호출하지 못했습니다.",
"retryable": true,
"retryAfterMs": 5000
}
}백엔드는 정상 수신하면 FAILED 콜백이어도 200과 {"code": "IMAGE-200-003", "message": ..., "data": {"analysisId": ...}}를 반환하고, 같은 결과가 다시 와도 중복 반영하지 않습니다. AI는 2xx를 받으면 전달을 끝내며 응답 본문은 해석하지 않습니다.
| 백엔드 응답 또는 통신 결과 | AI 처리 |
|---|---|
2xx |
전달 종료 |
400(GLOBAL-400-001), 401(IMAGE-401-001), 409, 그 밖의 응답 |
재전송하지 않고 운영 경고 로그 |
429, 500, 502, 503(IMAGE-503-003), 504
|
같은 본문 재전송 |
| 연결 실패·타임아웃 | 같은 본문 재전송 |
AI 서버는 200부터 299까지의 모든 2xx 응답을 콜백 전달 성공으로 판단합니다. 일반적으로 사용하는 상태는 다음과 같습니다.
| HTTP 상태 | 의미 | 콜백에서의 처리 |
|---|---|---|
200 OK |
콜백을 동기적으로 수신·저장하고 응답 본문을 반환 | 전달 성공 |
201 Created |
콜백 수신으로 새 결과 리소스를 생성 | 전달 성공 |
202 Accepted |
콜백을 접수했으며 백엔드 내부 처리는 비동기로 계속됨 | 전달 성공 |
204 No Content |
콜백을 정상 처리했으며 응답 본문 없음 | 전달 성공 |
현재 백엔드의 표준 정상 응답은 본문을 포함한 200 OK이며, 다른 2xx를 반환하더라도 AI 서버는 같은 콜백을 재전송하지 않습니다.
재전송은 분석을 다시 실행하지 않고 이미 만든 analysisId, status, completedAt, result, error를 그대로 보냅니다. 현재 설정은 요청당 timeout 5초, 최대 3회 시도(0.25초·0.5초 대기)입니다. 재전송 상태는 메모리에만 있으므로 AI 프로세스 재시작 이후 전달은 보장하지 않으며, 콜백 전달 실패는 분석 결과를 FAILED로 바꾸지 않습니다. GET은 정상 폴링이 아니라 콜백 누락 확인용입니다.
백엔드는 분석 접수 시 analysisId와 자기 사용자 요청의 관계를 백엔드 소유 임시 상태에 보관해야 합니다. 이 저장 방식과 TTL은 현재 백엔드 ERD에 없으므로 백엔드 팀이 별도로 확정합니다. 예상 완료 제한시간이 지나도록 콜백을 받지 못한 작업에만 GET 확인을 시작하고, 설정된 횟수만 조회한 뒤 재요청 또는 직접 입력으로 종료합니다. 완료 제한시간과 확인 횟수는 실제 95%·99% 완료시간을 측정한 뒤 정하며 무기한 폴링하지 않습니다.
POST /ai/v1/analyses
{
"requestId": "req_01JEXAMPLE",
"image": {
"objectKey": "uploads/user-scope/2026/08/31/image-001.webp",
"sha256": "72d88f091e4062e4f02c8fcbb0fd30b27a657e3250f574d9050875caa10f3260"
},
"inputHint": "AUTO",
"locale": "ko-KR",
"timezone": "Asia/Seoul"
}| 필드 | 필수 | 설명 |
|---|---|---|
requestId |
Y | 중복 요청 방지를 위한 서비스 요청 ID |
image.objectKey |
Y | 서비스가 발급하고 소유권을 확인한 S3 객체 키. 버킷은 서버 설정으로 고정 |
image.sha256 |
Y | 중복 분석 캐시와 무결성 확인. 클라이언트 값은 참고값이며 서비스가 업로드 객체에서 다시 계산·검증 |
inputHint |
N |
AUTO, RECEIPT, PRODUCT 중 하나. 생략하면 AUTO
|
locale |
N | OCR 언어 및 날짜 형식 해석에 사용. 생략하면 ko-KR
|
timezone |
N | 날짜 검증 기준 시간대. 생략하면 Asia/Seoul
|
현재 timezone은 비어 있지 않은 문자열인지까지만 요청 단계에서 검사합니다. 실행 환경이 알 수 없는
timezone이면 분석 기준일 계산에서 UTC로 폴백합니다. 따라서 백엔드는 IANA timezone 이름을
검증해 전달해야 하며 현재 AI API가 잘못된 timezone을 HTTP 400으로 거절한다고 가정하면 안 됩니다.
image.sha256은 .env에 넣는 고정 설정값이나 API 비밀키가 아닙니다. 이미지 파일마다 실제
바이트를 SHA-256으로 계산한 64자리 16진수 문자열입니다. 이미지가 달라지면 이 값도 달라집니다.
import hashlib
sha256 = hashlib.sha256(image_bytes).hexdigest()업로드를 담당하는 서비스 백엔드는 S3 업로드가 완료된 뒤 실제 객체의 바이트를 기준으로 해시를
계산하거나, 서버가 검증한 업로드 해시를 확정해 objectKey와 함께 AI API에 전달합니다. PWA가
계산한 값을 전달할 수는 있지만 백엔드가 검증하지 않은 클라이언트 값을 그대로 신뢰하지 않습니다.
원격 추론에서는 EC2 FastAPI가 Worker에 서명 URL과 같은 SHA-256을 전달합니다. Worker는 URL에서
받은 이미지 바이트의 SHA-256을 다시 계산해 요청값과 비교하고, 다르면 분석하지 않고
INVALID_REQUEST로 종료합니다. 이를 통해 업로드 후 객체 교체, 잘못된 URL·객체 키, 불완전한
다운로드로 EC2와 Worker가 서로 다른 이미지를 분석하는 상황을 막습니다.
X-Internal-API-Key / X-Worker-API-Key → 호출 권한 확인
S3 서명 URL → 제한된 시간 동안 이미지 읽기 허용
image.sha256 → 내려받은 이미지 내용의 무결성 확인
따라서 SHA-256은 API 키를 대신하지 않고 외부에 노출돼도 인증 권한을 주지 않습니다.
{
"analysisId": "ana_01JEXAMPLE",
"status": "QUEUED",
"submittedAt": "2026-08-31T01:15:00Z",
"pollAfterMs": 1000
}성공 시 HTTP 202 Accepted를 반환합니다.
처음 접수한 요청은 일반적으로 status=QUEUED, pollAfterMs=1000입니다. 동일한 requestId와
동일한 요청 내용을 다시 보내면 새 작업을 만들지 않고 기존 작업을 반환하므로 status가
QUEUED, PROCESSING, COMPLETED, FAILED 중 하나일 수 있습니다. 기존 작업이 이미
COMPLETED 또는 FAILED이면 pollAfterMs=0입니다.
AI 서버는 접수 단계에서 요청 형식·인증·멱등성만 검사하고 이미지를 열지 않습니다. 객체 존재 여부, 크기(15MB)·해상도(4천만 픽셀) 제한, 형식(JPEG·PNG·WebP), sha256 일치는 분석 중 Worker(로컬 모드에서는 FastAPI)가 이미지를 내려받은 뒤 검사하며, 위반하면 HTTP 오류가 아니라 FAILED 결과로 기록합니다. 따라서 접수 전에 서비스는 로그인 사용자에게 발급한 업로드 경로인지, 허용된 버킷·파일 형식·크기인지, 객체가 실제로 존재하는지를 확인합니다. sha256은 PWA가 보낸 값을 그대로 신뢰하지 않고 업로드 완료 시 서버 또는 저장소 검증 절차에서 얻은 값과 대조합니다. 검증된 객체 키와 해시만 분석 작업에 고정하여 다른 사용자의 객체 키 대입과 업로드 후 파일 교체를 막습니다.
내부 단계는 QUEUED, CLASSIFYING, EXTRACTING, ENRICHING, VALIDATING으로 추적합니다. PWA에는 내부 모델 이름을 노출하지 않습니다. 인메모리 큐에서 기다릴 때는 QUEUED, 소비자가 가져간 뒤에는 PROCESSING, 종료 후에는 COMPLETED 또는 FAILED로 보여줍니다. 별도 메시지 재전달이나 영속 단계 점유는 없으며 한 작업은 소비자 하나가 순서대로 처리합니다.
analysisId, requestId 멱등성, 상태와 완료 결과의 유효 범위는 현재 FastAPI 프로세스 수명으로 제한됩니다. 재시작 뒤에는 기존 ID 조회가 404 ANALYSIS_NOT_FOUND가 될 수 있으며, 서비스 백엔드는 새 요청으로 다시 접수하거나 직접 입력을 제공합니다. 완료 결과는 완료 후 1시간(ANALYSIS_RESULT_TTL_SECONDS=3600) 동안 보관하며, 전체 1,000건(ANALYSIS_RESULT_MAX_ENTRIES)을 넘으면 오래된 완료 결과부터 지웁니다. 지운 뒤 조회하면 404 ANALYSIS_NOT_FOUND입니다.
GET /ai/v1/analyses/{analysis_id}
백엔드는 이 절의 AnalysisView 응답 형식으로 연동합니다.
| 필드 | 형식 | 설명 |
|---|---|---|
analysisId |
string | 접수 시 발급한 분석 ID |
status |
QUEUED | PROCESSING | COMPLETED | FAILED
|
공개 작업 상태 |
stage |
QUEUED | CLASSIFYING | EXTRACTING | ENRICHING | VALIDATING | COMPLETED | FAILED
|
내부 처리 단계. 화면에는 status만 사용 |
submittedAt, startedAt, completedAt
|
ISO-8601 UTC(Z) 또는 null
|
접수·처리 시작·종료 시각 |
result |
object 또는 null
|
COMPLETED일 때만 분석 결과. 완료 콜백의 result는 이 객체를 평면 구조로 줄인 것(4.1절) |
error |
object 또는 null
|
FAILED일 때만 code, message, retryable, retryAfterMs
|
| 필드 | 설명 |
|---|---|
documentType |
{value, confidence}. value는 RECEIPT, PRODUCT, OTHER. OTHER이면 items는 빈 배열 |
imageQuality |
{score, issues}. issues는 LOW_OCR_CONFIDENCE, NO_TEXT
|
items[] |
재고 후보 목록. 아래 항목 필드 참고 |
modelTrace |
pipelineVersion, classifierVersion, ocrVersion, llmModel, normalizerVersion, policyVersion
|
| 필드 | 설명 |
|---|---|
itemId |
분석 안에서만 유일한 ID(item_001부터) |
displayStatus |
RECOGNIZED, UNRECOGNIZED, NEEDS_REVIEW, AI_ESTIMATED 중 하나 |
name |
{value, normalizedValue, source, confidence, evidenceText}. 아래 상품명 규칙 참고 |
category |
{value, source, confidence}. source는 BUSINESS_RULE, CATALOG, CATALOG_FUZZY, MODEL_INFERENCE
|
storageType |
문자열 REFRIGERATED 또는 FROZEN. 카테고리 기본 규칙(MEAT·SEAFOOD는 FROZEN)으로 채움 |
measureType |
COUNT 또는 WEIGHT
|
quantity |
{value, source, confidence} 또는 null. COUNT는 항상 있고, WEIGHT는 영수증 수량 열을 읽은 경우에만 구매 개수로 채움(3.4절) |
weight |
{value, unit, source, confidence, evidenceText}. COUNT이면 value=null, unit=NONE
|
expiration |
{date, dateType, source, confidence, evidenceText, estimatedRange, assumptions, evidenceIds}. 모든 키가 항상 존재 |
reviewReasons |
항목별 확인 사유 객체 배열. 확인 사유가 없으면 빈 배열 |
expiration.dateType은 USE_BY(이미지에 표시된 소비기한·유통기한, date 채움), ESTIMATED_CONSUMPTION_WINDOW(보관기간 규칙 추정, date=null, estimatedRange 채움), UNKNOWN(근거 없음) 중 하나입니다.
name.value는 해당 items[] 객체가 어떤 상품인지 나타냅니다. reviewReasons[].field는 그 상품의 어느 값을 확인해야 하는지 구분하며, 상품을 다시 식별하는 필드가 아닙니다.
| 필드 | 형식 | 설명 |
|---|---|---|
field |
enum | 확인할 대상. ITEM, NAME, CATEGORY, STORAGE_TYPE, MEASURE_TYPE, QUANTITY, WEIGHT, EXPIRATION, IMAGE 중 하나 |
code |
enum | 프론트엔드·백엔드가 분기에 사용할 안정적인 확인 사유 코드 |
messages |
string[] | 사용자에게 보여줄 설명. 최소 1개의 빈 문자열이 아닌 값 |
code |
field |
의미 |
|---|---|---|
ITEM_REVIEW_REQUIRED |
ITEM |
항목 전체에 대한 사용자 확인 필요 |
IMAGE_QUALITY_REVIEW_REQUIRED |
IMAGE |
이미지 품질 문제로 결과 확인 필요 |
EXPIRATION_NOT_FOUND |
EXPIRATION |
이미지에서 확정 가능한 소비기한을 찾지 못함 |
EXPIRATION_ESTIMATED |
EXPIRATION |
보관 조건과 참고 규칙으로 소비 가능 기간을 추정함 |
EXPIRATION_BASIS_FALLBACK |
EXPIRATION |
구매일·제조일이 없어 분석일을 추정 기준일로 사용함 |
QUANTITY_DEFAULTED |
QUANTITY |
수량을 읽지 못해 1개를 기본 후보로 제안함 |
CATEGORY_FUZZY_MATCHED |
CATEGORY |
유사 상품명을 기준으로 카테고리를 추정함 |
| 필드 | 영수증 | 실물 사진 |
|---|---|---|
name.value |
OCR 상품명에서 행번호·가격·용량·기호만 뗀 이름. Gemma·카탈로그 이름으로 덮어쓰지 않음 | 비전 모델·카탈로그가 정한 상품명 |
name.normalizedValue |
카탈로그 표준명 또는 Gemma가 제안한 정규화 이름 |
value와 같음 |
name.evidenceText |
OCR 원문 | 해당 OCR 줄 또는 모델 생성 줄 |
name.source |
RECEIPT_LINE |
VISION, OCR, CATALOG, CATALOG_FUZZY
|
예를 들어 Gemma가 불향차돌떡볶이를 떡볶이로 줄여 반환해도 name.value는 불향차돌떡볶이, name.normalizedValue는 떡볶이입니다. 재고 등록 화면의 기본 이름은 name.value를 사용합니다.
아래 예시는 계약 설명용이며 실제 코드 스키마로 생성했습니다.
{
"analysisId": "ana_02JEXAMPLE",
"status": "COMPLETED",
"stage": "COMPLETED",
"submittedAt": "2026-10-07T23:45:48.270945Z",
"startedAt": "2026-10-07T23:45:48.271096Z",
"completedAt": "2026-10-07T23:45:51.274497Z",
"result": {
"documentType": {"value": "RECEIPT", "confidence": 0.79},
"imageQuality": {"score": 0.987, "issues": []},
"items": [
{
"itemId": "item_001",
"displayStatus": "AI_ESTIMATED",
"name": {
"value": "서울우유",
"normalizedValue": "서울우유",
"source": "RECEIPT_LINE",
"confidence": 0.96,
"evidenceText": "서울우유 1L"
},
"category": {"value": "DAIRY", "source": "BUSINESS_RULE", "confidence": 0.96},
"storageType": "REFRIGERATED",
"measureType": "WEIGHT",
"quantity": {"value": 2, "source": "OCR", "confidence": 0.96},
"weight": {"value": 1000.0, "unit": "ML", "source": "OCR", "confidence": 0.96, "evidenceText": "1L"},
"expiration": {
"date": null,
"dateType": "ESTIMATED_CONSUMPTION_WINDOW",
"source": "KNOWLEDGE_ESTIMATE",
"confidence": 0.5,
"evidenceText": null,
"estimatedRange": {"from": "2026-10-15", "to": "2026-10-31"},
"assumptions": [
"영수증에서 확인한 구매일 2026-10-07",
"보관 가능 기간 8~24일 적용",
"보관 방식: REFRIGERATED",
"포장 개봉 여부 확인 안 됨"
],
"evidenceIds": ["shelf-life-rule-31"]
},
"reviewReasons": [
{
"field": "ITEM",
"code": "ITEM_REVIEW_REQUIRED",
"messages": ["현재 영수증 모델은 사용자 확인이 필요합니다."]
},
{
"field": "EXPIRATION",
"code": "EXPIRATION_NOT_FOUND",
"messages": ["영수증에서 확정 가능한 소비기한을 찾지 못했습니다."]
},
{
"field": "EXPIRATION",
"code": "EXPIRATION_ESTIMATED",
"messages": ["표시된 소비기한이 없어 보관 조건을 기준으로 예상했습니다. 출처: 식약처 소비기한 참고값(강화우유·유당분해우유 대리 적용, 흰우유 자체 기준 없음)"]
}
]
},
{
"itemId": "item_002",
"displayStatus": "NEEDS_REVIEW",
"name": {
"value": "친환경 애호박",
"normalizedValue": "친환경 애호박",
"source": "RECEIPT_LINE",
"confidence": 0.94,
"evidenceText": "친환경 애호박"
},
"category": {"value": "VEGETABLE", "source": "BUSINESS_RULE", "confidence": 0.94},
"storageType": "REFRIGERATED",
"measureType": "COUNT",
"quantity": {"value": 1, "source": "OCR", "confidence": 0.94},
"weight": {"value": null, "unit": "NONE", "source": "BUSINESS_RULE", "confidence": 1.0, "evidenceText": "영수증 수량 열: 1"},
"expiration": {
"date": null,
"dateType": "UNKNOWN",
"source": null,
"confidence": null,
"evidenceText": null,
"estimatedRange": null,
"assumptions": [],
"evidenceIds": []
},
"reviewReasons": [
{
"field": "ITEM",
"code": "ITEM_REVIEW_REQUIRED",
"messages": ["현재 영수증 모델은 사용자 확인이 필요합니다."]
},
{
"field": "EXPIRATION",
"code": "EXPIRATION_NOT_FOUND",
"messages": ["영수증에서 확정 가능한 소비기한을 찾지 못했습니다."]
}
]
}
],
"modelTrace": {
"pipelineVersion": "receipt-analysis-0.1.0",
"classifierVersion": "mobilenetv3-small-input-type-v1",
"ocrVersion": "pp-ocrv5-korean-mobile",
"llmModel": "gemma4:e2b",
"normalizerVersion": "receipt-normalizer-0.1.0",
"policyVersion": "receipt-policy-0.1.0"
}
},
"error": null
}실물 사진은 result.documentType.value=PRODUCT이며 항목 형식은 영수증과 같습니다. 한 사진에서 여러 포장이 검출되면 여러 항목을 반환합니다. 이미지에 보이는 소비기한 날짜는 상품이 하나만 검출됐을 때만 그 항목에 연결하고, 여러 상품이면 어느 상품의 날짜인지 알 수 없어 연결하지 않습니다. 구매일을 알 수 없으므로 보관기간 추정은 항상 분석일 기준이며 신뢰도 0.35입니다.
{"analysisId": "ana_03JEXAMPLE", "status": "PROCESSING", "stage": "EXTRACTING", "submittedAt": "2026-10-07T23:45:48Z", "startedAt": "2026-10-07T23:45:49Z", "completedAt": null, "result": null, "error": null}{
"analysisId": "ana_04JEXAMPLE",
"status": "FAILED",
"stage": "FAILED",
"submittedAt": "2026-10-07T23:45:48Z",
"startedAt": "2026-10-07T23:45:49Z",
"completedAt": "2026-10-07T23:46:10Z",
"result": null,
"error": {
"code": "DEPENDENCY_UNAVAILABLE",
"message": "원격 GPU 추론 작업자를 호출하지 못했습니다.",
"retryable": true,
"retryAfterMs": 5000
}
}AI 예상은 단일 확정 날짜보다 기간으로 반환합니다. ingredients.expiration_date는 NOT NULL이므로 서비스 백엔드는 예상 기간을 그대로 재고에 저장하지 않습니다. 사용자가 보관 방식과 개봉 여부를 확인하고 날짜 하나를 선택하거나 직접 입력한 뒤에만 재고를 생성합니다.
추정 기준일은 영수증에서 읽은 구매일입니다. 구매일이 없거나, 미래 날짜이거나, 분석일보다 365일 넘게 오래됐으면 요청 timezone 기준 분석일을 임시 기준일로 사용합니다. 이 경우 신뢰도를 0.35로 낮추고 assumptions에 근거를 남기며, reviewReasons에 field=EXPIRATION, code=EXPIRATION_BASIS_FALLBACK으로 "분석일 기준의 참고 범위이며 확정 소비기한이 아님"을 명시합니다. 보관기간 규칙은 가공식품은 식약처 식품유형별 소비기한 참고값, 신선 농산물·육류는 USDA FoodKeeper를 쓰며, 신선식품 규칙은 항목 카테고리가 채소·과일·육류·수산일 때만 적용합니다. 규칙이 없는 상품은 UNKNOWN으로 남깁니다.
영수증에 수량 표기가 없을 때 measureType=COUNT, quantity.value=1을 제안할 수 있지만 source=BUSINESS_RULE로 표시합니다. 기본값을 영수증에서 직접 읽은 값처럼 표시하지 않습니다.
POST /ai/v1/analyses/{analysisId}/feedback
{
"itemId": "item_101",
"action": "CORRECTED",
"confirmed": {
"name": "애호박",
"category": "VEGETABLE",
"storageType": "REFRIGERATED",
"measureType": "WEIGHT",
"quantity": null,
"weightValue": 500,
"weightUnit": "G",
"expirationDate": "2026-09-05",
"expirationSource": "USER_CONFIRMED"
},
"consentForModelImprovement": true
}피드백은 원본 추론 결과를 덮어쓰지 않고 모델 예측, 사용자 확정값, 수정 차이를 별도로 보관합니다. 물리 스키마·보관기간·동의 철회 정책은 데이터 거버넌스 정책으로 관리합니다.
피드백이 정상적으로 기록되면 HTTP 201 Created와 다음 응답을 반환합니다. RECORDED는 저장됐다는 뜻이며 학습 정답으로 승인됐다는 뜻이 아닙니다.
{
"feedbackId": "feedback_01JEXAMPLE",
"analysisId": "ana_02JEXAMPLE",
"status": "RECORDED",
"modelImprovementConsent": true,
"trainingEligibility": "PENDING_REVIEW",
"recordedAt": "2026-08-31T10:22:00+09:00"
}POST /ai/v1/recipe-recommendations
{
"requestId": "req_recipe_001",
"refrigeratorId": 17,
"inventoryFingerprint": "7eb9556c5b583a95f056fa50d8e180d6f727134f75d9d2568e828468c3487c67",
"recommendationMode": "EXCLUDE_EXPIRED",
"inventory": [
{
"ingredientId": 101,
"name": "두부",
"category": "TOFU_BEAN",
"storageType": "REFRIGERATED",
"measureType": "WEIGHT",
"quantity": null,
"weightValue": 300,
"weightUnit": "G",
"expirationDate": "2026-09-10"
},
{
"ingredientId": 102,
"name": "대파",
"category": "VEGETABLE",
"storageType": "REFRIGERATED",
"measureType": "COUNT",
"quantity": 1,
"weightValue": null,
"weightUnit": "NONE",
"expirationDate": "2026-09-12"
}
],
"excludedIngredientNames": ["땅콩", "고수"],
"limit": 5
}{
"recommendationId": "rec_01JEXAMPLE",
"refrigeratorId": 17,
"inventoryFingerprint": "7eb9556c5b583a95f056fa50d8e180d6f727134f75d9d2568e828468c3487c67",
"recommendationMode": "EXCLUDE_EXPIRED",
"recommendations": [
{
"source": "RECIPE_SOURCE_TBD",
"sourceRecipeId": "source-recipe-1024",
"sourceRevision": "revision-token-example",
"name": "두부 대파 조림",
"imageUrl": null,
"baseServings": 2,
"ingredients": [
{"ingredientNo": 1, "name": "두부", "amountText": "300g", "measureType": "WEIGHT", "amountValue": 300, "unit": "G"},
{"ingredientNo": 2, "name": "대파", "amountText": "1대", "measureType": "COUNT", "amountValue": 1, "unit": "EA"}
],
"steps": [
{"stepNo": 1, "instruction": "두부와 대파를 손질합니다.", "imageUrl": null}
],
"score": 0.89,
"scoreBreakdown": {
"expiringIngredient": 0.35,
"inventoryCoverage": 0.31,
"missingIngredientPenalty": 0.18,
"variety": 0.05
},
"usesInventory": ["두부", "대파"],
"usesExpiringIngredients": ["두부"],
"missingIngredients": ["진간장"],
"excludedIngredientCheck": "PASSED",
"reason": "소비기한이 가까운 두부를 사용할 수 있고 추가 재료가 한 가지뿐입니다."
}
],
"generatedAt": "2026-08-31T10:20:00+09:00"
}AI는 백엔드가 전달한 재료를 기준으로 식품안전나라, TheMealDB, Tavily 순서로 레시피를 검색하고, 저장·재배포가 허용된 원본만 검증·정규화해 AI PostgreSQL에 저장한 뒤 추천합니다. 응답의 source에는 실제 공급자 코드를 사용합니다.
AI와 백엔드의 데이터베이스 대리키는 공유하지 않습니다. 추천 응답은 AI가 관리하는 source, sourceRecipeId, sourceRevision을 안정 식별자로 사용하며 백엔드 MySQL의 recipe_id를 미리 생성하거나 추측하지 않습니다. 추천 응답은 동기화 전에도 화면을 구성할 수 있도록 ERD 대응 레시피 스냅샷을 포함합니다. 실제로 반환·저장할 수 있는 원문 범위는 공급자 이용 조건 확인 후 축소될 수 있습니다. 백엔드는 동기화 전 레시피를 MySQL에 이미 존재한다고 가정하지 않습니다.
추천 캐시는 refrigeratorId, inventoryFingerprint, exclusionFingerprint, recommendationId, recommendationMode, generatedAt을 유지합니다. recommendationMode는 INCLUDE_EXPIRED 또는 EXCLUDE_EXPIRED만 허용하며, 기본값은 안전한 EXCLUDE_EXPIRED입니다. inventoryFingerprint는 추천 생성 시점 재고의 정규화된 SHA-256 값입니다. exclusionFingerprint는 정규화·중복 제거·정렬한 excludedIngredientNames의 SHA-256 값이며, 두 fingerprint와 추천 모드를 함께 캐시 키 검증에 사용합니다. 따라서 제외 재료가 달라지면 기존 캐시를 재사용하지 않습니다.
현재 ERD에는 알레르기·비선호·선호 태그 테이블이 없습니다. 따라서 이 API는 호출자가 권한 확인 후 전달한 excludedIngredientNames만 후보 생성 전에 제외합니다. 빈 목록은 “알레르기 없음”을 뜻하지 않습니다. 영속 사용자 설정을 도입하기 전까지 응답은 allergenCheck라고 단정하지 않고 excludedIngredientCheck로 표시합니다. 자연어 의미 검색이나 생성 모델은 규칙 방식의 품질이 부족하다는 평가 결과가 있을 때 이후 버전에서 별도로 검증합니다.
요청 검증은 ERD의 CHECK 조건과 동일하게 수행합니다. COUNT이면 quantity는 1~100의 정수이고 weightValue=null, weightUnit=NONE이어야 합니다. WEIGHT이면 quantity=null, weightValue>0, weightUnit은 G 또는 ML이어야 합니다. expirationDate는 항상 필요하며, ingredientId가 해당 refrigeratorId의 재고인지 서비스 백엔드가 확인한 뒤 호출합니다. 조건을 어기면 HTTP 400 INVALID_REQUEST를 반환합니다.
GET /ai/v1/recipe-sync/changes?cursor={opaqueCursor}&limit={limit}
백엔드의 주기 작업이 호출하는 내부 API입니다. AI가 백엔드 MySQL에 직접 접속하지 않으며, 백엔드가 응답을 검증하고 자신의 트랜잭션으로 recipes, recipe_ingredients, recipe_steps를 갱신합니다.
{
"items": [
{
"source": "RECIPE_SOURCE_TBD",
"sourceRecipeId": "source-recipe-1024",
"sourceRevision": "revision-token-example",
"status": "ACTIVE",
"name": "두부 대파 조림",
"imageUrl": null,
"baseServings": 2,
"ingredients": [
{"ingredientNo": 1, "name": "두부", "amountText": "300g", "measureType": "WEIGHT", "amountValue": 300, "unit": "G"}
],
"steps": [
{"stepNo": 1, "instruction": "두부를 먹기 좋은 크기로 자릅니다.", "imageUrl": null}
]
}
],
"nextCursor": "opaque-cursor-example",
"hasMore": false
}cursor는 백엔드가 해석하지 않는 불투명 값입니다. 최초 동기화 cursor, 페이지 크기 상한, 실행 주기와 인증 방식은 운영 설정으로 관리합니다. 한 페이지를 MySQL 트랜잭션으로 성공 반영한 뒤에만 백엔드가 nextCursor를 자체 저장합니다. 같은 페이지를 다시 받아도 UNIQUE(source, source_recipe_id)와 sourceRevision으로 멱등 반영합니다.
부모 레시피와 재료·단계는 한 트랜잭션으로 반영합니다. 재료와 단계는 각각 ingredientNo, stepNo 순서를 보존하며 같은 원천 버전의 자식 집합으로 교체합니다. 원천에서 더 이상 제공하지 않는 레시피는 삭제하지 않고 status=INACTIVE 변경 항목으로 전달합니다. sourceRevision은 동일 여부 확인용 불투명 토큰이며 문자열 대소 비교로 최신 순서를 판단하지 않습니다. 동기화 실패 시 기존 MySQL 사본을 유지하고 같은 cursor 페이지부터 재시도합니다.
동기화 항목은 MySQL ERD 제약을 통과해야 합니다. recipe_ingredients.measureType은 COUNT, WEIGHT, VOLUME, UNKNOWN 중 하나이고 amountValue·unit 조합도 해당 유형과 일치해야 합니다. 백엔드는 페이지 적용 전에 전체 항목을 검증하고, 한 항목이라도 실패하면 페이지 전체를 롤백한 뒤 cursor를 전진시키지 않습니다. 수정된 페이지를 같은 cursor로 다시 받아 멱등하게 재처리합니다.
변경 cursor가 비활성화 이벤트까지 전달할 수 있도록 AI PostgreSQL에 순서가 안정적인 변경 이력을 유지합니다. 변경 이력 보관기간은 백엔드의 최대 동기화 지연보다 길게 설정합니다. recipes.synced_at은 백엔드가 해당 원천 버전을 성공 반영한 시각으로 기록합니다.
GET /ai/v1/models/status
이 엔드포인트는 내부 서비스와 운영 점검에서만 사용합니다. PWA에 모델 이름과 내부 배포 정보를 직접 노출하지 않습니다.
{
"ready": true,
"queueSize": 3,
"queueCapacity": 100,
"activeAnalysisId": "ana_8246a9905a083cf75ab9aea1",
"activeCount": 2,
"models": {"remoteInference": true}
}| 필드 | 설명 |
|---|---|
ready |
런타임이 접수 중이고 models가 모두 true일 때 true
|
queueSize, queueCapacity
|
현재 대기 작업 수와 대기 큐 상한(100) |
activeCount |
지금 처리 중인 작업 수(최대 소비자 수 2). activeAnalysisId는 그중 하나만 담는 하위 호환 필드 |
models |
원격 추론 모드에서는 remoteInference(Worker /health/ready 결과) 하나. 로컬 추론 모드에서는 ocr, ollama, inputClassifier
|
모델 버전은 이 응답이 아니라 분석 결과의 modelTrace로 확인합니다. Worker의 ollama 준비 여부는 모델이 설치돼 있는지만 확인하므로, 첫 요청의 콜드 스타트를 막으려면 Worker 시작 스크립트의 모델 워밍업(WARM_MODEL_ON_START=true)이 끝난 뒤 트래픽을 보냅니다.
FastAPI health 응답은 다음과 같습니다. /health/live만 인증 없이 호출할 수 있고,
/health/ready는 X-Internal-API-Key가 필요합니다.
{"status": "ok"}{"ready": true, "models": {"remoteInference": true}}/health/ready는 모든 모델이 준비되면 HTTP 200, 하나라도 준비되지 않으면 같은 응답 구조로
HTTP 503을 반환합니다.
POST /internal/v1/infer는 EC2 FastAPI만 호출하는 내부 API입니다. 요청 헤더의
X-Worker-API-Key를 Worker의 WORKER_API_KEY와 비교하며, 운영 환경에서는 키가 없거나 다르면
401 UNAUTHORIZED_SERVICE를 반환합니다.
{
"requestId": "remote_72d88f091e4062e4",
"imageUrl": "https://example-bucket.s3.ap-northeast-2.amazonaws.com/uploads/example.jpg?...",
"sha256": "72d88f091e4062e4f02c8fcbb0fd30b27a657e3250f574d9050875caa10f3260",
"inputHint": "AUTO"
}| 필드 | 필수 | 제약 |
|---|---|---|
requestId |
Y | 빈 문자열 불가, 최대 100자 |
imageUrl |
Y | 빈 문자열 불가, 최대 2,048자. Worker 허용 호스트의 URL이어야 함 |
sha256 |
Y | 이미지 바이트의 64자리 SHA-256 |
inputHint |
Y |
AUTO, RECEIPT, PRODUCT
|
{
"inputPrediction": {
"value": "RECEIPT",
"confidence": 0.96,
"margin": 0.72,
"accepted": true
},
"width": 1080,
"height": 1920,
"lines": [
{
"lineNo": 1,
"text": "상품명 수량 금액",
"confidence": 0.98,
"box": [[120.0, 300.0], [900.0, 300.0], [900.0, 350.0], [120.0, 350.0]]
},
{
"lineNo": 2,
"text": "서울우유 1L",
"confidence": 0.95,
"box": [[120.0, 380.0], [900.0, 380.0], [900.0, 430.0], [120.0, 430.0]]
}
],
"modelCandidates": [
{
"lineNo": 2,
"normalizedName": "서울우유",
"category": "DAIRY",
"confidence": 0.91,
"reason": "model_inference"
}
],
"diagnostics": {
"workerQueueWaitMs": 0.2,
"downloadMs": 85.4,
"inputClassifierMs": 10.5,
"ocrQueueWaitMs": 0.0,
"ocrMs": 2450.1,
"llmQueueWaitMs": 0.0,
"llmMs": 760.3,
"workerTotalMs": 3312.7
}
}inputPrediction은 분류기를 사용하지 않았거나 결과를 만들 수 없으면 null일 수 있습니다.
lines, modelCandidates, diagnostics는 항상 키를 반환하며 결과가 없으면 각각 빈 배열·빈 객체입니다.
box는 OCR 좌표가 없으면 빈 배열입니다. 이 Worker 응답은 백엔드에 직접 전달하지 않고 EC2
FastAPI가 PostgreSQL 참조 조회, 소비기한 정책과 최종 API 검증을 적용한 뒤 AnalysisResult로
변환합니다.
Worker의 GET /health/live는 {"live": true}, GET /health/ready는
{"ready": boolean, "models": object}를 반환하며 두 health endpoint는 현재 인증하지 않습니다.
사용자 네트워크에 직접 노출하지 않고 RunPod 프록시·방화벽 범위에서 운영해야 합니다.
분석 작업 생성은 이미지 내용을 추론하기 전에 202 Accepted를 반환합니다. 따라서 흐림·비대상 이미지·추론 실패는 생성 요청의 HTTP 오류가 아니라 이후 조회하는 작업 결과에 기록합니다.
| HTTP | 오류 코드 | 발생 조건 | 서비스 대응 |
|---|---|---|---|
400 |
INVALID_REQUEST |
필수 필드 누락, 잘못된 날짜·카테고리 | 요청 수정 후 재전송 |
401 |
UNAUTHORIZED_SERVICE |
내부 서비스 인증 실패 | 호출 차단 및 보안 로그 기록 |
404 |
ANALYSIS_NOT_FOUND |
존재하지 않는 분석 ID | 사용자에게 재요청 안내 |
409 |
IDEMPOTENCY_CONFLICT |
같은 요청 ID에 다른 이미지나 입력 조건을 사용함 | 새 요청 ID로 명시적으로 다시 요청 |
429 |
AI_QUEUE_FULL |
AI 분석 대기 작업이 100개에 도달함 |
retryAfterMs 이후 같은 requestId로 재시도 |
503 |
MODEL_UNAVAILABLE |
AI 서버가 종료 중이라 신규 접수를 받지 않음 | 백오프 후 같은 requestId로 재시도 |
이미지 크기·형식 오류(IMAGE_TOO_LARGE, UNSUPPORTED_IMAGE_TYPE)는 접수 시 HTTP 413·415가 아니라 아래 비동기 종료 결과로 전달됩니다. 사용자별 요청 빈도 제한(RATE_LIMITED)과 AI 서버가 응답하지 않을 때의 503 AI_GATEWAY_UNAVAILABLE은 서비스 백엔드가 PWA에 반환하는 코드이며 AI 서버는 만들지 않습니다.
모든 재시도 가능한 응답에는 retryable과 retryAfterMs를 포함합니다. 동일한 requestId와 동일한 요청 내용은 기존 작업을 반환하여 중복 작업을 만들지 않습니다. 같은 requestId로 다른 객체 키·해시·입력 조건을 보내면 기존 결과를 재사용하지 않고 HTTP 409와 IDEMPOTENCY_CONFLICT를 반환합니다.
AI_QUEUE_FULL은 요청 빈도 제한인 RATE_LIMITED와 구분합니다. FastAPI의 인메모리 대기 큐는 실행 중인 작업을 제외하고 최대 100개를 저장합니다. 큐가 가득 차면 작업 객체를 남기지 않고 다음 오류를 즉시 반환합니다.
{
"error": {
"code": "AI_QUEUE_FULL",
"message": "현재 분석 작업량이 너무 많습니다. 잠시 후 다시 시도해 주세요.",
"retryable": true,
"retryAfterMs": 3000
}
}| 작업 결과 | 발생 조건 | 서비스 대응 |
|---|---|---|
COMPLETED + documentType=OTHER + 빈 items
|
비대상 또는 식재료 근거 없음 | 재촬영·직접 입력 안내 |
COMPLETED + NEEDS_REVIEW
|
일부 값 누락, 흐림, 모델 충돌 | 확인 가능한 후보와 수정 화면 제공 |
FAILED + MODEL_UNAVAILABLE
|
OCR·Gemma를 실행할 수 없거나 응답을 해석하지 못함 | 직접 입력 제공, 재시도 가능 여부 표시 |
FAILED + DEPENDENCY_UNAVAILABLE
|
S3 읽기·서명 URL 생성 실패, GPU Worker 5xx·타임아웃·연결 실패 | 같은 이미지로 새 요청 또는 직접 입력 안내 |
FAILED + INVALID_REQUEST
|
허용되지 않은 objectKey, 이미지 없음, sha256 불일치, Worker가 거절한 요청(원인 코드는 메시지에 포함) |
재시도하지 않고 업로드부터 다시 진행 |
FAILED + IMAGE_TOO_LARGE / UNSUPPORTED_IMAGE_TYPE
|
15MB·4천만 픽셀 초과, JPEG·PNG·WebP 외 형식 | 압축·변환 후 새 요청 |
FAILED + INTERNAL_ERROR
|
예상하지 못한 내부 오류 | 재시도 가능, 반복되면 운영 확인 |
retryable=true인 오류는 AI 서버가 같은 작업 안에서 1초 뒤 한 번 더 전체 분석을 실행하고, 그래도 실패하면 FAILED로 기록합니다. 비식품·비대상 이미지는 FAILED가 아니라 COMPLETED + documentType=OTHER + 빈 items입니다.
GET /ai/v1/analyses/{analysis_id}는 작업이 정상적으로 조회되면 실패 작업도 HTTP 200과 status=FAILED로 반환합니다. 분석 ID가 없거나 조회 서비스 자체가 장애인 경우에만 해당 HTTP 오류를 사용합니다.
실패 응답 형식은 5.2절의 실패 예시와 같습니다.
현재 인증은 여러 발급 키를 DB에서 조회하는 방식이 아니라 환경변수에 저장된 공유 키 하나를
상수 시간 비교하는 방식입니다. 서비스 백엔드는 X-Internal-API-Key로 EC2 FastAPI를 호출하고,
EC2 FastAPI는 X-Worker-API-Key로 GPU Worker를 호출합니다. EC2의
REMOTE_INFERENCE_API_KEY와 Worker의 WORKER_API_KEY는 같은 값이어야 합니다. 운영 설정에서는
이 키들이 필수이며, 로컬 환경에서 키를 비워 인증을 끈 설정을 운영에 사용하지 않습니다.
- AI API는 사설 네트워크 또는 서비스 간 인증을 통해서만 호출합니다.
- PWA 업로드 주소는 짧게 만료시키고, 분석 요청에는 서버가 소유권을 확인한 객체 키만 사용합니다. FastAPI는 허용 접두어(
uploads/)의 객체만 받아들이고, 작업을 처리할 때 10분짜리 S3 GET 서명 URL을 만들어 GPU Worker에 전달합니다. Worker는 허용 호스트(WORKER_ALLOWED_IMAGE_HOSTS)의 HTTPS URL만 내려받고 사설·루프백 주소는 차단하며,X-Worker-API-Key로 EC2와 인증합니다. - 영수증 OCR 결과는 후처리 전에 카드번호(13~19자리 숫자열)와 휴대전화·유선 전화번호 패턴을
[REDACTED]로 마스킹합니다. 주소와 멤버십 번호는 현재 패턴 마스킹 대상이 아니며, 일반 로그에는 OCR 원문과evidenceText를 남기지 않는 방식으로 보호합니다. - 로그에는 원본 이미지, 인증 토큰, 전체 OCR 문자열을 남기지 않습니다.
- 모델 개선용 이미지의 수집은 동의 방식·보관기간·철회 절차와 저장소가 승인된 뒤에만 활성화합니다.
- 모델 추적 정보에는 자체 모델·프롬프트·사전·정책 버전만 남기고 원본 입력과 전체 모델 출력을 저장하지 않습니다.
S3 원본 이미지의 보관기간과 삭제 주체는 개인정보 정책에서 별도로 확정해야 합니다. 분석 작업·OCR 중간 결과·분석 결과는 현재 프로세스 메모리에만 있으며 TTL 만료 또는 프로세스 종료 시 사라집니다. 사용자 피드백과 모델 개선용 자료는 저장소·동의·보관·삭제·철회 절차가 승인되기 전에는 영속 저장하지 않습니다.
| 시나리오 | 기대 결과 |
|---|---|
| 선명한 우유 패키지와 날짜 |
NEEDS_REVIEW(RECOGNIZED는 미반환), DAIRY, dateType=USE_BY인 OCR 날짜와 근거 반환 |
| 날짜가 없는 애호박 실물 | 식별 성공 시 AI_ESTIMATED, 분석일 기준 추정 기간(신뢰도 0.35)과 가정 반환 |
| 구매일이 없거나 1년 넘게 지난 영수증 | 보관기간 규칙이 있는 상품만 분석일 기준 AI_ESTIMATED(신뢰도 0.35), 규칙이 없으면 UNKNOWN
|
| 흐린 냉장고 전체 사진 |
NEEDS_REVIEW, 화질 문제와 재촬영 안내 |
| 음식과 무관한 풍경 사진 |
COMPLETED, documentType=OTHER, 빈 항목 목록 반환 |
| 영수증의 축약 상품명 |
name.value는 OCR 상품명, 표준 후보는 name.normalizedValue로 반환. 유사도 매칭이면 CATALOG_FUZZY·NEEDS_REVIEW
|
Gemma가 상품명을 줄여 반환(불향차돌떡볶이→떡볶이) |
name.value=불향차돌떡볶이, name.normalizedValue=떡볶이
|
1kg 두부 포장 |
measureType=WEIGHT, weight.value=1000, weight.unit=G로 정규화 |
1L 우유 포장 |
measureType=WEIGHT, weight.value=1000, weight.unit=ML로 정규화 |
2개입 500g 포장 |
measureType=WEIGHT, weight=500 G. 2개입은 name.evidenceText에 보존 |
| 숫자만 보이고 단위가 잘린 사진 | 용량을 추정하지 않고 NEEDS_REVIEW 반환 |
| 과거 5년 전 소비기한 날짜로 인식 | 날짜로 채택하지 않음. 보관기간 규칙이 있으면 AI_ESTIMATED, 없으면 UNKNOWN·NEEDS_REVIEW
|
excludedIngredientNames 포함 레시피 |
추천 후보 생성 전에 제외하고 excludedIngredientCheck=PASSED만 반환 |
소수 quantity 또는 COUNT와 중량 동시 요청 |
HTTP 400 INVALID_REQUEST
|
expirationDate가 없는 추천 재고 |
HTTP 400 INVALID_REQUEST
|
| 분석 대기 또는 완료 후 FastAPI 재시작 | 기존 analysisId를 복구하지 못하며 백엔드는 재요청 또는 직접 입력 안내 |
| 레시피 동기화 페이지 중 한 항목 검증 실패 | 페이지 전체를 롤백하고 cursor를 전진시키지 않음 |
| 같은 레시피 원천 버전 재동기화 |
source + sourceRecipeId 기준으로 중복 생성 없이 멱등 반영 |
| 완료 콜백 중복 | 같은 analysisId를 여러 번 받아도 백엔드가 한 번만 반영 |
| 완료 콜백 전달 실패 | 제한 재시도 후 GET 누락 확인 경로 사용. 재시작 이후 전달을 보장하지 않음 |