티스토리 뷰

 

동시성 문제란?

공유 자원에 둘 이상의 스레드가 접근하여 생기는 경쟁 상황(race condition)을 동시성 문제라고 한다. 둘 이상의 스레드가 공유 데이터에 접근 가능한 상황에서 하나의 스레드가 공유 데이터를 수정 중일 때, 다른 스레드에서 수정 전의 데이터를 조회해 로직을 처리함으로써 데이터의 정합성이 깨지는 문제를 말한다.

 

자바 스프링 기반의 웹 애플리케이션은 기본적으로 멀티 스레드 환경에서 구동된다. 따라서 공유 자원에 대해 race condition이 발생하지 않도록 별도의 처리가 필요하다.

 

분산 락이란?

자바는 Synchronized 라는 키워드를 제공해 상호 배제 기능을 제공한다. 하지만 Sychronized는 하나의 프로세스 안에서만 상호 배제를 보장한다. 서버가 1대일 때는 문제가 없지만, 서버가 여러 대 일 경우에는 상호 배제를 보장할 수 없다. 일반적으로는 서버를 다중화하여 부하 분산하므로 Synchronized을 사용해도 동시성 문제가 발생할 수 있다.

 

이런 분산 환경에서 상호 배제를 구현하여 동시성 문제를 해결하기 위한 방법이 분산락이다. 분산 락을 구현하기 위해선 락(Lock)에 대한 정보를 '어딘가'에 공통적으로 보관하고 있어야 한다. 그리고 분산 환경에서 여러대의 서버들은 공통된 '어딘가'를 바라보며 자신이 임계 영역(Critical Section)에 접근할 수 있는지 확인한다. 분산 락을 통해 임계 영역에 하나의 스레드만 접근하도록 제어할 수 있다. '어딘가'로 활용되는 기술은 MySQL의 Named Lock, Redis, Zookeeper 등이 있다.

 

이번 포스팅에서는 Redis를 사용해 분산 락을 구현해보려 한다.

 

임계 영역 : 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원
상호 배제 : 한 프로세스가 임계 영역에 들어갔을 때 다른 프로세스는 들어갈 수 없어야 한다.

 

문제 상황 재현

재고 시스템을 구현해 동시성 문제 상황을 재현해보자. Entity, Service, Repository로 간단히 구성했다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.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 = this.quantity - quantity;
    }
}
public interface StockRepository extends JpaRepository<Stock, Long> {
}
@Service
@RequiredArgsConstructor
public class StockService {
    private final StockRepository stockRepository;

    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

동시성 이슈 발생

StockId가 1L 인 제품의 재고를 100개로 세팅하여 DB에 저장했다. ExecutorService를 통해 32개의 고정된 스레드풀을 생성하여 멀티 스레드 환경을 만들고, 100개의 스레드에서 재고를 1씩 감소시켰다.

 

@SpringBootTest
public class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void before() {
        Stock stock = new Stock(1L, 100L);
        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void after() {
        stockRepository.deleteAll();
    }

    @Test
    public void 동시에_100개_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        for(int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        Stock stock = stockRepository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isEqualTo(0L);
    }
}

 

 

100개의 재고를 100개의 스레드에서 1씩 감소시켰기에 재고는 0이 될거라 예상했지만, 96개의 재고가 남아있는 결과가 나왔다. 원인은 race condition이 일어났기 때문인다. 두 개 이상의 스레드가 공유 데이터에 접근할 수 있고, 공유 데이터를 동시에 변경하려 했기 때문에 예상과 다른 결과가 나왔다.

Executors.newFixedThreadPool(int nThreads)
스레드 풀에 생성된 스레드 개수만큼 작업을 처리한다. 위에선 32개 스레드 작업을 3번, 4개 스레드 작업을 1번 실행

 

표로 나타내면 아래와 같은 상황이다.

 

Thread-1 Stock Thread-2
select * from stock where id = 1 {id: 1L, quantity: 5}  
  {id: 1L, quantity: 5} select * from stock where id = 1
update set quantity = 4 from stock
where id = 1
{id: 1L, quantity: 4}  
  {id: 1L, quantity: 4} update set quantity = 4 from stock
where id = 1

 

Redis를 활용해 동시성 문제 해결

Redis란 key-value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비 관계형 인메모리 DBMS이다. Redis는 다양한 특징 중에서도 Single Threaded 한 특징 즉, 한 번에 하나의 명령만 처리할 수 있는 특징 때문에 동시성 문제를 해결하는데 많이 사용된다.

 

방법 1 : Lettuce

SETNX 명령어를 활용하여 분산락을 구현한다. Redis의 SETNX(SET if Not eXists) 명령은 Key에 대한 Value가 존재하지 않을 때만 값을 설정할 수 있는 명령이다. 

127.0.0.1:6379> setnx 1 lock
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 0
127.0.0.1:6379> del 1
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 1

 

Redis Lettuce 분산 락 실행 과정

 

SETNX을 사용한 분산락은 Spin Lock방식이므로 Lock 획득 재시도 로직을 개발자가 작성해 주어야 한다. 

 

  1. Thread-1이 Redis에 Key(1):Value(lock) 인 데이터를 SET 하려 한다.
  2. 처음에는 Redis에 Key가 1인 데이터가 없으므로 Redis에 저장 후 성공을 반환한다.
  3. Thread-2가 Redis에 Key(1):Value(lock) 인 데이터를 SET 하려 한다.
  4. Redis에는 이미 Key가 1인 데이터가 있으므로 실패를 반환한다.
  5. Thread-2는 Lock 획득에 실패했기 때문에 Lock 획득을 할 때까지 재시도 한다. (Spin Lock)

 

build.gradle의 dependencies에 아래 의존성을 추가한다.

Spring Data Redis를 이용하면 Lettuce 전략이 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

RedisLockRepository

@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_0900));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}

RedisTemplate 을 주입 받아서 Lock을 관리하는 RedisLockRepository 를 구현한다.

lock() 메소드는 setIfAbsent() 를 사용하여 SETNX 를 실행한다. 이때, Key는 Stock 엔티티의 ID로, Value는 lock 으로 설정한다. 세번째 파라미터는 Timeout 설정이다. unlock() 메소드는 Key에 대해 DEL 명령을 실행한다. 이를 통해 Lock을 해제 할 수 있다.

  

LettuceLockStockFacade

@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    /* StockId를 key로 사용, Reids의 SETNX 명령을 통해 lock 획득
    Redis에 key:value 저장에 성공하면 lock 획득
    key에 대한 값이 이미 존재해 저장에 실패하면 lock 획득할 때까지 재시도 (Spin Lock)
     */
     
    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);
        }
    }
}

로직 실행 전 후로 lock 획득과 해제를 해줘야 하므로 Facade 클래스를 생성한다.

while 문을 활용해 스핀 락을 구현했다. Lock을 획득할 때까지 재시도한다. 레디스 서버에 부하를 덜기 위해 반복마다 100ms 쉬어준다. Lock을 획득하면, 비즈니스 로직을 처리하고 Lock을 해제한다.

 

TEST

@Test
public void 동시에_100개_요청() throws InterruptedException {
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    for(int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                lettuceLockStockFacade.decrease(1L, 1L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    Stock stock = stockRepository.findById(1L).orElseThrow();
    assertThat(stock.getQuantity()).isEqualTo(0L);
}

 

재고 100개 세팅 후 100개의 스레드에서 1씩 감소 테스트를 다시 해보았다. 그 결과, 재고가 정상적으로 0개가 되었다 !

 

(장점) : 기본 제공되는 Redis Client인 Lettuce 만으로 간단히 구현할 수 있다.
(단점) : 스핀 락 방식으로 사용하여, 레디스 서버에 부하를 줄 수 있다.

 

방법 2 : Redisson

Redisson은 Pub-sub 기반으로 Lock 구현을 제공한다. Redis에서 SUBSCRIBE 명령으로 특정 채널(channel)을 구독할 수 있으며, PUBLISH 명령으로 특정 채널에 메시지를 발행할 수 있다. 직접 해보자. 아래는 ch1 이라는 채널을 구독한 모습이다.

127.0.0.1:6379> subscribe ch1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "ch1"
3) (integer) 1

터미널 창을 하나 더 띄워 아래와 같이 메시지를 발행해보자.

127.0.0.1:6379> publish ch1 helloworld
(integer) 1

다시 원래 터미널로 돌아가보자. 아래와 같이 메시지를 수신한 것을 확인할 수 있다.

1) "message"
2) "ch1"
3) "helloworld"

 

Redisson 분산 락 실행 과정

 

이 방식은 Channel을 통해 Lock 해제 여부를 알리므로, Spin Lock을 사용하지 않아도 된다.

Redisson은 락 획득 및 재시도 기능을 라이브러리에서 제공해준다.

  1. Channel 이 하나 있고, Thread-1이 Lock을 먼저 점유한 상태이다.
  2. Thread-2는 Channel을 구독한다.
  3. Thread-1이 Lock을 해제하면, Channel에 '락 획득을 시도 해도된다' 라는 메시지를 발행한다.
  4. 대기 중이던 Thread-2는 Lock 점유를 시도한다.

 

build.gradle의 dependencies에 아래 의존성을 추가한다.

implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.19.0'

RedissonLockStockFacade

@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final 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) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

redissonClient.getLock() 을 통해 락을 획득하고, tryLock() 을 통해 락 획득을 시도한다. 락 획득을 성공하면 임계 영역에 진입하여 비즈니스 로직을 실행하고, finally 블럭에서 unlock() 한다. 락 획득을 실패한 경우, 끊임없이 레디스 서버에 재확인하는것이 아니라 대기 상태로 들어가 메시지가 오기를 기다린다.

 

TEST

재고 100개 세팅 후 100개의 스레드에서 1씩 감소 테스트를 다시 해보았다. 그 결과, 재고가 정상적으로 0개가 되었다 !

 

(장점) : 스핀 락 방식이 아니므로 레디스 서버의 부하를 줄일 수 있다.
(단점) : 별도의 라이브러리를 사용해야 한다. 이로 인한 사용법 공부가 필요하다.

 

 

실무에서는 재시도가 필요하지 않은 Lock은 Lettuce 활용 (ex. 선착순 1명만 상품 구매 가능)

재시도가 필요한 Lock은 Redisson을 활용 (ex. 선착순 100명까지 상품 구매 가능)

 

코드는 아래 Repository에서 확인 가능합니다 :)

 

GitHub - twoosky/spring-lab: Spring boot 를 실험해보자

Spring boot 를 실험해보자. Contribute to twoosky/spring-lab development by creating an account on GitHub.

github.com

 

참고

https://dkswnkk.tistory.com/681

https://hudi.blog/distributed-lock-with-redis/

https://channel.io/ko/blog/distributedlock_2022_backend

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함