동시성 이슈 해결하기
주문이 한꺼번에 몰리는 한정판 판매를 구현할 때, 재고 수량의 정합성을 어떻게 보장할 수 있을까?!
문제점
아래는 Spring Framework, JPA를 사용한 재고 시스템 코드이다.
주문 시에 재고(Stock) 엔티티의 quantity의 값이 차감되어, 0이 되면 더이상 주문이 불가능하다.
이 코드에는 문제가 있다.
package com.example.domain;
@Entity
@Getter
public class Stock {
@Id
@GeneratedValue(strategy = GeneratedType.Identity)
private Long id;
private Long productId;
private Long quantity;
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("차감 불가능한 수량입니다.");
}
this.quantity -= quantity;
}
}
package com.example.repository;
public interface StockRepository extends JpaRepository<Stock, Long> {}
package com.example.service;
@Service
@RequiredArgsConstructor
public class StockService {
private StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
}
}
이 코드는 주문이 한 개씩 들어올 때는 문제없이 생각한대로 동작할 것이다.
하지만 주문이 한꺼번에 몰리면 동시성 문제를 발생시킨다.
예상한 남은 수량과 실제 남은 수량이 불일치할 것이고, 원했던 것보다 더 많은 수량의 주문이 완료될 것이다.
100개만 팔 수 있는데 120개의 주문이 들어오는 것은 비즈니스에 치명적인 문제가 된다.
둘 이상의 스레드가 공유 데이터에 접근하여 값을 변경하려고 하면 Race condition 문제가 발생한다.
스레드1이 갱신한 값을 스레드2가 읽어 갱신할 것을 기대하지만, 실제로는 스레드1이 갱신을 완료하기 전에 스레드2가 값을 읽어버린다. 그래서 갱신이 누락되어 버린다.
이를 해결하기 위해선 하나의 스레드가 작업을 완료한 후에야 다른 스레드가 값에 접근할 수 있도록 제한하여야 한다.
해결 방법 1. Synchronized 이용하기
Java의 Synchronized가 선언된 부분은 한 번에 하나의 스레드만 접근 가능하다.
package com.example.service;
@Service
@RequiredArgsConstructor
public class StockService {
private StockRepository stockRepository;
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
}
}
Synchronized의 문제점 1.
하지만 위 코드도 마찬가지로 동시성 문제를 해결하지 못한다. 이는 스프링 프레임워크의 @Transactional의 동작 방식과 관련 있다.
@Transactional은 스프링 프레임워크의 AOP를 사용하여 구현된다. 즉, 프록시 패턴으로 구현되어 해당 메서드 전후에 트랜잭션을 시작/완료한다. decrease 메서드가 synchronized이더라도 프록시는 synchronized 되어 있지 않기 때문에 트랙잭션이 완료되기 전에 다른 스레드가 공유 데이터에 접근할 수 있는 것이다.
이를 피하기 위해 트랜잭션을 직접 구현해도 되지만 굳이 스프링 프레임워크의 틀을 벗어나겠다는 건 좀 이상하다.
Synchronized의 문제점 2.
또한 Synchronized는 서버가 두 대 이상일 경우에는 소용이 없다. Synchronized는 하나의 프로세스 안에서 스레드들의 데이터 접근을 제어하는 것이다. 프로세스가 여러 개일 경우 결국 여러 개의 스레드가 공유 데이터에 접근하는 것을 막을 수 없다.
해결 방법 2. 데이터베이스의 Lock 이용하기
데이터베이스는 이런 문제점을 해결하기 위한 다양한 Lock을 제공한다. 다음은 MySQL에서 제공하는 Lock이다.
1. Pessimistic Lock (비관적 락)
데이터에 Lock을 걸어서 정합성을 맞춘다.
Lock을 걸면 다른 트랜잭션에서는 Lock이 해제되기 전에 데이터에 접근할 수 없다.
Deadlock이 걸릴 수 있기 때문에 주의해서 사용해야 한다. (Deadlock: 둘 이상의 트랜잭션이 Lock을 획득한 상태에서 상대 트랜잭션이 점유하고 있는 자원을 요구하며 무한정 기다리는 상태)
package com.example.repository;
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
JPA를 사용하면 위처럼 @Lock 어노테이션을 이용해 쉽게 락을 걸 수 있다.
위 메서드를 호출하면 실제로는 "select s.* from stock s where s.id=1 for update" SQL이 실행되는 것을 확인할 수 있다.
충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋을 수 있다.
2. Optimistic Lock (낙관적 락)
실제로 Lock을 이용하는 것은 아니고, 데이터의 버전을 이용하여 정합성을 맞춘다.
데이터를 읽은 후 update를 수행할 때 내가 읽은 버전이 맞는지 확인한다.
update set version = version + 1, quantity = 2
from stock
where id = 1 and version = 1;
내가 읽은 버전에서 수정 사항이 생겼을 경우에는 application에서 다시 데이터를 읽은 후에 작업을 수행하는 로직을 개발자가 직접 구현해줘야 한다.
package com.example.repository;
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
package com.example.domain;
@Entity
@Getter
public class Stock {
@Id
@GeneratedValue(strategy = GeneratedType.Identity)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
}
JPA를 사용하면 위처럼 @Lock 어노테이션을 이용해 쉽게 락을 걸 수 있다.
엔티티에 버전 필드를 만들어줘야 한다. 이 버전은 JPA가 관리하므로 개발자가 수정해서는 안 된다.
하지만 벌크 연산시에는 JPA가 버전을 관리해주지 않는다*는 것을 기억해야 한다.
(* JPA의 벌크 연산은 영속성 컨텍스트와 2차 캐시를 건너뛰고 데이터베이스에 직접 쿼리한다. 그래서 벌크 연산 후에는 엔티티를 다시 조회하거나, 영속성 컨텍스트를 초기화하거나, 벌크 연산을 아예 제일 먼저 실행해야 한다. JPA 사용시 꼭 기억해야 할 점이다.)
충돌이 일어나면 OptimisticLockException이 발생한다. 그러면 엔티티를 다시 읽어 다시 업데이트 하는 작업을 해준다.
Optimistic Lock은 별도의 락을 잡지 않으므로 Pessimistic Lock보다 성능이 좋을 수 있지만, 충돌이 빈번하다면 업데이트 실패시 Rollback으로 인해 성능이 더 안 좋을 수도 있고, 재시도 로직을 개발자가 직접 구현해줘야 한다는 단점이 있다.
3. Named Lock
이름을 가진 metadata lock이다. 이름을 가진 락을 획득하면 다른 세션에서 이 락을 획득할 수 없다.
Pessimistic Lock이 Stock에 대해 락을 걸었다면, Named Lock은 별도의 공간에 락을 건다.
트랜잭션이 종료될 때 락이 자동으로 해제되지 않는다. 별도의 명령어로 해제를 수행하거나 선점 시간이 끝나야 락이 해제된다.
# 락 획득
select get_lock("lock name", 3000);
# 락 해제
select release_lock("lock name");
@Transactional(propagation = Propagation.REQUIRES_NEW) // 부모 트랜잭션과 별도 실행
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
실무에서는 JDBC를 사용해 별도의 데이터소스를 사용하는 것이 좋다. 같은 데이터소스를 사용하면 커넥션풀이 부족해지는 문제가 발생할 수 있다.
Named Lock은 타임아웃을 설정하기 쉬워서(= Deadlock 방지) 주로 분산락을 구현할 때 사용한다.
(분산락 Distributed Lock: 여러 대의 서버에 대해 데이터 동기화를 보장하기 위해 여러 대의 서버에 공통으로 거는 락)
참고: https://techblog.woowahan.com/2631/
해결 방법 3. Redis로 구현한 Lock 이용하기
Redis를 사용해서도 분산락을 구현할 수 있다. 이 때 사용하는 대표적인 라이브러리로는 Lettuce와 Redisson이 있다.
1. Lettuce
스핀락 방식의 락을 구현할 수 있다.
(스핀락 Spin Lock: 락을 획득하려는 스레드가 락이 사용 가능한지 반복적으로 확인하며 락 획득을 시도하는 방식)
스레드1이 Key가 A인 데이터를 레디스에 set하려고 하면, 처음에는 Key가 A인 데이터가 없기 때문에 정상적으로 작동한다. 그 후 스레드2가 Key가 A인 데이터를 레디스에 set하려고 하면, 이미 Key가 A인 데이터가 있으므로 실패를 리턴한다. 스레드2는 락을 획득할 때까지 일정 시간마다 락 획득을 재시도한다. (재시도 로직을 개발자가 구현해주어야 한다.)
setnx A B #키가 A, 밸류가 B -> 성공
setnx A B #이미 키가 A인 데이터가 있으므로 실패
"setnx" (= SET if Not eXists) 명령어를 이용한다.
package com.example.repository;
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private ResidTemplate<String, String> redisTemplate;
// 락 획득
public Boolean lock(String key, String value) {
return redisTemplate
.opsForValue()
.setIfAbsent(key, value, Duration.ofMillis(3_000));
}
// 락 해제
public Boolean unlock(String key) {
return redisTemplate.delete(key);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
while (!redisLockRepository.lock(id.toString()) {
Thread.sleep(100); // 시간차를 줘서 레디스에 가해지는 부하를 덜어준다.
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id.toString());
}
}
계속 해서 락 획득을 시도하기 때문에 레디스에 부하를 줄 수 있다.
2. Redisson
pub-sub 기반의 락을 구현할 수 있다.
락을 점유 중인 스레드가 락 해제를 알리고, 락을 대기 중인 스레드가 락 해제를 알게 되면 락 획득을 시도한다. 락 획득이 가능할 때 락 획득을 시도하기 때문에 대부분의 경우에는 재시도 로직을 별도로 작성하지 않아도 된다.
# ch1 구독
subscribe ch1
# ch1에 hello 발행
publish ch1 hello
package com.example.facade;
@Component
@RequiredArgsConstructor
public class RedissonLockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public void decrease(Long key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS); // 몇 초간 점유할 것인지 설정
if (!available) {
System.out.println("락 획득 실패");
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Redis를 사용한 락 구현은 MySQL보다 성능이 좋지만 별도의 구축 비용과 인프라 관리 비용이 발생한다.