개요
Spring 프로젝트 같은 멀티 쓰레드 환경에서는 동시성 처리가 필요한 상황이 발생합니다.
서버가 1대라면 쓰레드 동기화를 통해 처리가 가능하지만, 서버가 여러대로 늘어난다면 요청이 분산되기 때문에 무용지물이 됩니다.
만약 쇼핑몰에서 재고가 1개인 상품을 2명의 회원의 동시에 주문한 경우 2번에 요청에서 상품을 조회 당시에는 재고가 1개이므로 2명의 회원 모두 주문에 성공하는 경우가 발생할 수 있습니다.
ZooKeeper나 MySQL 분산락을 사용할 수 있지만 Redis를 도입하고있고, 예정이라면 Redis를 통해 분산락으로 동시성 처리가 가능합니다.
라이브러리 선택
Redis를 이용해 분산락을 사용하기 전에 고려해야 할 것이 있는데 어떠한 라이브러리를 사용할 것인가 입니다.
Spring에서 제공하는 대표적인 redis 라이브러리로는 Lettuce가 있습니다.
Lettuce는 사용하기 쉽다는 장점이 있지만 다음과 같은 단점이 존재합니다.
- Lettuce는 스핀 락의 형태이므로 SETNX라는 명령어를 사용해서 Redis에 락 획득 요청을 보내야 합니다. 때문에 Redis에 많은 부하를 가하게 됩니다.
이를 방지하기 위해 락 획득 요청 사이 사이마다 Thread.sleep을 통해 부하를 줄여줘야 하고, 설령 sleep을 통해 줄여준다 하더라도 많은 부하가 가는 문제가 있습니다. - 자체적인 타임아웃 구현이 존재하지 않습니다.
락 획득 후 어떠한 이유로 어플리케이션이 종료되거나 락을 반환하지 못한 경우 타임아웃을 지정하지 않으면 무한루프가 돌아 시스템 장애로 이어질 수 있습니다.
반대로 Redisson은 조금 사용하기 어렵지만 Lettuce에 비해 다음과 같은 장점이 있습니다.
- 스핀 락 형태가 아닌 pub/sub방식을 사용합니다.
계속해서 락 획득 요청을 하는 것이 아닌 pub/sub을 이용하여 락을 획득하고 해제합니다.
이로인해 Redis에 가해질 수 있는 부하를 줄일 수 있습니다. - Lock에 타임아웃을 지정할 수 있습니다.
Redisson은 락 획득시도시 타임아웃을 명시하여 무한정 대기상태로 빠지는 것을 방지할 수 있습니다.
구축 예제
1. Redis 구축
Docker로 Redis 실행하기 글을 참고하여 Redis를 로컬에 구축하였습니다.
2. Spring Project Redisson 의존성 설정
여러 단점이 있는 Lettuce 라이브러리 대신 Redisson 라이브러리를 사용하기로 하였습니다.
Spring Data Redis는 기본 클라이언트로 Lettuce를 사용하기 때문에, Redisson은 의존성을 따로 설정해주어야 합니다.
...
dependencies {
...
implementation 'org.redisson:redisson-spring-boot-starter:3.17.4'
}
3. Redisson 설정
spring:
redis:
host: 127.0.0.1
port: 6379
application.yml - redis 서버 변수 설정
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
RedissonConfig.java - Reddisson 설정
4. 분산락 코드 작성
@Transactional
public void orderProduct(long userId, long productId, int orderCount) {
final RLock lock = redissonClient.getLock(String.format("orderProduct:productId:%d", shopProductId));
try {
// 타임아웃 설정
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("redisson lock timeout");
throw new IllegalArgumentException();
}
// 주문할 상품
final Product product = productRepository.findById(productId)
.orElseThrow(() -> new NotExistDataException(Product.class));
// 상품 재고량 확인 : 만약 주문 갯수가 상품의 재고량보다 높으면 Exception 발생
product.checkInventory(orderCount);
// 주문 데이터 등록
orderRepository.save(Order.create(userId, productId, orderCount));
// 재고량 감소
product.subtract(orderCount);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
예제 코드는 상품을 조회하고 재고량 확인 후 주문 데이터를 등록하고 상품의 재고량을 감소시키는 코드입니다.
tryLock 함수로 락을 획득하고 finally문에서 락을 해제하고 있습니다.
※ boolean tryLock(long waitTime, long leaseTime, TimeUnit timeUnit) throws InterruptedException;
- waitTime: 락 획득을 위해 기다리는 시간
- leaseTime: 락을 임대하는 시간
- timeUnit: 시간 단위
동시성 테스트
그럼 분산락을 적용하는 코드를 작성해보았으니 상품의 재고량을 2개로 설정 후 curl으로 동시에 1개씩 5번의 주문 요청을 해보겠습니다.
curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' \
'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}'
[1] 63646
[2] 63647
[3] 63648
[4] 63649
[5] 63650
{"code":"OK-200","message":"API 호출 성공"}[1] done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"OK-200","message":"API 호출 성공"}[4] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"OK-200","message":"API 호출 성공"}[1] done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"OK-200","message":"API 호출 성공"}[4] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"OK-200","message":"API 호출 성공"}[4] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
오잉.. 5번 모두 성공하였고 상품 재고량을 1개가 남아있습니다.
왜 동시성 문제가 해결되지 않았나?
메서드에 트랜잭션이 붙어있다면 Spring AOP를 통해 메서드 바깥으로 트랜잭션 처리가 진행되지만,
락 획득과 해제는 메서드 내부에서 일어나기때문에 락이 해제되고 트랜잭션 커밋이 되는 사이 다른 쓰레드가 락을 획득하고 트랜잭션 커밋이 이루어지지 않은 데이터를 읽어오게 됩니다.
반드시 락은 트랜잭션 커밋 이후 락이 해제되게끔 설정해야 합니다.
그럼 비지니스 로직에 설정된 분산락 코드를 지우고 컨트롤러단에서 분산락을 설정하도록 해보겠습니다.
컨트롤러에 분산락을 적용하는 방식은 옳지 않지만 간단한 테스트를 위해서 일단은 아래 예제 코드를 작성하였습니다.
@PostMapping(name = "상품 주문", value = "/product/order", produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<?> orderProduct(@RequestBody @Valid OrderProductRequest request) {
final RLock lock = redissonClient.getLock(String.format("orderProduct:productId:%d", request.getProductId()));
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("redisson lock timeout");
throw new IllegalArgumentException();
}
final long userId = SecurityContextHolderUtil.getTokenUserId();
orderService.orderProduct(userId, request.getProductId(), request.getOrderCount());
return ApiResponse.ok();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
동시성 테스트2
다시 상품 재고량을 2개로 설정 후 curl을 통해 테스트를 진행해보겠습니다.
curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' 'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}' & curl -X 'POST' \
'http://localhost:8090/product/order' \
-H 'accept: application/json' \
-H 'Authorization: Bearer token' \
-H 'Content-Type: application/json' \
-d '{
"productId": 1,
"orderCount": 1
}'
[1] 63657
[2] 63658
[3] 63659
[4] 63660
[5] 63661
{"code":"OK-200","message":"API 호출 성공"}[1] done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"OK-200","message":"API 호출 성공"}[2] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"ERR-603","message":"상품 재고가 부족합니다."}[4] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"ERR-603","message":"상품 재고가 부족합니다."}[3] - done curl -X 'POST' -H 'accept: application/json' -H -H -d
{"code":"ERR-603","message":"상품 재고가 부족합니다."}[5] + done curl -X 'POST' -H 'accept: application/json' -H -H -d
정상적으로 3개의 주문은 상품 재고가 부족하다는 Exception이 발생한 것을 확인할 수 있습니다.
마치며
Redis를 이용한 기본적인 분산락에는 어떠한 라이브러리들이 있는지 알게되었고 각 장단점을 파악할 수 있는 시간이였습니다.
그 중 Redisson로 분산락을 구현해보았는데 그로 인해 꼭 트랜잭션 커밋 이후 락이 해제되어야 한다는 중요한 정보도 알게 되었습니다.
또한 예제를 보시면 아시겠지만 컨트롤러에 분산락을 적용하는 방식은 옳지 않기 때문에 다음에는 어노테이션으로 설정할 수 있도록 만들어 적용하는 글을 정리하여 포스팅하도록 하겠습니다.
Reference
'Redis' 카테고리의 다른 글
Redis를 사용해 API Response 캐시화하기(with. Spring Boot) - 2 (2) | 2024.07.15 |
---|---|
Redis를 사용해 API Response 캐시화하기(with. Spring Boot) - 1 (0) | 2024.07.15 |
5. Redis HA(High availability)와 Sentinel (0) | 2024.02.21 |
4. Redis 복제 (0) | 2024.02.15 |
3. Redis 운영과 관리 (0) | 2023.10.01 |