From 704094c3ca38a5f6d930cf9b6cc6e6d8805ea846 Mon Sep 17 00:00:00 2001 From: jhkim31 Date: Tue, 9 Jul 2024 17:40:59 +0900 Subject: [PATCH] =?UTF-8?q?FEAT:=20=EC=9E=94=EA=B3=A0=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(=EB=82=99=EA=B4=80=EC=A0=81=EB=9D=BD)=20#34=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + src/main/java/jshop/InitConfig.java | 2 + .../domain/user/service/UserService.java | 25 ++- .../jshop/domain/wallet/entity/Wallet.java | 4 + .../wallet/repository/WalletRepository.java | 4 +- .../java/jshop/global/common/ErrorCode.java | 3 +- .../controller/GlobalExceptionHandler.java | 3 + .../global/exception/JshopException.java | 5 + .../java/jshop/global/utils/UserUtils.java | 8 + .../domain/user/UserControllerSyncTest.java | 146 ++++++++++++++++++ 10 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 src/test/java/jshop/integration/domain/user/UserControllerSyncTest.java diff --git a/build.gradle b/build.gradle index e25d9cdd..07098242 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.data:spring-data-envers' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.retry:spring-retry' //jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.3' diff --git a/src/main/java/jshop/InitConfig.java b/src/main/java/jshop/InitConfig.java index 730adffe..dffeb493 100644 --- a/src/main/java/jshop/InitConfig.java +++ b/src/main/java/jshop/InitConfig.java @@ -5,8 +5,10 @@ import org.springframework.data.envers.repository.support.EnversRevisionRepositoryFactoryBean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.retry.annotation.EnableRetry; @Configuration +@EnableRetry @EnableJpaAuditing @EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class) @EnableAspectJAutoProxy diff --git a/src/main/java/jshop/domain/user/service/UserService.java b/src/main/java/jshop/domain/user/service/UserService.java index e3602352..52d83930 100644 --- a/src/main/java/jshop/domain/user/service/UserService.java +++ b/src/main/java/jshop/domain/user/service/UserService.java @@ -1,5 +1,6 @@ package jshop.domain.user.service; +import jakarta.persistence.OptimisticLockException; import java.util.List; import java.util.Optional; import jshop.domain.address.entity.Address; @@ -11,11 +12,16 @@ import jshop.domain.user.entity.User; import jshop.domain.user.repository.UserRepository; import jshop.domain.wallet.entity.Wallet; +import jshop.domain.wallet.repository.WalletRepository; import jshop.global.common.ErrorCode; import jshop.global.exception.JshopException; import jshop.global.utils.UserUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +35,7 @@ public class UserService { private final BCryptPasswordEncoder bCryptPasswordEncoder; private final UserRepository userRepository; private final AddressRepository addressRepository; + private final WalletRepository walletRepository; public UserInfoResponse getUserInfo(Long userId) { User user = getUser(userId); @@ -58,9 +65,11 @@ public void updateUser(Long userId, UpdateUserRequest updateUserRequest) { } @Transactional + @Retryable(retryFor = { + OptimisticLockingFailureException.class}, maxAttempts = 5, backoff = @Backoff(100), recover = "walletUpdateRecover") public void updateWalletBalance(Long userId, UpdateWalletBalanceRequest updateWalletBalanceRequest) { - User user = getUser(userId); - Wallet wallet = user.getWallet(); + Wallet wallet = getWallet(userId); + switch (updateWalletBalanceRequest.getType()) { case DEPOSIT: wallet.deposit(updateWalletBalanceRequest.getAmount()); @@ -72,8 +81,20 @@ public void updateWalletBalance(Long userId, UpdateWalletBalanceRequest updateWa } } + @Recover + private void walletUpdateRecover(OptimisticLockingFailureException e, Long userId, + UpdateWalletBalanceRequest updateWalletBalanceRequest) { + log.error("잔고 변경 재시도 회수를 초과하였습니다. 다시 시도해 주세요. user : [{}]", userId, e); + throw JshopException.of(ErrorCode.INTERNAL_SERVER_ERROR); + } + public User getUser(Long userId) { Optional optionalUser = userRepository.findById(userId); return UserUtils.getUserOrThrow(optionalUser, userId); } + + public Wallet getWallet(Long userId) { + Optional optionalWallet = walletRepository.findWalletByUserId(userId); + return UserUtils.getWalletOrThrow(optionalWallet, userId); + } } diff --git a/src/main/java/jshop/domain/wallet/entity/Wallet.java b/src/main/java/jshop/domain/wallet/entity/Wallet.java index 20e5ea02..2030ea52 100644 --- a/src/main/java/jshop/domain/wallet/entity/Wallet.java +++ b/src/main/java/jshop/domain/wallet/entity/Wallet.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Version; import jshop.global.common.ErrorCode; import jshop.global.entity.BaseEntity; import jshop.global.exception.JshopException; @@ -37,6 +38,9 @@ public class Wallet extends BaseEntity { private Long balance; + @Version + private Integer version; + @Enumerated(EnumType.STRING) @Column(name = "wallet_change_type") private WalletChangeType walletChangeType; diff --git a/src/main/java/jshop/domain/wallet/repository/WalletRepository.java b/src/main/java/jshop/domain/wallet/repository/WalletRepository.java index 0d0da098..87d01b86 100644 --- a/src/main/java/jshop/domain/wallet/repository/WalletRepository.java +++ b/src/main/java/jshop/domain/wallet/repository/WalletRepository.java @@ -1,13 +1,15 @@ package jshop.domain.wallet.repository; +import jakarta.persistence.LockModeType; import java.util.Optional; import jshop.domain.wallet.entity.Wallet; 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; public interface WalletRepository extends JpaRepository { - + @Query("select u.wallet from User u where u.id = :userId") Optional findWalletByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/jshop/global/common/ErrorCode.java b/src/main/java/jshop/global/common/ErrorCode.java index 2ecc6503..b310f0dd 100644 --- a/src/main/java/jshop/global/common/ErrorCode.java +++ b/src/main/java/jshop/global/common/ErrorCode.java @@ -76,7 +76,8 @@ public enum ErrorCode { // 서버 문제 USER_WALLET_NOT_FOUND(200100, "사용자의 지갑을 찾을 수 없습니다. 관리자에게 문의하세요", "사용자의 지갑을 찾을 수 없습니다. user : [{}]", - HttpStatus.INTERNAL_SERVER_ERROR); + HttpStatus.INTERNAL_SERVER_ERROR), + INTERNAL_SERVER_ERROR(300100, "서버 문제가 발생했습니다. 잠시후 재시도 해주세요.", "", HttpStatus.INTERNAL_SERVER_ERROR); private final int code; private final String message; diff --git a/src/main/java/jshop/global/controller/GlobalExceptionHandler.java b/src/main/java/jshop/global/controller/GlobalExceptionHandler.java index 5e551d46..9f7a10e2 100644 --- a/src/main/java/jshop/global/controller/GlobalExceptionHandler.java +++ b/src/main/java/jshop/global/controller/GlobalExceptionHandler.java @@ -4,6 +4,9 @@ import jshop.global.dto.Response; import jshop.global.exception.JshopException; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.dao.TransientDataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; diff --git a/src/main/java/jshop/global/exception/JshopException.java b/src/main/java/jshop/global/exception/JshopException.java index 921f994a..99801070 100644 --- a/src/main/java/jshop/global/exception/JshopException.java +++ b/src/main/java/jshop/global/exception/JshopException.java @@ -18,9 +18,14 @@ public JshopException(String message) { super(message); } + public JshopException(String message, Throwable cause) { + super(message, cause); + } + public static JshopException of(ErrorCode errorCode) { return JshopException .builder().errorCode(errorCode).build(); } + } diff --git a/src/main/java/jshop/global/utils/UserUtils.java b/src/main/java/jshop/global/utils/UserUtils.java index 242dce6d..5be7489d 100644 --- a/src/main/java/jshop/global/utils/UserUtils.java +++ b/src/main/java/jshop/global/utils/UserUtils.java @@ -2,6 +2,7 @@ import java.util.Optional; import jshop.domain.user.entity.User; +import jshop.domain.wallet.entity.Wallet; import jshop.global.common.ErrorCode; import jshop.global.exception.JshopException; import lombok.extern.slf4j.Slf4j; @@ -15,4 +16,11 @@ public static User getUserOrThrow(Optional optionalUser, Long userId) { throw JshopException.of(ErrorCode.USERID_NOT_FOUND); }); } + + public static Wallet getWalletOrThrow(Optional optionalWallet, Long userId) { + return optionalWallet.orElseThrow(() -> { + log.error(ErrorCode.USER_WALLET_NOT_FOUND.getLogMessage(), userId); + throw JshopException.of(ErrorCode.USER_WALLET_NOT_FOUND); + }); + } } diff --git a/src/test/java/jshop/integration/domain/user/UserControllerSyncTest.java b/src/test/java/jshop/integration/domain/user/UserControllerSyncTest.java new file mode 100644 index 00000000..8c3036f1 --- /dev/null +++ b/src/test/java/jshop/integration/domain/user/UserControllerSyncTest.java @@ -0,0 +1,146 @@ +package jshop.integration.domain.user; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import jshop.domain.cart.repository.CartRepository; +import jshop.domain.user.entity.User; +import jshop.domain.user.repository.UserRepository; +import jshop.domain.user.service.UserService; +import jshop.domain.wallet.entity.Wallet; +import jshop.domain.wallet.repository.WalletRepository; +import jshop.global.common.ErrorCode; +import jshop.utils.dto.UserDtoUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@EnableWebMvc +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("[통합 테스트] UserController - Sync") +public class UserControllerSyncTest { + + private Long userId; + private String userToken; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CartRepository cartRepository; + + @Autowired + private WalletRepository walletRepository; + + @Autowired + private MockMvc mockMvc; + + @PersistenceContext + private EntityManager em; + + + @BeforeEach + public void init() throws Exception { + userId = userService.joinUser(UserDtoUtils.getJoinUserRequestDto()); + ResultActions login = mockMvc.perform( + post("/api/login").contentType(MediaType.APPLICATION_JSON).content(UserDtoUtils.getLoginJsonStr())); + userToken = login.andReturn().getResponse().getHeader("Authorization"); + } + + @Nested + @DisplayName("사용자 잔고 변경 낙관적락 동시성 테스트") + class UpdateUserWallet { + + @BeforeEach + public void init() { + User user = userService.getUser(userId); + Wallet wallet = user.getWallet(); + wallet.deposit(1000L); + walletRepository.save(wallet); + } + + @Test + @DisplayName("잔고 변경 요청이 동시에 들어와도 재시도를 통해 동시성 문제 해결") + public void updateBalance_success() throws Exception { + ExecutorService executors = Executors.newFixedThreadPool(10); + + for (int i = 0; i < 3; i++) { + executors.submit(() -> { + String request = """ + { "amount" : 100, "type" : "DEPOSIT"} + """; + try { + mockMvc.perform(patch("/api/users/balance") + .contentType(MediaType.APPLICATION_JSON) + .content(request) + .header("Authorization", userToken)); + } catch (Exception e) { + + } + }); + } + + executors.shutdown(); + executors.awaitTermination(1, TimeUnit.MINUTES); + + User user = userService.getUser(userId); + Wallet wallet = user.getWallet(); + assertThat(wallet.getBalance()).isEqualTo(1300L); + } + + @Test + @DisplayName("너무 많은 재시도를 시도 하면 예외를 발생시킴") + public void updateBalance_many_retry() throws Exception { + ExecutorService executors = Executors.newFixedThreadPool(10); + + for (int i = 0; i < 10; i++) { + executors.submit(() -> { + String request = """ + { "amount" : 100, "type" : "DEPOSIT"} + """; + try { + mockMvc.perform(patch("/api/users/balance") + .contentType(MediaType.APPLICATION_JSON) + .content(request) + .header("Authorization", userToken)); + } catch (Exception e) { + + } + }); + } + + executors.shutdown(); + executors.awaitTermination(1, TimeUnit.MINUTES); + + User user = userService.getUser(userId); + Wallet wallet = user.getWallet(); + assertThat(wallet.getBalance()).isNotEqualTo(2000L); + } + } +}