갱신: 2026-05-13 | V2 음식 검색/레시피 상세/조리 모드 계약 반영
Base URL : AppConfig.baseURL (기본 http://localhost:8000)
Timeout : 30초 (기본), 파싱 API 300초
| 헤더 | 값 | 비고 |
|---|---|---|
Content-Type |
application/json |
모든 요청 |
X-Device-Id |
DeviceManager.deviceId |
기기 식별 (리뷰/기록 귀속) |
모든 성공 응답은 SuccessResponse<T> 래퍼:
{ "success": true, "data": <T> }에러 응답:
{ "success": false, "error": { "code": "RECIPE_NOT_FOUND", "message": "레시피를 찾을 수 없습니다." } }PaginatedData<T>:
{ "items": [...], "total": 120, "page": 1, "size": 20, "total_pages": 6 }쿼리: ?page=1&size=20
| 방향 | 전략 | 예시 |
|---|---|---|
| 서버→iOS (디코딩) | convertFromSnakeCase |
recipe_id → recipeId |
| iOS→서버 (인코딩) | convertToSnakeCase |
recipeId → recipe_id |
- 서버는
id필드 사용 (Mongo_id는 서버에서id로 정규화) - iOS에서
CodingKeys제거 —convertFromSnakeCase전략과 충돌 방지
신규 검색 UX는 legacy /api/recipes 목록 검색이 아니라 V2 음식 중심 흐름을 사용한다.
V3 v3_* 컬렉션과 endpoint는 lab 전용이며 앱에서 직접 호출하지 않는다.
쿼리: ?q=떡볶이&page=1&size=20
응답: PaginatedData<V2Dish>
호출: FoodSearchViewModel.search()
응답: V2Dish (variants 포함 가능)
호출: V2DishDetailViewModel.load()
응답: PaginatedData<V2DishCreatorProfile>
creator.display_name, creator.thumbnail_url, top-level thumbnail_url, style_summary,
common_style_tags, signature_ingredients, comment_recipe_rating을 유튜버 비교 카드에 사용한다.
앱은 creator.display_name이 비어 있고 creator_id가 youtube:UC... 형태이면 원시 id를 노출하지 않고
일반 채널 fallback과 썸네일/이니셜 아바타를 표시한다.
호출: V2DishDetailViewModel.load()
쿼리:
| 파라미터 | 타입 | 기본값 | 비고 |
|---|---|---|---|
variant_id |
String? | — | 음식 variant 필터 |
creator_id |
String? | — | 유튜버 필터 |
style_tags |
String? | — | 쉼표 구분 스타일 필터 |
sort |
String | quality_score |
iOS 기본 정렬 |
order |
String | desc |
|
page, size |
Int | 1, 20 |
응답: PaginatedData<V2Recipe>
호출: V2DishDetailViewModel.load()
응답: V2Recipe
호출: V2RecipeDetailSheet.loadDetail()
상세 요청이 404 또는 네트워크 오류를 반환해도 목록에서 받은 V2Recipe 데이터가 있으면 사용자에게 서버 오류 배너를
노출하지 않고 그대로 조리 시작까지 진행한다.
응답:
{
"recipe_id": "recipe-id",
"title": "떡볶이",
"steps": [
{ "step_number": 1, "instruction": "...", "timer_seconds": 180, "timer_label": "양념 끓이기", "timestamp": "02:10", "tip": "...", "action_type": "cook" }
],
"ingredients": [
{ "name": "떡", "amount": 300, "unit": "g", "note": null, "is_main": true }
]
}timer_seconds, timer_label, tip, action_type은 조리 UI에서 그대로 사용한다.
action_type UI 매핑: prep 준비, cook 가열, wait 대기, plate 담기.
호출: CookingViewModel.startSession(recipe: V2Recipe)
/steps 요청이 시간 초과/서버 오류를 반환했지만 현재 V2Recipe.steps가 있으면 조리모드는 embedded steps로
즉시 시작한다. 이 경우 이전 timeout/offline 배너를 유지하지 않는다.
Legacy 레시피 저장/파싱/리뷰 호환 경로다. 새 음식 검색과 V2 데이터 조리모드는 위 FoodV2Service를 사용한다.
Timeout: 300초 (AppConfig.parseTimeoutSeconds)
요청:
// 유튜브
{ "youtube_url": "https://youtube.com/watch?v=...", "instagram_url": null, "source_type": "youtube" }
// 인스타그램
{ "youtube_url": null, "instagram_url": "https://instagram.com/p/...", "source_type": "instagram" }응답: Recipe (전체 필드 — §3.1 참조)
호출: HomeViewModel.parseRecipe()
쿼리:
| 파라미터 | 타입 | 기본값 | 비고 |
|---|---|---|---|
page |
Int | 1 | |
size |
Int | 20 | |
category |
String? | — | 카테고리 필터 |
tags |
String? | — | 태그 필터 |
search |
String? | — | 검색어 |
sort |
String | created_at |
정렬 기준 |
order |
String | desc |
asc/desc |
응답: PaginatedData<RecipeListItem>
호출: RecipeListViewModel.fetchPage()
응답: Recipe
호출: RecipeDetailViewModel
응답:
{
"recipe_id": "abc123",
"title": "김치찌개",
"steps": [
{ "step_number": 1, "instruction": "...", "timer_seconds": 300, "timer_label": "끓이기", "timestamp": "0:30", "tip": "...", "action_type": "cook" }
],
"ingredients": [
{ "id": "...", "name": "김치", "amount": 200, "unit": "g", "note": null, "is_main": true }
]
}호출: CookingViewModel.startSession()
응답: { "deleted": true }
호출: RecipeDetailViewModel
요청:
{
"title": "나만의 파스타",
"description": "간단 레시피",
"servings": "2인분",
"total_time_minutes": 30,
"difficulty": "easy",
"category": "양식",
"tags": ["파스타", "간단"],
"source_type": "custom",
"ingredients": [
{ "name": "스파게티면", "amount": 200, "unit": "g", "note": null, "is_main": true }
],
"steps": [
{ "step_number": 1, "instruction": "물을 끓인다", "timer_seconds": 600, "tip": "소금 한 꼬집" }
]
}응답: Recipe
호출: UserRecipeCreateViewModel
쿼리: ?page=1&size=20
응답: PaginatedData<RecipeListItem>
호출: 현재 미사용 (향후 내 레시피 화면)
바코드는 legacy 기능으로 유지한다. 현재 V2 앱 탭/검색/조리 흐름에서는 새 진입점으로 쓰지 않는다.
요청:
{ "barcode": "8801234567890" }입력 타입: 바코드 문자열(권장 8~14자리 숫자), 또는 QR 문자열.
클라이언트 정규화 규칙:
- 바코드/QR 원문에서 공백 제거
- 바코드 문자열에서 숫자 시퀀스를 추출해 8~14자리이면 우선 사용
- 추출 불가 시 원문 문자열 그대로 전송
응답:
{
"product": {
"id": "...",
"barcode": "8801234567890",
"product_name": "CJ 햇반",
"name": "햇반",
"brand": "CJ",
"category": "가공식품",
"image_url": "https://..."
},
"extracted_ingredients": ["쌀", "물"],
"recipes": [ /* RecommendationItem[] */ ],
"message": "2개의 레시피를 찾았습니다."
}extracted_ingredients는 바코드/QR로부터 추출한 재료명 후보이며, 검색 실패 시 null 가능.
| 필드 | 타입 | Nullable | 설명 |
|---|---|---|---|
product |
BarcodeProductInfo |
✅ | 미등록 바코드 시 null |
extracted_ingredients |
[String] |
✅ | 상품에서 추출한 재료명 |
recipes |
[RecommendationItem] |
✅ | 재료 기반 추천 레시피 |
message |
String |
✅ | 사용자 안내 메시지 |
호출: BarcodeScanViewModel.searchBarcode()
요청:
{ "recipe_id": "abc123" }응답:
{ "session_id": "sess_xyz", "recipe_id": "abc123", "started_at": "2026-03-19T10:00:00Z" }호출: CookingViewModel.startSession()
요청:
{ "step_number": 3 }응답: { "step_number": 3 }
호출: CookingViewModel.onStepChanged() (단계 이동 시 자동)
요청:
{ "deducted_ingredients": ["김치", "돼지고기", "두부"] }응답:
{
"completed_at": "2026-03-19T11:30:00Z",
"deducted": [
{ "name": "김치", "before": 500, "after": 300, "deleted": false },
{ "name": "두부", "before": 1, "after": 0, "deleted": true }
],
"rating_reminder_at": "2026-03-19T14:30:00Z",
"calories_per_serving": 450,
"servings_count": 2,
"servings_label": "2인분",
"total_calories": 900
}| 필드 | 타입 | Nullable | 설명 |
|---|---|---|---|
completed_at |
String | ❌ | ISO 8601 완료 시각 |
deducted |
[DeductedItem] |
❌ | 차감 결과 (빈 배열 가능) |
rating_reminder_at |
String | ❌ | 별점 리마인더 시각 |
calories_per_serving |
Int | ✅ | 1인분 칼로리 |
servings_count |
Int | ✅ | 인분 수 |
servings_label |
String | ✅ | "2인분" 등 표시 문구 |
total_calories |
Int | ✅ | 총 칼로리 (HealthKit 연동용) |
HealthKit 연동: total_calories > 0이면 HealthKitNutritionLogger.recordMealCompletion() 호출. 권한 거부 시 VoiceGuide.healthKitDenied 텍스트 표시.
호출: CookingViewModel.completeSession()
요청:
{ "rating": 4, "memo": "맛있었어요" }| 필드 | 타입 | Nullable | 범위 |
|---|---|---|---|
rating |
Int | ❌ | 1~5 |
memo |
String | ✅ |
응답:
{ "rating": 4, "rated_at": "2026-03-19T12:00:00Z" }호출: CookingViewModel+Voice.submitRating(), HistoryViewModel
쿼리: ?page=1&size=20
응답: PaginatedData<CookingHistoryItem>
{
"id": "hist_123",
"recipe_id": "abc123",
"recipe_title": "김치찌개",
"recipe_thumbnail": "https://...",
"started_at": "2026-03-19T10:00:00Z",
"completed_at": "2026-03-19T11:30:00Z",
"is_completed": true,
"rating": 4
}호출: HistoryViewModel
요청:
{
"session_id": "sess_xyz",
"recipe_id": "abc123",
"message": "이 단계에서 불 세기는?",
"current_step": 3,
"session_context": {
"active_timers": [
{ "label": "끓이기", "remaining_seconds": 120 }
],
"completed_steps": [1, 2]
}
}응답:
{ "answer": "중불로 조리하세요.", "suggestions": ["다음 단계", "타이머 확인"] }| 필드 | 타입 | Nullable |
|---|---|---|
answer |
String | ❌ |
suggestions |
[String] |
✅ |
오프라인 폴백: 네트워크 미연결 시 LocalCookingAssistant.fallbackReply() → 키워드 매칭 응답. 오프라인 음성 모드 시 LocalCookingAssistant.reply() → Foundation Models / llama.cpp / 키워드 3단 폴백.
호출: CookingViewModel+Chat.sendChat()
요청:
{
"session_id": "sess_xyz",
"recipe_id": "abc123",
"event_type": "timer_completed",
"current_step": 3,
"event_data": { "label": "끓이기" }
}응답:
{ "message": "끓이기가 완료되었습니다. 불을 줄이고 5분간 뜸을 들이세요.", "suggestions": ["다음 단계"] }호출: CookingViewModel.handleTimerComplete()
응답: 서버 [{ role, message }] → iOS [ChatMessage] 변환
호출: 현재 미사용 (향후 채팅 기록 복원)
쿼리: ?page=1&size=20
응답: PaginatedData<Review>
{
"id": "rev_123",
"recipe_id": "abc123",
"device_id": "device_xyz",
"rating": 4,
"comment": "맛있어요!",
"created_at": "2026-03-19T12:00:00Z",
"updated_at": null
}호출: ReviewViewModel.loadInitial(), ReviewViewModel.loadMore()
요청:
{ "rating": 5, "comment": "정말 맛있어요" }응답: Review
호출: ReviewViewModel.submitReview()
응답:
{ "average_rating": 4.2, "rating_count": 15 }| 필드 | 타입 | Nullable |
|---|---|---|
average_rating |
Double | ✅ |
rating_count |
Int | ✅ |
호출: ReviewViewModel.loadInitial(), CookingViewModel+Voice.fetchAndReadReviews() (음성 리뷰 조회)
응답: Review? (없으면 null)
호출: ReviewViewModel.loadInitial()
응답: { "deleted": true }
호출: 현재 미사용 (요리 중 음성 삭제는 차단 → 터치 안내)
쿼리: ?category=육류 (선택)
응답: [UserIngredient]
호출: FridgeViewModel.load()
요청:
{
"name": "돼지고기",
"category": "육류",
"quantity": 500,
"unit": "g",
"storage_type": "fridge",
"purchase_date": "2026-03-15",
"expiry_date": "2026-03-22",
"is_opened": false,
"memo": null
}응답: UserIngredient
호출: FridgeViewModel.addIngredient()
요청: 부분 업데이트 (모든 필드 optional)
{ "quantity": 300, "is_opened": true }응답: UserIngredient
호출: FridgeViewModel.editIngredient()
응답: { "deleted": true }
호출: FridgeViewModel.deleteIngredient()
쿼리: ?days=3
응답: [UserIngredient]
호출: FridgeViewModel.load()
쿼리: ?period=weekly&limit=10
응답: [RecommendationItem]
호출: RecommendationViewModel.load(), HomeViewModel.loadHome()
쿼리: ?count=20&category=한식&tags=매운
응답: [RecommendationItem]
호출: RecommendationViewModel.load(), HomeViewModel.loadHome()
쿼리: ?limit=20
응답: [RecommendationItem] (matchScore, matchedIngredients, missingIngredients 포함)
호출: RecommendationViewModel.load()
쿼리: ?days=3&limit=10
응답: [RecommendationItem]
호출: RecommendationViewModel.load(), HomeViewModel.loadHome()
응답: ["한식", "중식", "일식", "양식", ...]
호출: RecipeListViewModel.loadInitial()
응답: ["매운", "간단", "다이어트", ...]
호출: RecipeListViewModel.loadInitial()
| 필드 (iOS) | 서버 키 | 타입 | Nullable | 비고 |
|---|---|---|---|---|
id |
id |
String | ❌ | |
youtubeVideoId |
youtube_video_id |
String | ✅ | |
youtubeUrl |
youtube_url |
String | ✅ | |
instagramUrl |
instagram_url |
String | ✅ | |
channelName |
channel_name |
String | ❌ | |
channelId |
channel_id |
String | ✅ | |
thumbnailUrl |
thumbnail_url |
String | ❌ | |
title |
title |
String | ❌ | |
description |
description |
String | ✅ | |
servings |
servings |
String | ✅ | "2인분" |
totalTimeMinutes |
total_time_minutes |
Int | ✅ | |
difficulty |
difficulty |
String | ✅ | easy/medium/hard |
category |
category |
String | ❌ | |
tags |
tags |
[String] | ❌ | |
ingredients |
ingredients |
[IngredientItem] | ❌ | |
steps |
steps |
[RecipeStep] | ❌ | |
commentAnalysis |
comment_analysis |
CommentAnalysis | ✅ | |
viewCount |
view_count |
Int | ❌ | |
cookCount |
cook_count |
Int | ❌ | |
status |
status |
String | ❌ | |
parsingTimeSeconds |
parsing_time_seconds |
Double | ✅ | |
sourceType |
source_type |
String | ✅ | youtube/instagram/custom/user_upload |
createdAt |
created_at |
String | ✅ | ISO 8601 |
caloriesPerServing |
calories_per_serving |
Int | ✅ | AI 추정 칼로리 |
estimationMethod |
estimation_method |
String | ✅ | ai/rule_based/failed |
deliveryPriceKrw |
delivery_price_krw |
Int | ✅ | 배달 예상가 (신규 필드) |
estimatedCostKrw |
estimated_cost_krw |
Int | ✅ | 집밥 원가 (신규 필드) |
costSavingKrw |
cost_saving_krw |
Int | ✅ | 절감액 (신규 필드) |
averageRating |
average_rating |
Double | ✅ | |
ratingCount |
rating_count |
Int | ✅ | |
deviceId |
device_id |
String | ✅ | 작성자 기기 ID |
비용 필드 정합: 현재는 _krw 시리즈(delivery_price_krw, estimated_cost_krw, cost_saving_krw)만 클라이언트에서 수신/표시 대상입니다. 구버전(homeCookingCost, estimatedDeliveryCost, savingsAmount)은 서버 응답 계약에서 제외됐습니다.
Recipe의 서브셋 + 추천 전용 필드:
| 추천 전용 필드 | 타입 | 비고 |
|---|---|---|
matchScore |
Double? | 냉장고 매칭 점수 (0~1) |
matchedIngredients |
[String]? | 매칭된 재료명 |
missingIngredients |
[String]? | 부족 재료명 |
IngredientItem만 CodingKeys 사용 — id 필드 옵셔널 폴백 (서버 미제공 시 UUID 자동 생성).
| HTTP | 서버 코드 | 의미 | iOS 처리 |
|---|---|---|---|
| 400 | INVALID_REQUEST |
잘못된 요청 | APIError.server → userMessage 표시 |
| 404 | RECIPE_NOT_FOUND |
레시피 없음 | 에러 메시지 표시 |
| 404 | SESSION_NOT_FOUND |
세션 없음 | 에러 메시지 표시 |
| 409 | ALREADY_RATED |
이미 별점 등록 | VoiceGuide.ratingFailed |
| 500 | INTERNAL_ERROR |
서버 내부 오류 | 키워드 폴백 or 에러 표시 |
| ViewModel | Service 호출 |
|---|---|
HomeViewModel |
RecommendationService.popular/random/expiring, RecipeService.parse/parseInstagram |
FoodSearchViewModel |
FoodV2Service.status/searchDishes |
V2DishDetailViewModel |
FoodV2Service.dish/recipes/creators |
V2RecipeDetailSheet |
FoodV2Service.recipe |
RecipeListViewModel |
CategoryService.categories/tags, RecipeService.list |
RecipeDetailViewModel |
RecipeService.get/delete |
BarcodeScanViewModel |
RecipeService.searchBarcode |
UserRecipeCreateViewModel |
RecipeService.createCustom |
CookingViewModel |
legacy: CookingService.start/updateStep/complete, RecipeService.getSteps; V2: FoodV2Service.recipeSteps; 공통: ChatService.proactive |
CookingViewModel+Chat |
ChatService.chat |
CookingViewModel+Voice |
CookingService.rate, ReviewService.stats |
ReviewViewModel |
ReviewService.list/create/stats/myReview |
HistoryViewModel |
CookingService.rate/history |
FridgeViewModel |
IngredientService.list/create/update/delete/expiring |
RecommendationViewModel |
RecommendationService.popular/random/byIngredients/expiring |
| 엔드포인트 | 구현 | 호출 | 상태 |
|---|---|---|---|
GET /api/my-recipes |
✅ | ❌ | 내 레시피 화면 미구현 |
GET /api/chat/history/{sessionId} |
✅ | ❌ | 채팅 기록 복원 미사용 |
DELETE /api/recipes/reviews/{reviewId} |
✅ | ❌ | 요리 중 음성 삭제 차단, 터치 삭제 미연결 |
DELETE /api/recipes/{id} |
✅ | ✅ | RecipeDetailView에서 사용 |
RecipeService.listMyRecipes() |
✅ | ❌ | ViewModel 미연결 |
- 새 모델 추가 시
CodingKeys없이 작성 - 서버 키가 snake_case → iOS 프로퍼티는 camelCase로만 선언
- 예외 매핑이 필요하면 최소 범위
CodingKeys도입 + 이 문서 갱신 - Mock 모드는 JSON 디코딩 경로를 타지 않으므로 실서버 테스트 필수
- 전체 엔드포인트 계약 확정 (7개 Service, 28개 엔드포인트)
- 요청/응답 페이로드 + 예시 JSON 추가
- 바코드 API 입력 타입 명확화 (숫자 문자열/QR 문자열 지원)
- 요리 완료 API 칼로리/HealthKit 필드 정합
- 비용 필드
_krw단일화 정책 반영 - 누락 엔드포인트 체크리스트 추가
- ViewModel→Service 호출 맵 추가
- 대상 파일 4개에서 지정 타입 7개의
CodingKeys제거 정책 확정 API_CONTRACT.md문서 신규 작성- 전역 원칙:
APIClient의 snake_case 자동 변환 전략을 단일 소스로 유지