Repository: choung-ha/diary-be
Pull request #31: LLM 모듈 공통 예외 로직 추가
Author: login: kochungcheon
Created: 2025-03-29T10:20:06Z | Merged: 2025-04-04T15:40:00Z

구현 한 것
- [x] LLM Module 을 위한 공통 에러 코드 처리 로직
- [x] 에러 코드를 LlmApiservice 및 테스트 코드에 적용   


Diff:
diff --git a/diary-common/src/main/java/chungha/diarycommon/entity/Change.java b/diary-common/src/main/java/chungha/diarycommon/entity/Change.java
deleted file mode 100644
index 9f9dccd..0000000
--- a/diary-common/src/main/java/chungha/diarycommon/entity/Change.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package chungha.diarycommon.entity;
-
-public record Change(
-	int startIdx,
-	int endIdx,
-	String changeContent
-) {
-}
diff --git a/diary-llm/src/main/java/chungha/diaryllm/exception/LlmErrorCode.java b/diary-llm/src/main/java/chungha/diaryllm/exception/LlmErrorCode.java
new file mode 100644
index 0000000..6ac1874
--- /dev/null
+++ b/diary-llm/src/main/java/chungha/diaryllm/exception/LlmErrorCode.java
@@ -0,0 +1,33 @@
+package chungha.diaryllm.exception;
+
+import org.springframework.http.HttpStatus;
+
+import chungha.diarycommon.exception.BaseErrorCode;
+import chungha.diarycommon.exception.ServiceException;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum LlmErrorCode implements BaseErrorCode {
+	// llm module
+	DIARY_FEEDBACK_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 피드백이 반영된 일기입니다."),
+	LLM_CALL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "LLM 호출에 실패했습니다."),
+	DIARY_CONDITION_NOT_MATCH(HttpStatus.BAD_REQUEST, "조건에 맞는 일기가 없습니다."),
+	FEEDBACK_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "피드백 저장에 실패했습니다.");
+
+	private final HttpStatus httpStatus;
+	private final String message;
+
+	public ServiceException serviceException() {
+		return new ServiceException(this.name(), this.message);
+	}
+
+	public ServiceException serviceException(String debugMessage, Object... debugMessageArgs) {
+		return new ServiceException(this.name(), message, String.format(debugMessage, debugMessageArgs));
+	}
+
+	public ServiceException serviceException(Throwable cause, String debugMessage, Object... debugMessageArgs) {
+		return new ServiceException(cause, this.name(), message, String.format(debugMessage, debugMessageArgs));
+	}
+}
diff --git a/diary-llm/src/main/java/chungha/diaryllm/exception/LlmExceptionHandler.java b/diary-llm/src/main/java/chungha/diaryllm/exception/LlmExceptionHandler.java
new file mode 100644
index 0000000..92884c7
--- /dev/null
+++ b/diary-llm/src/main/java/chungha/diaryllm/exception/LlmExceptionHandler.java
@@ -0,0 +1,62 @@
+package chungha.diaryllm.exception;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
+
+import chungha.diarycommon.exception.ErrorResponse;
+import chungha.diarycommon.exception.ServiceException;
+import reactor.core.publisher.Mono;
+
+@RestControllerAdvice
+public class LlmExceptionHandler {
+	private final Logger logger = LoggerFactory.getLogger(LlmExceptionHandler.class);
+	private final ZoneId zoneId = ZoneId.of("Asia/Seoul");
+
+	@ExceptionHandler(ServiceException.class)
+	public Mono<ResponseEntity<ErrorResponse>> handleValidException(ServiceException exception) {
+		if (exception.getDebugMessage() != null) {
+			logger.error(exception.getMessage());
+		}
+
+		var errorResponse = new ErrorResponse(exception.getErrorMessage(), ZonedDateTime.now(zoneId));
+		var httpStatus = LlmErrorCode.valueOf(exception.getErrorCode()).getHttpStatus();
+
+		return Mono.just(ResponseEntity.status(httpStatus).body(errorResponse));
+	}
+
+	@ExceptionHandler(HttpMessageNotReadableException.class)
+	public Mono<ResponseEntity<ErrorResponse>> handleDeserializationException(HttpMessageNotReadableException exception) {
+		Throwable cause = exception.getCause();
+		if (cause instanceof ValueInstantiationException valueException) {
+			Throwable nested = valueException.getCause();
+			if (nested instanceof ServiceException serviceException) {
+				var errorResponse = new ErrorResponse(serviceException.getErrorMessage(), ZonedDateTime.now(zoneId));
+				return Mono.just(ResponseEntity.badRequest().body(errorResponse));
+			}
+		}
+
+		logger.error(exception.getMessage());
+		var errorResponse = new ErrorResponse("Sorry, something went wrong", ZonedDateTime.now(zoneId));
+		return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse));
+	}
+
+	@ExceptionHandler(Exception.class)
+	public Mono<ResponseEntity<ErrorResponse>> handleException(Exception exception) {
+		logger.error(exception.getMessage());
+		var errorResponse = new ErrorResponse(
+			"Sorry, something went wrong",
+			ZonedDateTime.now(zoneId)
+		);
+		return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse));
+	}
+}
diff --git a/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiRepository.java b/diary-llm/src/main/java/chungha/diaryllm/repository/LlmApiRepository.java
similarity index 89%
rename from diary-llm/src/main/java/chungha/diaryllm/service/LlmApiRepository.java
rename to diary-llm/src/main/java/chungha/diaryllm/repository/LlmApiRepository.java
index 989469c..c9bc381 100644
--- a/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiRepository.java
+++ b/diary-llm/src/main/java/chungha/diaryllm/repository/LlmApiRepository.java
@@ -1,4 +1,4 @@
-package chungha.diaryllm.service;
+package chungha.diaryllm.repository;
 
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
@@ -20,7 +20,7 @@
 public class LlmApiRepository {
 	private final ReactiveMongoTemplate reactiveMongoTemplate;
 
-	Mono<Diary> reserveDiaryUpdate(String diaryId, String userId) {
+	public Mono<Diary> reserveDiaryUpdate(String diaryId, String userId) {
 		Query query = new Query(
 			Criteria.where("_id")
 				.is(diaryId)
@@ -33,7 +33,7 @@ Mono<Diary> reserveDiaryUpdate(String diaryId, String userId) {
 		return reactiveMongoTemplate.findAndModify(query, update, options, Diary.class);
 	}
 
-	Mono<Diary> updateFeedbackAndChanges(
+	public Mono<Diary> updateFeedbackAndChanges(
 		String diaryId,
 		String improvedContent,
 		Map<String, String> feedbackMap) {
@@ -50,7 +50,7 @@ Mono<Diary> updateFeedbackAndChanges(
 		return reactiveMongoTemplate.findAndModify(query, updateQuery, options, Diary.class);
 	}
 
-	Mono<Diary> setPending(String diaryId, boolean pending) {
+	public Mono<Diary> setPending(String diaryId, boolean pending) {
 		Query query = new Query(Criteria.where("_id").is(diaryId));
 		Update update = new Update().set("pending", pending);
 		FindAndModifyOptions options = new FindAndModifyOptions().returnNew(true);
diff --git a/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiService.java b/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiService.java
index fa77ff7..acc724c 100644
--- a/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiService.java
+++ b/diary-llm/src/main/java/chungha/diaryllm/service/LlmApiService.java
@@ -8,11 +8,14 @@
 import org.springframework.ai.openai.OpenAiChatModel;
 import org.springframework.stereotype.Service;
 
+import chungha.diarycommon.exception.CommonErrorCode;
+import chungha.diaryllm.exception.LlmErrorCode;
 import chungha.diaryllm.model.request.FeedbackReq;
 import chungha.diaryllm.model.response.FeedbackRes;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
 import chungha.diarycommon.entity.Diary;
+import chungha.diaryllm.repository.LlmApiRepository;
 import lombok.RequiredArgsConstructor;
 import reactor.core.publisher.Mono;
 import reactor.core.scheduler.Schedulers;
@@ -49,10 +52,10 @@ public class LlmApiService {
 
 	public Mono<Diary> createFeedBackAndSave(FeedbackReq req) {
 		return llmApiRepository.reserveDiaryUpdate(req.diaryId(), req.userId())
-			.switchIfEmpty(Mono.error(new RuntimeException("일기를 찾을 수 없거나 권한이 없습니다.")))
+			.switchIfEmpty(Mono.error(CommonErrorCode.DIARY_NOT_FOUND.serviceException()))
 			.flatMap(diary -> {
 				if (diary.getImprovedContent() != null) {
-					return Mono.error(new RuntimeException("이미 피드백이 반영된 일기입니다"));
+					return Mono.error(LlmErrorCode.DIARY_FEEDBACK_ALREADY_EXISTS.serviceException());
 				}
 				return callAndParseFeedback(diary.getContent())
 					.flatMap(parseFeedback -> saveFeedBackAfterReservation(diary, parseFeedback))
@@ -74,7 +77,7 @@ private Mono<FeedbackRes> callAndParseFeedback(String content) {
 					.maxBackoff(Duration.ofSeconds(10))
 					.filter(throwable -> (throwable instanceof RuntimeException) || (throwable instanceof IOException))
 				.onRetryExhaustedThrow(((retrySpec, retrySignal) ->
-					new RuntimeException("LLM 호출에 실패했습니다.", retrySignal.failure())))
+					LlmErrorCode.LLM_CALL_FAILED.serviceException()))
 			);
 	}
 
@@ -82,7 +85,7 @@ private Mono<Diary> saveFeedBackAfterReservation(Diary diary, FeedbackRes feedba
 		String improvedContent = feedback.improvedContent();
 		Map<String, String> sanitizeMap = sanitizeFeedback(feedback.feedback());
 		return llmApiRepository.updateFeedbackAndChanges(diary.getId(), improvedContent, sanitizeMap)
-			.switchIfEmpty(Mono.error(new RuntimeException("조건에 맞는 일기가 없습니다")));
+			.switchIfEmpty(Mono.error(LlmErrorCode.FEEDBACK_SAVE_FAILED.serviceException()));
 	}
 
 
diff --git a/diary-llm/src/test/java/chungha/diaryllm/service/LlmApiServiceTest.java b/diary-llm/src/test/java/chungha/diaryllm/service/LlmApiServiceTest.java
index 64c550e..15edb0b 100644
--- a/diary-llm/src/test/java/chungha/diaryllm/service/LlmApiServiceTest.java
+++ b/diary-llm/src/test/java/chungha/diaryllm/service/LlmApiServiceTest.java
@@ -19,8 +19,12 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 
 import chungha.diarycommon.entity.Diary;
+import chungha.diarycommon.exception.CommonErrorCode;
+import chungha.diarycommon.exception.ServiceException;
+import chungha.diaryllm.exception.LlmErrorCode;
 import chungha.diaryllm.model.request.FeedbackReq;
 import chungha.diaryllm.model.response.FeedbackRes;
+import chungha.diaryllm.repository.LlmApiRepository;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
 
@@ -52,7 +56,11 @@ void createFeedback_diaryNotFound() {
 		Mono<Diary> result = llmApiService.createFeedBackAndSave(req);
 
 		StepVerifier.create(result)
-			.expectErrorMessage("일기를 찾을 수 없거나 권한이 없습니다.")
+			.expectErrorMatches(throwable ->
+				throwable instanceof ServiceException serviceException &&
+					serviceException.getErrorCode().equals(CommonErrorCode.DIARY_NOT_FOUND.name()) &&
+					serviceException.getErrorMessage().equals(CommonErrorCode.DIARY_NOT_FOUND.getMessage())
+			)
 			.verify();
 	}
 
@@ -76,7 +84,10 @@ void createFeedback_feedbackAlreadyExist() {
 		Mono<Diary> result = llmApiService.createFeedBackAndSave(req);
 
 		StepVerifier.create(result)
-			.expectErrorMessage("이미 피드백이 반영된 일기입니다")
+			.expectErrorMatches(throwable ->
+				throwable instanceof ServiceException serviceException &&
+					serviceException.getErrorCode().equals(LlmErrorCode.DIARY_FEEDBACK_ALREADY_EXISTS.name()) &&
+					serviceException.getErrorMessage().equals(LlmErrorCode.DIARY_FEEDBACK_ALREADY_EXISTS.getMessage()))
 			.verify();
 	}
 
@@ -94,7 +105,10 @@ void createFeedback_diaryPending() {
 		Mono<Diary> result = llmApiService.createFeedBackAndSave(req);
 
 		StepVerifier.create(result)
-			.expectErrorMessage("일기를 찾을 수 없거나 권한이 없습니다.")
+			.expectErrorMatches(throwable ->
+				throwable instanceof ServiceException serviceException &&
+					serviceException.getErrorCode().equals(CommonErrorCode.DIARY_NOT_FOUND.name()) &&
+					serviceException.getErrorMessage().equals(CommonErrorCode.DIARY_NOT_FOUND.getMessage()))
 			.verify();
 	}
 
@@ -175,7 +189,10 @@ void llmRetryAndPendingFalseOnFailure() {
 		Mono<Diary> result = llmApiService.createFeedBackAndSave(new FeedbackReq(userId, diaryId, content));
 
 		StepVerifier.create(result)
-			.expectErrorMessage("LLM 호출에 실패했습니다.")
+			.expectErrorMatches(throwable ->
+				throwable instanceof ServiceException serviceException &&
+					serviceException.getErrorCode().equals(LlmErrorCode.LLM_CALL_FAILED.name()) &&
+					serviceException.getErrorMessage().equals(LlmErrorCode.LLM_CALL_FAILED.getMessage()))
 			.verify();
 
 		verify(openAiChatModel, times(4)).call(anyString());


Reviews:
user: login: pear96
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T05:40:10Z
user: login: kochungcheon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T14:56:00Z

Review comments:
diff_hunk: @@ -12,8 +12,13 @@ public enum CommonErrorCode implements BaseErrorCode {
 	DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 일기입니다."),
 
 	// 유효성
-	WRONG_EMOTION(HttpStatus.BAD_REQUEST, "유효한 감정을 선택해주세요.");
+	WRONG_EMOTION(HttpStatus.BAD_REQUEST, "유효한 감정을 선택해주세요."),
 
+	// llm module
path: diary-common/src/main/java/chungha/diarycommon/exception/CommonErrorCode.java
user: login: pear96
body: diary-api랑 diary-llm 에서 각자만 쓰는 에러 코드를 `MvcErrorCode` 랑 `LlmErrorCode`에 각각 정의해서 쓰는거로 생각하고 있었어!그런데 지금 단계에서 diary-api에만 쓰는 에러 코드가 없어서 일단 Enum 클래스는 존재하게 하려고 SAMPLE이라고 만들어놨던거거든..
그래서 LlmErrorCode에 너는 SAMPLE이 필요 없고 이 밑의 4개 에러를 정의해주면 되겠지?
내 의도는 그런거였는데 따로 설명을 안써놨네. 혹시 이해 됐을까? 아니면 다른 방식 제안하고싶다면 얘기해줘!
created_at: 2025-04-02T05:37:50Z
author_association: COLLABORATOR
diff_hunk: @@ -12,8 +12,13 @@ public enum CommonErrorCode implements BaseErrorCode {
 	DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 일기입니다."),
 
 	// 유효성
-	WRONG_EMOTION(HttpStatus.BAD_REQUEST, "유효한 감정을 선택해주세요.");
+	WRONG_EMOTION(HttpStatus.BAD_REQUEST, "유효한 감정을 선택해주세요."),
 
+	// llm module
path: diary-common/src/main/java/chungha/diarycommon/exception/CommonErrorCode.java
user: login: kochungcheon
body: 친절한 설명 감사합니다 ㅋㅋㅋㅋㅋ 
created_at: 2025-04-02T14:55:48Z
author_association: CONTRIBUTOR

PR comments:
user: login: pear96
created_at: 2025-04-02T11:31:34Z
body: 아 그리고 혹시 작업 끝난 브랜치라면 원격에서도 지워줘유~
author_association: COLLABORATOR
user: login: kochungcheon
created_at: 2025-04-02T14:56:14Z
body: > 혹시 작업 끝난 브랜치라면 원격에서도 지워줘유~

네에~!
author_association: CONTRIBUTOR