From 7c511cfa1a5e8a9529cfec8a72b165c634275f8c Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 15:49:18 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Fix]=20=EC=9E=AC=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=A7=80=EC=9B=90=20=EC=88=9C?= =?UTF-8?q?=EB=B2=88=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock_applies에 sequence 필드 추가 - 모의 서류 지원 생성 request에서 sequence를 받을 수 있도록 수정 - 요청 sequence가 있으면 저장하고 없으면 기존 공고 기준 순번을 자동 계산 - 답변 저장 및 분석 응답에서 저장된 sequence를 우선 반환하도록 수정 - 기존 sequence null 데이터는 기존 계산 방식으로 fallback 처리 - 재지원 플로우의 sequence 반환 테스트 추가 --- .../controller/MockApplyController.java | 8 +++- .../request/MockApplyCreateActualRequest.java | 9 +++- ...kApplyCreateMockFromJobPostingRequest.java | 9 +++- .../request/MockApplyCreateMockRequest.java | 10 +++- .../dto/response/MockApplyCreateResponse.java | 6 ++- .../domain/mockapply/entity/MockApply.java | 7 +++ .../repository/MockApplyRepository.java | 4 ++ .../mockapply/service/MockApplyService.java | 46 +++++++++++++++++-- .../analysis/service/AnalysisServiceTest.java | 28 +++++++++++ .../analysis/service/QuestionServiceTest.java | 19 ++++++++ .../service/MockApplyServiceTest.java | 22 +++++++++ 11 files changed, 157 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index ef4e117..297745b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -88,7 +88,7 @@ public ApiResponse createActualApply( ) { return ApiResponse.onSuccess( "모의 서류 지원이 생성되었습니다.", - mockApplyService.createActualApply(userDetails.getUser(), request.jobPostingId()) + mockApplyService.createActualApply(userDetails.getUser(), request.jobPostingId(), request.sequence()) ); } @@ -132,7 +132,11 @@ public ApiResponse createMockApplyFromJobPosting( ) { return ApiResponse.onSuccess( "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApplyFromJobPosting(userDetails.getUser(), request.jobPostingId()) + mockApplyService.createMockApplyFromJobPosting( + userDetails.getUser(), + request.jobPostingId(), + request.sequence() + ) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java index a0c8da6..d56f9ba 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateActualRequest.java @@ -1,9 +1,16 @@ package com.jobdri.jobdri_api.domain.mockapply.dto.request; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateActualRequest( @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId + Long jobPostingId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateActualRequest(Long jobPostingId) { + this(jobPostingId, null); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java index 469355a..af1d53f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java @@ -1,9 +1,16 @@ package com.jobdri.jobdri_api.domain.mockapply.dto.request; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateMockFromJobPostingRequest( @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId + Long jobPostingId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateMockFromJobPostingRequest(Long jobPostingId) { + this(jobPostingId, null); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java index 1ba940c..b4a567e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public record MockApplyCreateMockRequest( @NotNull(message = "회사 ID는 필수입니다.") @@ -11,8 +12,15 @@ public record MockApplyCreateMockRequest( Long middleClassificationId, @NotNull(message = "소분류 ID는 필수입니다.") - Long detailClassificationId + Long detailClassificationId, + + @Positive(message = "지원 순번은 1 이상이어야 합니다.") + Integer sequence ) { + public MockApplyCreateMockRequest(Long companyId, Long middleClassificationId, Long detailClassificationId) { + this(companyId, middleClassificationId, detailClassificationId, null); + } + public JobPostingMockGenerateRequest toJobPostingMockGenerateRequest() { return new JobPostingMockGenerateRequest(companyId, middleClassificationId, detailClassificationId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java index 395cdbd..6e34956 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyCreateResponse.java @@ -6,13 +6,15 @@ public record MockApplyCreateResponse( Long jobPostingId, Long mockApplyId, - ApplyType applyType + ApplyType applyType, + int sequence ) { public static MockApplyCreateResponse from(MockApply mockApply) { return new MockApplyCreateResponse( mockApply.getJobPosting().getId(), mockApply.getId(), - mockApply.getApplyType() + mockApply.getApplyType(), + mockApply.getSequence() ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index fe1e4b5..561f7dd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -39,6 +39,8 @@ public class MockApply { @Column(nullable = false) private MockApplyStatus status; + private Integer sequence; + @Column(nullable = false) private LocalDateTime createdAt; @@ -50,11 +52,16 @@ public class MockApply { private List questions = new ArrayList<>(); public static MockApply create(User user, JobPosting jobPosting, ApplyType applyType) { + return create(user, jobPosting, applyType, null); + } + + public static MockApply create(User user, JobPosting jobPosting, ApplyType applyType, Integer sequence) { return MockApply.builder() .user(user) .jobPosting(jobPosting) .applyType(applyType) .status(MockApplyStatus.APPLICATION_CREATED) + .sequence(sequence) .createdAt(LocalDateTime.now()) .build(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java index f1bd89c..0e898e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java @@ -13,6 +13,10 @@ public interface MockApplyRepository extends JpaRepository { long countByUserIdAndJobPostingId(Long userId, Long jobPostingId); default int calculateSequence(MockApply mockApply) { + if (mockApply.getSequence() != null && mockApply.getSequence() > 0) { + return mockApply.getSequence(); + } + return Math.toIntExact(countSequenceByUserIdAndJobPostingId( mockApply.getUser().getId(), mockApply.getJobPosting().getId(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 23c1206..b8e9da0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -45,20 +45,42 @@ public class MockApplyService { @Transactional @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { + return createActualApply(user, jobPostingId, null); + } + + @Transactional + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") + public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.ACTUAL); + MockApply mockApply = MockApply.create( + validatedUser, + jobPosting, + ApplyType.ACTUAL, + resolveSequence(validatedUser, jobPosting, sequence) + ); return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); } @Transactional @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { + return createMockApplyFromJobPosting(user, jobPostingId, null); + } + + @Transactional + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") + public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.MOCK); + MockApply mockApply = MockApply.create( + validatedUser, + jobPosting, + ApplyType.MOCK, + resolveSequence(validatedUser, jobPosting, sequence) + ); return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); } @@ -90,7 +112,12 @@ public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockReq "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId )); - MockApply mockApply = MockApply.create(validatedUser, savedJobPosting, ApplyType.MOCK); + MockApply mockApply = MockApply.create( + validatedUser, + savedJobPosting, + ApplyType.MOCK, + resolveSequence(validatedUser, savedJobPosting, request.sequence()) + ); return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); } @@ -109,8 +136,9 @@ public MockApplySequenceResponse getMockApplySequence(User user, Long mockApplyI mockApplyRepository.countByUserIdAndJobPostingId(validatedUser.getId(), jobPostingId) ); int sequence = mockApplyRepository.calculateSequence(mockApply); + totalCount = Math.max(totalCount, sequence); - if (sequence < 1 || sequence > totalCount) { + if (sequence < 1) { throw new GeneralException( GeneralErrorCode.MOCK_APPLY_NOT_FOUND, "해당 공고에 연결된 모의 서류 지원 순서를 찾을 수 없습니다. mockApplyId=" + mockApplyId @@ -159,4 +187,14 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } + + private int resolveSequence(User user, JobPosting jobPosting, Integer requestedSequence) { + if (requestedSequence != null) { + return requestedSequence; + } + return Math.toIntExact(mockApplyRepository.countByUserIdAndJobPostingId( + user.getId(), + jobPosting.getId() + )) + 1; + } } 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 a6acfb5..bff8e14 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 @@ -161,6 +161,34 @@ void analyzeReturnsSequence() { assertThat(response.sequence()).isEqualTo(2); } + @Test + @DisplayName("분석 응답은 저장된 지원 순번을 우선 반환한다") + void analyzeReturnsStoredSequence() { + User user = saveUser("analysis-stored-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); + Question question = saveQuestion(mockApply, "재지원 분석 문항입니다.", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 81, + 82, + 83, + "저장 순번 분석입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발했습니다.", + "mentioned", + "성과 지표가 부족합니다.", + "Spring Boot API를 개발해 응답 시간을 개선했습니다." + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.sequence()).isEqualTo(4); + } + @Test @DisplayName("LLM 분석 실패 시 크레딧 차감과 분석 저장을 롤백한다") void analyzeRollsBackCreditWhenLlmFails() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java index 96ce1a3..155ed99 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -326,6 +326,25 @@ void saveAnswersReturnsSequence() { assertThat(response.sequence()).isEqualTo(2); } + @Test + @DisplayName("답변 저장 응답은 저장된 지원 순번을 우선 반환한다") + void saveAnswersReturnsStoredSequence() { + User user = saveUser("answer-stored-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); + QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( + new QuestionSelectionSaveRequest.QuestionItem("재지원 저장 순번 문항입니다.", 700, false) + ))); + Long questionId = selected.questions().get(0).questionId(); + + QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( + new QuestionAnswerSaveRequest.AnswerItem(questionId, "네 번째 지원 답변입니다.") + ))); + + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.sequence()).isEqualTo(4); + } + @Test @DisplayName("해당 지원서에 속하지 않은 문항은 답변 저장에 사용할 수 없다") void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 88a9385..1c36597 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -86,10 +86,28 @@ void createActualApply() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); assertThat(response.applyType()).isEqualTo(ApplyType.ACTUAL); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.ACTUAL); assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); + assertThat(mockApply.getSequence()).isEqualTo(1); + } + + @Test + @DisplayName("요청 순번이 있으면 ACTUAL 타입 지원에 저장한다") + void createActualApplyWithRequestedSequence() { + User user = saveUser("actual-apply-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + + MockApplyCreateResponse response = mockApplyService.createActualApply(user, jobPosting.getId(), 3); + + MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); + MockApplySequenceResponse sequenceResponse = mockApplyService.getMockApplySequence(user, mockApply.getId()); + assertThat(response.sequence()).isEqualTo(3); + assertThat(mockApply.getSequence()).isEqualTo(3); + assertThat(sequenceResponse.sequence()).isEqualTo(3); + assertThat(sequenceResponse.totalCount()).isEqualTo(3); } @Test @@ -120,9 +138,11 @@ void createMockApply() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); JobPosting jobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); + assertThat(mockApply.getSequence()).isEqualTo(1); assertThat(jobPosting.getCompany().getId()).isEqualTo(company.getId()); assertThat(jobPosting.getCompany().getName()).isEqualTo("선택 기업"); assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); @@ -143,9 +163,11 @@ void createMockApplyFromJobPosting() { MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); + assertThat(response.sequence()).isEqualTo(1); assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); + assertThat(mockApply.getSequence()).isEqualTo(1); } @Test From cac86e5871d6fecf65043786646be7baeaf34f8f Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 16:05:15 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=A7=80=EC=9B=90=20=EC=88=9C=EB=B2=88=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock_applies에 사용자, 공고, 순번 기준 유니크 제약 추가 - 자동 순번 생성 중 중복 충돌이 발생하면 다음 순번으로 재시도하도록 수정 - 요청으로 전달된 sequence가 이미 사용 중이면 잘못된 요청으로 예외 처리 - 모의 서류 지원 생성 응답 Swagger 예시에 sequence 필드 추가 - 중복 sequence 요청 예외 테스트 추가 --- .../controller/MockApplyController.java | 6 +-- .../domain/mockapply/entity/MockApply.java | 8 +++- .../mockapply/service/MockApplyService.java | 48 +++++++++++++++---- .../service/MockApplyServiceTest.java | 13 +++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index 297745b..df720cf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -59,7 +59,7 @@ public ApiResponse getMyMockApplies( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"ACTUAL\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"ACTUAL\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -103,7 +103,7 @@ public ApiResponse createActualApply( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -151,7 +151,7 @@ public ApiResponse createMockApplyFromJobPosting( content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\"},\"error\":null}") + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index 561f7dd..b3d6eee 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -16,7 +16,13 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) -@Table(name = "mock_applies") +@Table( + name = "mock_applies", + uniqueConstraints = @UniqueConstraint( + name = "uk_mock_apply_user_posting_sequence", + columnNames = {"user_id", "job_posting_id", "sequence"} + ) +) public class MockApply { @Id diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index b8e9da0..800894b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -25,6 +25,7 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +36,8 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class MockApplyService { + private static final int SEQUENCE_SAVE_MAX_RETRY = 5; + private final MockApplyRepository mockApplyRepository; private final JobPostingRepository jobPostingRepository; private final CompanyRepository companyRepository; @@ -54,13 +57,13 @@ public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, I User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create( + MockApply mockApply = saveMockApplyWithSequence( validatedUser, jobPosting, ApplyType.ACTUAL, - resolveSequence(validatedUser, jobPosting, sequence) + sequence ); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + return MockApplyCreateResponse.from(mockApply); } @Transactional @@ -75,13 +78,13 @@ public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long job User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - MockApply mockApply = MockApply.create( + MockApply mockApply = saveMockApplyWithSequence( validatedUser, jobPosting, ApplyType.MOCK, - resolveSequence(validatedUser, jobPosting, sequence) + sequence ); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + return MockApplyCreateResponse.from(mockApply); } @Transactional @@ -112,13 +115,13 @@ public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockReq "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId )); - MockApply mockApply = MockApply.create( + MockApply mockApply = saveMockApplyWithSequence( validatedUser, savedJobPosting, ApplyType.MOCK, - resolveSequence(validatedUser, savedJobPosting, request.sequence()) + request.sequence() ); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + return MockApplyCreateResponse.from(mockApply); } public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { @@ -197,4 +200,31 @@ private int resolveSequence(User user, JobPosting jobPosting, Integer requestedS jobPosting.getId() )) + 1; } + + private MockApply saveMockApplyWithSequence( + User user, + JobPosting jobPosting, + ApplyType applyType, + Integer requestedSequence + ) { + int sequence = resolveSequence(user, jobPosting, requestedSequence); + for (int attempt = 0; attempt < SEQUENCE_SAVE_MAX_RETRY; attempt++) { + try { + return mockApplyRepository.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); + } catch (DataIntegrityViolationException e) { + if (requestedSequence != null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 사용 중인 지원 순번입니다. sequence=" + requestedSequence + ); + } + sequence++; + } + } + + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 생성에 실패했습니다." + ); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 1c36597..1eb3616 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -110,6 +110,19 @@ void createActualApplyWithRequestedSequence() { assertThat(sequenceResponse.totalCount()).isEqualTo(3); } + @Test + @DisplayName("같은 공고에 이미 사용 중인 순번을 명시하면 예외를 던진다") + void createActualApplyThrowsWhenRequestedSequenceDuplicated() { + User user = saveUser("actual-apply-sequence-duplicate@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + mockApplyService.createActualApply(user, jobPosting.getId(), 2); + + assertThatThrownBy(() -> mockApplyService.createActualApply(user, jobPosting.getId(), 2)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + @Test @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") void createMockApply() { From d39319a94099b0dda7c11ed8c8cc424e7fbc80d3 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 16:17:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=A7=80=EC=9B=90=20=EC=88=9C=EB=B2=88=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 sequence가 양수일 때만 명시 순번으로 사용하도록 수정 - 0 또는 음수 sequence 요청은 자동 순번 할당 로직으로 처리 - 중복 순번 충돌 처리 기준을 양수 sequence 요청으로 제한 - 0 이하 sequence 요청 시 다음 유효 순번이 저장되는 테스트 추가 --- .../mockapply/service/MockApplyService.java | 8 ++++++-- .../service/MockApplyServiceTest.java | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 800894b..1ad331b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -192,7 +192,7 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { } private int resolveSequence(User user, JobPosting jobPosting, Integer requestedSequence) { - if (requestedSequence != null) { + if (isPositiveSequence(requestedSequence)) { return requestedSequence; } return Math.toIntExact(mockApplyRepository.countByUserIdAndJobPostingId( @@ -201,6 +201,10 @@ private int resolveSequence(User user, JobPosting jobPosting, Integer requestedS )) + 1; } + private boolean isPositiveSequence(Integer sequence) { + return sequence != null && sequence > 0; + } + private MockApply saveMockApplyWithSequence( User user, JobPosting jobPosting, @@ -212,7 +216,7 @@ private MockApply saveMockApplyWithSequence( try { return mockApplyRepository.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); } catch (DataIntegrityViolationException e) { - if (requestedSequence != null) { + if (isPositiveSequence(requestedSequence)) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "이미 사용 중인 지원 순번입니다. sequence=" + requestedSequence diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 1eb3616..fa3edae 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -110,6 +110,24 @@ void createActualApplyWithRequestedSequence() { assertThat(sequenceResponse.totalCount()).isEqualTo(3); } + @Test + @DisplayName("요청 순번이 0 이하이면 다음 유효 순번을 저장한다") + void createActualApplyIgnoresNonPositiveRequestedSequence() { + User user = saveUser("actual-apply-non-positive-sequence@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + mockApplyService.createActualApply(user, jobPosting.getId()); + + MockApplyCreateResponse zeroResponse = mockApplyService.createActualApply(user, jobPosting.getId(), 0); + MockApplyCreateResponse negativeResponse = mockApplyService.createActualApply(user, jobPosting.getId(), -1); + + MockApply zeroSequenceApply = mockApplyRepository.findById(zeroResponse.mockApplyId()).orElseThrow(); + MockApply negativeSequenceApply = mockApplyRepository.findById(negativeResponse.mockApplyId()).orElseThrow(); + assertThat(zeroResponse.sequence()).isEqualTo(2); + assertThat(negativeResponse.sequence()).isEqualTo(3); + assertThat(zeroSequenceApply.getSequence()).isEqualTo(2); + assertThat(negativeSequenceApply.getSequence()).isEqualTo(3); + } + @Test @DisplayName("같은 공고에 이미 사용 중인 순번을 명시하면 예외를 던진다") void createActualApplyThrowsWhenRequestedSequenceDuplicated() { From 2a6e59a60fd96dbc2010e70602488da3c315a726 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 16:34:09 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=A7=80=EC=9B=90=20=EC=88=9C=EB=B2=88=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순번 중복 충돌 후 재시도 시 새 트랜잭션에서 저장하도록 PersistenceService 추가 - 모의 서류 지원 생성 흐름의 외부 트랜잭션을 비활성화해 공고 저장 후 지원 저장 FK 참조 안정화 - 자동 순번 저장 중 충돌이 발생하면 다음 순번으로 재시도하도록 유지 - 0 이하 sequence 요청 시 중복 충돌을 거쳐 다음 유효 순번으로 저장되는 테스트 보강 --- .../service/MockApplyPersistenceService.java | 20 +++++ .../mockapply/service/MockApplyService.java | 14 ++-- .../service/MockApplyServiceTest.java | 76 +++++++++++++------ 3 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java new file mode 100644 index 0000000..d4c9f8d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.mockapply.service; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MockApplyPersistenceService { + + private final MockApplyRepository mockApplyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public MockApply saveAndFlush(MockApply mockApply) { + return mockApplyRepository.saveAndFlush(mockApply); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 1ad331b..5e7daf3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -44,14 +45,15 @@ public class MockApplyService { private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; + private final MockApplyPersistenceService mockApplyPersistenceService; - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { return createActualApply(user, jobPostingId, null); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); @@ -66,13 +68,13 @@ public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, I return MockApplyCreateResponse.from(mockApply); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { return createMockApplyFromJobPosting(user, jobPostingId, null); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId, Integer sequence) { User validatedUser = userService.validateUser(user); @@ -87,7 +89,7 @@ public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long job return MockApplyCreateResponse.from(mockApply); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { User validatedUser = userService.validateUser(user); @@ -214,7 +216,7 @@ private MockApply saveMockApplyWithSequence( int sequence = resolveSequence(user, jobPosting, requestedSequence); for (int attempt = 0; attempt < SEQUENCE_SAVE_MAX_RETRY; attempt++) { try { - return mockApplyRepository.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); + return mockApplyPersistenceService.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); } catch (DataIntegrityViolationException e) { if (isPositiveSequence(requestedSequence)) { throw new GeneralException( diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index fa3edae..22a7842 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -33,10 +33,15 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -72,6 +77,9 @@ class MockApplyServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @MockBean private MockJobPostingGenerationService mockJobPostingGenerationService; @@ -115,17 +123,18 @@ void createActualApplyWithRequestedSequence() { void createActualApplyIgnoresNonPositiveRequestedSequence() { User user = saveUser("actual-apply-non-positive-sequence@example.com"); JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); - mockApplyService.createActualApply(user, jobPosting.getId()); + saveMockApply(user, jobPosting, ApplyType.ACTUAL, 1); + saveMockApply(user, jobPosting, ApplyType.ACTUAL, 3); MockApplyCreateResponse zeroResponse = mockApplyService.createActualApply(user, jobPosting.getId(), 0); MockApplyCreateResponse negativeResponse = mockApplyService.createActualApply(user, jobPosting.getId(), -1); MockApply zeroSequenceApply = mockApplyRepository.findById(zeroResponse.mockApplyId()).orElseThrow(); MockApply negativeSequenceApply = mockApplyRepository.findById(negativeResponse.mockApplyId()).orElseThrow(); - assertThat(zeroResponse.sequence()).isEqualTo(2); - assertThat(negativeResponse.sequence()).isEqualTo(3); - assertThat(zeroSequenceApply.getSequence()).isEqualTo(2); - assertThat(negativeSequenceApply.getSequence()).isEqualTo(3); + assertThat(zeroResponse.sequence()).isEqualTo(4); + assertThat(negativeResponse.sequence()).isEqualTo(5); + assertThat(zeroSequenceApply.getSequence()).isEqualTo(4); + assertThat(negativeSequenceApply.getSequence()).isEqualTo(5); } @Test @@ -145,7 +154,8 @@ void createActualApplyThrowsWhenRequestedSequenceDuplicated() { @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") void createMockApply() { User user = saveUser("mock-apply@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + String companyName = "선택 기업 " + UUID.randomUUID(); + Company company = saveCompany(companyName, CompanySize.MEDIUM); DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); Long middleClassificationId = detailClassification.getMiddleClassification().getId(); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( @@ -155,7 +165,7 @@ void createMockApply() { ); when(mockJobPostingGenerationService.generate(any())) .thenReturn(new JobPostingMockGenerateResponse( - "선택 기업", + companyName, "프론트엔드 개발자", "웹 프론트엔드 개발 및 운영", "HTML/CSS/JavaScript 기본기", @@ -175,7 +185,7 @@ void createMockApply() { assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); assertThat(mockApply.getSequence()).isEqualTo(1); assertThat(jobPosting.getCompany().getId()).isEqualTo(company.getId()); - assertThat(jobPosting.getCompany().getName()).isEqualTo("선택 기업"); + assertThat(jobPosting.getCompany().getName()).isEqualTo(companyName); assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); assertThat(jobPosting.getTask()).isEqualTo("웹 프론트엔드 개발 및 운영"); @@ -303,7 +313,7 @@ void createMockApplyFromJobPostingThrowsWhenForbidden() { @DisplayName("존재하지 않는 소분류 ID로 MOCK 타입 지원 생성 시 예외를 던진다") void createMockApplyThrowsWhenDetailClassificationNotFound() { User user = saveUser("missing-detail-classification@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 1L, 9999L); when(mockJobPostingGenerationService.generate(any())) .thenThrow(new GeneralException( @@ -333,7 +343,7 @@ void createMockApplyThrowsWhenCompanyNotFound() { @DisplayName("소분류가 중분류에 속하지 않으면 MOCK 타입 지원 생성 시 예외를 던진다") void createMockApplyThrowsWhenMiddleClassificationMismatched() { User user = saveUser("middle-mismatch@example.com"); - Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); DetailClassification detailClassification = saveDetailClassification("데이터 분석"); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, detailClassification.getId()); when(mockJobPostingGenerationService.generate(any())) @@ -363,27 +373,49 @@ void getMockApplyJobPostingThrowsWhenForbidden() { } private User saveUser(String email) { - return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + return inNewTransaction(() -> userRepository.save(User.signup("테스트 사용자", email, "encoded-password"))); } private JobPosting saveJobPosting(User user, String detailName) { - Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); - DetailClassification detailClassification = saveDetailClassification(detailName); - return jobPostingRepository.save(JobPosting.create( - user, - company, - detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" - )); + return inNewTransaction(() -> { + Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassificationInCurrentTransaction(detailName); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + }); } private DetailClassification saveDetailClassification(String detailName) { - Classification classification = Classification.create("테스트 대분류 " + detailName); + return inNewTransaction(() -> saveDetailClassificationInCurrentTransaction(detailName)); + } + + private DetailClassification saveDetailClassificationInCurrentTransaction(String detailName) { + Classification classification = Classification.create("테스트 대분류 " + detailName + " " + UUID.randomUUID()); MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류 " + detailName); DetailClassification detailClassification = middleClassification.addDetailClassification(detailName); classificationRepository.save(classification); return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); } + + private Company saveCompany(String name, CompanySize size) { + return inNewTransaction(() -> companyRepository.save(Company.create(name, size))); + } + + private MockApply saveMockApply(User user, JobPosting jobPosting, ApplyType applyType, Integer sequence) { + return inNewTransaction(() -> mockApplyRepository.saveAndFlush( + MockApply.create(user, jobPosting, applyType, sequence) + )); + } + + private T inNewTransaction(Supplier action) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return transactionTemplate.execute(status -> action.get()); + } } From fc455da224006e97f1bac664246e372c63f91bae Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 16:45:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=A7=80=EC=9B=90=20=EC=88=9C=EB=B2=88=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataIntegrityViolationException 발생 시 순번 유니크 제약 위반 여부를 확인하도록 수정 - uk_mock_apply_user_posting_sequence 제약 위반일 때만 순번 재시도 로직 수행 - 명시 sequence 중복 요청은 기존처럼 잘못된 요청 예외로 처리 - FK 오류 등 다른 무결성 오류는 원본 예외를 그대로 전파하도록 변경 --- .../mockapply/service/MockApplyService.java | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 5e7daf3..e0f3046 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -30,7 +30,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.sql.SQLException; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; @Service @@ -38,6 +40,8 @@ @Transactional(readOnly = true) public class MockApplyService { private static final int SEQUENCE_SAVE_MAX_RETRY = 5; + private static final String SEQUENCE_UNIQUE_CONSTRAINT = "uk_mock_apply_user_posting_sequence"; + private static final String UNIQUE_VIOLATION_SQL_STATE = "23505"; private final MockApplyRepository mockApplyRepository; private final JobPostingRepository jobPostingRepository; @@ -218,6 +222,9 @@ private MockApply saveMockApplyWithSequence( try { return mockApplyPersistenceService.saveAndFlush(MockApply.create(user, jobPosting, applyType, sequence)); } catch (DataIntegrityViolationException e) { + if (!isSequenceUniqueConflict(e)) { + throw e; + } if (isPositiveSequence(requestedSequence)) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, @@ -229,8 +236,37 @@ private MockApply saveMockApplyWithSequence( } throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "모의 서류 지원 순번 생성에 실패했습니다." + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 생성에 실패했습니다." ); } + + private boolean isSequenceUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation + && isSequenceConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (cause instanceof SQLException sqlException + && UNIQUE_VIOLATION_SQL_STATE.equals(sqlException.getSQLState()) + && containsSequenceConstraint(sqlException.getMessage())) { + return true; + } + if (containsSequenceConstraint(cause.getMessage())) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private boolean isSequenceConstraintName(String constraintName) { + return containsSequenceConstraint(constraintName); + } + + private boolean containsSequenceConstraint(String value) { + return value != null + && value.toLowerCase(Locale.ROOT).contains(SEQUENCE_UNIQUE_CONSTRAINT); + } }