Github : 동시성 문제 Github 코드
여러 서버에서 동시성 문제를 해결하기 위해 Version을 활용한 낙관적 락과 DB Lock을 사용한 비관적 락을 알아봤다.
이전글 : 상품 주문 동시성 문제 해결하기 - 낙관적 락 & 비관적 락
하지만, DB도 분산된 환경에서 동시성 문제를 해결하기 위해서 어떻게 해야할까?
테이블이나 레코드, 데이터베이스 객체가 아닌 사용자가 지정한 문자열에 대해 락을 획득하고 반납하는 잠금으로, 한 세션이 Lock을 획득한다면, 다른 세션은 해당 세션이 Lock을 해제한 이후 획득할 수 있다. Lock에 이름을 지정하여 어플리케이션 단에서 제어가 가능하다.
Named Lock은 Redis를 사용하기 위한 인프라 구축, 유지보수 비용을 발생하지 않고, MySQL 을 사용해 분산 락을 구현할 수 있다. MySQL 에서는 getLock()을 통해 획득, releaseLock()으로 해지할 수 있다.
단점으로는 Lock이 자동으로 해제되지 않기 때문에, 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제하는 등 락의 획득,반납에 대한 로직을 철저하게 구현해야한다.
또한, 일시적인 락의 정보가 DB에 저장되고, 락을 획득,반납하는 과정에서 DB에 불필요한 부하가 있을 수 있으며, 락과 비즈니스 로직의 트랜잭션을 분리할 필요가 있다.
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
GET_LOCK(str, timeout)
MySQL 5.7 미만 : 동시에 하나의 잠금만 획득 가능, 잠금 이름 글자수 무제한
MySQL 5.7 이상 : 동시에 여러개 잠금 획득 가능, 잠금 이름 글자수 60자 제한
@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)의 잠금이 사용중인지 확인한다.
네임드 락을 위해 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 쿼리가 나간다.
SELECT ~ FOR UPDATE를 실행하면 특정 세션이 데이터를 수정 할 때까지 LOCK이 걸려 다른 세션이 데이터에 접근할 수 없다. 따라서 다른 세션은 해당 데이터가 수정될 때까지 계속 대기해야하는데, 이로 인해 무한 루프에 빠질 가능성이 있다.
Named Lock은 GET_LOCK에서 쉽게 설정할 수 있는 timeout 기능이 있는 반면에, select for update는 timeout 설정이 까다로워 Named Lock의 구현이 더 쉽다고 한다.
분산 락을 구현하기 위해 Lock에 대한 정보를 Redis에 보관하고, 분산 환경의 서버는 공통된 Redis를 통해 임계 영역(critical section)에 접근할 수 있는지 확인한다.
분산 락을 구현하기 위해 Java Redis 클라이언트인 Lettuce와 Redisson을 알아보자.
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에 부하가 생긴다.
또한, 계속 락을 획득하기 위해 시도하는 중, 락을 가지고 있는 스레드가 비정상적으로 종료되면서 무한 대기상태로 빠질 수 있다. 그래서 락을 획득하는 최대 허용시간이나 최대 허용횟수를 지정해야 한다.
Pub-sub 방식으로 Lock 을 구현한다.
Pub-Sub 방식이란 별도의 채널을 만들고, 락을 점유중인 스레드의 락이 해제될 때마다 대기중인 스레드에게 알림을 주어 대기중인 스레드가 락 점유를 시도하는 방식이다.
* 스핀락 방식이 아니므로, Lettuce와 다르게 Redis의 부하를 줄일 수 있으며 별도의 Retry 로직을 작성하지 않아도 된다.
Redis의 subscribe 명령어로 채널을 구독하고, publish 명령어로 메시지를 전달한다.
동작방식
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를 설정하면서 아래와 같은 에러가 있었다.
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);
}
테스트 성공
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
좋은 글 감사합니다.