개요
'따닥' 으로 발생할 수 있는 동시성 문제를 해결하는 방법을 알아보고, 몇 가지 시나리오에 대한 실험으로 이를 검증해보겠습니다.
'따닥'이란?
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 패턴을 이용해 잠금을 구현합니다.
Pub/Sub 패턴은 Sender가 메시지를 발행하면 이 토픽을 구독 중인 Receiver들이 메시지를 수신받는 패턴입니다.
Redisson은 락을 점유 중인 쓰레드가 이를 반환할 때 메시지를 발행하여 대기 중인 쓰레드가 락을 얻을 수 있게 합니다.
대기 중인 쓰레드가 계속해서 재시도를 보내는 Spin 락 방식이 비해 Redis I/O가 크게 줄어들어 성능이 좋습니다.
Redisson 역시 내부적으로는 setnx를 이용하고 부분적으로는 spin lock 방식을 사용하기는 합니다.
이에 대한 자세한 내용은 아래 블로그에 자세히 설명되어 있습니다.
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 |
---|