diff --git a/build.gradle b/build.gradle index 0f29b3b..07e4755 100644 --- a/build.gradle +++ b/build.gradle @@ -54,7 +54,6 @@ dependencies { implementation 'io.awspring.cloud:spring-cloud-starter-aws-secrets-manager-config:2.4.4' // export file implementation 'org.apache.poi:poi-ooxml:5.2.3' - } def querydslDir = "$buildDir/generated/querydsl" diff --git a/src/main/java/com/formssafe/domain/form/entity/Form.java b/src/main/java/com/formssafe/domain/form/entity/Form.java index 383f64c..30433a8 100644 --- a/src/main/java/com/formssafe/domain/form/entity/Form.java +++ b/src/main/java/com/formssafe/domain/form/entity/Form.java @@ -26,6 +26,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; +import jakarta.persistence.Version; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -82,6 +83,9 @@ public class Form extends BaseTimeEntity { private boolean isDeleted; + @Version + private int version; + @BatchSize(size = FormConstraints.PAGE_SIZE) @OneToMany(mappedBy = "form") private List formTagList = new ArrayList<>(); @@ -105,9 +109,20 @@ public class Form extends BaseTimeEntity { private List rewardRecipientList = new ArrayList<>(); @Builder - private Form(Long id, User user, String title, String detail, List imageUrl, - LocalDateTime startDate, LocalDateTime endDate, int expectTime, boolean isEmailVisible, - LocalDateTime privacyDisposalDate, FormStatus status, int questionCnt, int responseCnt, boolean isTemp, + private Form(Long id, + User user, + String title, + String detail, + List imageUrl, + LocalDateTime startDate, + LocalDateTime endDate, + int expectTime, + boolean isEmailVisible, + LocalDateTime privacyDisposalDate, + FormStatus status, + int questionCnt, + int responseCnt, + boolean isTemp, boolean isDeleted) { this.id = id; this.user = user; diff --git a/src/main/java/com/formssafe/domain/submission/service/SubmissionService.java b/src/main/java/com/formssafe/domain/submission/service/SubmissionService.java index 9a9ffbd..205e30c 100644 --- a/src/main/java/com/formssafe/domain/submission/service/SubmissionService.java +++ b/src/main/java/com/formssafe/domain/submission/service/SubmissionService.java @@ -24,9 +24,11 @@ import com.formssafe.domain.user.dto.UserRequest.LoginUserDto; import com.formssafe.domain.user.entity.User; import com.formssafe.domain.user.service.UserService; +import com.formssafe.global.aop.Retry; import com.formssafe.global.error.ErrorCode; import com.formssafe.global.error.type.BadRequestException; import com.formssafe.global.util.DateTimeUtil; +import jakarta.persistence.EntityManager; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -50,13 +52,13 @@ public class SubmissionService { private final DescriptiveSubmissionRepository descriptiveSubmissionRepository; private final ObjectiveSubmissionRepository objectiveSubmissionRepository; private final ApplicationEventPublisher applicationEventPublisher; + private final EntityManager em; @Transactional + @Retry public void create(long formId, SubmissionCreateDto request, LoginUserDto loginUser) { User user = userService.getUserById(loginUser.id()); - Form form = formService.getForm(formId); - if (getSubmissionByUserAndForm(user, form) != null) { throw new BadRequestException(ErrorCode.ONLY_ONE_SUBMISSION_ALLOWED, "한 사용자가 하나의 설문에 대하여 두 개 이상의 응답을 작성할 수 없습니다."); @@ -64,15 +66,16 @@ public void create(long formId, SubmissionCreateDto request, LoginUserDto loginU validate(user, form); - Submission submission = createSubmission(request, user, form); - - createDetailSubmission(request.submissions(), submission, form); - if (!request.isTemp()) { form.increaseResponseCount(); applicationEventPublisher.publishEvent( new FormParticipantsNotificationEvent(new FormParticipantsNotificationEventDto(form, user), this)); } + + em.flush(); + + Submission submission = createSubmission(request, user, form); + createDetailSubmission(request.submissions(), submission, form); } @Transactional @@ -106,8 +109,6 @@ public void modify(long formId, SubmissionCreateDto request, LoginUserDto loginU } public SubmissionResponseDto getSubmission(long formId, LoginUserDto loginUser) { - User user = userService.getUserById(loginUser.id()); - Submission submission = submissionRepository.findSubmissionByFormIDAndUserId(formId, loginUser.id()) .orElse(null); diff --git a/src/main/java/com/formssafe/global/aop/OptimisticLockRetryAspect.java b/src/main/java/com/formssafe/global/aop/OptimisticLockRetryAspect.java new file mode 100644 index 0000000..a0a2ec3 --- /dev/null +++ b/src/main/java/com/formssafe/global/aop/OptimisticLockRetryAspect.java @@ -0,0 +1,38 @@ +package com.formssafe.global.aop; + +import jakarta.persistence.OptimisticLockException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.hibernate.StaleObjectStateException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; + +@Order(Ordered.LOWEST_PRECEDENCE - 1) +@Aspect +@Component +public class OptimisticLockRetryAspect { + private static final int MAX_RETRIES = 1000; + private static final int RETRY_DELAY_MS = 100; + + @Pointcut("@annotation(Retry)") + public void retry() { + } + + @Around("retry()") + public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable { + Exception exceptionHolder = null; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return joinPoint.proceed(); + } catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) { + exceptionHolder = e; + Thread.sleep(RETRY_DELAY_MS); + } + } + throw exceptionHolder; + } +} \ No newline at end of file diff --git a/src/main/java/com/formssafe/global/aop/Retry.java b/src/main/java/com/formssafe/global/aop/Retry.java new file mode 100644 index 0000000..429748e --- /dev/null +++ b/src/main/java/com/formssafe/global/aop/Retry.java @@ -0,0 +1,11 @@ +package com.formssafe.global.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Retry { +} \ No newline at end of file diff --git a/src/test/java/com/formssafe/domain/form/service/FormServiceTest.java b/src/test/java/com/formssafe/domain/form/service/FormServiceTest.java index a830cfa..8399697 100644 --- a/src/test/java/com/formssafe/domain/form/service/FormServiceTest.java +++ b/src/test/java/com/formssafe/domain/form/service/FormServiceTest.java @@ -98,7 +98,7 @@ class 설문_상세_조회 { } @Test - void 작성자가아닌_사용자가_설문_상세_조회시_예외가_발생한다() { + void 작성자가_아닌_사용자가_설문_상세_조회_시_예외가_발생한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId() + 1); @@ -108,7 +108,7 @@ class 설문_상세_조회 { } @Test - void 작성자가_임시설문이_아닌_설문_상세_조회시_예외가_발생한다() { + void 작성자가_임시설문이_아닌_설문_상세_조회_시_예외가_발생한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId()); @@ -133,7 +133,7 @@ class 수동_설문_종료 { } @Test - void 로그인유저와_설문작성자가_다르다면_예외가_발생한다() { + void 로그인_유저와_설문_작성자가_다르다면_예외가_발생한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUser = new LoginUserDto(testUser.getId() + 1); @@ -154,7 +154,7 @@ class 수동_설문_종료 { @EnumSource(value = FormStatus.class, names = {"NOT_STARTED", "DONE", "REWARDED"}) @ParameterizedTest - void 설문이_진행중이_아니라면_예외가_발생한다(FormStatus status) { + void 설문이_진행_중이_아니라면_예외가_발생한다(FormStatus status) { //given LocalDateTime endDate = LocalDateTime.of(2024, 1, 1, 0, 0, 0); Form form = formRepository.save( @@ -181,7 +181,7 @@ class 설문_삭제 { } @Test - void 이미_삭제된_설문_삭제시_예외가_발생한다() { + void 이미_삭제된_설문_삭제_시_예외가_발생한다() { //given Form form = formRepository.save(createDeletedForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId()); @@ -191,7 +191,7 @@ class 설문_삭제 { } @Test - void 설문작성자와_로그인유저가_다르다면_예외가_발생한다() { + void 설문_작성자와_로그인_유저가_다르다면_예외가_발생한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId() + 1); @@ -205,7 +205,7 @@ class 설문_삭제 { class 설문_마감 { @Test - void 경품없는_설문을_마감한다() { + void 경품_없는_설문을_마감한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId()); @@ -217,7 +217,7 @@ class 설문_마감 { } @Test - void 경품있는_설문을_마감한다() { + void 경품_있는_설문을_마감한다() { //given List users = createUsers(5); List deletedUsers = List.of( @@ -260,7 +260,7 @@ class 설문_마감 { } @Test - void 설문작성자와_로그인유저가_다르다면_예외가_발생한다() { + void 설문_작성자와_로그인_유저가_다르다면_예외가_발생한다() { //given Form form = formRepository.save(createForm(testUser, "설문1", "설문설명1")); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId() + 1); @@ -271,7 +271,7 @@ class 설문_마감 { @EnumSource(value = FormStatus.class, names = {"NOT_STARTED", "DONE", "REWARDED"}) @ParameterizedTest - void 진행중인_설문이_아니라면_예외가_발생한다(FormStatus status) { + void 진행_중인_설문이_아니라면_예외가_발생한다(FormStatus status) { //given Form form = formRepository.save(createFormWithStatus(testUser, "설문1", "설문설명1", status)); LoginUserDto loginUserDto = new LoginUserDto(testUser.getId()); diff --git a/src/test/java/com/formssafe/util/Fixture.java b/src/test/java/com/formssafe/util/Fixture.java index 70137c5..eb06da6 100644 --- a/src/test/java/com/formssafe/util/Fixture.java +++ b/src/test/java/com/formssafe/util/Fixture.java @@ -137,6 +137,27 @@ public static Form createForm(User author, .build(); } + public static Form createForm(User author, + String title, + String detail, + int questionCnt) { + return Form.builder() + .user(author) + .title(title) + .imageUrl(null) + .detail(detail) + .startDate(LocalDateTime.now()) + .endDate(LocalDateTime.now().plusDays(2)) + .expectTime(10) + .isEmailVisible(false) + .privacyDisposalDate(null) + .status(FormStatus.PROGRESS) + .isTemp(false) + .isDeleted(false) + .questionCnt(questionCnt) + .build(); + } + /** * 삭제된 설문 엔티티를 생성한다. * diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index d01e054..887d038 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,7 +17,7 @@ spring: open-in-view: false database: mysql hibernate: - ddl-auto: create-drop + ddl-auto: create defer-datasource-initialization: true show-sql: true properties: