프로젝트 개요
이번 프로젝트에서는 분산 시스템 환경에서 발생하는 다양한 문제 상황중, 서로 다른 요청이 하나의 데이터에 몰리게 되어 발생하는 레이스 컨디션(Race Condition) 문제를 분산 락을 이용해 해결하는 미니 프로젝트를 진행하고 이를 정리했습니다.
이전글 - [Redis] Redis 분산락(Distributed Lock)을 이용한 재고관리 구현 - 1
저번 글에서는 프로젝트를 위한 환경설정과 애플리케이션 시작시 Redis Warmup, 서비스 시점 DB - Redis 실시간 동기화를 위한 처리등을 진행했습니다.
이번 글에서는 재고 관리 관련 API 개발 및 예외처리 그리고 Jedisson을 이용한 Redis 분산락을 적용하고 테스트 했던 내용을 정리했습니다.
프로젝트 전체 코드는 아래 github을 통해 확인할 수 있습니다.
https://github.com/kwj2435/spring-redis-stock-manager
1. 재고 관리 관련 API 개발 및 예외처리
1.1 재고 조회 API
Redis의 재고 데이터를 조회하는 간단한 API 입니다.
DB 재고 정보가 아닌 Redis를 타겟으로 조회한 이유는, DB의 경우 구성되어있는 Redis에 비해 부하에 상대적으로 취약한 리소스입니다. 또한 이미 DB와 재고 동기화가 되고 있는데 인메모리 기반의 Redis를 대상으로 조회하는것이 효율적이라 판단했습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/inventory")
public class InventoryController {
private final InventoryService inventoryService;
@GetMapping("/{inventoryId}")
public InventoryModel.InventoryResponse getInventory(@PathVariable("inventoryId") long inventoryId) {
return inventoryService.getInventory(inventoryId);
}
}
@Service
@RequiredArgsConstructor
public class InventoryService {
private final RedisTemplate<String, Object> redisTemplate;
public InventoryModel.InventoryResponse getInventory(long inventoryId) {
InventoryModel.Inventory inventory =
(InventoryModel.Inventory) redisTemplate.opsForHash()
.get(INVENTORY_DETAILS_KEY, String.valueOf(inventoryId));
if(inventory == null) {
throw ApiException.from(ApiExceptionCode.ERR_400_10001);
}
return InventoryModel.InventoryResponse.of(inventoryId, inventory);
}
1.2 재고 감소 API
클라이언트로부터 전달받은 상품 ID에 해당하는 상품 데이터를 Redis에서 조회후, 재고 상태 확인 및 감소 처리를 진행하도록 했습니다.
변경된 재고 정보는 이전 글에서 정리해두었던 Kafka 재고 감소 이벤트를 발행후 DB에 동기화 되도록 처리했습니다.
/**
* 재고 감소 API
*/
@PostMapping("/{inventoryId}/decrease")
public InventoryModel.InventoryResponse decreaseInventoryStock(
@PathVariable("inventoryId") long inventoryId) {
return inventoryService.decreaseInventoryStock(inventoryId);
}
public InventoryModel.InventoryResponse decreaseInventoryStock(long inventoryId) {
InventoryModel.Inventory inventory = getInventoryFromRedis(inventoryId);
inventory.decreaseStock(1);
redisTemplate.opsForHash().put(
INVENTORY_DETAILS_KEY,
String.valueOf(inventoryId),
inventory
);
InventoryModel.InventoryResponse response =
InventoryModel.InventoryResponse.of(inventoryId, inventory);
// kafka 재고 DB 동기화 이벤트 발행
publishInventoryUpdateEvent(response);
return response;
}
private void publishInventoryUpdateEvent(InventoryModel.InventoryResponse response) {
try {
String responseToJson = mapper.writeValueAsString(response);
inventoryProducer.sendMessage(INVENTORY_UPDATE_TOPIC, responseToJson);
} catch (Exception e) {
log.error("Inventory Stock Update Fail");
// DLQ 전달 로직 추가 필요
}
}
private InventoryModel.Inventory getInventoryFromRedis(long inventoryId) {
InventoryModel.Inventory inventory =
(InventoryModel.Inventory) redisTemplate.opsForHash()
.get(INVENTORY_DETAILS_KEY, String.valueOf(inventoryId));
if(inventory == null) {
throw ApiException.from(ApiExceptionCode.ERR_400_10001);
}
return inventory;
}
1.3 레이스 컨디션(Race Coindtion) 이슈
이렇게 하여 재고 조회, 감소 그리고 애플리케이션 실행시의 Redis Warmup과 재고 변경에 따른 DB - Redis 까지 구현을 마무리 했습니다. 다만 현재의 구조는 분산 시스템 환경에서 레이스 컨디션(Race Condition)에 취약한 상태입니다.
레이스 컨디션(Race Condtion)은 여러 스레드가 하나의 공유된 자원에 동시에 접근하거나 수정하면서 발생하는 동시성 문제입니다.
간단한 레이스 컨디션의 예를 들어보면, A 상품에 10개의 재고가 있는 상황을 가정하겠습니다.
- 첫 번째 스레드가 A 상품의 재고를 읽음 (재고 10).
- 두 번째 스레드가 A 상품의 재고를 읽음 (재고 10).
- 첫 번째 스레드가 재고 감소 작업을 수행함 (재고 10 → 9).
- 동시에, 두 번째 스레드가 같은 재고 10을 기준으로 감소 작업을 수행함 (재고 10 → 9).
- 최종 재고가 9로 설정되며, 재고가 두 번 감소되지 않음(결과적으로 재고 1 손실).
이와 같은 상황은 공유 자원에 대한 동기화가 이루어지지 않았기 때문에 발생한 레이스 컨디션입니다.
실제 테스트 코드 동작으로 레이스 컨디션 발생을 확인 해보았습니다.
아래 코드는 10개의 스레드가 동시에 하나의 상품 재고를 감소시키는 동작을 했을때에 대한 테스트 코드입니다.
처음 재고는 99999개이기 때문에 레이스 컨디션 이슈 없이 동작했다면 재고의 갯수는 10개가 빠진 99989개가 되어야 합니다.
하지만 테스트 코드의 결과를 보면 1개의 재고만 줄어든것을 확인할 수 있습니다.
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public class InventoryServiceRaceConditionTest {
@Autowired private InventoryService inventoryService;
@Autowired private RedisTemplate<String, Object> redisTemplate;
@Test
void testDecreaseInventoryStockRaceCondition() throws InterruptedException {
int numberOfThreads = 10;
int decreasePerThread = 1;
int totalDecrements = numberOfThreads * decreasePerThread;
int inventoryId = 1;
InventoryModel.Inventory beforeInventory =
(InventoryModel.Inventory)redisTemplate.opsForHash().get(INVENTORY_DETAILS_KEY, String.valueOf(inventoryId));
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
executorService.execute(() -> {
try {
inventoryService.decreaseInventoryStock(inventoryId);
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
InventoryModel.Inventory finalInventory =
(InventoryModel.Inventory) redisTemplate.opsForHash().get(INVENTORY_DETAILS_KEY, String.valueOf(inventoryId));
int expectedStock = beforeInventory.getStockQuantity() - totalDecrements;
assertThat(finalInventory.getStockQuantity()).isEqualTo(expectedStock);
}
}
위와 같은 동시성 문제를 Redis 레벨에서 해결 할 수 있는 방법으로는 분산락(Distributed Lock), 원자 연산(Atomic Operations) 그리고 Lua 스크립트를 이용할 수 있습니다.
원자 연산의 경우 성능은 빠르지만 복잡한 연산을 처리하기 어려운 단점이 있으며, Lua 스크립트의 경우 복잡한 디버깅과 유지보수의 어려움으로 인한 단점이 있습니다. 이번 프로젝트에서는 Redisson 라이브러리의 Redlock 알고리즘을 통한 분산락을 통해 동시성 문제를 해결보았습니다.
2. 분산락 적용 및 테스트
Redisson 라이브러리는 Redis를 단순 캐싱기능을 벗어나, 분산 데이터구조, 분산 락, 분산 이벤트 등의 확장된 기능을 사용할 수 있도록 도와주는 라이브러리입니다.
2.1 Redisson 분산락 적용
우선 분산락 적용을 위해 Redisson 의존성을 추가합니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.22.1'
이후 기존 Redis Config 클래스에 Redisson Config 클래스 파일을 추가로 작성해줍니다.
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.addNodeAddress(
"redis://127.0.0.1:7001",
...
)
.setMasterConnectionPoolSize(64)
.setSlaveConnectionPoolSize(64);
return Redisson.create(config);
}
Redisson 설정이 완료된 후, 분산 락을 이용하여 재고 감소 API의 임계구역을 구분하여 Lock 획득 로직을 추가하였습니다.
이를 위해 Redisson에서 제공하는 RLock을 사용했습니다. 아래는 RLock을 적용한 재고 감소 API 코드입니다.
public InventoryModel.InventoryResponse decreaseInventoryStock(long inventoryId) {
String lockKey = INVENTORY_DETAILS_KEY + inventoryId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
InventoryModel.Inventory inventory = getInventoryFromRedis(inventoryId);
inventory.decreaseStock(1);
redisTemplate.opsForHash().put(
INVENTORY_DETAILS_KEY,
String.valueOf(inventoryId),
inventory
);
InventoryModel.InventoryResponse response =
InventoryModel.InventoryResponse.of(inventoryId, inventory);
// kafka 재고 DB 동기화 이벤트 발행
publishInventoryUpdateEvent(response);
return response;
} else {
// 1. 재고 감소 RetryQueue
// 2. ReturyQueue 실패시 Dead Letter Queue 전송을 통한 수기처리
throw ApiException.from(ApiExceptionCode.ERR_409_10002);
}
} catch (Exception e) {
// 1. 재고 감소 여부를 확인할 수 없으므로 Dead Letter Queue로 전달하여 운영 수기 처리
throw ApiException.from(ApiExceptionCode.ERR_409_10002);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
2.2 락 획득 실패 및 예외 상황 처리 고찰
여러 이유로 락을 실패하거나 로직상 예외상황의 발생 했을 경우에 대한 처리가 필요합니다.
사용자의 재고 감소 요청이 실패했을 경우 여러 옵션이 있습니다.
첫째, 분산 트랜잭션을 통해 관련 서비스에 보상 트랜잭션 유도
둘째, 재고 감소 로직 Retry 처리
셋째, 실패한 요청을 별도 저장하여 수기 처리
이 프로젝트에서는 사용자의 재고 감소 요청은 반드시 성공한다는 전제로 서비스가 동작해야 함을 가정하였습니다.
이를 통해 락 획득 실패 혹은 예상치 못한 Exception 발생시 우선 Retry Queue에 전달하여 정해진 횟수만큼 재고 감소 재시도를 진행하며 Retry 로직이 실패 했을 경우 둘째로, DLQ(Dead Letter Queue)에 전달하여 운영자가 수기 처리할 수 있도록 가정하였습니다.
이상으로 Redis 분산락을 활용한 재고관리 미니 프로젝트 개발 과정을 마무리 하도록 하겠습니다.
'Redis' 카테고리의 다른 글
[Redis] Redis 분산락(Distributed Lock)을 이용한 재고관리 구현 - 1 (0) | 2025.01.15 |
---|---|
[Redis] Redis 클러스터(Redis Cluster) 구성하기 (0) | 2025.01.09 |