v20260619.0#91
Conversation
…eriod-validation [application] USER 역할에 대한 신청기간 검증 로직 추가
…omotion [application] 정식 신청 취소/삭제 시 예비 신청자 승격 로직 추가
There was a problem hiding this comment.
Code Review
This pull request introduces infrastructure changes to ignore EC2 AMI and root block device updates, adds a promotion mechanism for reserved applications when an application is canceled or deleted, and restricts application registration based on dates for regular users. The review feedback highlights a critical concurrency issue (race condition) in both CancelApplicationService and DeleteApplicationService where deleting or canceling an application and promoting a reserve applicant does not acquire a pessimistic lock on the associated Activity, potentially leading to inconsistent states when concurrent applications or cancellations occur.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ApplicationJpaEntity application = applicationRepository.findByActivity_IdAndUser_Id(activityId, userId) | ||
| .orElseThrow(() -> new ExpectedException("신청 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); | ||
|
|
||
| boolean wasConfirmed = !application.isReserve(); | ||
| applicationRepository.delete(application); | ||
|
|
||
| if (wasConfirmed) { | ||
| applicationRepository.findFirstByActivity_IdAndIsReserveTrueOrderByCreatedAtAscIdAsc(activityId) | ||
| .ifPresent(ApplicationJpaEntity::promote); | ||
| } |
There was a problem hiding this comment.
신청 취소(CancelApplicationService)와 신청(ApplyActivityService)이 동시에 발생할 때 동시성 이슈(Race Condition)가 발생할 수 있습니다. ApplyActivityService에서는 activityRepository.findByIdWithLock(activityId)를 통해 비관적 락(Pessimistic Lock)을 획득하여 동시 신청을 제어하고 있지만, CancelApplicationService에서는 Activity에 대한 락을 획득하지 않고 삭제 및 대기자 승격(Promote) 로직을 수행합니다.
이로 인해 동시에 여러 취소/신청이 일어날 때 isReserve 여부 판단 및 승격 로직이 꼬여 확정 인원이 정원을 초과하거나 미달될 수 있습니다.
따라서 CancelApplicationService에서도 로직 시작 시점에 activityRepository.findByIdWithLock(activityId)를 호출하여 동일한 Activity에 대해 락을 획득한 후 진행해야 안전합니다. 이를 위해 ActivityRepository 의존성을 주입받아야 합니다.
activityRepository.findByIdWithLock(activityId)
.orElseThrow(() -> new ExpectedException("활동을 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
ApplicationJpaEntity application = applicationRepository.findByActivity_IdAndUser_Id(activityId, userId)
.orElseThrow(() -> new ExpectedException("신청 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
boolean wasConfirmed = !application.isReserve();
applicationRepository.delete(application);
if (wasConfirmed) {
applicationRepository.findFirstByActivity_IdAndIsReserveTrueOrderByCreatedAtAscIdAsc(activityId)
.ifPresent(ApplicationJpaEntity::promote);
}| ApplicationJpaEntity application = applicationRepository.findById(applicationId) | ||
| .orElseThrow(() -> new ExpectedException("신청 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); | ||
|
|
||
| boolean wasConfirmed = !application.isReserve(); | ||
| Long activityId = application.getActivity().getId(); | ||
| applicationRepository.delete(application); | ||
|
|
||
| if (wasConfirmed) { | ||
| applicationRepository.findFirstByActivity_IdAndIsReserveTrueOrderByCreatedAtAscIdAsc(activityId) | ||
| .ifPresent(ApplicationJpaEntity::promote); | ||
| } |
There was a problem hiding this comment.
신청 내역 삭제(DeleteApplicationService)와 신청(ApplyActivityService)이 동시에 발생할 때 동시성 이슈(Race Condition)가 발생할 수 있습니다. ApplyActivityService에서는 activityRepository.findByIdWithLock(activityId)를 통해 비관적 락(Pessimistic Lock)을 획득하여 동시 신청을 제어하고 있지만, DeleteApplicationService에서는 Activity에 대한 락을 획득하지 않고 삭제 및 대기자 승격(Promote) 로직을 수행합니다.
따라서 DeleteApplicationService에서도 application 조회 후 해당 activityId로 activityRepository.findByIdWithLock(activityId)를 호출하여 동일한 Activity에 대해 락을 획득한 후 진행해야 안전합니다. 이를 위해 ActivityRepository 의존성을 주입받아야 합니다.
ApplicationJpaEntity application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new ExpectedException("신청 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
Long activityId = application.getActivity().getId();
activityRepository.findByIdWithLock(activityId)
.orElseThrow(() -> new ExpectedException("활동을 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
boolean wasConfirmed = !application.isReserve();
applicationRepository.delete(application);
if (wasConfirmed) {
applicationRepository.findFirstByActivity_IdAndIsReserveTrueOrderByCreatedAtAscIdAsc(activityId)
.ifPresent(ApplicationJpaEntity::promote);
}
#89 ~ #90 까지의 내용을 master 브랜치에 머지합니다.