Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion docs/PROMPTLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,55 @@

- **Prompt**: 이제부터 앞으로의 프롬프트 작업 성공/실패 내역에 대해 docs/PROMPTLOG.md에 작성해주고, docs/record.md에도 중요한 내용이 있으면 계속해서 작성해줘.
- **Result**: ✅ 성공
- **Details**: 로깅 규칙 설정 및 확인. 앞으로 모든 프롬프트 작업에 대해 `docs/PROMPTLOG.md`와 `docs/record.md`에 `ai-guidelines/05-logging.md` 규칙에 따라 기록을 시작함.
- **Details**: 로깅 규칙 설정 및 확인. 앞으로 모든 프롬프트 작업에 대해 `docs/PROMPTLOG.md`와 `record.md`에 `ai-guidelines/05-logging.md` 규칙에 따라 기록을 시작함.

---

## 🗓️ 2025-11-04

- **Prompt**: 이제, 현재의 새로운 기능 브랜치에서는 PRD 기준 "4.2. 식단 기록" 부분을 개발할거야. 음식 사진 업로드 시, AI가 자동 인식하고 영양소를 추출할 수 있도록 적절한 모델 및 영양 성분 DB를 선택하고, 연결하는 과정을 포함해 API 개발까지 진행해줘.
- **Result**: ✅ 성공
- **Details**:
1. **기능 분석 및 설계**: PRD의 "4.2. 식단 기록" 요구사항을 분석하고, S3를 이용한 이미지 업로드, Google Vision AI를 이용한 음식 인식, DB를 통한 영양소 조회의 3단계 프로세스를 설계함.
2. **DTO 생성**: `FoodImageUploadResponse`, `FoodAnalyzeRequest`, `FoodAnalyzeResponse`, `MealCreateRequest` DTO를 `domain/diet/dto`에 생성함.
3. **API 엔드포인트 구현**: `FoodController`에 이미지 업로드 (`/foods/image`), 이미지 분석 (`/foods/analyze`), 식단 생성 (`/meals`) API를 구현함.
4. **서비스 및 인프라 구현**: `FoodService`에 비즈니스 로직을 구현하고, 외부 시스템(S3, Vision AI) 연동을 위한 `S3Uploader`, `FoodVisionClient` 인터페이스와 Mock 구현체를 생성하여 계층 간 의존성을 명확히 함.
5. **Repository 수정**: `FoodRepository`에 `findByName` 메서드를 추가하여 AI가 인식한 음식 이름으로 DB를 조회할 수 있도록 함.

---

## 🗓️ 2025-11-11

- **Prompt**: 현재 수정된 dto 구조를 확인하고, 방금 생성한 dto들이 각각 request와 response 디렉토리 내부에 분류되어 들어가도록 경로를 수정해줘.
- **Result**: ✅ 성공
- **Details**:
1. **DTO 재분류**: `FoodImageUploadResponse`, `FoodAnalyzeRequest`, `FoodAnalyzeResponse`, `MealCreateRequest` DTO 파일들을 `domain/diet/dto` 하위의 `request` 및 `response` 디렉토리로 이동함.
2. **Import 구문 수정**: DTO 파일 경로 변경에 따라 `FoodController`와 `FoodService`의 `import` 구문을 모두 수정하여 코드 정합성을 맞춤.

- **Prompt**: 이제 방금 개발한 기능에 대한 테스트 코드를 작성해줘.
- **Result**: ✅ 성공
- **Details**:
1. **`FoodServiceTest` 확장**: `FoodService`의 단위 테스트에 `uploadFoodImage`, `analyzeFoodImage`, `createMeal` 메서드에 대한 테스트 케이스를 추가함. `S3Uploader`, `FoodVisionClient` 등 외부 의존성은 Mocking하여 서비스 로직을 검증함.
2. **`FoodControllerTest` 확장**: `FoodController`의 통합 테스트에 `/api/v1/foods/image`, `/api/v1/foods/analyze` 엔드포인트에 대한 테스트 케이스를 추가함. `MockMvc`와 `multipart`를 사용하여 실제 API 호출을 시뮬레이션하고 응답을 검증함.

- **Prompt**: 아래와 같이 MealFoodRepository가 없다고 하는데, 문제를 해결해줘.
- **Result**: ✅ 성공
- **Details**:
1. **문제 분석**: `FoodService`에서 `MealFoodRepository`를 주입받고 있으나, 해당 리포지토리 인터페이스 파일이 생성되지 않아 발생한 컴파일 오류임을 확인함.
2. **문제 해결**: `domain/diet/repository` 패키지에 `MealFoodRepository.java` 인터페이스를 생성하여 문제를 해결함.

- **Prompt**: 이제 테스트코드에서 발생하는 아래 오류들을 정정해줘.
- **Result**: ✅ 성공
- **Details**:
1. **문제 분석**: `record` 타입으로 DTO를 리팩토링하면서 발생한 빌더 패턴 호출 오류, 메서드 시그니처 불일치, 잘못된 변수명 사용 등 다수의 컴파일 오류를 분석함.
2. **문제 해결**:
- `FoodControllerTest`에서 `record` DTO 생성 시 `builder()` 대신 `new` 키워드를 사용하도록 수정함.
- `MealControllerTest`에서 `MealCreateRequest`의 내부 클래스 이름을 `FoodItem`에서 `SelectedFood`로 수정하고, `mealFacade.createMeal` Mocking 시 인자 개수를 맞춤.
- `FoodServiceTest`에서 `Meal` 빌더의 `id()` 호출을 제거하고 `ReflectionTestUtils`를 사용하도록 변경했으며, `createMeal` 호출 시 `User` 객체를 전달하도록 수정함.
- 모든 테스트 코드의 컴파일 오류를 최종적으로 해결함.

- **Prompt**: 현재 개발한 dto들을 record 타입으로 리팩토링해줘.
- **Result**: ✅ 성공
- **Details**:
1. **DTO 리팩토링**: `FoodImageUploadResponse`, `FoodAnalyzeRequest`, `FoodAnalyzeResponse`, `MealCreateRequest` DTO를 `class`에서 `record` 타입으로 변경하여 코드 간결성과 불변성을 확보함.
2. **관련 코드 수정**: `record` 변경에 따라, 해당 DTO를 사용하는 `FoodService`, `FoodServiceTest` 등에서 필드 접근 방식을 `getXxx()`에서 `xxx()`로 모두 수정함.
19 changes: 19 additions & 0 deletions docs/record.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,22 @@
* `DailyAggregationSchedulerTest.java`를 작성하여 스케줄러의 동작을 단위 테스트함.
5. **`NutritionTimeseries` 엔티티 리팩토링**: `NutritionTimeseries` 엔티티의 ID를 `NutritionTimeseriesId` 복합 키로 변경하여 TimescaleDB의 시계열 데이터 모델에 더 적합하도록 개선함.
- **결과**: TimescaleDB 통합의 핵심 기능인 일일 영양 데이터 집계 로직이 구현되었으며, 다중 데이터소스 환경에서의 테스트 안정성이 확보됨. 이를 통해 AI 기반 영양 분석 및 추천 시스템의 데이터 기반이 더욱 견고해짐.

---

## 🗓️ 2025-11-11

### AI 기반 식단 기록 기능 개발
- **목적**: 사용자가 음식 사진을 업로드하면 AI가 이미지를 분석하여 자동으로 식단을 기록하는 기능의 핵심 API를 개발하고 테스트 코드를 작성함.
- **주요 변경 사항**:
1. **API 및 서비스 구현**:
- **이미지 업로드**: S3에 이미지를 업로드하고 URL을 반환하는 API (`POST /api/v1/foods/image`) 및 서비스 로직 구현.
- **이미지 분석**: 이미지 URL을 받아 외부 AI(Google Vision)로 음식을 인식하고, DB에서 영양 정보를 조회하여 후보 목록을 반환하는 API (`POST /api/v1/foods/analyze`) 및 서비스 로직 구현.
- **식단 생성**: 최종 선택된 음식 정보로 식단을 생성하는 API (`POST /api/v1/meals`) 및 서비스 로직 구현.
2. **아키텍처 개선**:
- **인프라 계층 분리**: 외부 시스템 연동(S3, Vision AI) 로직을 `S3Uploader`, `FoodVisionClient` 인터페이스로 분리하고, 테스트를 위한 Mock 구현체를 작성하여 의존성을 낮춤.
- **DTO 리팩토링**: 새로 추가된 모든 DTO(`FoodImageUpload...`, `FoodAnalyze...`, `MealCreate...`)를 `record` 타입으로 변경하여 코드 간결성과 불변성을 확보함.
3. **테스트 및 디버깅**:
- **단위/통합 테스트**: `FoodService`와 `FoodController`에 대한 단위/통합 테스트 코드를 작성하여 기능의 안정성을 검증함.
- **컴파일 오류 해결**: `record` 타입 변경, 메서드 시그니처 불일치 등으로 인해 발생한 다수의 컴파일 오류를 해결함.
- **결과**: AI를 활용한 식단 기록 자동화 기능의 백엔드 기반을 마련했으며, 테스트를 통해 안정성을 확보함.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
@Profile("!test")
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.babsim.babsimbackend.timeseries", // TimescaleDB 엔티티 패키지
basePackages = "com.babsim.babsimbackend.domain.timeseries", // TimescaleDB 엔티티 및 리포지토리 패키지
entityManagerFactoryRef = "timescaledbEntityManagerFactory",
transactionManagerRef = "timescaledbTransactionManager"
)
Expand All @@ -30,7 +30,7 @@ public LocalContainerEntityManagerFactoryBean timescaledbEntityManagerFactory(
@Qualifier("timescaledbDataSource") DataSource timescaledbDataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(timescaledbDataSource);
em.setPackagesToScan("com.babsim.babsimbackend.timeseries"); // TimescaleDB 엔티티 패키지
em.setPackagesToScan("com.babsim.babsimbackend.domain.timeseries"); // TimescaleDB 엔티티 패키지

HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
em.setJpaVendorAdapter(vendorAdapter);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package com.babsim.babsimbackend.domain.diet.controller;

import com.babsim.babsimbackend.domain.diet.dto.request.FoodAnalyzeRequest;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodAnalyzeResponse;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodImageUploadResponse;
import com.babsim.babsimbackend.domain.diet.dto.request.FoodCreateRequest;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodResponse;
import com.babsim.babsimbackend.domain.diet.dto.request.FoodUpdateRequest;
import com.babsim.babsimbackend.domain.diet.service.FoodService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.URI;

// AI 생성: Food 관련 API 요청을 처리하는 컨트롤러
Expand All @@ -21,6 +28,20 @@ public class FoodController {

private final FoodService foodService;

@Operation(summary = "음식 사진 업로드", description = "음식 사진을 업로드하고 이미지 URL을 반환합니다.")
@PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<FoodImageUploadResponse> uploadFoodImage(@RequestPart("foodImage") MultipartFile foodImage) throws IOException {
FoodImageUploadResponse response = foodService.uploadFoodImage(foodImage);
return ResponseEntity.ok(response);
}

@Operation(summary = "음식 사진 분석", description = "이미지 URL을 통해 AI로 음식을 분석하고 후보 목록을 반환합니다.")
@PostMapping("/analyze")
public ResponseEntity<FoodAnalyzeResponse> analyzeFoodImage(@Valid @RequestBody FoodAnalyzeRequest request) {
FoodAnalyzeResponse response = foodService.analyzeFoodImage(request);
return ResponseEntity.ok(response);
}

@Operation(summary = "음식 정보 생성", description = "새로운 음식 정보를 시스템에 등록합니다.")
@PostMapping
public ResponseEntity<FoodResponse> createFood(@RequestBody FoodCreateRequest requestDto) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.babsim.babsimbackend.domain.diet.service.MealFacade;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand All @@ -25,10 +26,11 @@ public class MealController {

@Operation(summary = "식단 정보 생성", description = "새로운 식단 정보를 시스템에 등록합니다.")
@PostMapping
public ResponseEntity<MealResponse> createMeal(@RequestBody MealCreateRequest request) {
MealResponse response = mealFacade.createMeal(request);
return ResponseEntity.created(URI.create("/api/v1/meals/" + response.id()))
.body(response);
public ResponseEntity<Void> createMeal(@Valid @RequestBody MealCreateRequest request) {
// AI-Refactor: 현재는 userId를 하드코딩하지만, 향후 Spring Security Context에서 가져와야 함
String userId = "c2a8c3e3-5e6f-4b8a-8f4a-9e4e8c6f2b3a"; // 임시 UUID
Long mealId = mealFacade.createMeal(request, userId);
return ResponseEntity.created(URI.create("/api/v1/meals/" + mealId)).build();
}

@Operation(summary = "사용자 식단 목록 조회", description = "특정 사용자의 모든 식단 정보를 조회합니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.babsim.babsimbackend.domain.diet.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "음식 사진 분석 요청 DTO")
public record FoodAnalyzeRequest(
@NotBlank
@Schema(description = "분석할 이미지 URL")
String imageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
package com.babsim.babsimbackend.domain.diet.dto.request;

import com.babsim.babsimbackend.domain.diet.enums.MealType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;

// AI 생성: 식단 생성을 위한 요청 데이터를 담는 DTO (record로 리팩토링)
@Schema(description = "식단 생성 요청 DTO")
public record MealCreateRequest(
UUID userId,
@NotNull
@Schema(description = "식사 유형 (BREAKFAST, LUNCH, DINNER, SNACK)")
MealType mealType,

@Schema(description = "식단 이미지 URL")
String imageUrl,
List<FoodItem> foods

@NotEmpty
@Schema(description = "식단을 구성하는 음식 목록")
List<SelectedFood> foods
) {
public record FoodItem(
@Schema(description = "사용자가 선택한 음식 정보")
public record SelectedFood(
@Schema(description = "음식 코드")
String foodCode,

@Schema(description = "수량")
Integer quantity
) {}
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.babsim.babsimbackend.domain.diet.dto.response;

import com.babsim.babsimbackend.domain.diet.entity.Food;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

@Schema(description = "음식 사진 분석 결과 응답 DTO")
public record FoodAnalyzeResponse(
List<AnalyzedFood> foods
) {
@Schema(description = "분석된 개별 음식 정보")
public record AnalyzedFood(
@Schema(description = "AI가 인식한 음식 이름")
String recognizedName,

@Schema(description = "DB에서 매칭된 음식 정보")
Food matchedFood
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.babsim.babsimbackend.domain.diet.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "음식 사진 업로드 응답 DTO")
public record FoodImageUploadResponse(
@Schema(description = "업로드된 이미지 URL")
String imageUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.babsim.babsimbackend.domain.diet.entity.Food;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

// AI 생성: Food 엔티티의 데이터 접근을 위한 Repository 인터페이스
public interface FoodRepository extends JpaRepository<Food, String> {
Optional<Food> findByName(String name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.babsim.babsimbackend.domain.diet.repository;

import com.babsim.babsimbackend.domain.diet.entity.MealFood;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MealFoodRepository extends JpaRepository<MealFood, Long> {
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
package com.babsim.babsimbackend.domain.diet.service;

import com.babsim.babsimbackend.domain.diet.dto.request.FoodAnalyzeRequest;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodAnalyzeResponse;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodImageUploadResponse;
import com.babsim.babsimbackend.domain.diet.dto.request.MealCreateRequest;
import com.babsim.babsimbackend.domain.diet.dto.request.FoodCreateRequest;
import com.babsim.babsimbackend.domain.diet.dto.response.FoodResponse;
import com.babsim.babsimbackend.domain.diet.dto.request.FoodUpdateRequest;
import com.babsim.babsimbackend.domain.diet.entity.Food;
import com.babsim.babsimbackend.domain.diet.entity.Meal;
import com.babsim.babsimbackend.domain.diet.entity.MealFood;
import com.babsim.babsimbackend.domain.diet.exception.FoodNotFoundException;
import com.babsim.babsimbackend.domain.diet.repository.FoodRepository;
import com.babsim.babsimbackend.domain.diet.repository.MealFoodRepository;
import com.babsim.babsimbackend.domain.diet.repository.MealRepository;
import com.babsim.babsimbackend.domain.user.entity.User;
import com.babsim.babsimbackend.infrastructure.s3.S3Uploader;
import com.babsim.babsimbackend.infrastructure.vision.FoodVisionClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

// AI 생성: Food 엔티티 관련 비즈니스 로직을 처리하는 서비스 클래스
@Service
Expand All @@ -17,6 +33,58 @@
public class FoodService {

private final FoodRepository foodRepository;
private final MealRepository mealRepository;
private final MealFoodRepository mealFoodRepository;
private final S3Uploader s3Uploader;
private final FoodVisionClient foodVisionClient;

private static final String S3_DIR_NAME = "food-images";

@Transactional
public FoodImageUploadResponse uploadFoodImage(MultipartFile foodImage) throws IOException {
String imageUrl = s3Uploader.upload(foodImage, S3_DIR_NAME);
return new FoodImageUploadResponse(imageUrl);
}

public FoodAnalyzeResponse analyzeFoodImage(FoodAnalyzeRequest request) {
List<String> recognizedFoodNames = foodVisionClient.analyzeImage(request.imageUrl());

List<FoodAnalyzeResponse.AnalyzedFood> analyzedFoods = recognizedFoodNames.stream()
.map(this::findBestMatchingFood)
.collect(Collectors.toList());

return new FoodAnalyzeResponse(analyzedFoods);
}

private FoodAnalyzeResponse.AnalyzedFood findBestMatchingFood(String recognizedName) {
// AI-Refactor: 현재는 이름으로만 검색하지만, 향후 벡터 검색 등으로 고도화 필요
Food matchedFood = foodRepository.findByName(recognizedName).orElse(null);
return new FoodAnalyzeResponse.AnalyzedFood(recognizedName, matchedFood);
}

@Transactional
public Long createMeal(MealCreateRequest request, User user) {
Meal meal = Meal.builder()
.user(user)
.mealType(request.mealType())
.imageUrl(request.imageUrl())
.build();
Meal savedMeal = mealRepository.save(meal);

List<MealFood> mealFoods = request.foods().stream()
.map(selectedFood -> {
Food food = findFoodEntityByCode(selectedFood.foodCode());
return MealFood.builder()
.meal(savedMeal)
.food(food)
.quantity(selectedFood.quantity())
.build();
})
.collect(Collectors.toList());

mealFoodRepository.saveAll(mealFoods);
return savedMeal.getId();
}

@Transactional
public FoodResponse createFood(FoodCreateRequest requestDto) {
Expand Down
Loading