개요
회사 차세대 프로젝트에서 발송 요청 시 가결제 동시성 이슈로 인한 사용자 가결제 금액 - 잔액간 불일치 이슈가 발생했다.
발생한 가결제 이슈 프로세스를 정리하면 아래와 같다.
- 사용자가 2건의 60원짜리 가결제 요청
- P1: 가결제 금액 조회 ⇒ 0원
- P2: 가결제 금액 조회 ⇒ 0원
- P1: 가결제 처리 후 commit ⇒ 사용자 가결제 금액 60원
- P2: 가결제 처리 후 commit ⇒ 사용자 가결제 금액 120원
- 보유 금액 100원 < 가결제 금액 120원이 되어 유저 잔액(-20원) 조회 시 오류 발생
위 이슈는 공유 자원인 DB에 동시에 여러 프로세스가 접근하여 값을 수정하다보니 발생한 동시성 이슈인데, 해당 이슈 담당자는 이 이슈를 Redis를 활용한 분산락을 통하여 해결하였다. 이번 글에서는 DB 접근 동시성 이슈를 해결하기 위한 방안과 분산락, 그리고 Redis를 통해 어떻게 분산락을 구현하는지 정리한다.
솔루션 후보들
Syncronized
분산락에 대해 정리하기 이전에 해당 코드를 살펴보니 이미 관련 코드에는 syncronized 키워드가 활용되고 있었다. 우리 서비스에서 발송을 요청했을 때에는 준비(발,수신 번호, 내용 검증 등) → 가결제 → 발송 요청의 순서로 이루어지는데, 이 동작이 하나의 메서드로 묶여있고 해당 메서드에 syncronized 키워드가 적용되어 있다. 아래는 실제 코드와 비슷하게 가상으로 구현한 자바 예시 코드이다.
public syncronized sendRequest(SendRequestDto sendRequestDto) {
preprocess(sendRequestDto); // 준비(검증)
SendInfo sendInfo = makeOrder(sendRequestDto); // 가결제 및 정보 생성
publishQueue(sendInfo); // 발송 요청(queue publish)
}
syncronized 키워드는 이름에서부터 알 수 있듯이 멀티스레드 환경에서 여러 스레드가 하나의 공유자원(여기서는 sendRequest 메서드)에 동시 접근할 수 없도록 막는 역할을 하는 키워드이다.
만약 서버가 한 대라면 위의 키워드만으로 동시성 이슈를 해결할 수 있겠으나, 개발 환경이 아닌 실제 운영 환경에서는 서버를 여러 대 사용하기 때문에 각 서버 내부의 쓰레드간 동시성 이슈는 없을 수 있겠으나 서로 다른 서버에서 동작하는 프로세스들에 대한 처리는 되지 않았다.
따라서 발송 요청 메서드가 아닌 이슈가 발생하는 근본 이유인 데이터베이스 접근 자체에 동시성 제어를 걸어야 했고, 이를 위해 Lock을 활용할 수 있다.
Lettuce를 활용한 분산락
Lock이라 하면 데이터베이스에서 제공하는 Lock을 떠올릴 수 있지만, 개발자는 DB에 대한 권한도 없을뿐더러 굳이 DB의 Lock 기능을 사용하지 않고도 Lock을 구현할 수 있다. 현재 필요한 요구사항은 멀티프로세스 환경에서 가결제를 처리하는 프로세스가 단 하나만 될 수 있게 제한하는 것인데, 이는 현재 세션 등의 정보를 저장하기 위해 사용하는 Redis를 이용해 구현할 수 있다.
자바 진영의 대표적인 Redis Client로는 Jedis, Lettuce 등이 있는데 현재 프로젝트에도 import 되어있는 Lettuce를 사용해서 Lock을 구현할 수 있다.
void lockProcess() {
String lockKey = "lock";
try {
while(!tryLock(lockKey)) { // (2)
try {
Thread.sleep(50); // (3)
} catch(InterruptedException e) {
throw new RuntimeException(e);
}
// 가결제 진행
}
} finally {
unlock(lockKey); // (4)
}
}
boolean tryLock(String key) {
return command.setnx(key, "user_3"); // (1)
}
void unlock(String key) {
command.del(key);
}
- Lock을 획득하기 위해서는 Lock이 존재하는지 확인하는 작업과 존재하지 않으면 Lock을 획득하는 작업이 atomic하게 이루어 져야 한다. Lettuce에서는 setnx 명령어를 지원하는데 해당 명령어를 사용하면 Redis에 값이 존재하면 세팅하고 true를 리턴, 이미 존재하면 false를 리턴하기 때문에 요구사항을 만족한다.
- try 구문 내부에서 Lock을 획득할 때까지 무한정 Lock 획득을 시도한다.
- 무한정 Lock 획득 요청시 부하를 줄이기 위해 sleep
- Lock을 획득하여 작업을 한 뒤에는 반드시 Lock을 해제한다.
3번 유저의 가결제 프로세스에 접근하기 위한 Lock을 하나 만들어 모든 서버가 바라보는 Redis에 저장한 뒤, 최초 접근하는 단 하나의 프로세스(Server1)만 Lock을 획득하고 나머지 다른 프로세스는 unLock 시 까지 대기하도록 한다. Lock을 획득한 프로세스는 작업 종료 후 다시 Lock을 반환(unLock) 한다.
이 때 Lock을 획득하지 못한 Server2, Server3 은 계속해서 Redis로 Lock의 획득 여부를 반복 질의하게 되고 이렇게 락을 걸지 못하면 무한 루프를 돌며 계속 락을 얻으려고 시도하는 기법을 스핀락 이라고 한다.
위와 같이 스핀락을 구현하면 동시성 이슈는 해결할 수 있지만 그와 동시에 아래와 같은 새로운 이슈가 발생할 수 있다.
- Lock이 무한정 잠겨버릴 수 있다.
위와 같이 스핀락을 구현하고 Server1이 Lock을 획득하고 가결제 프로세스를 실행하고 있을 때, 해당 서버(프로세스)가 종료되는 경우 Server1에 의해 해당 Lock이 unLock 되지 않았으므로 영원히 잠기게 된다.
따라서 Lock을 점유중인 프로세스에 문제가 발생해도 Lock이 일정시간이 지난 뒤에 만료될 수 있도록 redis의 expire time을 활용하여 락의 만료를 구현해 주어야 한다. - Redis에 과도한 부하를 유발한다.
앞서 언급했던 세 서버 중 두 개의 서버는 Lock을 획득하지 못한 채 계속해서 Redis로 요청을 보내게 된다. Redis는 싱글 쓰레드로 동작하기 때문에 이러한 요청이 많을수록 과부하가 걸려 서비스에 지장을 줄 수 있다.
이를 해결하기 위해 Redis에 Lock을 확인할 때 일정시간 sleep을 하는 등의 조치를 취할 수 있지만, 계속해서 불필요한 부하가 걸리는 것은 피할 수 없다.
Redisson을 활용한 분산락
Redisson은 Jedis, Lettuce와 같이 자주 사용되지는 않지만 자바에서 활용할 수 있는 Redis Client중 하나이다. 해당 클라이언트는 앞의 두개와 다르게 Redis의 명령어를 직접 제공하지 않고, 독자적인 구현체의 형태로 제공한다(이래서 일반적인 상황에서 많이 안쓰이는듯..).
여기서 제공하는 구현체 중 Lock이라는 구현체도 존재하기 때문에 해당 구현체를 활용하면 쉽게 Lock을 구현할 수 있다. Redisson을 활용하면 위에서 언급되었던 문제점을 해결할 수 있다.
RLock rLock = redissonClient.getLock(key); // (1)
try {
boolean available = rLock.tryLock(5, 3, TimeUnit.SECONDS); // (2)
if (!available) { // (3)
return false;
}
// 가결제 진행
} catch (InterruptedException e) {
e.printStackTrace();
throw new InterruptedException();
} finally {
rLock.unlock(); // (4)
}
- key를 통해 RLock 인스턴스를 가져옴
- Lock 획득 시도(5초간 대기, 3초간 사용)
- 획득 실패 시 Lock을 subscribe하며 대기
- 작업 끝난 뒤 Lock 해제
문제 해결 1: Lock에 타임아웃이 구현되어 있다.
Lettuce에서 setnx 메소드를 활용해서 Lock을 획득했다면 Redisson에서는 tryLock이라는 메서드를 통해 Lock을 획득할 수 있다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
- waitTime: 락을 획득하기 위한 대기 시간
- leaseTime: 락을 임대하는 시간
- unit: 시간 단위
위 메서드를 사용하면 일정 시간(leaseTime)이 지난 뒤 Lock을 expire하기 때문에 Lock을 획득한 프로세스가 종료되어 unLock을 하지 못했다 하더라도 일정 시간 뒤부터는 다른 프로세스가 Lock을 획득할 수 있게 된다.
문제 해결 2: 스핀 락을 사용하지 않는다.
Lettuce와 다르게 Redisson에서는 스핀 락이 사용되지 않는다. 대신 Redis의 pubsub 기능을 활용하였는데, Lock을 획득하지 못한 프로세스들은 특정 채널을 구독(subscribe)하게 되고 해당 Lock이 unLock되면 그 시점에 Redis가 채널에 unLock 여부를 publish하여 다시 대기하던 프로세스들이 Lock을 요청할 수 있게 한다. 아래는 이 과정을 그림으로 표현한 예이다.
정리
위에서 정리한 바와 같이 Redisson을 활용하면 Lettuce를 썼을 때의 단점 없이 분산락을 구현할 수 있다. 이러한 분산락 적용은 예로 들었던 가결제 외에 다른 요청에도 쓰일 수 있는데, 이러한 경우 매번 비즈니스 로직에 분산락 로직을 넣는것은 중복 코드가 발생하고 번거로울 수 있다.
또한 Lock의 획득 과정과 Transaction 간의 순서도 신경써야 하는데 내가 진행하던 프로젝트에서는 이를 AOP를 통해 해결하고 있다.
정리한 내용 외에도 Redisson을 활용하여 분산락을 구현하는 데 고려해야할 사항과 코드 레벨의 내용이 더 있지만, 해당 글에서는 그러한 코드 레벨의 내용을 이해하기 위한 배경지식을 정리하는 데 중점을 두며 마무리한다.
'Java' 카테고리의 다른 글
자바 디자인 패턴(Design Pattern) (0) | 2024.10.06 |
---|---|
[Java] 버전 별 Map 선언 방법 (1) | 2024.09.27 |
자바에서 불변 객체(Immutable Object) 만들기 (0) | 2024.09.25 |
[Java] 자바에서 Optional의 올바른 사용법 (0) | 2024.09.24 |
[Java] 공통 적용 JsonDeserializer 만들어 보기 (4) | 2024.09.23 |
댓글