diff --git a/.env.example b/.env.example index 4990b2a..fd12851 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,11 @@ GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect OPENAI_API_KEY=change-me JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 +JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 +JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 +MAIL_ASYNC_CORE_POOL_SIZE=1 +MAIL_ASYNC_MAX_POOL_SIZE=2 +MAIL_ASYNC_QUEUE_CAPACITY=50 MANAGEMENT_HEALTH_SHOW_DETAILS=always diff --git a/.env.production.example b/.env.production.example index eb0adfb..9d5c50d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -32,5 +32,11 @@ GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect OPENAI_API_KEY=change-me JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 +JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 +JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 +MAIL_ASYNC_CORE_POOL_SIZE=1 +MAIL_ASYNC_MAX_POOL_SIZE=2 +MAIL_ASYNC_QUEUE_CAPACITY=50 MANAGEMENT_HEALTH_SHOW_DETAILS=never diff --git a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AsyncEmailSender.java b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AsyncEmailSender.java index e3f077e..8f69db3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AsyncEmailSender.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AsyncEmailSender.java @@ -22,7 +22,7 @@ public class AsyncEmailSender { @Value("${mail.from:${spring.mail.username:}}") private String fromAddress; - @Async + @Async("mailAsyncExecutor") @Retryable( retryFor = MailException.class, maxAttempts = 3, 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 3a4b58c..33f2c1e 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 @@ -3,9 +3,12 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +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.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; 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.JobPostingIngestService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -22,6 +25,8 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; @RestController @RequiredArgsConstructor @@ -31,6 +36,7 @@ public class JobPostingAiController { private final JobPostingAiService jobPostingAiService; private final JobPostingIngestService jobPostingIngestService; + private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService; @Operation( summary = "채용 공고 정보 추출", @@ -195,4 +201,32 @@ public ApiResponse ingestJobPosting( jobPostingIngestService.ingestAndCreate(request) ); } + + @Operation( + summary = "채용 공고 비동기 일괄 처리 접수", + description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다." + ) + @PostMapping(value = "/ingest/async", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse submitIngestJobPostingAsync( + @ModelAttribute JobPostingIngestMultipartRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 비동기 작업 접수에 성공했습니다.", + jobPostingAsyncFacadeService.submit(request) + ); + } + + @Operation( + summary = "채용 공고 비동기 작업 상태 조회", + description = "taskId로 비동기 작업 상태와 결과를 조회합니다." + ) + @GetMapping("/ingest/async/{taskId}") + public ApiResponse getIngestJobPostingAsyncStatus( + @PathVariable String taskId + ) { + return ApiResponse.onSuccess( + "채용 공고 비동기 작업 상태 조회에 성공했습니다.", + jobPostingAsyncFacadeService.getTask(taskId) + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java new file mode 100644 index 0000000..ef0cbf6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class JobPostingIngestCommand { + + private String rawText; + private String sourceUrl; + private byte[] imageBytes; + private String imageContentType; + private CompanySize companySize; + private String tone; + private Integer candidateLimit; +} 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 new file mode 100644 index 0000000..8e3d286 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java @@ -0,0 +1,22 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class JobPostingAsyncStatusResponse { + + private String taskId; + private String status; + private String message; + private String error; + private LocalDateTime createdAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private JobPostingIngestResponse result; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncSubmitResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncSubmitResponse.java new file mode 100644 index 0000000..9a6b2d4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncSubmitResponse.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JobPostingAsyncSubmitResponse { + + private String taskId; + private String status; + private String message; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index b025c4e..cb26740 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -105,18 +105,18 @@ public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartReq return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl()); } - public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) { - validateInput(rawText, imageFile); + public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType, String sourceUrl) { + validateInput(rawText, imageBytes); List contents = new ArrayList<>(); contents.add(ResponseInputContent.ofInputText( com.openai.models.responses.ResponseInputText.builder() - .text(buildPrompt(rawText, sourceUrl, imageFile != null)) + .text(buildPrompt(rawText, sourceUrl, imageBytes != null && imageBytes.length > 0)) .build() )); - if (imageFile != null && !imageFile.isEmpty()) { - contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageFile))); + if (imageBytes != null && imageBytes.length > 0) { + contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageBytes, imageContentType))); } var params = ResponseCreateParams.builder() @@ -136,16 +136,23 @@ public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile try { StructuredResponse response = openAIClient.responses().create(params); JobPostingExtractResponse extracted = extractStructuredContent(response, JobPostingExtractResponse.class); - normalizeResponse(extracted, rawText); return extracted; - } catch (Exception e) { log.error("채용 공고 추출 OpenAI API 호출 오류: {}", e.getMessage(), e); return createFallbackResponse(rawText); } } + public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) { + return extractJobPosting( + rawText, + imageFile == null || imageFile.isEmpty() ? null : readImageBytes(imageFile), + imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType(), + sourceUrl + ); + } + private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { String normalizedRawText = rawText == null ? "" : rawText; String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl; @@ -244,20 +251,18 @@ private String buildClassificationPrompt( } private ResponseInputImage buildImageContent(MultipartFile imageFile) { - validateImage(imageFile); + return buildImageContent(readImageBytes(imageFile), imageFile.getContentType()); + } - try { - String contentType = imageFile.getContentType(); - String base64 = Base64.getEncoder().encodeToString(imageFile.getBytes()); - String dataUrl = "data:%s;base64,%s".formatted(contentType, base64); - - return ResponseInputImage.builder() - .imageUrl(dataUrl) - .detail(ResponseInputImage.Detail.HIGH) - .build(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } + private ResponseInputImage buildImageContent(byte[] imageBytes, String imageContentType) { + validateImage(imageContentType); + String base64 = Base64.getEncoder().encodeToString(imageBytes); + String dataUrl = "data:%s;base64,%s".formatted(imageContentType, base64); + + return ResponseInputImage.builder() + .imageUrl(dataUrl) + .detail(ResponseInputImage.Detail.HIGH) + .build(); } private T extractStructuredContent(StructuredResponse response, Class responseType) { @@ -285,8 +290,23 @@ private void validateInput(String rawText, MultipartFile imageFile) { } } + private void validateInput(String rawText, byte[] imageBytes) { + boolean hasRawText = rawText != null && !rawText.isBlank(); + boolean hasImage = imageBytes != null && imageBytes.length > 0; + + if (!hasRawText && !hasImage) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "rawText 또는 image 중 하나는 반드시 포함되어야 합니다." + ); + } + } + private void validateImage(MultipartFile imageFile) { - String contentType = imageFile.getContentType(); + validateImage(imageFile.getContentType()); + } + + private void validateImage(String contentType) { if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, @@ -295,6 +315,16 @@ private void validateImage(MultipartFile imageFile) { } } + private byte[] readImageBytes(MultipartFile imageFile) { + validateImage(imageFile); + + try { + return imageFile.getBytes(); + } catch (IOException e) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); + } + } + private void normalizeResponse(JobPostingExtractResponse response, String rawText) { if (response == null) { throw new GeneralException( 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 new file mode 100644 index 0000000..79141d9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -0,0 +1,76 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +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.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class JobPostingAsyncFacadeService { + + private final JobPostingAsyncTaskService jobPostingAsyncTaskService; + private final JobPostingAsyncProcessor jobPostingAsyncProcessor; + + public JobPostingAsyncSubmitResponse submit(JobPostingIngestMultipartRequest request) { + String taskId = jobPostingAsyncTaskService.createPendingTask(); + JobPostingIngestCommand command = snapshot(request); + + try { + jobPostingAsyncProcessor.process(taskId, command); + return new JobPostingAsyncSubmitResponse( + taskId, + "PENDING", + "채용 공고 비동기 작업이 접수되었습니다." + ); + } catch (TaskRejectedException e) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "현재 비동기 작업이 많아 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요." + ); + } + } + + public JobPostingAsyncStatusResponse getTask(String taskId) { + return jobPostingAsyncTaskService.getTask(taskId); + } + + private JobPostingIngestCommand snapshot(JobPostingIngestMultipartRequest request) { + return JobPostingIngestCommand.builder() + .rawText(request.getRawText()) + .sourceUrl(request.getSourceUrl()) + .imageBytes(readBytes(request.getImage())) + .imageContentType(readContentType(request.getImage())) + .companySize(request.getCompanySize()) + .tone(request.getTone()) + .candidateLimit(request.getCandidateLimit()) + .build(); + } + + private byte[] readBytes(MultipartFile image) { + if (image == null || image.isEmpty()) { + return null; + } + + try { + return image.getBytes(); + } catch (IOException e) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); + } + } + + private String readContentType(MultipartFile image) { + if (image == null || image.isEmpty()) { + return null; + } + return image.getContentType(); + } +} 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 new file mode 100644 index 0000000..52e65bd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java @@ -0,0 +1,30 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JobPostingAsyncProcessor { + + private final JobPostingAsyncTaskService jobPostingAsyncTaskService; + private final JobPostingIngestService jobPostingIngestService; + + @Async("jobPostingAsyncExecutor") + public void process(String taskId, JobPostingIngestCommand command) { + jobPostingAsyncTaskService.markRunning(taskId); + + try { + JobPostingIngestResponse result = jobPostingIngestService.ingestAndCreate(command); + jobPostingAsyncTaskService.markSuccess(taskId, result); + } catch (Exception e) { + log.error("채용 공고 비동기 처리 실패: taskId={}", taskId, e); + jobPostingAsyncTaskService.markFailed(taskId, e.getMessage()); + } + } +} 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 new file mode 100644 index 0000000..4964499 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -0,0 +1,109 @@ +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.dto.response.JobPostingIngestResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.Getter; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class JobPostingAsyncTaskService { + + private final Map tasks = new ConcurrentHashMap<>(); + + public String createPendingTask() { + String taskId = UUID.randomUUID().toString(); + tasks.put(taskId, TaskState.pending(taskId)); + return taskId; + } + + public void markRunning(String taskId) { + TaskState current = getTaskState(taskId); + current.status = TaskStatus.RUNNING; + current.message = "채용 공고 비동기 처리를 진행 중입니다."; + current.startedAt = LocalDateTime.now(); + } + + public void markSuccess(String taskId, JobPostingIngestResponse result) { + TaskState current = getTaskState(taskId); + current.status = TaskStatus.SUCCEEDED; + current.message = "채용 공고 비동기 처리에 성공했습니다."; + current.result = result; + current.error = null; + current.completedAt = LocalDateTime.now(); + } + + public void markFailed(String taskId, String errorMessage) { + TaskState current = getTaskState(taskId); + current.status = TaskStatus.FAILED; + current.message = "채용 공고 비동기 처리에 실패했습니다."; + current.error = errorMessage; + current.completedAt = LocalDateTime.now(); + } + + public JobPostingAsyncStatusResponse getTask(String taskId) { + TaskState taskState = getTaskState(taskId); + return JobPostingAsyncStatusResponse.builder() + .taskId(taskState.taskId) + .status(taskState.status.name()) + .message(taskState.message) + .error(taskState.error) + .createdAt(taskState.createdAt) + .startedAt(taskState.startedAt) + .completedAt(taskState.completedAt) + .result(taskState.result) + .build(); + } + + private TaskState getTaskState(String taskId) { + TaskState taskState = tasks.get(taskId); + if (taskState == null) { + throw new GeneralException( + GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, + "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + ); + } + return taskState; + } + + private enum TaskStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED + } + + @Getter + private static class TaskState { + private final String taskId; + private final LocalDateTime createdAt; + private volatile TaskStatus status; + private volatile String message; + private volatile String error; + private volatile LocalDateTime startedAt; + private volatile LocalDateTime completedAt; + private volatile JobPostingIngestResponse result; + + private TaskState(String taskId, LocalDateTime createdAt, TaskStatus status, String message) { + this.taskId = taskId; + this.createdAt = createdAt; + this.status = status; + this.message = message; + } + + private static TaskState pending(String taskId) { + return new TaskState( + taskId, + LocalDateTime.now(), + TaskStatus.PENDING, + "채용 공고 비동기 작업이 접수되었습니다." + ); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index 77e4729..41baef8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; @@ -14,7 +15,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -24,26 +24,37 @@ public class JobPostingIngestService { private static final int DEFAULT_CANDIDATE_LIMIT = 10; - @Value("${job-posting.ingest.classification-confidence-threshold:0.65}") + @Value("${job-posting.ingest.classification-confidence-threshold}") private double classificationConfidenceThreshold; private final JobPostingAiService jobPostingAiService; private final JobPostingClassificationService jobPostingClassificationService; private final JobPostingService jobPostingService; - @Transactional public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest request) { - if (request.getCompanySize() == null) { + JobPostingIngestCommand command = JobPostingIngestCommand.builder() + .rawText(request.getRawText()) + .sourceUrl(request.getSourceUrl()) + .companySize(request.getCompanySize()) + .tone(request.getTone()) + .candidateLimit(request.getCandidateLimit()) + .build(); + return ingestAndCreate(command); + } + + public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) { + if (command.getCompanySize() == null) { throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "회사 규모는 필수입니다."); } JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( - request.getRawText(), - request.getImage(), - request.getSourceUrl() + command.getRawText(), + command.getImageBytes(), + command.getImageContentType(), + command.getSourceUrl() ); - int candidateLimit = request.getCandidateLimit() == null ? DEFAULT_CANDIDATE_LIMIT : request.getCandidateLimit(); + int candidateLimit = command.getCandidateLimit() == null ? DEFAULT_CANDIDATE_LIMIT : command.getCandidateLimit(); List candidates = jobPostingClassificationService.findCandidates(extracted, candidateLimit); @@ -72,14 +83,14 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest JobPostingGenerateResponse generated = jobPostingAiService.generateJobPosting( new JobPostingGenerateRequest( extracted.getCompanyName(), - request.getCompanySize(), + command.getCompanySize(), classification.getDetailClassificationId(), extracted.getRawText(), "", extracted.getTask(), extracted.getRequirements(), extracted.getPreferredQualifications(), - request.getTone(), + command.getTone(), extracted.getJobTitle() ) ); @@ -87,7 +98,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest JobPostingResponse saved = jobPostingService.createJobPosting( new JobPostingCreateRequest( fallbackCompanyName(extracted.getCompanyName()), - request.getCompanySize(), + command.getCompanySize(), classification.getDetailClassificationId(), generated.getTask(), generated.getRequirements(), diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 9132649..a81e266 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -34,6 +34,7 @@ public enum GeneralErrorCode implements BaseErrorCode { // 채용 공고 에러 JOB_POSTING_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4041", "채용 공고를 찾을 수 없습니다."), + JOB_POSTING_ASYNC_TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4042", "채용 공고 비동기 작업을 찾을 수 없습니다."), // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java new file mode 100644 index 0000000..9104e36 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java @@ -0,0 +1,48 @@ +package com.jobdri.jobdri_api.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +public class AsyncConfig { + + @Bean(name = "jobPostingAsyncExecutor") + public ThreadPoolTaskExecutor jobPostingAsyncExecutor( + @Value("${async.job-posting.core-pool-size:2}") int corePoolSize, + @Value("${async.job-posting.max-pool-size:4}") int maxPoolSize, + @Value("${async.job-posting.queue-capacity:20}") int queueCapacity + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("job-posting-async-"); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setAllowCoreThreadTimeOut(true); + executor.setWaitForTasksToCompleteOnShutdown(false); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.initialize(); + return executor; + } + + @Bean(name = "mailAsyncExecutor") + public ThreadPoolTaskExecutor mailAsyncExecutor( + @Value("${async.mail.core-pool-size:1}") int corePoolSize, + @Value("${async.mail.max-pool-size:2}") int maxPoolSize, + @Value("${async.mail.queue-capacity:50}") int queueCapacity + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("mail-async-"); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setAllowCoreThreadTimeOut(true); + executor.setWaitForTasksToCompleteOnShutdown(false); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index ab79a41..7432cfe 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -86,4 +86,14 @@ openai: job-posting: ingest: - classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD} + classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + +async: + job-posting: + core-pool-size: ${JOB_POSTING_ASYNC_CORE_POOL_SIZE:2} + max-pool-size: ${JOB_POSTING_ASYNC_MAX_POOL_SIZE:4} + queue-capacity: ${JOB_POSTING_ASYNC_QUEUE_CAPACITY:20} + mail: + core-pool-size: ${MAIL_ASYNC_CORE_POOL_SIZE:1} + max-pool-size: ${MAIL_ASYNC_MAX_POOL_SIZE:2} + queue-capacity: ${MAIL_ASYNC_QUEUE_CAPACITY:50} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4a41118..23b5d28 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -76,3 +76,13 @@ openai: job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + +async: + job-posting: + core-pool-size: ${JOB_POSTING_ASYNC_CORE_POOL_SIZE:2} + max-pool-size: ${JOB_POSTING_ASYNC_MAX_POOL_SIZE:4} + queue-capacity: ${JOB_POSTING_ASYNC_QUEUE_CAPACITY:20} + mail: + core-pool-size: ${MAIL_ASYNC_CORE_POOL_SIZE:1} + max-pool-size: ${MAIL_ASYNC_MAX_POOL_SIZE:2} + queue-capacity: ${MAIL_ASYNC_QUEUE_CAPACITY:50}