From b254f0f93573b75bf9c03f67a7a42a7021e84860 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 17:32:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Fix]=20=EC=9E=AC=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=88=9C=EB=B2=88=20=EA=B3=84=EC=82=B0=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 sequence 계산 기준을 공고 ID에서 사용자, 회사, 직무 기준으로 변경 - 재지원 시 공고가 복제되어도 같은 회사와 직무이면 다음 순번이 저장되도록 수정 - 같은 회사와 직무의 다른 공고로 재지원할 때 sequence가 증가하는 테스트 추가 - 기존 명시 sequence 저장 및 중복 충돌 처리 로직은 유지 --- .../repository/MockApplyRepository.java | 13 +++++++ .../mockapply/service/MockApplyService.java | 7 ++-- .../service/MockApplyServiceTest.java | 34 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) 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 0e898e0..d739925 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 @@ -12,6 +12,19 @@ public interface MockApplyRepository extends JpaRepository { List findAllByJobPostingId(Long jobPostingId); long countByUserIdAndJobPostingId(Long userId, Long jobPostingId); + @Query(""" + select coalesce(max(ma.sequence), 0) + from MockApply ma + where ma.user.id = :userId + and ma.jobPosting.company.id = :companyId + and ma.jobPosting.detailClassification.id = :detailClassificationId + """) + int findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( + @Param("userId") Long userId, + @Param("companyId") Long companyId, + @Param("detailClassificationId") Long detailClassificationId + ); + default int calculateSequence(MockApply mockApply) { if (mockApply.getSequence() != null && mockApply.getSequence() > 0) { return mockApply.getSequence(); 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 e0f3046..bfad499 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 @@ -201,10 +201,11 @@ private int resolveSequence(User user, JobPosting jobPosting, Integer requestedS if (isPositiveSequence(requestedSequence)) { return requestedSequence; } - return Math.toIntExact(mockApplyRepository.countByUserIdAndJobPostingId( + return mockApplyRepository.findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( user.getId(), - jobPosting.getId() - )) + 1; + jobPosting.getCompany().getId(), + jobPosting.getDetailClassification().getId() + ) + 1; } private boolean isPositiveSequence(Integer sequence) { 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 22a7842..e51a370 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 @@ -118,6 +118,24 @@ void createActualApplyWithRequestedSequence() { assertThat(sequenceResponse.totalCount()).isEqualTo(3); } + @Test + @DisplayName("같은 회사와 직무의 다른 공고로 재지원하면 다음 순번을 저장한다") + void createActualApplySequencesAcrossSameCompanyAndDetailJobPostings() { + User user = saveUser("actual-apply-retry-sequence@example.com"); + Company company = saveCompany("재지원 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); + DetailClassification detailClassification = saveDetailClassification("백엔드 개발"); + JobPosting firstJobPosting = saveJobPosting(user, company, detailClassification, "첫 번째 JD"); + JobPosting secondJobPosting = saveJobPosting(user, company, detailClassification, "복제된 JD"); + + MockApplyCreateResponse firstResponse = mockApplyService.createActualApply(user, firstJobPosting.getId()); + MockApplyCreateResponse secondResponse = mockApplyService.createActualApply(user, secondJobPosting.getId()); + + MockApply secondMockApply = mockApplyRepository.findById(secondResponse.mockApplyId()).orElseThrow(); + assertThat(firstResponse.sequence()).isEqualTo(1); + assertThat(secondResponse.sequence()).isEqualTo(2); + assertThat(secondMockApply.getSequence()).isEqualTo(2); + } + @Test @DisplayName("요청 순번이 0 이하이면 다음 유효 순번을 저장한다") void createActualApplyIgnoresNonPositiveRequestedSequence() { @@ -391,6 +409,22 @@ private JobPosting saveJobPosting(User user, String detailName) { }); } + private JobPosting saveJobPosting( + User user, + Company company, + DetailClassification detailClassification, + String task + ) { + return inNewTransaction(() -> jobPostingRepository.save(JobPosting.create( + userRepository.findById(user.getId()).orElseThrow(), + companyRepository.findById(company.getId()).orElseThrow(), + detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(), + task, + "자격 요건", + "우대 사항" + ))); + } + private DetailClassification saveDetailClassification(String detailName) { return inNewTransaction(() -> saveDetailClassificationInCurrentTransaction(detailName)); } From 9519110d1761436d6b9bc0301de805cb173284a4 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 17:47:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Fix]=20=EC=9E=AC=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=88=9C=EB=B2=88=20=ED=95=A0=EB=8B=B9=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=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 - 사용자, 회사, 직무 기준 순번 관리를 위한 MockApplySequence 엔티티 추가 - 순번 row를 비관적 락으로 조회한 뒤 증가시키는 전용 서비스 추가 - 자동 sequence 생성 시 max+1 직접 계산 대신 원자적 allocator를 사용하도록 수정 - sequence row 최초 생성 시 기존 모의 서류 지원의 최대 순번을 기준으로 초기화 - 최초 row 생성 충돌 시 재시도하도록 처리 --- .../mockapply/entity/MockApplySequence.java | 58 +++++++++++++++++++ .../MockApplySequenceRepository.java | 27 +++++++++ .../service/MockApplySequenceService.java | 37 ++++++++++++ .../mockapply/service/MockApplyService.java | 29 ++++++++-- 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java new file mode 100644 index 0000000..3245e8d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java @@ -0,0 +1,58 @@ +package com.jobdri.jobdri_api.domain.mockapply.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table( + name = "mock_apply_sequences", + uniqueConstraints = @UniqueConstraint( + name = "uk_mock_apply_sequences_key", + columnNames = {"user_id", "company_id", "detail_classification_id"} + ) +) +public class MockApplySequence { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(name = "detail_classification_id", nullable = false) + private Long detailClassificationId; + + @Column(nullable = false) + private int lastSequence; + + public static MockApplySequence create( + Long userId, + Long companyId, + Long detailClassificationId, + int lastSequence + ) { + return MockApplySequence.builder() + .userId(userId) + .companyId(companyId) + .detailClassificationId(detailClassificationId) + .lastSequence(lastSequence) + .build(); + } + + public int incrementAndGet() { + lastSequence++; + return lastSequence; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java new file mode 100644 index 0000000..f519d1a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java @@ -0,0 +1,27 @@ +package com.jobdri.jobdri_api.domain.mockapply.repository; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplySequence; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface MockApplySequenceRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select mas + from MockApplySequence mas + where mas.userId = :userId + and mas.companyId = :companyId + and mas.detailClassificationId = :detailClassificationId + """) + Optional findByKeyForUpdate( + @Param("userId") Long userId, + @Param("companyId") Long companyId, + @Param("detailClassificationId") Long detailClassificationId + ); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java new file mode 100644 index 0000000..d6cf1a8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.mockapply.service; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplySequence; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplySequenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MockApplySequenceService { + + private final MockApplySequenceRepository mockApplySequenceRepository; + private final MockApplyRepository mockApplyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int allocate(Long userId, Long companyId, Long detailClassificationId) { + MockApplySequence sequence = mockApplySequenceRepository + .findByKeyForUpdate(userId, companyId, detailClassificationId) + .orElseGet(() -> mockApplySequenceRepository.saveAndFlush( + MockApplySequence.create( + userId, + companyId, + detailClassificationId, + mockApplyRepository.findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( + userId, + companyId, + detailClassificationId + ) + ) + )); + + return sequence.incrementAndGet(); + } +} 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 bfad499..925cdec 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 @@ -40,6 +40,7 @@ @Transactional(readOnly = true) public class MockApplyService { private static final int SEQUENCE_SAVE_MAX_RETRY = 5; + private static final int SEQUENCE_ALLOCATE_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"; @@ -50,6 +51,7 @@ public class MockApplyService { private final JobPostingService jobPostingService; private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; + private final MockApplySequenceService mockApplySequenceService; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -201,11 +203,28 @@ private int resolveSequence(User user, JobPosting jobPosting, Integer requestedS if (isPositiveSequence(requestedSequence)) { return requestedSequence; } - return mockApplyRepository.findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( - user.getId(), - jobPosting.getCompany().getId(), - jobPosting.getDetailClassification().getId() - ) + 1; + return allocateSequence(user, jobPosting); + } + + private int allocateSequence(User user, JobPosting jobPosting) { + for (int attempt = 0; attempt < SEQUENCE_ALLOCATE_MAX_RETRY; attempt++) { + try { + return mockApplySequenceService.allocate( + user.getId(), + jobPosting.getCompany().getId(), + jobPosting.getDetailClassification().getId() + ); + } catch (DataIntegrityViolationException e) { + if (attempt == SEQUENCE_ALLOCATE_MAX_RETRY - 1) { + throw e; + } + } + } + + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 할당에 실패했습니다." + ); } private boolean isPositiveSequence(Integer sequence) { From dde2adafd43ac28e85389ee5a33e5bbfb30e2c1b Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 17:58:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Fix]=20=EC=88=9C=EB=B2=88=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EC=8B=A4=ED=8C=A8=20=EC=98=88=EC=99=B8=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sequence row 생성 재시도 최종 실패 시 DataIntegrityViolationException이 그대로 전파되지 않도록 수정 - 순번 할당 실패를 기존 정책에 맞춰 INTERNAL_SERVER_ERROR GeneralException으로 변환 - 모의 서류 지원 생성 경로의 예외 응답 일관성 보강 --- .../domain/mockapply/service/MockApplyService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 925cdec..0b8e62a 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 @@ -216,7 +216,10 @@ private int allocateSequence(User user, JobPosting jobPosting) { ); } catch (DataIntegrityViolationException e) { if (attempt == SEQUENCE_ALLOCATE_MAX_RETRY - 1) { - throw e; + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 할당에 실패했습니다." + ); } } }