[Spring] Race Condition + Lock : 동시성이슈를 해결하는 방법

7 분 소요

📺 재고시스템으로 알아보는 동시성이슈 해결방법 [최상용 - 인프런]

강의 소개

최근 과제 전형 - “동시성 문제가 발생할 수 있는 기능을 구현하라”

목표는

⇒ 동시성 문제가 언제 발생하는 지를 인지하고

⇒ 어떻게 해결해야 하는지 확인

재고시스템 내 문제점

재고시스템 코드
@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개의 쓰레드에서 실행

image

⇒ 예상 결과는 0 하지만 실제 결과는 87이 나옴 (실패)

Race Condition이 발생했기 때문

  • 둘 이상의 쓰레드가 공유 데이터에 엑세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제
  • 해결방법: 하나의 쓰레드가 작업이 완료된 이후에 다른 쓰레드가 데이터에 접근할 수 있도록 하면 된다.

Synchronized

  • 자바에서 지원 (애플리케이션 레벨)
  • 한개의 쓰레드만 접근 가능하게
@Transactional
public synchronized void decrease(Long id, Long quantity) {

image 1

⇒ 하지만 여전히 실패

⇒ 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에 대한 락을 기다리는 경우
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);
}

image 2

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을 건다

MySQL 명령어

  • get_lock : lock 획득, release_lock : lock 해제
  • Session1이 1이라는 이름으로 락을 건다면 (select get_lock(’1’, 1000))
  • 다른 Session에서는 Session1이 락을 해제한 후에 락을 획득할 수 있다.

실제로 사용할 때는 데이터 소스를 분리해서 사용하는 것을 추천

같은 데이터 소스를 사용하면 커넥션 풀이 부족해지는 현상으로 인해서 다른 서비스에도 영향을 끼칠 수 있음

Named Lock 적용 코드
  1. Repository 생성
public interface LockRepository extends JpaRepository<Stock, Long> {
}

  • 실무에서는 Stock Entity 대신 별도의 JDBC를 사용
  1. 실제 로직 전후로 락 획득 해제를 해줘야 하기 때문에 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()); // 락 해제
        }
    }
}
  1. 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);
    }
}
  1. 같은 데이터 소스를 사용할 거기 때문에 커넥션 풀 사이즈 변경 (실무에서는 비추)
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 하는 명령어

    image 3

    실패시 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을 통해 락 획득 재시도간에 텀을 둬야 한다.

Redisson

  • pub-sub 기반으로 Lock 구현 제공
    • 채널을 하나를 만들고 락을 점유 중인 쓰레드가 락 획득하려고 대기중인 쓰레드에게 해제를 알려주면 안내를 받은 쓰레드가 락 획득 시도를 하는 방식

image 4

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 보다 성능 좋음 (더 많은 요청 처리 가능)

카테고리:

업데이트: