From bde20f99ac9452f29c3ae90c85347334b20e2848 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 17:10:14 +0900 Subject: [PATCH 01/27] =?UTF-8?q?[Fix]=20analysis=20service=20compile=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20bootJar?= =?UTF-8?q?=20=EB=B9=8C=EB=93=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/service/AnalysisService.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) 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 9c77b2b..5f41f40 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 @@ -148,14 +148,18 @@ public AnalysisResponse finalizeAnalysis( ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + validateRequiredScores(llmResponse); + int jobFit = validateScore("jobFit", llmResponse.jobFit()); + int impact = validateScore("impact", llmResponse.impact()); + int completeness = validateScore("completeness", llmResponse.completeness()); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( mockApply, - clampScore(llmResponse.score()), - clampScore(llmResponse.jobFit()), - clampScore(llmResponse.impact()), - clampScore(llmResponse.completeness()), + calculateScore(jobFit, impact, completeness), + jobFit, + impact, + completeness, normalizeFeedback(llmResponse.feedback()) )); @@ -280,12 +284,27 @@ private List buildQuestionAnalyses( if (!StringUtils.hasText(answer)) { continue; } + QuestionAnalysisStatus status = parseStatus(item.status()); + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); + if (currentCount >= MAX_ANALYSES_PER_QUESTION) { + continue; + } String sentence = item.sentence(); - int start = answer.indexOf(sentence); + String dedupeKey = question.getId() + ":" + sentence.trim(); + if (!seenSentences.add(dedupeKey)) { + continue; + } + int start = findNextSentenceStart( + answer, + sentence, + nextSearchIndexByQuestionId.getOrDefault(question.getId(), 0) + ); if (start < 0) { continue; } - seenSentences.add(dedupeKey); nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); analysisCountByQuestionId.put(question.getId(), currentCount + 1); From e5309faf8e7115ffe8d4dffe05fcced72c875270 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 17:26:35 +0900 Subject: [PATCH 02/27] =?UTF-8?q?[Feat]=20analysis=20async=EB=A5=BC=20MQ?= =?UTF-8?q?=20worker=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20worker=20=EC=83=81=ED=83=9C=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- .env.production.example | 8 +- ...4_analysis_async_tasks_worker_metadata.sql | 20 +++ .../AnalysisWorkerInternalController.java | 105 ++++++++++++++ .../response/AnalysisAsyncStatusResponse.java | 7 + .../dto/worker/AnalysisTaskMessage.java | 40 ++++++ .../worker/AnalysisWorkerCompleteRequest.java | 16 +++ .../worker/AnalysisWorkerContextRequest.java | 11 ++ .../worker/AnalysisWorkerContextResponse.java | 25 ++++ .../worker/AnalysisWorkerFailureRequest.java | 16 +++ .../worker/AnalysisWorkerRetryRequest.java | 16 +++ .../worker/AnalysisWorkerRunningRequest.java | 13 ++ .../analysis/entity/AnalysisAsyncTask.java | 63 ++++++++- .../service/AnalysisAsyncFacadeService.java | 15 +- .../service/AnalysisAsyncProcessor.java | 36 +---- .../service/AnalysisAsyncTaskService.java | 54 ++++++-- .../service/AnalysisTaskMessagePublisher.java | 31 +++++ .../service/AnalysisWorkerBridgeService.java | 129 ++++++++++++++++++ .../global/config/RabbitMqConfig.java | 29 +++- src/main/resources/application-dev.yaml | 9 +- src/main/resources/application-prod.yaml | 9 +- .../AnalysisAsyncFacadeServiceTest.java | 4 +- 22 files changed, 609 insertions(+), 55 deletions(-) create mode 100644 ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java diff --git a/.env.example b/.env.example index 08dab98..320abfc 100644 --- a/.env.example +++ b/.env.example @@ -38,11 +38,17 @@ TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +APP_WORKER_EXCHANGE=jobdri.worker.exchange APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key -APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange +APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE} APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq +APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE} +APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute +APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute +APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq +APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/.env.production.example b/.env.production.example index 5578866..e5991d9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -42,11 +42,17 @@ TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +APP_WORKER_EXCHANGE=jobdri.worker.exchange APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key -APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange +APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE} APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq +APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE} +APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute +APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute +APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq +APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql new file mode 100644 index 0000000..b743b58 --- /dev/null +++ b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql @@ -0,0 +1,20 @@ +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40); + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100); + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java new file mode 100644 index 0000000..0b33a7b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -0,0 +1,105 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerFailureRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRunningRequest; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisWorkerBridgeService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/internal/worker/analysis") +public class AnalysisWorkerInternalController { + + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + private final InternalApiKeyValidator internalApiKeyValidator; + private final AnalysisWorkerBridgeService analysisWorkerBridgeService; + private final AnalysisAsyncTaskService analysisAsyncTaskService; + + @PostMapping("/tasks/{taskId}/running") + public ApiResponse markRunning( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerRunningRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 시작 상태를 반영했습니다."); + } + + @PostMapping("/tasks/{taskId}/retry") + public ApiResponse markRetry( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerRetryRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 재시도 상태를 반영했습니다."); + } + + @PostMapping("/tasks/{taskId}/failed") + public ApiResponse failTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerFailureRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 실패 상태를 반영했습니다."); + } + + @PostMapping("/context") + public ApiResponse getContext( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @Valid @RequestBody AnalysisWorkerContextRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 컨텍스트 조회에 성공했습니다.", + analysisWorkerBridgeService.getContext(request.taskId(), request.userId(), request.mockApplyId()) + ); + } + + @PostMapping("/tasks/{taskId}/complete") + public ApiResponse completeTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerCompleteRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 작업 완료 상태를 반영했습니다.", + analysisWorkerBridgeService.completeTask(taskId, request) + ); + } + + @GetMapping("/tasks/{taskId}") + public ApiResponse getTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 작업 상태 조회에 성공했습니다.", + analysisAsyncTaskService.getTaskStatusByTaskId(taskId) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java index 5220d1b..62000ce 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java @@ -11,7 +11,14 @@ public record AnalysisAsyncStatusResponse( String status, String message, String error, + String failureReason, + String workerId, + Integer retryCount, + Integer maxRetryCount, + Long queueLatencyMillis, LocalDateTime createdAt, + LocalDateTime submittedAt, + LocalDateTime lastAttemptAt, LocalDateTime startedAt, LocalDateTime completedAt, AnalysisResponse result diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java new file mode 100644 index 0000000..066a294 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java @@ -0,0 +1,40 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +@Builder +public record AnalysisTaskMessage( + String messageId, + String taskType, + String taskId, + Long userId, + Long mockApplyId, + String creditReferenceId, + int retryCount, + int maxRetryCount, + Instant submittedAt +) { + + public static AnalysisTaskMessage of( + String taskId, + Long userId, + Long mockApplyId, + String creditReferenceId, + int maxRetryCount + ) { + return AnalysisTaskMessage.builder() + .messageId(UUID.randomUUID().toString()) + .taskType("ANALYSIS") + .taskId(taskId) + .userId(userId) + .mockApplyId(mockApplyId) + .creditReferenceId(creditReferenceId) + .retryCount(0) + .maxRetryCount(Math.max(0, maxRetryCount)) + .submittedAt(Instant.now()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java new file mode 100644 index 0000000..5f3fb7c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerCompleteRequest( + @NotNull Long userId, + @NotNull Long mockApplyId, + @Valid @NotNull AnalysisLlmResponse llmResponse, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java new file mode 100644 index 0000000..0ec7a04 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerContextRequest( + @NotBlank String taskId, + @NotNull Long userId, + @NotNull Long mockApplyId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java new file mode 100644 index 0000000..f92e7a9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java @@ -0,0 +1,25 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import java.util.List; + +public record AnalysisWorkerContextResponse( + Long userId, + Long mockApplyId, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String bigClassificationName, + String middleClassificationName, + String detailClassificationName, + List questions +) { + public record AnalysisWorkerQuestionItem( + Long questionId, + String question, + String answer, + int charLimit + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java new file mode 100644 index 0000000..86b3433 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerFailureRequest( + @NotBlank String errorMessage, + @NotNull FailureReason failureReason, + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java new file mode 100644 index 0000000..2ea31a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerRetryRequest( + @NotBlank String errorMessage, + @NotNull FailureReason failureReason, + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java new file mode 100644 index 0000000..08ecd1f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; + +public record AnalysisWorkerRunningRequest( + @NotBlank String workerId, + @Min(0) int retryCount, + Instant submittedAt +) { +} 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 c9cc408..2f5c0fa 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 @@ -11,6 +11,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -47,13 +49,35 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(length = 2000) private String error; + @Enumerated(EnumType.STRING) + @Column(name = "failure_reason", length = 40) + private FailureReason failureReason; + + @Column(name = "worker_id", length = 100) + private String workerId; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "max_retry_count", nullable = false) + private int maxRetryCount; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + @Column(name = "last_attempt_at") + private LocalDateTime lastAttemptAt; + + @Column(name = "queue_latency_millis") + private Long queueLatencyMillis; + @Column(name = "started_at") private LocalDateTime startedAt; @Column(name = "completed_at") private LocalDateTime completedAt; - public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) { + public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) { AnalysisAsyncTask task = new AnalysisAsyncTask(); task.taskId = UUID.randomUUID().toString(); task.userId = userId; @@ -61,6 +85,9 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) { task.creditStatus = CreditStatus.NONE; task.status = TaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; + task.retryCount = 0; + task.maxRetryCount = Math.max(0, maxRetryCount); + task.submittedAt = LocalDateTime.now(); return task; } @@ -77,23 +104,43 @@ public void markCreditReleased() { this.creditStatus = CreditStatus.RELEASED; } - public void markRunning() { + public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; - this.startedAt = LocalDateTime.now(); + this.failureReason = null; + this.error = null; + this.workerId = workerId; + this.retryCount = Math.max(0, retryCount); + this.lastAttemptAt = LocalDateTime.now(); + this.startedAt = this.lastAttemptAt; + if (messageSubmittedAt != null) { + this.queueLatencyMillis = Math.max(0L, Duration.between(messageSubmittedAt, Instant.now()).toMillis()); + } } public void markSuccess() { this.status = TaskStatus.SUCCEEDED; this.message = "자소서 분석이 완료되었습니다."; this.error = null; + this.failureReason = null; this.completedAt = LocalDateTime.now(); } - public void markFailed(String errorMessage) { + public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + this.status = TaskStatus.PENDING; + this.message = "자소서 분석 재시도를 대기 중입니다."; + this.failureReason = failureReason; + this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); + this.completedAt = null; + } + + public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { this.status = TaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; + this.failureReason = failureReason; this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); } @@ -110,4 +157,12 @@ public enum CreditStatus { CONFIRMED, RELEASED } + + public enum FailureReason { + RATE_LIMIT, + QUEUE_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR + } } 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 a0dd4b2..2a414cd 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 @@ -48,7 +48,14 @@ public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String t .status(status.status()) .message(status.message()) .error(status.error()) + .failureReason(status.failureReason()) + .workerId(status.workerId()) + .retryCount(status.retryCount()) + .maxRetryCount(status.maxRetryCount()) + .queueLatencyMillis(status.queueLatencyMillis()) .createdAt(status.createdAt()) + .submittedAt(status.submittedAt()) + .lastAttemptAt(status.lastAttemptAt()) .startedAt(status.startedAt()) .completedAt(status.completedAt()) .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) @@ -68,7 +75,13 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp try { analysisService.reserveAnalysisCredit(user, creditReferenceId); analysisAsyncTaskService.markCreditReserved(taskId, creditReferenceId); - analysisAsyncProcessor.process(taskId, user.getId(), mockApplyId, creditReferenceId); + analysisAsyncProcessor.process( + taskId, + user.getId(), + mockApplyId, + creditReferenceId, + task.getMaxRetryCount() + ); return new AnalysisAsyncSubmitResponse( taskId, "PENDING", 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 afb24f4..61151f6 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 @@ -1,42 +1,18 @@ package com.jobdri.jobdri_api.domain.analysis.service; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -@Slf4j @Service @RequiredArgsConstructor public class AnalysisAsyncProcessor { - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisService analysisService; - private final UserService userService; + private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; - @Async("llmAsyncExecutor") - public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId) { - analysisAsyncTaskService.markRunning(taskId); - - try { - User user = userService.getUser(userId); - 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.releaseAnalysisCredit(userService.getUser(userId), creditReferenceId); - analysisAsyncTaskService.markCreditReleased(taskId); - } catch (Exception refundException) { - log.error("자소서 분석 실패 환불 처리 실패: taskId={}, userId={}", taskId, userId, refundException); - } - analysisAsyncTaskService.markFailed(taskId, e.getMessage()); - } + public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId, int maxRetryCount) { + analysisTaskMessagePublisher.publish( + AnalysisTaskMessage.of(taskId, userId, mockApplyId, creditReferenceId, maxRetryCount) + ); } } 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 223daea..8459225 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 @@ -3,14 +3,17 @@ 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.FailureReason; 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; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.EnumSet; import java.util.Optional; @@ -19,10 +22,15 @@ public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Value("${app.worker.analysis.max-retry-count:3}") + private int maxRetryCount; @Transactional public AnalysisAsyncTask createPendingTask(Long userId, Long mockApplyId) { - return analysisAsyncTaskRepository.saveAndFlush(AnalysisAsyncTask.pending(userId, mockApplyId)); + return analysisAsyncTaskRepository.saveAndFlush( + AnalysisAsyncTask.pending(userId, mockApplyId, maxRetryCount) + ); } @Transactional @@ -40,8 +48,8 @@ public Optional findActiveTask(Long userId, Long mockApplyId) } @Transactional - public void markRunning(String taskId) { - getTask(taskId).markRunning(); + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + getTask(taskId).markRunning(workerId, retryCount, submittedAt); } @Transactional @@ -50,8 +58,13 @@ public void markSuccess(String taskId) { } @Transactional - public void markFailed(String taskId, String errorMessage) { - getTask(taskId).markFailed(errorMessage); + public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + getTask(taskId).markRetryScheduled(failureReason, errorMessage, retryCount); + } + + @Transactional + public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + getTask(taskId).markFailed(failureReason, errorMessage, retryCount); } @Transactional @@ -81,25 +94,40 @@ public AnalysisAsyncStatusResponse getTaskStatus(Long userId, String taskId) { GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); + return toStatusResponse(task); + } + + @Transactional(readOnly = true) + public AnalysisAsyncStatusResponse getTaskStatusByTaskId(String taskId) { + return toStatusResponse(getTask(taskId)); + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task) { return AnalysisAsyncStatusResponse.builder() .taskId(task.getTaskId()) .mockApplyId(task.getMockApplyId()) .status(task.getStatus().name()) .message(task.getMessage()) .error(task.getError()) + .failureReason(task.getFailureReason() != null ? task.getFailureReason().name() : null) + .workerId(task.getWorkerId()) + .retryCount(task.getRetryCount()) + .maxRetryCount(task.getMaxRetryCount()) + .queueLatencyMillis(task.getQueueLatencyMillis()) .createdAt(task.getCreatedAt()) + .submittedAt(task.getSubmittedAt()) + .lastAttemptAt(task.getLastAttemptAt()) .startedAt(task.getStartedAt()) .completedAt(task.getCompletedAt()) .result(null) .build(); } - - private AnalysisAsyncTask getTask(String taskId) { - return analysisAsyncTaskRepository.findById(taskId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, - "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId - )); - } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java new file mode 100644 index 0000000..8f99d8a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -0,0 +1,31 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.MessageDeliveryMode; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AnalysisTaskMessagePublisher { + + private final RabbitTemplate rabbitTemplate; + + @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") + private String exchange; + + @Value("${app.worker.analysis.routing-key:analysis.execute}") + private String routingKey; + + public void publish(AnalysisTaskMessage message) { + rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + }); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java new file mode 100644 index 0000000..87743b2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -0,0 +1,129 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; +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.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AnalysisWorkerBridgeService { + + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisService analysisService; + private final UserService userService; + + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); + } + + public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + } + + @Transactional + public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); + } + + @Transactional(readOnly = true) + public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { + AnalysisAsyncTask task = getTask(taskId); + if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + User user = userService.getUser(userId); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); + + return new AnalysisWorkerContextResponse( + userId, + mockApplyId, + payload.jobPosting().getCompany().getName(), + payload.jobPosting().getDetailClassification().getDetailName(), + payload.jobPosting().getTask(), + payload.jobPosting().getRequirement(), + payload.jobPosting().getPreferred(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), + payload.jobPosting().getDetailClassification().getDetailName(), + toQuestionItems(payload.questions()) + ); + } + + @Transactional + public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED) { + return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); + } + if (task.getStatus() == TaskStatus.FAILED) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + + User user = userService.getUser(request.userId()); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); + AnalysisLlmResponse llmResponse = request.llmResponse(); + AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); + analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditConfirmed(taskId); + analysisAsyncTaskService.markSuccess(taskId); + return response; + } + + private List toQuestionItems(List questions) { + return questions.stream() + .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( + question.getId(), + question.getContent(), + question.getAnswer(), + question.getLimit() + )) + .toList(); + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + User user = userService.getUser(task.getUserId()); + analysisService.releaseAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditReleased(task.getTaskId()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java index fcf71aa..aad06c2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java @@ -17,7 +17,7 @@ public class RabbitMqConfig { @Bean public DirectExchange workerExchange( - @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") String exchangeName + @Value("${app.worker.exchange:jobdri.worker.exchange}") String exchangeName ) { return new DirectExchange(exchangeName, true, false); } @@ -49,6 +49,33 @@ public Binding jobPostingIngestBinding( return BindingBuilder.bind(jobPostingIngestQueue).to(workerExchange).with(routingKey); } + @Bean + public Queue analysisQueue( + @Value("${app.worker.analysis.queue:jobdri.analysis.execute}") String queueName, + @Value("${app.worker.analysis.dlq:jobdri.analysis.execute.dlq}") String deadLetterQueueName + ) { + return QueueBuilder.durable(queueName) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", deadLetterQueueName) + .build(); + } + + @Bean + public Queue analysisDeadLetterQueue( + @Value("${app.worker.analysis.dlq:jobdri.analysis.execute.dlq}") String deadLetterQueueName + ) { + return QueueBuilder.durable(deadLetterQueueName).build(); + } + + @Bean + public Binding analysisBinding( + @Qualifier("analysisQueue") Queue analysisQueue, + DirectExchange workerExchange, + @Value("${app.worker.analysis.routing-key:analysis.execute}") String routingKey + ) { + return BindingBuilder.bind(analysisQueue).to(workerExchange).with(routingKey); + } + @Bean public MessageConverter rabbitMessageConverter() { return new Jackson2JsonMessageConverter(); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index db107f7..37a134e 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -75,12 +75,19 @@ app: oauth2: redirect-uri: ${APP_OAUTH2_REDIRECT_URI:http://localhost:3000/oauth2/redirect} worker: + exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY:change-me-internal-worker-key} job-posting: - exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:jobdri.worker.exchange} + exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + analysis: + exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} + queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} + routing-key: ${APP_WORKER_ANALYSIS_ROUTING_KEY:analysis.execute} + dlq: ${APP_WORKER_ANALYSIS_DLQ:jobdri.analysis.execute.dlq} + max-retry-count: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:3} admin: bootstrap-emails: ${APP_ADMIN_BOOTSTRAP_EMAILS:} corpus: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index acd8a2b..852d6bd 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -75,12 +75,19 @@ app: oauth2: redirect-uri: ${APP_OAUTH2_REDIRECT_URI} worker: + exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY} job-posting: - exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:jobdri.worker.exchange} + exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + analysis: + exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} + queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} + routing-key: ${APP_WORKER_ANALYSIS_ROUTING_KEY:analysis.execute} + dlq: ${APP_WORKER_ANALYSIS_DLQ:jobdri.analysis.execute.dlq} + max-retry-count: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:3} admin: bootstrap-emails: ${APP_ADMIN_BOOTSTRAP_EMAILS:} corpus: 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 index 4050907..cd8a13b 100644 --- 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 @@ -47,7 +47,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { User user = User.signup("테스트 사용자", "analysis-async@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - AnalysisAsyncTask existingTask = AnalysisAsyncTask.pending(1L, 10L); + AnalysisAsyncTask existingTask = AnalysisAsyncTask.pending(1L, 10L, 3); when(userService.validateUser(user)).thenReturn(user); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.of(existingTask)); @@ -59,7 +59,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { 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()); + verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), anyString(), eq(3)); } @Test From 3b8789b3f0186d2ed9734d9e96073738e4c51d40 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:03:42 +0900 Subject: [PATCH 03/27] =?UTF-8?q?[Fix]=20MQ=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20analysis?= =?UTF-8?q?=20=ED=81=AC=EB=A0=88=EB=94=A7=20=EC=98=88=EC=95=BD=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=A1=B0=EC=A0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradle-8.14.4-bin.zip.lck | 0 .../gradle-8.14.4-bin.zip.part | 0 .../dto/worker/AnalysisTaskMessage.java | 3 - .../service/AnalysisAsyncFacadeService.java | 4 - .../service/AnalysisAsyncProcessor.java | 4 +- .../service/AnalysisTaskMessagePublisher.java | 26 ++-- .../service/AnalysisWorkerBridgeService.java | 26 +++- .../JobPostingTaskMessagePublisher.java | 26 ++-- .../mq/service/RabbitPublishSupport.java | 72 +++++++++++ src/main/resources/application-dev.yaml | 5 + src/main/resources/application-prod.yaml | 5 + .../AnalysisAsyncFacadeServiceTest.java | 23 +++- .../AnalysisWorkerBridgeServiceTest.java | 119 ++++++++++++++++++ 13 files changed, 282 insertions(+), 31 deletions(-) delete mode 100644 .gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck delete mode 100644 .gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part create mode 100644 src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java index 066a294..04d23bf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java @@ -12,7 +12,6 @@ public record AnalysisTaskMessage( String taskId, Long userId, Long mockApplyId, - String creditReferenceId, int retryCount, int maxRetryCount, Instant submittedAt @@ -22,7 +21,6 @@ public static AnalysisTaskMessage of( String taskId, Long userId, Long mockApplyId, - String creditReferenceId, int maxRetryCount ) { return AnalysisTaskMessage.builder() @@ -31,7 +29,6 @@ public static AnalysisTaskMessage of( .taskId(taskId) .userId(userId) .mockApplyId(mockApplyId) - .creditReferenceId(creditReferenceId) .retryCount(0) .maxRetryCount(Math.max(0, maxRetryCount)) .submittedAt(Instant.now()) 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 2a414cd..3fa23e0 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 @@ -70,16 +70,12 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp AnalysisAsyncTask task = pendingTaskResult.task(); String taskId = task.getTaskId(); - String creditReferenceId = "analysisTaskId=" + taskId; try { - analysisService.reserveAnalysisCredit(user, creditReferenceId); - analysisAsyncTaskService.markCreditReserved(taskId, creditReferenceId); analysisAsyncProcessor.process( taskId, user.getId(), mockApplyId, - creditReferenceId, task.getMaxRetryCount() ); return new AnalysisAsyncSubmitResponse( 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 61151f6..3b632c0 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 @@ -10,9 +10,9 @@ public class AnalysisAsyncProcessor { private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; - public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId, int maxRetryCount) { + public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { analysisTaskMessagePublisher.publish( - AnalysisTaskMessage.of(taskId, userId, mockApplyId, creditReferenceId, maxRetryCount) + AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java index 8f99d8a..89fb2f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -11,7 +11,7 @@ @RequiredArgsConstructor public class AnalysisTaskMessagePublisher { - private final RabbitTemplate rabbitTemplate; + private final RabbitPublishSupport rabbitPublishSupport; @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") private String exchange; @@ -20,12 +20,20 @@ public class AnalysisTaskMessagePublisher { private String routingKey; public void publish(AnalysisTaskMessage message) { - rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { - outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); - outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); - outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); - outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); - return outgoingMessage; - }); + rabbitPublishSupport.publish( + exchange, + routingKey, + message, + message.messageId(), + "자소서 분석 작업 메시지 발행에 실패했습니다.", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + } + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index 87743b2..b8af231 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -49,7 +49,7 @@ public void failTask(String taskId, FailureReason failureReason, String errorMes analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } - @Transactional(readOnly = true) + @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { AnalysisAsyncTask task = getTask(taskId); if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { @@ -58,6 +58,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." ); } + reserveCreditIfNeeded(task); User user = userService.getUser(userId); AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); @@ -93,8 +94,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); - analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditConfirmed(taskId); + confirmCreditIfNeeded(task, user); analysisAsyncTaskService.markSuccess(taskId); return response; } @@ -118,6 +118,26 @@ private AnalysisAsyncTask getTask(String taskId) { )); } + private void reserveCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.NONE) { + return; + } + + User user = userService.getUser(task.getUserId()); + String creditReferenceId = "analysisTaskId=" + task.getTaskId(); + analysisService.reserveAnalysisCredit(user, creditReferenceId); + analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); + } + + private void confirmCreditIfNeeded(AnalysisAsyncTask task, User user) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + + analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); + } + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java index 34432a0..0126f37 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; +import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -11,7 +11,7 @@ @RequiredArgsConstructor public class JobPostingTaskMessagePublisher { - private final RabbitTemplate rabbitTemplate; + private final RabbitPublishSupport rabbitPublishSupport; @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") private String exchange; @@ -20,12 +20,20 @@ public class JobPostingTaskMessagePublisher { private String routingKey; public void publish(JobPostingIngestTaskMessage message) { - rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { - outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); - outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); - outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); - outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); - return outgoingMessage; - }); + rabbitPublishSupport.publish( + exchange, + routingKey, + message, + message.messageId(), + "채용 공고 작업 메시지 발행에 실패했습니다.", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + } + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java new file mode 100644 index 0000000..2746f0c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java @@ -0,0 +1,72 @@ +package com.jobdri.jobdri_api.global.mq.service; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.amqp.core.ReturnedMessage; +import org.springframework.amqp.rabbit.connection.CorrelationData; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Component +@RequiredArgsConstructor +public class RabbitPublishSupport { + + private final RabbitTemplate rabbitTemplate; + + @Value("${app.worker.publish-confirm-timeout-millis:5000}") + private long publishConfirmTimeoutMillis; + + public void publish( + String exchange, + String routingKey, + Object payload, + String correlationId, + String failureMessage, + MessagePostProcessor messagePostProcessor + ) { + CorrelationData correlationData = new CorrelationData(correlationId); + rabbitTemplate.convertAndSend(exchange, routingKey, payload, messagePostProcessor, correlationData); + awaitPublisherConfirm(correlationData, failureMessage); + } + + private void awaitPublisherConfirm(CorrelationData correlationData, String failureMessage) { + try { + CorrelationData.Confirm confirm = correlationData.getFuture() + .get(publishConfirmTimeoutMillis, TimeUnit.MILLISECONDS); + + if (confirm == null || !confirm.isAck()) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + failureMessage + buildReasonSuffix(confirm != null ? confirm.getReason() : null) + ); + } + + ReturnedMessage returnedMessage = correlationData.getReturned(); + if (returnedMessage != null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + failureMessage + " routingKey=" + returnedMessage.getRoutingKey() + ); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + } catch (ExecutionException | TimeoutException e) { + throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + } + } + + private String buildReasonSuffix(String reason) { + if (reason == null || reason.isBlank()) { + return ""; + } + return " reason=" + reason; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 37a134e..8bf3075 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -30,6 +30,10 @@ spring: username: ${RABBITMQ_USERNAME:guest} password: ${RABBITMQ_PASSWORD:guest} virtual-host: ${RABBITMQ_VHOST:/} + publisher-confirm-type: correlated + publisher-returns: true + template: + mandatory: true mail: host: ${MAIL_HOST:smtp.gmail.com} port: ${MAIL_PORT:587} @@ -77,6 +81,7 @@ app: worker: exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY:change-me-internal-worker-key} + publish-confirm-timeout-millis: ${APP_WORKER_PUBLISH_CONFIRM_TIMEOUT_MILLIS:5000} job-posting: exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 852d6bd..53c869d 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -30,6 +30,10 @@ spring: username: ${RABBITMQ_USERNAME} password: ${RABBITMQ_PASSWORD} virtual-host: ${RABBITMQ_VHOST:/} + publisher-confirm-type: correlated + publisher-returns: true + template: + mandatory: true mail: host: ${MAIL_HOST} port: ${MAIL_PORT} @@ -77,6 +81,7 @@ app: worker: exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY} + publish-confirm-timeout-millis: ${APP_WORKER_PUBLISH_CONFIRM_TIMEOUT_MILLIS:5000} job-posting: exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} 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 index cd8a13b..04b519f 100644 --- 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 @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -59,7 +60,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { 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(), eq(3)); + verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), eq(3)); } @Test @@ -78,4 +79,24 @@ void submitPropagatesExceptionWhenTaskDisappearsAfterConflict() { assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) .isSameAs(exception); } + + @Test + @DisplayName("메시지 발행이 성공해도 submit 단계에서는 크레딧을 바로 예약하지 않는다") + void submitDoesNotReserveCreditBeforeWorkerStarts() { + User user = User.signup("테스트 사용자", "analysis-async-submit@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + AnalysisAsyncTask createdTask = AnalysisAsyncTask.pending(1L, 10L, 3); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenReturn(createdTask); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(createdTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); + verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java new file mode 100644 index 0000000..b9075a8 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -0,0 +1,119 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +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.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisWorkerBridgeServiceTest { + + @Mock + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisService analysisService; + + @Mock + private UserService userService; + + @InjectMocks + private AnalysisWorkerBridgeService analysisWorkerBridgeService; + + @Test + @DisplayName("worker가 컨텍스트를 조회할 때 처음 한 번만 크레딧을 예약한다") + void getContextReservesCreditBeforePreparingExecution() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-worker@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getTask()).thenReturn("백엔드 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring"); + when(jobPosting.getPreferred()).thenReturn("RabbitMQ"); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + + AnalysisExecutionPayload payload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); + + analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + + verify(analysisService).reserveAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); + verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); + verify(analysisService).prepareAnalysisExecution(user, 10L); + } + + @Test + @DisplayName("이미 예약된 작업은 컨텍스트 재조회 시 크레딧을 다시 예약하지 않는다") + void getContextDoesNotReserveCreditTwice() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); + User user = User.signup("테스트 사용자", "analysis-worker-repeat@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getTask()).thenReturn("백엔드 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring"); + when(jobPosting.getPreferred()).thenReturn("RabbitMQ"); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + + AnalysisExecutionPayload payload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); + + analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + + verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); + verify(analysisService).prepareAnalysisExecution(user, 10L); + } +} From b7385b3157a50fc66ab8c0617a915b02caccc64d Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:08:21 +0900 Subject: [PATCH 04/27] =?UTF-8?q?[Fix]=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=84=EB=8F=99=EA=B8=B0=20task=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260704_job_posting_async_tasks_user_id.sql | 5 ++ .../controller/JobPostingAiController.java | 2 +- .../JobPostingWorkerInternalController.java | 2 +- .../entity/JobPostingAsyncTask.java | 6 +- .../JobPostingAsyncTaskRepository.java | 3 + .../service/JobPostingAsyncFacadeService.java | 9 +- .../service/JobPostingAsyncTaskService.java | 24 ++++- .../JobPostingAsyncFacadeServiceTest.java | 89 +++++++++++++++++++ .../JobPostingAsyncTaskServiceTest.java | 78 ++++++++++++++++ 9 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql new file mode 100644 index 0000000..37d4942 --- /dev/null +++ b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS user_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_job_posting_async_tasks_user_id_created_at + ON job_posting_async_tasks (user_id, created_at DESC); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index b89683a..3e3fedb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -104,7 +104,7 @@ public ApiResponse getIngestJobPostingAsyncStatus validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( "채용 공고 비동기 작업 상태 조회에 성공했습니다.", - jobPostingAsyncFacadeService.getTask(taskId) + jobPostingAsyncFacadeService.getTask(userDetails.getUser(), taskId) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index 7f0f163..c8006c7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -125,7 +125,7 @@ public ApiResponse getTask( internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 상태 조회에 성공했습니다.", - jobPostingAsyncFacadeService.getTask(taskId) + jobPostingAsyncFacadeService.getTaskInternal(taskId) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index aaa834e..0bfd4fa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -25,6 +25,9 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "task_id", nullable = false, updatable = false, length = 36) private String taskId; + @Column(name = "user_id") + private Long userId; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private TaskStatus status; @@ -45,9 +48,10 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "result_payload") private String resultPayload; - public static JobPostingAsyncTask pending() { + public static JobPostingAsyncTask pending(Long userId) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); + task.userId = userId; task.status = TaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; return task; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java index d867989..4841c0c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java @@ -3,5 +3,8 @@ import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface JobPostingAsyncTaskRepository extends JpaRepository { + Optional findByTaskIdAndUserId(String taskId, Long userId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index 4ca8451..11a1b33 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -21,7 +21,7 @@ public class JobPostingAsyncFacadeService { public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); - String taskId = jobPostingAsyncTaskService.createPendingTask(); + String taskId = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); JobPostingIngestCommand command = snapshot(validatedUser, request); try { @@ -40,7 +40,12 @@ public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest r } } - public JobPostingAsyncStatusResponse getTask(String taskId) { + public JobPostingAsyncStatusResponse getTask(User user, String taskId) { + User validatedUser = userService.validateUser(user); + return jobPostingAsyncTaskService.getTask(validatedUser, taskId); + } + + public JobPostingAsyncStatusResponse getTaskInternal(String taskId) { return jobPostingAsyncTaskService.getTask(taskId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 6212c2a..459bafd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -5,6 +5,8 @@ import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,8 +23,8 @@ public class JobPostingAsyncTaskService { private final ObjectMapper objectMapper; @Transactional - public String createPendingTask() { - JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending()); + public String createPendingTask(Long userId) { + JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId)); return task.getTaskId(); } @@ -68,6 +70,24 @@ public void markFailed(String taskId, String errorMessage) { @Transactional(readOnly = true) public JobPostingAsyncStatusResponse getTask(String taskId) { JobPostingAsyncTask taskState = getTaskState(taskId); + return toStatusResponse(taskState); + } + + @Transactional(readOnly = true) + public JobPostingAsyncStatusResponse getTask(User user, String taskId) { + if (user.getRole() == UserRole.ADMIN) { + return getTask(taskId); + } + + JobPostingAsyncTask taskState = jobPostingAsyncTaskRepository.findByTaskIdAndUserId(taskId, user.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, + "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + return toStatusResponse(taskState); + } + + private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskState) { return JobPostingAsyncStatusResponse.builder() .taskId(taskState.getTaskId()) .status(taskState.getStatus().name()) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java new file mode 100644 index 0000000..74f9b8e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java @@ -0,0 +1,89 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +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.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingAsyncFacadeServiceTest { + + @Mock + private JobPostingAsyncTaskService jobPostingAsyncTaskService; + + @Mock + private JobPostingAsyncProcessor jobPostingAsyncProcessor; + + @Mock + private UserService userService; + + @InjectMocks + private JobPostingAsyncFacadeService jobPostingAsyncFacadeService; + + @Test + @DisplayName("채용공고 비동기 작업 생성 시 작업 소유자 userId를 함께 저장한다") + void submitCreatesTaskWithUserId() { + User user = User.signup("테스트 사용자", "job-posting-submit@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn("task-1"); + + JobPostingAsyncSubmitResponse response = jobPostingAsyncFacadeService.submit(user, request); + + assertThat(response.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).createPendingTask(7L); + verify(jobPostingAsyncProcessor).process(any(), any()); + } + + @Test + @DisplayName("일반 사용자 조회는 본인 소유 task만 조회한다") + void getTaskUsesValidatedUserScope() { + User user = User.signup("테스트 사용자", "job-posting-owner@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncStatusResponse response = JobPostingAsyncStatusResponse.builder() + .taskId("task-1") + .status("PENDING") + .message("진행 중") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.getTask(user, "task-1")).thenReturn(response); + + JobPostingAsyncStatusResponse result = jobPostingAsyncFacadeService.getTask(user, "task-1"); + + assertThat(result.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).getTask(user, "task-1"); + } + + @Test + @DisplayName("내부 조회는 사용자 검증 없이 taskId로 조회한다") + void getTaskInternalReadsByTaskId() { + JobPostingAsyncStatusResponse response = JobPostingAsyncStatusResponse.builder() + .taskId("task-1") + .status("SUCCEEDED") + .message("완료") + .build(); + + when(jobPostingAsyncTaskService.getTask("task-1")).thenReturn(response); + + JobPostingAsyncStatusResponse result = jobPostingAsyncFacadeService.getTaskInternal("task-1"); + + assertThat(result.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).getTask("task-1"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java new file mode 100644 index 0000000..6d2e034 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +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.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.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingAsyncTaskServiceTest { + + @Mock + private JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; + + private JobPostingAsyncTaskService jobPostingAsyncTaskService; + + @BeforeEach + void setUp() { + jobPostingAsyncTaskService = new JobPostingAsyncTaskService(jobPostingAsyncTaskRepository, new ObjectMapper()); + } + + @Test + @DisplayName("일반 사용자는 본인 소유 task만 조회할 수 있다") + void getTaskAllowsOwnerOnly() { + User user = User.signup("테스트 사용자", "job-posting-owner-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(user, task.getTaskId()); + + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); + verify(jobPostingAsyncTaskRepository).findByTaskIdAndUserId(task.getTaskId(), 7L); + } + + @Test + @DisplayName("일반 사용자는 다른 사람 task를 조회할 수 없다") + void getTaskRejectsNonOwner() { + User user = User.signup("테스트 사용자", "job-posting-other-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId("task-1", 7L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jobPostingAsyncTaskService.getTask(user, "task-1")) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("관리자는 소유자와 관계없이 모든 task를 조회할 수 있다") + void getTaskAllowsAdmin() { + User admin = User.signup("관리자", "admin-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(admin, "id", 99L); + admin.promoteToAdmin(); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(admin, task.getTaskId()); + + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); + verify(jobPostingAsyncTaskRepository).findById(task.getTaskId()); + } +} From 18b5eb925540f186e647fc96f3826be24edad429 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:47:16 +0900 Subject: [PATCH 05/27] =?UTF-8?q?[Fix]=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=84=EB=8F=99=EA=B8=B0=20retry/timeout=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B0=8F=20MQ=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ob_posting_async_tasks_worker_metadata.sql | 20 +++ .../JobPostingWorkerInternalController.java | 20 ++- .../JobPostingAsyncStatusResponse.java | 7 ++ .../worker/JobPostingIngestTaskMessage.java | 4 +- .../JobPostingWorkerFailureRequest.java | 7 +- .../worker/JobPostingWorkerRetryRequest.java | 13 ++ .../JobPostingWorkerRunningRequest.java | 13 ++ .../entity/JobPostingAsyncTask.java | 64 +++++++++- .../service/JobPostingAsyncFacadeService.java | 6 +- .../service/JobPostingAsyncProcessor.java | 4 +- .../service/JobPostingAsyncTaskService.java | 84 +++++++++++-- .../JobPostingWorkerBridgeService.java | 14 ++- .../domain/payment/entity/Payment.java | 5 + .../payment/service/PaymentService.java | 11 ++ .../service/PaymentTransactionService.java | 11 ++ .../payment/service/TossPaymentClient.java | 8 ++ src/main/resources/application-dev.yaml | 3 + src/main/resources/application-prod.yaml | 3 + .../JobPostingAsyncFacadeServiceTest.java | 36 +++++- .../JobPostingAsyncTaskServiceTest.java | 37 +++++- .../payment/service/PaymentServiceTest.java | 37 ++++++ .../RabbitPublishSupportIntegrationTest.java | 115 ++++++++++++++++++ 22 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql new file mode 100644 index 0000000..d17e0e1 --- /dev/null +++ b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql @@ -0,0 +1,20 @@ +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40); + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100); + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index c8006c7..a5cfa8a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -8,6 +8,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerContextResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFailureRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFinalizeRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerRunningRequest; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingWorkerBridgeService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; @@ -38,13 +40,25 @@ public class JobPostingWorkerInternalController { @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, - @PathVariable String taskId + @PathVariable String taskId, + @Valid @RequestBody JobPostingWorkerRunningRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.markRunning(taskId); + jobPostingWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("채용 공고 worker 작업 시작 상태를 반영했습니다."); } + @PostMapping("/tasks/{taskId}/retry") + public ApiResponse markRetry( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody JobPostingWorkerRetryRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + jobPostingWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("채용 공고 worker 작업 재시도 상태를 반영했습니다."); + } + @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @@ -65,7 +79,7 @@ public ApiResponse failTask( @Valid @RequestBody JobPostingWorkerFailureRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.failTask(taskId, request.errorMessage()); + jobPostingWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); return ApiResponse.onSuccess("채용 공고 worker 작업 실패 상태를 반영했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java index 8e3d286..9c20cc0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java @@ -15,7 +15,14 @@ public class JobPostingAsyncStatusResponse { private String status; private String message; private String error; + private String failureReason; + private String workerId; + private Integer retryCount; + private Integer maxRetryCount; + private Long queueLatencyMillis; private LocalDateTime createdAt; + private LocalDateTime submittedAt; + private LocalDateTime lastAttemptAt; private LocalDateTime startedAt; private LocalDateTime completedAt; private JobPostingIngestResponse result; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java index 3d2d8cb..00faa8a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java @@ -15,10 +15,11 @@ public record JobPostingIngestTaskMessage( String rawText, String imageObjectKey, int retryCount, + int maxRetryCount, Instant submittedAt ) { - public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestCommand command) { + public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestCommand command, int maxRetryCount) { return JobPostingIngestTaskMessage.builder() .messageId(UUID.randomUUID().toString()) .taskType("JOB_POSTING_INGEST") @@ -27,6 +28,7 @@ public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestComm .rawText(command.getRawText()) .imageObjectKey(command.getImageObjectKey()) .retryCount(0) + .maxRetryCount(Math.max(0, maxRetryCount)) .submittedAt(Instant.now()) .build(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java index f66a287..3305fef 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java @@ -1,8 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record JobPostingWorkerFailureRequest( - @NotBlank String errorMessage + @NotNull FailureReason failureReason, + @NotBlank String errorMessage, + @Min(0) int retryCount ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java new file mode 100644 index 0000000..345b49f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.worker; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record JobPostingWorkerRetryRequest( + @NotNull FailureReason failureReason, + @NotBlank String errorMessage, + @Min(0) int retryCount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java new file mode 100644 index 0000000..dbeebb0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.worker; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; + +public record JobPostingWorkerRunningRequest( + @NotBlank String workerId, + @Min(0) int retryCount, + Instant submittedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index 0bfd4fa..a5d811e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -12,6 +12,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -38,6 +40,28 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(length = 2000) private String error; + @Enumerated(EnumType.STRING) + @Column(name = "failure_reason", length = 40) + private FailureReason failureReason; + + @Column(name = "worker_id", length = 100) + private String workerId; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "max_retry_count", nullable = false) + private int maxRetryCount; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + @Column(name = "last_attempt_at") + private LocalDateTime lastAttemptAt; + + @Column(name = "queue_latency_millis") + private Long queueLatencyMillis; + @Column(name = "started_at") private LocalDateTime startedAt; @@ -48,36 +72,68 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "result_payload") private String resultPayload; - public static JobPostingAsyncTask pending(Long userId) { + public static JobPostingAsyncTask pending(Long userId, int maxRetryCount) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); task.userId = userId; task.status = TaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; + task.retryCount = 0; + task.maxRetryCount = Math.max(0, maxRetryCount); + task.submittedAt = LocalDateTime.now(); return task; } - public void markRunning() { + public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { this.status = TaskStatus.RUNNING; this.message = "채용 공고 비동기 처리를 진행 중입니다."; - this.startedAt = LocalDateTime.now(); + this.failureReason = null; + this.error = null; + this.workerId = workerId; + this.retryCount = Math.max(0, retryCount); + this.lastAttemptAt = LocalDateTime.now(); + this.startedAt = this.lastAttemptAt; + if (messageSubmittedAt != null) { + this.queueLatencyMillis = Math.max(0L, Duration.between(messageSubmittedAt, Instant.now()).toMillis()); + } } public void markSuccess(String resultPayload) { this.status = TaskStatus.SUCCEEDED; this.message = "채용 공고 비동기 처리에 성공했습니다."; this.error = null; + this.failureReason = null; this.resultPayload = resultPayload; this.completedAt = LocalDateTime.now(); } - public void markFailed(String errorMessage) { + public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + this.status = TaskStatus.PENDING; + this.message = "채용 공고 비동기 재시도를 대기 중입니다."; + this.failureReason = failureReason; + this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); + this.completedAt = null; + } + + public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { this.status = TaskStatus.FAILED; this.message = "채용 공고 비동기 처리에 실패했습니다."; + this.failureReason = failureReason; this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); } + public enum FailureReason { + RATE_LIMIT, + QUEUE_TIMEOUT, + WORKER_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR + } + public enum TaskStatus { PENDING, RUNNING, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index 11a1b33..cc134a3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -4,6 +4,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; 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; @@ -21,11 +22,12 @@ public class JobPostingAsyncFacadeService { public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); - String taskId = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); + JobPostingAsyncTask task = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); + String taskId = task.getTaskId(); JobPostingIngestCommand command = snapshot(validatedUser, request); try { - jobPostingAsyncProcessor.process(taskId, command); + jobPostingAsyncProcessor.process(taskId, command, task.getMaxRetryCount()); return new JobPostingAsyncSubmitResponse( taskId, "PENDING", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java index 7cff597..6c529a2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java @@ -11,7 +11,7 @@ public class JobPostingAsyncProcessor { private final JobPostingTaskMessagePublisher jobPostingTaskMessagePublisher; - public void process(String taskId, JobPostingIngestCommand command) { - jobPostingTaskMessagePublisher.publish(JobPostingIngestTaskMessage.of(taskId, command)); + public void process(String taskId, JobPostingIngestCommand command, int maxRetryCount) { + jobPostingTaskMessagePublisher.publish(JobPostingIngestTaskMessage.of(taskId, command, maxRetryCount)); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 459bafd..4a4fd79 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -3,6 +3,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -12,9 +13,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class JobPostingAsyncTaskService { @@ -22,10 +28,18 @@ public class JobPostingAsyncTaskService { private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; private final ObjectMapper objectMapper; + @Value("${app.worker.job-posting.max-retry-count:3}") + private int maxRetryCount; + + @Value("${app.worker.job-posting.queue-timeout-minutes:10}") + private long queueTimeoutMinutes; + + @Value("${app.worker.job-posting.processing-timeout-minutes:20}") + private long processingTimeoutMinutes; + @Transactional - public String createPendingTask(Long userId) { - JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId)); - return task.getTaskId(); + public JobPostingAsyncTask createPendingTask(Long userId) { + return jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId, maxRetryCount)); } @Transactional @@ -34,12 +48,12 @@ public void deleteTask(String taskId) { } @Transactional - public void markRunning(String taskId) { + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; } - task.markRunning(); + task.markRunning(workerId, retryCount, submittedAt); } @Transactional @@ -59,21 +73,31 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } @Transactional - public void markFailed(String taskId, String errorMessage) { + public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + JobPostingAsyncTask task = getTaskState(taskId); + if (isTerminal(task)) { + return; + } + task.markRetryScheduled(failureReason, errorMessage, retryCount); + } + + @Transactional + public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (task.getStatus() == TaskStatus.SUCCEEDED) { return; } - task.markFailed(errorMessage); + task.markFailed(failureReason, errorMessage, retryCount); } - @Transactional(readOnly = true) + @Transactional public JobPostingAsyncStatusResponse getTask(String taskId) { JobPostingAsyncTask taskState = getTaskState(taskId); + expireTimedOutTaskIfNeeded(taskState); return toStatusResponse(taskState); } - @Transactional(readOnly = true) + @Transactional public JobPostingAsyncStatusResponse getTask(User user, String taskId) { if (user.getRole() == UserRole.ADMIN) { return getTask(taskId); @@ -84,6 +108,7 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); + expireTimedOutTaskIfNeeded(taskState); return toStatusResponse(taskState); } @@ -93,7 +118,14 @@ private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskS .status(taskState.getStatus().name()) .message(taskState.getMessage()) .error(taskState.getError()) + .failureReason(taskState.getFailureReason() != null ? taskState.getFailureReason().name() : null) + .workerId(taskState.getWorkerId()) + .retryCount(taskState.getRetryCount()) + .maxRetryCount(taskState.getMaxRetryCount()) + .queueLatencyMillis(taskState.getQueueLatencyMillis()) .createdAt(taskState.getCreatedAt()) + .submittedAt(taskState.getSubmittedAt()) + .lastAttemptAt(taskState.getLastAttemptAt()) .startedAt(taskState.getStartedAt()) .completedAt(taskState.getCompletedAt()) .result(deserializeResult(taskState.getResultPayload())) @@ -112,6 +144,40 @@ private boolean isTerminal(JobPostingAsyncTask task) { return task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED; } + private void expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { + if (isTerminal(task)) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + if (task.getStatus() == TaskStatus.PENDING + && isExpired(task.getSubmittedAt(), now, queueTimeoutMinutes)) { + task.markFailed( + FailureReason.QUEUE_TIMEOUT, + "채용 공고 작업이 대기열에서 시간 내 처리되지 않았습니다.", + task.getRetryCount() + ); + return; + } + + LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); + if (task.getStatus() == TaskStatus.RUNNING + && isExpired(lastActivityAt, now, processingTimeoutMinutes)) { + task.markFailed( + FailureReason.WORKER_TIMEOUT, + "채용 공고 작업이 처리 제한 시간을 초과했습니다.", + task.getRetryCount() + ); + } + } + + private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutMinutes) { + if (baseTime == null || timeoutMinutes <= 0) { + return false; + } + return Duration.between(baseTime, now).toMinutes() >= timeoutMinutes; + } + private String serializeResult(JobPostingIngestResponse result) { try { return objectMapper.writeValueAsString(result); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 38fee88..768bf97 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -6,6 +6,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -15,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; @Service @@ -28,8 +30,8 @@ public class JobPostingWorkerBridgeService { private final JobPostingService jobPostingService; private final UserService userService; - public void markRunning(String taskId) { - jobPostingAsyncTaskService.markRunning(taskId); + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + jobPostingAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); } @Transactional @@ -37,8 +39,12 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp return jobPostingAsyncTaskService.markSuccess(taskId, result); } - public void failTask(String taskId, String errorMessage) { - jobPostingAsyncTaskService.markFailed(taskId, errorMessage); + public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + jobPostingAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + } + + public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + jobPostingAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } public String createReadableImageUrl(Long userId, String imageObjectKey) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 962c4ce..62b99f7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -81,6 +81,11 @@ public void fail() { this.status = PaymentStatus.FAILED; } + public void resetToPending() { + this.paymentKey = null; + this.status = PaymentStatus.PENDING; + } + public boolean belongsTo(Long userId) { return user != null && user.getId() != null && user.getId().equals(userId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 7614133..47de7c1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -81,6 +81,17 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) TossPaymentConfirmResponse tossResponse = tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount()); validateTossResponse(request, tossResponse); + } catch (GeneralException e) { + if (e.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { + paymentTransactionService.rollbackConfirmationToPending( + validatedUser.getId(), + request.orderId(), + request.paymentKey() + ); + throw e; + } + paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); + throw e; } catch (RuntimeException e) { paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); throw e; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index 3881a35..4ed0e4c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -76,6 +76,17 @@ public void failConfirmation(Long userId, String orderId, String paymentKey) { } } + @Transactional + public void rollbackConfirmationToPending(Long userId, String orderId, String paymentKey) { + Payment payment = getOwnedPaymentForUpdate(userId, orderId); + if (!payment.hasPaymentKey(paymentKey)) { + return; + } + if (payment.getStatus() == PaymentStatus.PROCESSING) { + payment.resetToPending(); + } + } + private Payment getOwnedPaymentForUpdate(Long userId, String orderId) { Payment payment = paymentRepository.findByOrderIdForUpdate(orderId) .orElseThrow(() -> new GeneralException( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index 2e9c1a8..fbf2584 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -13,6 +13,7 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; @@ -74,6 +75,13 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스페이먼츠 결제 승인 실패" ); + } catch (ResourceAccessException e) { + log.warn("Toss payment confirm request timed out. message={}", truncate(e.getMessage())); + log.warn("Toss payment confirm timeout exception", e); + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "토스페이먼츠 결제 승인 응답이 지연되고 있습니다." + ); } catch (RestClientException e) { log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage())); log.warn("Toss payment confirm request exception", e); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 8bf3075..a75cffd 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -87,6 +87,9 @@ app: queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + max-retry-count: ${APP_WORKER_JOB_POSTING_MAX_RETRY_COUNT:3} + queue-timeout-minutes: ${APP_WORKER_JOB_POSTING_QUEUE_TIMEOUT_MINUTES:10} + processing-timeout-minutes: ${APP_WORKER_JOB_POSTING_PROCESSING_TIMEOUT_MINUTES:20} analysis: exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 53c869d..4276989 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -87,6 +87,9 @@ app: queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + max-retry-count: ${APP_WORKER_JOB_POSTING_MAX_RETRY_COUNT:3} + queue-timeout-minutes: ${APP_WORKER_JOB_POSTING_QUEUE_TIMEOUT_MINUTES:10} + processing-timeout-minutes: ${APP_WORKER_JOB_POSTING_PROCESSING_TIMEOUT_MINUTES:20} analysis: exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java index 74f9b8e..63e3f84 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java @@ -3,8 +3,11 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,7 +17,11 @@ import org.springframework.test.util.ReflectionTestUtils; 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.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,15 +46,16 @@ void submitCreatesTaskWithUserId() { User user = User.signup("테스트 사용자", "job-posting-submit@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(userService.validateUser(user)).thenReturn(user); - when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn("task-1"); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn(task); JobPostingAsyncSubmitResponse response = jobPostingAsyncFacadeService.submit(user, request); - assertThat(response.getTaskId()).isEqualTo("task-1"); + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); verify(jobPostingAsyncTaskService).createPendingTask(7L); - verify(jobPostingAsyncProcessor).process(any(), any()); + verify(jobPostingAsyncProcessor).process(eq(task.getTaskId()), any(), eq(3)); } @Test @@ -86,4 +94,26 @@ void getTaskInternalReadsByTaskId() { assertThat(result.getTaskId()).isEqualTo("task-1"); verify(jobPostingAsyncTaskService).getTask("task-1"); } + + @Test + @DisplayName("메시지 발행 실패 시 생성했던 task를 삭제하고 접수 실패를 반환한다") + void submitDeletesTaskWhenPublishFails() { + User user = User.signup("테스트 사용자", "job-posting-submit-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn(task); + doThrow(new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, "publish failed")) + .when(jobPostingAsyncProcessor) + .process(eq(task.getTaskId()), any(), eq(3)); + + assertThatThrownBy(() -> jobPostingAsyncFacadeService.submit(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE); + + verify(jobPostingAsyncTaskService, times(1)).deleteTask(task.getTaskId()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index 6d2e034..844754d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -38,7 +40,7 @@ void setUp() { void getTaskAllowsOwnerOnly() { User user = User.signup("테스트 사용자", "job-posting-owner-task@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); - JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); @@ -66,7 +68,7 @@ void getTaskAllowsAdmin() { User admin = User.signup("관리자", "admin-task@example.com", "encoded-password"); ReflectionTestUtils.setField(admin, "id", 99L); admin.promoteToAdmin(); - JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); @@ -75,4 +77,35 @@ void getTaskAllowsAdmin() { assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); verify(jobPostingAsyncTaskRepository).findById(task.getTaskId()); } + + @Test + @DisplayName("대기 시간이 임계치를 넘은 task는 QUEUE_TIMEOUT으로 실패 처리한다") + void getTaskMarksPendingTimeout() { + ReflectionTestUtils.setField(jobPostingAsyncTaskService, "queueTimeoutMinutes", 1L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + ReflectionTestUtils.setField(task, "submittedAt", java.time.LocalDateTime.now().minusMinutes(2)); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); + + assertThat(response.getStatus()).isEqualTo("FAILED"); + assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + } + + @Test + @DisplayName("실행 중 시간이 임계치를 넘은 task는 WORKER_TIMEOUT으로 실패 처리한다") + void getTaskMarksRunningTimeout() { + ReflectionTestUtils.setField(jobPostingAsyncTaskService, "processingTimeoutMinutes", 1L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markRunning("worker-1", 1, java.time.Instant.now().minusSeconds(120)); + ReflectionTestUtils.setField(task, "lastAttemptAt", java.time.LocalDateTime.now().minusMinutes(2)); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); + + assertThat(response.getStatus()).isEqualTo("FAILED"); + assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 37d489f..bb0e9fa 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -183,6 +183,43 @@ void confirmMarksPaymentAsFailedWhenTossConfirmFails() { )).isEmpty(); } + @Test + @DisplayName("토스 승인 타임아웃이면 결제를 PENDING으로 되돌리고 재시도를 허용한다") + void confirmAllowsRetryWhenTossConfirmTimesOut() { + User user = saveUser("payment-confirm-timeout@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenThrow(new GeneralException(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, "timeout")) + .thenReturn(new TossPaymentConfirmResponse( + paymentKey, + prepared.orderId(), + prepared.orderName(), + "DONE", + 2500, + "CARD" + )); + + assertThatThrownBy(() -> paymentService.confirm(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT); + + Payment timedOutPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(timedOutPayment.getStatus()).isEqualTo(PaymentStatus.PENDING); + + PaymentConfirmResponse retryResponse = paymentService.confirm(user, request); + + assertThat(retryResponse.status()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + @Test @DisplayName("동일 결제 승인 요청이 동시에 들어와도 한 번만 크레딧을 충전한다") void confirmConcurrentlyChargesOnlyOnce() throws Exception { diff --git a/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java new file mode 100644 index 0000000..ed55bdf --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java @@ -0,0 +1,115 @@ +package com.jobdri.jobdri_api.global.mq.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageDeliveryMode; +import org.springframework.amqp.rabbit.core.RabbitAdmin; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.rabbitmq.host=localhost", + "spring.rabbitmq.port=5672", + "spring.rabbitmq.username=guest", + "spring.rabbitmq.password=guest", + "spring.rabbitmq.publisher-confirm-type=correlated", + "spring.rabbitmq.publisher-returns=true", + "spring.rabbitmq.template.mandatory=true" +}) +class RabbitPublishSupportIntegrationTest { + + @Autowired + private RabbitPublishSupport rabbitPublishSupport; + + @Autowired + private RabbitTemplate rabbitTemplate; + + @Autowired + private RabbitAdmin rabbitAdmin; + + @Autowired + private DirectExchange workerExchange; + + @Value("${app.worker.job-posting.queue:jobdri.job-posting.ingest}") + private String queueName; + + @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") + private String routingKey; + + @BeforeEach + void purgeQueue() { + rabbitAdmin.purgeQueue(queueName, true); + } + + @Test + @DisplayName("RabbitMQ publisher confirm이 오면 메시지가 큐에 적재된다") + void publishStoresMessageInQueue() { + JobPostingIngestTaskMessage message = JobPostingIngestTaskMessage.of( + "task-1", + JobPostingIngestCommand.builder() + .userId(1L) + .rawText("채용 공고") + .build(), + 3 + ); + + rabbitPublishSupport.publish( + workerExchange.getName(), + routingKey, + message, + message.messageId(), + "메시지 발행 실패", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + return outgoingMessage; + } + ); + + Message queuedMessage = rabbitTemplate.receive(queueName, 3000); + + assertThat(queuedMessage).isNotNull(); + assertThat(queuedMessage.getMessageProperties().getMessageId()).isEqualTo(message.messageId()); + } + + @Test + @DisplayName("routing key가 잘못되면 publisher return을 감지해 예외를 던진다") + void publishThrowsWhenRouteIsInvalid() { + JobPostingIngestTaskMessage message = JobPostingIngestTaskMessage.of( + "task-2", + JobPostingIngestCommand.builder() + .userId(1L) + .rawText("채용 공고") + .build(), + 3 + ); + + assertThatThrownBy(() -> rabbitPublishSupport.publish( + workerExchange.getName(), + "job-posting.ingest.invalid", + message, + message.messageId(), + "메시지 발행 실패", + outgoingMessage -> outgoingMessage + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE); + } +} From ba9788fd5470a66ec9debf648915410eaa365d02 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:57:56 +0900 Subject: [PATCH 06/27] =?UTF-8?q?[Fix]=20analysis=20async=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=EC=9D=B4=EC=99=80=20worker=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisWorkerInternalController.java | 18 +++++++- .../worker/AnalysisWorkerCompleteRequest.java | 3 +- .../worker/AnalysisWorkerFailureRequest.java | 3 +- .../worker/AnalysisWorkerRetryRequest.java | 3 +- .../analysis/entity/AnalysisAsyncTask.java | 26 +++++++++++ .../service/AnalysisAsyncTaskService.java | 5 +++ .../service/AnalysisQueueProperties.java | 10 +++++ .../service/AnalysisTaskMessagePublisher.java | 14 +++--- .../service/AnalysisWorkerBridgeService.java | 35 ++++++++++++++- .../service/JobPostingQueueProperties.java | 10 +++++ .../JobPostingTaskMessagePublisher.java | 14 +++--- .../AnalysisAsyncFacadeServiceTest.java | 14 ++++++ .../AnalysisWorkerBridgeServiceTest.java | 45 +++++++++++++++++++ 13 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java index 0b33a7b..c5f9452 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -51,7 +51,14 @@ public ApiResponse markRetry( @Valid @RequestBody AnalysisWorkerRetryRequest request ) { internalApiKeyValidator.validate(internalApiKey); - analysisWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + analysisWorkerBridgeService.markRetry( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("자소서 분석 worker 작업 재시도 상태를 반영했습니다."); } @@ -62,7 +69,14 @@ public ApiResponse failTask( @Valid @RequestBody AnalysisWorkerFailureRequest request ) { internalApiKeyValidator.validate(internalApiKey); - analysisWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + analysisWorkerBridgeService.failTask( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("자소서 분석 worker 작업 실패 상태를 반영했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java index 5f3fb7c..32eb1fd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerCompleteRequest( @NotNull Long mockApplyId, @Valid @NotNull AnalysisLlmResponse llmResponse, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java index 86b3433..1c9d902 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerFailureRequest( @NotNull FailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java index 2ea31a0..b8448db 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerRetryRequest( @NotNull FailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } 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 2f5c0fa..513ce37 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 @@ -105,6 +105,9 @@ public void markCreditReleased() { } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { + if (isTerminal()) { + return; + } this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; this.failureReason = null; @@ -127,6 +130,13 @@ public void markSuccess() { } public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + if (isTerminal()) { + return; + } + if (retryCount >= maxRetryCount) { + markFailed(failureReason, errorMessage, retryCount); + return; + } this.status = TaskStatus.PENDING; this.message = "자소서 분석 재시도를 대기 중입니다."; this.failureReason = failureReason; @@ -136,6 +146,9 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + if (status == TaskStatus.SUCCEEDED) { + return; + } this.status = TaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; this.failureReason = failureReason; @@ -144,6 +157,19 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret this.completedAt = LocalDateTime.now(); } + public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { + if (workerId != null && !workerId.isBlank()) { + this.workerId = workerId; + } + if (queueLatencyMillis != null) { + this.queueLatencyMillis = Math.max(0L, queueLatencyMillis); + } + } + + private boolean isTerminal() { + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + } + public enum TaskStatus { PENDING, RUNNING, 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 8459225..816cf5d 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 @@ -67,6 +67,11 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM getTask(taskId).markFailed(failureReason, errorMessage, retryCount); } + @Transactional + public void updateWorkerMetadata(String taskId, String workerId, Long queueLatencyMillis) { + getTask(taskId).updateWorkerMetadata(workerId, queueLatencyMillis); + } + @Transactional public void markCreditReserved(String taskId, String creditReferenceId) { getTask(taskId).markCreditReserved(creditReferenceId); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java new file mode 100644 index 0000000..6c155eb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public record AnalysisQueueProperties( + @Value("${app.worker.analysis.routing-key:analysis.execute}") String routingKey +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java index 89fb2f0..6a8e781 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -4,7 +4,7 @@ import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.amqp.core.DirectExchange; import org.springframework.stereotype.Service; @Service @@ -12,17 +12,13 @@ public class AnalysisTaskMessagePublisher { private final RabbitPublishSupport rabbitPublishSupport; - - @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") - private String exchange; - - @Value("${app.worker.analysis.routing-key:analysis.execute}") - private String routingKey; + private final DirectExchange workerExchange; + private final AnalysisQueueProperties analysisQueueProperties; public void publish(AnalysisTaskMessage message) { rabbitPublishSupport.publish( - exchange, - routingKey, + workerExchange.getName(), + analysisQueueProperties.routingKey(), message, message.messageId(), "자소서 분석 작업 메시지 발행에 실패했습니다.", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index b8af231..1d28b78 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -31,20 +31,44 @@ public class AnalysisWorkerBridgeService { private final UserService userService; public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); } - public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetry( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); } @Transactional - public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void failTask( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { AnalysisAsyncTask task = getTask(taskId); if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { return; } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); releaseCreditIfNeeded(task); analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } @@ -80,6 +104,12 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long @Transactional public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { AnalysisAsyncTask task = getTask(taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } if (task.getStatus() == TaskStatus.SUCCEEDED) { return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); } @@ -94,6 +124,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); + analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); confirmCreditIfNeeded(task, user); analysisAsyncTaskService.markSuccess(taskId); return response; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java new file mode 100644 index 0000000..967e8f2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public record JobPostingQueueProperties( + @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") String routingKey +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java index 0126f37..1d5338d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java @@ -3,8 +3,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.DirectExchange; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service @@ -12,17 +12,13 @@ public class JobPostingTaskMessagePublisher { private final RabbitPublishSupport rabbitPublishSupport; - - @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") - private String exchange; - - @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") - private String routingKey; + private final DirectExchange workerExchange; + private final JobPostingQueueProperties jobPostingQueueProperties; public void publish(JobPostingIngestTaskMessage message) { rabbitPublishSupport.publish( - exchange, - routingKey, + workerExchange.getName(), + jobPostingQueueProperties.routingKey(), message, message.messageId(), "채용 공고 작업 메시지 발행에 실패했습니다.", 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 index 04b519f..443b93d 100644 --- 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 @@ -2,6 +2,7 @@ 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.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import org.junit.jupiter.api.DisplayName; @@ -99,4 +100,17 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); } + + @Test + @DisplayName("재시도 횟수가 maxRetryCount에 도달하면 task를 FAILED로 전환한다") + void retryAtLimitMarksTaskFailed() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-1", 1); + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-2", 2); + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-3", 3); + + assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTask.TaskStatus.FAILED); + assertThat(task.getRetryCount()).isEqualTo(3); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java index b9075a8..84b6857 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; 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.exception.GeneralException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +20,8 @@ import java.util.List; import java.util.Optional; +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.mock; @@ -116,4 +121,44 @@ void getContextDoesNotReserveCreditTwice() { verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } + + @Test + @DisplayName("완료된 작업은 running이나 retry로 되돌리지 않는다") + void terminalTaskDoesNotReopen() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.markSuccess(); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + analysisWorkerBridgeService.markRunning(task.getTaskId(), "worker-1", 1, java.time.Instant.now()); + analysisWorkerBridgeService.markRetry( + task.getTaskId(), + FailureReason.INTERNAL_ERROR, + "retry", + 1, + "worker-1", + 10L + ); + + verify(analysisAsyncTaskService, never()).markRunning(eq(task.getTaskId()), eq("worker-1"), eq(1), any()); + verify(analysisAsyncTaskService, never()).markRetryScheduled(eq(task.getTaskId()), eq(FailureReason.INTERNAL_ERROR), eq("retry"), eq(1)); + } + + @Test + @DisplayName("완료 요청의 사용자나 mockApply가 task와 다르면 거부한다") + void completeTaskRejectsMismatchedIdentity() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 2L, + 11L, + mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class), + "worker-1", + 10L + ); + + assertThatThrownBy(() -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request)) + .isInstanceOf(GeneralException.class); + } } From e01fa8003ee251aacaac6ef6db49ab6634f18372 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 6 Jul 2026 18:53:08 +0900 Subject: [PATCH 07/27] =?UTF-8?q?[Feat]=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EB=B6=84=EC=84=9D=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=97=AD=EB=9F=89=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자기소개서 분석 점수 가중치를 jobFit 50%, impact 30%, completeness 20%로 변경 - 분석 응답에 missingKeywords 필드 추가 - missingKeyword source enum 추가 - LLM missingKeywords 응답 검증 로직 추가 - 검증된 missingKeywords를 analyses.missing_keywords 컬럼에 JSON 배열 문자열로 저장 - POST 분석 응답과 GET 조회 응답 모두 저장된 missingKeywords 기준으로 반환 - 기존 GET 조회 시 JD/답변 기반 missingKeywords 재계산 로직 제거 - null, blank keyword, invalid source, 중복 keyword, 60자 초과 keyword 필터링 유지 - source 응답 값은 qualification, preference, mainTask로 유지 - JSON 직렬화/역직렬화 실패 시 [] fallback 및 warn 로그 처리 - missing status는 기존처럼 questionAnalysis에 저장하지 않도록 유지 - schema.sql에 analyses.missing_keywords 컬럼 추가 SQL 반영 - missingKeywords 검증, 저장, 조회 일관성, DB round-trip, malformed JSON fallback 테스트 보완 - 점수 계산 테스트 보완 --- .../analysis/dto/llm/AnalysisLlmResponse.java | 17 ++ .../dto/response/AnalysisResponse.java | 3 + .../dto/response/MissingKeywordResponse.java | 7 + .../dto/response/MissingKeywordSource.java | 38 +++++ .../domain/analysis/entity/Analysis.java | 17 ++ .../analysis/service/AnalysisAiClient.java | 25 ++- .../analysis/service/AnalysisService.java | 147 ++++++++++++++++- src/main/resources/schema.sql | 3 + .../analysis/service/AnalysisServiceTest.java | 153 +++++++++++++++++- 9 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java index f2fbbbd..04d27a1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -7,8 +7,25 @@ public record AnalysisLlmResponse( Integer impact, Integer completeness, String feedback, + List missingKeywords, List questionAnalyses ) { + public AnalysisLlmResponse( + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses + ) { + this(jobFit, impact, completeness, feedback, List.of(), questionAnalyses); + } + + public record MissingKeywordItem( + String keyword, + String source + ) { + } + public record QuestionAnalysisItem( Long questionId, String sentence, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java index 9d841fb..7f9cdad 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java @@ -15,12 +15,14 @@ public record AnalysisResponse( int impact, int completeness, String feedback, + List missingKeywords, List questions ) { public static AnalysisResponse of( Analysis analysis, MockApplyStatus status, int sequence, + List missingKeywords, List questions ) { return new AnalysisResponse( @@ -33,6 +35,7 @@ public static AnalysisResponse of( analysis.getImpact(), analysis.getCompleteness(), analysis.getFeedback(), + missingKeywords == null ? List.of() : missingKeywords, questions ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java new file mode 100644 index 0000000..27464ac --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record MissingKeywordResponse( + String keyword, + MissingKeywordSource source +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java new file mode 100644 index 0000000..ced0646 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +public enum MissingKeywordSource { + QUALIFICATION("qualification"), + PREFERENCE("preference"), + MAIN_TASK("mainTask"); + + private final String value; + + MissingKeywordSource(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static MissingKeywordSource fromJson(String value) { + return from(value).orElse(null); + } + + public static Optional from(String value) { + if (value == null) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(source -> source.value.equals(value.trim())) + .findFirst(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index 4080120..10e6132 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -39,6 +39,10 @@ public class Analysis extends BaseEntity { @Column(nullable = false, columnDefinition = "TEXT") private String feedback; + @Builder.Default + @Column(name = "missing_keywords", nullable = false, columnDefinition = "TEXT DEFAULT '[]'") + private String missingKeywordsJson = "[]"; + @Builder.Default @OneToMany(mappedBy = "analysis", cascade = CascadeType.ALL, orphanRemoval = true) private List questionAnalyses = new ArrayList<>(); @@ -50,6 +54,18 @@ public static Analysis create( int impact, int completeness, String feedback + ) { + return create(mockApply, score, jobFit, impact, completeness, feedback, "[]"); + } + + public static Analysis create( + MockApply mockApply, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + String missingKeywordsJson ) { return Analysis.builder() .mockApply(mockApply) @@ -58,6 +74,7 @@ public static Analysis create( .impact(impact) .completeness(completeness) .feedback(feedback) + .missingKeywordsJson(missingKeywordsJson == null ? "[]" : missingKeywordsJson) .build(); } } 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 74b210b..72c64ea 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 @@ -33,6 +33,12 @@ public class AnalysisAiClient { "impact": 55, "completeness": 67, "feedback": "한 줄 피드백", + "missingKeywords": [ + { + "keyword": "SQL 활용 경험", + "source": "qualification" + } + ], "questionAnalyses": [ { "questionId": 1, @@ -52,7 +58,8 @@ public class AnalysisAiClient { 4. jobFit, impact, completeness를 각각 평가한다. 5. 감점 금지 조건과 status 오남용 여부를 확인한다. 6. 보완이 필요한 원문 문장을 문항당 최대 3개만 추출한다. - 7. 지정된 JSON만 반환한다. + 7. JD에는 있지만 자소서에 충분히 드러나지 않은 역량을 missingKeywords로 최대 3개 추출한다. + 8. 지정된 JSON만 반환한다. [jobFit 평가 기준] JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. @@ -127,6 +134,18 @@ public class AnalysisAiClient { - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. + - missing은 questionAnalyses에 억지로 넣지 않는다. + + [missingKeywords 규칙] + - JD에는 있지만 자소서에 충분히 드러나지 않은 요건이나 역량을 추출한다. + - questionAnalyses와 분리해서 missingKeywords에만 넣는다. + - 최대 3개만 반환한다. + - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + - 우선순위는 자격요건(qualification) > 우대사항(preference) > 주요 업무(mainTask)다. + - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. + - 가능하면 JD에 실제 들어간 표현을 유지한다. + - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. + - source는 qualification, preference, mainTask 중 하나만 사용한다. [reason 작성 규칙] - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. @@ -243,10 +262,14 @@ private String buildPrompt( - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. - sentence는 answer에 포함된 정확한 substring만 사용한다. + - missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다. + - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. + - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. - jobFit, impact, completeness는 0~100 정수로 출력한다. + - 총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다. """.formatted( OUTPUT_SCHEMA, EVALUATION_CRITERIA, 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 4025b79..822bcc4 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 @@ -1,8 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; @@ -22,6 +27,7 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -38,15 +44,20 @@ import java.util.stream.Collectors; @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class AnalysisService { private static final int MIN_SCORE = 0; private static final int MAX_SCORE = 100; private static final int MAX_ANALYSES_PER_QUESTION = 3; - private static final double JOB_FIT_WEIGHT = 0.40; - private static final double IMPACT_WEIGHT = 0.35; - private static final double COMPLETENESS_WEIGHT = 0.25; + private static final int MAX_MISSING_KEYWORDS = 3; + private static final int MAX_MISSING_KEYWORD_LENGTH = 60; + private static final double JOB_FIT_WEIGHT = 0.50; + private static final double IMPACT_WEIGHT = 0.30; + private static final double COMPLETENESS_WEIGHT = 0.20; + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; @@ -55,6 +66,7 @@ public class AnalysisService { private final JobPostingService jobPostingService; private final AnalysisAiClient analysisAiClient; private final CreditService creditService; + private final ObjectMapper objectMapper; @Transactional @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") @@ -82,6 +94,7 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { int impact = validateScore("impact", llmResponse.impact()); int completeness = validateScore("completeness", llmResponse.completeness()); int score = calculateScore(jobFit, impact, completeness); + List missingKeywords = buildMissingKeywords(llmResponse); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( @@ -90,14 +103,15 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { jobFit, impact, completeness, - normalizeFeedback(llmResponse.feedback()) + normalizeFeedback(llmResponse.feedback()), + serializeMissingKeywords(missingKeywords) )); List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse(mockApply, analysis, questions, questionAnalyses, readMissingKeywords(analysis)); } catch (RuntimeException e) { creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); throw e; @@ -115,7 +129,13 @@ public AnalysisResponse getAnalysis(User user, Long mockApplyId) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + readMissingKeywords(analysis) + ); } public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPostingId, int sequence) { @@ -130,7 +150,13 @@ public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPosti List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + readMissingKeywords(analysis) + ); } private MockApply resolveMockApplyBySequence(JobPosting jobPosting, int sequence) { @@ -254,7 +280,8 @@ private AnalysisResponse toResponse( MockApply mockApply, Analysis analysis, List questions, - List questionAnalyses + List questionAnalyses, + List missingKeywords ) { Map> analysesByQuestionId = questionAnalyses.stream() .collect(Collectors.groupingBy( @@ -274,10 +301,114 @@ private AnalysisResponse toResponse( analysis, mockApply.getStatus(), mockApplyRepository.calculateSequence(mockApply), + missingKeywords, questionResponses ); } + private List buildMissingKeywords(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + private String serializeMissingKeywords(List missingKeywords) { + try { + return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); + return "[]"; + } + } + + private List readMissingKeywords(Analysis analysis) { + if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { + return List.of(); + } + + try { + List missingKeywords = objectMapper.readValue( + analysis.getMissingKeywordsJson(), + MISSING_KEYWORDS_TYPE + ); + return sanitizeStoredMissingKeywords(missingKeywords); + } catch (Exception e) { + log.warn( + "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", + analysis.getId(), + e + ); + return List.of(); + } + } + + private List sanitizeStoredMissingKeywords(List missingKeywords) { + if (missingKeywords == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (MissingKeywordResponse item : missingKeywords) { + if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, item.source())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 27e5d3d..e31007c 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -39,3 +39,6 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); + +ALTER TABLE IF EXISTS analyses + ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index 60e1887..6731d53 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -27,11 +27,13 @@ import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import java.util.List; @@ -81,6 +83,12 @@ class AnalysisServiceTest { @Autowired private CreditTransactionRepository creditTransactionRepository; + @Autowired + private EntityManager entityManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + @MockBean private AnalysisAiClient analysisAiClient; @@ -115,6 +123,7 @@ void analyzeSavesAnalysis() { assertThat(response.jobFit()).isEqualTo(82); assertThat(response.impact()).isEqualTo(71); assertThat(response.completeness()).isEqualTo(80); + assertThat(response.missingKeywords()).isEmpty(); assertThat(response.questions()).hasSize(1); assertThat(response.questions().get(0).analyses()).hasSize(1); assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("mentioned"); @@ -153,7 +162,7 @@ void analyzeCalculatesScoreFromDimensionScores() { AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); - assertThat(response.score()).isEqualTo(40); + assertThat(response.score()).isEqualTo(50); assertThat(response.jobFit()).isEqualTo(100); assertThat(response.impact()).isZero(); assertThat(response.completeness()).isZero(); @@ -182,6 +191,71 @@ void analyzeThrowsAndRefundsWhenDimensionScoreOutOfRange() { assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isEmpty(); } + @Test + @DisplayName("missingKeywords는 검증 후 최대 3개까지 응답에 포함한다") + void analyzeReturnsValidatedMissingKeywords() { + User user = saveUser("analysis-missing-keywords@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "누락 키워드 검증입니다.", + List.of( + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem(" ", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("잘못된 출처", "unknown"), + new AnalysisLlmResponse.MissingKeywordItem( + "이 키워드는 너무 길어서 응답에서 제외되어야 하는 매우 긴 역량 문구이며 허용 길이를 명확하게 초과하는 잘못된 누락 키워드입니다", + "mainTask" + ), + new AnalysisLlmResponse.MissingKeywordItem("대용량 트래픽 처리 경험", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("테스트 자동화 경험", "mainTask") + ), + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.missingKeywords()).hasSize(3); + assertThat(response.missingKeywords()).extracting("keyword") + .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); + assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) + .containsExactly("qualification", "preference", "mainTask"); + + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getMissingKeywordsJson()) + .contains("\"keyword\":\"SQL 활용 경험\"", "\"source\":\"qualification\"") + .contains("\"keyword\":\"대용량 트래픽 처리 경험\"", "\"source\":\"preference\"") + .contains("\"keyword\":\"테스트 자동화 경험\"", "\"source\":\"mainTask\"") + .doesNotContain("잘못된 출처") + .doesNotContain("이 키워드는 너무 길어서"); + } + + @Test + @DisplayName("missingKeywords가 null이면 빈 배열로 응답한다") + void analyzeReturnsEmptyMissingKeywordsWhenLlmMissingKeywordsIsNull() { + User user = saveUser("analysis-missing-keywords-null@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "누락 키워드가 없습니다.", + null, + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.missingKeywords()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getMissingKeywordsJson()).isEqualTo("[]"); + } + @Test @DisplayName("분석 응답은 같은 공고 기준 현재 지원 순번을 반환한다") void analyzeReturnsSequence() { @@ -619,6 +693,73 @@ void getAnalysis() { assertThat(response.questions().get(0).analyses()).hasSize(1); } + @Test + @DisplayName("조회 응답은 JD와 답변으로 재계산하지 않고 DB에 저장된 missingKeywords를 반환한다") + void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { + User user = saveUser("analysis-get-missing-keywords@example.com"); + JobPosting jobPosting = saveJobPosting( + user, + "- 테스트 자동화 경험", + "- SQL 활용 경험", + "- 대용량 트래픽 처리 경험" + ); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "저장된 분석입니다.", + List.of(new AnalysisLlmResponse.MissingKeywordItem("LLM 저장 키워드", "qualification")), + List.of() + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + entityManager.clear(); + + Analysis persisted = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(persisted.getMissingKeywordsJson()) + .contains("\"keyword\":\"LLM 저장 키워드\"", "\"source\":\"qualification\""); + entityManager.clear(); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(saved.missingKeywords()).extracting("keyword") + .containsExactly("LLM 저장 키워드"); + assertThat(response.missingKeywords()).extracting("keyword") + .containsExactly("LLM 저장 키워드"); + assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) + .containsExactly("qualification"); + } + + @Test + @DisplayName("저장된 missingKeywords JSON이 깨져 있어도 조회 응답은 빈 배열로 fallback한다") + void getAnalysisReturnsEmptyMissingKeywordsWhenPersistedJsonIsMalformed() { + User user = saveUser("analysis-get-malformed-missing-keywords@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "저장된 분석입니다.", + List.of(new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification")), + List.of() + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + jdbcTemplate.update( + "UPDATE analyses SET missing_keywords = ? WHERE id = ?", + "not-json", + saved.analysisId() + ); + entityManager.clear(); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.missingKeywords()).isEmpty(); + } + @Test @DisplayName("jobPosting 기준 sequence로 특정 회차 분석 결과를 조회한다") void getAnalysisByJobPostingSequence() { @@ -801,15 +942,19 @@ private AnalysisLlmResponse.QuestionAnalysisItem analysisItem(Long questionId, S } private JobPosting saveJobPosting(User user) { + return saveJobPosting(user, "주요 업무", "자격 요건", "우대 사항"); + } + + private JobPosting saveJobPosting(User user, String task, String requirement, String preferred) { Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); DetailClassification detailClassification = saveDetailClassification(); return jobPostingRepository.save(JobPosting.create( user, company, detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" + task, + requirement, + preferred )); } From 04c7549934d288cb10dd3adf0a59eb20d29d9c13 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 6 Jul 2026 19:11:44 +0900 Subject: [PATCH 08/27] =?UTF-8?q?[Fix]=20=EB=B6=84=EC=84=9D=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=ED=8D=BC=EC=84=BC=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=EB=9F=B4=20escape=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnalysisAiClient formatted prompt의 점수 가중치 퍼센트 값을 %%로 escape - STATUS_AND_WRITING_RULES의 X% 예시도 formatter-safe하게 수정 - AnalysisServiceTest로 컴파일 및 분석 흐름 회귀 검증 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy.yml | 1 + .../jobdri_api/domain/analysis/service/AnalysisAiClient.java | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26d8c32..ff85d09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: pull_request: branches: - main - - develop + - dev push: branches: - main - - develop + - dev permissions: contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e5ef1e..ff29774 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev workflow_dispatch: permissions: 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 dffff39..9f8e4ed 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 @@ -161,7 +161,7 @@ public class AnalysisAiClient { - 반드시 한국어 평서문으로 작성한다. - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. - - 수치가 필요하지만 원문에 없다면 N건, X%, 약 N시간 같은 빈칸 표현을 사용한다. + - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. - 원래 경험과 맥락을 최대한 유지한다. - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. - 너무 길거나 과도하게 화려한 문장으로 만들지 않는다. @@ -278,7 +278,7 @@ private String buildPrompt( - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. - jobFit, impact, completeness는 0~100 정수로 출력한다. - - 총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다. + - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. """.formatted( OUTPUT_SCHEMA, EVALUATION_CRITERIA, From 528cb31012a8862a28ad4c0cbecb6cc5c8938b7c Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 6 Jul 2026 19:18:34 +0900 Subject: [PATCH 09/27] =?UTF-8?q?[Fix]=20RabbitMQ=20=EB=B8=8C=EB=A1=9C?= =?UTF-8?q?=EC=BB=A4=20=EB=B6=80=EC=9E=AC=20=EC=8B=9C=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20skip=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RabbitPublishSupportIntegrationTest 실행 전 localhost:5672 연결 가능 여부 확인 - RabbitMQ가 없는 CI clean test 환경에서는 JUnit assumption으로 테스트 skip - RabbitMQ가 준비된 환경에서는 기존 publisher confirm/return 통합 테스트 유지 - ./gradlew --no-daemon clean test 통과 확인 --- .../RabbitPublishSupportIntegrationTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java index ed55bdf..e40bc56 100644 --- a/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java +++ b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java @@ -18,8 +18,13 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; @SpringBootTest @ActiveProfiles("test") @@ -33,6 +38,9 @@ "spring.rabbitmq.template.mandatory=true" }) class RabbitPublishSupportIntegrationTest { + private static final String RABBITMQ_HOST = "localhost"; + private static final int RABBITMQ_PORT = 5672; + private static final int RABBITMQ_CONNECT_TIMEOUT_MILLIS = 500; @Autowired private RabbitPublishSupport rabbitPublishSupport; @@ -54,9 +62,25 @@ class RabbitPublishSupportIntegrationTest { @BeforeEach void purgeQueue() { + assumeTrue( + isRabbitMqAvailable(), + "RabbitMQ is not available on localhost:5672. Skipping RabbitMQ integration test." + ); rabbitAdmin.purgeQueue(queueName, true); } + private boolean isRabbitMqAvailable() { + try (Socket socket = new Socket()) { + socket.connect( + new InetSocketAddress(RABBITMQ_HOST, RABBITMQ_PORT), + RABBITMQ_CONNECT_TIMEOUT_MILLIS + ); + return true; + } catch (IOException e) { + return false; + } + } + @Test @DisplayName("RabbitMQ publisher confirm이 오면 메시지가 큐에 적재된다") void publishStoresMessageInQueue() { From 3784b044bfe1c7e96bc24184a310a6f42408a8ae Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 14:36:07 +0900 Subject: [PATCH 10/27] =?UTF-8?q?[Refactor]=20=EA=B2=B0=EC=A0=9C=20UNKNOWN?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20analys?= =?UTF-8?q?is=20=ED=81=AC=EB=A0=88=EB=94=A7=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EC=A0=95=EB=A6=AC=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AnalysisAdminDebugService.java | 1 + .../analysis/service/AnalysisAiClient.java | 1 + .../service/AnalysisAsyncFacadeService.java | 1 + .../service/AnalysisAsyncProcessor.java | 1 + .../service/AnalysisAsyncTaskService.java | 1 + .../service/AnalysisExecutionPayload.java | 1 + .../service/AnalysisImprovementRules.java | 1 + .../service/AnalysisQueueProperties.java | 1 + .../analysis/service/AnalysisService.java | 17 +++++------------ .../service/AnalysisTaskMessagePublisher.java | 1 + .../service/AnalysisWorkerBridgeService.java | 7 +++---- .../analysis/service/QuestionService.java | 1 + .../domain/payment/entity/Payment.java | 5 ++--- .../domain/payment/entity/PaymentStatus.java | 1 + .../domain/payment/service/PaymentService.java | 2 +- .../service/PaymentTransactionService.java | 11 +++++++++-- .../service/AnalysisAsyncFacadeServiceTest.java | 4 ++-- .../AnalysisWorkerBridgeServiceTest.java | 4 ++-- .../payment/service/PaymentServiceTest.java | 4 ++-- 19 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java index f7bb3f2..d073b10 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java @@ -19,6 +19,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) +// 관리자용으로 분석 참조 데이터와 retrieval 결과를 미리 점검하는 서비스다. public class AnalysisAdminDebugService { private final MockApplyRepository mockApplyRepository; 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 9f8e4ed..d58015a 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 @@ -24,6 +24,7 @@ @Component @Slf4j @RequiredArgsConstructor +// 자소서 분석에 필요한 프롬프트를 만들고 LLM 호출을 수행하는 클라이언트다. public class AnalysisAiClient { private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; private static final int MAX_REFERENCE_FIELD_LENGTH = 300; 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 3fa23e0..53d9678 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 @@ -15,6 +15,7 @@ @Service @RequiredArgsConstructor +// 분석 비동기 작업의 접수와 상태 조회를 외부 API 관점에서 조율하는 서비스다. public class AnalysisAsyncFacadeService { private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; 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 3b632c0..e113255 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 @@ -6,6 +6,7 @@ @Service @RequiredArgsConstructor +// 분석 비동기 작업을 MQ 메시지로 변환해 워커 실행 경로로 넘기는 서비스다. public class AnalysisAsyncProcessor { private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; 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 816cf5d..97d270c 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 @@ -19,6 +19,7 @@ @Service @RequiredArgsConstructor +// 분석 비동기 task 엔티티의 생성, 상태 전이, 조회를 전담하는 서비스다. public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; 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 index 5461bf8..078c996 100644 --- 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 @@ -5,6 +5,7 @@ import java.util.List; +// 실제 분석 실행에 필요한 공고, 문항, 답변 데이터를 묶어 전달하는 payload다. public record AnalysisExecutionPayload( Long userId, Long mockApplyId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java index 6f4c012..cee9ba6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java @@ -2,6 +2,7 @@ import java.util.List; +// 개선 문구를 후처리할 때 사용하는 내부 규칙 모음이다. final class AnalysisImprovementRules { private static final List BANNED_PHRASES = List.of( "추가하세요", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java index 6c155eb..f3462f8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; @Component +// 분석 워커 큐 관련 설정값을 주입받아 묶어두는 설정 객체다. public record AnalysisQueueProperties( @Value("${app.worker.analysis.routing-key:analysis.execute}") String routingKey ) { 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 0385b48..5242f1c 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 @@ -47,6 +47,7 @@ @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) +// 자소서 분석의 핵심 비즈니스 로직과 결과 저장을 담당하는 메인 서비스다. public class AnalysisService { private static final int MIN_SCORE = 0; private static final int MAX_SCORE = 100; @@ -73,16 +74,15 @@ public class AnalysisService { public AnalysisResponse analyze(User user, Long mockApplyId) { validateAnalysisRequest(user, mockApplyId); String referenceId = "mockApplyId=" + mockApplyId; - reserveAnalysisCredit(user, referenceId); + deductAnalysisCredit(user, referenceId); try { AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); AnalysisLlmResponse llmResponse = executeAnalysis(payload); AnalysisResponse response = finalizeAnalysis(user, mockApplyId, payload, llmResponse); - confirmAnalysisCredit(user, referenceId); return response; } catch (RuntimeException e) { - releaseAnalysisCredit(user, referenceId); + refundAnalysisCredit(user, referenceId); throw e; } } @@ -103,19 +103,12 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional - public void reserveAnalysisCredit(User user, String referenceId) { + public void deductAnalysisCredit(User user, String referenceId) { creditService.use(user, 1, "자소서 분석 크레딧 예약", referenceId); } @Transactional - public void confirmAnalysisCredit(User user, String referenceId) { - if (user == null || referenceId == null) { - return; - } - } - - @Transactional - public void releaseAnalysisCredit(User user, String referenceId) { + public void refundAnalysisCredit(User user, String referenceId) { creditService.refund(user, 1, "자소서 분석 크레딧 예약 해제", referenceId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java index 6a8e781..2104ad2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -9,6 +9,7 @@ @Service @RequiredArgsConstructor +// 분석 작업 메시지를 공통 worker exchange로 발행하는 publisher다. public class AnalysisTaskMessagePublisher { private final RabbitPublishSupport rabbitPublishSupport; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index 1d28b78..d2a2121 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -23,6 +23,7 @@ @Service @RequiredArgsConstructor +// 외부 분석 워커와 내부 분석 도메인 상태를 연결해 주는 브리지 서비스다. public class AnalysisWorkerBridgeService { private final AnalysisAsyncTaskService analysisAsyncTaskService; @@ -156,7 +157,7 @@ private void reserveCreditIfNeeded(AnalysisAsyncTask task) { User user = userService.getUser(task.getUserId()); String creditReferenceId = "analysisTaskId=" + task.getTaskId(); - analysisService.reserveAnalysisCredit(user, creditReferenceId); + analysisService.deductAnalysisCredit(user, creditReferenceId); analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); } @@ -164,8 +165,6 @@ private void confirmCreditIfNeeded(AnalysisAsyncTask task, User user) { if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } - - analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); } @@ -174,7 +173,7 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { return; } User user = userService.getUser(task.getUserId()); - analysisService.releaseAnalysisCredit(user, task.getCreditReferenceId()); + analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); analysisAsyncTaskService.markCreditReleased(task.getTaskId()); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java index c355f83..f72e28f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -34,6 +34,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) +// 분석 전 단계에서 문항 선택, 추가, 답변 저장을 담당하는 서비스다. public class QuestionService { private static final int MIN_SELECTION_COUNT = 1; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 62b99f7..de03f3b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -81,9 +81,8 @@ public void fail() { this.status = PaymentStatus.FAILED; } - public void resetToPending() { - this.paymentKey = null; - this.status = PaymentStatus.PENDING; + public void markUnknown() { + this.status = PaymentStatus.UNKNOWN; } public boolean belongsTo(Long userId) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java index c274f83..03fbb0e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java @@ -6,6 +6,7 @@ public enum PaymentStatus { PENDING, PROCESSING, + UNKNOWN, FAILED, COMPLETED } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 47de7c1..96662a4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -83,7 +83,7 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) validateTossResponse(request, tossResponse); } catch (GeneralException e) { if (e.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { - paymentTransactionService.rollbackConfirmationToPending( + paymentTransactionService.markConfirmationUnknown( validatedUser.getId(), request.orderId(), request.paymentKey() diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index 4ed0e4c..64c4edc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -32,6 +32,13 @@ public PaymentConfirmationStart startConfirmation(Long userId, PaymentConfirmReq } return new PaymentConfirmationStart(payment, true); } + if (payment.getStatus() == PaymentStatus.UNKNOWN) { + if (!payment.hasPaymentKey(request.paymentKey())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); + } + payment.markProcessing(request.paymentKey()); + return new PaymentConfirmationStart(payment, false); + } if (payment.getStatus() == PaymentStatus.PROCESSING || payment.getStatus() == PaymentStatus.FAILED) { throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); } @@ -77,13 +84,13 @@ public void failConfirmation(Long userId, String orderId, String paymentKey) { } @Transactional - public void rollbackConfirmationToPending(Long userId, String orderId, String paymentKey) { + public void markConfirmationUnknown(Long userId, String orderId, String paymentKey) { Payment payment = getOwnedPaymentForUpdate(userId, orderId); if (!payment.hasPaymentKey(paymentKey)) { return; } if (payment.getStatus() == PaymentStatus.PROCESSING) { - payment.resetToPending(); + payment.markUnknown(); } } 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 index 443b93d..a7c69be 100644 --- 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 @@ -60,7 +60,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); - verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), eq(3)); } @@ -98,7 +98,7 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { assertThat(response.taskId()).isEqualTo(createdTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); - verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java index 84b6857..1656527 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -79,7 +79,7 @@ void getContextReservesCreditBeforePreparingExecution() { analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); - verify(analysisService).reserveAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); + verify(analysisService).deductAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); verify(analysisService).prepareAnalysisExecution(user, 10L); } @@ -117,7 +117,7 @@ void getContextDoesNotReserveCreditTwice() { analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); - verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index bb0e9fa..21238a8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -184,7 +184,7 @@ void confirmMarksPaymentAsFailedWhenTossConfirmFails() { } @Test - @DisplayName("토스 승인 타임아웃이면 결제를 PENDING으로 되돌리고 재시도를 허용한다") + @DisplayName("토스 승인 타임아웃이면 결제를 UNKNOWN으로 남기고 재시도를 허용한다") void confirmAllowsRetryWhenTossConfirmTimesOut() { User user = saveUser("payment-confirm-timeout@example.com"); PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); @@ -208,7 +208,7 @@ void confirmAllowsRetryWhenTossConfirmTimesOut() { .isEqualTo(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT); Payment timedOutPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); - assertThat(timedOutPayment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(timedOutPayment.getStatus()).isEqualTo(PaymentStatus.UNKNOWN); PaymentConfirmResponse retryResponse = paymentService.confirm(user, request); From abb0a4b66cd23a6c5ffb4d796167eee8a29cdb86 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 14:46:25 +0900 Subject: [PATCH 11/27] =?UTF-8?q?Fix:=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20async=20retry=20=EC=83=81=ED=83=9C=EB=A8=B8?= =?UTF-8?q?=EC=8B=A0=20=EB=B0=8F=20worker=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EA=B0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JobPostingWorkerInternalController.java | 18 ++++++++-- .../JobPostingWorkerFailureRequest.java | 4 ++- .../worker/JobPostingWorkerRetryRequest.java | 4 ++- .../entity/JobPostingAsyncTask.java | 23 ++++++++++++ .../service/JobPostingAsyncTaskService.java | 5 +++ .../JobPostingWorkerBridgeService.java | 36 +++++++++++++++++-- .../JobPostingAsyncTaskServiceTest.java | 32 +++++++++++++++++ 7 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index a5cfa8a..1d22206 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -55,7 +55,14 @@ public ApiResponse markRetry( @Valid @RequestBody JobPostingWorkerRetryRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + jobPostingWorkerBridgeService.markRetry( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("채용 공고 worker 작업 재시도 상태를 반영했습니다."); } @@ -79,7 +86,14 @@ public ApiResponse failTask( @Valid @RequestBody JobPostingWorkerFailureRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + jobPostingWorkerBridgeService.failTask( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("채용 공고 worker 작업 실패 상태를 반영했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java index 3305fef..db9f8e3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java @@ -8,6 +8,8 @@ public record JobPostingWorkerFailureRequest( @NotNull FailureReason failureReason, @NotBlank String errorMessage, - @Min(0) int retryCount + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java index 345b49f..ca17138 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java @@ -8,6 +8,8 @@ public record JobPostingWorkerRetryRequest( @NotNull FailureReason failureReason, @NotBlank String errorMessage, - @Min(0) int retryCount + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index a5d811e..c670b55 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -108,6 +108,13 @@ public void markSuccess(String resultPayload) { } public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + if (isTerminal()) { + return; + } + if (retryCount >= maxRetryCount) { + markFailed(failureReason, errorMessage, retryCount); + return; + } this.status = TaskStatus.PENDING; this.message = "채용 공고 비동기 재시도를 대기 중입니다."; this.failureReason = failureReason; @@ -117,6 +124,9 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + if (status == TaskStatus.SUCCEEDED) { + return; + } this.status = TaskStatus.FAILED; this.message = "채용 공고 비동기 처리에 실패했습니다."; this.failureReason = failureReason; @@ -125,6 +135,19 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret this.completedAt = LocalDateTime.now(); } + public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { + if (workerId != null && !workerId.isBlank()) { + this.workerId = workerId; + } + if (queueLatencyMillis != null) { + this.queueLatencyMillis = Math.max(0L, queueLatencyMillis); + } + } + + private boolean isTerminal() { + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + } + public enum FailureReason { RATE_LIMIT, QUEUE_TIMEOUT, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 4a4fd79..3135870 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -90,6 +90,11 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM task.markFailed(failureReason, errorMessage, retryCount); } + @Transactional + public void updateWorkerMetadata(String taskId, String workerId, Long queueLatencyMillis) { + getTaskState(taskId).updateWorkerMetadata(workerId, queueLatencyMillis); + } + @Transactional public JobPostingAsyncStatusResponse getTask(String taskId) { JobPostingAsyncTask taskState = getTaskState(taskId); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 768bf97..63c5cff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -39,11 +39,35 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp return jobPostingAsyncTaskService.markSuccess(taskId, result); } - public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetry( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + JobPostingAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + jobPostingAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); jobPostingAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); } - public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void failTask( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + JobPostingAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + jobPostingAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); jobPostingAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } @@ -104,6 +128,14 @@ public JobPostingIngestResponse finalizeAndComplete( return jobPostingAsyncTaskService.markSuccess(taskId, result); } + private JobPostingAsyncTask getTask(String taskId) { + return jobPostingAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, + "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + private String fallbackCompanyName(String companyName) { if (companyName == null || companyName.isBlank()) { return "미분류 회사"; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index 844754d..ecf3a5e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -108,4 +108,36 @@ void getTaskMarksRunningTimeout() { assertThat(response.getStatus()).isEqualTo("FAILED"); assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); } + + @Test + @DisplayName("재시도 횟수가 최대값에 도달하면 FAILED로 전이한다") + void markRetryScheduledFailsWhenMaxRetryReached() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.markRetryScheduled( + task.getTaskId(), + FailureReason.INTERNAL_ERROR, + "retry exhausted", + 3 + ); + + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); + assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getRetryCount()).isEqualTo(3); + } + + @Test + @DisplayName("worker 메타데이터를 별도로 갱신할 수 있다") + void updateWorkerMetadataUpdatesWorkerFields() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.updateWorkerMetadata(task.getTaskId(), "worker-2", 1234L); + + assertThat(task.getWorkerId()).isEqualTo("worker-2"); + assertThat(task.getQueueLatencyMillis()).isEqualTo(1234L); + } } From 4977edd836a61b05e41fe78f9a935e4c5dea6f71 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 14:59:32 +0900 Subject: [PATCH 12/27] =?UTF-8?q?[Feat]=20async=20task=20SSE=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#109)=20=EC=B1=84=EC=9A=A9=EA=B3=B5=EA=B3=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=90=EC=86=8C=EC=84=9C=20=EB=B6=84=EC=84=9D=20async=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20SSE=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=ED=99=95=EC=9E=A5=ED=96=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EA=B3=B5=ED=86=B5=20SSE=20emitt?= =?UTF-8?q?er=20=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EA=B3=A0,=20async=20task=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=A6=89=EC=8B=9C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B0=9C=ED=96=89=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=97=B0=EA=B2=B0=ED=95=B4=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EC=8B=A4=EC=8B=9C=EA=B0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=96=88=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AnalysisController.java | 16 ++++ .../service/AnalysisAsyncSseService.java | 42 ++++++++++ .../service/AnalysisAsyncTaskService.java | 17 +++- .../controller/JobPostingAiController.java | 17 ++++ .../service/JobPostingAsyncSseService.java | 42 ++++++++++ .../service/JobPostingAsyncTaskService.java | 5 ++ .../global/sse/SseSubscriptionRegistry.java | 79 +++++++++++++++++++ .../JobPostingAsyncTaskServiceTest.java | 9 ++- 8 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java index 23b0585..b202304 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -4,6 +4,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncFacadeService; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncSseService; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; @@ -13,11 +14,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor @@ -27,6 +30,7 @@ public class AnalysisController { private final AnalysisService analysisService; private final AnalysisAsyncFacadeService analysisAsyncFacadeService; + private final AnalysisAsyncSseService analysisAsyncSseService; @Operation(summary = "자소서 분석 비동기 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서 분석 작업을 접수합니다.") @PostMapping @@ -53,6 +57,18 @@ public ApiResponse getAnalysisTask( ); } + @Operation(summary = "자소서 분석 비동기 작업 상태 SSE 구독", description = "taskId로 자소서 분석 비동기 작업 상태를 SSE 스트림으로 구독합니다.") + @GetMapping(value = "/async/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamAnalysisTask( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId, + @PathVariable String taskId + ) { + AnalysisAsyncStatusResponse initialStatus = + analysisAsyncFacadeService.getTask(getAuthenticatedUser(userDetails), mockApplyId, taskId); + return analysisAsyncSseService.subscribe(initialStatus); + } + @Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.") @GetMapping public ApiResponse getAnalysis( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java new file mode 100644 index 0000000..6324635 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java @@ -0,0 +1,42 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@RequiredArgsConstructor +public class AnalysisAsyncSseService { + + private static final String EVENT_NAME = "analysis-status"; + + private final SseSubscriptionRegistry sseSubscriptionRegistry; + + public SseEmitter subscribe(AnalysisAsyncStatusResponse initialStatus) { + return sseSubscriptionRegistry.subscribe( + channelKey(initialStatus.taskId()), + EVENT_NAME, + initialStatus, + isTerminal(initialStatus) + ); + } + + public void publish(AnalysisAsyncStatusResponse statusResponse) { + sseSubscriptionRegistry.publish( + channelKey(statusResponse.taskId()), + EVENT_NAME, + statusResponse, + isTerminal(statusResponse) + ); + } + + private String channelKey(String taskId) { + return "analysis:" + taskId; + } + + private boolean isTerminal(AnalysisAsyncStatusResponse statusResponse) { + return "SUCCEEDED".equals(statusResponse.status()) || "FAILED".equals(statusResponse.status()); + } +} 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 97d270c..d21f276 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 @@ -23,6 +23,7 @@ public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisAsyncSseService analysisAsyncSseService; @Value("${app.worker.analysis.max-retry-count:3}") private int maxRetryCount; @@ -50,22 +51,30 @@ public Optional findActiveTask(Long userId, Long mockApplyId) @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { - getTask(taskId).markRunning(workerId, retryCount, submittedAt); + AnalysisAsyncTask task = getTask(taskId); + task.markRunning(workerId, retryCount, submittedAt); + analysisAsyncSseService.publish(toStatusResponse(task)); } @Transactional public void markSuccess(String taskId) { - getTask(taskId).markSuccess(); + AnalysisAsyncTask task = getTask(taskId); + task.markSuccess(); + analysisAsyncSseService.publish(toStatusResponse(task)); } @Transactional public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { - getTask(taskId).markRetryScheduled(failureReason, errorMessage, retryCount); + AnalysisAsyncTask task = getTask(taskId); + task.markRetryScheduled(failureReason, errorMessage, retryCount); + analysisAsyncSseService.publish(toStatusResponse(task)); } @Transactional public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { - getTask(taskId).markFailed(failureReason, errorMessage, retryCount); + AnalysisAsyncTask task = getTask(taskId); + task.markFailed(failureReason, errorMessage, retryCount); + analysisAsyncSseService.publish(toStatusResponse(task)); } @Transactional diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 3e3fedb..c3137b3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncSseService; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.security.UserDetailsImpl; @@ -20,6 +21,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -35,6 +37,7 @@ public class JobPostingAiController { private final JobPostingAiService jobPostingAiService; private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService; + private final JobPostingAsyncSseService jobPostingAsyncSseService; private final UserService userService; @Operation( @@ -108,6 +111,20 @@ public ApiResponse getIngestJobPostingAsyncStatus ); } + @Operation( + summary = "채용 공고 비동기 작업 상태 SSE 구독", + description = "taskId로 비동기 작업 상태를 SSE 스트림으로 구독합니다." + ) + @GetMapping(value = "/ingest/async/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamIngestJobPostingAsyncStatus( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable String taskId + ) { + var user = validateAuthenticatedUser(userDetails); + JobPostingAsyncStatusResponse initialStatus = jobPostingAsyncFacadeService.getTask(user, taskId); + return jobPostingAsyncSseService.subscribe(initialStatus); + } + private com.jobdri.jobdri_api.domain.user.entity.User validateAuthenticatedUser(UserDetailsImpl userDetails) { return userService.validateUser(userDetails == null ? null : userDetails.getUser()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java new file mode 100644 index 0000000..94dbc0e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java @@ -0,0 +1,42 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; +import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@RequiredArgsConstructor +public class JobPostingAsyncSseService { + + private static final String EVENT_NAME = "job-posting-status"; + + private final SseSubscriptionRegistry sseSubscriptionRegistry; + + public SseEmitter subscribe(JobPostingAsyncStatusResponse initialStatus) { + return sseSubscriptionRegistry.subscribe( + channelKey(initialStatus.getTaskId()), + EVENT_NAME, + initialStatus, + isTerminal(initialStatus) + ); + } + + public void publish(JobPostingAsyncStatusResponse statusResponse) { + sseSubscriptionRegistry.publish( + channelKey(statusResponse.getTaskId()), + EVENT_NAME, + statusResponse, + isTerminal(statusResponse) + ); + } + + private String channelKey(String taskId) { + return "job-posting:" + taskId; + } + + private boolean isTerminal(JobPostingAsyncStatusResponse statusResponse) { + return "SUCCEEDED".equals(statusResponse.getStatus()) || "FAILED".equals(statusResponse.getStatus()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 3135870..aa8c6d4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -27,6 +27,7 @@ public class JobPostingAsyncTaskService { private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; private final ObjectMapper objectMapper; + private final JobPostingAsyncSseService jobPostingAsyncSseService; @Value("${app.worker.job-posting.max-retry-count:3}") private int maxRetryCount; @@ -54,6 +55,7 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant return; } task.markRunning(workerId, retryCount, submittedAt); + jobPostingAsyncSseService.publish(toStatusResponse(task)); } @Transactional @@ -69,6 +71,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo ); } task.markSuccess(serializeResult(result)); + jobPostingAsyncSseService.publish(toStatusResponse(task)); return result; } @@ -79,6 +82,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin return; } task.markRetryScheduled(failureReason, errorMessage, retryCount); + jobPostingAsyncSseService.publish(toStatusResponse(task)); } @Transactional @@ -88,6 +92,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM return; } task.markFailed(failureReason, errorMessage, retryCount); + jobPostingAsyncSseService.publish(toStatusResponse(task)); } @Transactional diff --git a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java new file mode 100644 index 0000000..0b35047 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java @@ -0,0 +1,79 @@ +package com.jobdri.jobdri_api.global.sse; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Component +public class SseSubscriptionRegistry { + + private final Map> emitters = new ConcurrentHashMap<>(); + private final long timeoutMillis; + + public SseSubscriptionRegistry(@Value("${app.sse.timeout-millis:1800000}") long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + } + + public SseEmitter subscribe(String channelKey, String eventName, Object initialPayload, boolean completeAfterInitial) { + SseEmitter emitter = new SseEmitter(timeoutMillis); + emitters.computeIfAbsent(channelKey, ignored -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> remove(channelKey, emitter)); + emitter.onTimeout(() -> { + emitter.complete(); + remove(channelKey, emitter); + }); + emitter.onError(ignored -> remove(channelKey, emitter)); + + try { + emitter.send(SseEmitter.event().name("connected").data("connected")); + emitter.send(SseEmitter.event().name(eventName).data(initialPayload)); + if (completeAfterInitial) { + emitter.complete(); + remove(channelKey, emitter); + } + } catch (IOException e) { + emitter.completeWithError(e); + remove(channelKey, emitter); + } + + return emitter; + } + + public void publish(String channelKey, String eventName, Object payload, boolean completeAfterPublish) { + List subscribers = emitters.get(channelKey); + if (subscribers == null || subscribers.isEmpty()) { + return; + } + + for (SseEmitter emitter : subscribers) { + try { + emitter.send(SseEmitter.event().name(eventName).data(payload)); + if (completeAfterPublish) { + emitter.complete(); + remove(channelKey, emitter); + } + } catch (IOException e) { + emitter.completeWithError(e); + remove(channelKey, emitter); + } + } + } + + private void remove(String channelKey, SseEmitter emitter) { + List subscribers = emitters.get(channelKey); + if (subscribers == null) { + return; + } + subscribers.remove(emitter); + if (subscribers.isEmpty()) { + emitters.remove(channelKey); + } + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index ecf3a5e..a01e644 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -28,11 +28,18 @@ class JobPostingAsyncTaskServiceTest { @Mock private JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; + @Mock + private JobPostingAsyncSseService jobPostingAsyncSseService; + private JobPostingAsyncTaskService jobPostingAsyncTaskService; @BeforeEach void setUp() { - jobPostingAsyncTaskService = new JobPostingAsyncTaskService(jobPostingAsyncTaskRepository, new ObjectMapper()); + jobPostingAsyncTaskService = new JobPostingAsyncTaskService( + jobPostingAsyncTaskRepository, + new ObjectMapper(), + jobPostingAsyncSseService + ); } @Test From f387d0e6b112d641766d2c8cdc669a176ef24ea5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 15:02:06 +0900 Subject: [PATCH 13/27] =?UTF-8?q?[Feat]=20SSE=20=ED=95=98=ED=8A=B8?= =?UTF-8?q?=EB=B9=84=ED=8A=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#109)=20SSE=20=EA=B8=B0=EB=B0=98=20async=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=EC=97=90=20?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=EC=A0=81=EC=9D=B8=20heartbeat=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=B4=20?= =?UTF-8?q?=EC=9E=A5=EC=8B=9C=EA=B0=84=20=EC=9C=A0=EC=A7=80=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=97=B0=EA=B2=B0=EC=9D=B4=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=EB=82=98=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9C=A0=ED=9C=B4=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EB=A1=9C=20=EB=81=8A=EA=B8=B0=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=B4=EA=B0=95=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20=EA=B3=B5=ED=86=B5=20SSE=20=EB=A0=88=EC=A7=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=AC=EC=97=90=EC=84=9C=20heartbeat?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=95=B4=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=8F=20=EC=9E=90=EC=86=8C=EC=84=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=EC=97=90=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=A0=81=EC=9A=A9=ED=96=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/sse/SseSubscriptionRegistry.java | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java index 0b35047..170d049 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java +++ b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.global.sse; +import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -9,15 +10,35 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; @Component public class SseSubscriptionRegistry { private final Map> emitters = new ConcurrentHashMap<>(); private final long timeoutMillis; + private final ScheduledExecutorService heartbeatExecutor; - public SseSubscriptionRegistry(@Value("${app.sse.timeout-millis:1800000}") long timeoutMillis) { + public SseSubscriptionRegistry( + @Value("${app.sse.timeout-millis:1800000}") long timeoutMillis, + @Value("${app.sse.heartbeat-interval-millis:15000}") long heartbeatIntervalMillis + ) { this.timeoutMillis = timeoutMillis; + this.heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "sse-heartbeat"); + thread.setDaemon(true); + return thread; + }); + if (heartbeatIntervalMillis > 0) { + this.heartbeatExecutor.scheduleAtFixedRate( + this::sendHeartbeatSafely, + heartbeatIntervalMillis, + heartbeatIntervalMillis, + TimeUnit.MILLISECONDS + ); + } } public SseEmitter subscribe(String channelKey, String eventName, Object initialPayload, boolean completeAfterInitial) { @@ -66,6 +87,33 @@ public void publish(String channelKey, String eventName, Object payload, boolean } } + @PreDestroy + void shutdown() { + heartbeatExecutor.shutdownNow(); + } + + private void sendHeartbeatSafely() { + try { + sendHeartbeat(); + } catch (Exception ignored) { + // Heartbeat failures should not affect the rest of the application. + } + } + + private void sendHeartbeat() { + for (Map.Entry> entry : emitters.entrySet()) { + String channelKey = entry.getKey(); + for (SseEmitter emitter : entry.getValue()) { + try { + emitter.send(SseEmitter.event().name("heartbeat").data("ping")); + } catch (IOException e) { + emitter.completeWithError(e); + remove(channelKey, emitter); + } + } + } + } + private void remove(String channelKey, SseEmitter emitter) { List subscribers = emitters.get(channelKey); if (subscribers == null) { From 0faa17a734ca3a5c363e9bbfa2782dc441d34df4 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 15:14:31 +0900 Subject: [PATCH 14/27] =?UTF-8?q?[Fix]=20async=20stuck=20task=20sweep=20?= =?UTF-8?q?=EB=B0=8F=20timeout=20=EB=B3=B5=EA=B5=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#109)=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?task=EA=B0=80=20PENDING=20=EB=98=90=EB=8A=94=20RUNNING=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20=EC=9E=A5=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=A9=88=EC=B6=B0=20=EC=9E=88=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EC=A3=BC=EA=B8=B0=EC=A0=81=EC=9C=BC=EB=A1=9C=20timeout=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EB=A5=BC=20=EC=A0=90=EA=B2=80=ED=95=B4=20FAI?= =?UTF-8?q?LED=EB=A1=9C=20=EC=A0=95=EB=A6=AC=ED=95=98=EB=8A=94=20stuck-tas?= =?UTF-8?q?k=20sweep=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=96=88=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20=EC=B1=84=EC=9A=A9=EA=B3=B5=EA=B3=A0?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=ED=83=9C=EB=A7=8C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20=EC=9E=90=EC=86=8C=EC=84=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EC=9D=80=20timeout=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=EB=90=9C=20=ED=81=AC=EB=A0=88=EB=94=A7?= =?UTF-8?q?=EC=9D=84=20=ED=95=A8=EA=BB=98=20=ED=99=98=EB=B6=88=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=84=B1=ED=95=B4=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=B3=B5=EA=B5=AC=EC=99=80=20SSE=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=84=ED=8C=8C=EC=9D=98=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=EC=9D=BC=EA=B4=80=EC=84=B1=EC=9D=84=20=EB=86=92?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobdri_api/JobdriApiApplication.java | 2 + .../AnalysisAsyncTaskRepository.java | 3 + .../service/AnalysisAsyncSweepService.java | 94 +++++++++++++++++++ .../JobPostingAsyncTaskRepository.java | 5 + .../service/JobPostingAsyncTaskService.java | 15 +++ .../scheduling/AsyncTaskSweepScheduler.java | 34 +++++++ 6 files changed, 153 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java diff --git a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java index 897d2d4..b96a502 100644 --- a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java +++ b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync @EnableRetry +@EnableScheduling public class JobdriApiApplication { public static void main(String[] args) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index 045c904..4426619 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; +import java.util.List; import java.util.Optional; public interface AnalysisAsyncTaskRepository extends JpaRepository { @@ -15,4 +16,6 @@ Optional findFirstByUserIdAndMockApplyIdAndStatusInOrderByCre Long mockApplyId, Collection statuses ); + + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java new file mode 100644 index 0000000..1e59dc3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java @@ -0,0 +1,94 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +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.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumSet; + +@Service +@RequiredArgsConstructor +public class AnalysisAsyncSweepService { + + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisService analysisService; + private final UserService userService; + + @Value("${app.worker.analysis.queue-timeout-minutes:10}") + private long queueTimeoutMinutes; + + @Value("${app.worker.analysis.processing-timeout-minutes:20}") + private long processingTimeoutMinutes; + + @Transactional + public int sweepTimedOutTasks() { + int expiredCount = 0; + for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + ExpirationDecision expirationDecision = resolveExpiration(task); + if (expirationDecision == null) { + continue; + } + + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed( + task.getTaskId(), + expirationDecision.failureReason(), + expirationDecision.errorMessage(), + task.getRetryCount() + ); + expiredCount++; + } + return expiredCount; + } + + private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStatus() == TaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, queueTimeoutMinutes)) { + return new ExpirationDecision( + FailureReason.QUEUE_TIMEOUT, + "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." + ); + } + + LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); + if (task.getStatus() == TaskStatus.RUNNING && isExpired(lastActivityAt, now, processingTimeoutMinutes)) { + return new ExpirationDecision( + FailureReason.INTERNAL_ERROR, + "자소서 분석 작업이 처리 제한 시간을 초과했습니다." + ); + } + + return null; + } + + private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutMinutes) { + if (baseTime == null || timeoutMinutes <= 0) { + return false; + } + return Duration.between(baseTime, now).toMinutes() >= timeoutMinutes; + } + + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + + User user = userService.getUser(task.getUserId()); + analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditReleased(task.getTaskId()); + } + + private record ExpirationDecision(FailureReason failureReason, String errorMessage) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java index 4841c0c..5deefb5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java @@ -1,10 +1,15 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface JobPostingAsyncTaskRepository extends JpaRepository { Optional findByTaskIdAndUserId(String taskId, Long userId); + + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index aa8c6d4..596a73e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -20,6 +20,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; +import java.util.EnumSet; @Service @RequiredArgsConstructor @@ -122,6 +123,20 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { return toStatusResponse(taskState); } + @Transactional + public int sweepTimedOutTasks() { + int expiredCount = 0; + for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + TaskStatus beforeStatus = task.getStatus(); + expireTimedOutTaskIfNeeded(task); + if (beforeStatus != task.getStatus() && task.getStatus() == TaskStatus.FAILED) { + jobPostingAsyncSseService.publish(toStatusResponse(task)); + expiredCount++; + } + } + return expiredCount; + } + private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskState) { return JobPostingAsyncStatusResponse.builder() .taskId(taskState.getTaskId()) diff --git a/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java new file mode 100644 index 0000000..5e60c04 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java @@ -0,0 +1,34 @@ +package com.jobdri.jobdri_api.global.scheduling; + +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncSweepService; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AsyncTaskSweepScheduler { + + private final JobPostingAsyncTaskService jobPostingAsyncTaskService; + private final AnalysisAsyncSweepService analysisAsyncSweepService; + + @Scheduled( + fixedDelayString = "${app.async-task-sweep.fixed-delay-millis:60000}", + initialDelayString = "${app.async-task-sweep.initial-delay-millis:30000}" + ) + public void sweepTimedOutTasks() { + int expiredJobPostingTasks = jobPostingAsyncTaskService.sweepTimedOutTasks(); + int expiredAnalysisTasks = analysisAsyncSweepService.sweepTimedOutTasks(); + + if (expiredJobPostingTasks > 0 || expiredAnalysisTasks > 0) { + log.info( + "Async task sweep expired tasks. jobPostingExpired={} analysisExpired={}", + expiredJobPostingTasks, + expiredAnalysisTasks + ); + } + } +} From fa683baa287db6d2eed06d152a4abb2c364532f1 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 15:44:16 +0900 Subject: [PATCH 15/27] =?UTF-8?q?[Fix]=20async=20SSE=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=20=EB=B0=8F=20sweep=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EA=B0=95=20(#109)=20async=20SSE=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EC=9D=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=BB=A4=EB=B0=8B=20=EC=9D=B4=ED=9B=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=ED=95=B4=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EA=B0=80=20=EB=AF=B8=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EA=B4=80=EC=B8=A1=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20SSE=20=EA=B5=AC=EB=8F=85/=ED=95=98?= =?UTF-8?q?=ED=8A=B8=EB=B9=84=ED=8A=B8=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=EC=B1=84=EB=84=90=EB=B3=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=96=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EB=98=90=ED=95=9C=20analysis=20?= =?UTF-8?q?sweep=EB=A5=BC=20per-task=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20scheduler=20=EC=98=88=EC=99=B8=20=EA=B2=A9=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EC=9C=BC=EB=A9=B0,=20?= =?UTF-8?q?=EC=B1=84=EC=9A=A9=EA=B3=B5=EA=B3=A0=20worker=20retry/failed=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20DTO=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=ED=95=B4=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=B5=EA=B5=AC=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=9D=98=20=EC=95=88=EC=A0=95=EC=84=B1=EC=9D=84=20=EB=86=92?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.:wq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AnalysisController.java | 8 +- .../service/AnalysisAsyncSseService.java | 14 ++- .../service/AnalysisAsyncSweepService.java | 44 +++++-- .../service/AnalysisAsyncTaskService.java | 23 +++- .../analysis/service/AnalysisService.java | 4 +- .../controller/JobPostingAiController.java | 6 +- .../JobPostingWorkerFailureRequest.java | 3 +- .../worker/JobPostingWorkerRetryRequest.java | 3 +- .../entity/JobPostingAsyncTask.java | 2 +- .../service/JobPostingAsyncSseService.java | 14 ++- .../service/JobPostingAsyncTaskService.java | 25 +++- .../JobPostingWorkerBridgeService.java | 2 + .../scheduling/AsyncTaskSweepScheduler.java | 22 +++- .../global/sse/SseSubscriptionRegistry.java | 114 ++++++++++++------ .../JobPostingAsyncTaskServiceTest.java | 26 ++++ 15 files changed, 231 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java index b202304..c9985a3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -64,9 +64,11 @@ public SseEmitter streamAnalysisTask( @PathVariable Long mockApplyId, @PathVariable String taskId ) { - AnalysisAsyncStatusResponse initialStatus = - analysisAsyncFacadeService.getTask(getAuthenticatedUser(userDetails), mockApplyId, taskId); - return analysisAsyncSseService.subscribe(initialStatus); + var user = getAuthenticatedUser(userDetails); + return analysisAsyncSseService.subscribe( + taskId, + () -> analysisAsyncFacadeService.getTask(user, mockApplyId, taskId) + ); } @Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.") diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java index 6324635..895359a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.function.Supplier; + @Service @RequiredArgsConstructor public class AnalysisAsyncSseService { @@ -14,12 +17,12 @@ public class AnalysisAsyncSseService { private final SseSubscriptionRegistry sseSubscriptionRegistry; - public SseEmitter subscribe(AnalysisAsyncStatusResponse initialStatus) { + public SseEmitter subscribe(String taskId, Supplier initialStatusSupplier) { return sseSubscriptionRegistry.subscribe( - channelKey(initialStatus.taskId()), + channelKey(taskId), EVENT_NAME, - initialStatus, - isTerminal(initialStatus) + initialStatusSupplier, + this::isTerminal ); } @@ -37,6 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(AnalysisAsyncStatusResponse statusResponse) { - return "SUCCEEDED".equals(statusResponse.status()) || "FAILED".equals(statusResponse.status()); + TaskStatus status = TaskStatus.valueOf(statusResponse.status()); + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java index 1e59dc3..4214588 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java @@ -8,7 +8,9 @@ import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +18,7 @@ import java.time.LocalDateTime; import java.util.EnumSet; +@Slf4j @Service @RequiredArgsConstructor public class AnalysisAsyncSweepService { @@ -24,6 +27,8 @@ public class AnalysisAsyncSweepService { private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisService analysisService; private final UserService userService; + @Lazy + private final AnalysisAsyncSweepService self; @Value("${app.worker.analysis.queue-timeout-minutes:10}") private long queueTimeoutMinutes; @@ -31,27 +36,40 @@ public class AnalysisAsyncSweepService { @Value("${app.worker.analysis.processing-timeout-minutes:20}") private long processingTimeoutMinutes; - @Transactional public int sweepTimedOutTasks() { int expiredCount = 0; for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { - ExpirationDecision expirationDecision = resolveExpiration(task); - if (expirationDecision == null) { - continue; + try { + expiredCount += self.sweepTimedOutTask(task.getTaskId()); + } catch (RuntimeException e) { + log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); } - - releaseCreditIfNeeded(task); - analysisAsyncTaskService.markFailed( - task.getTaskId(), - expirationDecision.failureReason(), - expirationDecision.errorMessage(), - task.getRetryCount() - ); - expiredCount++; } return expiredCount; } + @Transactional + public int sweepTimedOutTask(String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); + if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return 0; + } + + ExpirationDecision expirationDecision = resolveExpiration(task); + if (expirationDecision == null) { + return 0; + } + + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed( + task.getTaskId(), + expirationDecision.failureReason(), + expirationDecision.errorMessage(), + task.getRetryCount() + ); + return 1; + } + private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { LocalDateTime now = LocalDateTime.now(); if (task.getStatus() == TaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, queueTimeoutMinutes)) { 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 d21f276..c9970d1 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 @@ -12,6 +12,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Instant; import java.util.EnumSet; @@ -53,28 +55,28 @@ public Optional findActiveTask(Long userId, Long mockApplyId) public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { AnalysisAsyncTask task = getTask(taskId); task.markRunning(workerId, retryCount, submittedAt); - analysisAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional public void markSuccess(String taskId) { AnalysisAsyncTask task = getTask(taskId); task.markSuccess(); - analysisAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); task.markRetryScheduled(failureReason, errorMessage, retryCount); - analysisAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); task.markFailed(failureReason, errorMessage, retryCount); - analysisAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional @@ -145,4 +147,17 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task) { .result(null) .build(); } + + private void publishAfterCommit(AnalysisAsyncStatusResponse statusResponse) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + analysisAsyncSseService.publish(statusResponse); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + analysisAsyncSseService.publish(statusResponse); + } + }); + } } 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 5242f1c..01b7650 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 @@ -104,12 +104,12 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { @Transactional public void deductAnalysisCredit(User user, String referenceId) { - creditService.use(user, 1, "자소서 분석 크레딧 예약", referenceId); + creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); } @Transactional public void refundAnalysisCredit(User user, String referenceId) { - creditService.refund(user, 1, "자소서 분석 크레딧 예약 해제", referenceId); + creditService.refund(user, 1, "자소서 분석 크레딧 환불", referenceId); } @Transactional(readOnly = true) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index c3137b3..103a98e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -121,8 +121,10 @@ public SseEmitter streamIngestJobPostingAsyncStatus( @PathVariable String taskId ) { var user = validateAuthenticatedUser(userDetails); - JobPostingAsyncStatusResponse initialStatus = jobPostingAsyncFacadeService.getTask(user, taskId); - return jobPostingAsyncSseService.subscribe(initialStatus); + return jobPostingAsyncSseService.subscribe( + taskId, + () -> jobPostingAsyncFacadeService.getTask(user, taskId) + ); } private com.jobdri.jobdri_api.domain.user.entity.User validateAuthenticatedUser(UserDetailsImpl userDetails) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java index db9f8e3..3dc9a3c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java @@ -4,12 +4,13 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public record JobPostingWorkerFailureRequest( @NotNull FailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, - @NotBlank String workerId, + @NotBlank @Size(max = 100) String workerId, Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java index ca17138..99e3ec1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java @@ -4,12 +4,13 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public record JobPostingWorkerRetryRequest( @NotNull FailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, - @NotBlank String workerId, + @NotBlank @Size(max = 100) String workerId, Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index c670b55..c2fc1c0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -124,7 +124,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { - if (status == TaskStatus.SUCCEEDED) { + if (isTerminal()) { return; } this.status = TaskStatus.FAILED; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java index 94dbc0e..9a1aebc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.function.Supplier; + @Service @RequiredArgsConstructor public class JobPostingAsyncSseService { @@ -14,12 +17,12 @@ public class JobPostingAsyncSseService { private final SseSubscriptionRegistry sseSubscriptionRegistry; - public SseEmitter subscribe(JobPostingAsyncStatusResponse initialStatus) { + public SseEmitter subscribe(String taskId, Supplier initialStatusSupplier) { return sseSubscriptionRegistry.subscribe( - channelKey(initialStatus.getTaskId()), + channelKey(taskId), EVENT_NAME, - initialStatus, - isTerminal(initialStatus) + initialStatusSupplier, + this::isTerminal ); } @@ -37,6 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(JobPostingAsyncStatusResponse statusResponse) { - return "SUCCEEDED".equals(statusResponse.getStatus()) || "FAILED".equals(statusResponse.getStatus()); + TaskStatus status = TaskStatus.valueOf(statusResponse.getStatus()); + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 596a73e..266b8fc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -16,6 +16,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; import java.time.Instant; @@ -56,7 +58,7 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant return; } task.markRunning(workerId, retryCount, submittedAt); - jobPostingAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional @@ -72,7 +74,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo ); } task.markSuccess(serializeResult(result)); - jobPostingAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); return result; } @@ -83,7 +85,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin return; } task.markRetryScheduled(failureReason, errorMessage, retryCount); - jobPostingAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional @@ -93,7 +95,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM return; } task.markFailed(failureReason, errorMessage, retryCount); - jobPostingAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); } @Transactional @@ -130,7 +132,7 @@ public int sweepTimedOutTasks() { TaskStatus beforeStatus = task.getStatus(); expireTimedOutTaskIfNeeded(task); if (beforeStatus != task.getStatus() && task.getStatus() == TaskStatus.FAILED) { - jobPostingAsyncSseService.publish(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task)); expiredCount++; } } @@ -227,4 +229,17 @@ private JobPostingIngestResponse deserializeResult(String resultPayload) { ); } } + + private void publishAfterCommit(JobPostingAsyncStatusResponse statusResponse) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + jobPostingAsyncSseService.publish(statusResponse); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + jobPostingAsyncSseService.publish(statusResponse); + } + }); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 63c5cff..72497ed 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -39,6 +39,7 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp return jobPostingAsyncTaskService.markSuccess(taskId, result); } + @Transactional public void markRetry( String taskId, FailureReason failureReason, @@ -55,6 +56,7 @@ public void markRetry( jobPostingAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); } + @Transactional public void failTask( String taskId, FailureReason failureReason, diff --git a/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java index 5e60c04..40cc55d 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java +++ b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java @@ -20,8 +20,8 @@ public class AsyncTaskSweepScheduler { initialDelayString = "${app.async-task-sweep.initial-delay-millis:30000}" ) public void sweepTimedOutTasks() { - int expiredJobPostingTasks = jobPostingAsyncTaskService.sweepTimedOutTasks(); - int expiredAnalysisTasks = analysisAsyncSweepService.sweepTimedOutTasks(); + int expiredJobPostingTasks = sweepJobPostingTasks(); + int expiredAnalysisTasks = sweepAnalysisTasks(); if (expiredJobPostingTasks > 0 || expiredAnalysisTasks > 0) { log.info( @@ -31,4 +31,22 @@ public void sweepTimedOutTasks() { ); } } + + private int sweepJobPostingTasks() { + try { + return jobPostingAsyncTaskService.sweepTimedOutTasks(); + } catch (RuntimeException e) { + log.error("Job posting async task sweep failed.", e); + return 0; + } + } + + private int sweepAnalysisTasks() { + try { + return analysisAsyncSweepService.sweepTimedOutTasks(); + } catch (RuntimeException e) { + log.error("Analysis async task sweep failed.", e); + return 0; + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java index 170d049..3f5f6dc 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java +++ b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java @@ -5,25 +5,30 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.function.Supplier; @Component public class SseSubscriptionRegistry { private final Map> emitters = new ConcurrentHashMap<>(); + private final Map channelMonitors = new ConcurrentHashMap<>(); private final long timeoutMillis; private final ScheduledExecutorService heartbeatExecutor; + private final ExecutorService heartbeatDispatchExecutor; public SseSubscriptionRegistry( @Value("${app.sse.timeout-millis:1800000}") long timeoutMillis, - @Value("${app.sse.heartbeat-interval-millis:15000}") long heartbeatIntervalMillis + @Value("${app.sse.heartbeat-interval-millis:15000}") long heartbeatIntervalMillis, + @Value("${app.sse.heartbeat-dispatch-thread-count:4}") int heartbeatDispatchThreadCount ) { this.timeoutMillis = timeoutMillis; this.heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -31,6 +36,14 @@ public SseSubscriptionRegistry( thread.setDaemon(true); return thread; }); + this.heartbeatDispatchExecutor = Executors.newFixedThreadPool( + Math.max(1, heartbeatDispatchThreadCount), + runnable -> { + Thread thread = new Thread(runnable, "sse-heartbeat-dispatch"); + thread.setDaemon(true); + return thread; + } + ); if (heartbeatIntervalMillis > 0) { this.heartbeatExecutor.scheduleAtFixedRate( this::sendHeartbeatSafely, @@ -42,47 +55,63 @@ public SseSubscriptionRegistry( } public SseEmitter subscribe(String channelKey, String eventName, Object initialPayload, boolean completeAfterInitial) { - SseEmitter emitter = new SseEmitter(timeoutMillis); - emitters.computeIfAbsent(channelKey, ignored -> new CopyOnWriteArrayList<>()).add(emitter); + return subscribe(channelKey, eventName, () -> initialPayload, ignored -> completeAfterInitial); + } - emitter.onCompletion(() -> remove(channelKey, emitter)); - emitter.onTimeout(() -> { - emitter.complete(); - remove(channelKey, emitter); - }); - emitter.onError(ignored -> remove(channelKey, emitter)); + public SseEmitter subscribe( + String channelKey, + String eventName, + Supplier initialPayloadSupplier, + Predicate completeAfterInitialPredicate + ) { + SseEmitter emitter = new SseEmitter(timeoutMillis); + synchronized (channelMonitor(channelKey)) { + emitters.computeIfAbsent(channelKey, ignored -> new CopyOnWriteArrayList<>()).add(emitter); - try { - emitter.send(SseEmitter.event().name("connected").data("connected")); - emitter.send(SseEmitter.event().name(eventName).data(initialPayload)); - if (completeAfterInitial) { + emitter.onCompletion(() -> remove(channelKey, emitter)); + emitter.onTimeout(() -> { emitter.complete(); remove(channelKey, emitter); + }); + emitter.onError(ignored -> remove(channelKey, emitter)); + + T initialPayload = initialPayloadSupplier.get(); + boolean completeAfterInitial = completeAfterInitialPredicate.test(initialPayload); + + try { + emitter.send(SseEmitter.event().name("connected").data("connected")); + emitter.send(SseEmitter.event().name(eventName).data(initialPayload)); + if (completeAfterInitial) { + emitter.complete(); + remove(channelKey, emitter); + } + } catch (Exception e) { + emitter.completeWithError(e); + remove(channelKey, emitter); } - } catch (IOException e) { - emitter.completeWithError(e); - remove(channelKey, emitter); } return emitter; } public void publish(String channelKey, String eventName, Object payload, boolean completeAfterPublish) { - List subscribers = emitters.get(channelKey); - if (subscribers == null || subscribers.isEmpty()) { - return; - } + synchronized (channelMonitor(channelKey)) { + List subscribers = emitters.get(channelKey); + if (subscribers == null || subscribers.isEmpty()) { + return; + } - for (SseEmitter emitter : subscribers) { - try { - emitter.send(SseEmitter.event().name(eventName).data(payload)); - if (completeAfterPublish) { - emitter.complete(); + for (SseEmitter emitter : subscribers) { + try { + emitter.send(SseEmitter.event().name(eventName).data(payload)); + if (completeAfterPublish) { + emitter.complete(); + remove(channelKey, emitter); + } + } catch (Exception e) { + emitter.completeWithError(e); remove(channelKey, emitter); } - } catch (IOException e) { - emitter.completeWithError(e); - remove(channelKey, emitter); } } } @@ -90,6 +119,7 @@ public void publish(String channelKey, String eventName, Object payload, boolean @PreDestroy void shutdown() { heartbeatExecutor.shutdownNow(); + heartbeatDispatchExecutor.shutdownNow(); } private void sendHeartbeatSafely() { @@ -104,12 +134,21 @@ private void sendHeartbeat() { for (Map.Entry> entry : emitters.entrySet()) { String channelKey = entry.getKey(); for (SseEmitter emitter : entry.getValue()) { - try { - emitter.send(SseEmitter.event().name("heartbeat").data("ping")); - } catch (IOException e) { - emitter.completeWithError(e); - remove(channelKey, emitter); - } + heartbeatDispatchExecutor.execute(() -> sendHeartbeat(channelKey, emitter)); + } + } + } + + private void sendHeartbeat(String channelKey, SseEmitter emitter) { + synchronized (channelMonitor(channelKey)) { + if (!emitters.getOrDefault(channelKey, new CopyOnWriteArrayList<>()).contains(emitter)) { + return; + } + try { + emitter.send(SseEmitter.event().name("heartbeat").data("ping")); + } catch (Exception e) { + emitter.completeWithError(e); + remove(channelKey, emitter); } } } @@ -122,6 +161,11 @@ private void remove(String channelKey, SseEmitter emitter) { subscribers.remove(emitter); if (subscribers.isEmpty()) { emitters.remove(channelKey); + channelMonitors.remove(channelKey); } } + + private Object channelMonitor(String channelKey) { + return channelMonitors.computeIfAbsent(channelKey, ignored -> new Object()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index a01e644..523ac62 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -19,6 +19,8 @@ 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.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -133,6 +135,7 @@ void markRetryScheduledFailsWhenMaxRetryReached() { assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); assertThat(task.getRetryCount()).isEqualTo(3); + verify(jobPostingAsyncSseService).publish(any()); } @Test @@ -147,4 +150,27 @@ void updateWorkerMetadataUpdatesWorkerFields() { assertThat(task.getWorkerId()).isEqualTo("worker-2"); assertThat(task.getQueueLatencyMillis()).isEqualTo(1234L); } + + @Test + @DisplayName("이미 종료된 task에 대한 재시도 예약은 무시된다") + void markRetryScheduledDoesNothingWhenTaskIsTerminal() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + String originalError = task.getError(); + int originalRetryCount = task.getRetryCount(); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.markRetryScheduled( + task.getTaskId(), + FailureReason.QUEUE_TIMEOUT, + "should be ignored", + 2 + ); + + assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getError()).isEqualTo(originalError); + assertThat(task.getRetryCount()).isEqualTo(originalRetryCount); + verify(jobPostingAsyncSseService, never()).publish(any()); + } } From 06df34657c3b740f4d1e53a0360982844a251525 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 16:00:01 +0900 Subject: [PATCH 16/27] =?UTF-8?q?[Fix]=20analysis=20async=20sweep=20?= =?UTF-8?q?=EC=88=9C=ED=99=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#110)=20analysis=20async=20stuck-task=20sweep?= =?UTF-8?q?=EC=97=90=20per-task=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=98=EB=8A=94=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=9C=20?= =?UTF-8?q?=EC=88=9C=ED=99=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20self-?= =?UTF-8?q?injection=20=EB=8C=80=EC=8B=A0=20TransactionTemplate=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B0=81=20task?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=9C=EB=B3=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=ED=95=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A7=81=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=8B=A4=ED=8C=A8=EC=99=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EB=B0=98=EC=9D=98=20?= =?UTF-8?q?=EC=97=B0=EC=87=84=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/service/AnalysisAsyncSweepService.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java index 4214588..50b7b85 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSweepService.java @@ -10,9 +10,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Duration; import java.time.LocalDateTime; @@ -27,8 +27,7 @@ public class AnalysisAsyncSweepService { private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisService analysisService; private final UserService userService; - @Lazy - private final AnalysisAsyncSweepService self; + private final TransactionTemplate transactionTemplate; @Value("${app.worker.analysis.queue-timeout-minutes:10}") private long queueTimeoutMinutes; @@ -40,7 +39,7 @@ public int sweepTimedOutTasks() { int expiredCount = 0; for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { try { - expiredCount += self.sweepTimedOutTask(task.getTaskId()); + expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); } catch (RuntimeException e) { log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); } @@ -48,8 +47,7 @@ public int sweepTimedOutTasks() { return expiredCount; } - @Transactional - public int sweepTimedOutTask(String taskId) { + private int sweepTimedOutTask(String taskId) { AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { return 0; From b5d7ba54fc9af38bb3a6241d73e78aa565abf1ba Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 16:24:15 +0900 Subject: [PATCH 17/27] =?UTF-8?q?[Feat]=20SSE=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=8F=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#120)=20?= =?UTF-8?q?SSE=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EC=95=B1=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=99=80=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EB=8F=84=EC=9E=85=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=C2=B7=EB=AF=B8=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EA=B0=9C=EC=88=98=20=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=C2=B7=EC=A0=84=EC=B2=B4=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20API=EB=A5=BC=20=EA=B5=AC=ED=98=84=ED=96=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B0,=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=EC=9D=84=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EA=B0=80=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=84=20=ED=91=9C=EC=8B=9C=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EB=98=90=ED=95=9C=20as?= =?UTF-8?q?ync=20=EC=9E=91=EC=97=85=20=EC=99=84=EB=A3=8C/=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20taskId,=20mockApplyId,=20jobPostingId?= =?UTF-8?q?=20=EB=93=B1=20=EC=9D=B4=EB=8F=99=EC=9A=A9=20payload=EB=A5=BC?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=97=B0=EA=B2=B0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ops/db/migrations/20260707_notifications.sql | 20 ++ .../service/AnalysisAsyncTaskService.java | 43 ++++ .../service/JobPostingAsyncTaskService.java | 48 +++++ .../controller/NotificationController.java | 83 ++++++++ .../response/NotificationReadAllResponse.java | 4 + .../dto/response/NotificationResponse.java | 23 ++ .../NotificationStreamBootstrapResponse.java | 9 + .../NotificationStreamEventResponse.java | 11 + .../NotificationUnreadCountResponse.java | 4 + .../notification/entity/Notification.java | 88 ++++++++ .../entity/NotificationTargetType.java | 9 + .../notification/entity/NotificationType.java | 9 + .../repository/NotificationRepository.java | 28 +++ .../service/NotificationService.java | 197 ++++++++++++++++++ .../service/NotificationSseService.java | 37 ++++ .../JobPostingAsyncTaskServiceTest.java | 6 +- 17 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 ops/db/migrations/20260707_notifications.sql create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationReadAllResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamBootstrapResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamEventResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationUnreadCountResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/entity/Notification.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationTargetType.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationType.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/repository/NotificationRepository.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationSseService.java diff --git a/.gitignore b/.gitignore index 43d7664..5b8aa79 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ out/ # application secrets application-local.yml +.venv \ No newline at end of file diff --git a/ops/db/migrations/20260707_notifications.sql b/ops/db/migrations/20260707_notifications.sql new file mode 100644 index 0000000..5640723 --- /dev/null +++ b/ops/db/migrations/20260707_notifications.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + notification_type VARCHAR(50) NOT NULL, + title VARCHAR(120) NOT NULL, + body VARCHAR(500) NOT NULL, + target_type VARCHAR(50) NOT NULL, + target_id VARCHAR(100), + payload_json TEXT, + read_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_created_at + ON notifications (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_read_at + ON notifications (user_id, read_at); 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 c9970d1..b7b0fde 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 @@ -5,6 +5,9 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -18,6 +21,8 @@ import java.time.Instant; import java.util.EnumSet; import java.util.Optional; +import java.util.LinkedHashMap; +import java.util.Map; @Service @RequiredArgsConstructor @@ -26,6 +31,7 @@ public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisAsyncSseService analysisAsyncSseService; + private final NotificationService notificationService; @Value("${app.worker.analysis.max-retry-count:3}") private int maxRetryCount; @@ -63,6 +69,7 @@ public void markSuccess(String taskId) { AnalysisAsyncTask task = getTask(taskId); task.markSuccess(); publishAfterCommit(toStatusResponse(task)); + createSuccessNotification(task); } @Transactional @@ -77,6 +84,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM AnalysisAsyncTask task = getTask(taskId); task.markFailed(failureReason, errorMessage, retryCount); publishAfterCommit(toStatusResponse(task)); + createFailureNotification(task); } @Transactional @@ -160,4 +168,39 @@ public void afterCommit() { } }); } + + private void createSuccessNotification(AnalysisAsyncTask task) { + Map payload = new LinkedHashMap<>(); + payload.put("taskId", task.getTaskId()); + payload.put("mockApplyId", task.getMockApplyId()); + payload.put("status", task.getStatus().name()); + + notificationService.createNotification( + task.getUserId(), + NotificationType.ANALYSIS_ASYNC_SUCCEEDED, + "자소서 분석이 완료되었습니다.", + "분석 결과를 확인해보세요.", + NotificationTargetType.ANALYSIS_RESULT, + String.valueOf(task.getMockApplyId()), + payload + ); + } + + private void createFailureNotification(AnalysisAsyncTask task) { + Map payload = new LinkedHashMap<>(); + payload.put("taskId", task.getTaskId()); + payload.put("mockApplyId", task.getMockApplyId()); + payload.put("failureReason", task.getFailureReason() != null ? task.getFailureReason().name() : null); + payload.put("status", task.getStatus().name()); + + notificationService.createNotification( + task.getUserId(), + NotificationType.ANALYSIS_ASYNC_FAILED, + "자소서 분석이 실패했습니다.", + task.getError() != null ? task.getError() : "자소서 분석 처리 중 오류가 발생했습니다.", + NotificationTargetType.ANALYSIS_TASK, + task.getTaskId(), + payload + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 266b8fc..1154cb1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -5,6 +5,9 @@ import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.entity.UserRole; @@ -23,6 +26,8 @@ import java.time.Instant; import java.time.LocalDateTime; import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; @Service @RequiredArgsConstructor @@ -31,6 +36,7 @@ public class JobPostingAsyncTaskService { private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; private final ObjectMapper objectMapper; private final JobPostingAsyncSseService jobPostingAsyncSseService; + private final NotificationService notificationService; @Value("${app.worker.job-posting.max-retry-count:3}") private int maxRetryCount; @@ -75,6 +81,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } task.markSuccess(serializeResult(result)); publishAfterCommit(toStatusResponse(task)); + createSuccessNotification(task, result); return result; } @@ -96,6 +103,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM } task.markFailed(failureReason, errorMessage, retryCount); publishAfterCommit(toStatusResponse(task)); + createFailureNotification(task); } @Transactional @@ -242,4 +250,44 @@ public void afterCommit() { } }); } + + private void createSuccessNotification(JobPostingAsyncTask task, JobPostingIngestResponse result) { + Map payload = new LinkedHashMap<>(); + payload.put("taskId", task.getTaskId()); + payload.put("savedToDatabase", result.isSavedToDatabase()); + payload.put("jobPostingId", result.getSaved() != null ? result.getSaved().getJobPostingId() : null); + + notificationService.createNotification( + task.getUserId(), + NotificationType.JOB_POSTING_ASYNC_SUCCEEDED, + "채용 공고 작업이 완료되었습니다.", + result.isSavedToDatabase() + ? "채용 공고 분석과 저장이 완료되었습니다." + : "채용 공고 분석이 완료되었습니다.", + result.getSaved() != null && result.getSaved().getJobPostingId() != null + ? NotificationTargetType.JOB_POSTING_RESULT + : NotificationTargetType.JOB_POSTING_TASK, + result.getSaved() != null && result.getSaved().getJobPostingId() != null + ? String.valueOf(result.getSaved().getJobPostingId()) + : task.getTaskId(), + payload + ); + } + + private void createFailureNotification(JobPostingAsyncTask task) { + Map payload = new LinkedHashMap<>(); + payload.put("taskId", task.getTaskId()); + payload.put("failureReason", task.getFailureReason() != null ? task.getFailureReason().name() : null); + payload.put("status", task.getStatus().name()); + + notificationService.createNotification( + task.getUserId(), + NotificationType.JOB_POSTING_ASYNC_FAILED, + "채용 공고 작업이 실패했습니다.", + task.getError() != null ? task.getError() : "채용 공고 작업 처리 중 오류가 발생했습니다.", + NotificationTargetType.JOB_POSTING_TASK, + task.getTaskId(), + payload + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..de349d0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java @@ -0,0 +1,83 @@ +package com.jobdri.jobdri_api.domain.notification.controller; + +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationReadAllResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +@Tag(name = "Notification", description = "인앱 알림 API") +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "알림 목록 조회", description = "최근 알림 목록을 조회합니다.") + @GetMapping + public ApiResponse> getNotifications( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.onSuccess( + "알림 목록 조회에 성공했습니다.", + notificationService.getNotifications(userDetails.getUser()) + ); + } + + @Operation(summary = "미읽음 알림 개수 조회", description = "현재 미읽음 알림 개수를 조회합니다.") + @GetMapping("/unread-count") + public ApiResponse getUnreadCount( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.onSuccess( + "미읽음 알림 개수 조회에 성공했습니다.", + notificationService.getUnreadCount(userDetails.getUser()) + ); + } + + @Operation(summary = "알림 SSE 구독", description = "실시간 알림 스트림을 구독합니다.") + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamNotifications( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return notificationService.subscribe(userDetails.getUser()); + } + + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 처리합니다.") + @PatchMapping("/{notificationId}/read") + public ApiResponse markRead( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long notificationId + ) { + return ApiResponse.onSuccess( + "알림 읽음 처리에 성공했습니다.", + notificationService.markRead(userDetails.getUser(), notificationId) + ); + } + + @Operation(summary = "알림 전체 읽음 처리", description = "미읽음 알림을 모두 읽음 처리합니다.") + @PatchMapping("/read-all") + public ApiResponse markAllRead( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.onSuccess( + "알림 전체 읽음 처리에 성공했습니다.", + notificationService.markAllRead(userDetails.getUser()) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationReadAllResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationReadAllResponse.java new file mode 100644 index 0000000..f4e15b3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationReadAllResponse.java @@ -0,0 +1,4 @@ +package com.jobdri.jobdri_api.domain.notification.dto.response; + +public record NotificationReadAllResponse(int updatedCount, long unreadCount) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..6951a50 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,23 @@ +package com.jobdri.jobdri_api.domain.notification.dto.response; + +import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.Map; + +@Builder +public record NotificationResponse( + Long id, + NotificationType type, + String title, + String body, + boolean isRead, + LocalDateTime readAt, + LocalDateTime createdAt, + NotificationTargetType targetType, + String targetId, + Map payload +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamBootstrapResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamBootstrapResponse.java new file mode 100644 index 0000000..84644f8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamBootstrapResponse.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.notification.dto.response; + +import java.util.List; + +public record NotificationStreamBootstrapResponse( + long unreadCount, + List notifications +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamEventResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamEventResponse.java new file mode 100644 index 0000000..9e5ae86 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationStreamEventResponse.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.notification.dto.response; + +import java.util.List; + +public record NotificationStreamEventResponse( + String action, + NotificationResponse notification, + List notificationIds, + long unreadCount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationUnreadCountResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationUnreadCountResponse.java new file mode 100644 index 0000000..ffbb240 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/dto/response/NotificationUnreadCountResponse.java @@ -0,0 +1,4 @@ +package com.jobdri.jobdri_api.domain.notification.dto.response; + +public record NotificationUnreadCountResponse(long unreadCount) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/Notification.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/Notification.java new file mode 100644 index 0000000..7f6fa62 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/Notification.java @@ -0,0 +1,88 @@ +package com.jobdri.jobdri_api.domain.notification.entity; + +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notifications") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 50) + private NotificationType notificationType; + + @Column(nullable = false, length = 120) + private String title; + + @Column(nullable = false, length = 500) + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 50) + private NotificationTargetType targetType; + + @Column(name = "target_id", length = 100) + private String targetId; + + @Column(name = "payload_json", columnDefinition = "TEXT") + private String payloadJson; + + @Column(name = "read_at") + private LocalDateTime readAt; + + public static Notification create( + User user, + NotificationType notificationType, + String title, + String body, + NotificationTargetType targetType, + String targetId, + String payloadJson + ) { + Notification notification = new Notification(); + notification.user = user; + notification.notificationType = notificationType; + notification.title = title; + notification.body = body; + notification.targetType = targetType; + notification.targetId = targetId; + notification.payloadJson = payloadJson; + return notification; + } + + public boolean isRead() { + return readAt != null; + } + + public void markRead() { + if (readAt == null) { + readAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationTargetType.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationTargetType.java new file mode 100644 index 0000000..1b15264 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationTargetType.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.notification.entity; + +public enum NotificationTargetType { + JOB_POSTING_TASK, + JOB_POSTING_RESULT, + ANALYSIS_TASK, + ANALYSIS_RESULT, + NONE +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationType.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationType.java new file mode 100644 index 0000000..e129d5d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/entity/NotificationType.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.notification.entity; + +public enum NotificationType { + JOB_POSTING_ASYNC_SUCCEEDED, + JOB_POSTING_ASYNC_FAILED, + ANALYSIS_ASYNC_SUCCEEDED, + ANALYSIS_ASYNC_FAILED, + GENERAL +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/repository/NotificationRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..1a11522 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.notification.repository; + +import com.jobdri.jobdri_api.domain.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + List findTop50ByUserIdOrderByCreatedAtDesc(Long userId); + + Optional findByIdAndUserId(Long id, Long userId); + + long countByUserIdAndReadAtIsNull(Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Notification n + set n.readAt = :readAt + where n.user.id = :userId + and n.readAt is null + """) + int markAllAsRead(@Param("userId") Long userId, @Param("readAt") LocalDateTime readAt); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..7e51c28 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java @@ -0,0 +1,197 @@ +package com.jobdri.jobdri_api.domain.notification.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationReadAllResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationStreamBootstrapResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationStreamEventResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.jobdri.jobdri_api.domain.notification.entity.Notification; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; +import com.jobdri.jobdri_api.domain.notification.repository.NotificationRepository; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private static final TypeReference> PAYLOAD_TYPE = new TypeReference<>() { + }; + + private final NotificationRepository notificationRepository; + private final NotificationSseService notificationSseService; + private final UserService userService; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public List getNotifications(User user) { + User validatedUser = userService.validateUser(user); + return notificationRepository.findTop50ByUserIdOrderByCreatedAtDesc(validatedUser.getId()).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public NotificationUnreadCountResponse getUnreadCount(User user) { + User validatedUser = userService.validateUser(user); + return new NotificationUnreadCountResponse(notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId())); + } + + @Transactional(readOnly = true) + public SseEmitter subscribe(User user) { + User validatedUser = userService.validateUser(user); + return notificationSseService.subscribe( + validatedUser.getId(), + () -> new NotificationStreamBootstrapResponse( + notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId()), + notificationRepository.findTop50ByUserIdOrderByCreatedAtDesc(validatedUser.getId()).stream() + .map(this::toResponse) + .toList() + ) + ); + } + + @Transactional + public NotificationResponse markRead(User user, Long notificationId) { + User validatedUser = userService.validateUser(user); + Notification notification = notificationRepository.findByIdAndUserId(notificationId, validatedUser.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "해당 알림을 찾을 수 없습니다. notificationId=" + notificationId + )); + boolean wasUnread = !notification.isRead(); + notification.markRead(); + + NotificationResponse response = toResponse(notification); + if (wasUnread) { + publishAfterCommit( + validatedUser.getId(), + () -> new NotificationStreamEventResponse( + "READ", + response, + List.of(notification.getId()), + notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId()) + ) + ); + } + return response; + } + + @Transactional + public NotificationReadAllResponse markAllRead(User user) { + User validatedUser = userService.validateUser(user); + int updatedCount = notificationRepository.markAllAsRead(validatedUser.getId(), LocalDateTime.now()); + if (updatedCount > 0) { + publishAfterCommit( + validatedUser.getId(), + () -> new NotificationStreamEventResponse( + "READ_ALL", + null, + null, + notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId()) + ) + ); + } + return new NotificationReadAllResponse(updatedCount, notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId())); + } + + @Transactional + public NotificationResponse createNotification( + Long userId, + NotificationType notificationType, + String title, + String body, + NotificationTargetType targetType, + String targetId, + Map payload + ) { + User user = userService.getUser(userId); + Notification notification = notificationRepository.save( + Notification.create( + user, + notificationType, + title, + body, + targetType, + targetId, + serializePayload(payload) + ) + ); + + NotificationResponse response = toResponse(notification); + publishAfterCommit( + userId, + () -> new NotificationStreamEventResponse( + "CREATED", + response, + List.of(notification.getId()), + notificationRepository.countByUserIdAndReadAtIsNull(userId) + ) + ); + return response; + } + + private void publishAfterCommit(Long userId, Supplier eventSupplier) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + notificationSseService.publish(userId, eventSupplier.get()); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + notificationSseService.publish(userId, eventSupplier.get()); + } + }); + } + + private NotificationResponse toResponse(Notification notification) { + return NotificationResponse.builder() + .id(notification.getId()) + .type(notification.getNotificationType()) + .title(notification.getTitle()) + .body(notification.getBody()) + .isRead(notification.isRead()) + .readAt(notification.getReadAt()) + .createdAt(notification.getCreatedAt()) + .targetType(notification.getTargetType()) + .targetId(notification.getTargetId()) + .payload(readPayload(notification.getPayloadJson())) + .build(); + } + + private String serializePayload(Map payload) { + try { + return payload == null || payload.isEmpty() ? null : objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR, "알림 payload 직렬화에 실패했습니다."); + } + } + + private Map readPayload(String payloadJson) { + if (payloadJson == null || payloadJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(payloadJson, PAYLOAD_TYPE); + } catch (JsonProcessingException e) { + return Map.of(); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationSseService.java new file mode 100644 index 0000000..79fb918 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationSseService.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.notification.service; + +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationStreamBootstrapResponse; +import com.jobdri.jobdri_api.domain.notification.dto.response.NotificationStreamEventResponse; +import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.function.Supplier; + +@Service +@RequiredArgsConstructor +public class NotificationSseService { + + private static final String BOOTSTRAP_EVENT_NAME = "notification-bootstrap"; + private static final String NOTIFICATION_EVENT_NAME = "notification"; + + private final SseSubscriptionRegistry sseSubscriptionRegistry; + + public SseEmitter subscribe(Long userId, Supplier bootstrapSupplier) { + return sseSubscriptionRegistry.subscribe( + channelKey(userId), + BOOTSTRAP_EVENT_NAME, + bootstrapSupplier, + ignored -> false + ); + } + + public void publish(Long userId, NotificationStreamEventResponse eventResponse) { + sseSubscriptionRegistry.publish(channelKey(userId), NOTIFICATION_EVENT_NAME, eventResponse, false); + } + + private String channelKey(Long userId) { + return "notification:" + userId; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index 523ac62..a01ed86 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -33,6 +33,9 @@ class JobPostingAsyncTaskServiceTest { @Mock private JobPostingAsyncSseService jobPostingAsyncSseService; + @Mock + private com.jobdri.jobdri_api.domain.notification.service.NotificationService notificationService; + private JobPostingAsyncTaskService jobPostingAsyncTaskService; @BeforeEach @@ -40,7 +43,8 @@ void setUp() { jobPostingAsyncTaskService = new JobPostingAsyncTaskService( jobPostingAsyncTaskRepository, new ObjectMapper(), - jobPostingAsyncSseService + jobPostingAsyncSseService, + notificationService ); } From b541a1a1d36883f1a24ae04fbf58156bbbd0e86f Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 16:48:24 +0900 Subject: [PATCH 18/27] =?UTF-8?q?[Docs]=20readme=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 512 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 387 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index e0c1c47..7b6165f 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,445 @@ -# BackEnd -Repository of JobDri BackEnd +# JobDri SERVER -## Docker +**AI 기반 채용 공고 정리 · 모의 지원서 생성 · 자소서 분석 플랫폼 `JobDri`의 백엔드 서버**입니다. -로컬 실행: +> **"채용 공고를 정리하고, 지원서를 만들고, 자소서까지 분석받자."** +> 사용자가 업로드하거나 입력한 채용 공고를 AI가 구조화하고, +> 직무/회사 기반의 **모의 공고와 문항**을 생성하며, +> 작성한 자소서를 **AI 분석 + 실시간 상태 알림**으로 제공하는 **AI 취업 지원 플랫폼**입니다. -```bash -cp .env.example .env -docker compose up --build -``` +Server: `https://api.jobdri.site` -포함 서비스: +## 📖 프로젝트 소개 -- Spring Boot API: `http://localhost:8080` -- FastAPI worker health: `http://localhost:8000/health` -- RabbitMQ: `amqp://localhost:5672` -- RabbitMQ management: `http://localhost:15672` +`JobDri`는 채용 공고부터 모의 지원, 자소서 분석까지 이어지는 지원 준비 흐름을 하나의 서비스 안에서 제공하는 백엔드입니다. -배포 서버 실행: - -```bash -cp .env.production.example .env -docker compose -f docker-compose.prod.yml up -d -``` +이 서버는 다음 흐름을 중심으로 동작합니다. -`prod` 프로필은 `/actuator/health`를 노출합니다. +1. **채용 공고 입력/정리** + 사용자가 텍스트 또는 이미지 기반 공고를 입력하면 AI가 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 구조화합니다. +2. **공고 저장 및 모의 지원 생성** + 저장된 실제 공고 또는 AI가 만든 모의 공고를 기반으로 `MockApply`를 생성하고, 추천 문항 또는 직접 추가 문항을 선택해 지원서를 작성합니다. +3. **자소서 분석** + 작성한 답변을 AI가 비동기로 분석하고, 점수/피드백/문항별 코멘트/누락 키워드를 저장합니다. +4. **실시간 상태 추적** + 긴 작업은 `RabbitMQ + 외부 FastAPI worker + internal callback API + SSE` 구조로 처리하며, 사용자는 진행 상태와 완료 알림을 실시간으로 확인할 수 있습니다. -## Async Worker Architecture +## 🔄 Core Flow + +### 1. 채용 공고 처리 -현재 비동기 구조는 아래처럼 분리됩니다. +- `POST /api/job-postings/extract` + 텍스트 또는 S3 이미지 object key를 받아 AI가 공고를 추출합니다. +- `POST /api/job-postings/ingest` + 공고 추출 → 분류 후보 탐색 → 공고 초안 생성 → 저장까지 비동기 작업으로 접수합니다. +- `GET /api/job-postings/ingest/async/{taskId}` + 비동기 상태를 조회합니다. +- `GET /api/job-postings/ingest/async/{taskId}/stream` + SSE로 상태 변화를 구독합니다. -- API 서버: task row 생성, 상태 조회 API 제공, RabbitMQ publish, 최종 DB 저장 -- RabbitMQ: `job posting ingest` 작업 버퍼링과 재전달 -- FastAPI worker: MQ consume, OpenAI 기반 추출/분류/생성 수행, 내부 API callback +### 2. 모의 지원서 생성 + +- 저장된 실제 공고 기반 `ACTUAL` 타입 모의 지원 생성 +- 저장된 공고 기반 `MOCK` 타입 모의 지원 생성 +- 회사/직무 분류 기반 가상 공고 생성과 추천 질문 생성 +- 같은 공고 기준 회차(`sequence`) 관리 및 재도전 생성 -로컬에 필요한 추가 env: +### 3. 자소서 작성 및 분석 -```bash -RABBITMQ_HOST=localhost -RABBITMQ_PORT=5672 -RABBITMQ_USERNAME=guest -RABBITMQ_PASSWORD=guest -RABBITMQ_VHOST=/ -APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key -APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange -APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest -APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest -APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq -WORKER_MAX_RETRY_COUNT=3 +- 문항 후보 조회, 직접 문항 추가, 선택 문항 저장 +- 답변 저장/수정 +- `POST /api/mock-applies/{mockApplyId}/analysis` + 자소서 분석 비동기 작업 접수 +- `GET /api/mock-applies/{mockApplyId}/analysis` + 저장된 분석 결과 조회 +- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}/stream` + SSE 상태 스트림 구독 + +## 🛠️ Tech Stack + +| Category | Stack | +| --- | --- | +| **Language** | Java 21 | +| **Framework** | Spring Boot 3.5.14 | +| **ORM / DB** | Spring Data JPA, PostgreSQL, H2(Test), pgvector | +| **Cache / Messaging** | Redis, RabbitMQ | +| **Auth / Security** | Spring Security, JWT, Google OAuth2 | +| **AI / Embedding** | OpenAI API (`gpt-4o-mini` default), Cohere Embedding (`embed-v4.0`) | +| **Storage / Infra** | AWS S3, Docker, GHCR, EC2, Nginx | +| **Payment** | Toss Payments | +| **Mail / Realtime** | JavaMailSender(Gmail SMTP), SSE | +| **Docs / Build** | SpringDoc Swagger, Gradle, GitHub Actions | + +## ✨ Key Features + +### 1. 인증 및 사용자 관리 + +- 이메일 인증번호 발송/검증 기반 회원가입 +- JWT Access/Refresh Token 로그인 및 재발급 +- Redis 기반 로그아웃/토큰 무효화 처리 +- Google OAuth2 로그인 및 프론트 리다이렉트 연동 + +### 2. 채용 공고 AI 처리 + +- 공고 텍스트/이미지에서 구조화 정보 추출 +- S3 presigned PUT URL 기반 이미지 직접 업로드 +- 직무 소분류 자동 분류 후보 추천 +- 회사/직무 기반 실제 공고 초안 생성 +- 회사/직무 기반 모의 공고 및 추천 질문 생성 + +### 3. 모의 지원서 작성 흐름 + +- 실제 공고 기반 `ACTUAL` 타입 지원 생성 +- 생성 공고 기반 `MOCK` 타입 지원 생성 +- 질문 후보 조회 및 직접 문항 추가 +- 선택 문항 저장 후 답변 작성 단계 전환 +- 공고별 회차 관리 및 재도전 지원 + +### 4. 자소서 분석 + +- 비동기 분석 작업 접수 및 상태 조회 +- 분석 완료 시 종합 점수, 항목별 점수, 전체 피드백 저장 +- 문항별 분석 결과와 누락 키워드 제공 +- 분석 실행 시 크레딧 1회 차감, 실패 시 환불 + +### 5. 결제 및 크레딧 + +- 크레딧 플랜 조회 +- 토스페이먼츠 결제 준비/승인 +- 사용자 크레딧 잔액/거래 내역 조회 +- 기본 플랜 + `1회권`, `5회권`, `10회권` + +### 6. 실시간 알림 + +- SSE 기반 인앱 알림 스트림 +- 미읽음 개수 조회 +- 개별/전체 읽음 처리 +- 현재 구현된 주요 알림 타입 + `JOB_POSTING_ASYNC_SUCCEEDED`, `JOB_POSTING_ASYNC_FAILED`, `ANALYSIS_ASYNC_SUCCEEDED`, `ANALYSIS_ASYNC_FAILED` + +### 7. 관리자 / 운영 기능 + +- corpus xlsx 적재 API +- corpus embedding 동기화 API +- 분석 retrieval preview 디버그 API +- 부트스트랩 관리자 이메일 승격 +- 비동기 task timeout sweep 스케줄러 +- 감사 로그 AOP (`@AuditLogEvent`) + +## 🔍 상세 기능 명세 + +### 🧩 인증 및 유저 + +- `POST /api/auth/email-verifications` +- `POST /api/auth/email-verifications/confirmations` +- `POST /api/auth/signup` +- `POST /api/auth/login` +- `POST /api/auth/reissue` +- `POST /api/auth/logout` +- `GET /oauth2/authorization/google` +- `GET /login/oauth2/code/google` + +### 🗂️ 직무 분류 + +- `GET /api/classifications` +- `GET /api/classifications/{bigId}/middles` +- `GET /api/classifications/middles/{middleId}/details` + +### 📝 채용 공고 + +- `POST /api/job-postings` +- `PATCH /api/job-postings/{jobPostingId}` +- `GET /api/job-postings/me` +- `GET /api/job-postings/me/{jobPostingId}` +- `DELETE /api/job-postings/{jobPostingId}` +- `POST /api/job-postings/generate` +- `POST /api/job-postings/extract` +- `POST /api/job-postings/ingest` +- `GET /api/job-postings/ingest/async/{taskId}` +- `GET /api/job-postings/ingest/async/{taskId}/stream` +- `POST /api/job-postings/images/presign-upload` +- `POST /api/job-postings/extension/ingest` + +### 🎯 모의 공고 / 모의 지원 + +- `POST /api/job-postings/mock/generate` +- `GET /api/job-postings/mock/questions` +- `POST /api/job-postings/mock/questions` +- `GET /api/mock-applies/me` +- `POST /api/mock-applies/actual` +- `POST /api/mock-applies/mock/from-job-posting` +- `POST /api/mock-applies/mock` +- `POST /api/mock-applies/{mockApplyId}/retry` +- `GET /api/mock-applies/{mockApplyId}/job-posting` +- `GET /api/mock-applies/{mockApplyId}/sequence` + +### ✍️ 문항 / 답변 / 분석 + +- `GET /api/mock-applies/{mockApplyId}/questions/candidates` +- `POST /api/mock-applies/{mockApplyId}/questions/candidates` +- `GET /api/mock-applies/{mockApplyId}/questions` +- `PUT /api/mock-applies/{mockApplyId}/questions` +- `PATCH /api/mock-applies/{mockApplyId}/questions/answers` +- `POST /api/mock-applies/{mockApplyId}/analysis` +- `GET /api/mock-applies/{mockApplyId}/analysis` +- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}` +- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}/stream` +- `GET /api/job-postings/{jobPostingId}/analysis` + +### 💳 결제 / 크레딧 + +- `GET /api/payments/plans` +- `POST /api/payments/prepare` +- `POST /api/payments/confirm` +- `GET /api/payments/credits/me` +- `GET /api/payments/credits/me/transactions` + +### 🔔 알림 + +- `GET /api/notifications` +- `GET /api/notifications/unread-count` +- `GET /api/notifications/stream` +- `PATCH /api/notifications/{notificationId}/read` +- `PATCH /api/notifications/read-all` + +### 🛠️ 관리자 / 워커 내부 API + +- `POST /api/admin/corpus/import` +- `POST /api/admin/corpus/import/upload` +- `POST /api/admin/corpus/embeddings/sync` +- `POST /api/admin/analysis/retrieval-preview` +- `POST /api/internal/worker/job-postings/...` +- `POST /api/internal/worker/analysis/...` + +## 🏛️ System Architecture + +### 1. 전체 서버 아키텍처 + +```mermaid +graph TD + Client[Web Client] -->|JWT / OAuth2 / REST| Spring[Spring Boot API] + Client -->|SSE| Spring + Client -->|Presigned URL Upload| S3[(AWS S3)] + + Spring -->|JPA| Postgres[(PostgreSQL)] + Spring -->|Cache / Token / Realtime state| Redis[(Redis)] + Spring -->|AMQP Publish| Rabbit[(RabbitMQ)] + Spring -->|OpenAI Responses API| OpenAI[OpenAI] + Spring -->|Embedding Sync| Cohere[Cohere] + Spring -->|Payment Confirm| Toss[Toss Payments] + Spring -->|SMTP| Gmail[Gmail SMTP] + + Rabbit -->|Task Consume| Worker[FastAPI Worker
external deployment] + Worker -->|Internal callback API| Spring + + GitHub[GitHub Actions] -->|Build jar / Docker image| GHCR[GHCR] + GHCR -->|Pull latest image| EC2[EC2 + Docker Compose] + EC2 -->|Reverse Proxy| Nginx[Nginx] + Nginx --> Spring ``` -스모크 테스트: +### 2. 비동기 워커 처리 흐름 + +```mermaid +sequenceDiagram + participant U as User + participant C as Client + participant S as Spring Boot API + participant MQ as RabbitMQ + participant W as FastAPI Worker + participant DB as PostgreSQL + participant SSE as SSE Stream + + U->>C: 채용 공고 분석 / 자소서 분석 요청 + C->>S: 비동기 API 호출 + S->>DB: async task 생성 + S->>MQ: 작업 메시지 publish + S-->>C: taskId 반환 + + C->>SSE: taskId 기준 상태 스트림 구독 + MQ->>W: 작업 전달 + W->>S: context 조회 (internal API) + W->>S: running / retry / failed / complete callback + S->>DB: 상태 및 결과 저장 + S-->>SSE: 상태 변경 이벤트 전송 + SSE-->>C: 진행 상태 / 완료 / 실패 수신 +``` -1. `docker compose up --build` -2. 로그인 토큰을 준비합니다. -3. `POST /api/job-postings/ingest` 로 `rawText` 또는 `imageObjectKey`를 전송합니다. -4. 응답의 `taskId`로 `GET /api/job-postings/ingest/async/{taskId}` 를 조회합니다. -5. worker가 성공하면 `SUCCEEDED`, 실패하면 `FAILED` 와 에러 메시지가 반환됩니다. +## 🔁 Async Worker Flow -worker 단독 실행: +이 서버의 핵심 비동기 작업은 `채용 공고 ingest`와 `자소서 분석` 두 종류입니다. -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r worker/requirements.txt -uvicorn worker.app.main:app --host 0.0.0.0 --port 8000 -``` +1. 사용자가 비동기 API를 호출합니다. +2. Spring 서버가 task row를 생성합니다. +3. Spring 서버가 RabbitMQ exchange/queue로 작업 메시지를 publish 합니다. +4. 외부 FastAPI worker가 메시지를 consume 합니다. +5. worker는 필요 컨텍스트를 internal API로 조회합니다. +6. worker가 AI 처리 결과를 internal callback API로 완료/실패 반영합니다. +7. Spring 서버가 DB 저장, 알림 생성, SSE 상태 전파를 수행합니다. -## Corpus Import Script +> 현재 이 저장소에는 **Spring Boot API 서버 코드만 포함**되어 있습니다. +> `RabbitMQ`로 연동되는 **FastAPI worker는 외부 배포 단위**로 전제되어 있으며, +> `docker-compose.yml`에는 worker 서비스가 정의되어 있지만 실제 `worker/` 디렉터리는 이 리포지토리에 포함되어 있지 않습니다. -대용량 corpus 엑셀 적재는 관리자 API 대신 Python 스크립트로 직접 실행할 수 있습니다. - -설치: +## 📂 Project Structure ```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r scripts/requirements-corpus-import.txt +com.jobdri.jobdri_api +├── domain +│ ├── analysis # 자소서 분석, 질문 선택, async task, worker bridge +│ ├── auth # 회원가입, 로그인, 이메일 인증, OAuth2 +│ ├── classification # 대/중/소분류 직무 체계 조회 +│ ├── company # 회사 엔티티 +│ ├── corpus # corpus 적재, 임베딩 동기화, retrieval +│ ├── jobposting # 공고 저장/수정/생성/추출/ingest/extension 연동 +│ ├── mockapply # 모의 지원 생성, 회차, 재도전 +│ ├── notification # SSE 알림, 읽음 처리 +│ ├── payment # 크레딧, 결제, 거래 내역 +│ ├── audit # 감사 로그 AOP +│ ├── user # 사용자 도메인 +│ └── skill / experience +├── global +│ ├── apiPayload # 공통 응답/에러 코드 +│ ├── config # Security, Swagger, RabbitMQ, AWS S3, Async 설정 +│ ├── jwt # JWT 필터/유틸 +│ ├── mq # RabbitMQ publish support +│ ├── scheduling # async timeout sweep scheduler +│ ├── security # UserDetails, internal API key validator +│ └── sse # SSE subscription registry +└── resources + ├── application*.yaml + └── schema.sql ``` -실행: +## 🚀 Local Run + +### 1. 애플리케이션 실행 ```bash cp .env.example .env -python scripts/import_corpus.py /absolute/path/to/file.xlsx +./gradlew bootRun ``` -- `.env`의 `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`를 사용합니다. -- 기본 env 파일 경로를 바꾸려면 `--env-file /path/to/.env` 옵션을 사용할 수 있습니다. -- 엑셀 시트명은 `jd_embed_corpus`, `question_embed_corpus`를 사용합니다. -- `source_analysis_id`, `source_question_id` 기준으로 `INSERT ... ON CONFLICT DO UPDATE` 방식으로 적재합니다. - -## Corpus Embedding Sync Script +기본 포트: -관리자 API 대신 Python 스크립트로 corpus 임베딩을 일괄 동기화할 수 있습니다. +- API: `http://localhost:8080` +- Swagger: `http://localhost:8080/swagger-ui/index.html` -실행: +### 2. 테스트 실행 ```bash -source .venv/bin/activate -pip install -r scripts/requirements-corpus-import.txt -python scripts/sync_corpus_embeddings.py --env-file .env +./gradlew test ``` -옵션 예시: +테스트 환경은 `H2 (MODE=PostgreSQL)`를 사용합니다. + +### 3. Docker Compose ```bash -python scripts/sync_corpus_embeddings.py --env-file .env --limit 100 -python scripts/sync_corpus_embeddings.py --env-file .env --job-only -python scripts/sync_corpus_embeddings.py --env-file .env --question-only --batch-size 16 +cp .env.example .env +docker compose up --build ``` -- `.env`의 `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `COHERE_API_KEY`를 사용합니다. -- 기본 모델은 `embed-v4.0`, 기본 배치 크기는 `32`입니다. -- `mock_job_posting_embeddings`, `mock_question_embeddings` 테이블에 `INSERT ... ON CONFLICT DO UPDATE` 방식으로 적재합니다. +포함 의도 서비스: -## CI/CD +- Spring Boot API +- PostgreSQL +- Redis +- RabbitMQ +- external FastAPI worker 연결용 worker service 정의 -- `CI`: `main`, `develop` 브랜치 push 및 PR에서 테스트와 Docker 이미지 빌드를 실행합니다. -- `Deploy`: `main` 브랜치 push 또는 수동 실행 시 GHCR에 이미지를 푸시하고, 배포 서버 secret이 있으면 SSH로 `docker-compose.prod.yml`을 갱신합니다. +주의사항: -GitHub Actions 배포 secret: +- 현재 `docker-compose.yml`은 `worker/Dockerfile`을 참조하지만, 이 리포지토리에는 `worker/` 디렉터리가 없습니다. +- 따라서 로컬에서 compose 전체를 그대로 올리려면 worker 구현체 또는 별도 이미지 준비가 필요합니다. -- `DEPLOY_HOST` -- `DEPLOY_USER` -- `DEPLOY_PORT` optional, default `22` -- `DEPLOY_SSH_KEY` -- `DEPLOY_PATH` -- `GHCR_USERNAME` -- `GHCR_TOKEN` +## ⚙️ Environment -## 배포 환경 +주요 환경 변수: -백엔드 API는 Docker 이미지로 빌드되어 GHCR에 업로드되고, GitHub Actions를 통해 EC2 서버에 배포됩니다. +- `DB_URL`, `DB_USERNAME`, `DB_PASSWORD` +- `REDIS_HOST`, `REDIS_PORT` +- `RABBITMQ_HOST`, `RABBITMQ_PORT`, `RABBITMQ_USERNAME`, `RABBITMQ_PASSWORD` +- `JWT_SECRET_KEY`, `JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION` +- `MAIL_HOST`, `MAIL_USERNAME`, `MAIL_PASSWORD` +- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` +- `OPENAI_API_KEY` +- `COHERE_API_KEY` +- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET` +- `TOSS_CLIENT_KEY`, `TOSS_SECRET_KEY` +- `APP_WORKER_INTERNAL_API_KEY` -### 인프라 +worker 연동 관련 기본 queue 설정: -- 서버: AWS EC2 `t3.small` -- OS: Amazon Linux 2023 -- DB: AWS RDS PostgreSQL `db.t3.micro` -- Redis: Upstash Redis -- 웹 서버: Nginx -- 컨테이너 런타임: Docker, Docker Compose +- `APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest` +- `APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute` -### 배포 흐름 +## 🧠 Corpus / Retrieval -1. `main` 브랜치에 변경사항이 반영됩니다. -2. GitHub Actions가 Docker 이미지를 빌드합니다. -3. 이미지를 GHCR에 푸시합니다. -4. GitHub Actions가 EC2에 SSH로 접속합니다. -5. EC2에서 `docker-compose.prod.yml`을 사용해 최신 이미지를 pull하고 API 컨테이너를 재기동합니다. +이 프로젝트는 단순 LLM 호출만 사용하는 구조가 아니라, corpus 기반 retrieval를 함께 사용합니다. -### 서버 배포 경로 +- 채용 공고 corpus 적재 +- 질문 corpus 적재 +- Cohere 임베딩 생성 +- pgvector 저장 +- 모의 공고 생성 및 자소서 분석 시 retrieval context 참고 +- 관리자 preview API로 retrieval 결과 사전 점검 가능 -```bash -/opt/jobdri-api -``` +Python 스크립트도 포함되어 있습니다. -서버에는 아래 파일이 필요합니다. +- `scripts/import_corpus.py` +- `scripts/sync_corpus_embeddings.py` -```text -docker-compose.prod.yml -.env -``` +## 🚀 Deployment Pipeline -## 운영 API 도메인 +GitHub Actions 기준 배포 흐름: -운영 API는 Nginx reverse proxy와 Let's Encrypt 인증서를 통해 HTTPS로 제공됩니다. +1. `main`, `dev` 브랜치 push 또는 PR 발생 +2. CI에서 JDK 21 기반 `./gradlew clean test` 실행 +3. Docker image build +4. Deploy workflow에서 GHCR에 이미지 push +5. 배포 secret이 존재하면 원격 서버에 SSH 접속 +6. 서버에서 `docker compose -f docker-compose.prod.yml pull api` +7. `docker compose -f docker-compose.prod.yml up -d api` -```text -https://api.jobdri.site -``` +현재 workflow 기준 특징: -- Nginx는 80/443 요청을 Spring Boot API 컨테이너의 8080 포트로 프록시합니다. -- SSL 인증서는 Certbot으로 발급했으며 자동 갱신이 설정되어 있습니다. -- 상태 확인: `https://api.jobdri.site/actuator/health` +- 배포 이미지는 `ghcr.io/jobdri-developer/backend` +- `docker-compose.prod.yml`은 **API 컨테이너 단독 배포** 기준 +- worker 배포는 이 리포지토리 workflow에 포함되어 있지 않습니다 -정상 응답: +## 📝 API Documentation -```json -{"status":"UP","groups":["liveness","readiness"]} -``` +SpringDoc Swagger UI: + +- Local: `http://localhost:8080/swagger-ui/index.html` +- Production: `https://api.jobdri.site/swagger-ui/index.html` + +추가 공개 경로: + +- OpenAPI docs: `/v3/api-docs` +- Health check: `/actuator/health` + +## 📌 현재 구현 범위 + +- 이메일 인증 기반 로컬 회원가입/로그인 +- Google OAuth2 로그인 +- JWT 인증/인가 및 로그아웃 처리 +- 직무 대/중/소분류 조회 +- 채용 공고 생성/수정/조회/삭제 +- 채용 공고 이미지 presigned upload +- AI 채용 공고 추출 +- AI 채용 공고 초안 생성 +- AI 모의 공고 생성 +- 추천 질문 캐시 및 조회 +- 실제/모의 공고 기반 모의 지원 생성 +- 질문 선택, 직접 문항 추가, 답변 저장 +- 자소서 분석 비동기 처리 +- 크레딧 차감/환불 +- 토스 결제 연동 +- SSE 알림 +- 관리자 corpus import / embedding sync +- RabbitMQ 기반 외부 worker 연동용 internal API +- async timeout sweep scheduler +- 감사 로그 적재 From ee4af8860d8b3979f9b19c9aea19263af9664355 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 7 Jul 2026 16:53:30 +0900 Subject: [PATCH 19/27] =?UTF-8?q?[Fix]=20isolate=20async=20notifications?= =?UTF-8?q?=20and=20sanitize=20failure=20messages=20(#121)=20-=20async=20t?= =?UTF-8?q?ask=20=EC=99=84=EB=A3=8C/=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=EC=9D=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=EC=9D=B4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EB=A1=A4=EB=B0=B1=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=95=8C=EB=A6=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EB=B3=84=EB=8F=84=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=ED=98=B8=EC=B6=9C=EB=B6=80=EB=A5=BC=20bes?= =?UTF-8?q?t-effort=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D/=EC=B1=84=EC=9A=A9=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=8C=EB=A6=BC=EC=97=90=EC=84=9C=20wor?= =?UTF-8?q?ker=20=EC=97=90=EB=9F=AC=20=EC=9B=90=EB=AC=B8=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9A=A9=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20SSE=20=EC=9D=91=EB=8B=B5=EC=97=90=20Cache-Control:?= =?UTF-8?q?=20no-cache,=20X-Accel-Buffering:=20no=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20unread=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=97=90=20=EB=A7=9E=EA=B2=8C=20notification?= =?UTF-8?q?s=20unread=20index=EB=A5=BC=20partial=20index=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20-=20job=20posting=20async=20notification?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=99=80=20?= =?UTF-8?q?import/style=20=EC=A0=95=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/db/migrations/20260707_notifications.sql | 3 +- .../service/AnalysisAsyncTaskService.java | 31 +++++++- .../service/JobPostingAsyncTaskService.java | 36 +++++++-- .../controller/NotificationController.java | 10 ++- .../service/NotificationService.java | 3 +- .../global/sse/SseSubscriptionRegistry.java | 5 +- .../JobPostingAsyncTaskServiceTest.java | 74 ++++++++++++++++++- 7 files changed, 146 insertions(+), 16 deletions(-) diff --git a/ops/db/migrations/20260707_notifications.sql b/ops/db/migrations/20260707_notifications.sql index 5640723..b290381 100644 --- a/ops/db/migrations/20260707_notifications.sql +++ b/ops/db/migrations/20260707_notifications.sql @@ -17,4 +17,5 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_created_at ON notifications (user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_notifications_user_read_at - ON notifications (user_id, read_at); + ON notifications (user_id, read_at) + WHERE read_at IS NULL; 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 b7b0fde..5eb3f75 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 @@ -12,6 +12,7 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,7 @@ @Service @RequiredArgsConstructor +@Slf4j // 분석 비동기 task 엔티티의 생성, 상태 전이, 조회를 전담하는 서비스다. public class AnalysisAsyncTaskService { @@ -69,7 +71,7 @@ public void markSuccess(String taskId) { AnalysisAsyncTask task = getTask(taskId); task.markSuccess(); publishAfterCommit(toStatusResponse(task)); - createSuccessNotification(task); + createSuccessNotificationSafely(task); } @Transactional @@ -84,7 +86,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM AnalysisAsyncTask task = getTask(taskId); task.markFailed(failureReason, errorMessage, retryCount); publishAfterCommit(toStatusResponse(task)); - createFailureNotification(task); + createFailureNotificationSafely(task); } @Transactional @@ -169,6 +171,28 @@ public void afterCommit() { }); } + private void createSuccessNotificationSafely(AnalysisAsyncTask task) { + try { + createSuccessNotification(task); + } catch (Exception e) { + log.warn("분석 완료 알림 생성에 실패했습니다. taskId={}, userId={}", task.getTaskId(), task.getUserId(), e); + } + } + + private void createFailureNotificationSafely(AnalysisAsyncTask task) { + try { + createFailureNotification(task); + } catch (Exception e) { + log.warn( + "분석 실패 알림 생성에 실패했습니다. taskId={}, userId={}, error={}", + task.getTaskId(), + task.getUserId(), + task.getError(), + e + ); + } + } + private void createSuccessNotification(AnalysisAsyncTask task) { Map payload = new LinkedHashMap<>(); payload.put("taskId", task.getTaskId()); @@ -187,6 +211,7 @@ private void createSuccessNotification(AnalysisAsyncTask task) { } private void createFailureNotification(AnalysisAsyncTask task) { + String userFacingMessage = "자소서 분석 처리 중 오류가 발생했습니다."; Map payload = new LinkedHashMap<>(); payload.put("taskId", task.getTaskId()); payload.put("mockApplyId", task.getMockApplyId()); @@ -197,7 +222,7 @@ private void createFailureNotification(AnalysisAsyncTask task) { task.getUserId(), NotificationType.ANALYSIS_ASYNC_FAILED, "자소서 분석이 실패했습니다.", - task.getError() != null ? task.getError() : "자소서 분석 처리 중 오류가 발생했습니다.", + userFacingMessage, NotificationTargetType.ANALYSIS_TASK, task.getTaskId(), payload diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 1154cb1..52275a5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class JobPostingAsyncTaskService { private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; @@ -81,7 +83,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } task.markSuccess(serializeResult(result)); publishAfterCommit(toStatusResponse(task)); - createSuccessNotification(task, result); + createSuccessNotificationSafely(task, result); return result; } @@ -103,7 +105,7 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM } task.markFailed(failureReason, errorMessage, retryCount); publishAfterCommit(toStatusResponse(task)); - createFailureNotification(task); + createFailureNotificationSafely(task); } @Transactional @@ -251,7 +253,30 @@ public void afterCommit() { }); } + private void createSuccessNotificationSafely(JobPostingAsyncTask task, JobPostingIngestResponse result) { + try { + createSuccessNotification(task, result); + } catch (Exception e) { + log.warn("채용 공고 완료 알림 생성에 실패했습니다. taskId={}, userId={}", task.getTaskId(), task.getUserId(), e); + } + } + + private void createFailureNotificationSafely(JobPostingAsyncTask task) { + try { + createFailureNotification(task); + } catch (Exception e) { + log.warn( + "채용 공고 실패 알림 생성에 실패했습니다. taskId={}, userId={}, error={}", + task.getTaskId(), + task.getUserId(), + task.getError(), + e + ); + } + } + private void createSuccessNotification(JobPostingAsyncTask task, JobPostingIngestResponse result) { + boolean hasSavedJobPosting = result.getSaved() != null && result.getSaved().getJobPostingId() != null; Map payload = new LinkedHashMap<>(); payload.put("taskId", task.getTaskId()); payload.put("savedToDatabase", result.isSavedToDatabase()); @@ -264,10 +289,10 @@ private void createSuccessNotification(JobPostingAsyncTask task, JobPostingInges result.isSavedToDatabase() ? "채용 공고 분석과 저장이 완료되었습니다." : "채용 공고 분석이 완료되었습니다.", - result.getSaved() != null && result.getSaved().getJobPostingId() != null + hasSavedJobPosting ? NotificationTargetType.JOB_POSTING_RESULT : NotificationTargetType.JOB_POSTING_TASK, - result.getSaved() != null && result.getSaved().getJobPostingId() != null + hasSavedJobPosting ? String.valueOf(result.getSaved().getJobPostingId()) : task.getTaskId(), payload @@ -275,6 +300,7 @@ private void createSuccessNotification(JobPostingAsyncTask task, JobPostingInges } private void createFailureNotification(JobPostingAsyncTask task) { + String userFacingMessage = "채용 공고 작업 처리 중 오류가 발생했습니다."; Map payload = new LinkedHashMap<>(); payload.put("taskId", task.getTaskId()); payload.put("failureReason", task.getFailureReason() != null ? task.getFailureReason().name() : null); @@ -284,7 +310,7 @@ private void createFailureNotification(JobPostingAsyncTask task) { task.getUserId(), NotificationType.JOB_POSTING_ASYNC_FAILED, "채용 공고 작업이 실패했습니다.", - task.getError() != null ? task.getError() : "채용 공고 작업 처리 중 오류가 발생했습니다.", + userFacingMessage, NotificationTargetType.JOB_POSTING_TASK, task.getTaskId(), payload diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java index de349d0..fbe815d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/controller/NotificationController.java @@ -9,7 +9,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -52,10 +55,13 @@ public ApiResponse getUnreadCount( @Operation(summary = "알림 SSE 구독", description = "실시간 알림 스트림을 구독합니다.") @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter streamNotifications( + public ResponseEntity streamNotifications( @AuthenticationPrincipal UserDetailsImpl userDetails ) { - return notificationService.subscribe(userDetails.getUser()); + return ResponseEntity.ok() + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue()) + .header("X-Accel-Buffering", "no") + .body(notificationService.subscribe(userDetails.getUser())); } @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 처리합니다.") diff --git a/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java index 7e51c28..3dc0f93 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/notification/service/NotificationService.java @@ -18,6 +18,7 @@ import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -112,7 +113,7 @@ public NotificationReadAllResponse markAllRead(User user) { return new NotificationReadAllResponse(updatedCount, notificationRepository.countByUserIdAndReadAtIsNull(validatedUser.getId())); } - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public NotificationResponse createNotification( Long userId, NotificationType notificationType, diff --git a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java index 3f5f6dc..a6478ca 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java +++ b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java @@ -75,10 +75,9 @@ public SseEmitter subscribe( }); emitter.onError(ignored -> remove(channelKey, emitter)); - T initialPayload = initialPayloadSupplier.get(); - boolean completeAfterInitial = completeAfterInitialPredicate.test(initialPayload); - try { + T initialPayload = initialPayloadSupplier.get(); + boolean completeAfterInitial = completeAfterInitialPredicate.test(initialPayload); emitter.send(SseEmitter.event().name("connected").data("connected")); emitter.send(SseEmitter.event().name(eventName).data(initialPayload)); if (completeAfterInitial) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index a01ed86..27fd097 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -2,9 +2,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; +import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +24,7 @@ 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.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,7 +39,7 @@ class JobPostingAsyncTaskServiceTest { private JobPostingAsyncSseService jobPostingAsyncSseService; @Mock - private com.jobdri.jobdri_api.domain.notification.service.NotificationService notificationService; + private NotificationService notificationService; private JobPostingAsyncTaskService jobPostingAsyncTaskService; @@ -176,5 +181,72 @@ void markRetryScheduledDoesNothingWhenTaskIsTerminal() { assertThat(task.getError()).isEqualTo(originalError); assertThat(task.getRetryCount()).isEqualTo(originalRetryCount); verify(jobPostingAsyncSseService, never()).publish(any()); + verify(notificationService, never()).createNotification(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("성공 처리 시 알림 서비스를 호출한다") + void markSuccessCreatesNotification() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + JobPostingResponse saved = JobPostingResponse.builder() + .jobPostingId(123L) + .build(); + JobPostingIngestResponse result = new JobPostingIngestResponse(true, null, null, null, null, null, saved); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.markSuccess(task.getTaskId(), result); + + verify(notificationService).createNotification( + eq(7L), + eq(NotificationType.JOB_POSTING_ASYNC_SUCCEEDED), + eq("채용 공고 작업이 완료되었습니다."), + eq("채용 공고 분석과 저장이 완료되었습니다."), + eq(NotificationTargetType.JOB_POSTING_RESULT), + eq("123"), + any() + ); + } + + @Test + @DisplayName("실패 처리 시 사용자에게는 일반화된 실패 알림을 보낸다") + void markFailedCreatesSanitizedNotification() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.markFailed( + task.getTaskId(), + FailureReason.INTERNAL_ERROR, + "worker stack trace", + 1 + ); + + verify(notificationService).createNotification( + eq(7L), + eq(NotificationType.JOB_POSTING_ASYNC_FAILED), + eq("채용 공고 작업이 실패했습니다."), + eq("채용 공고 작업 처리 중 오류가 발생했습니다."), + eq(NotificationTargetType.JOB_POSTING_TASK), + eq(task.getTaskId()), + any() + ); + } + + @Test + @DisplayName("재시도 예약 중에는 알림을 생성하지 않는다") + void markRetryScheduledDoesNotCreateNotification() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.markRetryScheduled( + task.getTaskId(), + FailureReason.INTERNAL_ERROR, + "retry scheduled", + 1 + ); + + verify(notificationService, never()).createNotification(any(), any(), any(), any(), any(), any(), any()); } } From 140f0709b01b21e9f2fd73378f2e0853bb9d4088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=8B=A0=EC=95=A0?= Date: Tue, 7 Jul 2026 16:58:19 +0900 Subject: [PATCH 20/27] [Docs] Update README to clarify worker implementation details Removed notes about the absence of the worker directory and its Dockerfile in the repository, clarifying the project structure and deployment details. --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index 7b6165f..e294cfa 100644 --- a/README.md +++ b/README.md @@ -278,10 +278,6 @@ sequenceDiagram 6. worker가 AI 처리 결과를 internal callback API로 완료/실패 반영합니다. 7. Spring 서버가 DB 저장, 알림 생성, SSE 상태 전파를 수행합니다. -> 현재 이 저장소에는 **Spring Boot API 서버 코드만 포함**되어 있습니다. -> `RabbitMQ`로 연동되는 **FastAPI worker는 외부 배포 단위**로 전제되어 있으며, -> `docker-compose.yml`에는 worker 서비스가 정의되어 있지만 실제 `worker/` 디렉터리는 이 리포지토리에 포함되어 있지 않습니다. - ## 📂 Project Structure ```bash @@ -349,10 +345,6 @@ docker compose up --build - RabbitMQ - external FastAPI worker 연결용 worker service 정의 -주의사항: - -- 현재 `docker-compose.yml`은 `worker/Dockerfile`을 참조하지만, 이 리포지토리에는 `worker/` 디렉터리가 없습니다. -- 따라서 로컬에서 compose 전체를 그대로 올리려면 worker 구현체 또는 별도 이미지 준비가 필요합니다. ## ⚙️ Environment @@ -403,12 +395,6 @@ GitHub Actions 기준 배포 흐름: 6. 서버에서 `docker compose -f docker-compose.prod.yml pull api` 7. `docker compose -f docker-compose.prod.yml up -d api` -현재 workflow 기준 특징: - -- 배포 이미지는 `ghcr.io/jobdri-developer/backend` -- `docker-compose.prod.yml`은 **API 컨테이너 단독 배포** 기준 -- worker 배포는 이 리포지토리 workflow에 포함되어 있지 않습니다 - ## 📝 API Documentation SpringDoc Swagger UI: From 1830e66720acf37d6c251078133ada5d8a322ef8 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 8 Jul 2026 01:40:33 +0900 Subject: [PATCH 21/27] =?UTF-8?q?[Feat]=20=EC=A7=81=EB=AC=B4=20=EC=A4=91?= =?UTF-8?q?=EB=B6=84=EB=A5=98=20=ED=8F=89=EA=B0=80=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EB=B0=8F=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직무 중분류 평가 기준이 없으면 프롬프트 보조 기준 섹션 생략 - 중분류명 매칭 시 공백 및 구분자 정규화 처리 - blank 중분류 기준 row skip 시 warn 로그 추가 - AnalysisService payload에 중분류 평가 기준이 연결되는 경로 테스트 추가 - Provider 정규화 조회 테스트 및 프롬프트 섹션 생략 테스트 보강 --- .../JobCategoryEvaluationCriteria.java | 16 + .../analysis/service/AnalysisAiClient.java | 66 +- .../service/AnalysisExecutionPayload.java | 13 +- .../analysis/service/AnalysisService.java | 17 +- ...JobCategoryEvaluationCriteriaProvider.java | 71 ++ .../job-category-evaluation-criteria.json | 1028 +++++++++++++++++ .../service/AnalysisAiClientTest.java | 90 ++ .../analysis/service/AnalysisServiceTest.java | 61 +- ...ategoryEvaluationCriteriaProviderTest.java | 48 + 9 files changed, 1401 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProvider.java create mode 100644 src/main/resources/analysis/job-category-evaluation-criteria.json create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProviderTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java new file mode 100644 index 0000000..15733c5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.criteria; + +import java.util.List; + +public record JobCategoryEvaluationCriteria( + String jobCategoryMiddle, + List includedJobs, + List competencyTags, + List coreCompetencies, + List relatedActions, + List relatedExperiences, + List relatedKeywords, + String goodEvidenceExample, + List missingKeywordExamples +) { +} 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 d58015a..ec6dbca 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 @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; @@ -28,6 +29,7 @@ public class AnalysisAiClient { private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; private static final int MAX_REFERENCE_FIELD_LENGTH = 300; + private static final int MAX_CRITERIA_ITEMS = 5; private static final String OUTPUT_SCHEMA = """ [출력 형식] { @@ -177,10 +179,18 @@ public class AnalysisAiClient { private String analysisModel; public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { - return analyze(payload.jobPosting(), payload.answeredQuestions()); + return analyze(payload.jobPosting(), payload.answeredQuestions(), payload.jobCategoryEvaluationCriteria()); } public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { + return analyze(jobPosting, questions, null); + } + + public AnalysisLlmResponse analyze( + JobPosting jobPosting, + List questions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { RetrievalContext referenceContext = emptyContext(); try { referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions); @@ -190,7 +200,7 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List questio } var params = ResponseCreateParams.builder() .model(analysisModel) - .input(buildPrompt(jobPosting, questions, referenceContext)) + .input(buildPrompt(jobPosting, questions, referenceContext, jobCategoryEvaluationCriteria)) .temperature(0.2) .text(AnalysisLlmResponse.class) .build(); @@ -212,10 +222,11 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List questio } } - private String buildPrompt( + String buildPrompt( JobPosting jobPosting, List questions, - RetrievalContext referenceContext + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { String questionText = questions.stream() .map(question -> """ @@ -231,6 +242,7 @@ private String buildPrompt( String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); return """ [시스템 지시] @@ -264,6 +276,8 @@ private String buildPrompt( [유사 자소서 문항 검색 결과] %s + %s + [자소서 문항과 답변] %s @@ -291,10 +305,54 @@ private String buildPrompt( defaultString(jobPosting.getPreferred()), similarJobPostingText, similarQuestionText, + jobCategoryCriteriaSection, questionText ); } + private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { + if (criteria == null) { + return ""; + } + + return """ + [직무별 보조 평가 기준] + 중분류: %s + 주의: + - 이 직무별 기준은 실제 JD를 대체하지 않는다. + - 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다. + - 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다. + - missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다. + - 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다. + - 원문에 없는 수치, 도구, 경험을 만들어내지 않는다. + 핵심 역량: %s + 관련 행동: %s + 관련 키워드: %s + 좋은 근거 예시: %s + 누락 키워드 문구 예시: %s + """.formatted( + defaultString(criteria.jobCategoryMiddle()), + formatCriteriaList(criteria.coreCompetencies()), + formatCriteriaList(criteria.relatedActions()), + formatCriteriaList(criteria.relatedKeywords()), + truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH), + formatCriteriaList(criteria.missingKeywordExamples()) + ).trim(); + } + + private String formatCriteriaList(List values) { + if (values == null || values.isEmpty()) { + return "없음"; + } + String formatted = values.stream() + .filter(value -> value != null && !value.isBlank()) + .limit(MAX_CRITERIA_ITEMS) + .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return formatted.isBlank() ? "없음" : "\n" + formatted; + } + private String formatJobPostingReferences(List references) { if (references == null || references.isEmpty()) { return "없음"; 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 index 078c996..73c3383 100644 --- 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 @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; @@ -11,6 +12,16 @@ public record AnalysisExecutionPayload( Long mockApplyId, JobPosting jobPosting, List questions, - List answeredQuestions + List answeredQuestions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { + public AnalysisExecutionPayload( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions + ) { + this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null); + } } 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 01b7650..926d21c 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; @@ -68,6 +69,7 @@ public class AnalysisService { private final AnalysisAiClient analysisAiClient; private final CreditService creditService; private final ObjectMapper objectMapper; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; @Transactional @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") @@ -130,18 +132,29 @@ public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApp // 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(); + JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider + .findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) + .orElse(null); return new AnalysisExecutionPayload( user.getId(), mockApplyId, mockApply.getJobPosting(), List.copyOf(questions), - List.copyOf(answeredQuestions) + List.copyOf(answeredQuestions), + evaluationCriteria ); } public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { - return analysisAiClient.analyze(payload.jobPosting(), payload.answeredQuestions()); + if (payload.jobCategoryEvaluationCriteria() == null) { + return analysisAiClient.analyze(payload.jobPosting(), payload.answeredQuestions()); + } + return analysisAiClient.analyze( + payload.jobPosting(), + payload.answeredQuestions(), + payload.jobCategoryEvaluationCriteria() + ); } @Transactional diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProvider.java new file mode 100644 index 0000000..3c8998c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProvider.java @@ -0,0 +1,71 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +@Slf4j +public class JobCategoryEvaluationCriteriaProvider { + private static final String RESOURCE_PATH = "analysis/job-category-evaluation-criteria.json"; + private static final TypeReference> CRITERIA_TYPE = new TypeReference<>() { + }; + + private final Map criteriaByMiddleName; + + public JobCategoryEvaluationCriteriaProvider(ObjectMapper objectMapper) { + this.criteriaByMiddleName = loadCriteria(objectMapper); + log.info("Loaded job category evaluation criteria. count={}", criteriaByMiddleName.size()); + } + + public Optional findByMiddleName(String middleName) { + if (!StringUtils.hasText(middleName)) { + return Optional.empty(); + } + return Optional.ofNullable(criteriaByMiddleName.get(normalizeKey(middleName))); + } + + private Map loadCriteria(ObjectMapper objectMapper) { + ClassPathResource resource = new ClassPathResource(RESOURCE_PATH); + try (InputStream inputStream = resource.getInputStream()) { + List criteria = objectMapper.readValue(inputStream, CRITERIA_TYPE); + Map result = new LinkedHashMap<>(); + + for (JobCategoryEvaluationCriteria item : criteria) { + if (item == null || !StringUtils.hasText(item.jobCategoryMiddle())) { + log.warn("Skipped invalid job category evaluation criteria row. reason=blank_middle_name"); + continue; + } + + String key = normalizeKey(item.jobCategoryMiddle()); + if (result.containsKey(key)) { + throw new IllegalStateException("중복된 직무 중분류 평가 기준입니다. middleName=" + item.jobCategoryMiddle()); + } + result.put(key, item); + } + + return Map.copyOf(result); + } catch (IOException e) { + throw new IllegalStateException("직무 중분류 평가 기준 resource 로딩에 실패했습니다. path=" + RESOURCE_PATH, e); + } + } + + private String normalizeKey(String value) { + return value.trim() + .replace('ㆍ', '·') + .replace('・', '·') + .replace('/', '·') + .replaceAll("\\s+", ""); + } +} diff --git a/src/main/resources/analysis/job-category-evaluation-criteria.json b/src/main/resources/analysis/job-category-evaluation-criteria.json new file mode 100644 index 0000000..1560361 --- /dev/null +++ b/src/main/resources/analysis/job-category-evaluation-criteria.json @@ -0,0 +1,1028 @@ +[ + { + "jobCategoryMiddle": "엔지니어링·설계", + "includedJobs": [ + "공정개발", + "공정기술", + "기계설계", + "레이더 장비 설치 및 시운전", + "반도체 공정 개발", + "반도체 공정 엔지니어", + "반도체 공정기술", + "반도체공정기술", + "생산기술", + "설계", + "설비기술", + "양산기술", + "연구개발", + "장비 엔지니어", + "재료개발", + "전기기술", + "차량 시스템 개발", + "차량 아키텍처 개발", + "회로설계" + ], + "competencyTags": [ + "설계검증", + "공정최적화", + "도면해독", + "기술적문제해결", + "협업" + ], + "coreCompetencies": [ + "기계/회로/공정 설계 및 도면 분석", + "성능 시뮬레이션 및 설계 타당성 검증" + ], + "relatedActions": [ + "설계 요구 사양서 검토 및 사양에 부합하는 구조/회로 설계", + "시뮬레이션 툴을 활용한 오차 범위 최소화 및 설계 최적화" + ], + "relatedExperiences": [ + "신제품 및 신기술 설계 프로젝트 참여 경험", + "시뮬레이션 툴 활용 및 프로토타입 설계 검증 경험" + ], + "relatedKeywords": [ + "CAD", + "3D 모델링", + "시뮬레이션", + "도면 분석", + "회로/구조 설계", + "설계 최적화" + ], + "goodEvidenceExample": "설계 사양 검토 과정에서 잠재적 오류 리스크를 식별하고, 가상 시뮬레이션을 통해 구조적 취약점이나 신호 왜곡을 보완하여 설계 완성도를 높인 경험", + "missingKeywordExamples": [ + "설계 타당성 검증 경험", + "시뮬레이션 기반 설계 최적화", + "도면 분석 및 오류 식별 경험", + "공정 최적화 경험", + "CAD/3D 모델링 활용 경험" + ] + }, + { + "jobCategoryMiddle": "제조·생산", + "includedJobs": [ + "공정기술", + "생산", + "생산관리", + "생산기술", + "생산운영", + "생산직", + "설비관리", + "안전관리", + "양산기술", + "오퍼레이터", + "제조", + "제조직", + "품질관리", + "품질보증", + "플랜트 운영" + ], + "competencyTags": [ + "공정관리", + "수율개선", + "생산계획", + "안전관리", + "현장협업" + ], + "coreCompetencies": [ + "생산 공정 관리 및 수율 개선", + "제조 설비 가동 최적화 및 유지보수" + ], + "relatedActions": [ + "공정 주요 변수 모니터링 및 표준 작업 기준(SOP) 수립", + "가동 정지 리스크 방지를 위한 예방 정비 및 트러블슈팅" + ], + "relatedExperiences": [ + "양산 공정 운영 및 수율 향상 개선 활동 경험", + "현장 생산 설비 정기 점검 및 고장 대응 경험" + ], + "relatedKeywords": [ + "생산 공정 관리", + "수율 향상", + "설비 보전", + "표준운영절차(SOP)", + "트러블슈팅" + ], + "goodEvidenceExample": "양산 단계에서 발생한 공정 병목 또는 설비 알람 데이터를 분석하여, 작업 방식을 개선하거나 설비 파라미터를 최적화해 생산 수율과 안정성을 개선한 경험", + "missingKeywordExamples": [ + "공정 수율 개선 경험", + "표준작업지침(SOP) 수립 경험", + "설비 예방정비 조치 경험", + "공정 병목 원인 분석", + "생산 모니터링 체계 구축" + ] + }, + { + "jobCategoryMiddle": "기획·전략", + "includedJobs": [ + "ESG 관리", + "경영기획", + "경영지원", + "기획", + "사업개발", + "사업관리", + "사업기획", + "상품기획", + "서비스 기획", + "운영컨설팅", + "인턴", + "일반관리", + "전략기획", + "전시기획", + "카드상품기획", + "콘텐츠 기획" + ], + "competencyTags": [ + "문제정의", + "데이터기반의사결정", + "일정관리", + "커뮤니케이션", + "전략수립" + ], + "coreCompetencies": [ + "비즈니스 모델 기획 및 중장기 전략 수립", + "성과 지표(KPI) 설정 및 프로세스 혁신" + ], + "relatedActions": [ + "시장 트렌드 및 사내 운영 지표 분석을 통한 기획안 도출", + "다부서 이해관계 조율을 통한 프로젝트 관리(PM)" + ], + "relatedExperiences": [ + "신규 사업 타당성 검토 및 상품 기획 참여 경험", + "부서별 성과 관리 지표 설계 및 모니터링 경험" + ], + "relatedKeywords": [ + "사업 기획", + "지표 분석(KPI)", + "리소스 배분", + "이해관계자 조율", + "타당성 분석" + ], + "goodEvidenceExample": "사용자 지표 및 시장 조사를 기반으로 비즈니스 개선 기회를 포착하고, 세부 실행 계획 및 일정표를 조율하여 신규 서비스 출시를 총괄 기획한 경험", + "missingKeywordExamples": [ + "비즈니스 모델 기획 경험", + "성과 지표(KPI) 설계 경험", + "시장 트렌드 분석 경험", + "다부서 리소스 조율 경험", + "사업 타당성 검토 경험" + ] + }, + { + "jobCategoryMiddle": "의료·바이오", + "includedJobs": [ + "R&D", + "간호사", + "공정 엔지니어", + "공정엔지니어", + "미생물 연구", + "분석연구", + "소재개발", + "연구개발", + "연구원", + "임상병리사", + "품질관리", + "품질보증" + ], + "competencyTags": [ + "실험설계", + "데이터해석", + "분석연구", + "GMP준수", + "논리적분석" + ], + "coreCompetencies": [ + "미생물/생물학/의약품 연구 분석 및 시험 검증", + "임상·검체 데이터 관리와 GMP/GCP 등 규정 준수" + ], + "relatedActions": [ + "분석 장비를 활용한 시료/검체 분석 및 데이터 해석", + "임상/제조 현장에서 요구되는 절차와 문서 기준 준수 확인" + ], + "relatedExperiences": [ + "신소재/원료 분석 및 실험법 개발 프로젝트 경험", + "품질 검증을 위한 HPLC/GC 기기 분석, 검체 관리 또는 임상 데이터 기록 경험" + ], + "relatedKeywords": [ + "기기 분석", + "실험 설계", + "물성 해석", + "검체 관리", + "임상 데이터", + "GMP/GCP 준수", + "분석법 검증" + ], + "goodEvidenceExample": "실험실 또는 임상 현장에서 시료·검체 분석 데이터를 기반으로 유효성을 검증하고, 규정에 맞는 문서화와 품질 확인 절차를 수행하여 시험 과정의 신뢰성과 정밀도를 입증한 경험", + "missingKeywordExamples": [ + "실험 설계 및 수행 경험", + "시험 데이터 해석 경험", + "기기 분석 활용 경험", + "검체 관리 및 임상 기록 경험", + "GMP/GCP 규정 준수 경험" + ] + }, + { + "jobCategoryMiddle": "AI·개발·데이터", + "includedJobs": [ + "HW 개발", + "데이터 분석", + "백엔드 개발", + "서버 개발", + "소프트웨어 개발", + "시스템 운영", + "응용 개발", + "정보보안", + "제품개발", + "플랫폼 개발" + ], + "competencyTags": [ + "트러블슈팅", + "아키텍처설계", + "데이터분석", + "가설검증", + "논리적사고" + ], + "coreCompetencies": [ + "소프트웨어 아키텍처 설계 및 시스템 개발", + "데이터 수집/가공 및 분석 모델링" + ], + "relatedActions": [ + "요구사항 분석을 통한 효율적인 개발 및 리팩토링 수행", + "데이터 기반의 문제 진단 및 알고리즘을 통한 성능 최적화" + ], + "relatedExperiences": [ + "신규 서비스/플랫폼 개발 및 운영 프로젝트 경험", + "정형/비정형 데이터 정제 및 비즈니스 인사이트 도출 경험" + ], + "relatedKeywords": [ + "소프트웨어 아키텍처", + "데이터베이스 설계", + "API", + "데이터 모델링", + "최적화", + "트러블슈팅" + ], + "goodEvidenceExample": "데이터 처리 프로세스나 시스템 구조 상의 병목을 진단하고, 적합한 프레임워크나 최적화 기법을 도입하여 처리 속도 및 안정성을 향상시킨 경험", + "missingKeywordExamples": [ + "아키텍처 설계 및 리팩토링", + "데이터 파이프라인 구축 경험", + "API 설계 및 고도화", + "쿼리 실행 계획 최적화", + "시스템 트러블슈팅 경험" + ] + }, + { + "jobCategoryMiddle": "마케팅·광고·MD", + "includedJobs": [ + "MD", + "디지털 마케팅", + "마케팅", + "브랜드 마케팅", + "상품기획", + "언론홍보", + "온라인 마케팅", + "인플루언서 마케팅", + "전략마케팅", + "제품 마케팅", + "콘텐츠 기획", + "콘텐츠 마케팅", + "트렌드 마케팅", + "홍보" + ], + "competencyTags": [ + "성과측정", + "고객분석", + "시장조사", + "채널전략", + "트렌드분석" + ], + "coreCompetencies": [ + "디지털 광고 캠페인 설계 및 효율(ROAS) 분석", + "브랜드 비주얼 아이덴티티 수립 및 상품 소싱(MD)" + ], + "relatedActions": [ + "잠재 고객 타깃 세그먼트 분류 및 최적의 채널 광고 집행", + "브랜드 일관성을 유지하는 비주얼 가이드라인 작성 및 관리" + ], + "relatedExperiences": [ + "온라인 광고 캠페인 기획 및 퍼널별 전환율 개선 경험", + "브랜드 런칭/리뉴얼 및 신제품 MD 소싱 기획 경험" + ], + "relatedKeywords": [ + "ROAS 최적화", + "캠페인 관리", + "타깃 고객", + "상품 기획(MD)", + "브랜드 전략" + ], + "goodEvidenceExample": "고객 행동 데이터를 분석하여 전환율이 이탈하는 지점을 정의하고, 고객 맞춤형 프로모션 기획 및 광고 타겟팅 정교화를 통해 마케팅 성과(ROAS)를 향상시킨 경험", + "missingKeywordExamples": [ + "광고 캠페인 최적화 경험", + "ROAS 데이터 분석", + "브랜드 비주얼 가이드 수립", + "퍼널별 이탈률 전환 분석", + "마케팅 콘텐츠 기획" + ] + }, + { + "jobCategoryMiddle": "영업", + "includedJobs": [ + "기술지원", + "사무영업", + "영업", + "영업관리", + "영업마케팅", + "해외영업" + ], + "competencyTags": [ + "고객발굴", + "단가협상", + "수주리드", + "파트너십", + "목표달성" + ], + "coreCompetencies": [ + "신규 영업 기회 발굴 및 고객사 관리", + "영업 계약 체결 및 단가 협상(Negotiation)" + ], + "relatedActions": [ + "고객사별 요구 사양 파악 및 맞춤형 제안서 작성", + "리드 타임 및 매출 목표 관리, 거래처 이견 조율 및 수주 성공" + ], + "relatedExperiences": [ + "B2B/B2C 영업 전략 수립 및 파이프라인 관리 경험", + "국내외 바이어 비즈니스 매칭 및 수출/영업 계약 경험" + ], + "relatedKeywords": [ + "영업 제안", + "고객사 발굴", + "단가 협상", + "수주 리드", + "매출 목표 관리" + ], + "goodEvidenceExample": "고객사 원가 구조 및 구매 성향을 면밀히 파악하여 설득력 있는 맞춤형 제안과 협상을 이끌어내고, 이를 통해 장기 영업 계약 체결에 성공한 경험", + "missingKeywordExamples": [ + "신규 바이어 개척 경험", + "고객사 관리 및 네고 전략", + "영업 계약 단가 협상", + "매출 목표 파이프라인 관리", + "고객 요구사항 조율" + ] + }, + { + "jobCategoryMiddle": "법무·사무·총무", + "includedJobs": [ + "경영사무", + "비서", + "사무보조", + "사무지원", + "사무직원", + "송무직원", + "신입행원", + "일반직", + "행정", + "행정지원" + ], + "competencyTags": [ + "사무행정", + "문서표준화", + "계약검토", + "운영효율화", + "꼼꼼함" + ], + "coreCompetencies": [ + "사내 일반 행정 및 스케줄링 조율", + "계약 검토 지원 및 문서 표준화 관리" + ], + "relatedActions": [ + "우선순위 중심의 업무 스케줄 제어 및 기밀 사항 보안 통제", + "사내 표준 양식 가이드 제공 및 기획 보고 문서화" + ], + "relatedExperiences": [ + "회의록 작성 및 이사회 등 경영진 의전 행정 지원 경험", + "부서 비용 정산 총괄 및 계약서 초안 법무 검토 지원 경험" + ], + "relatedKeywords": [ + "사무 행정", + "문서 표준화", + "기밀 보안", + "일정 관리", + "전표 정산" + ], + "goodEvidenceExample": "부서별 기획 문서 및 보고 양식이 혼재해 있던 프로세스를 단일 표준 템플릿으로 통합하고, 비용 전표 처리 단계를 간소화하여 행정 소요 시간을 단축한 경험", + "missingKeywordExamples": [ + "사내 일반 행정 지원", + "회의 일정 및 스케줄 조율", + "계약서 검토 및 표준화", + "사내 자산 관리 경험", + "행정 업무 지원" + ] + }, + { + "jobCategoryMiddle": "인사·HR", + "includedJobs": [ + "HR전략수립", + "경영지원", + "인사", + "인사 운영", + "인사관리", + "인사운영", + "인사제도 기획", + "인턴" + ], + "competencyTags": [ + "인재확보", + "조직문화", + "평가보상", + "교육기획", + "인사운영" + ], + "coreCompetencies": [ + "채용 파이프라인 구축 및 인재 소싱", + "임직원 온보딩 및 교육 프로그램(HRD) 설계" + ], + "relatedActions": [ + "직무 분석을 통한 세부 요구 명세(JD) 정의", + "조직 문화 적합도 지표 정의 및 조직 진단 모니터링" + ], + "relatedExperiences": [ + "다이렉트 소싱을 통한 우수 인재 채용 프로젝트 리드 경험", + "사내 핵심 가치 전파 및 직급별 역량 교육 설계 경험" + ], + "relatedKeywords": [ + "채용 전략(TA)", + "직무 분석", + "조직 문화", + "HRD 교육", + "온보딩" + ], + "goodEvidenceExample": "사내 핵심 부서의 퇴사율 요인을 시계열 데이터로 분석하여 조직 문화적 갈등 포인트를 정의하고, 온보딩 교육 개선 및 직무 매칭 제도를 개선하여 고용 유지율을 높인 경험", + "missingKeywordExamples": [ + "채용 파이프라인 소싱 경험", + "온보딩 프로그램 기획", + "인사 제도 및 규정 정립", + "임직원 교육 과정 설계", + "조직 문화 진단 및 개선" + ] + }, + { + "jobCategoryMiddle": "금융·보험", + "includedJobs": [ + "(미분류)", + "금융상담", + "기업분석", + "신입행원", + "은행업무 지원", + "자산관리" + ], + "competencyTags": [ + "리스크관리", + "금융상품설계", + "고객상담", + "시장분석", + "수리분석" + ], + "coreCompetencies": [ + "여신/수신 금융 서비스 및 상품 제안", + "금융 리스크 스크리닝 및 투자 가치 평가(Valuation)" + ], + "relatedActions": [ + "기업 및 개인 재무 안정성 수리 분석 및 여신 적합성 판정", + "시장 거시 경제 지표 분석 리포트 작성" + ], + "relatedExperiences": [ + "금융 영업 지원 및 은행 업무 수검 준비 경험", + "기업 신용 분석 및 포트폴리오 리스크 평가 프로젝트 참여 경험" + ], + "relatedKeywords": [ + "금융 상품", + "리스크 심사", + "신용 분석", + "거시 경제 리서치", + "가치 평가" + ], + "goodEvidenceExample": "고객사 재무제표 상의 이상 지표와 산업 동향을 입체적으로 다각 분석하여, 여신 지원 여부를 결정하기 위한 정교한 리스크 진단 리포트를 작성한 경험", + "missingKeywordExamples": [ + "금융 상품 제안 경험", + "투자 타당성 리스크 평가", + "여신 수신 자산 심사", + "자금 조달 및 집행 관리", + "금융 데이터 트렌드 분석" + ] + }, + { + "jobCategoryMiddle": "물류·무역", + "includedJobs": [ + "구매", + "물류 운영", + "물류관리", + "물류운영", + "수출입통관", + "협력업체 관리" + ], + "competencyTags": [ + "공급망최적화", + "재고분석", + "수출입통관", + "운송조율", + "비용절감" + ], + "coreCompetencies": [ + "공급망(SCM) 최적화 및 수요 예측", + "무역 서류(CI, PL, B/L 등) 검토 및 관세 통관 조율" + ], + "relatedActions": [ + "적정 안전 재고 일수 산정 및 창고 공간 보관 효율화", + "통관 지연 이슈 발생 시 선사 및 관세사와 긴급 소통/중재" + ], + "relatedExperiences": [ + "글로벌 포워딩 및 복합 운송 관리 업무 경험", + "재고 분석 기법 적용을 통한 적정 재고 유지 및 SCM 안정화 경험" + ], + "relatedKeywords": [ + "SCM", + "재고 효율화", + "안전 재고", + "무역 서류", + "통관 지연 대응", + "운송 라우팅" + ], + "goodEvidenceExample": "수출입 물류 과정에서 발생한 항만 지연 이슈에 대응하기 위해 신속히 대체 운송 라우팅을 확보하고, 관세 증빙 서류를 정밀 보완해 최종 납기 지연을 방지한 경험", + "missingKeywordExamples": [ + "재고 관리 및 분석 경험", + "수출입 서류 검토 경험", + "통관 지연 대응 경험", + "납기 관리 및 조율 경험", + "물류비 절감 활동 경험" + ] + }, + { + "jobCategoryMiddle": "디자인", + "includedJobs": [ + "UI/UX 디자인", + "UX/UI 디자인", + "VMD", + "공간디자인", + "디자인", + "크리에이티브 디렉터 인턴" + ], + "competencyTags": [ + "시각화", + "사용자중심", + "컨셉도출", + "UIUX개선", + "그래픽설계" + ], + "coreCompetencies": [ + "브랜드 비주얼 아이덴티티(BI/CI) 수립", + "시각/공간/UX/UI 디자인 설계 및 3D 모델링" + ], + "relatedActions": [ + "고유 소구점(USP) 분석을 반영한 그래픽 모티프 및 디자인 콘셉트 도출", + "그래픽 툴을 활용한 정밀 설계 및 고해상도 렌더링 검증" + ], + "relatedExperiences": [ + "신규 브랜드 디자인 런칭 및 브랜드 리뉴얼 비주얼 가이드 수립 경험", + "평면 도면 기반의 3D 공간 레이아웃 모델링 참여 경험" + ], + "relatedKeywords": [ + "BI/CI", + "비주얼 커뮤니케이션", + "UX/UI", + "그래픽 툴(Photoshop", + "Illustrator)", + "3D 모델링" + ], + "goodEvidenceExample": "제품의 핵심 콘셉트를 분석하여 디자인 방향성을 정의하고, 온·오프라인 매체에 일관되게 적용할 수 있는 비주얼 가이드라인을 수립해 브랜드 완성도를 높인 경험", + "missingKeywordExamples": [ + "브랜드 BI/CI 수립 경험", + "공간 레이아웃 디자인", + "UX/UI 인터페이스 설계", + "디자인 가이드라인 정립", + "3D 모델링 비주얼 시각화" + ] + }, + { + "jobCategoryMiddle": "회계·세무", + "includedJobs": [ + "세무사무", + "재무회계", + "회계" + ], + "competencyTags": [ + "결산관리", + "재무제표분석", + "세무신고", + "자금계획", + "내부통제" + ], + "coreCompetencies": [ + "재무제표 작성 및 월/분기 손익 결산 통제", + "법인세/부가가치세 신고 및 내부회계관리" + ], + "relatedActions": [ + "전표 승인 프로세스 준수 및 기간별 비용 배부 오류 정정", + "회계 데이터 검증 매크로 작성을 통한 데이터 정합성 검토" + ], + "relatedExperiences": [ + "SAP FI/CO 등 ERP 회계 모듈 활용 및 결산 수검 경험", + "자금 수지 계획 수립 및 외부 감사 수검 준비 경험" + ], + "relatedKeywords": [ + "재무 회계", + "연결 결산", + "세무 조정", + "SAP FI/CO", + "자금 관리" + ], + "goodEvidenceExample": "다국적 법인 간 복잡한 전표 전산 처리 과정에서 발생한 거래 계정 오류의 원인을 추적하여 해결하고, ERP 회계 마스터를 정비하여 결산 오류 리스크를 낮춘 경험", + "missingKeywordExamples": [ + "월/분기 결산 실무 경험", + "재무제표 작성 경험", + "세무 신고 실무 경험", + "ERP 회계 모듈 활용", + "K-IFRS 기준 이해" + ] + }, + { + "jobCategoryMiddle": "미디어·문화·스포츠", + "includedJobs": [ + "공연기획", + "드라마 프로듀서", + "방송PD", + "예능PD", + "콘텐츠 모니터링 및 데이터 관리" + ], + "competencyTags": [ + "콘텐츠기획", + "제작일정조율", + "트렌드분석", + "리소스관리", + "협업" + ], + "coreCompetencies": [ + "방송/드라마/공연 콘텐츠 제작 기획", + "제작 타임라인 설계 및 외부 이해관계 조율(PM)" + ], + "relatedActions": [ + "트렌드 리서치를 바탕으로 한 크리에이티브 콘셉트 및 스토리보드 기획", + "제작 비용 및 외주 리소스 일정 준수 모니터링" + ], + "relatedExperiences": [ + "영상 콘텐츠 편집/촬영 및 편집 프로세스 참여 경험", + "무대 공간 구성 및 문화 행사 프로젝트 PM 경험" + ], + "relatedKeywords": [ + "콘텐츠 기획", + "스토리보드", + "제작 일정 조율", + "리소스 통제", + "편집/촬영" + ], + "goodEvidenceExample": "프로젝트 총괄 PM으로서 초기 시나리오/콘셉트 기획 단계부터 최종 아웃풋 편집 및 현장 연출 조율까지의 타임라인을 관리하여 콘텐츠의 퀄리티를 유지한 경험", + "missingKeywordExamples": [ + "콘텐츠 제작 기획 경험", + "제작 타임라인 마일스톤 관리", + "외부 에이전시 커뮤니케이션", + "미디어 트렌드 리서치", + "프로젝트 리소스 조율" + ] + }, + { + "jobCategoryMiddle": "건축·시설", + "includedJobs": [ + "건축", + "건축 시공", + "건축시공", + "토목공사관리" + ], + "competencyTags": [ + "도면분석", + "시공관리", + "공정관리", + "안전준수", + "현장조율" + ], + "coreCompetencies": [ + "건축/토목 시공 관리 및 감리 업무", + "안전 법령 준수 및 현장 시설물 제어" + ], + "relatedActions": [ + "공정표 기준의 작업 진척률 모니터링 및 협력사 관리", + "안전 수칙 위반 요인 교정 및 비상 대피 체계 수립" + ], + "relatedExperiences": [ + "건설/시공 현장 관리 및 자재 검수 참여 경험", + "시설 정기 점검 및 안전 수칙 준수 검증 경험" + ], + "relatedKeywords": [ + "시공 관리", + "공정표", + "자재 검수", + "현장 감리", + "안전 관리 지침", + "시설 점검" + ], + "goodEvidenceExample": "현장 시공 중 발생한 공정 지연 리스크를 조기에 식별하고, 공정표 조율 및 협력사와의 안전한 시공 방안 수정을 통해 기한 내 시공을 정상 완료한 경험", + "missingKeywordExamples": [ + "건축 시공 관리 경험", + "공사 현장 감리 업무", + "안전 법령 준수 상태 점검", + "설계 도면 정밀 검토" + ] + }, + { + "jobCategoryMiddle": "공공·복지", + "includedJobs": [ + "공공외교", + "공공행정", + "보안검색", + "사회복지사", + "항공보안", + "환경관리" + ], + "competencyTags": [ + "사례관리", + "지역사회연계", + "복지프로그램", + "행정지원", + "공감능력" + ], + "coreCompetencies": [ + "공공 행정 서비스 지원 및 복지 상담", + "복지 프로그램 기획 및 지역사회 자원 연계" + ], + "relatedActions": [ + "사회적 취약 지표 정의 및 수혜 대상자 선별을 위한 심층 면담", + "행정 법령 준수 확인 및 수작업 행정 프로세스 간소화" + ], + "relatedExperiences": [ + "복지 수혜 대상 사례 관리 및 맞춤형 연계 경험", + "정부 과제 행정 지원 및 대외 이해관계자 소통 경험" + ], + "relatedKeywords": [ + "사례 관리", + "수혜자 발굴", + "행정 지원", + "컴플라이언스", + "연계 협력" + ], + "goodEvidenceExample": "지역사회 내 취약계층의 복지 사각지대 해소를 위해 정성적 심층 면담을 수행하고, 적합한 민간·공공 지원 프로세스를 연계하여 자립을 도운 경험", + "missingKeywordExamples": [ + "공공 행정 서비스 지원", + "복지 프로그램 기획", + "지역사회 자원 조율", + "민원 상담 및 현장 대응", + "복지 정책 가이드라인 분석" + ] + }, + { + "jobCategoryMiddle": "고객서비스·리테일", + "includedJobs": [ + "객실승무원", + "고객서비스", + "프론트" + ], + "competencyTags": [ + "매장운영", + "고객경험", + "VOC분석", + "서비스품질", + "소통능력" + ], + "coreCompetencies": [ + "매장 운영 지표 관리 및 서비스 품질 제어", + "고객 경험(CX) 향상 및 VOC(고객의소리) 개선" + ], + "relatedActions": [ + "고객 만족도를 높이는 서비스 표준화 가이드라인 정립", + "불만 고객 응대 및 분쟁 요인의 신속한 조율 및 정상화" + ], + "relatedExperiences": [ + "오프라인 매장 리테일 운영 및 스태프 관리 경험", + "고객 불만 접수(VOC) 통계 분석 및 불만 예방 프로세스 도입 경험" + ], + "relatedKeywords": [ + "매장 운영 관리", + "고객 만족도(CS)", + "VOC 개선", + "서비스 가이드라인" + ], + "goodEvidenceExample": "매장 운영 중 빈번히 발생하는 불만 접수(VOC) 유형을 분석하여 서비스 절차상의 문제점을 도출하고, 응대 매뉴얼 보완을 통해 서비스 만족도를 개선한 경험", + "missingKeywordExamples": [ + "매장 운영 지표 관리", + "서비스 품질(QSC) 제어", + "고객 경험(CX) 향상 활동", + "리테일 매출 트렌드 분석", + "매장 재고 및 발주 관리" + ] + }, + { + "jobCategoryMiddle": "운전·운송·배송", + "includedJobs": [ + "선박 승무원", + "여객운송", + "운송총괄" + ], + "competencyTags": [ + "배송최적화", + "안전운행", + "운송일정관리", + "장비점검", + "성실성" + ], + "coreCompetencies": [ + "화물 및 여객 운송 일정 관리 및 배송 경로 최적화", + "차량/장비 안전 수칙 준수 및 정기 예방 점검" + ], + "relatedActions": [ + "실시간 물동량 및 교통 요인 반영 배송 일정 조율", + "배송 적재 기준 준수 및 현장 기기 안전 진단" + ], + "relatedExperiences": [ + "안전 무사고 운송 운영 및 배송 경로 맵핑 경험", + "운송 원장 실사 매칭 및 현장 배송 조율 경험" + ], + "relatedKeywords": [ + "배송 경로", + "안전 운행", + "일정 관리", + "차량 점검", + "성실성" + ], + "goodEvidenceExample": "화물 배송 최적화 프로그램을 도입하여 일일 물동량과 차량별 가동 상황을 조율하고, 최적화 경로를 재배치해 배송 완료율을 높이고 유류비를 절감한 경험", + "missingKeywordExamples": [ + "운송 경로 최적화 경험", + "화물 운송 일정 관리", + "차량 장비 안전 점검", + "물류 배송 리드타임 단축", + "물류 안전 규정 준수" + ] + }, + { + "jobCategoryMiddle": "교육", + "includedJobs": [ + "교육 기획", + "소프트웨어 교육", + "연구원" + ], + "competencyTags": [ + "교육과정설계", + "교수요목작성", + "교육효과평가", + "LMS운영", + "소통역량" + ], + "coreCompetencies": [ + "교육 과정 설계 및 강의 커리큘럼 개발", + "교육 효과 평가 분석 및 학습 관리 시스템(LMS) 운영" + ], + "relatedActions": [ + "교육 대상자 요구도 분석 및 타겟팅된 교수 매체 선정", + "교육 콘텐츠 기획안 작성 및 강의 스크립트 표준화" + ], + "relatedExperiences": [ + "연령별/대상별 교육 프로그램 강사 및 운영 경험", + "이러닝 강좌 설계 및 사후 만족도 지표 피드백 반영 경험" + ], + "relatedKeywords": [ + "교육 과정 설계", + "커리큘럼 개발", + "학습 진단", + "교육 만족도", + "LMS 운영" + ], + "goodEvidenceExample": "교육 대상자의 역량 격차 요인을 사전 진단하여 적합한 난이도별 맞춤형 교육 커리큘럼을 설계하고, 학습 몰입도를 높이기 위한 교수 매체를 적용해 교육 만족도를 향상시킨 경험", + "missingKeywordExamples": [ + "교육 과정 설계 및 기획", + "강의 커리큘럼 개발", + "교육 효과성 평가 분석", + "학습자 요구사항 진단", + "온/오프라인 교육 운영" + ] + }, + { + "jobCategoryMiddle": "고객상담·TM", + "includedJobs": [ + "고객상담", + "기술지원", + "사무영업" + ], + "competencyTags": [ + "고객응대", + "불만해결", + "상담품질", + "VOC분석", + "경청과공감" + ], + "coreCompetencies": [ + "고객 문의 대응 및 1차 고객 접점 상담 관리", + "상담 품질 관리 및 지식베이스(FAQ) 표준화" + ], + "relatedActions": [ + "고객 니즈에 부합하는 경청과 공감을 기반으로 신속하게 응대", + "반복적인 고객 요구 사항 파악을 통한 대시보드 피드백 제공" + ], + "relatedExperiences": [ + "고객 콜센터/채팅 상담 운영 및 상담 템플릿 제작 경험", + "악성 고객 민원 조율 및 상담 가동 지표 관리 경험" + ], + "relatedKeywords": [ + "고객 상담", + "상담 이력 관리", + "VOC 공유", + "경청과 공감", + "안내 템플릿" + ], + "goodEvidenceExample": "반복되는 특정 고객 문의 유형을 식별하고, 직관적으로 설명할 수 있는 안내 스크립트 템플릿을 개발하여 상담 소요 시간을 단축하고 만족도를 향상시킨 경험", + "missingKeywordExamples": [ + "고객 문의 상담 대응", + "상담 품질(QA) 모니터링", + "고객 클레임 조율 해결", + "상담 스크립트 개선 경험", + "고객 만족도(CSAT) 관리" + ] + }, + { + "jobCategoryMiddle": "식·음료", + "includedJobs": [ + "식품안전", + "품질관리", + "품질위생" + ], + "competencyTags": [ + "품질위생", + "식품안전", + "공정관리", + "메뉴기획", + "원가관리" + ], + "coreCompetencies": [ + "식음료 제조 공정 및 품질위생 관리", + "위생 법령 컴플라이언스 체크 및 위해 요소 차단" + ], + "relatedActions": [ + "현장 가공 설비 위생 점검 및 원료 유통 기한 이력 관리", + "불량 원료 선별 프로세스 준수 및 폐기 조치 통제" + ], + "relatedExperiences": [ + "해썹(HACCP) 인증 준비 및 식품안전 위생 점검 경험", + "식품 원가 분석 및 생산 수율 관리 경험" + ], + "relatedKeywords": [ + "HACCP", + "위생 관리", + "식품안전", + "유통 이력", + "원가 통제" + ], + "goodEvidenceExample": "식음료 가공 공정의 오염 방지를 위해 위해 요소를 분석하고 위생 관리 기준을 개선한 뒤, 현장 작업자에게 위생 수칙을 교육하여 안전성을 확보한 경험", + "missingKeywordExamples": [ + "식음료 제조 공정 관리", + "품질 위생 점검 수행", + "위생 법령 컴플라이언스", + "원부자재 이력 관리", + "HACCP 기준 준수 검증" + ] + }, + { + "jobCategoryMiddle": "QA·품질관리", + "includedJobs": [ + "품질관리" + ], + "competencyTags": [ + "품질보증", + "부적합관리", + "CAPA", + "품질경영시스템", + "규격인증" + ], + "coreCompetencies": [ + "품질 경영 시스템(QMS) 구축 및 관리", + "부적합품 처리 및 시정/예방조치(CAPA)" + ], + "relatedActions": [ + "원자재 및 완제품의 품질 규격 적합성 검사 및 분석", + "부적합/일탈 발생 시 원인 추적(5-Why) 및 개선안 수립" + ], + "relatedExperiences": [ + "품질 감사 및 해외 규격(ISO 등) 인증 획득/유지 경험", + "품질 일탈 사고 대응 및 시정 조치 프로세스 리드 경험" + ], + "relatedKeywords": [ + "QMS", + "품질 검사", + "일탈 분석", + "CAPA", + "ISO 인증", + "품질 신뢰성" + ], + "goodEvidenceExample": "공정 내 제품 불량률 상승 원인을 추적하여 근본 원인을 분석하고, 부적합 조치 프로세스(CAPA)를 수립하여 표준화 지침을 제정해 재발률을 낮춘 경험", + "missingKeywordExamples": [ + "품질 기준 적합성 검사", + "불량 원인 분석 경험", + "시정조치(CAPA) 수행", + "품질 문서/SOP 관리", + "ISO 등 인증 대응 경험" + ] + } +] diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java new file mode 100644 index 0000000..3e5208d --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java @@ -0,0 +1,90 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.openai.client.OpenAIClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AnalysisAiClientTest { + + private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( + mock(OpenAIClient.class), + mock(CorpusRetrievalService.class), + mock(LlmConcurrencyLimiter.class) + ); + + @Test + @DisplayName("직무 중분류 기준이 있으면 프롬프트에 보조 평가 기준 섹션을 포함한다") + void buildPromptIncludesJobCategoryCriteriaWhenPresent() { + String prompt = analysisAiClient.buildPrompt( + mockJobPosting(), + List.of(mockQuestion()), + new RetrievalContext(List.of(), List.of()), + new JobCategoryEvaluationCriteria( + "AI·개발·데이터", + List.of("백엔드 개발"), + List.of("트러블슈팅"), + List.of("소프트웨어 아키텍처 설계 및 시스템 개발"), + List.of("요구사항 분석을 통한 효율적인 개발 및 리팩토링 수행"), + List.of("신규 서비스/플랫폼 개발 및 운영 프로젝트 경험"), + List.of("API", "데이터베이스 설계"), + "시스템 구조 상의 병목을 진단하고 최적화해 안정성을 향상시킨 경험", + List.of("API 설계 및 고도화", "시스템 트러블슈팅 경험") + ) + ); + + assertThat(prompt).contains("[직무별 보조 평가 기준]"); + assertThat(prompt).contains("중분류: AI·개발·데이터"); + assertThat(prompt).contains("이 직무별 기준은 실제 JD를 대체하지 않는다."); + assertThat(prompt).contains("실제 JD의 자격요건, 우대사항, 주요업무를 우선한다."); + assertThat(prompt).contains("직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다."); + assertThat(prompt).contains("소프트웨어 아키텍처 설계 및 시스템 개발"); + assertThat(prompt).contains("API 설계 및 고도화"); + } + + @Test + @DisplayName("직무 중분류 기준이 없으면 보조 평가 기준 섹션을 생략한다") + void buildPromptOmitsJobCategoryCriteriaSectionWhenCriteriaMissing() { + String prompt = analysisAiClient.buildPrompt( + mockJobPosting(), + List.of(mockQuestion()), + new RetrievalContext(List.of(), List.of()), + null + ); + + assertThat(prompt).doesNotContain("[직무별 보조 평가 기준]"); + assertThat(prompt).doesNotContain("이 직무별 기준은 실제 JD를 대체하지 않는다."); + } + + private JobPosting mockJobPosting() { + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드 개발"); + when(jobPosting.getTask()).thenReturn("API 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring Boot 경험"); + when(jobPosting.getPreferred()).thenReturn("대용량 트래픽 경험"); + return jobPosting; + } + + private Question mockQuestion() { + Question question = mock(Question.class); + when(question.getId()).thenReturn(1L); + when(question.getContent()).thenReturn("직무 경험을 작성해주세요."); + when(question.getAnswer()).thenReturn("Spring Boot API를 개발했습니다."); + return question; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index 6731d53..1f779c8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -458,6 +458,39 @@ void analyzeThrowsWhenNoAnswers() { .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); } + @Test + @DisplayName("분석 실행 payload는 공고 중분류에 맞는 직무별 평가 기준을 포함한다") + void prepareAnalysisExecutionIncludesJobCategoryEvaluationCriteria() { + User user = saveUser("analysis-criteria-payload@example.com"); + JobPosting jobPosting = saveJobPosting( + user, + "AI·개발·데이터", + "백엔드 개발", + "API 개발", + "Spring Boot 경험", + "대용량 트래픽 경험" + ); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApply.getId()); + + assertThat(payload.jobCategoryEvaluationCriteria()).isNotNull(); + assertThat(payload.jobCategoryEvaluationCriteria().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + } + + @Test + @DisplayName("공고 중분류에 맞는 직무별 평가 기준이 없으면 payload 기준은 null이다") + void prepareAnalysisExecutionKeepsCriteriaNullWhenMiddleNameIsUnknown() { + User user = saveUser("analysis-criteria-missing-payload@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApply.getId()); + + assertThat(payload.jobCategoryEvaluationCriteria()).isNull(); + } + @Test @DisplayName("다른 사용자의 지원서는 분석할 수 없다") void analyzeThrowsWhenUserDoesNotOwnMockApply() { @@ -958,10 +991,34 @@ private JobPosting saveJobPosting(User user, String task, String requirement, St )); } + private JobPosting saveJobPosting( + User user, + String middleName, + String detailName, + String task, + String requirement, + String preferred + ) { + Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(middleName, detailName); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + task, + requirement, + preferred + )); + } + private DetailClassification saveDetailClassification() { + return saveDetailClassification("분석 테스트 중분류", "분석 테스트 소분류"); + } + + private DetailClassification saveDetailClassification(String middleName, String detailName) { Classification classification = Classification.create("분석 테스트 대분류 " + System.nanoTime()); - MiddleClassification middleClassification = classification.addMiddleClassification("분석 테스트 중분류"); - DetailClassification detailClassification = middleClassification.addDetailClassification("분석 테스트 소분류"); + MiddleClassification middleClassification = classification.addMiddleClassification(middleName); + DetailClassification detailClassification = middleClassification.addDetailClassification(detailName); classificationRepository.save(classification); return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProviderTest.java new file mode 100644 index 0000000..54a6eb4 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/JobCategoryEvaluationCriteriaProviderTest.java @@ -0,0 +1,48 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class JobCategoryEvaluationCriteriaProviderTest { + + private JobCategoryEvaluationCriteriaProvider provider; + + @BeforeEach + void setUp() { + provider = new JobCategoryEvaluationCriteriaProvider(new ObjectMapper()); + } + + @Test + @DisplayName("직무 중분류 평가 기준 JSON resource를 로드하고 중분류명으로 조회한다") + void findByMiddleName() { + Optional criteria = provider.findByMiddleName("AI·개발·데이터"); + + assertThat(criteria).isPresent(); + assertThat(criteria.get().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + assertThat(criteria.get().coreCompetencies()).isNotEmpty(); + assertThat(criteria.get().missingKeywordExamples()).isNotEmpty(); + } + + @Test + @DisplayName("중분류명 매칭 시 앞뒤 공백과 구분자 주변 공백을 정규화한다") + void findByMiddleNameNormalizesWhitespaceAndSeparator() { + Optional criteria = provider.findByMiddleName(" AI / 개발 / 데이터 "); + + assertThat(criteria).isPresent(); + assertThat(criteria.get().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + } + + @Test + @DisplayName("없는 중분류명은 Optional.empty를 반환한다") + void findByMiddleNameReturnsEmptyWhenMissing() { + assertThat(provider.findByMiddleName("없는 중분류")).isEmpty(); + assertThat(provider.findByMiddleName(" ")).isEmpty(); + } +} From 7ace11498d446fbe2ecd98771685b46af4985bd3 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 8 Jul 2026 01:53:00 +0900 Subject: [PATCH 22/27] =?UTF-8?q?[Fix]=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EA=B8=B0=EC=A4=80=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=B6=84=EB=A6=AC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Photoshop, Illustrator 관련 키워드를 하나의 문자열로 병합 - 직무 중분류 평가 기준 JSON 유효성 확인 - Provider resource 로딩 테스트 통과 확인 --- .../resources/analysis/job-category-evaluation-criteria.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/resources/analysis/job-category-evaluation-criteria.json b/src/main/resources/analysis/job-category-evaluation-criteria.json index 1560361..68b85b4 100644 --- a/src/main/resources/analysis/job-category-evaluation-criteria.json +++ b/src/main/resources/analysis/job-category-evaluation-criteria.json @@ -588,8 +588,7 @@ "BI/CI", "비주얼 커뮤니케이션", "UX/UI", - "그래픽 툴(Photoshop", - "Illustrator)", + "그래픽 툴(Photoshop, Illustrator)", "3D 모델링" ], "goodEvidenceExample": "제품의 핵심 콘셉트를 분석하여 디자인 방향성을 정의하고, 온·오프라인 매체에 일관되게 적용할 수 있는 비주얼 가이드라인을 수립해 브랜드 완성도를 높인 경험", From 22258657d290e3b0cf15e16dcfc36719819ce219 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 13 Jul 2026 18:09:16 +0900 Subject: [PATCH 23/27] =?UTF-8?q?[Feat]=20=ED=8F=89=EA=B0=80=EC=9A=A9=20?= =?UTF-8?q?=EC=9E=90=EA=B8=B0=EC=86=8C=EA=B0=9C=EC=84=9C=20AI=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20runner=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=95=88=EC=A0=84=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analysis-eval profile 전용 평가 runner 추가 - evaluation CSV 입력을 읽어 케이스별 자기소개서 AI 분석 결과 생성 - 평가 결과를 evaluation_ai_results.csv 형식으로 저장 - 실패한 케이스도 output CSV에 row를 남기고 errorMessage 기록 - 운영 DB 저장, mockApply 상태 변경, 크레딧 차감/환불 등 실제 서비스 플로우와 분리 - 기존 AnalysisAiClient structured output과 프롬프트 생성 로직 재사용 - DB entity 없이 평가 입력을 전달할 수 있도록 AnalysisPromptInput 추가 - 직무 중분류 평가 기준 Provider를 평가 runner에도 적용 - missingKeywords 검증 정책 적용 - 최대 3개 제한 - blank keyword 제외 - invalid source 제외 - 중복 keyword 제거 - 60자 초과 keyword 제외 - questionAnalyses 검증 정책 적용 - missing status 제외 - invalid status 제외 - answer exact substring 검증 - 문항당 최대 3개 제한 - 중복 sentence 제거 - 서버 기준 점수 공식 적용 - score = round(jobFit * 0.5 + impact * 0.3 + completeness * 0.2) - CSV 입출력 유틸 추가 - quoted comma, quote, newline 처리 - JSON 문자열 CSV escaping 처리 - evaluation_cases/evaluation_ai_results CSV 파일 gitignore 추가 - evaluation.confirm-openai-cost=true guard 추가로 의도치 않은 OpenAI 호출 방지 - application-analysis-eval.yaml 추가 - analysis-eval 실행 시 H2 in-memory datasource 사용 - 운영 DB_URL/RDS 기본 fallback 제거 - schema.sql 실행 방지 - spring.sql.init.mode=never - Hibernate DDL 실행 방지 - spring.jpa.hibernate.ddl-auto=none - 웹 서버 기동 방지 - spring.main.web-application-type=none - devtools restart 비활성화 및 datasource shutdown auto-config 제외 - H2 종료 warning 방지를 위해 DB_CLOSE_ON_EXIT=FALSE 추가 - analysis-eval context 기동용 더미 설정 추가 - mail - OAuth2 - AWS S3 - Toss payment - JWT - prod profile과 analysis-eval profile 동시 활성화 시 fail-fast 처리 - analysis-eval profile 누락 시 runner 검증 실패 처리 - CorpusAdminRunner를 analysis-eval profile에서 제외 - AsyncTaskSweepScheduler를 analysis-eval profile에서 제외 - analysis-eval 실행 중 async task sweep으로 인한 H2 빈 테이블 조회 side effect 방지 - dev/prod 기존 runner 및 scheduler 동작은 유지 - 평가 runner safety 테스트 추가 - prod profile 동시 활성화 fail-fast 검증 - analysis-eval profile 누락 검증 - confirm-openai-cost=false 시 batch 미실행 검증 - analysis-eval 설정의 DDL/schema 비활성화 검증 - CorpusAdminRunner 제외 검증 - AsyncTaskSweepScheduler 제외 검증 - 평가 CSV 유틸 테스트 추가 - 평가 batch 결과 검증 테스트 추가 - 기존 AnalysisAiClient/AnalysisService 테스트 통과 확인 --- .gitignore | 6 +- .../jobdri_api/JobdriApiApplication.java | 23 ++ .../EvaluationAnalysisBatchService.java | 329 ++++++++++++++++++ .../evaluation/EvaluationAnalysisCase.java | 13 + .../evaluation/EvaluationAnalysisResult.java | 35 ++ .../evaluation/EvaluationAnalysisRunner.java | 104 ++++++ .../evaluation/EvaluationCsvSupport.java | 161 +++++++++ .../EvaluationQuestionAnalysisResult.java | 12 + .../analysis/service/AnalysisAiClient.java | 58 ++- .../service/AnalysisImprovementRules.java | 4 +- .../analysis/service/AnalysisPromptInput.java | 43 +++ .../corpus/service/CorpusAdminRunner.java | 2 + .../scheduling/AsyncTaskSweepScheduler.java | 2 + .../resources/application-analysis-eval.yaml | 80 +++++ .../EvaluationAnalysisBatchServiceTest.java | 129 +++++++ .../EvaluationAnalysisRunnerSafetyTest.java | 129 +++++++ .../evaluation/EvaluationCsvSupportTest.java | 64 ++++ 17 files changed, 1182 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisPromptInput.java create mode 100644 src/main/resources/application-analysis-eval.yaml create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java diff --git a/.gitignore b/.gitignore index 5b8aa79..c4515e3 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,8 @@ out/ # application secrets application-local.yml -.venv \ No newline at end of file +.venv + +# Local evaluation datasets/results +evaluation_cases*.csv +evaluation_ai_results*.csv diff --git a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java index b96a502..7c9bcaa 100644 --- a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java +++ b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java @@ -6,6 +6,8 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import java.util.Arrays; + @SpringBootApplication @EnableAsync @EnableRetry @@ -13,7 +15,28 @@ public class JobdriApiApplication { public static void main(String[] args) { + disableDevtoolsRestartForAnalysisEval(args); SpringApplication.run(JobdriApiApplication.class, args); } + private static void disableDevtoolsRestartForAnalysisEval(String[] args) { + String activeProfiles = System.getProperty("spring.profiles.active", System.getenv("SPRING_PROFILES_ACTIVE")); + boolean analysisEvalProfile = containsAnalysisEvalProfile(activeProfiles); + if (!analysisEvalProfile) { + analysisEvalProfile = Arrays.stream(args) + .filter(arg -> arg.startsWith("--spring.profiles.active=")) + .map(arg -> arg.substring("--spring.profiles.active=".length())) + .anyMatch(JobdriApiApplication::containsAnalysisEvalProfile); + } + if (analysisEvalProfile) { + System.setProperty("spring.devtools.restart.enabled", "false"); + } + } + + private static boolean containsAnalysisEvalProfile(String activeProfiles) { + return activeProfiles != null && Arrays.stream(activeProfiles.split(",")) + .map(String::trim) + .anyMatch("analysis-eval"::equals); + } + } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java new file mode 100644 index 0000000..5d58880 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -0,0 +1,329 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisImprovementRules; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput; +import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EvaluationAnalysisBatchService { + private static final int MIN_SCORE = 0; + private static final int MAX_SCORE = 100; + private static final int MAX_ANALYSES_PER_QUESTION = 3; + private static final int MAX_MISSING_KEYWORDS = 3; + private static final int MAX_MISSING_KEYWORD_LENGTH = 60; + private static final double JOB_FIT_WEIGHT = 0.50; + private static final double IMPACT_WEIGHT = 0.30; + private static final double COMPLETENESS_WEIGHT = 0.20; + private static final Long EVALUATION_QUESTION_ID = 1L; + + private final AnalysisAiClient analysisAiClient; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final ObjectMapper objectMapper; + + public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOException { + List cases = readCases(inputPath); + List results = new ArrayList<>(); + int successCount = 0; + + for (int i = 0; i < cases.size(); i++) { + EvaluationAnalysisCase evaluationCase = cases.get(i); + log.info("[{}/{}] {} analyzing...", i + 1, cases.size(), evaluationCase.caseId()); + + try { + EvaluationAnalysisResult result = analyzeCase(evaluationCase); + results.add(result); + if (!StringUtils.hasText(result.errorMessage())) { + successCount++; + } + } catch (Exception e) { + log.warn("[{}/{}] {} failed. message={}", i + 1, cases.size(), evaluationCase.caseId(), e.getMessage()); + results.add(EvaluationAnalysisResult.failed( + evaluationCase, + safeErrorMessage(e), + createdAt() + )); + } + } + + EvaluationCsvSupport.write(outputPath, results); + return new EvaluationBatchSummary(cases.size(), successCount, cases.size() - successCount, outputPath); + } + + private List readCases(Path inputPath) throws IOException { + return EvaluationCsvSupport.read(inputPath).stream() + .map(row -> new EvaluationAnalysisCase( + value(row, "caseId"), + value(row, "jobCategoryMiddle"), + value(row, "jobCategorySmall"), + value(row, "mainTasks"), + value(row, "qualifications"), + value(row, "preferences"), + value(row, "question"), + value(row, "answer") + )) + .toList(); + } + + private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) { + AnalysisPromptInput promptInput = new AnalysisPromptInput( + "평가용 회사", + evaluationCase.jobCategorySmall(), + evaluationCase.mainTasks(), + evaluationCase.qualifications(), + evaluationCase.preferences(), + List.of(new AnalysisPromptInput.QuestionAnswer( + EVALUATION_QUESTION_ID, + evaluationCase.question(), + evaluationCase.answer() + )) + ); + + AnalysisLlmResponse llmResponse = analysisAiClient.analyzeForEvaluation( + promptInput, + jobCategoryEvaluationCriteriaProvider + .findByMiddleName(evaluationCase.jobCategoryMiddle()) + .orElse(null) + ); + + int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit()); + int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact()); + int completeness = validateScore("completeness", llmResponse == null ? null : llmResponse.completeness()); + List missingKeywords = buildMissingKeywords(llmResponse); + List questionAnalyses = buildQuestionAnalyses(evaluationCase, llmResponse); + + return new EvaluationAnalysisResult( + evaluationCase.caseId(), + evaluationCase.jobCategoryMiddle(), + evaluationCase.jobCategorySmall(), + calculateScore(jobFit, impact, completeness), + jobFit, + impact, + completeness, + normalizeFeedback(llmResponse.feedback()), + writeJson(missingKeywords), + writeJson(questionAnalyses), + writeJson(llmResponse), + "", + createdAt() + ); + } + + private List buildMissingKeywords(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + return result; + } + + private List buildQuestionAnalyses( + EvaluationAnalysisCase evaluationCase, + AnalysisLlmResponse llmResponse + ) { + if (llmResponse == null || llmResponse.questionAnalyses() == null) { + return List.of(); + } + + String answer = evaluationCase.answer(); + if (!StringUtils.hasText(answer)) { + return List.of(); + } + + List result = new ArrayList<>(); + Map analysisCountByQuestionId = new HashMap<>(); + Map nextSearchIndexByQuestionId = new HashMap<>(); + Set seenSentences = new HashSet<>(); + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + if (!EVALUATION_QUESTION_ID.equals(item.questionId())) { + continue; + } + + QuestionAnalysisStatus status = parseStatus(item.status()); + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + + int currentCount = analysisCountByQuestionId.getOrDefault(item.questionId(), 0); + if (currentCount >= MAX_ANALYSES_PER_QUESTION) { + continue; + } + + String sentence = item.sentence(); + String dedupeKey = item.questionId() + ":" + sentence.trim(); + if (!seenSentences.add(dedupeKey)) { + continue; + } + + int start = findNextSentenceStart( + answer, + sentence, + nextSearchIndexByQuestionId.getOrDefault(item.questionId(), 0) + ); + if (start < 0) { + continue; + } + + nextSearchIndexByQuestionId.put(item.questionId(), start + sentence.length()); + analysisCountByQuestionId.put(item.questionId(), currentCount + 1); + result.add(new EvaluationQuestionAnalysisResult( + item.questionId(), + sentence, + status.name().toLowerCase(), + defaultString(item.reason()), + normalizeImprovement(item.improvement()), + start, + start + sentence.length() + )); + } + return result; + } + + private int validateScore(String fieldName, Integer score) { + if (score == null || score < MIN_SCORE || score > MAX_SCORE) { + throw new IllegalArgumentException("자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다."); + } + return score; + } + + private int calculateScore(int jobFit, int impact, int completeness) { + return (int) Math.round( + jobFit * JOB_FIT_WEIGHT + + impact * IMPACT_WEIGHT + + completeness * COMPLETENESS_WEIGHT + ); + } + + private int findNextSentenceStart(String answer, String sentence, int fromIndex) { + int start = answer.indexOf(sentence, Math.max(0, fromIndex)); + if (start >= 0) { + return start; + } + return answer.indexOf(sentence); + } + + private QuestionAnalysisStatus parseStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value == null ? List.of() : value); + } catch (JsonProcessingException e) { + log.warn("평가 결과 JSON 직렬화에 실패했습니다. 빈 배열로 대체합니다.", e); + return "[]"; + } + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String normalizeImprovement(String improvement) { + if (!StringUtils.hasText(improvement)) { + return ""; + } + + String normalized = improvement.trim(); + if (AnalysisImprovementRules.isInstructionLike(normalized)) { + return ""; + } + return normalized; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private String value(Map row, String key) { + return row.getOrDefault(key, ""); + } + + private String safeErrorMessage(Exception e) { + String message = e.getMessage(); + return StringUtils.hasText(message) ? message : e.getClass().getSimpleName(); + } + + private String createdAt() { + return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + public record EvaluationBatchSummary( + int totalCount, + int successCount, + int failureCount, + Path outputPath + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java new file mode 100644 index 0000000..bd32f5c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +record EvaluationAnalysisCase( + String caseId, + String jobCategoryMiddle, + String jobCategorySmall, + String mainTasks, + String qualifications, + String preferences, + String question, + String answer +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java new file mode 100644 index 0000000..6978a20 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java @@ -0,0 +1,35 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +record EvaluationAnalysisResult( + String caseId, + String jobCategoryMiddle, + String jobCategorySmall, + Integer aiScore, + Integer aiJobFit, + Integer aiImpact, + Integer aiCompleteness, + String aiFeedback, + String aiMissingKeywordsJson, + String aiQuestionAnalysesJson, + String rawLlmResponseJson, + String errorMessage, + String createdAt +) { + static EvaluationAnalysisResult failed(EvaluationAnalysisCase evaluationCase, String errorMessage, String createdAt) { + return new EvaluationAnalysisResult( + evaluationCase.caseId(), + evaluationCase.jobCategoryMiddle(), + evaluationCase.jobCategorySmall(), + null, + null, + null, + null, + "", + "[]", + "[]", + "", + errorMessage, + createdAt + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java new file mode 100644 index 0000000..8b3cd47 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java @@ -0,0 +1,104 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +@Component +@Profile("analysis-eval") +@RequiredArgsConstructor +@Slf4j +// 수동 실행 예: +// ./gradlew bootRun --args='--spring.profiles.active=analysis-eval --evaluation.input=/path/evaluation_cases_검수.csv --evaluation.output=/path/evaluation_ai_results.csv --evaluation.confirm-openai-cost=true' +public class EvaluationAnalysisRunner implements ApplicationRunner { + + @Value("${evaluation.input:}") + private String inputPath; + + @Value("${evaluation.output:}") + private String outputPath; + + @Value("${evaluation.confirm-openai-cost:false}") + private boolean confirmOpenAiCost; + + @Value("${openai.api.key:}") + private String openAiApiKey; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel; + + private final EvaluationAnalysisBatchService evaluationAnalysisBatchService; + private final ConfigurableApplicationContext applicationContext; + private final Environment environment; + + @Override + public void run(ApplicationArguments args) throws Exception { + try { + validateProfiles(); + validateExecutionProperties(); + log.info("평가용 자소서 분석을 시작합니다. input={}, output={}, model={}", inputPath, outputPath, analysisModel); + EvaluationAnalysisBatchService.EvaluationBatchSummary summary = + evaluationAnalysisBatchService.run(Path.of(inputPath), Path.of(outputPath)); + log.info( + "평가용 자소서 분석이 완료되었습니다. total={}, success={}, failure={}, output={}", + summary.totalCount(), + summary.successCount(), + summary.failureCount(), + summary.outputPath() + ); + exitApplication(0); + } catch (Exception e) { + log.error("평가용 자소서 분석 실행에 실패했습니다. message={}", e.getMessage(), e); + throw e; + } + } + + private void exitApplication(int exitCode) { + Thread shutdownThread = new Thread(() -> SpringApplication.exit(applicationContext, () -> exitCode)); + shutdownThread.setDaemon(false); + shutdownThread.start(); + } + + void validateProfiles() { + List profiles = Arrays.asList(environment.getActiveProfiles()); + if (profiles.contains("prod")) { + throw new IllegalStateException("analysis-eval must not run with prod profile."); + } + if (!profiles.contains("analysis-eval")) { + throw new IllegalStateException("Evaluation runner requires analysis-eval profile."); + } + } + + void validateExecutionProperties() { + if (!StringUtils.hasText(inputPath)) { + throw new IllegalArgumentException("evaluation.input 값을 지정해야 합니다."); + } + if (!StringUtils.hasText(outputPath)) { + throw new IllegalArgumentException("evaluation.output 값을 지정해야 합니다."); + } + if (!Files.isRegularFile(Path.of(inputPath))) { + throw new IllegalArgumentException("evaluation.input 파일을 찾을 수 없습니다. path=" + inputPath); + } + if (!StringUtils.hasText(openAiApiKey)) { + throw new IllegalArgumentException("OPENAI_API_KEY 또는 openai.api.key 값을 지정해야 합니다."); + } + if (!confirmOpenAiCost) { + throw new IllegalArgumentException( + "외부 OpenAI API 비용 발생을 확인한 뒤 evaluation.confirm-openai-cost=true 값을 지정해야 합니다." + ); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java new file mode 100644 index 0000000..3a3442f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java @@ -0,0 +1,161 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class EvaluationCsvSupport { + private EvaluationCsvSupport() { + } + + static List> read(Path path) throws IOException { + StringBuilder content = new StringBuilder(); + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + char[] buffer = new char[4096]; + int length; + while ((length = reader.read(buffer)) != -1) { + content.append(buffer, 0, length); + } + } + + List> rows = parseRows(content.toString()); + if (rows.isEmpty()) { + return List.of(); + } + + List headers = new ArrayList<>(rows.getFirst()); + if (!headers.isEmpty() && !headers.getFirst().isEmpty() && headers.getFirst().charAt(0) == '\uFEFF') { + headers.set(0, headers.getFirst().substring(1)); + } + + List> result = new ArrayList<>(); + for (int i = 1; i < rows.size(); i++) { + List row = rows.get(i); + if (row.stream().allMatch(String::isBlank)) { + continue; + } + Map values = new LinkedHashMap<>(); + for (int j = 0; j < headers.size(); j++) { + values.put(headers.get(j), j < row.size() ? row.get(j) : ""); + } + result.add(values); + } + return result; + } + + static void write(Path path, List results) throws IOException { + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + writeRow(writer, List.of( + "caseId", + "jobCategoryMiddle", + "jobCategorySmall", + "aiScore", + "aiJobFit", + "aiImpact", + "aiCompleteness", + "aiFeedback", + "aiMissingKeywordsJson", + "aiQuestionAnalysesJson", + "rawLlmResponseJson", + "errorMessage", + "createdAt" + )); + + for (EvaluationAnalysisResult result : results) { + writeRow(writer, List.of( + value(result.caseId()), + value(result.jobCategoryMiddle()), + value(result.jobCategorySmall()), + value(result.aiScore()), + value(result.aiJobFit()), + value(result.aiImpact()), + value(result.aiCompleteness()), + value(result.aiFeedback()), + value(result.aiMissingKeywordsJson()), + value(result.aiQuestionAnalysesJson()), + value(result.rawLlmResponseJson()), + value(result.errorMessage()), + value(result.createdAt()) + )); + } + } + } + + private static List> parseRows(String content) { + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + StringBuilder field = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < content.length(); i++) { + char current = content.charAt(i); + if (inQuotes) { + if (current == '"') { + if (i + 1 < content.length() && content.charAt(i + 1) == '"') { + field.append('"'); + i++; + } else { + inQuotes = false; + } + } else { + field.append(current); + } + continue; + } + + if (current == '"') { + inQuotes = true; + } else if (current == ',') { + row.add(field.toString()); + field.setLength(0); + } else if (current == '\n') { + row.add(field.toString()); + field.setLength(0); + rows.add(row); + row = new ArrayList<>(); + } else if (current != '\r') { + field.append(current); + } + } + + row.add(field.toString()); + if (!(row.size() == 1 && row.getFirst().isEmpty() && content.endsWith("\n"))) { + rows.add(row); + } + return rows; + } + + private static void writeRow(BufferedWriter writer, List values) throws IOException { + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + writer.write(','); + } + writer.write(escape(values.get(i))); + } + writer.newLine(); + } + + private static String escape(String value) { + String safeValue = value == null ? "" : value; + if (safeValue.contains(",") || safeValue.contains("\"") || safeValue.contains("\n") || safeValue.contains("\r")) { + return "\"" + safeValue.replace("\"", "\"\"") + "\""; + } + return safeValue; + } + + private static String value(Object value) { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java new file mode 100644 index 0000000..15e0af5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +record EvaluationQuestionAnalysisResult( + Long questionId, + String sentence, + String status, + String reason, + String improvement, + int startIndex, + int endIndex +) { +} 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 ec6dbca..a834add 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 @@ -222,21 +222,61 @@ public AnalysisLlmResponse analyze( } } + public AnalysisLlmResponse analyzeForEvaluation( + AnalysisPromptInput promptInput, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(buildPrompt(promptInput, emptyContext(), jobCategoryEvaluationCriteria)) + .temperature(0.2) + .text(AnalysisLlmResponse.class) + .build(); + + try { + StructuredResponse response = llmConcurrencyLimiter.execute( + "cover-letter-analysis-evaluation", + () -> openAIClient.responses().create(params) + ); + return extractStructuredContent(response); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + log.error("평가용 자소서 분석 OpenAI API 호출 오류: {}", e.getMessage(), e); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "평가용 자소서 분석 AI 호출에 실패했습니다." + ); + } + } + String buildPrompt( JobPosting jobPosting, List questions, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - String questionText = questions.stream() + return buildPrompt( + AnalysisPromptInput.from(jobPosting, questions), + referenceContext, + jobCategoryEvaluationCriteria + ); + } + + String buildPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = promptInput.questions().stream() .map(question -> """ - questionId: %d question: %s answer: %s """.formatted( - question.getId(), - defaultString(question.getContent()), - defaultString(question.getAnswer()) + question.questionId(), + defaultString(question.question()), + defaultString(question.answer()) )) .reduce("", (left, right) -> left + "\n" + right); @@ -298,11 +338,11 @@ String buildPrompt( OUTPUT_SCHEMA, EVALUATION_CRITERIA, STATUS_AND_WRITING_RULES.formatted(AnalysisImprovementRules.bannedPhrasesText()), - defaultString(jobPosting.getCompany().getName()), - defaultString(jobPosting.getDetailClassification().getDetailName()), - defaultString(jobPosting.getTask()), - defaultString(jobPosting.getRequirement()), - defaultString(jobPosting.getPreferred()), + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), similarJobPostingText, similarQuestionText, jobCategoryCriteriaSection, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java index cee9ba6..77cd767 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisImprovementRules.java @@ -3,7 +3,7 @@ import java.util.List; // 개선 문구를 후처리할 때 사용하는 내부 규칙 모음이다. -final class AnalysisImprovementRules { +public final class AnalysisImprovementRules { private static final List BANNED_PHRASES = List.of( "추가하세요", "보완하세요", @@ -32,7 +32,7 @@ static String bannedPhrasesText() { return String.join(", ", BANNED_PHRASES); } - static boolean isInstructionLike(String improvement) { + public static boolean isInstructionLike(String improvement) { if (improvement == null) { return false; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisPromptInput.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisPromptInput.java new file mode 100644 index 0000000..555a899 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisPromptInput.java @@ -0,0 +1,43 @@ +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 AnalysisPromptInput( + String companyName, + String jobName, + String mainTasks, + String qualifications, + String preferences, + List questions +) { + public static AnalysisPromptInput from(JobPosting jobPosting, List questions) { + return new AnalysisPromptInput( + jobPosting != null && jobPosting.getCompany() != null ? jobPosting.getCompany().getName() : "", + jobPosting != null && jobPosting.getDetailClassification() != null + ? jobPosting.getDetailClassification().getDetailName() + : "", + jobPosting != null ? jobPosting.getTask() : "", + jobPosting != null ? jobPosting.getRequirement() : "", + jobPosting != null ? jobPosting.getPreferred() : "", + questions == null + ? List.of() + : questions.stream() + .map(question -> new QuestionAnswer( + question.getId(), + question.getContent(), + question.getAnswer() + )) + .toList() + ); + } + + public record QuestionAnswer( + Long questionId, + String question, + String answer + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java index b02575a..64d5850 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -12,6 +13,7 @@ @Slf4j @Component +@Profile("!analysis-eval") @RequiredArgsConstructor public class CorpusAdminRunner implements ApplicationRunner { diff --git a/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java index 40cc55d..5c19b3e 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java +++ b/src/main/java/com/jobdri/jobdri_api/global/scheduling/AsyncTaskSweepScheduler.java @@ -4,11 +4,13 @@ import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncTaskService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Slf4j @Component +@Profile("!analysis-eval") @RequiredArgsConstructor public class AsyncTaskSweepScheduler { diff --git a/src/main/resources/application-analysis-eval.yaml b/src/main/resources/application-analysis-eval.yaml new file mode 100644 index 0000000..8121437 --- /dev/null +++ b/src/main/resources/application-analysis-eval.yaml @@ -0,0 +1,80 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.devtools.autoconfigure.DevToolsDataSourceAutoConfiguration + + main: + web-application-type: none + devtools: + add-properties: false + restart: + enabled: false + + sql: + init: + mode: never + + datasource: + url: ${EVAL_DB_URL:jdbc:h2:mem:analysis_eval;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE} + username: ${EVAL_DB_USERNAME:sa} + password: ${EVAL_DB_PASSWORD:} + driver-class-name: ${EVAL_DB_DRIVER:org.h2.Driver} + + jpa: + hibernate: + ddl-auto: none + defer-datasource-initialization: false + open-in-view: false + + mail: + host: localhost + port: 2525 + username: dummy + password: dummy + + security: + oauth2: + client: + registration: + google: + client-id: dummy-google-client-id + client-secret: dummy-google-client-secret + scope: + - email + - profile + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + + cloud: + aws: + credentials: + access-key: dummy-access-key + secret-key: dummy-secret-key + region: + static: ap-northeast-2 + s3: + bucket: dummy-bucket + presign-get-expiration-minutes: 10 + presign-put-expiration-minutes: 5 + +app: + admin: + bootstrap-emails: "" + corpus: + import: + run-on-startup: false + xlsx-path: "" + embedding: + sync-on-startup: false + +jwt: + secret: + key: ${JWT_SECRET_KEY:am9iZHJpLWFuYWx5c2lzLWV2YWwtbG9jYWwtc2VjcmV0LWtleQ==} + expiration: + access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} + refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} + +payment: + toss: + client-key: ${TOSS_CLIENT_KEY:dummy-evaluation-client-key} + secret-key: ${TOSS_SECRET_KEY:dummy-evaluation-secret-key} + base-url: ${TOSS_BASE_URL:https://api.tosspayments.com} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java new file mode 100644 index 0000000..2695f15 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java @@ -0,0 +1,129 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput; +import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class EvaluationAnalysisBatchServiceTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("LLM 응답을 검증해 평가 결과 CSV로 저장한다") + void runWritesSanitizedEvaluationResults() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), any())) + .thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "검증 피드백", + List.of( + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "preference"), + new AnalysisLlmResponse.MissingKeywordItem(" ", "mainTask"), + new AnalysisLlmResponse.MissingKeywordItem("잘못된 출처", "unknown"), + new AnalysisLlmResponse.MissingKeywordItem("테스트 자동화 경험", "mainTask") + ), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "데이터 처리 경험이 있습니다.", + "mentioned", + "구체성이 부족합니다.", + "데이터 처리 경험을 바탕으로 배치 성능을 개선했습니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "답변에 없는 문장", + "proven", + "원문에 없습니다.", + "무시되어야 합니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "데이터 처리 경험이 있습니다.", + "missing", + "missing은 저장하지 않습니다.", + "무시되어야 합니다." + ) + ) + )); + + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-01,AI·개발·데이터,백엔드,서버 개발,SQL,대용량 처리,경험을 쓰세요,데이터 처리 경험이 있습니다.\n", + StandardCharsets.UTF_8 + ); + + EvaluationAnalysisBatchService.EvaluationBatchSummary summary = service.run(input, output); + + assertThat(summary.totalCount()).isEqualTo(1); + assertThat(summary.successCount()).isEqualTo(1); + String csv = Files.readString(output, StandardCharsets.UTF_8); + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(csv).contains("EV-01"); + assertThat(csv).contains(",73,80,70,60,"); + assertThat(row.get("aiMissingKeywordsJson")).contains("SQL 활용 경험"); + assertThat(row.get("aiMissingKeywordsJson")).contains("테스트 자동화 경험"); + assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("잘못된 출처"); + assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("답변에 없는 문장"); + assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("missing은 저장하지 않습니다."); + verify(analysisAiClient).analyzeForEvaluation(any(AnalysisPromptInput.class), any()); + } + + @Test + @DisplayName("없는 직무 중분류는 보조 기준 없이 분석한다") + void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), isNull())) + .thenReturn(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of())); + + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-02,없는 중분류,백엔드,서버 개발,SQL,,경험을 쓰세요,SQL 경험이 있습니다.\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + verify(analysisAiClient).analyzeForEvaluation(any(AnalysisPromptInput.class), isNull()); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java new file mode 100644 index 0000000..ad20e2e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java @@ -0,0 +1,129 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import com.jobdri.jobdri_api.domain.corpus.service.CorpusAdminRunner; +import com.jobdri.jobdri_api.global.scheduling.AsyncTaskSweepScheduler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EvaluationAnalysisRunnerSafetyTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("prod profile과 analysis-eval을 함께 활성화하면 fail-fast 한다") + void validateProfilesFailsWhenProdProfileIsActive() { + EvaluationAnalysisRunner runner = runnerWithProfiles("prod", "analysis-eval"); + + assertThatThrownBy(runner::validateProfiles) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("analysis-eval must not run with prod profile"); + } + + @Test + @DisplayName("analysis-eval profile이 없으면 fail-fast 한다") + void validateProfilesFailsWhenAnalysisEvalProfileIsMissing() { + EvaluationAnalysisRunner runner = runnerWithProfiles("dev"); + + assertThatThrownBy(runner::validateProfiles) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Evaluation runner requires analysis-eval profile"); + } + + @Test + @DisplayName("confirm-openai-cost=false이면 실행 속성 검증에서 실패한다") + void validateExecutionPropertiesFailsWhenCostConfirmationIsFalse() throws Exception { + EvaluationAnalysisRunner runner = runnerWithProfiles("analysis-eval"); + Path input = tempDir.resolve("evaluation_cases.csv"); + Files.writeString(input, "caseId\nEV-01\n"); + ReflectionTestUtils.setField(runner, "inputPath", input.toString()); + ReflectionTestUtils.setField(runner, "outputPath", tempDir.resolve("evaluation_ai_results.csv").toString()); + ReflectionTestUtils.setField(runner, "openAiApiKey", "test-key"); + ReflectionTestUtils.setField(runner, "confirmOpenAiCost", false); + + assertThatThrownBy(runner::validateExecutionProperties) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("evaluation.confirm-openai-cost=true"); + } + + @Test + @DisplayName("analysis-eval 설정은 DB DDL과 schema.sql 실행을 비활성화한다") + void analysisEvalYamlDisablesDatabaseSideEffects() { + Properties properties = loadAnalysisEvalProperties(); + + assertThat(properties.getProperty("spring.main.web-application-type")).isEqualTo("none"); + assertThat(properties.getProperty("spring.autoconfigure.exclude[0]")) + .isEqualTo("org.springframework.boot.devtools.autoconfigure.DevToolsDataSourceAutoConfiguration"); + assertThat(properties.getProperty("spring.devtools.add-properties")).isEqualTo("false"); + assertThat(properties.getProperty("spring.devtools.restart.enabled")).isEqualTo("false"); + assertThat(properties.getProperty("spring.sql.init.mode")).isEqualTo("never"); + assertThat(properties.getProperty("spring.jpa.hibernate.ddl-auto")).isEqualTo("none"); + assertThat(properties.getProperty("spring.jpa.defer-datasource-initialization")).isEqualTo("false"); + assertThat(properties.getProperty("spring.datasource.url")) + .contains("EVAL_DB_URL") + .contains("DB_CLOSE_ON_EXIT=FALSE") + .doesNotContain("${DB_URL"); + assertThat(properties.getProperty("spring.mail.host")).isEqualTo("localhost"); + assertThat(properties.getProperty("spring.mail.port")).isEqualTo("2525"); + assertThat(properties.getProperty("spring.mail.username")).isEqualTo("dummy"); + assertThat(properties.getProperty("spring.security.oauth2.client.registration.google.client-id")) + .isEqualTo("dummy-google-client-id"); + assertThat(properties.getProperty("spring.cloud.aws.region.static")).isEqualTo("ap-northeast-2"); + assertThat(properties.getProperty("spring.cloud.aws.s3.bucket")).isEqualTo("dummy-bucket"); + assertThat(properties.getProperty("app.admin.bootstrap-emails")).isEmpty(); + assertThat(properties.getProperty("app.corpus.import.run-on-startup")).isEqualTo("false"); + assertThat(properties.getProperty("app.corpus.embedding.sync-on-startup")).isEqualTo("false"); + assertThat(properties.getProperty("payment.toss.client-key")).contains("dummy-evaluation-client-key"); + } + + @Test + @DisplayName("CorpusAdminRunner는 analysis-eval profile에서 bean 등록되지 않는다") + void corpusAdminRunnerIsExcludedFromAnalysisEvalProfile() { + Profile profile = CorpusAdminRunner.class.getAnnotation(Profile.class); + + assertThat(profile).isNotNull(); + assertThat(profile.value()).containsExactly("!analysis-eval"); + } + + @Test + @DisplayName("AsyncTaskSweepScheduler는 analysis-eval profile에서 bean 등록되지 않는다") + void asyncTaskSweepSchedulerIsExcludedFromAnalysisEvalProfile() { + Profile profile = AsyncTaskSweepScheduler.class.getAnnotation(Profile.class); + + assertThat(profile).isNotNull(); + assertThat(profile.value()).containsExactly("!analysis-eval"); + } + + private EvaluationAnalysisRunner runnerWithProfiles(String... profiles) { + Environment environment = mock(Environment.class); + when(environment.getActiveProfiles()).thenReturn(profiles); + return new EvaluationAnalysisRunner( + mock(EvaluationAnalysisBatchService.class), + mock(ConfigurableApplicationContext.class), + environment + ); + } + + private Properties loadAnalysisEvalProperties() { + YamlPropertiesFactoryBean factoryBean = new YamlPropertiesFactoryBean(); + factoryBean.setResources(new ClassPathResource("application-analysis-eval.yaml")); + Properties properties = factoryBean.getObject(); + return properties == null ? new Properties() : properties; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java new file mode 100644 index 0000000..cb9bf6b --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java @@ -0,0 +1,64 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationCsvSupportTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("quoted comma, quote, newline을 포함한 CSV를 읽는다") + void readQuotedCsv() throws Exception { + Path input = tempDir.resolve("evaluation_cases.csv"); + Files.writeString( + input, + "\uFEFFcaseId,jobCategoryMiddle,answer\n" + + "EV-01,AI·개발·데이터,\"문장, 쉼표와 \"\"따옴표\"\"\n" + + "줄바꿈\"\n", + StandardCharsets.UTF_8 + ); + + List> rows = EvaluationCsvSupport.read(input); + + assertThat(rows).hasSize(1); + assertThat(rows.getFirst().get("caseId")).isEqualTo("EV-01"); + assertThat(rows.getFirst().get("answer")).isEqualTo("문장, 쉼표와 \"따옴표\"\n줄바꿈"); + } + + @Test + @DisplayName("평가 결과 CSV는 JSON 문자열을 안전하게 escape한다") + void writeEscapedCsv() throws Exception { + Path output = tempDir.resolve("evaluation_ai_results.csv"); + EvaluationCsvSupport.write(output, List.of(new EvaluationAnalysisResult( + "EV-01", + "AI·개발·데이터", + "백엔드", + 80, + 80, + 80, + 80, + "피드백, 쉼표", + "[{\"keyword\":\"SQL\",\"source\":\"qualification\"}]", + "[]", + "{\"feedback\":\"문장\"}", + "", + "2026-07-13T10:00:00" + ))); + + String csv = Files.readString(output, StandardCharsets.UTF_8); + + assertThat(csv).contains("\"피드백, 쉼표\""); + assertThat(csv).contains("\"[{\"\"keyword\"\":\"\"SQL\"\",\"\"source\"\":\"\"qualification\"\"}]\""); + } +} From ef8ec97dc9f6555f8902d509e24bd394aa8fd0d4 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 13 Jul 2026 18:25:51 +0900 Subject: [PATCH 24/27] =?UTF-8?q?[Fix]=20=ED=8F=89=EA=B0=80=20runner=20CSV?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 평가 입력 CSV 필수 헤더 검증 추가 - 누락되거나 오타가 있는 헤더는 AI 호출 전 fail-fast 처리 - CSV quoted field 파싱 검증 강화 - 닫히지 않은 quote와 closing quote 뒤 잘못된 문자를 파싱 오류로 처리 - LLM 호출 실패 시 batch를 중단하지 않고 실패 row를 저장하는 테스트 추가 - CSV header 검증 및 malformed quoted field 테스트 추가 --- .../EvaluationAnalysisBatchService.java | 21 ++++++ .../evaluation/EvaluationCsvSupport.java | 72 +++++++++++++++---- .../EvaluationAnalysisBatchServiceTest.java | 62 ++++++++++++++++ .../evaluation/EvaluationCsvSupportTest.java | 31 ++++++++ 4 files changed, 172 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index 5d58880..163980e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -40,6 +40,16 @@ public class EvaluationAnalysisBatchService { private static final double IMPACT_WEIGHT = 0.30; private static final double COMPLETENESS_WEIGHT = 0.20; private static final Long EVALUATION_QUESTION_ID = 1L; + private static final List REQUIRED_HEADERS = List.of( + "caseId", + "jobCategoryMiddle", + "jobCategorySmall", + "mainTasks", + "qualifications", + "preferences", + "question", + "answer" + ); private final AnalysisAiClient analysisAiClient; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; @@ -75,6 +85,7 @@ public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOExce } private List readCases(Path inputPath) throws IOException { + validateHeaders(EvaluationCsvSupport.readHeaders(inputPath)); return EvaluationCsvSupport.read(inputPath).stream() .map(row -> new EvaluationAnalysisCase( value(row, "caseId"), @@ -89,6 +100,16 @@ private List readCases(Path inputPath) throws IOExceptio .toList(); } + private void validateHeaders(List headers) { + Set headerSet = new HashSet<>(headers); + List missingHeaders = REQUIRED_HEADERS.stream() + .filter(header -> !headerSet.contains(header)) + .toList(); + if (!missingHeaders.isEmpty()) { + throw new IllegalArgumentException("Evaluation CSV missing required headers: " + missingHeaders); + } + } + private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) { AnalysisPromptInput promptInput = new AnalysisPromptInput( "평가용 회사", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java index 3a3442f..c868dee 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java @@ -16,6 +16,30 @@ private EvaluationCsvSupport() { } static List> read(Path path) throws IOException { + CsvRows csvRows = readRows(path); + if (csvRows.headers().isEmpty()) { + return List.of(); + } + + List> result = new ArrayList<>(); + for (List row : csvRows.rows()) { + if (row.stream().allMatch(String::isBlank)) { + continue; + } + Map values = new LinkedHashMap<>(); + for (int j = 0; j < csvRows.headers().size(); j++) { + values.put(csvRows.headers().get(j), j < row.size() ? row.get(j) : ""); + } + result.add(values); + } + return result; + } + + static List readHeaders(Path path) throws IOException { + return readRows(path).headers(); + } + + private static CsvRows readRows(Path path) throws IOException { StringBuilder content = new StringBuilder(); try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { char[] buffer = new char[4096]; @@ -27,7 +51,7 @@ static List> read(Path path) throws IOException { List> rows = parseRows(content.toString()); if (rows.isEmpty()) { - return List.of(); + return new CsvRows(List.of(), List.of()); } List headers = new ArrayList<>(rows.getFirst()); @@ -35,19 +59,7 @@ static List> read(Path path) throws IOException { headers.set(0, headers.getFirst().substring(1)); } - List> result = new ArrayList<>(); - for (int i = 1; i < rows.size(); i++) { - List row = rows.get(i); - if (row.stream().allMatch(String::isBlank)) { - continue; - } - Map values = new LinkedHashMap<>(); - for (int j = 0; j < headers.size(); j++) { - values.put(headers.get(j), j < row.size() ? row.get(j) : ""); - } - result.add(values); - } - return result; + return new CsvRows(headers, rows.subList(1, rows.size())); } static void write(Path path, List results) throws IOException { @@ -98,6 +110,7 @@ private static List> parseRows(String content) { List row = new ArrayList<>(); StringBuilder field = new StringBuilder(); boolean inQuotes = false; + boolean afterClosingQuote = false; for (int i = 0; i < content.length(); i++) { char current = content.charAt(i); @@ -108,6 +121,7 @@ private static List> parseRows(String content) { i++; } else { inQuotes = false; + afterClosingQuote = true; } } else { field.append(current); @@ -115,6 +129,27 @@ private static List> parseRows(String content) { continue; } + if (afterClosingQuote) { + if (current == ',') { + row.add(field.toString()); + field.setLength(0); + afterClosingQuote = false; + } else if (current == '\n') { + row.add(field.toString()); + field.setLength(0); + rows.add(row); + row = new ArrayList<>(); + afterClosingQuote = false; + } else if (current == '\r') { + // Allow CR after a closing quote. LF, comma, or EOF will complete the field. + } else { + throw new IllegalArgumentException( + "Malformed CSV: unexpected character after closing quote at position " + i + ); + } + continue; + } + if (current == '"') { inQuotes = true; } else if (current == ',') { @@ -130,6 +165,9 @@ private static List> parseRows(String content) { } } + if (inQuotes) { + throw new IllegalArgumentException("Malformed CSV: quoted field is not closed."); + } row.add(field.toString()); if (!(row.size() == 1 && row.getFirst().isEmpty() && content.endsWith("\n"))) { rows.add(row); @@ -158,4 +196,10 @@ private static String escape(String value) { private static String value(Object value) { return value == null ? "" : String.valueOf(value); } + + private record CsvRows( + List headers, + List> rows + ) { + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java index 2695f15..273a564 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java @@ -16,10 +16,12 @@ import java.util.Map; 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.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class EvaluationAnalysisBatchServiceTest { @@ -126,4 +128,64 @@ void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { verify(analysisAiClient).analyzeForEvaluation(any(AnalysisPromptInput.class), isNull()); } + + @Test + @DisplayName("필수 CSV header가 없으면 AI 호출 전에 실패한다") + void runFailsFastWhenRequiredHeaderIsMissing() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question\n" + + "EV-03,AI·개발·데이터,백엔드,서버 개발,SQL,,경험을 쓰세요\n", + StandardCharsets.UTF_8 + ); + + assertThatThrownBy(() -> service.run(input, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing required headers") + .hasMessageContaining("answer"); + verifyNoInteractions(analysisAiClient); + } + + @Test + @DisplayName("LLM 호출 실패 케이스도 실패 row로 CSV에 기록한다") + void runWritesFailureRowWhenAnalyzeFails() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), any())) + .thenThrow(new RuntimeException("rate limit exceeded")); + + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-04,AI·개발·데이터,백엔드,서버 개발,SQL,,경험을 쓰세요,SQL 경험이 있습니다.\n", + StandardCharsets.UTF_8 + ); + + EvaluationAnalysisBatchService.EvaluationBatchSummary summary = service.run(input, output); + Map row = EvaluationCsvSupport.read(output).getFirst(); + + assertThat(summary.totalCount()).isEqualTo(1); + assertThat(summary.successCount()).isZero(); + assertThat(summary.failureCount()).isEqualTo(1); + assertThat(row.get("caseId")).isEqualTo("EV-04"); + assertThat(row.get("aiMissingKeywordsJson")).isEqualTo("[]"); + assertThat(row.get("aiQuestionAnalysesJson")).isEqualTo("[]"); + assertThat(row.get("errorMessage")).contains("rate limit exceeded"); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java index cb9bf6b..c40c5e0 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java @@ -11,6 +11,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class EvaluationCsvSupportTest { @@ -61,4 +62,34 @@ void writeEscapedCsv() throws Exception { assertThat(csv).contains("\"피드백, 쉼표\""); assertThat(csv).contains("\"[{\"\"keyword\"\":\"\"SQL\"\",\"\"source\"\":\"\"qualification\"\"}]\""); } + + @Test + @DisplayName("닫히지 않은 quoted field는 파싱 예외를 던진다") + void readRejectsUnclosedQuotedField() throws Exception { + Path input = tempDir.resolve("evaluation_cases.csv"); + Files.writeString( + input, + "caseId,answer\nEV-01,\"닫히지 않은 문장\n", + StandardCharsets.UTF_8 + ); + + assertThatThrownBy(() -> EvaluationCsvSupport.read(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("quoted field is not closed"); + } + + @Test + @DisplayName("닫힌 quote 뒤 delimiter가 아닌 문자가 오면 파싱 예외를 던진다") + void readRejectsUnexpectedCharacterAfterClosingQuote() throws Exception { + Path input = tempDir.resolve("evaluation_cases.csv"); + Files.writeString( + input, + "caseId,answer\nEV-01,\"문장\"invalid\n", + StandardCharsets.UTF_8 + ); + + assertThatThrownBy(() -> EvaluationCsvSupport.read(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unexpected character after closing quote"); + } } From 4d579c90bc99c40f53dfa8e72b01e043d9b759e7 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 14 Jul 2026 16:26:42 +0900 Subject: [PATCH 25/27] =?UTF-8?q?[Chore]=20worker=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A6=88=20=ED=8C=8C=EC=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production.example | 20 ++++++++++++++- docker-compose.prod.yml | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.env.production.example b/.env.production.example index e5991d9..0f68085 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,22 +1,27 @@ APP_PORT=8080 +SPRING_PROFILES_ACTIVE=prod IMAGE_NAME=ghcr.io/jobdri-developer/backend IMAGE_TAG=latest +WORKER_IMAGE_NAME=ghcr.io/jobdri-developer/analysis-worker +WORKER_IMAGE_TAG=latest DB_URL=jdbc:postgresql://your-db-host:5432/jobdri DB_USERNAME=jobdri DB_PASSWORD=change-me DB_DRIVER=org.postgresql.Driver JPA_DDL_AUTO=update +SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION=true REDIS_HOST=your-upstash-host REDIS_PORT=6379 REDIS_PASSWORD=change-me REDIS_SSL_ENABLED=true -RABBITMQ_HOST=your-rabbitmq-host +RABBITMQ_HOST=rabbitmq RABBITMQ_PORT=5672 RABBITMQ_USERNAME=jobdri RABBITMQ_PASSWORD=change-me RABBITMQ_VHOST=/ +RABBITMQ_MANAGEMENT_PORT=15672 JWT_SECRET_KEY=base64-encoded-secret JWT_ACCESS_TOKEN_EXPIRATION=3600000 @@ -36,11 +41,20 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect OPENAI_API_KEY=change-me +COHERE_API_KEY= TOSS_CLIENT_KEY=change-me TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com +AWS_REGION=ap-northeast-2 +S3_BUCKET=your-s3-bucket +AWS_ACCESS_KEY_ID=change-me +AWS_SECRET_ACCESS_KEY=change-me +S3_PRESIGN_GET_EXPIRATION_MINUTES=10 +S3_PRESIGN_PUT_EXPIRATION_MINUTES=5 +JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES=5242880 + JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 APP_WORKER_EXCHANGE=jobdri.worker.exchange APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key @@ -53,6 +67,10 @@ APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 +APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS=300000 +WORKER_PREFETCH_COUNT=1 +OPENAI_JOB_POSTING_MODEL=gpt-4o-mini +OPENAI_ANALYSIS_MODEL=gpt-4.1-mini JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 14a8931..c64189d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,20 @@ services: + rabbitmq: + image: rabbitmq:3.13-management-alpine + container_name: jobdri-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-jobdri} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-change-me} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/} + ports: + - "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + api: image: ${IMAGE_NAME:-ghcr.io/jobdri-developer/backend}:${IMAGE_TAG:-latest} container_name: jobdri-api @@ -7,6 +23,46 @@ services: environment: SPRING_PROFILES_ACTIVE: prod OPENAI_API_KEY: ${OPENAI_API_KEY:-} + RABBITMQ_HOST: rabbitmq ports: - "${APP_PORT:-8080}:8080" + depends_on: + rabbitmq: + condition: service_healthy + restart: unless-stopped + + worker: + image: ${WORKER_IMAGE_NAME:-ghcr.io/jobdri-developer/analysis-server}:${WORKER_IMAGE_TAG:-latest} + container_name: jobdri-analysis-worker + env_file: + - .env + environment: + WORKER_ENV: prod + SPRING_API_BASE_URL: http://api:8080 + APP_WORKER_INTERNAL_API_KEY: ${APP_WORKER_INTERNAL_API_KEY} + RABBITMQ_HOST: rabbitmq + RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} + RABBITMQ_USERNAME: ${RABBITMQ_USERNAME} + RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD} + RABBITMQ_VHOST: ${RABBITMQ_VHOST:-/} + APP_WORKER_JOB_POSTING_EXCHANGE: ${APP_WORKER_JOB_POSTING_EXCHANGE:-${APP_WORKER_EXCHANGE:-jobdri.worker.exchange}} + APP_WORKER_JOB_POSTING_QUEUE: ${APP_WORKER_JOB_POSTING_QUEUE:-jobdri.job-posting.ingest} + APP_WORKER_JOB_POSTING_ROUTING_KEY: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:-job-posting.ingest} + APP_WORKER_JOB_POSTING_DLQ: ${APP_WORKER_JOB_POSTING_DLQ:-jobdri.job-posting.ingest.dlq} + APP_WORKER_ANALYSIS_EXCHANGE: ${APP_WORKER_ANALYSIS_EXCHANGE:-${APP_WORKER_EXCHANGE:-jobdri.worker.exchange}} + APP_WORKER_ANALYSIS_QUEUE: ${APP_WORKER_ANALYSIS_QUEUE:-jobdri.analysis.execute} + APP_WORKER_ANALYSIS_ROUTING_KEY: ${APP_WORKER_ANALYSIS_ROUTING_KEY:-analysis.execute} + APP_WORKER_ANALYSIS_DLQ: ${APP_WORKER_ANALYSIS_DLQ:-jobdri.analysis.execute.dlq} + APP_WORKER_ANALYSIS_MAX_RETRY_COUNT: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:-3} + APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS: ${APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS:-300000} + WORKER_PREFETCH_COUNT: ${WORKER_PREFETCH_COUNT:-1} + JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:-0.65} + OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_JOB_POSTING_MODEL: ${OPENAI_JOB_POSTING_MODEL:-gpt-4o-mini} + OPENAI_ANALYSIS_MODEL: ${OPENAI_ANALYSIS_MODEL:-gpt-4.1-mini} + depends_on: + api: + condition: service_started + rabbitmq: + condition: service_healthy restart: unless-stopped From 612be47e784b60610a6fe835ac9536181ccce9da Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 14 Jul 2026 17:00:19 +0900 Subject: [PATCH 26/27] =?UTF-8?q?[Chore]=20production=20analysis=20model?= =?UTF-8?q?=20fallback=EC=9D=84=20gpt-4o-mini=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production.example | 2 +- docker-compose.prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.production.example b/.env.production.example index 0f68085..ea85838 100644 --- a/.env.production.example +++ b/.env.production.example @@ -70,7 +70,7 @@ APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS=300000 WORKER_PREFETCH_COUNT=1 OPENAI_JOB_POSTING_MODEL=gpt-4o-mini -OPENAI_ANALYSIS_MODEL=gpt-4.1-mini +OPENAI_ANALYSIS_MODEL=gpt-4o-mini JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c64189d..cdc0832 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -59,7 +59,7 @@ services: JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:-0.65} OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_JOB_POSTING_MODEL: ${OPENAI_JOB_POSTING_MODEL:-gpt-4o-mini} - OPENAI_ANALYSIS_MODEL: ${OPENAI_ANALYSIS_MODEL:-gpt-4.1-mini} + OPENAI_ANALYSIS_MODEL: ${OPENAI_ANALYSIS_MODEL:-gpt-4o-mini} depends_on: api: condition: service_started From 4e6d4a9741daf53e0d4b7f7ee182e24d70e20222 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 14 Jul 2026 18:07:10 +0900 Subject: [PATCH 27/27] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EvaluationAnalysisBatchService.java | 25 +++++------ .../evaluation/EvaluationAnalysisRunner.java | 8 +++- .../analysis/service/AnalysisAiClient.java | 6 +-- .../service/AnalysisAsyncTaskService.java | 11 +++-- .../service/AnalysisResultConstants.java | 16 ++++++++ .../analysis/service/AnalysisService.java | 17 ++++---- .../service/AnalysisWorkerBridgeService.java | 8 ++-- .../controller/JobPostingAiController.java | 4 +- .../service/JobPostingAsyncTaskService.java | 21 +++++----- .../payment/service/PaymentService.java | 8 ++-- .../payment/service/TossPaymentClient.java | 29 +++++++++++-- .../mq/service/RabbitPublishSupport.java | 10 ++++- .../global/sse/SseSubscriptionRegistry.java | 41 +++++++++++++------ .../EvaluationAnalysisRunnerSafetyTest.java | 8 ++++ 14 files changed, 142 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index 163980e..064a85c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -9,6 +9,7 @@ import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisImprovementRules; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants; import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,14 +32,6 @@ @RequiredArgsConstructor @Slf4j public class EvaluationAnalysisBatchService { - private static final int MIN_SCORE = 0; - private static final int MAX_SCORE = 100; - private static final int MAX_ANALYSES_PER_QUESTION = 3; - private static final int MAX_MISSING_KEYWORDS = 3; - private static final int MAX_MISSING_KEYWORD_LENGTH = 60; - private static final double JOB_FIT_WEIGHT = 0.50; - private static final double IMPACT_WEIGHT = 0.30; - private static final double COMPLETENESS_WEIGHT = 0.20; private static final Long EVALUATION_QUESTION_ID = 1L; private static final List REQUIRED_HEADERS = List.of( "caseId", @@ -168,7 +161,7 @@ private List buildMissingKeywords(AnalysisLlmResponse ll } String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + if (keyword.length() > AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH) { continue; } @@ -183,7 +176,7 @@ private List buildMissingKeywords(AnalysisLlmResponse ll } result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= MAX_MISSING_KEYWORDS) { + if (result.size() >= AnalysisResultConstants.MAX_MISSING_KEYWORDS) { break; } } @@ -222,7 +215,7 @@ private List buildQuestionAnalyses( } int currentCount = analysisCountByQuestionId.getOrDefault(item.questionId(), 0); - if (currentCount >= MAX_ANALYSES_PER_QUESTION) { + if (currentCount >= AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION) { continue; } @@ -257,7 +250,9 @@ private List buildQuestionAnalyses( } private int validateScore(String fieldName, Integer score) { - if (score == null || score < MIN_SCORE || score > MAX_SCORE) { + if (score == null + || score < AnalysisResultConstants.MIN_SCORE + || score > AnalysisResultConstants.MAX_SCORE) { throw new IllegalArgumentException("자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다."); } return score; @@ -265,9 +260,9 @@ private int validateScore(String fieldName, Integer score) { private int calculateScore(int jobFit, int impact, int completeness) { return (int) Math.round( - jobFit * JOB_FIT_WEIGHT - + impact * IMPACT_WEIGHT - + completeness * COMPLETENESS_WEIGHT + jobFit * AnalysisResultConstants.JOB_FIT_WEIGHT + + impact * AnalysisResultConstants.IMPACT_WEIGHT + + completeness * AnalysisResultConstants.COMPLETENESS_WEIGHT ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java index 8b3cd47..272ea6e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java @@ -59,15 +59,19 @@ public void run(ApplicationArguments args) throws Exception { summary.failureCount(), summary.outputPath() ); - exitApplication(0); } catch (Exception e) { log.error("평가용 자소서 분석 실행에 실패했습니다. message={}", e.getMessage(), e); + exitApplication(1); throw e; } + exitApplication(0); } private void exitApplication(int exitCode) { - Thread shutdownThread = new Thread(() -> SpringApplication.exit(applicationContext, () -> exitCode)); + Thread shutdownThread = new Thread(() -> { + int resolvedExitCode = SpringApplication.exit(applicationContext, () -> exitCode); + System.exit(resolvedExitCode); + }); shutdownThread.setDaemon(false); shutdownThread.start(); } 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 a834add..91a6742 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 @@ -21,6 +21,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.stream.Collectors; @Component @Slf4j @@ -278,7 +279,7 @@ String buildPrompt( defaultString(question.question()), defaultString(question.answer()) )) - .reduce("", (left, right) -> left + "\n" + right); + .collect(Collectors.joining("\n")); String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); @@ -388,8 +389,7 @@ private String formatCriteriaList(List values) { .filter(value -> value != null && !value.isBlank()) .limit(MAX_CRITERIA_ITEMS) .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) - .reduce("", (left, right) -> left + "\n" + right) - .trim(); + .collect(Collectors.joining("\n")); return formatted.isBlank() ? "없음" : "\n" + formatted; } 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 5eb3f75..a78056a 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 @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; 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.FailureReason; @@ -67,10 +68,10 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant } @Transactional - public void markSuccess(String taskId) { + public void markSuccess(String taskId, AnalysisResponse result) { AnalysisAsyncTask task = getTask(taskId); task.markSuccess(); - publishAfterCommit(toStatusResponse(task)); + publishAfterCommit(toStatusResponse(task, result)); createSuccessNotificationSafely(task); } @@ -138,6 +139,10 @@ private AnalysisAsyncTask getTask(String taskId) { } private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task) { + return toStatusResponse(task, null); + } + + private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task, AnalysisResponse result) { return AnalysisAsyncStatusResponse.builder() .taskId(task.getTaskId()) .mockApplyId(task.getMockApplyId()) @@ -154,7 +159,7 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task) { .lastAttemptAt(task.getLastAttemptAt()) .startedAt(task.getStartedAt()) .completedAt(task.getCompletedAt()) - .result(null) + .result(task.getStatus() == TaskStatus.SUCCEEDED ? result : null) .build(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java new file mode 100644 index 0000000..6263e28 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +public final class AnalysisResultConstants { + + public static final int MIN_SCORE = 0; + public static final int MAX_SCORE = 100; + public static final int MAX_ANALYSES_PER_QUESTION = 3; + public static final int MAX_MISSING_KEYWORDS = 3; + public static final int MAX_MISSING_KEYWORD_LENGTH = 60; + public static final double JOB_FIT_WEIGHT = 0.50; + public static final double IMPACT_WEIGHT = 0.30; + public static final double COMPLETENESS_WEIGHT = 0.20; + + private AnalysisResultConstants() { + } +} 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 926d21c..aa1c78c 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 @@ -44,20 +44,21 @@ import java.util.function.Function; import java.util.stream.Collectors; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.COMPLETENESS_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.IMPACT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.JOB_FIT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_MISSING_KEYWORDS; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_SCORE; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MIN_SCORE; + @Service @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) // 자소서 분석의 핵심 비즈니스 로직과 결과 저장을 담당하는 메인 서비스다. public class AnalysisService { - private static final int MIN_SCORE = 0; - private static final int MAX_SCORE = 100; - private static final int MAX_ANALYSES_PER_QUESTION = 3; - private static final int MAX_MISSING_KEYWORDS = 3; - private static final int MAX_MISSING_KEYWORD_LENGTH = 60; - private static final double JOB_FIT_WEIGHT = 0.50; - private static final double IMPACT_WEIGHT = 0.30; - private static final double COMPLETENESS_WEIGHT = 0.20; private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { }; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index d2a2121..bb06d30 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -31,6 +31,7 @@ public class AnalysisWorkerBridgeService { private final AnalysisService analysisService; private final UserService userService; + @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { AnalysisAsyncTask task = getTask(taskId); if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { @@ -39,6 +40,7 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); } + @Transactional public void markRetry( String taskId, FailureReason failureReason, @@ -126,8 +128,8 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); - confirmCreditIfNeeded(task, user); - analysisAsyncTaskService.markSuccess(taskId); + confirmCreditIfNeeded(task); + analysisAsyncTaskService.markSuccess(taskId, response); return response; } @@ -161,7 +163,7 @@ private void reserveCreditIfNeeded(AnalysisAsyncTask task) { analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); } - private void confirmCreditIfNeeded(AnalysisAsyncTask task, User user) { + private void confirmCreditIfNeeded(AnalysisAsyncTask task) { if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 103a98e..158daff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -104,10 +104,10 @@ public ApiResponse getIngestJobPostingAsyncStatus @AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable String taskId ) { - validateAuthenticatedUser(userDetails); + var user = validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( "채용 공고 비동기 작업 상태 조회에 성공했습니다.", - jobPostingAsyncFacadeService.getTask(userDetails.getUser(), taskId) + jobPostingAsyncFacadeService.getTask(user, taskId) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 52275a5..c08622b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -100,7 +100,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin @Transactional public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (isTerminal(task)) { return; } task.markFailed(failureReason, errorMessage, retryCount); @@ -139,10 +139,7 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { public int sweepTimedOutTasks() { int expiredCount = 0; for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { - TaskStatus beforeStatus = task.getStatus(); - expireTimedOutTaskIfNeeded(task); - if (beforeStatus != task.getStatus() && task.getStatus() == TaskStatus.FAILED) { - publishAfterCommit(toStatusResponse(task)); + if (expireTimedOutTaskIfNeeded(task)) { expiredCount++; } } @@ -181,31 +178,35 @@ private boolean isTerminal(JobPostingAsyncTask task) { return task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED; } - private void expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { + private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { if (isTerminal(task)) { - return; + return false; } LocalDateTime now = LocalDateTime.now(); if (task.getStatus() == TaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, queueTimeoutMinutes)) { - task.markFailed( + markFailed( + task.getTaskId(), FailureReason.QUEUE_TIMEOUT, "채용 공고 작업이 대기열에서 시간 내 처리되지 않았습니다.", task.getRetryCount() ); - return; + return true; } LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); if (task.getStatus() == TaskStatus.RUNNING && isExpired(lastActivityAt, now, processingTimeoutMinutes)) { - task.markFailed( + markFailed( + task.getTaskId(), FailureReason.WORKER_TIMEOUT, "채용 공고 작업이 처리 제한 시간을 초과했습니다.", task.getRetryCount() ); + return true; } + return false; } private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutMinutes) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 96662a4..d384cc8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -81,8 +81,9 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) TossPaymentConfirmResponse tossResponse = tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount()); validateTossResponse(request, tossResponse); - } catch (GeneralException e) { - if (e.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { + } catch (RuntimeException e) { + if (e instanceof GeneralException generalException + && generalException.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { paymentTransactionService.markConfirmationUnknown( validatedUser.getId(), request.orderId(), @@ -92,9 +93,6 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) } paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); throw e; - } catch (RuntimeException e) { - paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); - throw e; } return paymentTransactionService.completeConfirmation(validatedUser.getId(), request); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index fbf2584..7c1bd7a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -17,7 +17,9 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import java.io.InterruptedIOException; import java.nio.charset.StandardCharsets; +import java.net.SocketTimeoutException; import java.time.Duration; import java.util.Base64; @@ -76,11 +78,19 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int "토스페이먼츠 결제 승인 실패" ); } catch (ResourceAccessException e) { - log.warn("Toss payment confirm request timed out. message={}", truncate(e.getMessage())); - log.warn("Toss payment confirm timeout exception", e); + if (isTimeoutException(e)) { + log.warn("Toss payment confirm request timed out. message={}", truncate(e.getMessage())); + log.warn("Toss payment confirm timeout exception", e); + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "토스페이먼츠 결제 승인 응답이 지연되고 있습니다." + ); + } + log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage())); + log.warn("Toss payment confirm request exception", e); throw new GeneralException( - GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, - "토스페이먼츠 결제 승인 응답이 지연되고 있습니다." + GeneralErrorCode.PAYMENT_CONFIRM_FAILED, + "토스페이먼츠 결제 승인 중 오류 발생" ); } catch (RestClientException e) { log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage())); @@ -103,4 +113,15 @@ private String truncate(String value) { } return value.substring(0, LOG_MESSAGE_MAX_LENGTH) + "..."; } + + private boolean isTimeoutException(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SocketTimeoutException || current instanceof InterruptedIOException) { + return true; + } + current = current.getCause(); + } + return false; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java index 2746f0c..d006fb2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java @@ -57,9 +57,9 @@ private void awaitPublisherConfirm(CorrelationData correlationData, String failu } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + throw withCause(failureMessage, e); } catch (ExecutionException | TimeoutException e) { - throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + throw withCause(failureMessage, e); } } @@ -69,4 +69,10 @@ private String buildReasonSuffix(String reason) { } return " reason=" + reason; } + + private GeneralException withCause(String failureMessage, Exception cause) { + GeneralException exception = new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + exception.initCause(cause); + return exception; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java index a6478ca..9516e60 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java +++ b/src/main/java/com/jobdri/jobdri_api/global/sse/SseSubscriptionRegistry.java @@ -94,24 +94,17 @@ public SseEmitter subscribe( } public void publish(String channelKey, String eventName, Object payload, boolean completeAfterPublish) { + List subscribers; synchronized (channelMonitor(channelKey)) { - List subscribers = emitters.get(channelKey); + subscribers = emitters.get(channelKey); if (subscribers == null || subscribers.isEmpty()) { return; } + subscribers = List.copyOf(subscribers); + } - for (SseEmitter emitter : subscribers) { - try { - emitter.send(SseEmitter.event().name(eventName).data(payload)); - if (completeAfterPublish) { - emitter.complete(); - remove(channelKey, emitter); - } - } catch (Exception e) { - emitter.completeWithError(e); - remove(channelKey, emitter); - } - } + for (SseEmitter emitter : subscribers) { + heartbeatDispatchExecutor.execute(() -> publish(channelKey, emitter, eventName, payload, completeAfterPublish)); } } @@ -152,6 +145,28 @@ private void sendHeartbeat(String channelKey, SseEmitter emitter) { } } + private void publish( + String channelKey, + SseEmitter emitter, + String eventName, + Object payload, + boolean completeAfterPublish + ) { + if (!emitters.getOrDefault(channelKey, new CopyOnWriteArrayList<>()).contains(emitter)) { + return; + } + try { + emitter.send(SseEmitter.event().name(eventName).data(payload)); + if (completeAfterPublish) { + emitter.complete(); + remove(channelKey, emitter); + } + } catch (Exception e) { + emitter.completeWithError(e); + remove(channelKey, emitter); + } + } + private void remove(String channelKey, SseEmitter emitter) { List subscribers = emitters.get(channelKey); if (subscribers == null) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java index ad20e2e..e52eaa9 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java @@ -46,6 +46,14 @@ void validateProfilesFailsWhenAnalysisEvalProfileIsMissing() { .hasMessageContaining("Evaluation runner requires analysis-eval profile"); } + @Test + @DisplayName("analysis-eval profile만 활성화되면 프로필 검증에 성공한다") + void validateProfilesSucceedsWhenOnlyAnalysisEvalProfileIsActive() { + EvaluationAnalysisRunner runner = runnerWithProfiles("analysis-eval"); + + runner.validateProfiles(); + } + @Test @DisplayName("confirm-openai-cost=false이면 실행 속성 검증에서 실패한다") void validateExecutionPropertiesFailsWhenCostConfirmationIsFalse() throws Exception {