다음과 같은 개발 요구사항을 해결하고자 한다.
계좌이체를 구현하라. 단 동시에 여러 계좌에서 입출금이 발생할 수 있으니 이런 문제를 해결하도록 구현하라.
단순하게 계좌이체를 구현하면 데이터베이스에서 입금계좌와 출금계좌에 대한 정보를 가져와서 입금계좌의 잔고를 이체액만큼 감소시키고 출금계좌의 잔고를 그만큼 증가시키면된다. 세상 모든 개발이 이렇겐 간단하면 이보다 좋을 순 없겠지만 계좌이체는 그렇게 단순하지 않다. 내가 이체하는 순간에 누군가 나에게 이체를 한다면 예상한 결과가 나오지 않을 수 있다. 더욱이 돈과 관련된 아주 중요한 비즈니스 로직에서 이런 상황이 발생한다면 큰 사고로 이어질 수 있다. 이번 포스팅에서는 이런 상황을 Redis를 이용해서 해결하는 과정에 대해서 소개하고자한다.
Java의 특징을 알아보면 멀티스레드라는 말을 흔히 볼 수 있다. 비유해서 설명하면 작업을 하는 일꾼이 여럿이란 뜻이다.
각 스레드는 본인들만의 작업 영역이 나누어져 있고 스레드간에 작업내용을 공유하지 않는다. 바로 여기서 동시성 문제가 발생한다. 한 스레드는 A계좌에서 B계좌로 5000원을 이체하기 위해 DB로 부터 A와 B 계좌의 잔고정보를 가져온다고하자. 잔고에 이체내역을 반영하고 다시 DB로 데이터를 보낸다고하자. 하지만 다른 스레드에서는 C가 A로 10000원을 이체하는 작업이 진행된다고 하자. 이 작업이 정상적으로 이루어지면 A계좌는 잔고가 5천원이 늘어나야한다. 하지만 서로 다른 두 스레드는 작업 내용을 공유하지 않기 때문에 서로의 이체사실을 알 수 없다. 따라서 둘 중 하나의 스레드의 내용만 정상적으로 DB에 반영될 수 있다.이런 문제를 해결하기 위해서 몇가지 방법이 존재한다. 간단하게만 알아보자.
다음과 같이 특정 메소드를 Synchronized로 선언하거나 block으로 묶으면 오직 하나의 스레드만 해당 구역에 접근할 수 있다. 이러면 동시에 한 메소드를 여러 스레드가 접근하여 작업할 수 없기 때문에 동시성 이슈가 발생하지 않는다. 하지만 이건 단일 서버로 구성된 서비스에만 해당되는 이야기이다.
public synchronized void methodName() {
// 메소드 구현
}
public void methodName() {
synchronized(this) {
// 동기화할 영역 구현
}
}
우리가 Scale Up으로 서버를 확장한다고 하면 각 서버에서 스레드 하나만 구역에 접근가능하지 여러 서버의 모든 스레드 중 하나의 스레드만 접근하게 제한하는 것은 아니어서 근본적으로 동시성 문제를 해결하지 못한다.
만약 단일 서버로만 구성된 서비스라고 해도 메소드에 오직 하나의 스레드만 접근 가능하다면 작업을 요청받은 다른 스레드들은 waiting 상태가 지속되며 다른 작업을 못한채 대기만 하게 된다. 이는 자바의 멀티 스레드 장점을 누리지 못하고 성능저하의 원인이 된다.
이런 waiting 문제를 해결하기 위해 ReentrantLock, Atomic Class들이 존재하지만 여전히 Scale Up 환경에서 이 문제를 해결하지 못한다.
Redis는 대표적인 in-memory DB이며 (key, value) 형태로 데이터를 저장하는 NoSQL이다. 주로 DB와의 I/O 응답속도 문제 때문에 캐시 혹은 세션 저장소로 활용하는데 우리는 Redis의 구조적인 특징을 이용하여 동시성 문제를 해결하고자한다. Redis에는 여러개의 스레드가 존재하지만 실제로 외부에서 입력된 명령어를 처리하는 스레드는 단일 스레드로 동작한다. 이 말은 한번에 여러 명령어를 동시에 처리할 수 없다는 의미이다. 물론 Lua 스크립트나 파이프라인을 이용해 한번에 여러 명령어를 전송할 수는 있지만 명령어는 하나씩만 처리된다.
자바의 멀티스레드와는 다르게 Redis의 싱글스레드 특성으로 인해 동시성을 보장해줄 수 있다. 하나의 데이터를 동시에 여러 스레드가 작업하지 않기 때문이다. 이런 특징으로 Redis에 특정 (key, value)값이 존재하지 않으면 Lock을 획득할 수 있고 (key, value)값이 존재하면 사라질 때까지 대기하는 방식으로 동시성 문제를 해결할 수 있다.
또 Redis는 다중서버 환경에서도 동시성 문제를 해결할 수 있다. 여러 서버가 한대의 Redis 서버만 참조하면 Redis가 여러서버의 Lock 획득을 조절할 수 있기 때문이다.
스프링부트 Redis에서는 기본적으로 Lettuce라는 Redis 클라이언트를 제공한다. 또 RedisTemplate이라는 유용한 Redis 유틸리티 클래스도 제공해준다. 기본적으로 스프링 빈에 등록되어 있으므로 주입해서 사용하면 된다. 전체코드를 한 번 살펴보자. 락을 획득하는 lock메소드, lock을 해제하는 unlock메소드, 그리고 lock을 획득하기 위해 무한정 대기하는 것을 막는 lockLimitTry메소드 총 3가지 메소드로 TransferLockService를 구현했다.
@Service
@RequiredArgsConstructor
public class TransferLockService {
private final RedisTemplate<String, Object> redisTemplate;
private final String lockKey = "transferLock";
/**
* redis에 lockKey에서 참조한 key로 "locked"라는 value를 추가합니다. 이 때 해당 key에 존재하는 값이 있다면 false를 반환합니다. key에
* 존재하는 값이 없어서 새로운 (key, value)를 추가한다면 lock을 획득해서 true를 반환합니다.
*
* @return lock 획득 여부
*/
public boolean lock() {
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked");
return success != null && success;
}
/**
* 획득한 lock을 해제합니다. redis에 생성한 (key, value)를 삭제하여 lock을 해제합니다.
*/
public void unLock() {
redisTemplate.delete(lockKey);
}
/**
* maxRetry만큼 락획득을 시도합니다. 락 획득 간에 50ms의 시간텀을 두었고, maxRetry만큼 시도했는데 lock을 획득하지 못한다면
* LockTimeOutException을 던집니다.
*
* @param maxRetry lock 획득 최대 시도횟수
*/
public void lockLimitTry(int maxRetry) {
int retry = 0;
while (!lock()) {
if (++retry == maxRetry) {
throw new LockTimeOutException(TRANSFER_SERVER_ERROR.getErrorResponse());
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public boolean lock() {
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked");
return success != null && success;
}
RedisTemplate에서 setIfAbsent 명령어는 redis 명령어중 SETNX명령어를 추상화한 것이다. key가 존재하지 않는 경우에만 key에 value를 설정한다. 즉 key가 존재한다는 뜻은 다른 스레드가 이미 Lock을 획득했다는 의미이고 key가 존재하지 않는다는 뜻은 아무도 Lock을 획득하지 않았으므로 스레드가 Lock을 획득할 수 있다는 것을 의미한다. return값으로 Lock획득 성공여부를 반환한다.
public void unLock() {
redisTemplate.delete(lockKey);
}
lock을 획득한 후 다른 스레드가 lock에 접근하기 위해서는 (key, value) 값을 삭제해야한다. 따라서 delete 메소드로 lockKey에 해당하는 (key, value) 값을 삭제한다.
ublic void lockLimitTry(int maxRetry) {
int retry = 0;
while (!lock()) {
if (++retry == maxRetry) {
throw new LockTimeOutException(TRANSFER_SERVER_ERROR.getErrorResponse());
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
maxRetry만큼 Lock 획득을 시도하는 메소드이다. 만약 Lock을 획득한 스레드가 반환하지 않는다면 Lock을 획득하기 위해 대기하는 스레드들이 무한정 대기할 것이다. 이런 데드락을 방지하기 위해 최디 Lock 획득시도횟수를 제한한다. Lock 획득 시도 사이에 50ms의 텀을 두어서 동시성 실제로 재시도가 효과적이게 구현하였다.
public void transfer() {
transferLockService.lockLimitTry(5);
try {
// 이체관련 로직
} finally {
transferLockService.unLock();
// 이체 완료 후 로직
}
}
다음과 같이 lockLimitTry의 파라미터 값인 5만큼 락획득을 시도하여 락을 획득한 후 try 메소드로 진입하여 이체를 진행하면된다.
Lock을 획득하기 위해서 지속적으로 Redis에게 명령어를 전송해야한다. 이는 불필요하게 반복적인 I/O를 발생시키므로 message Queue 형태로 요청을 Redis에서 처리하는 방법을 알아봐야 할 것 같다.