diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java index 1a353e9..fe02b3b 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java @@ -14,9 +14,6 @@ import com.back.domain.scenario.repository.ScenarioRepository; import com.back.domain.scenario.repository.SceneCompareRepository; import com.back.domain.scenario.repository.SceneTypeRepository; -import com.back.global.ai.dto.result.BaseScenarioResult; -import com.back.global.ai.dto.result.DecisionScenarioResult; -import com.back.global.ai.service.AiService; import com.back.global.common.PageResponse; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; @@ -29,7 +26,6 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,9 +56,6 @@ public class ScenarioService { // Object Mapper 주입 private final ObjectMapper objectMapper; - // AI Service 주입 - private final AiService aiService; - // Scenario Transaction Service 주입 private final ScenarioTransactionService scenarioTransactionService; @@ -92,8 +85,8 @@ public ScenarioStatusResponse createScenario(Long userId, validationResult.decisionLine ); - // 3. 비동기 AI 처리 트리거 (트랜잭션 외부) - processScenarioGenerationAsync(scenarioId); + // 3. 비동기 AI 처리 트리거 (트랜잭션 외부, 별도 Bean에서 호출) + scenarioTransactionService.processScenarioGenerationAsync(scenarioId); return new ScenarioStatusResponse( scenarioId, @@ -278,8 +271,8 @@ private ScenarioStatusResponse handleFailedScenarioRetry(Scenario failedScenario // 1. 상태 업데이트 (트랜잭션) Long scenarioId = retryScenarioInTransaction(failedScenario.getId()); - // 2. 비동기 AI 처리 트리거 (트랜잭션 외부) - processScenarioGenerationAsync(scenarioId); + // 2. 비동기 AI 처리 트리거 (트랜잭션 외부, 별도 Bean에서 호출) + scenarioTransactionService.processScenarioGenerationAsync(scenarioId); return new ScenarioStatusResponse( scenarioId, @@ -305,91 +298,6 @@ protected Long retryScenarioInTransaction(Long scenarioId) { return scenario.getId(); } - // 비동기 방식으로 AI 시나리오 생성 - @Async("aiTaskExecutor") - public void processScenarioGenerationAsync(Long scenarioId) { - try { - // 1. 상태를 PROCESSING으로 업데이트 (별도 트랜잭션) - scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); - - // 2. AI 생성에 필요한 모든 데이터를 트랜잭션 내에서 미리 로드 - Scenario scenarioWithData = scenarioTransactionService.prepareScenarioData(scenarioId); - - // 3. AI 시나리오 생성 (트랜잭션 외부에서 실행) - AiScenarioGenerationResult result = executeAiGeneration(scenarioWithData); - - // 4. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션) - scenarioTransactionService.saveAiResult(scenarioId, result); - scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null); - - log.info("Scenario generation completed successfully for ID: {}", scenarioId); - - } catch (Exception e) { - // 5. 실패 상태 업데이트 (별도 트랜잭션) - scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.FAILED, - "시나리오 생성 실패: " + e.getMessage()); - log.error("Scenario generation failed for ID: {}, error: {}", - scenarioId, e.getMessage(), e); - } - } - - // AI 호출 전용 메서드 (트랜잭션 없음) - private AiScenarioGenerationResult executeAiGeneration(Scenario scenario) { - // AI 호출 로직 (미리 로드된 데이터 사용) - DecisionLine decisionLine = scenario.getDecisionLine(); - BaseLine baseLine = decisionLine.getBaseLine(); - - // 베이스 시나리오 확보 - Scenario baseScenario = ensureBaseScenarioExists(baseLine); - - // AI 호출 (트랜잭션 외부) with 타임아웃 (60초) - DecisionScenarioResult aiResult = aiService - .generateDecisionScenario(decisionLine, baseScenario) - .orTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .exceptionally(ex -> { - log.error("Decision scenario generation timeout or error for scenario ID: {}", scenario.getId(), ex); - throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "시나리오 생성 시간 초과 (60초)"); - }) - .join(); - - return new AiScenarioGenerationResult(aiResult); - } - - // 베이스 시나리오 확보 (없으면 생성) - private Scenario ensureBaseScenarioExists(BaseLine baseLine) { - return scenarioRepository.findByBaseLineIdAndDecisionLineIsNull(baseLine.getId()) - .orElseGet(() -> createBaseScenario(baseLine)); - } - - // 베이스 시나리오 생성 - private Scenario createBaseScenario(BaseLine baseLine) { - log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId()); - - // 1. AI 호출 with 타임아웃 (180초 - 테스트용) - BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine) - .orTimeout(180, java.util.concurrent.TimeUnit.SECONDS) - .exceptionally(ex -> { - log.error("Base scenario generation timeout or error for BaseLine ID: {}", baseLine.getId(), ex); - throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (180초)"); - }) - .join(); - - // 2. 베이스 시나리오 엔티티 생성 - Scenario baseScenario = Scenario.builder() - .user(baseLine.getUser()) - .decisionLine(null) // 베이스 시나리오는 DecisionLine 없음 - .baseLine(baseLine) // 베이스 시나리오는 BaseLine 연결 - .status(ScenarioStatus.COMPLETED) // 베이스는 바로 완료 - .build(); - - Scenario savedScenario = scenarioRepository.save(baseScenario); - - // 3. AI 결과 적용 - scenarioTransactionService.applyBaseScenarioResult(savedScenario, aiResult); - - return savedScenario; - } - // 시나리오 생성 상태 조회 @Transactional(readOnly = true) public ScenarioStatusResponse getScenarioStatus(Long scenarioId, Long userId) { diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java index c44aac3..1da1aca 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +47,8 @@ public class ScenarioTransactionService { private final AiService aiService; private final ObjectMapper objectMapper; private final com.back.global.ai.config.ImageAiConfig imageAiConfig; + private final com.back.global.ai.config.DecisionScenarioAiProperties decisionScenarioAiProperties; + private final com.back.global.ai.config.BaseScenarioAiProperties baseScenarioAiProperties; // 상태 업데이트 전용 트랜잭션 메서드 @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -268,4 +271,101 @@ public Scenario prepareScenarioData(Long scenarioId) { return scenario; } + + /** + * 비동기 방식으로 AI 시나리오 생성. + * ScenarioService의 self-invocation 문제를 해결하기 위해 별도 Bean으로 분리. + */ + @org.springframework.scheduling.annotation.Async("aiTaskExecutor") + public void processScenarioGenerationAsync(Long scenarioId) { + try { + // 1. 상태를 PROCESSING으로 업데이트 (별도 트랜잭션) + updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); + + // 2. AI 생성에 필요한 모든 데이터를 트랜잭션 내에서 미리 로드 + Scenario scenarioWithData = prepareScenarioData(scenarioId); + + // 3. AI 시나리오 생성 (트랜잭션 외부에서 실행) + AiScenarioGenerationResult result = executeAiGeneration(scenarioWithData); + + // 4. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션) + saveAiResult(scenarioId, result); + updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null); + + log.info("Scenario generation completed successfully for ID: {}", scenarioId); + + } catch (Exception e) { + // 5. 실패 상태 업데이트 (별도 트랜잭션) + updateScenarioStatus(scenarioId, ScenarioStatus.FAILED, + "시나리오 생성 실패: " + e.getMessage()); + log.error("Scenario generation failed for ID: {}, error: {}", + scenarioId, e.getMessage(), e); + } + } + + /** + * AI 호출 전용 메서드 (트랜잭션 없음). + * 미리 로드된 데이터를 사용하여 AI 시나리오를 생성한다. + */ + private AiScenarioGenerationResult executeAiGeneration(Scenario scenario) { + // AI 호출 로직 (미리 로드된 데이터 사용) + com.back.domain.node.entity.DecisionLine decisionLine = scenario.getDecisionLine(); + BaseLine baseLine = decisionLine.getBaseLine(); + + // 베이스 시나리오 확보 + Scenario baseScenario = ensureBaseScenarioExists(baseLine); + + // AI 호출 (트랜잭션 외부) with 타임아웃 + DecisionScenarioResult aiResult = aiService + .generateDecisionScenario(decisionLine, baseScenario) + .orTimeout(decisionScenarioAiProperties.getTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) + .exceptionally(ex -> { + log.error("Decision scenario generation timeout or error for scenario ID: {}", scenario.getId(), ex); + throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, + "시나리오 생성 시간 초과 (" + decisionScenarioAiProperties.getTimeoutSeconds() + "초)"); + }) + .join(); + + return new AiScenarioGenerationResult(aiResult); + } + + /** + * 베이스 시나리오 확보 (없으면 생성). + */ + private Scenario ensureBaseScenarioExists(BaseLine baseLine) { + return scenarioRepository.findByBaseLineIdAndDecisionLineIsNull(baseLine.getId()) + .orElseGet(() -> createBaseScenario(baseLine)); + } + + /** + * 베이스 시나리오 생성. + */ + private Scenario createBaseScenario(BaseLine baseLine) { + log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId()); + + // 1. AI 호출 with 타임아웃 + BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine) + .orTimeout(baseScenarioAiProperties.getTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) + .exceptionally(ex -> { + log.error("Base scenario generation timeout or error for BaseLine ID: {}", baseLine.getId(), ex); + throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, + "베이스 시나리오 생성 시간 초과 (" + baseScenarioAiProperties.getTimeoutSeconds() + "초)"); + }) + .join(); + + // 2. 베이스 시나리오 엔티티 생성 + Scenario baseScenario = Scenario.builder() + .user(baseLine.getUser()) + .decisionLine(null) // 베이스 시나리오는 DecisionLine 없음 + .baseLine(baseLine) // 베이스 시나리오는 BaseLine 연결 + .status(ScenarioStatus.COMPLETED) // 베이스는 바로 완료 + .build(); + + Scenario savedScenario = scenarioRepository.save(baseScenario); + + // 3. AI 결과 적용 + applyBaseScenarioResult(savedScenario, aiResult); + + return savedScenario; + } } diff --git a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java index 09549c8..0970d2d 100644 --- a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java +++ b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java @@ -44,10 +44,9 @@ class ScenarioServiceTest { @Mock private ScenarioRepository scenarioRepository; @Mock private DecisionLineRepository decisionLineRepository; @Mock private ObjectMapper objectMapper; - @Mock private AiService aiService; @Mock private ScenarioTransactionService scenarioTransactionService; - @Spy @InjectMocks private ScenarioService scenarioService; + @InjectMocks private ScenarioService scenarioService; @Nested @DisplayName("시나리오 생성") @@ -89,7 +88,7 @@ class CreateScenarioTests { .willReturn(savedScenario); // 비동기 메서드 호출을 무효화 (동기 로직만 테스트) - doNothing().when(scenarioService).processScenarioGenerationAsync(anyLong()); + doNothing().when(scenarioTransactionService).processScenarioGenerationAsync(anyLong()); // When ScenarioStatusResponse result = scenarioService.createScenario(userId, request, null); @@ -105,8 +104,8 @@ class CreateScenarioTests { verify(scenarioRepository).findByDecisionLineId(decisionLineId); verify(scenarioRepository).save(any(Scenario.class)); - // 비동기 메서드가 호출되었는지 확인 (하지만 실행은 되지 않음) - verify(scenarioService).processScenarioGenerationAsync(1001L); + // 비동기 메서드가 호출되었는지 확인 (ScenarioTransactionService를 통해) + verify(scenarioTransactionService).processScenarioGenerationAsync(1001L); } @Test @@ -196,92 +195,6 @@ class CreateScenarioTests { } } - @Nested - @DisplayName("비동기 시나리오 생성 처리") - class ProcessScenarioGenerationAsyncTests { - - @Test - @DisplayName("성공 - 비동기 시나리오 생성 및 완료") - void processScenarioGenerationAsync_성공_시나리오생성완료() { - // Given - Long scenarioId = 1001L; - User mockUser = User.builder().build(); - ReflectionTestUtils.setField(mockUser, "id", 1L); - - BaseLine mockBaseLine = BaseLine.builder().user(mockUser).build(); - ReflectionTestUtils.setField(mockBaseLine, "id", 100L); // BaseLine ID 설정 - - DecisionLine mockDecisionLine = DecisionLine.builder() - .user(mockUser) - .baseLine(mockBaseLine) - .build(); - ReflectionTestUtils.setField(mockDecisionLine, "id", 200L); - - Scenario mockScenario = Scenario.builder() - .user(mockUser) - .decisionLine(mockDecisionLine) - .baseLine(mockBaseLine) - .status(ScenarioStatus.PENDING) - .build(); - ReflectionTestUtils.setField(mockScenario, "id", scenarioId); - - // 트랜잭션 분리된 메서드들 모킹 - doNothing().when(scenarioTransactionService).updateScenarioStatus(anyLong(), any(), any()); - doNothing().when(scenarioTransactionService).saveAiResult(anyLong(), any()); - - // prepareScenarioData 모킹 추가 (핵심!) - given(scenarioTransactionService.prepareScenarioData(scenarioId)) - .willReturn(mockScenario); - - // 베이스 시나리오가 이미 존재하도록 설정 (AI 호출 방지) - Scenario mockBaseScenario = Scenario.builder() - .user(mockUser) - .baseLine(mockBaseLine) - .status(ScenarioStatus.COMPLETED) - .build(); - ReflectionTestUtils.setField(mockBaseScenario, "id", 999L); - - given(scenarioRepository.findByBaseLineIdAndDecisionLineIsNull(any())) - .willReturn(Optional.of(mockBaseScenario)); // 베이스 시나리오 이미 존재 - - // DecisionScenario 생성용 AI 서비스 모킹 - given(aiService.generateDecisionScenario(any(DecisionLine.class), any(Scenario.class))) - .willReturn(CompletableFuture.completedFuture(mockDecisionScenarioResult())); - - // When - scenarioService.processScenarioGenerationAsync(scenarioId); - - // Then - // 트랜잭션 서비스 호출 검증 - verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); - verify(scenarioTransactionService).prepareScenarioData(scenarioId); - verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null); - } - - @Test - @DisplayName("실패 - 존재하지 않는 시나리오 (비동기 처리)") - void processScenarioGenerationAsync_실패_존재하지않는_시나리오() { - // Given - Long scenarioId = 999L; - - // 트랜잭션 서비스는 정상 동작하도록 모킹 - doNothing().when(scenarioTransactionService).updateScenarioStatus(anyLong(), any(), any()); - - // prepareScenarioData에서 시나리오를 찾을 수 없음 (예외 발생) - given(scenarioTransactionService.prepareScenarioData(scenarioId)) - .willThrow(new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); - - // When - scenarioService.processScenarioGenerationAsync(scenarioId); - - // Then - // 실패 시 FAILED 상태 업데이트가 호출되어야 함 - verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); - verify(scenarioTransactionService).prepareScenarioData(scenarioId); - verify(scenarioTransactionService).updateScenarioStatus(eq(scenarioId), eq(ScenarioStatus.FAILED), anyString()); - } - } - @Nested @DisplayName("시나리오 상태 조회") class GetScenarioStatusTests { @@ -470,23 +383,4 @@ class GetStatusMessageTests { assertThat(failedResult.message()).isEqualTo("시나리오 생성에 실패했습니다. 다시 시도해주세요."); } } - - // 헬퍼 메서드: Mock DecisionScenario 결과 생성 - private DecisionScenarioResult mockDecisionScenarioResult() { - return new DecisionScenarioResult( - "테스트 직업", - "테스트 요약", - "테스트 설명", - 100, // total - "테스트 이미지", - Map.of("2025", "테스트 타이틀"), - java.util.List.of( - new DecisionScenarioResult.Indicator("경제", 50, "테스트 경제 분석"), - new DecisionScenarioResult.Indicator("행복", 50, "테스트 행복 분석") - ), - java.util.List.of( - new DecisionScenarioResult.Comparison("TOTAL", 200, 250, "테스트 비교 분석") - ) - ); - } } \ No newline at end of file