-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[fix] 좋아요 동시성 이슈 해결 #224
base: develop
Are you sure you want to change the base?
[fix] 좋아요 동시성 이슈 해결 #224
Changes from all commits
c051ff2
728a9a6
c168451
2c50455
c5d6a08
94a84b3
f193f3b
75279d1
b7ebf44
2017fed
7542ea5
ea4867f
bd915e9
00dd813
fd83ac8
5f07afc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package org.hankki.hankkiserver.api.config; | ||
|
||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.retry.annotation.EnableRetry; | ||
|
||
@EnableRetry | ||
@Configuration | ||
public class RetryConfig { | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,21 +2,25 @@ | |
|
||
import lombok.RequiredArgsConstructor; | ||
import org.hankki.hankkiserver.api.auth.service.UserFinder; | ||
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; | ||
import org.hankki.hankkiserver.api.store.service.response.HeartDeleteResponse; | ||
import org.hankki.hankkiserver.api.store.service.command.HeartCommand; | ||
import org.hankki.hankkiserver.api.store.service.response.HeartResponse; | ||
import org.hankki.hankkiserver.common.code.HeartErrorCode; | ||
import org.hankki.hankkiserver.common.exception.BadRequestException; | ||
import org.hankki.hankkiserver.common.exception.ConcurrencyException; | ||
import org.hankki.hankkiserver.common.exception.ConflictException; | ||
import org.hankki.hankkiserver.common.exception.NotFoundException; | ||
import org.hankki.hankkiserver.domain.heart.model.Heart; | ||
import org.hankki.hankkiserver.domain.store.model.Store; | ||
import org.hankki.hankkiserver.domain.user.model.User; | ||
import org.springframework.orm.ObjectOptimisticLockingFailureException; | ||
import org.springframework.retry.annotation.Backoff; | ||
import org.springframework.retry.annotation.Recover; | ||
import org.springframework.retry.annotation.Retryable; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
@Transactional | ||
public class HeartCommandService { | ||
|
||
private final HeartUpdater heartUpdater; | ||
|
@@ -25,32 +29,48 @@ public class HeartCommandService { | |
private final UserFinder userFinder; | ||
private final StoreFinder storeFinder; | ||
|
||
public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) { | ||
User user = userFinder.getUserReference(heartPostCommand.userId()); | ||
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId()); | ||
@Retryable( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. optimisticlockexception 발생하면 retry시키는건가요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넹 맞습니다. 다만, noRetryFor을 통해서 재시도 하지 않을 예외들은 제외하였습니다. |
||
noRetryFor = {ConflictException.class, NotFoundException.class}, | ||
notRecoverable = {ConflictException.class, NotFoundException.class}, | ||
backoff = @Backoff(delay = 100)) | ||
@Transactional | ||
public HeartResponse createHeart(final HeartCommand heartCommand) { | ||
User user = userFinder.getUserReference(heartCommand.userId()); | ||
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId()); | ||
validateStoreHeartCreation(user, store); | ||
saveStoreHeart(user, store); | ||
store.increaseHeartCount(); | ||
return HeartCreateResponse.of(store); | ||
return HeartResponse.of(store); | ||
} | ||
|
||
public HeartDeleteResponse deleteHeart(final HeartDeleteCommand heartDeleteCommand) { | ||
User user = userFinder.getUserReference(heartDeleteCommand.userId()); | ||
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartDeleteCommand.storeId()); | ||
@Retryable( | ||
noRetryFor = {NotFoundException.class, ConflictException.class, BadRequestException.class}, | ||
notRecoverable = {NotFoundException.class, ConflictException.class, BadRequestException.class}, | ||
backoff = @Backoff(delay = 100)) | ||
@Transactional | ||
public HeartResponse deleteHeart(final HeartCommand heartCommand) { | ||
User user = userFinder.getUserReference(heartCommand.userId()); | ||
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId()); | ||
validateStoreHeartRemoval(user, store); | ||
heartDeleter.deleteHeart(user,store); | ||
heartDeleter.deleteHeart(user, store); | ||
store.decreaseHeartCount(); | ||
return HeartDeleteResponse.of(store); | ||
return HeartResponse.of(store); | ||
} | ||
|
||
@Recover | ||
public HeartResponse recoverFromOptimisticLockFailure(final ObjectOptimisticLockingFailureException e, | ||
final HeartCommand heartCommand) { | ||
throw new ConcurrencyException(HeartErrorCode.HEART_COUNT_CONCURRENCY_ERROR); | ||
} | ||
|
||
private void validateStoreHeartCreation(final User user, final Store store) { | ||
if(heartFinder.existsByUserAndStore(user, store)){ | ||
if (heartFinder.existsByUserAndStore(user, store)) { | ||
throw new ConflictException(HeartErrorCode.ALREADY_EXISTED_HEART); | ||
} | ||
} | ||
|
||
private void validateStoreHeartRemoval(final User user, final Store store) { | ||
if(!heartFinder.existsByUserAndStore(user, store)){ | ||
if (!heartFinder.existsByUserAndStore(user, store)) { | ||
throw new ConflictException(HeartErrorCode.ALREADY_NO_HEART); | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package org.hankki.hankkiserver.api.store.service.command; | ||
|
||
public record HeartCommand( | ||
Long userId, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 필드이 필수 값들이라면 원시타입으로 하면 어떨까요? |
||
Long storeId | ||
) { | ||
public static HeartCommand of(final Long userId, final Long storeId) { | ||
return new HeartCommand(userId, storeId); | ||
} | ||
} |
This file was deleted.
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package org.hankki.hankkiserver.common.exception; | ||
|
||
import lombok.Getter; | ||
import org.hankki.hankkiserver.common.code.ErrorCode; | ||
|
||
@Getter | ||
public class ConcurrencyException extends RuntimeException { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 초반에 exception 유형 에러유형400 401... 등으로 하기로 했었음 |
||
private final ErrorCode errorCode; | ||
|
||
public ConcurrencyException(final ErrorCode errorCode) { | ||
this.errorCode = errorCode; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,40 @@ | ||
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; | ||
import lombok.NoArgsConstructor; | ||
import org.hankki.hankkiserver.common.code.HeartErrorCode; | ||
import org.hankki.hankkiserver.common.exception.BadRequestException; | ||
import org.hankki.hankkiserver.domain.common.BaseTimeEntity; | ||
import org.hankki.hankkiserver.domain.common.Point; | ||
import org.hankki.hankkiserver.domain.heart.model.Heart; | ||
import org.hankki.hankkiserver.domain.universitystore.model.UniversityStore; | ||
import org.hibernate.annotations.BatchSize; | ||
|
||
import java.util.ArrayList; | ||
import java.util.List; | ||
import org.hibernate.annotations.ColumnDefault; | ||
import org.hibernate.annotations.DynamicInsert; | ||
|
||
@Entity | ||
@Getter | ||
@BatchSize(size = 100) | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@DynamicInsert | ||
public class Store extends BaseTimeEntity { | ||
|
||
private static final int DEFAULT_HEART_COUNT = 0; | ||
|
||
@Id | ||
@Column(name = "store_id") | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
|
@@ -57,8 +72,13 @@ public class Store extends BaseTimeEntity { | |
@Column(nullable = false) | ||
private boolean isDeleted; | ||
|
||
@Version | ||
@ColumnDefault("0L") | ||
private Long version; | ||
|
||
@Builder | ||
private Store (String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, boolean isDeleted) { | ||
private Store(String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, | ||
boolean isDeleted) { | ||
this.name = name; | ||
this.point = point; | ||
this.address = address; | ||
|
@@ -69,6 +89,7 @@ private Store (String name, Point point, String address, StoreCategory category, | |
} | ||
|
||
public void decreaseHeartCount() { | ||
validateHeartCount(); | ||
this.heartCount--; | ||
} | ||
|
||
|
@@ -87,4 +108,10 @@ public String getImageUrlOrElseNull() { | |
public void updateLowestPrice(int lowestPrice) { | ||
this.lowestPrice = lowestPrice; | ||
} | ||
|
||
private void validateHeartCount() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BadRequestException이 domain 패키지에 존재하는 클래스에 있는건 적절하지 않은 것 같습니당 차라리 리턴값 boolean 등으로 변경하고 에러는 service 쪽에서 던져주는게 어떨까요? |
||
if (this.heartCount <= DEFAULT_HEART_COUNT) { | ||
throw new BadRequestException(HeartErrorCode.INVALID_HEART_COUNT); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
빈 등록하면서 aop 적용순서(transactional이랑 retry) 정해야 하는 거 같기도 하던데 고려된건가요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
명시적으로 지정해주지 않아도 기본적으로 Spring AOP의 우선순위에 따라 @retryable이 먼저 적용된다고 합니다.