실험실

'따닥' 으로 인한 동시성 문제 해결 해보기

kiseoky 2024. 4. 9. 14:42

개요

'따닥' 으로 발생할 수 있는 동시성 문제를 해결하는 방법을 알아보고, 몇 가지 시나리오에 대한 실험으로 이를 검증해보겠습니다.

'따닥'이란?

API 요청이 불필요하게 연속적으로 발생하는 상황을 이야기합니다. 네트워크 지연이나 버튼을 '따닥' 클릭하는 경우 발생할 수 있습니다.

이 문제는 머리 아픈 동시성 문제로 이어질 수 있어서 적절한 조치가 필요합니다.

이 글에서는 서버측에서 따닥으로 인한 동시성 문제를 해결하는 방법을 알아보겠습니다.

'따닥'

문제 정의

'따닥'이 정확히 어떤 과정으로 문제를 발생시키는지, 그리고 해결해야 할 부분은 무엇인지 살펴보겠습니다.

동시성 문제를 고려하지 않은, 곧 파산할 위기인 은행을 예로 들겠습니다.

1. 사용자의 계좌에 1000원이 있습니다.
2. 사용자가 100원 출금 요청을 보냅니다. 그리고 이 때 '따닥'이 발생하여 2개의 요청이 서버에 도착합니다.

 

결과는 어떻게 될까요? 

대부분의 상황에서 '사용자는 200원을 갖고 계좌에는 900원이 남는다.'가 정답입니다.

 

왜 이렇게 되는지, 이 상황을 간단한 코드로 재현해 확인해보겠습니다.

(이 글의 모든 코드는 맨 아래 첨부된 Github Repo에서 확인하실 수 있습니다.)

 

출금 로직 코드

먼저 정상적으로 동작하는 출금 로직을 작성하고 성공을 확인해 줍니다.

더보기
// AccountService.java
@Transactional
public void withDraw(Long id, Long amount) {
    Account account = accountRepository.findById(id).orElseThrow(() -> new RuntimeException("Account not found"));
    account.withDraw(amount);
}

// AccountServiceTest.java
@Test
void 기본_출금_테스트(){
    // given
    Account account = accountRepository.save(new Account("1", 1000L));

    // when
    accountService.withDraw(account.getId(), 100L);

    // then
    Account afterAccount = accountService.getAccount(account.getId());
    assertEquals(900, afterAccount.getBalance());
}

시나리오 테스트 코드

@Test
void 동시성_테스트_요청_2개() throws InterruptedException {
    // given
    Account account = accountRepository.save(new Account("1", 1000L));
    int requestCount = 2;
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    CountDownLatch countDownLatch = new CountDownLatch(requestCount);

    // when
    for (int i = 0; i < requestCount; i++) {
        executorService.submit(() -> {
            try {
                accountService.withDraw(account.getId(), 100L);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    // then
    Account afterAccount = accountService.getAccount(account.getId());
    assertEquals(800, afterAccount.getBalance());
}

 

 

100원 출금 요청을 동시에 2개 처리하고 결과를 확인하는 테스트입니다.

실패!

그림으로 살펴보기

이해를 위해 간단한 그림으로 동작을 표현 해보겠습니다.

 

1. 요청1 Thread가 잔액을 조회합니다. -> 잔액 1000원
2. 요청2 Thread가 잔액을 조회합니다. -> 잔액 1000원
3. 요청1 Thread가 100원을 출금합니다. -> 잔액 900원
4. 요청2 Thread가 100원을 출금합니다. -> 잔액 900원

 

결과적으로 은행은 100원을 손해봤네요. 😢

 

이 시나리오에서 서버는 요청1을 처리하는 동안 다른 요청을 `전부 버리거나 순차적으로 수행`함으로써 '따닥'으로 인한 동시성 문제를 해결할 수 있습니다.

 

그리고 사실 동시성 문제는 '따닥'이 아니더라도 발생합니다. `동시성 문제를 고려하지 않은` 재고 시스템에 사용자 100명이 동시에 주문을 요청하면 어떤 일이 발생할까요?

예상하셨겠지만 '따닥' 예제와 같은 과정으로 동시성 문제가 발생합니다.

동시성 문제를 해결하는 방법

동시성 문제는 Lock(잠금)을 통해 해결할 수 있습니다. 락을 이용하면 한 쓰레드가 자원을 점유하고 동작을 수행하는 동안, 다른 쓰레드를 끝내거나 대기시켜 요청을 순차적으로 처리할 수 있습니다. 

 

서버에서 락을 사용할 수 있는 선택지는 다음과 같습니다.

1. 애플리케이션 Level 락
2. DB Named 락
3. 낙관적 락, 비관적 락
4. Redis 락

이 중에서 Redis 락을 이용해 문제를 해결해 보겠습니다.


Redis 락을 선택한 이유

웹 서버 scale-out과 성능을 고려하여 Redis 락을 선택했습니다.

1. 애플리케이션 Level 락은 웹 서버가 한 대인 경우에만 정상적으로 동작합니다. 일반적으로 웹 서버는 scale-out을 고려해야 하므로 선택하지 않았습니다.
2. DB Named Lock은 Redis보다 느리고 신경써야 할 부분이 많아 선택하지 않았습니다. 하지만 대규모 트래픽을 고려하지 않아도 되고 Redis 도입이 부담스럽다면 선택할 수 있습니다.
3. 낙관적 락이나 비관적 락을 사용해도 위 예제의 동시성 문제를 해결할 수 있습니다. 하지만 이 둘은 Database row에 대한 잠금이므로 요청 쓰레드에 대한 잠금을 수행할 수 없어서 선택하지 않았습니다.

상황에 따라 DB Lock을 사용하거나 함께 사용하는 경우도 있습니다.
  - Redis 락과 비관적 락을 함께 사용한 경우(토스 하루이자)
  - Redis 락 대신 DB Named 락을 사용한 경우

Redis Lock

Redis Lock은 Redis의 setnx 함수를 이용해 구현합니다. Redis는 싱글 스레드로 실행되기 때문에 setnx를 활용하면 요청을 순차적으로 하나씩 처리하도록 구현하여 동시성 문제를 해결할 수 있습니다.

setnx는 "SET if Not eXists"의 준말로, key가 존재하지 않으면 key에 대한 value를 set을 수행하고, 이미 key가 존재한다면 아무 동작도 수행하지 않는다.
출처: https://redis.io/commands/setnx/

 

다음으로 이를 이용해 Redis 락을 구현한 Lettuce와 Redisson 라이브러리의 내부 동작과 특징을 비교해 보겠습니다.

Lettuce

Lettuce는 Spin 락 방법을 이용합니다. Spin Lock은 Lock을 얻지 못하는 동안 계속해서 Lock을 요청하는 방법입니다.

이해를 위해 Spin 락을 구현하는 간단한 코드를 살펴보겠습니다.

while (true) {
    if (!lock(key)) {
        try {
            // 100ms 마다 lock 요청
            Thread.sleep(100);
        } catch (InterruptedException e) {
          // Exception 처리
        }
    } else {
        // 락 획득 성공!
        break;
    }
}
try {
    // 락 획득 성공 시 동작
    doSomething();
} finally {
    unlock(key);
}

 

lock을 시도하고, 실패하면 재시도한다. lock을 얻은 후에는 원하는 동작을 수행하고 key를 unlock한다.

 

여기서 lock은 내부적으로 위에서 말한 redis setnx를 이용합니다.

lock을 재시도 할 때마다 Redis I/O가 발생하기 때문에 후술할 redisson에 비해 성능이 안좋고 잠금에 대한 timeout을 설정할 수 없다는 단점이 있습니다.

Redisson

Redisson는 Redis의 Pub/Sub 패턴을 이용해 잠금을 구현합니다.

출처:&nbsp;https://ably.com/blog/pub-sub-pattern-examples

 

Pub/Sub 패턴은 Sender가 메시지를 발행하면 이 토픽을 구독 중인 Receiver들이 메시지를 수신받는 패턴입니다.

 

Redisson은 락을 점유 중인 쓰레드가 이를 반환할 때 메시지를 발행하여 대기 중인 쓰레드가 락을 얻을 수 있게 합니다. 

대기 중인 쓰레드가 계속해서 재시도를 보내는 Spin 락 방식이 비해 Redis I/O가 크게 줄어들어 성능이 좋습니다.

 

Redisson 역시 내부적으로는 setnx를 이용하고 부분적으로는 spin lock 방식을 사용하기는 합니다.

이에 대한 자세한 내용은 아래 블로그에 자세히 설명되어 있습니다.

- Redisson trylock 내부로직 살펴보기

 

redisson trylock 내부로직 살펴보기 | Incheol's TECH BLOG

Last updated 5 months ago

incheol-jung.gitbook.io

 

 

 

대규모 트래픽을 가정할 때 Spin 락 방식은 예상치 못한 병목을 발생시킬 수 있기 때문에

두 라이브러리 중 Redisson을 사용해 문제를 해결해 보겠습니다. 

동시성 문제를 해결한 코드

@Component
@RequiredArgsConstructor
public class RedissonLockAccountFacade {
    private final RedissonClient redissonClient;
    private final AccountService accountService;

    public void withDraw(Long id, Long amount) {
        RLock lock = redissonClient.getLock("account:" + id);
        try{
            boolean lockAvailable = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!lockAvailable) {
                throw new RuntimeException("Locking failed");
            }
            accountService.withDraw(id, amount);
        }
        catch (InterruptedException e){
            throw new RuntimeException("Locking failed");
        }
        finally {
            lock.unlock();
        }
    }
}

 

테스트 성공~!

Facade 패턴을 사용했습니다.
@Transactional 함수 내부에서 Redis 락을 적용하면 Redis 락이 끝난 뒤에 Transaction이 commit 되기 때문에 commit 시점에 동시성 문제가 생길 수 있습니다. Facade 패턴이나 AOP를 사용하면 이를 방지할 수 있습니다.

 

 

Redis 락 사용 시 주의할 점

- Transaction commit이 Redis 락 외부에서 일어나면 정상적으로 동작하지 않습니다.

  - Facade, AOP를 이용해 이를 해결해야 합니다.

- expire time이 Redis 락 내부 수행 시간보다 짧은 경우에도 문제가 발생할 수 있으니 적절히 조정해줘야 합니다.

  - 분산 환경 속에서 ‘따닥’을 외치다 - 와디즈 블로그

 

분산 환경 속에서 '따닥'을 외치다 - 와디즈 블로그

와디즈에는 간편 카드 등록을 할 수 있어요. 오직 1개만 등록할 수 있는데 간헐적으로 중복으로 등록되는 버그, '따닥 이슈'가 발생했죠. 레디스 분산 락을 걸어 빈도수를 제로에 가깝게 줄였지

blog.wadiz.kr

 

'실험실' 카테고리의 다른 글

AWS API Gateway 배포 자동화 해보기  (1) 2024.03.28