[Spring] Race Condition + Lock : 동시성이슈를 해결하는 방법
📺 재고시스템으로 알아보는 동시성이슈 해결방법 [최상용 - 인프런]
강의 소개
최근 과제 전형 - “동시성 문제가 발생할 수 있는 기능을 구현하라”
목표는
⇒ 동시성 문제가 언제 발생하는 지를 인지하고
⇒ 어떻게 해결해야 하는지 확인
재고시스템 내 문제점
재고시스템 코드
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 재고감소() {
stockService.decrease(1L, 1L);
// 100 - 1 = 99
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
- 요청이 한개씩 들어오는 상황(단건 요청) ⇒ 정상 작동
하지만 요청이 동시에 여러 개가 들어오면 어떻게 될까??
동시에 여러개의 요청 ⇒ 멀티 쓰레드 ⇒ ExecutorService
사용
ExecutorService
- 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 자바의 API
100개의 요청이 끝날때까지 대기 ⇒ CountDownLatch
사용
CountDownLatch
- 다른 쓰레드에서 수행중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
100개 요청 테스트 코드
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount); // 100개의 요청이 끝날때까지 대기
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();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
- 100개의 요청이 32개의 쓰레드에서 실행
⇒ 예상 결과는 0 하지만 실제 결과는 87이 나옴 (실패)
⇒ Race Condition이 발생했기 때문
- 둘 이상의 쓰레드가 공유 데이터에 엑세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제
- 해결방법: 하나의 쓰레드가 작업이 완료된 이후에 다른 쓰레드가 데이터에 접근할 수 있도록 하면 된다.
Synchronized
- 자바에서 지원 (애플리케이션 레벨)
- 한개의 쓰레드만 접근 가능하게
@Transactional
public synchronized void decrease(Long id, Long quantity) {
⇒ 하지만 여전히 실패
⇒ Spring의 Transactional annotation의 동작방식 때문
- @Transactional : 만든 클래스를 래핑한 클래스(Proxy)를 새로 만들어서 실행
- StockService를 필드로 가지는 클래스(Proxy)를 새로 만들어서 실행
- 트랜잭션을 시작한 후에 메서드를 호출하고 메서드 실행이 종료가 된다면 트랜잭션을 종료하게 됨
- 트랜잭션 종료 시점에 실제 데이터베이스가 업데이트 되는데 그 전에 다른 Thread가 decrease 메서드를 호출할 수 있음
⇒ Transaction annotation을 빼면 되긴 한다!
문제점
- 하나의 프로세스 안에서만 보장
- 서버가 1대일 때는 데이터의 접근을 서버가 1대만 해서 괜찮지만, 서버가 2대 혹은 그 이상일 경우 데이터의 접근을 여러 대에서 할 수 있게 됨
- 서버 1에서 재고감소 로직을 시작하고 있는 중에 서버 2에서 갱신되지 않은 재고를 가져가서 새로운 값으로 갱신할 수 있음 ⇒ Race Condition
- 실제 운영 중인 서비스는 대부분 2대 이상의 서버를 사용하기 때문에 synchronized는 거의 사용하지 않는다
Database 이용
Pessimistic Lock
- 실제로 데이터에 락을 걸어서 정합성을 맞추는 방법
- Exclusive Lock을 걸게되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없게 된다.
- Exclusive Lock(배타적 락): 특정 데이터에 대한 독점적인 접근 권한을 부여하는 락 방식
- 데이터에는 락을 가진 쓰레드만 접근이 가능하다.
- 하지만 Deadlock이 걸릴 수 있기 때문에 주의하여 사용
- Deadlock(데드락): 두 개 이상의 트랜잭션이나 프로세스가 서로 상대방의 락을 기다리는 상태가 되어, 모두가 영원히 대기하는 상황
- ex) 트랜잭션 A가 데이터 1에 대한 락을 획득하고 데이터 2에 대한 락을 기다리고 있는 동안 트랜잭션 B가 데이터 2에 대한 락을 획득하고 데이터 1에 대한 락을 기다리는 경우
- Deadlock(데드락): 두 개 이상의 트랜잭션이나 프로세스가 서로 상대방의 락을 기다리는 상태가 되어, 모두가 영원히 대기하는 상황
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
for update
: 락을 걸고 데이터를 가져오는 부분
- 장점
- 충돌이 빈번하게 일어난다면 Optimistic lock보다 성능이 좋음
- 락을 통해 업데이트를 제어하기 때문에 데이터 정합성 보장
- 단점
- 별도의 락을 잡기 때문에 성능 감소
Optimistic Lock
- 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
- 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트
- 내가 읽은 버전에서 수정사항이 생겼을 경우 application에서 다시 읽은 후에 작업을 수행
- 자주 읽음 lock이 많이 필요한 경우 부적절
- 서버 1과 서버2가 데이터베이스에서 버전이 1인 row를 읽어옴 → 읽고 난 이후 서버1이 먼저 업데이트 쿼리를 날림 (업데이트 쿼리를 수행할 때 where 조건에 버전이 1인 걸 명시해주면서 업데이트)
update set version = version + 1, quantity = 2 from stock where id = 1 and version = 1
→ 실제 데이터의 version == 2 → 서버 2가 이후에 동일하게 업데이트 쿼리를 수행 (현재 읽은 버전을 업데이트 하는 조건 포함) → 수행 안됨
update set version = version + 1, quantity = 2 from stock where id = 1 and version = 1
(현재 읽은 버전은 version이 1이고 실제 데이터는 version이 2인 상태)
→ 업데이트가 실패한 경우 실제 어플리케이션에서 다시 읽은 후에 작업을 수행 (로직 직접 넣어야 함)
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
Optimistic Lock은 실패했을 때 재시도를 해야하므로 facade라는 클래스를 생성
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50); // 50ms 있다가 재시도
}
}
}
}
- 장점
- 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능상 이점
- 단점
- 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해줘야 하는 번거로움 존재
- 충돌이 빈번하게 일어난다면 혹은 충돌이 빈번하게 일어날 것이라고 예상된다면 Pessimistic Lock, 빈번하게 일어나지 않을 것이라고 예상된다면 Optimistic Lock
Named Lock
- 이름을 가진 metadata locking
- 이름을 가진 lock을 획득한 후 해제할때까지 다른 세션은 이 lock을 획득할 수 없도록 한다.
- 주의할 점으로는 transaction이 종료될 때 lock이 자동으로 해제되지 않는다.
- 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다.
-
주로 분산 락을 구현할 때 사용
- Pessimistic lock이랑 유사
- 차이점 : Pessimistic lock은 row나 테이블 단위로 걸지만 named lock은 메타데이터에 locking 하는 방식
- Pessimistic lock은 stock 데이터에 lock을 걸고 Named Lock은 별도의 공간에 lock을 건다
- 차이점 : Pessimistic lock은 row나 테이블 단위로 걸지만 named lock은 메타데이터에 locking 하는 방식
MySQL 명령어
get_lock
: lock 획득,release_lock
: lock 해제- Session1이 1이라는 이름으로 락을 건다면 (
select get_lock(’1’, 1000)
) - 다른 Session에서는 Session1이 락을 해제한 후에 락을 획득할 수 있다.
실제로 사용할 때는 데이터 소스를 분리해서 사용하는 것을 추천
같은 데이터 소스를 사용하면 커넥션 풀이 부족해지는 현상으로 인해서 다른 서비스에도 영향을 끼칠 수 있음
Named Lock 적용 코드
- Repository 생성
public interface LockRepository extends JpaRepository<Stock, Long> {
}
- 실무에서는 Stock Entity 대신 별도의 JDBC를 사용
- 실제 로직 전후로 락 획득 해제를 해줘야 하기 때문에 facade 클래스 생성
@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString()); // 락 획득
stockService.decrease(id, quantity); // 재고 감소
} finally {
lockRepository.releaseLock(id.toString()); // 락 해제
}
}
}
- StockService에서는 부모의 transition과 별도로 실행이 되어야 하기 때문에 propagation을 변경
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
- 같은 데이터 소스를 사용할 거기 때문에 커넥션 풀 사이즈 변경 (실무에서는 비추)
spring:
datasource:
hikari:
maximum-pool-size: 40
- 장점
- Pessimistic Lock과 달리 Named Lock은 타임아웃을 손쉽게 구현할 수 있음
- 데이터 삽입 시에 정합성을 맞춰야 하는 경우에도 Named Lock을 사용
- 단점
- Transaction 종료 시에 락 해제, 세션 관리를 잘 해줘야 하기 때문에 주의 해서 사용해야 함
- 실제로 사용할 때는 구현 방법이 복잡할 수 있음
Redis 이용
- 분산 락을 구현할 때 사용하는 대표적인 라이브러리
- MySQL의 Named Lock과 비슷
- 다른점은 Session 관리에 신경을 쓰지 않아도 된다
Lettuce
setNx
명령어를 활용하여 분산 락을 구현- set if not exist의 줄임말로 키와 밸류를 세트할 때 기존의 값이 없을 때만 set 하는 명령어
실패시 0 성공시 1
spin lock
방식이므로 retry 로직을 개발자가 작성해야 함- spin lock : 락을 획득하려는 쓰레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식
Lettuce 적용 코드
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000)); // key - stock ID, value - "lock"
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100); // 레디스에 갈 수 있는 부하 감소
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id);
}
}
}
- 장점
- 구현이 간단하다
- spring-data-redis를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
- 단점
- spin lock 방식이라 동시에 많은 스레드가 lock 획득 대기 상태라면 레디스에 부하를 줄 수 있다.
Thread.sleep
을 통해 락 획득 재시도간에 텀을 둬야 한다.
- spin lock 방식이라 동시에 많은 스레드가 lock 획득 대기 상태라면 레디스에 부하를 줄 수 있다.
Redisson
- pub-sub 기반으로 Lock 구현 제공
- 채널을 하나를 만들고 락을 점유 중인 쓰레드가 락 획득하려고 대기중인 쓰레드에게 해제를 알려주면 안내를 받은 쓰레드가 락 획득 시도를 하는 방식
Redisson 라이브러리 추가
- Lock 관련 클래스들을 제공
- 별도 레포지토리 작성하지 않아도 됨
Maven Repository: org.redisson » redisson-spring-boot-starter
Redisson 적용 코드
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 몇 초 동안 락 획득을 시도하는지, 몇 초 동안 점유할 것인지
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
테스트 실패시 락 획득을 기다리는 시간을 늘려준다. ex) 10 → 15
시간을 늘려줘도 계속 실패한다? ⇒ Redis 락이 이전 테스트에서 해제되지 않은 상태로 남아있는가를 의심 ⇒ flushall
명령어로 redis 데이터 초기화
- 장점
- 락 획득 재시도를 기본으로 제공한다.
- Lettuce는 계속 락 획득을 시도하는 반면에 Redisson은 락 해제가 되었을 때 한 번 혹은 몇 번만 시도를 하기 때문에 레디스의 부하를 줄인다.
- 단점
- 구현이 조금 복잡하다
- 별도의 라이브러리를 사용해야 하는 부담감
⇒ 재시도가 필요한다면 redisson, 재시도가 필요하지 않은 lock은 lettuce
MySQL과 Redis의 비교
MySQL
- 이미 Mysql을 사용하고 있다면 별도의 비용없이 사용 가능
- 성능이 레디스보다는 좋지 않지만 어느 정도의 트래픽까지는 문제없이 활용 가능
Redis
- 활용적인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 추가로 발생
- Mysql 보다 성능 좋음 (더 많은 요청 처리 가능)