diff --git a/build.gradle b/build.gradle index e8ec5591..8865cd81 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,9 @@ dependencies { // Discord Webhook implementation 'com.github.napstr:logback-discord-appender:1.0.0' + + // aop + implementation 'org.springframework.boot:spring-boot-starter-aop' } tasks.named('test') { @@ -93,4 +96,4 @@ tasks.register('copyYml', Copy) { include "*.yml" into 'src/main/resources' } -} \ No newline at end of file +} diff --git a/src/main/java/org/hankki/hankkiserver/api/common/annotation/Retry.java b/src/main/java/org/hankki/hankkiserver/api/common/annotation/Retry.java new file mode 100644 index 00000000..3745cb21 --- /dev/null +++ b/src/main/java/org/hankki/hankkiserver/api/common/annotation/Retry.java @@ -0,0 +1,15 @@ +package org.hankki.hankkiserver.api.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Retry { + + int maxAttempts() default 3; + + int backoff() default 100; +} diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java b/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java index 592a982f..c27fb066 100644 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java +++ b/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.hankki.hankkiserver.api.auth.service.UserFinder; +import org.hankki.hankkiserver.api.common.annotation.Retry; import org.hankki.hankkiserver.api.store.service.command.HeartDeleteCommand; import org.hankki.hankkiserver.api.store.service.command.HeartPostCommand; import org.hankki.hankkiserver.api.store.service.response.HeartCreateResponse; @@ -16,7 +17,6 @@ @Service @RequiredArgsConstructor -@Transactional public class HeartCommandService { private final HeartUpdater heartUpdater; @@ -25,6 +25,8 @@ public class HeartCommandService { private final UserFinder userFinder; private final StoreFinder storeFinder; + @Retry + @Transactional public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) { User user = userFinder.getUserReference(heartPostCommand.userId()); Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId()); @@ -34,6 +36,8 @@ public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) return HeartCreateResponse.of(store); } + @Retry + @Transactional public HeartDeleteResponse deleteHeart(final HeartDeleteCommand heartDeleteCommand) { User user = userFinder.getUserReference(heartDeleteCommand.userId()); Store store = storeFinder.findByIdWhereDeletedIsFalse(heartDeleteCommand.storeId()); diff --git a/src/main/java/org/hankki/hankkiserver/common/aspect/RetryAspect.java b/src/main/java/org/hankki/hankkiserver/common/aspect/RetryAspect.java new file mode 100644 index 00000000..448ecf25 --- /dev/null +++ b/src/main/java/org/hankki/hankkiserver/common/aspect/RetryAspect.java @@ -0,0 +1,30 @@ +package org.hankki.hankkiserver.common.aspect; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.hankki.hankkiserver.api.common.annotation.Retry; +import org.hankki.hankkiserver.common.code.HeartErrorCode; +import org.hankki.hankkiserver.common.exception.ConflictException; +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 RetryAspect { + + @Around("@annotation(retry)") + public Object retryOptimisticLock(final ProceedingJoinPoint joinPoint, final Retry retry) throws Throwable { + for (int attempt = 0; attempt < retry.maxAttempts(); attempt++) { + try { + return joinPoint.proceed(); + } catch (ObjectOptimisticLockingFailureException e) { + Thread.sleep(retry.backoff()); + } + } + throw new ConflictException(HeartErrorCode.HEART_COUNT_CONCURRENCY_ERROR); + } +} diff --git a/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java b/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java index 2578d04c..a284eb01 100644 --- a/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java +++ b/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java @@ -9,7 +9,8 @@ public enum HeartErrorCode implements ErrorCode { ALREADY_EXISTED_HEART(HttpStatus.CONFLICT, "이미 좋아요 한 가게입니다."), - ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다."); + ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다."), + HEART_COUNT_CONCURRENCY_ERROR(HttpStatus.CONFLICT, "좋아요 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java b/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java index 78345239..bd8d98da 100644 --- a/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java +++ b/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java @@ -1,6 +1,17 @@ package org.hankki.hankkiserver.domain.store.model; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -11,9 +22,6 @@ import org.hankki.hankkiserver.domain.universitystore.model.UniversityStore; import org.hibernate.annotations.BatchSize; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @BatchSize(size = 100) @@ -57,6 +65,9 @@ public class Store extends BaseTimeEntity { @Column(nullable = false) private boolean isDeleted; + @Version + private Long version = 0L; + @Builder private Store (String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, boolean isDeleted) { this.name = name;