이전글
핀테크 프로젝트를 진행하면서 거래 관련 API에서 부하테스트를 진행하며 동시성 문제를 접하게 되었다.
예를 들어 1원 송금을 100회 동시에 진행했을 때 데이터의 정합성이 지켜지지 않아 31원이 들어와 있는 상황이 발생하였다.
이를 해결하기 위해 분산락을 적용한 과정에 대해 기록하고자 글을 작성하였다.
(락의 동작과정과 개념은 이전에 정리해둔 링크에서 확인하시면 글을 이해하는데에 더 좋을 것 같아요!)
타인의 계좌로 송금하는 기능에서 동시성 문제가 발생하였다.
이전 글에서 기록하였지만 가볍게 문제 상황에 대해 다시 한번 살펴보면 100번 동시에 송금을 진행 하였을 때의 결과이다.
2->1로 100번의 1원 송금이 성공하였지만 2 계좌 에서는 44원이 차감되고 1 계좌에서는 31원이 증가한 것을 볼 수 있다.
기존 코드를 살펴보자.
@Transactional
@Override
public TransactionDto transfer(TransactionTransferDto transactionTransferDto) {
User sender = userRepository.findById(transactionTransferDto.getSender())
.orElseThrow(() -> new BusinessException(USER_NOT_FOUND));
User receiver = userRepository.findById(transactionTransferDto.getReceiver())
.orElseThrow(() -> new BusinessException(USER_NOT_FOUND));
Account senderAccount = accountRepository.findByUser(sender)
.orElseThrow(() -> new BusinessException(ACCOUNT_NOT_EXIST));
Account receiverAccount = accountRepository.findByUser(receiver)
.orElseThrow(() -> new BusinessException(ACCOUNT_NOT_EXIST));
long amount = transactionTransferDto.getAmount();
senderAccount.updateBalance(-amount);
receiverAccount.updateBalance(amount);
Long balance = senderAccount.getBalance();
if (balance < 0) {
throw new BusinessException(BELOW_ZERO_BALANCE);
}
//... 거래내역 저장 로직 생략
}
그냥 러프하게 작성한 코드이다.
보내는 사람과 받는 사람의 계좌에서 금액을 변경 시키고 반영하는 식으로 작성하였다.
@Transactional
을 통하여 원자성을 보장하려고 했지만 이런식으로 작성한 코드는 동시성 문제를 전혀 고려하지 않은 것이라고 할 수 있다.
이전 상태를 기준으로 현재 상태를 변경하는 상황에서 발생하는 동시성 문제
위 문제에서 발생하는 동시성 문제이다.
코드를 살펴보면 이전값을 기준으로 잔액을 업데이트 시키는 과정에서 공유자원에 여러 쓰레드가 동시에 접근하여서 경쟁조건이 발생하게 된다.
따라서 공유자원에 대해 경쟁 상태가 발생하지 않도록 별도의 처리가 필요하다.
나는 이 문제를 풀기 위해 분산락을 도입하였다.
경쟁 상황(Race Condition) 이 발생할때, 하나의 공유자원에 접근할때 데이터에 결함이 발생하지 않도록 원자성(atomic) 을 보장하는 기법
위 문제를 해결하기 위해 분산락 이외에 낙관락, 비관락, DB락 .. 등 다양한 방법이 존재하겠지만
Redis의 분산락을 선택한 이유는 다음과 같다.
이러한 이유로 락에 대한 정보를 Redis에 저장하고 분산된 서버들은 공통된 곳(Redis)를 바라보며 자신의 임계영역에 접근할 수 있도록 하였다.
분산락을 구현하기 위해서 Redis 는 RedLock 이라는 알고리즘을 제안하며, 3가지 특성을 보장해야 한다고 말하고있다.
- 오직 한 순간에 하나의 작업자만이 락(lock) 을 걸 수 있다.
- 락 이후, 어떠한 문제로 인해 락을 풀지 못하고, 종료된 경우라도 다른 작업자가 락을 획득할 수 있어야한다.
- Redis 노드가 작동하는한, 모든 작업자는 락을 걸고 해체할 수 있어야한다.
Redis로 분산락을 구현하는 방법은 크게 Lettuce
의 SETNX
를 이용하는 방법과 Redission
을 이용하는 방식이 있다.
Redis 클라이언트 라이브러리중 하나로 분산락을 구현하기 위해서 SETNX
명령어를 사용한다.
이 명령어는 키가 존재하지 않을 때 값을 설정하고, 키가 있을때는 아무것도 하지 않는다.
스핀락 방식으로 구현을 하고 retry, timeout과 같은 기능을 직접 구현해야 한다는 단점이 있다.
-> 스핀락으로 인해 Redis에 부담을 주고, 구현이 복잡해지게 된다.
Lettuce
와는 다르게 Pub/Sub 방식을 이용하여 락을 subscribe하고 락이 해제되었을때 신호를 받아 락 획득을 하게 된다.
또한 분산락을 직접적으로 지원(timeout, retry와 같은 기능을 제공)하기 때문에 구현에 쉽다.
이러한 이유로 Redission을 사용하게 되었다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.17.0'
redis:
host: 127.0.0.1
port: 6379
password: password
version: '3'
services:
redis:
container_name: redis
image: redis:7.2-rc3
ports:
- "6379:6379"
command: redis-server
로컬 환경에서 docker-compose 를 통해 Redis 서버를 띄웠다.
@Configuration
public class RedissionConfig {
@Value(value = "${redis.host}")
private String redisHost;
@Value(value = "${redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
@Transactional
@Override
public TransactionDto transfer(TransactionTransferDto transactionTransferDto) {
RLock lock = redissionClient.getLock("{key}");
boolean isLocked = false;
try {
//락 획득 요청
isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
if (!isLocked) {
throw new BusinessException(DISTRIBUTED_LOCK_FAILED);
}
User sender = userRepository.findById(transactionTransferDto.getSender())
.orElseThrow(() -> new BusinessException(USER_NOT_FOUND));
User receiver = userRepository.findById(transactionTransferDto.getReceiver())
.orElseThrow(() -> new BusinessException(USER_NOT_FOUND));
Account senderAccount = accountRepository.findByUser(sender)
.orElseThrow(() -> new BusinessException(ACCOUNT_NOT_EXIST));
Account receiverAccount = accountRepository.findByUser(receiver)
.orElseThrow(() -> new BusinessException(ACCOUNT_NOT_EXIST));
long amount = transactionTransferDto.getAmount();
senderAccount.updateBalance(-amount);
receiverAccount.updateBalance(amount);
Long balance = senderAccount.getBalance();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); /
throw new BusinessException(INTERRUPTED_THREAD);
} finally {
//락 해제
if (isLocked) {
lock.unlock();
}
}
}
이전코드와 비교해서 가볍게 분산락만 적용해본 코드이다.
코드에 대해 위에서부터 설명을 하자면 다음과 같다.
1. 락 획득 시도
redissionClient.getLock("{key}");
을 통해 특정 키에 대한 락(RLock) 객체를 가져온다.
lock.tryLock(2, 3, TimeUnit.SECONDS);
는 최대 2초 동안 락 획득을 시도하며, 락을 획득하면 3초 동안 락을 유지한다.
이 메소드는 락을 성공적으로 획득하면 true를 반환하고, 그렇지 못하면 false를 반환한다.
2. 로직 실행
3. 예외 처리
InterruptedException이 발생한 경우 현재 스레드의 인터럽트 상태를 설정하고 비즈니스 예외를 발생시킨다.
4. 락 해제
락 획득에 성공했는지 여부(isLocked)를 확인하고, 성공한 경우에만 lock.unlock();
을 호출하여 락을 해제한다.
다음과 같이 코드를 수정한다음 이전과 동일하게 동시성 테스트를 진행해보았더니 1번과 2번계좌의 금액이 정상적으로 반영 되어있다!
분산환경에서 동시성 문제 해결을 위해 분산 락을 적용해보면서 Redission
을 사용하여 복잡한 락 관리 로직을 직접 구현할 필요 없이 손쉽게 구현 가능했고 비즈니스 로직에 집중하면서 분산락을 적용할 수 있었다.
이렇게 분산락을 러프하게 적용해서 동시성 문제가 어느정도는 해결되지만 다음과 같은 문제점이 있다.
1. 분산락 관련 로직이 추가되면서 비즈니스 로직이 지저분해졌다.
2. 락의 이름과 대기시간 유효시간을 고정해두었다.
3. 모든 사람이 공통된 락을 가지고 경쟁하게 된다.
송금 서비스 뿐 아니라 다른 서비스에서도 공통적으로 사용할 수 있게 관리할 필요가 있을 것 같다는 생각이든다.
https://www.baeldung.com/redis-redisson
https://helloworld.kurly.com/blog/distributed-redisson-lock/
https://velog.io/@msung99/Redis-%EB%B6%84%EC%82%B0-%EB%9D%BD%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4-race-condition-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0