동시성 문제 해결하기2 - 네임드 락(Named Lock) & Redis를 활용한 분산 락(Distributed lock)

손효재·2023년 3월 30일
2

Github : 동시성 문제 Github 코드

여러 서버에서 동시성 문제를 해결하기 위해 Version을 활용한 낙관적 락과 DB Lock을 사용한 비관적 락을 알아봤다.
이전글 : 상품 주문 동시성 문제 해결하기 - 낙관적 락 & 비관적 락
하지만, DB도 분산된 환경에서 동시성 문제를 해결하기 위해서 어떻게 해야할까?

Named Lock

테이블이나 레코드, 데이터베이스 객체가 아닌 사용자가 지정한 문자열에 대해 락을 획득하고 반납하는 잠금으로, 한 세션이 Lock을 획득한다면, 다른 세션은 해당 세션이 Lock을 해제한 이후 획득할 수 있다. Lock에 이름을 지정하여 어플리케이션 단에서 제어가 가능하다.

Named Lock은 Redis를 사용하기 위한 인프라 구축, 유지보수 비용을 발생하지 않고, MySQL 을 사용해 분산 락을 구현할 수 있다. MySQL 에서는 getLock()을 통해 획득, releaseLock()으로 해지할 수 있다.

단점으로는 Lock이 자동으로 해제되지 않기 때문에, 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제하는 등 락의 획득,반납에 대한 로직을 철저하게 구현해야한다.

또한, 일시적인 락의 정보가 DB에 저장되고, 락을 획득,반납하는 과정에서 DB에 불필요한 부하가 있을 수 있으며, 락과 비즈니스 로직의 트랜잭션을 분리할 필요가 있다.

락 획득하기(GET_LOCK)

@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);

GET_LOCK(str, timeout)

  • 입력받은 이름(str)으로 timeout 초 동안 잠금 획득을 시도한다.
    timeout에 음수를 입력하면 잠금을 획득할 때 까지 무한대로 대기하게 된다.
  • 한 session에서 잠금을 유지하고 있는 동안 다른 session에서 동일한 이름의 잠금을 획득할 수 없다.
  • GET_LOCK을 이용하여 획득한 lock은 Transaction 이 커밋되거나 롤백되어도 해제되지 않는다.
  • GET_LOCK의 결과값은 1(성공), 0(실패), null(에러발생) 이다

MySQL 5.7 미만 : 동시에 하나의 잠금만 획득 가능, 잠금 이름 글자수 무제한
MySQL 5.7 이상 : 동시에 여러개 잠금 획득 가능, 잠금 이름 글자수 60자 제한

락 반납하기(RELEASE_LOCK)

@Query(value = "select release_lock(:key, key)", nativeQuery = true)
void releaseLock(String key);

RELEASE_LOCK(str)

입력받은 이름(str)의 잠금을 해제한다. RELEASE_LOCK()의 결과값은 1(잠금해제 성공), 0(현재 스레드에서 획득한 잠금이 아님), null(잠금 없음) 을 반환한다.

해당 Lock은 스레드가 종료된다고해서 자동으로 락이 반납되지 않기 때문에 반드시 반납해야 한다!

관련된 다른 함수들

RELEASEALLLOCKS() : 현재 세션에서 유지되고 있는 모든 잠금을 해제하고 해제한 잠금 갯수를 반환
ISFREELOCK(str) : 입력한 이름(str)에 해당하는 잠금이 획득 가능한지 확인한다.
ISUSEDLOCK(str) : 입력한 이름(str)의 잠금이 사용중인지 확인한다.

@Transactional 분리가 안된다

네임드 락을 위해 try 구문 내에서 Lcok을 얻고 상품 구매로직을 수행한 후, Lock을 반환하는 로직을 구현하면서 동시성 문제가 해결되지 않는 문제가 있었다.
같은 클래스 내에서 @Transactional 분리와 관련된 내용으로 추후 정리할 블로그 내용을 참고하자

아래와 같이 네임드 락을 획득하고 상품을 구매하는 로직을 구현하여 동시성 문제를 해결할 수 있다.

ItemService 클래스

// 핵심 코드만 작성
public class ItemService {
		// 부모 트랜잭션과 분리
		@Transactional(propagation = Propagation.REQUIRES_NEW)
		public void buyItem(Long id, Long quantity) {
		    Item item = itemRepository.findById(id)
		            .orElseThrow(() -> new ItemNotExistException("아이템이 존재하지 않습니다"));
		    item.decreaseStock(quantity);
		}
}

NamedLockItemService 클래스

// 핵심 코드만 작성
public class NamedLockItemService {
		@Transactional
		public void namedLockBuyItem(Long id, int timeoutSeconds, Long quantity) {
		    try {
		        // NamedLock 획득
		        lockRepository.getLock(id.toString(), timeoutSeconds);
		        itemService.buyItem(id, quantity);
		    } finally {
		        // NamedLock 해제
		        lockRepository.releaseLock(id.toString());
		    }
		}
}

100개의 동시 요청 테스트가 통과하면서 GET_LOCK, RELEASE_LOCK 쿼리가 나간다.

MySQL query 로 동시성 제어를 한다면, SELECT ~ FOR UPDATE로 구현할 수 없을까?

SELECT ~ FOR UPDATE를 실행하면 특정 세션이 데이터를 수정 할 때까지 LOCK이 걸려 다른 세션이 데이터에 접근할 수 없다. 따라서 다른 세션은 해당 데이터가 수정될 때까지 계속 대기해야하는데, 이로 인해 무한 루프에 빠질 가능성이 있다.

Named Lock은 GET_LOCK에서 쉽게 설정할 수 있는 timeout 기능이 있는 반면에, select for update는 timeout 설정이 까다로워 Named Lock의 구현이 더 쉽다고 한다.

Redis를 활용한 분산 락

분산 락을 구현하기 위해 Lock에 대한 정보를 Redis에 보관하고, 분산 환경의 서버는 공통된 Redis를 통해 임계 영역(critical section)에 접근할 수 있는지 확인한다.

분산 락을 구현하기 위해 Java Redis 클라이언트인 Lettuce와 Redisson을 알아보자.

Lettuce

Setnx 명령어를 활용하여 분산락을 구현한다. Setnx는 Lock을 획득하려는 스레드가 Lock 획득 가능여부의 확인을 반복적으로 시도하는 스핀 락(Spin Lock) 방식이다.

Redis Lock 획득 & 삭제 로직

@Component
@RequiredArgsConstructor
public class RedisLettuceStructure {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean generateLock(final Long key, int timeout) {
        return redisTemplate
                .opsForValue()
                //setnx 명령어 - key(key) value("lock")
                .setIfAbsent(key.toString(), "lock", Duration.ofMillis(timeout));
    }

    public Boolean deleteLock(final Long key) {
        return redisTemplate.delete(key.toString());
    }
}

Redis Lettuce 방식

@Component
@RequiredArgsConstructor
public class RedisLettuceService {

    private final static int TIME_OUT = 3000;
    private final RedisLettuceStructure redisLettuceStructure;
    private final ItemService itemService;

    public void buyITem(final Long key, final Long quantity) throws InterruptedException {
        // SpinLock 방식
        while (!redisLettuceStructure.generateLock(key, TIME_OUT)) {
            // SpinLock 으로 redis 부하를 줄여주기위한 sleep
            Thread.sleep(100);
        }

        // lock 획득 성공시 로직 수행
        try {
            itemService.buyItem(key, quantity);
        } finally {
            // lock 해제
            redisLettuceStructure.deleteLock(key);
        }
    }
}

테스트 성공

스핀 락 방식으로 인한 문제점

스핀 락 방식이기 때문에 지속적으로 락의 획득을 시도하면서 Redis에 많은 부하가 생긴다. 이를 위해, 일정 시간만큼 sleep 하면서 개선하지만 역시 Redis에 부하가 생긴다.

또한, 계속 락을 획득하기 위해 시도하는 중, 락을 가지고 있는 스레드가 비정상적으로 종료되면서 무한 대기상태로 빠질 수 있다. 그래서 락을 획득하는 최대 허용시간이나 최대 허용횟수를 지정해야 한다.

Redisson

Pub-sub 방식으로 Lock 을 구현한다.
Pub-Sub 방식이란 별도의 채널을 만들고, 락을 점유중인 스레드의 락이 해제될 때마다 대기중인 스레드에게 알림을 주어 대기중인 스레드가 락 점유를 시도하는 방식이다.
* 스핀락 방식이 아니므로, Lettuce와 다르게 Redis의 부하를 줄일 수 있으며 별도의 Retry 로직을 작성하지 않아도 된다.
Redis의 subscribe 명령어로 채널을 구독하고, publish 명령어로 메시지를 전달한다.

동작방식

  1. tryLock으로 락 획득에 성공하면 true를 반환한다.
  2. pubsub을 이용하여 메시지가 올 때까지 대기하다가 락이 해제되었다는 메시지를 받으면 락 획득을 시도한다. 락 획득에 실패하면 타임아웃 시간까지 다시 락 해제를 기다린다.
  3. 타임아웃이 지나면 false를 반환하고 락 획득에 실패함을 알린다.

Redis Redisson 구현

public class RedissonService {

    private final RedissonClient redissonClient;
    private final ItemService itemService;

    public void buyItem(final Long key, final Long quantity) {
        
        // key 로 Lock 객체를 가져온다
        RLock lock = redissonClient.getLock(key.toString());

        try {
            // 획득 대기 타임아웃, 락 만료 시간
            boolean available = lock.tryLock(10, 2, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }

            itemService.buyItem(key, quantity);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

RedissonClient url 에러

RedissonClient를 설정하면서 아래와 같은 에러가 있었다.
Redis url should start with redis:// or rediss:// (for SSL connection)

RedisConfig 에서 RedissonClient를 빈으로 등록하면서 주소의 앞에 redis:// 를 추가해주면서 해결했다.

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
            .setAddress("redis://" + redisHost + ":" + redisPort);
    return Redisson.create(config);
}

테스트 성공

Redisson의 타임아웃

tryLock 메서드에 타임아웃을 명시하여 구현할 수 있다. tryLock(waitTime, leaseTime, Timeunit)
Redisson은 락 획득을 대기할 타임아웃(waitTime), 락이 만료되는 시간(leaseTime)의 파라미터를 가진다.

타임아웃 시간이 지나면 false가 반환되며 락 획득에 실패하게 되며, 락이 만료되는 시간이 지나면 락이 사라지기 때문에 어플리케이션에서 락을 해제해주지 않아도 된다.

결과

여러대의 서버와 분산된 DB 환경에서 네임드 락과 Redis를 활용한 분산 락에 대해 알게 되면서, 동시성 문제를 해결하기 위한 다양한 방법을 학습할 수 있었다.
현업에서 분산된 DB 환경을 주로 사용하기에 비관적,낙관적 락으로는 해결할 수 없는 동시성 문제가 많을 것으로 예상된다. 학습한 방법 중에서는 Redis를 구축하는 비용이 들겠지만, MySQL에 종속적이지 않으며 Redis에 부하도 줄일 수 있는 Redisson 방식의 분산 락이 가장 효율적이라 생각된다.
추후 동시성 문제가 발생할 여지가 있는 로직에 적용하면서 안정적인 서비스를 제공할 수 있겠다.

참고

우아한형제들 기술블로그
Redis 공식문서 - distributed-locks
https://velog.io/@hgs-study/redisson-distributed-lock

2개의 댓글

comment-user-thumbnail
2023년 9월 7일

좋은 글 감사합니다.

1개의 답글