Skip to content

Commit

Permalink
FEAT: 잔고 변경 동시성 문제 해결 (낙관적락) #34 #65
Browse files Browse the repository at this point in the history
  • Loading branch information
jhkim31 committed Jul 9, 2024
1 parent b4d6804 commit 704094c
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 4 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/jshop/InitConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions src/main/java/jshop/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -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<User> optionalUser = userRepository.findById(userId);
return UserUtils.getUserOrThrow(optionalUser, userId);
}

public Wallet getWallet(Long userId) {
Optional<Wallet> optionalWallet = walletRepository.findWalletByUserId(userId);
return UserUtils.getWalletOrThrow(optionalWallet, userId);
}
}
4 changes: 4 additions & 0 deletions src/main/java/jshop/domain/wallet/entity/Wallet.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Wallet, Long> {

@Query("select u.wallet from User u where u.id = :userId")
Optional<Wallet> findWalletByUserId(@Param("userId") Long userId);
}
3 changes: 2 additions & 1 deletion src/main/java/jshop/global/common/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/jshop/global/exception/JshopException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}


}
8 changes: 8 additions & 0 deletions src/main/java/jshop/global/utils/UserUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,4 +16,11 @@ public static User getUserOrThrow(Optional<User> optionalUser, Long userId) {
throw JshopException.of(ErrorCode.USERID_NOT_FOUND);
});
}

public static Wallet getWalletOrThrow(Optional<Wallet> optionalWallet, Long userId) {
return optionalWallet.orElseThrow(() -> {
log.error(ErrorCode.USER_WALLET_NOT_FOUND.getLogMessage(), userId);
throw JshopException.of(ErrorCode.USER_WALLET_NOT_FOUND);
});
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}

0 comments on commit 704094c

Please sign in to comment.