From 08816328eeeee90c66efbaf3ea1871144eb14272 Mon Sep 17 00:00:00 2001 From: Kwon-DoHee <152317074+seamooll@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:38:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=8F=99=EA=B8=B0=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RecommendationController.java | 26 +------ .../RecommendationControllerDocs.java | 72 ++++++----------- .../service/RecommendationAsyncWorker.java | 51 ------------ .../service/RecommendationService.java | 78 +++++++------------ .../service/RecommendationTxService.java | 77 ------------------ .../valuedi/global/config/AsyncConfig.java | 11 --- 6 files changed, 58 insertions(+), 257 deletions(-) delete mode 100644 src/main/java/org/umc/valuedi/domain/savings/service/RecommendationAsyncWorker.java diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java index 8f30b05..57e1e2e 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java @@ -3,11 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import org.umc.valuedi.domain.savings.dto.response.SavingsResponseDTO; -import org.umc.valuedi.domain.savings.entity.RecommendationBatch; -import org.umc.valuedi.domain.savings.enums.RecommendationStatus; -import org.umc.valuedi.domain.savings.service.RecommendationAsyncWorker; import org.umc.valuedi.domain.savings.service.RecommendationService; -import org.umc.valuedi.domain.savings.service.RecommendationTxService; import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.apiPayload.code.GeneralSuccessCode; import org.umc.valuedi.global.security.annotation.CurrentMember; @@ -18,28 +14,14 @@ public class RecommendationController implements RecommendationControllerDocs { private final RecommendationService recommendationService; - private final RecommendationTxService recommendationTxService; - private final RecommendationAsyncWorker recommendationAsyncWorker; - // 추천 생성 트리거(비동기) + // 추천 생성 @PostMapping - public ApiResponse recommend( + public ApiResponse recommend( @CurrentMember Long memberId ) { - SavingsResponseDTO.TriggerDecision triggerDecision = recommendationTxService.triggerRecommendation(memberId); - - // 진행 중이면 새로 실행하지 않음 - if (triggerDecision.shouldStartAsync()) { - recommendationAsyncWorker.generateAndSaveAsync(memberId, triggerDecision.batchId()); - } - - return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, - SavingsResponseDTO.TriggerResponse.builder() - .batchId(triggerDecision.batchId()) - .status(triggerDecision.status()) - .message(triggerDecision.message()) - .build() - ); + SavingsResponseDTO.SavingsListResponse result = recommendationService.generateAndSaveRecommendations(memberId); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, result); } // 최신 추천 15개 조회 diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java index 866ad3e..9506fd5 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java @@ -16,68 +16,44 @@ public interface RecommendationControllerDocs { @Operation( - summary = "적금 추천 생성 트리거 API (비동기)", + summary = "적금 추천 생성 API", description = """ - 로그인 사용자(JWT)의 금융 MBTI 결과를 바탕으로, Gemini 추천 생성 작업을 비동기로 트리거 + 로그인 사용자(JWT)의 금융 MBTI 결과를 바탕으로 Gemini가 즉시 추천 상품을 생성하고 저장합니다 - - 최초 요청 시: recommendation_batch를 PENDING으로 생성하고 202(ACCEPTED) 반환 - - 이미 추천 생성 중(PENDING/PROCESSING)인 경우: 기존 batchId를 반환하며 재실행하지 않음 - - 이미 최신 추천이 존재(SUCCESS)하고 MBTI 테스트가 동일한 경우: 재생성하지 않고 안내 메시지 반환 - - 추천 결과는 조회 API(GET)로 확인 + - 응답 속도는 Gemini API 호출을 포함하므로 약 3~7초 정도 소요될 수 있습니다 + - 성공 시 추천된 상품 리스트와 Gemini의 추천 사유(rationale)가 반환됩니다. """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON202", - description = "요청이 접수되었습니다.", + responseCode = "COMMON200", + description = "추천 상품 생성 및 저장 성공", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject( - name = "pending", - summary = "신규 트리거(PENDING)", - value = """ - { - "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", - "result": { - "batchId": 12, - "status": "PENDING", - "message": "추천 상품을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요." - } - } - """ - ), - @ExampleObject( - name = "alreadyProcessing", - summary = "이미 생성 중(PENDING/PROCESSING)", - value = """ - { - "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", - "result": { - "batchId": 12, - "status": "PROCESSING", - "message": "추천을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요." - } - } - """ - ), - @ExampleObject( - name = "alreadySuccess", - summary = "이미 최신 추천 존재(SUCCESS)", + name = "success", + summary = "추천 생성 성공 예시", value = """ { "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", + "code": "COMMON200", + "message": "요청이 성공적으로 처리되었습니다.", "result": { - "batchId": 10, + "totalCount": 15, + "maxPageNo": 1, + "nowPageNo": 1, + "products": [ + { + "korCoNm": "OO은행", + "finPrdtCd": "ABC123", + "finPrdtNm": "OO정기적금", + "rsrvType": "S", + "rsrvTypeNm": "정액적립식" + } + ], "status": "SUCCESS", - "message": "이미 최신 추천이 존재합니다. 조회 API로 확인해 주세요." + "message": "사용자님의 MBTI 성향인 '도전적인 투자자'에 맞춰 높은 금리와 안정성을 동시에 잡을 수 있는 상품들을 추천해 드립니다..." } } """ @@ -129,7 +105,7 @@ public interface RecommendationControllerDocs { ) } ) - ApiResponse recommend( + ApiResponse recommend( @CurrentMember Long memberId ); diff --git a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationAsyncWorker.java b/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationAsyncWorker.java deleted file mode 100644 index b5f9caa..0000000 --- a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationAsyncWorker.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.umc.valuedi.domain.savings.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.umc.valuedi.global.external.genai.exception.GeminiException; -import org.umc.valuedi.global.external.genai.exception.code.GeminiErrorCode; - -@Slf4j -@Service -@RequiredArgsConstructor -public class RecommendationAsyncWorker { - - private final RecommendationService recommendationService; - private final RecommendationTxService recommendationTxService; - - @Async("recommendationExecutor") - public void generateAndSaveAsync(Long memberId, Long batchId) { - try { - recommendationTxService.markProcessing(batchId); - recommendationService.generateAndSaveRecommendations(memberId); - recommendationTxService.markSuccess(batchId); - } catch (GeminiException ge) { - log.error("[RecommendAsync] failed. memberId={}, batchId={}", memberId, batchId, ge); - - String msg; - GeminiErrorCode code = ge.getErrorCode(); - - if (code == GeminiErrorCode.GEMINI_QUOTA_EXCEEDED) { - msg = "Gemini 사용량/요청 제한이 초과되었습니다. 잠시 후(또는 내일) 다시 시도해 주세요."; - } else if (code == GeminiErrorCode.GEMINI_TIMEOUT) { - msg = "추천 생성 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요."; - } else { - msg = "추천 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."; - } - - recommendationTxService.markFailed(batchId, msg); - } catch (Exception e) { - log.error("[RecommendAsync] failed. memberId={}, batchId={}", memberId, batchId, e); - recommendationTxService.markFailed(batchId, safeMsg(e)); - } - } - - private String safeMsg(Exception e) { - String msg = e.getMessage(); - if (msg == null || msg.isBlank()) return e.getClass().getSimpleName(); - return msg.length() > 450 ? msg.substring(0, 450) : msg; - } -} - diff --git a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationService.java b/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationService.java index 8305b22..9867b3d 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationService.java +++ b/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationService.java @@ -16,7 +16,6 @@ import org.umc.valuedi.domain.savings.converter.SavingsConverter; import org.umc.valuedi.domain.savings.dto.response.SavingsResponseDTO; import org.umc.valuedi.domain.savings.entity.Recommendation; -import org.umc.valuedi.domain.savings.entity.RecommendationBatch; import org.umc.valuedi.domain.savings.entity.Savings; import org.umc.valuedi.domain.savings.entity.SavingsOption; import org.umc.valuedi.domain.savings.enums.RecommendationStatus; @@ -55,38 +54,24 @@ public class RecommendationService { private final RecommendationTxService recommendationTxService; @Transactional - public void generateAndSaveRecommendations(Long memberId) { + public SavingsResponseDTO.SavingsListResponse generateAndSaveRecommendations(Long memberId) { // 금융 mbti 최신 결과 조회 MemberMbtiTest memberMbtiTest = memberMbtiTestRepository.findCurrentActiveTest(memberId) .orElseThrow(() -> new MbtiException(MbtiErrorCode.TYPE_INFO_NOT_FOUND)); - Long memberMbtiTestId = memberMbtiTest.getId(); - - // 멱등성(중복 저장 방지) - boolean exists = recommendationRepository.existsByMemberIdAndMemberMbtiTestId(memberId, memberMbtiTestId); - if (exists) { - log.info("[RecommendAsync] already exists. memberId={}, mbtiTestId={}", memberId, memberMbtiTestId); - return; - } - // 추천 상품 후보 조회 Pageable candidatePage = PageRequest.of(0, CANDIDATE_LIMIT); List candidates = savingsOptionRepository.findCandidates(candidatePage); - if (candidates.isEmpty()) { - log.warn("[RecommendAsync] no candidates. memberId={}", memberId); - return; - } - // 제미나이 프롬프트 생성 MbtiType mbtiType = memberMbtiTest.getResultType(); FinanceMbtiTypeInfoDto financeMbtiTypeInfo = financeMbtiProvider.get(mbtiType); String prompt = buildPrompt(mbtiType, financeMbtiTypeInfo, candidates, RECOMMEND_COUNT); // 제미나이 호출 - log.info("[RecommendAsync] Gemini request. memberId={}, promptChars={}", memberId, prompt.length()); + log.info("[Recommend] Gemini request. memberId={}, promptChars={}", memberId, prompt.length()); String raw = geminiClient.generateText(prompt); - log.info("[RecommendAsync] Gemini response. memberId={}, rawChars={}", memberId, raw == null ? 0 : raw.length()); + log.info("[Recommend] Gemini response. memberId={}, rawChars={}", memberId, raw == null ? 0 : raw.length()); // JSON 파싱 GeminiSavingsResponseDTO.Result parsed = parseGeminiJson(raw); @@ -99,13 +84,30 @@ public void generateAndSaveRecommendations(Long memberId) { .toList(); if (items.isEmpty()) { - log.warn("[RecommendAsync] parsed items empty. memberId={}", memberId); - return; + return SavingsResponseDTO.SavingsListResponse.builder() + .products(List.of()) + .status(RecommendationStatus.SUCCESS) + .message("조건에 맞는 추천 상품을 찾지 못했습니다.") + .build(); } + SavingsResponseDTO.RecommendResponse savedResult = recommendationTxService.saveRecommendations(memberId, memberMbtiTest, parsed, items); - // 추천 결과 저장 - recommendationTxService.saveRecommendations(memberId, memberMbtiTest, parsed, items); - log.info("[RecommendAsync] saved. memberId={}, mbtiTestId={}", memberId, memberMbtiTestId); + return SavingsResponseDTO.SavingsListResponse.builder() + .totalCount(savedResult.products().size()) + .nowPageNo(1) + .maxPageNo(1) + .products(savedResult.products().stream() + .map(p -> new SavingsResponseDTO.SavingsListResponse.RecommendedSavingProduct( + p.korCoNm(), + p.finPrdtCd(), + p.finPrdtNm(), + p.rsrvType(), + p.rsrvTypeNm() + )) + .toList()) + .status(RecommendationStatus.SUCCESS) + .message(parsed.rationale()) + .build(); } // 추천 상품 15개 조회 @@ -155,7 +157,7 @@ public SavingsResponseDTO.SavingsListResponse getRecommendation(Long memberId, S } // 추천 자체가 없는 경우 - return getRecommendationState(memberId); + return emptyResponse("아직 추천받은 내역이 없습니다. 상품 추천을 먼저 진행해 주세요."); } return SavingsResponseDTO.SavingsListResponse.builder() @@ -180,7 +182,7 @@ public SavingsResponseDTO.SavingsListResponse getRecommendationTop3(Long memberI if (recs.isEmpty()) { // 추천 자체가 없는 경우 - return getRecommendationState(memberId); + return emptyResponse("아직 추천받은 내역이 없습니다."); } List products = recs.stream() @@ -207,34 +209,14 @@ public SavingsResponseDTO.SavingsListResponse getRecommendationTop3(Long memberI .build(); } - private SavingsResponseDTO.SavingsListResponse getRecommendationState(Long memberId) { - RecommendationStatus status = RecommendationStatus.PENDING; - String message = "추천 상품을 추천 중입니다. 잠시 후 다시 조회해 주세요."; - - Optional latestBatch = recommendationTxService.findLatestBatch(memberId); - - if (latestBatch.isPresent()) { - RecommendationStatus batchStatus = latestBatch.get().getStatus(); - - if (batchStatus == RecommendationStatus.FAILED) { - status = RecommendationStatus.FAILED; - message = "추천 생성에 실패했습니다. 다시 시도해 주세요."; - } else if (batchStatus == RecommendationStatus.SUCCESS) { - status = RecommendationStatus.SUCCESS; - message = "조건에 맞는 추천 결과가 없습니다."; - } else { - // PENDING/PROCESSING이면 기본값 유지 - } - } else { - // 배치 없음이면 기본값 유지 - } - + // 추천 결과가 없을 때 사용할 공통 응답 + private SavingsResponseDTO.SavingsListResponse emptyResponse(String message) { return SavingsResponseDTO.SavingsListResponse.builder() .totalCount(0) .nowPageNo(1) .maxPageNo(1) .products(List.of()) - .status(status) + .status(RecommendationStatus.SUCCESS) .message(message) .build(); } diff --git a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationTxService.java b/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationTxService.java index 2b8f4e7..b7b60c5 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationTxService.java +++ b/src/main/java/org/umc/valuedi/domain/savings/service/RecommendationTxService.java @@ -2,13 +2,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.mbti.entity.MemberMbtiTest; -import org.umc.valuedi.domain.mbti.exception.MbtiException; -import org.umc.valuedi.domain.mbti.exception.code.MbtiErrorCode; -import org.umc.valuedi.domain.mbti.repository.MemberMbtiTestRepository; import org.umc.valuedi.domain.member.entity.Member; import org.umc.valuedi.domain.member.exception.MemberException; import org.umc.valuedi.domain.member.exception.code.MemberErrorCode; @@ -16,8 +12,6 @@ import org.umc.valuedi.domain.savings.dto.response.SavingsResponseDTO; import org.umc.valuedi.domain.savings.entity.*; import org.umc.valuedi.domain.savings.enums.ReasonCode; -import org.umc.valuedi.domain.savings.enums.RecommendationStatus; -import org.umc.valuedi.domain.savings.repository.RecommendationBatchRepository; import org.umc.valuedi.domain.savings.repository.RecommendationRepository; import org.umc.valuedi.domain.savings.repository.SavingsOptionRepository; import org.umc.valuedi.global.external.genai.dto.response.GeminiSavingsResponseDTO; @@ -33,81 +27,10 @@ @RequiredArgsConstructor public class RecommendationTxService { - private final RecommendationBatchRepository batchRepository; - private final MemberMbtiTestRepository memberMbtiTestRepository; private final MemberRepository memberRepository; private final SavingsOptionRepository savingsOptionRepository; private final RecommendationRepository recommendationRepository; - private static final int RECOMMEND_COUNT = 15; - - // 없으면 PENDING 배치 만들거나, 진행 중이면 기존 배치 반환 - @Transactional - public SavingsResponseDTO.TriggerDecision triggerRecommendation(Long memberId) { - // 금융 mbti 최신 결과 조회 - Long mbtiTestId = memberMbtiTestRepository.findCurrentActiveTest(memberId) - .orElseThrow(() -> new MbtiException(MbtiErrorCode.TYPE_INFO_NOT_FOUND)) - .getId(); - - Optional latest = batchRepository.findTopByMemberIdAndMemberMbtiTestIdOrderByIdDesc(memberId, mbtiTestId); - - // PENDING/PROCESSING이면 재실행 금지 - if (latest.isPresent() && latest.get().isPendingOrProcessing()) { - RecommendationBatch b = latest.get(); - return SavingsResponseDTO.TriggerDecision.builder() - .batchId(b.getId()) - .status(b.getStatus()) // PENDING/PROCESSING - .message("추천을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요.") - .shouldStartAsync(false) - .build(); - } - - // SUCCESS면 재생성 금지 - if (latest.isPresent() && latest.get().getStatus() == RecommendationStatus.SUCCESS) { - RecommendationBatch b = latest.get(); - return SavingsResponseDTO.TriggerDecision.builder() - .batchId(b.getId()) - .status(b.getStatus()) - .message("이미 최신 추천이 존재합니다. 조회 API로 확인해 주세요.") - .shouldStartAsync(false) - .build(); - } - - // 추천 상품 생성 - RecommendationBatch created = batchRepository.save(RecommendationBatch.pending(memberId, mbtiTestId)); - return SavingsResponseDTO.TriggerDecision.builder() - .batchId(created.getId()) - .status(created.getStatus()) // PENDING - .message("추천 상품을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요.") - .shouldStartAsync(true) - .build(); - } - - // 상태 변경 - @Transactional - public void markProcessing(Long batchId) { - RecommendationBatch b = batchRepository.findById(batchId).orElseThrow(); - b.markProcessing(); - } - - @Transactional - public void markSuccess(Long batchId) { - RecommendationBatch b = batchRepository.findById(batchId).orElseThrow(); - b.markSuccess(); - } - - @Transactional - public void markFailed(Long batchId, String errorMessage) { - RecommendationBatch b = batchRepository.findById(batchId).orElseThrow(); - b.markFailed(errorMessage); - } - - // 상태 조회용 - @Transactional(readOnly = true) - public Optional findLatestBatch(Long memberId) { - return batchRepository.findTopByMemberIdOrderByIdDesc(memberId); - } - // 추천 상품 저장 @Transactional public SavingsResponseDTO.RecommendResponse saveRecommendations( diff --git a/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java b/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java index 56632d6..0fb4480 100644 --- a/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java +++ b/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java @@ -33,15 +33,4 @@ public Executor assetFetchExecutor() { executor.initialize(); return executor; } - - @Bean(name = "recommendationExecutor") - public Executor recommendationExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(4); - executor.setMaxPoolSize(8); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("RecommendAsync-"); - executor.initialize(); - return executor; - } } From 4103b956c435706c74913e4bd0a3051847f933df Mon Sep 17 00:00:00 2001 From: Kwon-DoHee <152317074+seamooll@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:09:18 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20MBTI=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B6=94=EC=B2=9C=20=EC=83=81=ED=92=88=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mbti/service/FinanceMbtiService.java | 16 ++- .../RecommendationControllerDocs.java | 101 +++--------------- 2 files changed, 27 insertions(+), 90 deletions(-) diff --git a/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java b/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java index b675c65..50bc1c1 100644 --- a/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java +++ b/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java @@ -1,6 +1,7 @@ package org.umc.valuedi.domain.mbti.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.mbti.converter.FinanceMbtiTestConverter; @@ -12,11 +13,13 @@ import org.umc.valuedi.domain.mbti.validator.FinanceMbtiTestValidator; import org.umc.valuedi.domain.member.entity.Member; import org.umc.valuedi.domain.member.repository.MemberRepository; +import org.umc.valuedi.domain.savings.service.RecommendationService; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -28,6 +31,7 @@ public class FinanceMbtiService { private final FinanceMbtiScoringService scoringService; private final FinanceMbtiTestValidator financeMbtiTestValidator; private final FinanceMbtiTestConverter financeMbtiTestConverter; + private final RecommendationService recommendationService; public MemberMbtiTest submitTest(Long memberId, FinanceMbtiTestRequestDto req) { @@ -46,9 +50,17 @@ public MemberMbtiTest submitTest(Long memberId, FinanceMbtiTestRequestDto req) { )); FinanceMbtiScoringService.ScoreResult score = scoringService.score(activeQuestions, answersByQuestionId); - MemberMbtiTest test = financeMbtiTestConverter.toEntity(member, req, score, activeQuestionMap); - return memberMbtiTestRepository.save(test); + MemberMbtiTest savedTest = memberMbtiTestRepository.save(test); + + try { + recommendationService.generateAndSaveRecommendations(memberId); + log.info("[Recommend] MBTI 검사 후 자동 추천 생성 성공. memberId={}", memberId); + } catch (Exception e) { + // 제미나이 호출이 실패해도 MBTI 저장은 유지되도록 예외를 삼키고 로그만 남김 + log.error("[Recommend] MBTI 저장에는 성공했으나, 자동 추천 생성 중 오류 발생. memberId={}: {}", memberId, e.getMessage()); + } + return savedTest; } } diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java index 9506fd5..4473a25 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java @@ -16,12 +16,13 @@ public interface RecommendationControllerDocs { @Operation( - summary = "적금 추천 생성 API", + summary = "[개발/수동 갱신용] 적금 추천 생성 API", description = """ - 로그인 사용자(JWT)의 금융 MBTI 결과를 바탕으로 Gemini가 즉시 추천 상품을 생성하고 저장합니다 + **주의: 일반적인 서비스 흐름에서는 MBTI 검사 완료 시 서버 내부에서 자동 호출됩니다.** + - 로그인 사용자(JWT)의 현재 MBTI를 바탕으로 Gemini 추천을 생성하고 DB를 갱신합니다. - 응답 속도는 Gemini API 호출을 포함하므로 약 3~7초 정도 소요될 수 있습니다 - - 성공 시 추천된 상품 리스트와 Gemini의 추천 사유(rationale)가 반환됩니다. + - 개발 테스트 중 추천 데이터를 수동으로 만들 때 사용하세요. """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -63,45 +64,13 @@ public interface RecommendationControllerDocs { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "MBTI404_3", - description = "해당 MBTI 유형 정보가 존재하지 않습니다.", - content = @Content( - mediaType = "application/json", - examples = { - @ExampleObject( - name = "typeInfoNotFound", - summary = "MBTI 타입 정보 미존재", - value = """ - { - "isSuccess": false, - "code": "MBTI404_3", - "message": "해당 MBTI 유형 정보가 존재하지 않습니다.", - "result": null - } - """ - ) - } - ) + description = "MBTI 검사 내역이 없어 추천을 생성할 수 없습니다.", + content = @Content(mediaType = "application/json") ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "GEMINI502_2", - description = "Gemini API 호출에 실패했습니다.", - content = @Content( - mediaType = "application/json", - examples = { - @ExampleObject( - name = "geminiFail", - summary = "Gemini 호출 실패", - value = """ - { - "isSuccess": false, - "code": "GEMINI502_2", - "message": "Gemini API 호출에 실패했습니다.", - "result": null - } - """ - ) - } - ) + description = "Gemini API 호출 실패 (타임아웃 등).", + content = @Content(mediaType = "application/json") ) } ) @@ -115,8 +84,7 @@ ApiResponse recommend( 로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로 DB에 저장된 최신 추천 15개를 조회 (Gemini 호출 X) - - 추천이 아직 생성되지 않았으면 status=PENDING, message에 안내 문구 포함 - - 추천 생성에 실패한 경우 status=FAILED, message에 실패 안내 포함 + - 추천 내역이 없는 경우(검사 미실행 등) status: SUCCESS와 함께 비어있는 리스트가 반환됩니다. """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -148,14 +116,14 @@ ApiResponse recommend( } ], "status": "SUCCESS", - "message": null + "message": "사용자님의 성향에 맞춘 AI 추천 리스트입니다." } } """ ), @ExampleObject( - name = "pending", - summary = "추천 생성 중(PENDING)", + name = "noHistory", + summary = "추천 내역 없음", value = """ { "isSuccess": true, @@ -163,30 +131,9 @@ ApiResponse recommend( "message": "요청이 성공적으로 처리되었습니다.", "result": { "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, - "products": [], - "status": "PENDING", - "message": "추천 상품을 추천 중입니다. 잠시 후 다시 조회해 주세요." - } - } - """ - ), - @ExampleObject( - name = "noResultForType", - summary = "해당 적립유형 결과 없음(필터로 인해 비어있음)", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, "products": [], "status": "SUCCESS", - "message": "조건에 맞는 추천 결과가 없습니다." + "message": "아직 추천받은 내역이 없습니다. MBTI 검사를 먼저 진행해 주세요." } } """ @@ -231,9 +178,6 @@ ApiResponse latest15( description = """ 로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로 DB에 저장된 최신 추천 Top3를 조회 (Gemini 호출 X) - - - 추천이 아직 생성되지 않았으면 status=PENDING, message에 안내 문구 포함 - - 추천 생성에 실패한 경우 status=FAILED, message에 실패 안내 포함 """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -269,25 +213,6 @@ ApiResponse latest15( } } """ - ), - @ExampleObject( - name = "pending", - summary = "추천 생성 중(PENDING)", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, - "products": [], - "status": "PENDING", - "message": "추천 상품을 추천 중입니다. 잠시 후 다시 조회해 주세요." - } - } - """ ) } ) From 9f8d8898cc24f3cd718a8723ec9a8c3cf7017207 Mon Sep 17 00:00:00 2001 From: Kwon-DoHee <152317074+seamooll@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:10:28 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=A0=81=EA=B8=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationControllerDocs.java | 84 ++++++------- .../savings/converter/SavingsConverter.java | 28 ++--- .../dto/response/SavingsResponseDTO.java | 50 +++----- .../exception/code/SavingsErrorCode.java | 5 +- .../service/RecommendationService.java | 111 ++++-------------- 5 files changed, 85 insertions(+), 193 deletions(-) diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java index 4473a25..79a1caf 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java @@ -22,7 +22,6 @@ public interface RecommendationControllerDocs { - 로그인 사용자(JWT)의 현재 MBTI를 바탕으로 Gemini 추천을 생성하고 DB를 갱신합니다. - 응답 속도는 Gemini API 호출을 포함하므로 약 3~7초 정도 소요될 수 있습니다 - - 개발 테스트 중 추천 데이터를 수동으로 만들 때 사용하세요. """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -52,9 +51,7 @@ public interface RecommendationControllerDocs { "rsrvType": "S", "rsrvTypeNm": "정액적립식" } - ], - "status": "SUCCESS", - "message": "사용자님의 MBTI 성향인 '도전적인 투자자'에 맞춰 높은 금리와 안정성을 동시에 잡을 수 있는 상품들을 추천해 드립니다..." + ] } } """ @@ -63,14 +60,22 @@ public interface RecommendationControllerDocs { ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "MBTI404_3", - description = "MBTI 검사 내역이 없어 추천을 생성할 수 없습니다.", - content = @Content(mediaType = "application/json") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "GEMINI502_2", - description = "Gemini API 호출 실패 (타임아웃 등).", - content = @Content(mediaType = "application/json") + responseCode = "SAVINGS500_1", + description = "AI 추천 생성 실패 (Gemini 호출 실패 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "fail", + value = """ + { + "isSuccess": false, + "code": "SAVINGS500_1", + "message": "AI 추천 생성에 실패했습니다. 다시 시도해 주세요.", + "result": null + } + """ + ) + ) ) } ) @@ -83,8 +88,6 @@ ApiResponse recommend( description = """ 로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로 DB에 저장된 최신 추천 15개를 조회 (Gemini 호출 X) - - - 추천 내역이 없는 경우(검사 미실행 등) status: SUCCESS와 함께 비어있는 리스트가 반환됩니다. """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -114,47 +117,19 @@ ApiResponse recommend( "rsrvType": "S", "rsrvTypeNm": "정액적립식" } - ], - "status": "SUCCESS", - "message": "사용자님의 성향에 맞춘 AI 추천 리스트입니다." + ] } } """ ), @ExampleObject( name = "noHistory", - summary = "추천 내역 없음", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "products": [], - "status": "SUCCESS", - "message": "아직 추천받은 내역이 없습니다. MBTI 검사를 먼저 진행해 주세요." - } - } - """ - ) - } - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "MBTI404_3", - description = "해당 MBTI 유형 정보가 존재하지 않습니다.", - content = @Content( - mediaType = "application/json", - examples = { - @ExampleObject( - name = "typeInfoNotFound", - summary = "MBTI 타입 정보 미존재", + summary = "추천 내역 없음 (MBTI 검사 미실행 등)", value = """ { "isSuccess": false, - "code": "MBTI404_3", - "message": "해당 MBTI 유형 정보가 존재하지 않습니다.", + "code": "SAVINGS404_2", + "message": "아직 추천받은 내역이 없습니다. 먼저 상품 추천을 진행해 주세요.", "result": null } """ @@ -207,12 +182,22 @@ ApiResponse latest15( "rsrvType": "S", "rsrvTypeNm": "정액적립식" } - ], - "status": "SUCCESS", - "message": null + ] } } """ + ), + @ExampleObject( + name = "noHistory", + summary = "추천 내역 없음 (SAVINGS404_2)", + value = """ + { + "isSuccess": false, + "code": "SAVINGS404_2", + "message": "아직 추천받은 내역이 없습니다. 먼저 상품 추천을 진행해 주세요.", + "result": null + } + """ ) } ) @@ -283,7 +268,6 @@ ApiResponse latestTop3( examples = { @ExampleObject( name = "notFound", - summary = "상품 미존재 예시", value = """ { "isSuccess": false, diff --git a/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java b/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java index cc26df7..0717dc0 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java +++ b/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java @@ -89,8 +89,8 @@ public static SavingsResponseDTO.SavingsListResponse toSavingsListResponseDTO(Li return SavingsResponseDTO.SavingsListResponse.builder() .totalCount(totalCount) - .maxPageNo(maxPageNo) .nowPageNo(nowPageNo) + .hasNext(nowPageNo < maxPageNo) .products(products) .build(); } @@ -109,22 +109,18 @@ public static SavingsResponseDTO.SavingsDetailResponse toSavingsDetailResponseDT )) .toList(); - SavingsResponseDTO.SavingsDetailResponse.SavingProductDetail product = new SavingsResponseDTO.SavingsDetailResponse.SavingProductDetail( - savings.getKorCoNm(), - savings.getFinPrdtCd(), - savings.getFinPrdtNm(), - savings.getJoinWay(), - savings.getMtrtInt(), - savings.getSpclCnd(), - savings.getJoinDeny(), - savings.getJoinMember(), - savings.getEtcNote(), - savings.getMaxLimit() == null ? null : String.valueOf(savings.getMaxLimit()), - options - ); - return SavingsResponseDTO.SavingsDetailResponse.builder() - .product(product) + .korCoNm(savings.getKorCoNm()) + .finPrdtCd(savings.getFinPrdtCd()) + .finPrdtNm(savings.getFinPrdtNm()) + .joinWay(savings.getJoinWay()) + .mtrtInt(savings.getMtrtInt()) + .spclCnd(savings.getSpclCnd()) + .joinDeny(savings.getJoinDeny()) + .joinMember(savings.getJoinMember()) + .etcNote(savings.getEtcNote()) + .maxLimit(savings.getMaxLimit() == null ? null : String.valueOf(savings.getMaxLimit())) + .options(options) .build(); } diff --git a/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java b/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java index 0f753be..f7cf137 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java +++ b/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java @@ -11,11 +11,9 @@ public class SavingsResponseDTO { @Builder public record SavingsListResponse( int totalCount, // 총 상품건수 - int maxPageNo, // 총 페이지 건수 - int nowPageNo, // 현재 조회 페이지 번호 - List products, // 상품 목록 - RecommendationStatus status, // 상품 추천 상태 (PENDING | SUCCESS | FAILED) - String message // 상품 추천 상태 메시지 + int nowPageNo, // 현재 페이지 + boolean hasNext, // 다음 페이지 존재 여부 + List products // 상품 목록 ) { // 상품 목록 조회 public record RecommendedSavingProduct( @@ -29,23 +27,18 @@ public record RecommendedSavingProduct( @Builder public record SavingsDetailResponse( - SavingProductDetail product // 상품 + String korCoNm, + String finPrdtCd, + String finPrdtNm, + String joinWay, + String mtrtInt, + String spclCnd, + String joinDeny, + String joinMember, + String etcNote, + String maxLimit, + List