-
Notifications
You must be signed in to change notification settings - Fork 2
Redis 저장 구조 변경, DECR 명령어와 사후 검증 로직으로 재고 차감 동시성 문제 해결
daadaadaah edited this page Nov 12, 2024
·
33 revisions
-
기존에는 Redis에 딜상품과 재고가 통합된 구조로 저장되어 있어서, 주문 처리로 인한 재고 감소 로직은 다음과 같았습니다.
-
이 로직은 여러 트랜잭션이 동시에 접근하는 경우, 다음과 같이 데이터 일관성이 깨지는 동시성 이슈 문제가 발생할 수 있습니다.
단계 | A 트랜잭션 | B 트랜잭션 | 비고 |
---|---|---|---|
1 | 딜 상품 조회: 2개(현재 재고량) | ||
2 | 딜 상품 조회: 2개(현재 재고량) | ||
3 | 주문량 2개로 재고 감소: 2(조회된 재고량) - 2(현재 주문량) = 0(주문 후 재고량) |
||
4 | 주문량 1개로 재고 감소: 2(조회된 재고량) - 1(현재 주문량) = 1(주문 후 재고량) |
||
5 | 재고량 0으로 딜 상품 업데이트 | ||
6 | 재고량 1로 딜 상품 업데이트 | 🧨 데이터 일관성 깨짐. 실제 재고량은 0인데, 1로 업데이트되는 문제 발생 |
- 이를 해결하기 위해, Redis를 사용하는 있는 상황에서 Redis 분산락 도입을 고민했습니다.
- 그런데, 분산락을 사용할 경우, 다음과 같은 단점들이 존재합니다.
- (단점 1. 작업 복잡성 증가 및 디버깅 어려움) 멀티쓰레드 환경에서 발생하는 버그들이 재현하기도 함들고, 특수한 케이스에서만 발생되기 때문에, 오랜 시간이 지나서야 결함이 발견되는 문제가 있습니다.
- (단점 2. 락 해제 타이밍 문제) 설정한 락 임대하는 시간보다 실제로 더 걸려서, 로직이 다 수행하기도 전에 락이 해제되고 다른 쓰레드가 락을 잡게 될 경우, 개발자가 의도하지 않은 대로 동작할 수 있는 문제가 있습니다.
- 따라서, 분산락을 회피하는 다른 방법으로 동시성 이슈 문제를 해결하고 싶었습니다.
딜 상품과 재고를 통합형 저장 구조+분산락 -> 딜 상품과 재고 분리형 저장 구조+Redis DECR 활용+사후검증
- 크게 2단계를 거쳐서 개선했습니다.
단계 1. Redis DECR을 활용하기 위해 Redis의 저장 구조 변경(딜 상품과 재고 통합형 -> 분리형)
단계 2. 데이터 무결성을 보장하기 위해 사후 검증 로직 추가
구조 | (A 구조) 딜 상품과 재고 통합형 저장 구조 | (B 구조) 딜 상품과 재고 분리형 저장 구조 |
---|---|---|
장점 | 1. CRUD 상황에서 ‘CRD 상황’(예: 상품 등록, 상품 조회, 상품 삭제 API)에서 B 구조보다 유리하다. (1) 딜 상품과 재고량이 이미 통합되어 있으므로, 개발자가 직접 원자성 있게 트랜잭션 처리를 고려하지 않아도 되므로, 애플리케이션 로직이 간단하다. (2) Redis에 1번만 Access하면 되므로, B 구조에 비해 읽기 또는 쓰기 부하가 덜하고, CRD 상황에서 B 구조보다 높은 TPS를 가질 수 있다. |
1. CRUD 상황에서 ‘U 상황’(예: 주문 API)에서 A 구조보다 유리하다. (1) 동시성 제어 로직이 필요없으므로, 애플리케이션 로직의 복잡도가 A 구조에 비해 간단하고, U 상황에서 A 구조보다 높은 TPS를 가질 수 있다. 2. 추후에 재고 도메인이 확장되어 분리해야 되는 상황이 발생할 때 A 구조보다 작업량이 줄어든다. |
단점 | 1. CRUD 상황에서 U 상황(예: 주문 API)에서 B 구조보다 불리하다. (1) 동시성 제어 로직이 필요하므로, 애플리케이션 로직의 복잡도가 증가한다. (2) 동시성 제어 로직으로 인해 U 상황에서 B 구조보다 낮은 TPS를 가질 수 있다. (3) 경우에 따라서 Redis 로직이 필요하므로, B 구조보다 높은 Redis 쓰기 부하가 발생할 수 있다. 2. 추후에 재고 도메인이 확장되어 분리해야 되는 상황이 발생할 때 B 구조보다 작업량이 많아지고, 그로 인한 실수도 발생할 수 있다. |
1. CRUD 상황에서 ‘CRD 상황’(예: 상품 등록, 상품 조회, 상품 삭제 API)에서 A 구조보다 불리하다. (1) 딜 상품과 재고량을 개발자가 직접 원자성 있게 트랜잭션 처리를 고려해야 하므로, 애플리케이션 로직의 복잡도가 증가한다. (2) 트랜잭션 실패로 인해 재시도 로직이 필요하고, 그로 인해 A 구조에 비해 읽기 부하가 발생할 수 있다. (3) Redis에 2번씩 Access해야 하므로, A 구조에 비해 읽기 또는 쓰기 부하가 더 높을 수 있다. (4) CRD 상황에서 B 구조보다 낮은 TPS를 가질 수 있다. |
- C 상황(상품 등록 API) : 발생 빈도가 낮고, 딜이라는 특성상 특정 시간에만 발생합니다. 또한, 트래픽이 비교적 적어, 복잡한 구조라도 데이터 일관성이 깨지는 경우, 수동으로 해결할 수 있다.
- R 상황(상품 조회 API) : 많은 트래픽이 몰리지만, 데이터 일관성 측면에서 상대적으로 중요도가 낮고, 서버 확장 등의 다른 해결책으로 성능을 개선할 수 있다.
- U 상황(주문 API(feat.재고 감소)) : 트래픽이 많이 몰리고, 데이터 일관성 보장을 위한 작업도 필요하다. U 상황에서 데이터가 잘못 처리되면 큰 문제가 발생할 수 있으므로, 간단하고 유지보수하기 쉬운 구조가 필요하다.
- D상황(상품 삭제 API) : C 상황과 동일한 특징
- Redis의 DECR 명령어는 원자적으로 작동하여, 복잡한 트랜잭션 처리 없이 재고를 간단하게 차감할 수 있어서, 유지보수하기 쉽습니다.
- R 상황(상품 조회) : 트래픽이 많지만, 읽기 작업이 주를 이루기 때문에 데이터 일관성 문제가 발생할 가능성이 적습니다. 따라서, 유지보수성이 매우 중요한 요소는 아니라고 판단하여 제외했습니다.
- CD 상황(상품 등록/삭제): 트래픽이 적고 발생 빈도가 낮아, 데이터 일관성이 깨지더라도 개발자가 수동으로 해결할 수 있습니다. 비즈니스 리스크도 크지 않으며, 복잡한 구조를 적용해도 문제가 되지 않습니다.
- U 상황(주문): 트래픽이 몰리고, 데이터 일관성이 매우 중요한 상황입니다. 만약, U 상황에서의 데이터 처리가 잘못되거나 유지보수가 어려우면, 비즈니스 리스크가 발생할 위험이 있습니다.
- 따라서, 비즈니스 리스크 관점에서, CD 상황에서 간단하지만, U 상황에서 복잡한 동시성 제어(예 : 분산락)가 필요한 통합형은 적합하지 않다고 판단했습니다,
- 반면, CD 상황에서 복잡한 로직이 필요하더라도, 비즈니스적으로 중요한 U상황에서 유지보수하기 쉬운 분리형 구조는 비즈니스 리스크 관점에서 더 적합하다고 생각했습니다.
- 왜냐하면, CD 상황에서 문제가 발생할 경우 비즈니스 리스크가 적고 개발자가 대응하기 쉽기 때문입니다.
- 다만, Redis DECR을 활용하더라도, 딜 상품과 재고 분리형에서는 동시성 문제로 인해 재고가 음수로 내려가는 단점이 발생할 수 있어, 이를 보완할 필요가 있었습니다.
- Redis의 DECR 명령어는 원자적으로 작동하지만, 다음과 같이 멀티 서버 환경에서 동시 접근 시 재고 조회와 감소가 비동기적으로 발생하면서 재고가 음수로 내려가서 데이터 무결성이 깨지는 문제가 발생할 수 있습니다.
단계 | 2개 주문하는 A 트랜잭션 | 1개 주문하는 B 트랜잭션 | 비고 |
---|---|---|---|
1 | 주문량 2개로 재고 감소(DECR): 2(현재 재고량) - 2(현재 주문량) = 0 (차감 후 재고량) |
||
2 | 주문량 1개로 재고 감소(DECR): 0(현재 재고량) - 1(현재 주문량) = -1(차감 후 재고량) |
🧨 차감되지 않아야 하는데, 차감됨 |
// (1) 재고 감소
int inventoryAfterDecrease = inventoryCommandRepository.decrease(....);
- 다음 코드처럼 Redis의 DECR 명령어 실행 후 반환된 값을 활용하여 사후 재고 검증 로직을 추가하였고, 잘못 차감된 경우에는 Rollback을 통해 데이터 무결성을 유지할 수 있었습니다.
// (1) 주문 처리로 인한 재고 감소
int inventoryAfterDecrease = inventoryCommandRepository.decrease(...); // Redis의 DECR 명령어 사용
// (2) 재고 사후 검증
if(inventoryAfterDecrease < 0) { // 재고 감소가 되지 않아야 되는데, 감소가 된 경우이다. 재고 조회시의 재고량과 실제 감소시킬 떄의 재고량이 달라진 경우에 발생 함.
inventoryCommandRepository.increase(...); // rollback 처리로 재고 복구
}
단계 | 2개 주문하는 A 트랜잭션 | 1개 주문하는 B 트랜잭션 | 비고 |
---|---|---|---|
1 | 주문량 2개로 재고 감소(DECR): 2(현재 재고량) - 2(현재 주문량) = 0 (차감 후 재고량) |
||
2 | 주문량 1개로 재고 감소(DECR): 0(현재 재고량) - 1(현재 주문량) = -1(차감 후 재고량) |
🧨 차감됨 차감(=주문 처리)되지 않아야 하는데 |
|
3 | 주문량 1개 만큼 Rollback : -1(현재 재고량) + 1(현재 주문량) = 0(rollback 후 재고량) |
✨ 재고 원상 복구 차감(=주문 처리)되지 않아야 했기 때문에 |
- 데이터 일관성 및 무결성 보장: 분산락 없이도 사후 검증 로직을 통해 재고 데이터의 일관성과 무결성을 확보했습니다.