diff --git a/ops/db/migrations/20260704_analysis_async_tasks.sql b/ops/db/migrations/20260704_analysis_async_tasks.sql index 83361c8..e7abdca 100644 --- a/ops/db/migrations/20260704_analysis_async_tasks.sql +++ b/ops/db/migrations/20260704_analysis_async_tasks.sql @@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS analysis_async_tasks ( task_id VARCHAR(36) PRIMARY KEY, user_id BIGINT NOT NULL, mock_apply_id BIGINT NOT NULL, + credit_reference_id VARCHAR(100), + credit_status VARCHAR(20) NOT NULL DEFAULT 'NONE', status VARCHAR(20) NOT NULL, message VARCHAR(255) NOT NULL, error VARCHAR(2000), diff --git a/ops/db/migrations/20260704_analysis_async_tasks_active_unique.sql b/ops/db/migrations/20260704_analysis_async_tasks_active_unique.sql new file mode 100644 index 0000000..2784e82 --- /dev/null +++ b/ops/db/migrations/20260704_analysis_async_tasks_active_unique.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS uk_analysis_async_tasks_active_user_mock_apply + ON analysis_async_tasks (user_id, mock_apply_id) + WHERE status IN ('PENDING', 'RUNNING'); diff --git a/ops/db/migrations/20260704_analysis_async_tasks_credit_status.sql b/ops/db/migrations/20260704_analysis_async_tasks_credit_status.sql new file mode 100644 index 0000000..392f876 --- /dev/null +++ b/ops/db/migrations/20260704_analysis_async_tasks_credit_status.sql @@ -0,0 +1,5 @@ +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS credit_reference_id VARCHAR(100); + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS credit_status VARCHAR(20) NOT NULL DEFAULT 'NONE'; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index e12fd34..c9cc408 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -30,6 +30,13 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(name = "mock_apply_id", nullable = false) private Long mockApplyId; + @Column(name = "credit_reference_id", length = 100) + private String creditReferenceId; + + @Enumerated(EnumType.STRING) + @Column(name = "credit_status", nullable = false, length = 20) + private CreditStatus creditStatus; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private TaskStatus status; @@ -51,11 +58,25 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) { task.taskId = UUID.randomUUID().toString(); task.userId = userId; task.mockApplyId = mockApplyId; + task.creditStatus = CreditStatus.NONE; task.status = TaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; return task; } + public void markCreditReserved(String creditReferenceId) { + this.creditReferenceId = creditReferenceId; + this.creditStatus = CreditStatus.RESERVED; + } + + public void markCreditConfirmed() { + this.creditStatus = CreditStatus.CONFIRMED; + } + + public void markCreditReleased() { + this.creditStatus = CreditStatus.RELEASED; + } + public void markRunning() { this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; @@ -82,4 +103,11 @@ public enum TaskStatus { SUCCEEDED, FAILED } + + public enum CreditStatus { + NONE, + RESERVED, + CONFIRMED, + RELEASED + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java index 7ce9eda..8815d4c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -29,10 +30,15 @@ public class AnalysisAiClient { private final OpenAIClient openAIClient; private final CorpusRetrievalService corpusRetrievalService; + private final LlmConcurrencyLimiter llmConcurrencyLimiter; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") private String analysisModel; + public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { + return analyze(payload.jobPosting(), payload.answeredQuestions()); + } + public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { RetrievalContext referenceContext = emptyContext(); try { @@ -49,7 +55,10 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List questio .build(); try { - StructuredResponse response = openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "cover-letter-analysis", + () -> openAIClient.responses().create(params) + ); return extractStructuredContent(response); } catch (GeneralException e) { throw e; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java index fe6a02f..a0dd4b2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java @@ -2,16 +2,21 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import java.util.Locale; + @Service @RequiredArgsConstructor public class AnalysisAsyncFacadeService { + private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisAsyncProcessor analysisAsyncProcessor; @@ -23,11 +28,7 @@ public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { analysisService.validateAnalysisRequest(validatedUser, mockApplyId); return analysisAsyncTaskService.findActiveTask(validatedUser.getId(), mockApplyId) - .map(existingTask -> new AnalysisAsyncSubmitResponse( - existingTask.getTaskId(), - existingTask.getStatus().name(), - "이미 진행 중인 자소서 분석 작업이 있습니다." - )) + .map(this::toInProgressResponse) .orElseGet(() -> createAndProcessTask(validatedUser, mockApplyId)); } @@ -55,11 +56,18 @@ public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String t } private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { - String taskId = analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId); + PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); + if (!pendingTaskResult.created()) { + return toInProgressResponse(pendingTaskResult.task()); + } + + AnalysisAsyncTask task = pendingTaskResult.task(); + String taskId = task.getTaskId(); String creditReferenceId = "analysisTaskId=" + taskId; try { - analysisService.chargeAnalysisCredit(user, creditReferenceId); + analysisService.reserveAnalysisCredit(user, creditReferenceId); + analysisAsyncTaskService.markCreditReserved(taskId, creditReferenceId); analysisAsyncProcessor.process(taskId, user.getId(), mockApplyId, creditReferenceId); return new AnalysisAsyncSubmitResponse( taskId, @@ -71,4 +79,50 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp throw e; } } + + private PendingTaskResult createPendingTask(User user, Long mockApplyId) { + try { + return new PendingTaskResult( + analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId), + true + ); + } catch (DataIntegrityViolationException e) { + if (!isActiveTaskUniqueConflict(e)) { + throw e; + } + AnalysisAsyncTask existingTask = analysisAsyncTaskService.findActiveTask(user.getId(), mockApplyId) + .orElseThrow(() -> e); + return new PendingTaskResult(existingTask, false); + } + } + + private AnalysisAsyncSubmitResponse toInProgressResponse(AnalysisAsyncTask task) { + return new AnalysisAsyncSubmitResponse( + task.getTaskId(), + task.getStatus().name(), + "이미 진행 중인 자소서 분석 작업이 있습니다." + ); + } + + private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation + && containsConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (containsConstraintName(cause.getMessage())) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private boolean containsConstraintName(String value) { + return value != null && value.toLowerCase(Locale.ROOT).contains(ACTIVE_TASK_UNIQUE_CONSTRAINT); + } + + private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java index 65b767f..afb24f4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java @@ -22,12 +22,17 @@ public void process(String taskId, Long userId, Long mockApplyId, String creditR try { User user = userService.getUser(userId); - analysisService.runAnalysis(user, mockApplyId); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); + var llmResponse = analysisService.executeAnalysis(payload); + analysisService.finalizeAnalysis(user, mockApplyId, payload, llmResponse); + analysisService.confirmAnalysisCredit(user, creditReferenceId); + analysisAsyncTaskService.markCreditConfirmed(taskId); analysisAsyncTaskService.markSuccess(taskId); } catch (Exception e) { log.error("자소서 분석 비동기 처리 실패: taskId={}, mockApplyId={}", taskId, mockApplyId, e); try { - analysisService.refundAnalysisCredit(userService.getUser(userId), creditReferenceId); + analysisService.releaseAnalysisCredit(userService.getUser(userId), creditReferenceId); + analysisAsyncTaskService.markCreditReleased(taskId); } catch (Exception refundException) { log.error("자소서 분석 실패 환불 처리 실패: taskId={}, userId={}", taskId, userId, refundException); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java index bb90906..223daea 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; @@ -20,9 +21,8 @@ public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; @Transactional - public String createPendingTask(Long userId, Long mockApplyId) { - AnalysisAsyncTask task = analysisAsyncTaskRepository.save(AnalysisAsyncTask.pending(userId, mockApplyId)); - return task.getTaskId(); + public AnalysisAsyncTask createPendingTask(Long userId, Long mockApplyId) { + return analysisAsyncTaskRepository.saveAndFlush(AnalysisAsyncTask.pending(userId, mockApplyId)); } @Transactional @@ -54,6 +54,26 @@ public void markFailed(String taskId, String errorMessage) { getTask(taskId).markFailed(errorMessage); } + @Transactional + public void markCreditReserved(String taskId, String creditReferenceId) { + getTask(taskId).markCreditReserved(creditReferenceId); + } + + @Transactional + public void markCreditConfirmed(String taskId) { + getTask(taskId).markCreditConfirmed(); + } + + @Transactional + public void markCreditReleased(String taskId) { + getTask(taskId).markCreditReleased(); + } + + @Transactional(readOnly = true) + public CreditStatus getCreditStatus(String taskId) { + return getTask(taskId).getCreditStatus(); + } + @Transactional(readOnly = true) public AnalysisAsyncStatusResponse getTaskStatus(Long userId, String taskId) { AnalysisAsyncTask task = analysisAsyncTaskRepository.findByTaskIdAndUserId(taskId, userId) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisExecutionPayload.java new file mode 100644 index 0000000..5461bf8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisExecutionPayload.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; + +import java.util.List; + +public record AnalysisExecutionPayload( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java index 497e24d..7cd40a8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -52,12 +52,16 @@ public class AnalysisService { public AnalysisResponse analyze(User user, Long mockApplyId) { validateAnalysisRequest(user, mockApplyId); String referenceId = "mockApplyId=" + mockApplyId; - creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); + reserveAnalysisCredit(user, referenceId); try { - return runAnalysis(user, mockApplyId); + AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); + AnalysisLlmResponse llmResponse = executeAnalysis(payload); + AnalysisResponse response = finalizeAnalysis(user, mockApplyId, payload, llmResponse); + confirmAnalysisCredit(user, referenceId); + return response; } catch (RuntimeException e) { - creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); + releaseAnalysisCredit(user, referenceId); throw e; } } @@ -78,17 +82,24 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional - public void chargeAnalysisCredit(User user, String referenceId) { - creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); + public void reserveAnalysisCredit(User user, String referenceId) { + creditService.use(user, 1, "자소서 분석 크레딧 예약", referenceId); } @Transactional - public void refundAnalysisCredit(User user, String referenceId) { - creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); + public void confirmAnalysisCredit(User user, String referenceId) { + if (user == null || referenceId == null) { + return; + } } @Transactional - public AnalysisResponse runAnalysis(User user, Long mockApplyId) { + public void releaseAnalysisCredit(User user, String referenceId) { + creditService.refund(user, 1, "자소서 분석 크레딧 예약 해제", referenceId); + } + + @Transactional(readOnly = true) + public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); List answeredQuestions = questions.stream() @@ -102,7 +113,32 @@ public AnalysisResponse runAnalysis(User user, Long mockApplyId) { ); } - AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); + // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. + mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); + mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); + + return new AnalysisExecutionPayload( + user.getId(), + mockApplyId, + mockApply.getJobPosting(), + List.copyOf(questions), + List.copyOf(answeredQuestions) + ); + } + + public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + return analysisAiClient.analyze(payload.jobPosting(), payload.answeredQuestions()); + } + + @Transactional + public AnalysisResponse finalizeAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse + ) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( @@ -114,7 +150,12 @@ public AnalysisResponse runAnalysis(User user, Long mockApplyId) { normalizeFeedback(llmResponse.feedback()) )); - List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); + List questionAnalyses = buildQuestionAnalyses( + analysis, + questions, + payload.answeredQuestions(), + llmResponse + ); questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); @@ -200,10 +241,13 @@ private void replaceExistingAnalysis(MockApply mockApply) { private List buildQuestionAnalyses( Analysis analysis, List questions, + List answeredQuestions, AnalysisLlmResponse llmResponse ) { Map questionMap = questions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); + Map answerByQuestionId = answeredQuestions.stream() + .collect(Collectors.toMap(Question::getId, Question::getAnswer)); List result = new ArrayList<>(); if (llmResponse.questionAnalyses() == null) { @@ -220,7 +264,10 @@ private List buildQuestionAnalyses( continue; } - String answer = question.getAnswer(); + String answer = answerByQuestionId.get(item.questionId()); + if (!StringUtils.hasText(answer)) { + continue; + } String sentence = item.sentence(); int start = answer.indexOf(sentence); if (start < 0) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 5e27ef3..699d05c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -17,6 +17,7 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -41,6 +42,7 @@ public class JobPostingAiService { private final DetailClassificationRepository detailClassificationRepository; private final CorpusRetrievalService corpusRetrievalService; private final JobPostingImageStorageService jobPostingImageStorageService; + private final LlmConcurrencyLimiter llmConcurrencyLimiter; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; @@ -68,9 +70,14 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r .build(); try { - StructuredResponse response = openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "job-posting-generate", + () -> openAIClient.responses().create(params) + ); JobPostingGenerateResponse generated = extractStructuredContent(response, JobPostingGenerateResponse.class); return normalizeGeneratedResponse(generated, request); + } catch (GeneralException e) { + throw e; } catch (Exception e) { log.error("채용 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); return createFallbackGeneratedResponse(request); @@ -97,12 +104,17 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener .build(); try { - StructuredResponse response = openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "mock-job-posting-generate", + () -> openAIClient.responses().create(params) + ); JobPostingMockGenerateResponse generated = extractStructuredContent( response, JobPostingMockGenerateResponse.class ); return normalizeMockGeneratedResponse(generated, company, detailClassification); + } catch (GeneralException e) { + throw e; } catch (Exception e) { log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); return createFallbackMockGeneratedResponse(company, detailClassification, retrievalContext.jobPostingReferences()); @@ -132,12 +144,17 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( .build(); try { - StructuredResponse response = openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "mock-question-generate", + () -> openAIClient.responses().create(params) + ); JobPostingMockQuestionResponse generated = extractStructuredContent( response, JobPostingMockQuestionResponse.class ); return normalizeMockQuestionResponse(generated, detailClassification); + } catch (GeneralException e) { + throw e; } catch (Exception e) { log.error("모의 공고 추천 질문 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); return createFallbackMockQuestionResponse(detailClassification); @@ -156,11 +173,15 @@ public JobPostingClassificationResultResponse classifyDetailClassification( .build(); try { - StructuredResponse response = - openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "job-posting-classification", + () -> openAIClient.responses().create(params) + ); JobPostingClassificationResultResponse classification = extractStructuredContent(response, JobPostingClassificationResultResponse.class); return normalizeClassificationResponse(classification, candidates); + } catch (GeneralException e) { + throw e; } catch (Exception e) { log.error("채용 공고 소분류 분류 OpenAI API 호출 오류: {}", e.getMessage(), e); return fallbackClassification(candidates); @@ -199,9 +220,14 @@ public JobPostingExtractResponse extractJobPosting(Long userId, String rawText, .build(); try { - StructuredResponse response = openAIClient.responses().create(params); + StructuredResponse response = llmConcurrencyLimiter.execute( + "job-posting-extract", + () -> openAIClient.responses().create(params) + ); JobPostingExtractResponse extracted = extractStructuredContent(response, JobPostingExtractResponse.class); return normalizeResponse(extracted, rawText); + } catch (GeneralException e) { + throw e; } catch (Exception e) { log.error("채용 공고 추출 OpenAI API 호출 오류: {}", e.getMessage(), e); return createFallbackResponse(rawText); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java index 7e95599..3fd3e0d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java @@ -6,74 +6,94 @@ import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; -import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; -import com.jobdri.jobdri_api.domain.jobposting.repository.MockQuestionCacheRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Locale; +import java.util.Optional; @Service @RequiredArgsConstructor -@Transactional public class MockQuestionCacheService { static final String PROMPT_VERSION = "v1"; + private static final String CACHE_UNIQUE_CONSTRAINT = "uk_mock_question_cache_company_detail_version"; - private final MockQuestionCacheRepository mockQuestionCacheRepository; private final DetailClassificationRepository detailClassificationRepository; private final CompanyRepository companyRepository; private final JobPostingAiService jobPostingAiService; + private final MockQuestionInflightRegistry mockQuestionInflightRegistry; + private final MockQuestionCacheTransactionalService mockQuestionCacheTransactionalService; public List getRecommendedQuestions(JobPostingMockGenerateRequest request) { - return mockQuestionCacheRepository - .findByCompany_IdAndDetailClassification_IdAndPromptVersion( - request.companyId(), - request.detailClassificationId(), - PROMPT_VERSION - ) - .map(this::copyQuestions) - .orElseGet(() -> createAndCacheQuestions(request)); + return getCachedQuestions(request) + .orElseGet(() -> mockQuestionInflightRegistry.execute(cacheKey(request), () -> createAndCacheQuestions(request))); } public List createAndCacheQuestions(JobPostingMockGenerateRequest request) { - return mockQuestionCacheRepository - .findByCompany_IdAndDetailClassification_IdAndPromptVersion( - request.companyId(), - request.detailClassificationId(), - PROMPT_VERSION - ) - .map(this::copyQuestions) - .orElseGet(() -> { - DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() - )); - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.COMPANY_NOT_FOUND, - "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() - )); + return getCachedQuestions(request).orElseGet(() -> createAndCacheQuestionsInternal(request)); + } + + private List createAndCacheQuestionsInternal(JobPostingMockGenerateRequest request) { + DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + )); + Company company = companyRepository.findById(request.companyId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.COMPANY_NOT_FOUND, + "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() + )); + + JobPostingMockQuestionResponse generated = jobPostingAiService.generateMockRecommendedQuestions(request, company); + try { + return mockQuestionCacheTransactionalService.saveQuestions( + company, + detailClassification, + PROMPT_VERSION, + generated.recommendedQuestions() + ); + } catch (DataIntegrityViolationException e) { + if (!isCacheUniqueConflict(e)) { + throw e; + } + return getCachedQuestions(request).orElseThrow(() -> e); + } + } + + private Optional> getCachedQuestions(JobPostingMockGenerateRequest request) { + return mockQuestionCacheTransactionalService.findQuestions( + request.companyId(), + request.detailClassificationId(), + PROMPT_VERSION + ); + } + + private String cacheKey(JobPostingMockGenerateRequest request) { + return request.companyId() + ":" + request.detailClassificationId() + ":" + PROMPT_VERSION; + } - JobPostingMockQuestionResponse generated = - jobPostingAiService.generateMockRecommendedQuestions(request, company); - MockQuestionCache saved = mockQuestionCacheRepository.save( - MockQuestionCache.create( - company, - detailClassification, - PROMPT_VERSION, - generated.recommendedQuestions() - ) - ); - return copyQuestions(saved); - }); + private boolean isCacheUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation + && containsConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (containsConstraintName(cause.getMessage())) { + return true; + } + cause = cause.getCause(); + } + return false; } - private List copyQuestions(MockQuestionCache cache) { - return List.copyOf(cache.getQuestions()); + private boolean containsConstraintName(String value) { + return value != null && value.toLowerCase(Locale.ROOT).contains(CACHE_UNIQUE_CONSTRAINT); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheTransactionalService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheTransactionalService.java new file mode 100644 index 0000000..f6dc944 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheTransactionalService.java @@ -0,0 +1,49 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; +import com.jobdri.jobdri_api.domain.jobposting.repository.MockQuestionCacheRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MockQuestionCacheTransactionalService { + + private final MockQuestionCacheRepository mockQuestionCacheRepository; + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + public Optional> findQuestions(Long companyId, Long detailClassificationId, String promptVersion) { + return mockQuestionCacheRepository + .findByCompany_IdAndDetailClassification_IdAndPromptVersion( + companyId, + detailClassificationId, + promptVersion + ) + .map(cache -> List.copyOf(cache.getQuestions())); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List saveQuestions( + Company company, + DetailClassification detailClassification, + String promptVersion, + List questions + ) { + MockQuestionCache saved = mockQuestionCacheRepository.saveAndFlush( + MockQuestionCache.create( + company, + detailClassification, + promptVersion, + questions + ) + ); + return List.copyOf(saved.getQuestions()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java new file mode 100644 index 0000000..ff8e379 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java @@ -0,0 +1,56 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +@Component +@RequiredArgsConstructor +public class MockQuestionInflightRegistry { + + private final ConcurrentHashMap>> inflightTasks = new ConcurrentHashMap<>(); + + public java.util.List execute(String key, TaskSupplier supplier) { + FutureTask> task = new FutureTask<>(supplier::get); + FutureTask> existingTask = inflightTasks.putIfAbsent(key, task); + + if (existingTask == null) { + try { + task.run(); + return task.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("추천 질문 생성 대기 중 인터럽트가 발생했습니다.", e); + } catch (ExecutionException e) { + throw unwrap(e); + } finally { + inflightTasks.remove(key, task); + } + } + + try { + return existingTask.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("추천 질문 생성 대기 중 인터럽트가 발생했습니다.", e); + } catch (ExecutionException e) { + throw unwrap(e); + } + } + + private RuntimeException unwrap(ExecutionException executionException) { + Throwable cause = executionException.getCause(); + if (cause instanceof RuntimeException runtimeException) { + return runtimeException; + } + return new IllegalStateException("추천 질문 생성 중 알 수 없는 오류가 발생했습니다.", cause); + } + + @FunctionalInterface + public interface TaskSupplier { + java.util.List get() throws Exception; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java b/src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java new file mode 100644 index 0000000..1755749 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java @@ -0,0 +1,72 @@ +package com.jobdri.jobdri_api.global.config; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +public class LlmConcurrencyLimiter { + + private final Semaphore semaphore; + private final long acquireTimeoutMillis; + + public LlmConcurrencyLimiter( + @Value("${llm.concurrency.max-concurrent-requests:4}") int maxConcurrentRequests, + @Value("${llm.concurrency.acquire-timeout-millis:3000}") long acquireTimeoutMillis + ) { + if (maxConcurrentRequests <= 0) { + throw new IllegalArgumentException("llm.concurrency.max-concurrent-requests must be positive"); + } + if (acquireTimeoutMillis <= 0) { + throw new IllegalArgumentException("llm.concurrency.acquire-timeout-millis must be positive"); + } + this.semaphore = new Semaphore(maxConcurrentRequests, true); + this.acquireTimeoutMillis = acquireTimeoutMillis; + } + + public T execute(String operationName, CheckedSupplier supplier) { + boolean acquired = false; + try { + acquired = semaphore.tryAcquire(acquireTimeoutMillis, TimeUnit.MILLISECONDS); + if (!acquired) { + log.warn( + "LLM concurrency limiter timeout. operation={}, availablePermits={}", + operationName, + semaphore.availablePermits() + ); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "현재 AI 요청이 많아 처리 대기 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요." + ); + } + return supplier.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "AI 요청 대기 중 인터럽트가 발생했습니다." + ); + } catch (GeneralException e) { + throw e; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + if (acquired) { + semaphore.release(); + } + } + } + + @FunctionalInterface + public interface CheckedSupplier { + T get() throws Exception; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 9b55375..db107f7 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -140,3 +140,12 @@ async: core-pool-size: ${MAIL_ASYNC_CORE_POOL_SIZE:1} max-pool-size: ${MAIL_ASYNC_MAX_POOL_SIZE:2} queue-capacity: ${MAIL_ASYNC_QUEUE_CAPACITY:50} + llm: + core-pool-size: ${LLM_ASYNC_CORE_POOL_SIZE:2} + max-pool-size: ${LLM_ASYNC_MAX_POOL_SIZE:4} + queue-capacity: ${LLM_ASYNC_QUEUE_CAPACITY:20} + +llm: + concurrency: + max-concurrent-requests: ${LLM_MAX_CONCURRENT_REQUESTS:4} + acquire-timeout-millis: ${LLM_CONCURRENCY_ACQUIRE_TIMEOUT_MILLIS:3000} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 76fa7c4..acd8a2b 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -152,3 +152,12 @@ async: core-pool-size: ${MAIL_ASYNC_CORE_POOL_SIZE:1} max-pool-size: ${MAIL_ASYNC_MAX_POOL_SIZE:2} queue-capacity: ${MAIL_ASYNC_QUEUE_CAPACITY:50} + llm: + core-pool-size: ${LLM_ASYNC_CORE_POOL_SIZE:2} + max-pool-size: ${LLM_ASYNC_MAX_POOL_SIZE:4} + queue-capacity: ${LLM_ASYNC_QUEUE_CAPACITY:20} + +llm: + concurrency: + max-concurrent-requests: ${LLM_MAX_CONCURRENT_REQUESTS:4} + acquire-timeout-millis: ${LLM_CONCURRENCY_ACQUIRE_TIMEOUT_MILLIS:3000} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java new file mode 100644 index 0000000..4050907 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java @@ -0,0 +1,81 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncFacadeServiceTest { + + @Mock + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Mock + private AnalysisAsyncProcessor analysisAsyncProcessor; + + @Mock + private AnalysisService analysisService; + + @Mock + private UserService userService; + + @InjectMocks + private AnalysisAsyncFacadeService analysisAsyncFacadeService; + + @Test + @DisplayName("task 생성 충돌 시 기존 진행 중 작업을 반환하고 추가 처리를 하지 않는다") + void submitReturnsExistingTaskWhenCreateConflicts() { + User user = User.signup("테스트 사용자", "analysis-async@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + AnalysisAsyncTask existingTask = AnalysisAsyncTask.pending(1L, 10L); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.of(existingTask)); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)) + .thenThrow(new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply")); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), anyString()); + } + + @Test + @DisplayName("task 생성 충돌 후에도 진행 중 작업이 없으면 원래 예외를 전파한다") + void submitPropagatesExceptionWhenTaskDisappearsAfterConflict() { + User user = User.signup("테스트 사용자", "analysis-async-missing@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + DataIntegrityViolationException exception = + new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenThrow(exception); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(exception); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 1007a36..4a3d630 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -15,6 +15,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -31,6 +32,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -52,6 +58,9 @@ class JobPostingAiServiceTest { @Mock private JobPostingImageStorageService jobPostingImageStorageService; + @Mock + private LlmConcurrencyLimiter llmConcurrencyLimiter; + private JobPostingAiService jobPostingAiService; @BeforeEach @@ -60,11 +69,17 @@ void setUp() { openAIClient, detailClassificationRepository, corpusRetrievalService, - jobPostingImageStorageService + jobPostingImageStorageService, + llmConcurrencyLimiter ); ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); ReflectionTestUtils.setField(TEST_USER, "id", 1L); ReflectionTestUtils.setField(jobPostingAiService, "extractionModel", "gpt-4o-mini"); + lenient().when(llmConcurrencyLimiter.execute(anyString(), any())) + .thenAnswer(invocation -> { + LlmConcurrencyLimiter.CheckedSupplier supplier = invocation.getArgument(1); + return supplier.get(); + }); } @Test @@ -186,6 +201,7 @@ void generateMockRecommendedQuestionsUsesFallback() { assertThat(response.recommendedQuestions()).hasSize(5); assertThat(response.recommendedQuestions().getFirst()).contains("Java/Spring"); + verify(llmConcurrencyLimiter).execute(eq("mock-question-generate"), any()); } @Test @@ -246,6 +262,26 @@ void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() { assertThat(response.companyName()).isEqualTo("테스트 기업"); assertThat(response.jobTitle()).isEqualTo("백엔드 개발자"); + verify(llmConcurrencyLimiter).execute(eq("job-posting-generate"), any()); + } + + @Test + @DisplayName("limiter 예외는 fallback으로 삼키지 않고 전파한다") + void generateMockRecommendedQuestionsPropagatesLimiterFailure() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(), List.of())); + when(llmConcurrencyLimiter.execute(eq("mock-question-generate"), any())) + .thenThrow(new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, "busy")); + + assertThatThrownBy(() -> jobPostingAiService.generateMockRecommendedQuestions( + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE); } private DetailClassification createDetailClassification( diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java index 55b16d9..79458eb 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java @@ -9,14 +9,13 @@ import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; -import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; -import com.jobdri.jobdri_api.domain.jobposting.repository.MockQuestionCacheRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -30,9 +29,6 @@ @ExtendWith(MockitoExtension.class) class MockQuestionCacheServiceTest { - @Mock - private MockQuestionCacheRepository mockQuestionCacheRepository; - @Mock private DetailClassificationRepository detailClassificationRepository; @@ -42,15 +38,22 @@ class MockQuestionCacheServiceTest { @Mock private JobPostingAiService jobPostingAiService; + @Mock + private MockQuestionInflightRegistry mockQuestionInflightRegistry; + + @Mock + private MockQuestionCacheTransactionalService mockQuestionCacheTransactionalService; + private MockQuestionCacheService mockQuestionCacheService; @BeforeEach void setUp() { - mockQuestionCacheService = new MockQuestionCacheService( - mockQuestionCacheRepository, + this.mockQuestionCacheService = new MockQuestionCacheService( detailClassificationRepository, companyRepository, - jobPostingAiService + jobPostingAiService, + mockQuestionInflightRegistry, + mockQuestionCacheTransactionalService ); } @@ -59,18 +62,12 @@ void setUp() { void getRecommendedQuestionsUsesCache() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); Company company = Company.create("선택 기업", CompanySize.MEDIUM); - MockQuestionCache cache = MockQuestionCache.create( - company, - detailClassification, - MockQuestionCacheService.PROMPT_VERSION, - List.of("질문 1", "질문 2") - ); - when(mockQuestionCacheRepository.findByCompany_IdAndDetailClassification_IdAndPromptVersion( + when(mockQuestionCacheTransactionalService.findQuestions( 1L, 100L, MockQuestionCacheService.PROMPT_VERSION )) - .thenReturn(Optional.of(cache)); + .thenReturn(Optional.of(List.of("질문 1", "질문 2"))); List questions = mockQuestionCacheService.getRecommendedQuestions( new JobPostingMockGenerateRequest(1L, 10L, 100L) @@ -90,7 +87,7 @@ void createAndCacheQuestionsWhenCacheMissing() { JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest(1L, 10L, 100L); JobPostingMockQuestionResponse aiResponse = new JobPostingMockQuestionResponse(List.of("질문 A", "질문 B")); - when(mockQuestionCacheRepository.findByCompany_IdAndDetailClassification_IdAndPromptVersion( + when(mockQuestionCacheTransactionalService.findQuestions( 1L, 100L, MockQuestionCacheService.PROMPT_VERSION @@ -102,13 +99,95 @@ void createAndCacheQuestionsWhenCacheMissing() { org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.any(Company.class) )).thenReturn(aiResponse); - when(mockQuestionCacheRepository.save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); + when(mockQuestionCacheTransactionalService.saveQuestions( + org.mockito.ArgumentMatchers.any(Company.class), + org.mockito.ArgumentMatchers.eq(detailClassification), + org.mockito.ArgumentMatchers.eq(MockQuestionCacheService.PROMPT_VERSION), + org.mockito.ArgumentMatchers.eq(List.of("질문 A", "질문 B")) + )).thenReturn(List.of("질문 A", "질문 B")); List questions = mockQuestionCacheService.createAndCacheQuestions(request); assertThat(questions).containsExactly("질문 A", "질문 B"); - verify(mockQuestionCacheRepository).save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class)); + verify(mockQuestionCacheTransactionalService).saveQuestions( + org.mockito.ArgumentMatchers.any(Company.class), + org.mockito.ArgumentMatchers.eq(detailClassification), + org.mockito.ArgumentMatchers.eq(MockQuestionCacheService.PROMPT_VERSION), + org.mockito.ArgumentMatchers.eq(List.of("질문 A", "질문 B")) + ); + } + + @Test + @DisplayName("캐시 저장 충돌 시 재조회한 캐시를 반환한다") + void createAndCacheQuestionsReturnsRefetchedCacheWhenSaveConflicts() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest(1L, 10L, 100L); + Company company = Company.create("선택 기업", CompanySize.MEDIUM); + JobPostingMockQuestionResponse aiResponse = new JobPostingMockQuestionResponse(List.of("질문 A", "질문 B")); + + when(mockQuestionCacheTransactionalService.findQuestions( + 1L, + 100L, + MockQuestionCacheService.PROMPT_VERSION + )) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(List.of("질문 A", "질문 B"))); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobPostingAiService.generateMockRecommendedQuestions( + org.mockito.ArgumentMatchers.eq(request), + org.mockito.ArgumentMatchers.eq(company) + )).thenReturn(aiResponse); + when(mockQuestionCacheTransactionalService.saveQuestions( + org.mockito.ArgumentMatchers.eq(company), + org.mockito.ArgumentMatchers.eq(detailClassification), + org.mockito.ArgumentMatchers.eq(MockQuestionCacheService.PROMPT_VERSION), + org.mockito.ArgumentMatchers.eq(List.of("질문 A", "질문 B")) + )).thenThrow(new DataIntegrityViolationException("uk_mock_question_cache_company_detail_version")); + + List questions = mockQuestionCacheService.createAndCacheQuestions(request); + + assertThat(questions).containsExactly("질문 A", "질문 B"); + } + + @Test + @DisplayName("캐시 미스 시 inflight registry를 통해 생성 경로로 진입한다") + void getRecommendedQuestionsUsesInflightRegistryOnCacheMiss() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest(1L, 10L, 100L); + Company company = Company.create("선택 기업", CompanySize.MEDIUM); + JobPostingMockQuestionResponse aiResponse = new JobPostingMockQuestionResponse(List.of("질문 A", "질문 B")); + + when(mockQuestionCacheTransactionalService.findQuestions(1L, 100L, MockQuestionCacheService.PROMPT_VERSION)) + .thenReturn(Optional.empty(), Optional.empty()); + when(mockQuestionInflightRegistry.execute( + org.mockito.ArgumentMatchers.eq("1:100:v1"), + org.mockito.ArgumentMatchers.any() + )).thenAnswer(invocation -> { + MockQuestionInflightRegistry.TaskSupplier supplier = invocation.getArgument(1); + try { + return supplier.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobPostingAiService.generateMockRecommendedQuestions(request, company)).thenReturn(aiResponse); + when(mockQuestionCacheTransactionalService.saveQuestions( + company, + detailClassification, + MockQuestionCacheService.PROMPT_VERSION, + List.of("질문 A", "질문 B") + )).thenReturn(List.of("질문 A", "질문 B")); + + List questions = mockQuestionCacheService.getRecommendedQuestions(request); + + assertThat(questions).containsExactly("질문 A", "질문 B"); + verify(mockQuestionInflightRegistry).execute( + org.mockito.ArgumentMatchers.eq("1:100:v1"), + org.mockito.ArgumentMatchers.any() + ); } private DetailClassification createDetailClassification( diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 223740f..1534e20 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -82,3 +82,14 @@ payment: client-key: test-toss-client-key secret-key: test-toss-secret-key base-url: https://api.tosspayments.com + +async: + llm: + core-pool-size: 2 + max-pool-size: 4 + queue-capacity: 20 + +llm: + concurrency: + max-concurrent-requests: 4 + acquire-timeout-millis: 1000