쇼핑몰과 같이 실시간으로 상품의 재고가 계속 변하는 환경에서는 재고 관리가 매우 중요하다.
실시간으로 변경되는 정보를 정확하고 신속하게 데이터베이스에 반영해야 한다. 이때, saveAndFlush() 메서드를 사용하여 즉각적으로 데이터베이스에 반영하는 방식이 더티체킹 방식보다 더 적합할 수 있다.
@Transactional
public void decrease(Long id, Long quantity) {
// Stock 조회
// 재고 감소시킨 뒤
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
saveAndFlush 장점
- 즉각적 반영: `saveAndFlush()` 메서드는 영속성 컨텍스트의 변경 사항을 바로 데이터베이스에 반영한다. 재고 정보 같이 시시각각 변하는 중요한 데이터를 관리할 때, 데이터의 일관성과 실시간성을 유지하는 데 큰 도움이 된다.
- 데이터 동기화: 변경 사항을 바로 데이터베이스에 반영함으로써 여러 트랜잭션이 동시에 일어날 때 발생할 수 있는 데이터 불일치 문제를 최소화할 수 있다. 이는 트랜잭션 간 경쟁 조건(race conditions)을 방지하는 데 효과적이다.
- 과부하 방지: 주문 처리 같이 높은 동시성을 요구하는 환경에서는 더티 체킹에 의한 자동 업데이트 대신 `saveAndFlush()`를 사용하여 필요한 순간에만 데이터베이스에 쓰기 작업을 수행하게 함으로써 과부하를 방지할 수 있다.
고려사항
- 성능 문제: 빈번한 `flush()` 호출은 데이터베이스와의 쓰기 작업을 증가시켜 성능 문제의 원인이 될 수 있다.
- 트랜잭션 관리: `saveAndFlush()` 메서드를 사용할 때는 메서드 호출이 트랜잭션 범위 내에서 실행되는지, 그리고 이에 따른 트랜잭션 관리가 적절하게 수행되는지 주의해야 한다. 올바른 트랜잭션 관리 없이 빈번한 `flush()` 호출은 예기치 않은 부작용을 초래할 수 있다.
문제점
@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 {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertThat(stock.getQuantity()).isEqualTo(0);
}
- 32개의 쓰레드가 100개의 작업을 처리한다.
- 기대하는 결과는 상품의 남은 재고 수량이 0개인 것이다.
그러나 실제 결과는 0개가 아닌 88개인 것을 알 수 있다.
Race Condition
- Thread1: 상품의 재고를 데이터베이스로부터 조회한다. (재고: 100)
- Thread2: 상품의 재고를 데이터베이스로부터 조회한다. (재고: 100)
- Thread1: 상품의 재고를 감소시키고 데이터베이스에 반영한다. (100 - 1 = 99)
- Thread4: 상품의 재고를 감소시키고 데이터베이스에 반영한다. (100 - 1 = 99)
- ...
이러한 문제를 해결할 수 있는 방법으로는 Synchronized, Database, Redis를 이용하는 방식이 있다.
Synchronized
synchronized 키워드 추가
@Transactional
public synchronized void decrease(Long id, Long quantity) {
// Stock 조회
// 재고 감소시킨 뒤
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
synchronized 키워드를 추가하고 기존 테스트 코드를 수행한 결과는 다음과 같다.
여전히 문제가 해결되지 않는다.
이것은 스프링의 Transactional 어노테이션 동작 방식 때문이다. Transactional 어노테이션을 사용하면 구현한 클래스를 래핑해 새로 클래스를 만들어 실행하게 된다. 트랜잭션을 시작하고 메서드를 호출한 뒤 메서드 실행이 종료되면 트랜잭션을 종료하게 된다.
문제는 트랜잭션 종료 시점에 데이터베이스에 업데이트한다는 것이다.
감소시킨 재고 값을 데이터베이스에 갱신하기 전에 다른 쓰레드가 decrease 메서드를 호출하게 되면 데이터베이스로부터 갱신되기 이전의 값을 가져오기 때문에 문제가 된다.
만약, Transactional 어노테이션을 주석처리하고 테스트를 수행하면 통과하는 것을 알 수 있다.
Synchronized 문제점
Java의 Synchronized는 하나의 프로세스 안에서만 보장된다.
- 서버가 한대일 경우 데이터 접근에 문제가 없다.
- 만약 서버가 2대 혹은 그 이상인 경우, 데이터 접근을 여러 군데에서 할 수 있게 된다.
- 실제 운영하는 서비스는 2대 이상의 서버가 동작하므로 Synchronized는 사용되지 않는다.
데이터베이스를 활용한 방식
비관적 락 (Pessimistic Lock)
- 충돌이 발생할 것이라고 가정하고, 데이터를 읽거나 수정하기 전에 락을 획득한다.
- 락을 성공적으로 획득한 트랜잭션이 해당 데이터에 접근할 수 있으며, 다른 트랜잭션은 락이 해제될 때까지 대기해야 한다.
- 충돌이 많이 일어난다면 optimistic lock보다 성능이 좋을 수 있다.
- 락을 통해 업데이트를 제어하기 때문에 데이터 정합성이 보장된다.
- 별도의 락을 잡기 때문에, 대기 중인 트랜잭션이 많을 경우 성능 저하를 야기할 수 있다.
- 유형
- 공유 락(Shared Lock): 데이터를 동시에 읽을 수 있지만, 데이터의 수정은 동시에 이루어질 수 없다.
- 배타적 락(Exclusive Lock): 트랜잭션이 데이터를 독점적으로 읽고 수정할 수 있다.
낙관적 락 (Optimistic Lock)
- 데이터베이스 레벨에서 충돌이 드물다고 가정하며, 특정 데이터에 대한 락을 미리 가져가지 않는다.
- 대신에, 데이터가 마지막으로 읽힌 이후 수정되었는지를 확인하여 충돌을 탐지한다.
- 별도의 락을 잡지 않아서 pesssimistic Lock보다 성능상 이점이 있다.
- 업데이트 실패 시, 재시도 로직을 개발자가 직접 작성해 주어야 한다.
- 일반적으로 버전 번호나 타임스탬프를 사용하여 데이터의 변경 여부를 확인한다.
- 트랜잭션이 커밋될 때, 해당 데이터의 버전이 변경되었는지 확인하고, 변경되었을 경우 충돌로 간주하여 롤백하거나 다른 조치를 취한다.
명명된 락 (Named Lock)
- 실제 데이터가 아닌 별도의 공간에 lock을 건다.
- 명명된 락은 특정한 이름을 가진 락을 데이터베이스 레벨에서 제공하는 기능이다. 이는 데이터베이스 외부의 자원이나 공유 데이터에 대한 동기화를 제어할 때 유용하다.
- 같은 데이터 소스를 사용하면 커넥션 풀이 부족해지는 현상으로 인해 다른 서비스에도 영향을 줄 수 있으므로 데이터 소스를 분리해서 사용하는 것이 좋다.
- MySQL에서는 `GET_LOCK`과 `RELEASE_LOCK` 함수를 사용하여 특정 이름의 락을 획득하고 해제할 수 있다.
- 명명된 락을 사용하면 다양한 트랜잭션이나 프로세스 간에 복잡한 동기화 및 조절이 가능해진다.
- 하지만, 락을 해제하는 로직을 반드시 구현해야 하며, 락이 해제되지 않으면 데드락(deadlock)이나 리소스 고갈 문제가 발생할 수 있다.
- 비관적 락 (Pessimistic Lock)과의 차이점은 비관적 락은 로우나 테이블 단위로 락을 거는 반면, 명명된 락은 메타데이터 단위로 락을 건다.
Redis
- named lock과 흡사
- redis 사용하고 세션 관리 신경 안 써도 된다는 점
- lettuce는 락 획득을 계속 시도하는 반면 redisson은 락 해제가 되었을 때 한 번 혹은 몇 번만 시도를 하기 때문에 레디스에 부하를 줄여준다.
- lettuce
- redisson
- redisson은 락 관련 클래스를 라이브러리에 제공해줌
Lettuce
- 구현이 간당함
- 스핀락 방식이라서 레디스에 부하를 줄 수 있음 → 스레드 슬립을 통해 락 획득 재시도 간에 텀을 둬야함
Redisson
- pub-sub 기반의 구현이기 때문에 레디스의 부하를 줄여줌
- 구현이 복잡하고 별도의 라이브러리를 사용해야한다는 부담이 있음
Lettuce vs Redisson
Lettuce
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
- spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
Redisson
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 덜 간다.
- 별도의 라이브러리를 사용해야한다.
- lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다.
실무에서는 ?
- 재시도가 필요하지 않은 lock 은 lettuce 활용
- 재시도가 필요한 경우에는 redisson 를 활용
재시도가 필요한 경우란?
재시도가 필요한 경우를 예로 들어드리자면 주문할때일것 같습니다.
유저 A 가 로지텍마우스를 구매하였고 동시에 유저 B 도 로지텍마우스를 구매하였습니다.
이럴때는 유저 A 가 먼저 Lock 을 잡고있다면 유저B 는 기다렸다가 재시도를 하여 로지텍마우스를 구매할 수 있게합니다.
재시도가 필요없을경우는 선착순 1명만 로지텍마우스를 구매할 수 있다고 가정합니다.
유저 A 가 로지텍마우스를 구매하고자 Lock 을 획득하였고, 유저 B 가 추후에 로지텍마우스를 구매하고자 Lock 획득을 시도합니다.
유저 A 가 구매를 하고있는중이며 구매는 1명만 할 수 있기때문에 유저 B 는 획득을 시도해봤자 로지텍마우스를 구매할 수 없습니다.
Q. 재시도가 필요하지 않은 경우 Lettuce를 사용한다고 하셨는데 그렇다면 while 문 밖에서 다시 while 문으로 재고를 확인하고 재고가 없다면 break 처리된다고 이해하면 될까요?
class LettuceLockStockFacade { public void decrease(Long key, Long quantity) throws InterruptedException { while (!redisRepository.lock(key)) { TimeUnit.MILLISECONDS.sleep(100); // 시간을 두고 부하를 줄여준다 } }
A. 재시도가 필요하지 않은경우에는 lock 획득을 하지 못하면 바로 로직수행을 멈추시면 될것같습니다.public void demo() { Boolean lock = redisRepository.lock(key); if(!lock) { // 재시도가 필요하지 않으므로 다른로직을 수행하지 않습니다. // 로깅이 필요하다면 로깅을 추가할 수 있습니다. // 에러 상황이라면 throw 를 할 수도 있겠습니다. return; } // 비즈니스 로직 }
Mysql vs Redis
Mysql
- 이미 Mysql 을 사용하고 있다면 별도의 비용없이 사용가능하다.
- 어느정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis 보다는 성능이 좋지않다.
Redis
- 활용중인 Redis 가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- Mysql 보다 성능이 좋다.
'기타' 카테고리의 다른 글
Redis 학습 (1) | 2024.05.02 |
---|---|
MSA 멀티 모듈 설정 (0) | 2024.04.24 |
성능 테스트 관련 개념 정리(작성 중) (1) | 2024.03.22 |
Nginx란? (1) | 2024.01.23 |
Grafana (0) | 2023.12.17 |