프로젝트를 진행하던 중, 상품 판매에 따른 재고데이터에 동시성 문제가 발현되어 이슈를 처리하게 되었다.
[이슈 정보]
https://github.com/OnNaNOn/OMG/issues/22
주문기능 동작 시, 재고관리 동시성 문제해결 예정 #22 · Issue #22 · OnNaNOn/OMG
github.com
해당 상황을 처리하기 위해 Synchronized, 비관적/낙관적 잠금, 분산 DB환경, Redisson 등등 여러가지를 고려해본 결과,
우선 당장은 비관적잠금을 사용할 예정이나, 대규모 접근이 이뤄질 경우 성능(속도)는 필수적으로 일어날 것으로 여겨져
이를 해결하기 위해 최종적으론 Redis의 Redisson을 활용하기로 결정하였다.
(추후 서버를 추가증설하였을 경우, Synchronized 및 비관/낙관적 잠금은 단일 DB에서만 가능한 점도 있으므로 Redisson이 미래지향적으로는 제일 낫다고 판단하였다)
제일 큰 선정이유는 in-memory방식인지라 작업속도가 굉장히 빠르며, 커넥션이 대기하는 상황이 없어지기때문에 기존보다 의미있는 속도개선이 될 것으로 여겨져서였다.
하지만 그로 인해 서버가 다운될 경우 데이터가 유실될 것으로 걱정했으나, pub/sub형식으로 곧바로 데이터를 산출하기 때문에 이와 같은 걱정은 하지않아도 될 것으로 판단되어 Redisson을 도입하게 되었다.
왜 Lettuce를 사용하지않고 Redisson을 사용하셨나요? 라고 누군가 물어본다면 아래와 같이 답변할 수 있겠다.
Lettuce
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
- spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
- 추가적으로, 만료시간을 제공하고 있지 않아서 락을 점유한 서버가 장애가 생기면 다른 서버들도 해당 락을 점유할 수 없는 상황이 연출된다.
Redisson
- spin lock 방식이 아니며, 타임아웃을 설정하는 기능이 있어 데드락 현상을 방지할 수 있다.
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 덜 간다.
- Lettuce에 비해 Redisson만의 java에서 캐싱을 수행하는데 도움되는 API를 제공한다.
- 별도의 라이브러리를 사용해야한다.
- lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다.
실무에서는 ?
- 재시도가 필요하지 않은 lock 은 lettuce 활용 (ex. 선착순 1명에게만 상품증정!)
- 재시도가 필요한 경우에는 redisson 를 활용 (ex. 선착순 100명에게 상품증정!)
이를 위한 1번째 방법으로 우선 Rediss를 설치하였다.
[레디스 설치 참고링크]
[REDIS] 📚 Window10 환경에 Redis 설치 & 설정
Redis 윈도우 설치 Redis 다운로드 페이지로 이동하여 설치 프로그램을 다운로드하고 설치를 진행한다. Releases · microsoftarchive/redis Redis is an in-memory database that persists on disk. The data model is key-value, but
inpa.tistory.com
그다음으로 의존성을 주입하였으나, 실행할 시 swagger와의 충돌이 발생하여 별도 클래스를 생성하였다.
// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@Conditional(OnServletBasedWebApplication.class)
public class WebMvcRequestHandlerProvider implements RequestHandlerProvider {
private final List<RequestMappingInfoHandlerMapping> handlerMappings;
private final HandlerMethodResolver methodResolver;
private final String contextPath;
@Autowired
public WebMvcRequestHandlerProvider(
Optional<ServletContext> servletContext,
HandlerMethodResolver methodResolver,
List<RequestMappingInfoHandlerMapping> handlerMappings) {
this.handlerMappings = handlerMappings.stream().filter(mapping -> mapping.getPatternParser() == null)
.collect(Collectors.toList());
this.methodResolver = methodResolver;
this.contextPath = servletContext
.map(ServletContext::getContextPath)
.orElse(ROOT);
}
@Override
public List<RequestHandler> requestHandlers() {
return nullToEmptyList(handlerMappings).stream()
.filter(requestMappingInfoHandlerMapping ->
!("org.springframework.integration.http.inbound.IntegrationRequestMappingHandlerMapping"
.equals(requestMappingInfoHandlerMapping.getClass()
.getName())))
.map(toMappingEntries())
.flatMap((entries -> StreamSupport.stream(entries.spliterator(), false)))
.map(toRequestHandler())
.sorted(byPatternsCondition())
.collect(toList());
}
private Function<RequestMappingInfoHandlerMapping,
Iterable<Map.Entry<RequestMappingInfo, HandlerMethod>>> toMappingEntries() {
return input -> input.getHandlerMethods()
.entrySet();
}
private Function<Map.Entry<RequestMappingInfo, HandlerMethod>, RequestHandler> toRequestHandler() {
return input -> new WebMvcRequestHandler(
contextPath,
methodResolver,
input.getKey(),
input.getValue());
}
}
마지막으론, 드디어 주문 서비스(상품 재고수를 소진하는 기능) 의 코드를 일부 수정하여 동시성을 해결하였다.
그전에 작성한 테스트 코드는 아래와 같다.
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final OrderService orderService;
public void decrease(Long key, Account account) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
orderService.testDecrease(key, account);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
public void testDecrease(Long productId, Account account) {
Product findProduct = productRepository.findById(productId).orElseThrow(
() -> new CustomCommonException(ErrorCode.NOT_FOUND_PRODUCT)
);
Account findAccount = accountRepository.findByUsername(account.getUsername()).orElseThrow(
() -> new CustomCommonException(ErrorCode.USER_NOT_FOUND)
);
findProduct.decrease();
productRepository.saveAndFlush(findProduct);
}
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private ProductRepository productRepository;
@Autowired
private AccountRepository accountRepository;
Account account1;
@BeforeEach
public void insert() {
account1 = new Account(1L, AccountType.ROLE_ADMIN, "이승우", "1234", DeletedType.DELETE_NO);
accountRepository.saveAndFlush(account1);
// 검증에 상관없는 요소 (ID값을 25로 준들, IDENTITY즉, Autoincreament로 했기떄문에 어차피 1로 저장됨.. 해당 요소는 소용이 없다)
Product product = new Product(25L,"이승우",20000,100,"카테고리","배달",12L,"N","imgUrl");
productRepository.saveAndFlush(product);
}
// 검증에 상관없는 요소 (삭제 순서가 바뀌어도 관련이 없다)
@AfterEach
public void delete() {
productRepository.deleteAll();
accountRepository.deleteAll();
}
@Test
public void 동시에_100개의요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
redissonLockStockFacade.decrease(1L, account1);
} finally {
latch.countDown();
}
});
}
latch.await();
Product product = productRepository.findById(1L).orElseThrow();
// 1000 - (1000 * 1) = 0
assertEquals(0, product.getStock());
}
}
테스트 결과는 아주 만족스럽다.
이를 기반으로 주문서비스 기능은 아래와 같이 구성하였다.
@Service
@Slf4j
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final AccountRepository accountRepository;
private final RedissonClient redissonClient;
Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* 주문하기
* @param productId
* @param account
* @return
*/
public CreatedOrdersResponseDto productOrder(Long productId, Account account) {
RLock lock = redissonClient.getLock(productId.toString());
try {
// 1번째 인자 : wait time : wait time 동안 lock 획득을 시도하고, 이 시간이 초과되면 lock 획득에 실패하고 false를 리턴한다.
// 2번째 인자 : lease time : lock 획득에 성공한 이후, lease time 이 지나면 자동으로 lock을 해제한다.
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
}
log.info("lock 획득");
Product findProduct = productRepository.findById(productId).orElseThrow(
() -> new CustomCommonException(ErrorCode.NOT_FOUND_PRODUCT)
);
Account findAccount = accountRepository.findByUsername(account.getUsername()).orElseThrow(
() -> new CustomCommonException(ErrorCode.USER_NOT_FOUND)
);
findProduct.decrease();
productRepository.saveAndFlush(findProduct);
// 상품에 대한 주문은 여러개도 발생할 수 있다..?
Order savedOrder = new Order(findAccount, findProduct, getTotalOrderPrice(findProduct.getPrice()));
// 별도의 public method 로 만들고 controller 에서 호출하는 것이 바람직한지?
logger.info("u_id: "+ account.getId() + ", p_id: "+ productId);
orderRepository.save(savedOrder);
CreatedOrdersResponseDto createdOrderDto = new CreatedOrdersResponseDto(savedOrder.getId(),
savedOrder.getTotalPrice(),
findAccount.getUsername(),
findProduct.getTitle(),
findProduct.getCategory(),
findProduct.getDelivery(),
findProduct.getSellerId());
return createdOrderDto;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
log.info("lock 반납");
}
}
기분이 좋은 하루다.
많은 것을 배웠다.
다음번에는 git자동액션배포를 배워보고싶다.
'★ 프로젝트 + 트러블 슈팅 ★' 카테고리의 다른 글
[JAVA] Spring @Scheduled (1) | 2022.11.24 |
---|---|
Gradle build 오류처리 (0) | 2022.11.21 |
동시성 제어문제 트러블슈팅 1 (0) | 2022.11.09 |
팀 협업프로젝트 회고 (0) | 2022.10.31 |
미니프로젝트 README (0) | 2022.10.26 |
댓글