# 분산 서버 환경과 멀티스레드 환경에서 모두 간단히 적용 가능한 락이 없을까?
이전 글에서 사용했던 것과 같은 예시를 들겠습니다. 100명의 구매자가 100개의 상품을 동시에 1개씩 구매하는 상황입니다. 모든 사용자는 구매에 성공해야하고, 모든 사용자의 구매가 끝났을 때 상품의 재고는 0개가 되어야 합니다.

지난 글에서는 트랜잭션에 사용하는 데이터에 대해 PESSIMISTIC_WRITE를 걸어 해당 레코드에 다른 트랜잭션이 접근하지 못하게 만들었지만 이번에는 조금 다른 방법을 사용하고자 합니다.
사실은 쓰면 안되지만! synchronized를 쓰거나, 내부 락을 이용해서 어플리케이션 코드 내에서 멀티 스레드로 동작하여 동시성 문제를 나타낼 수 있는 문제를 해결할 수 있습니다(스프링 부트의 톰캣 기준으로 하나의 웹서버는 200개의 멀티 스레드로 동작하는 것이 기본 설정이기 때문에 단일 서버에서도 DB 동시성 이슈가 많이 발생합니다..). 조금 다른 주제이지만 주의해야 하는게, @Transactional을 사용했다고 해서 모든 트랜잭션이 트랜잭션의 독립성 원칙을 지키며 동작한다고 생각하면 안된다는 것입니다. @Transactional은 어노테이션이 붙은 메소드가 시작할 때 DBCP의 커넥션을 얻고 해당 메소드 내에서 이뤄지는 작업을 묶어서 원자성 처리를 보장하는 역할을 합니다. 메소드 실행이 완전히 끝나고 나서야 commit을 하고, 중간에 예외가 발생하면 roll back을 시켜 이전의 작업들을 모두 되돌리는 식으로 동작합니다.
Repository 메소드에서 PESSIMISTIC_WRITE를 쓰는 것은 쿼리를 날릴 때 for update를 붙이기 때문에 DB서버에서 동시성 문제를 처리합니다. 그러나 분산 환경 시스템에서 이는 한계가 있을 수 있는데요. 가령 DB 자체가 이중화되어있다거나, 이런 락 기능을 완전하게 제공하지 못하고 있다거나, DB 이외에 분산 서버들이 공유하는 다른 리소스가 있을 경우 각각에 대한 race condition을 고려하고 이에 대한 대책을 세우는 것은 어려울 것입니다.
이에 세션 불일치 문제를 공유 세션 서버가 해결해주듯, 분산 서버에서 공유된 락을 사용하기 위해 "분산락"이라는 해결책이 고안되었습니다. 여러 대의 서버에서 하나의 세션 서버를 공유하듯, 여러 대의 서버에서 하나의 락을 공유하는 것입니다. 같은 자원을 업데이트하려는 두 개의 서버 요청이 충돌할 때, 서버 A의 스레드1이 먼저 분산락을 집으면 서버 B의 스레드1은 A의 스레드1이 작업을 완료하고 락을 놓을 때까지 대기하다가 락이 놓아지면 해당 락을 갖고 기존에 하려던 요청을 계속하는 것입니다.

레디스의 분산락을 활용한 서비스 구조는 위와 같아집니다.
# Lettuce VS Redisson
- 분산락을 구현하는데 가장 널리 사용되기에 레퍼런스를 찾기 쉽고
- 이미 한 번 사용해봤기에 사용법에 익숙한 솔루션인
Redis를 분산락으로 사용할 데이터를 저장할 저장소로 채택했습니다. Redis 자체가 정확히! 싱글스레드로 동작하는 것은 아니지만, 사용자의 자료 요청은 O(1) 안에 응답을 주는 싱글 스레드로 동작한다고 생각해도 무방합니다.(그래서 long time 명령어를 무식하게 날리면 다른 요청들이 오래걸리게되고 어쩌구)
그러나 스프링 진영에서 레디스를 사용할 수 있도록 제공하는 인터페이스인 lettuce는 redisson에 비해 다음과 같은 한계점이 있습니다.
- Lettuce에선 레디스 데이터를 분산락으로 사용하기 위한 기본 인터페이스를 제공하지 않습니다. 따라서 setnx/setex(not exist/exist - 키값의 유무에 따라 어떻게 동작할지), time out, lease time등을 직접 개발자가 구현해야 합니다.
- 그렇기 때문에 락 획득에 대해서도 락의 사용가능 여부를 계속 묻는 spinlock 방식으로 사용할 수밖에 없습니다. 레디스 서버로 락의 가용여부를 묻는 서버 스레드도, 해당 요청을 계속 받는 레디스 서버에도 부담일 것입니다.
이에 비해 Redisson은 분산락 인터페이스를 제공하며, pub/sub 구조로 락을 사용 완료한 스레드가 대기 스레드들에게 사용 완료를 알리는 방식으로 구현되었기 때문에 리소스 낭비 걱정을 덜해도 됩니다. 따라서 Spring boot 진영에서 기본적으로 제공하는 redis 솔루션 구현체인 Lettuce 대신 Redisson을 분산락으로 사용하도록 하겠습니다.
# 분산락 사용해보기
분산락을 멋있게 사용한 여러 블로그 글들을 보면 어노테이션 기반 AOP를 사용하고 그러던데(사실 이게 맞는 것 같습니다. 락을 획득하는 것은 횡단 관심사이기 때문에..) 공부하는 입장에서 그럴 여력은 없고 Redisson 진영에서 제공하는 분산락 기능을 사용해보는 정도로 공부해보겠습니다.
일단 build.gradle에 아래를 추가하고, host와 port 번호를 설정 파일에 추가합니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.24.2'
configuration 컴포넌트에 redissonClient 빈을 만들어줍니다.
@Configuration
public class RedissonConfiguration {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Bean
public RedissonClient redissonClient() {
System.out.println(port);
Config config = new Config();
config.useSingleServer().setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
}
}
그리고 분산락 옵션 상수를 저장할 인터페이스도 만들었습니다. 락이 풀리기를 최대 3초간 기다리고, 락을 획득한 후 2초가 지나면 락을 놓도록 구성했습니다.
public interface LockOption {
long WAIT_TIME = 3L;
long LEASE_TIME = 2L;
}
이제 락을 얻는 메소드를 작성하겠습니다. buyGoodsWithDistributedLock()을 살펴봅시다!
@Component
@RequiredArgsConstructor
@Slf4j
public class MarketDistributedLock {
private final RedissonClient redissonClient;
private final MarketService marketService;
/**
* Redisson의 분산 락(distributed lock)을 이용해 race condition을 컨트롤한다.
* 동작 결과는 나머지 메소드와 일치한다.
* @param customerName 구매자 이름
* @param goodsName 상품 이름
* @param number 상품 개수
*/
public void buyGoodsWithDistributedLock(String customerName, String goodsName, int number) {
RLock lock = redissonClient.getLock(goodsName + ":lock");
try {
// 분산락 획득
if (!lock.tryLock(LockOption.WAIT_TIME,LockOption.LEASE_TIME, TimeUnit.SECONDS)) {
return;
}
marketService.buyGoodsWithoutLock(customerName, goodsName, number);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock.isLocked()) {
lock.unlock();
}
}
}
}
- RLock, 즉 Redisson Lock을 바라봅니다. 여기서 lock 이름은 상품이름 + ":lock"문자열로 설정했습니다. 만약 상품이름이 "leek"이라면 락 이름은 "leek:lock"이 되는 것입니다.
- tryLock()를 통해 분산락 획득을 시도합니다. tryLock은 wate time만큼 락 획득 시도를 합니다. 여기서 말하는 시도는 락의 subscriber로써, 먼저 락을 획득하고 작업 진행중인 스레드가 락을 놓기를 대기한다는 뜻입니다. 그리고 최대 lease time만큼 락을 사용합니다.
- marketService.buyGoodsWithoutLock()을 호출하여 다른 공유/배타락 설정을 해주지 않은 DB select 및 업데이트 쿼리를 날릴 것입니다.
marketService.buyGoodsWithoutLock()는 다음과 같습니다. 지난 포스트와 100%일치합니다. 전파 옵션을 제외하고!
/**
* customer가 number 개수의 goods를 구매한다. 별다른 동시성 제어를 하지 않는다.
* @param customerName 구매자 이름
* @param goodsName 상품 이름
* @param number 상품 개수
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void buyGoodsWithoutLock(String customerName, String goodsName, int number) {
Customer customer = customerJpaRepository.findByName(customerName)
.orElseThrow(EntityNotFoundException::new);
Goods goods = goodsJpaRepository.findByName(goodsName)
.orElseThrow(EntityNotFoundException::new);
customer.buy(goods, number);
}
propagation = Propagation.REQUIRES_NEW를 한 이유가 무엇일까요?
# 커밋 뒤에 락이 해제되어야 한다
Propagation.REQUIRES_NEW는 해당 메소드가 호출되었을 때 스레드가 트랜잭션중에 있든말든 상관없이 새로운 트랜잭션을 독립적으로 여는 전파 옵션입니다. 위의 예시에서, buyGoodsWithoutLock는 해당 메소드를 실행시키는 외부 환경관 상관없이 독립적으로 트랜잭션을 열고 메소드가 끝나면 해당 트랜잭션의 변경사항만 감지하여 커밋합니다. 이렇게 새로운 트랜잭션을 열지 말고, 기존에 락을 획득했던 메소드 안에서 이 과정을 전부 처리하면 안되었던 걸까요?
네 안됩니다. 트랜잭션 메소드가 끝나기 전에 락이 해제될 경우, 커밋 전에 락이 다른 스레드에게 점유되어 데이터 일관성이 깨질 수 있기 때문입니다.
MySQL이 채택하는 트랜잭션 격리 수준에선 커밋 전의 데이터 변경사항은 다른 트랜잭션이 읽을 수 없습니다. 레코드 a에 1을 더하는 트랜잭션 T1,T2가 동시에 쓰기 작업을 수행하는 다음과 같은 상황을 가정합시다.
- T1이 락을 획득하고 레코드 a에 1을 더해 a+1로 수정한다.
- T1이 락을 해제한다.
- T2가 락을 획득한 뒤 레코드 a를 읽는다.
- T1이 커밋을 하기 전이므로 레코드 a는 a인 그대로다.
- T1이 커밋한다. 레코드는 a+1로 변경되었다.
- T2가 레코드 a에 1을 더해 a+1로 수정한다.
- 원하는 결과는 a+2이지만 a+1의 결과가 되었다.
문제는 4번때문에 발생합니다. 커밋 전에 락이 해제되면, 락을 획득한 다른 트랜잭션은 커밋 전의 데이터를 보게되는 문제가 발생한 것입니다. 그렇기 때문에 락을 해제하기 전에! 커밋을 완료해야 합니다. 때문에 락을 획득하는 과정과 레코드에 접근하는 과정을 나누고, 레코드에 접근하는 부분만을 새로운 트랜잭션을 여는 옵션으로 설정하여 아래와 같이 안전한(?) 분산락을 구현할 수 있도록 합시다.

# 테스트
@Test
@DisplayName("분산락을 통해 동시성 이슈를 해결한다.")
public void distributedLockTest() throws InterruptedException {
// given : 구매자 100명과 재고가 100개인 대파가 존재한다.
List<Customer> customers = IntStream.range(0,100)
.mapToObj(i -> Customer.builder().name("구매자" + i).build())
.map(customer -> customerJpaRepository.save(customer)).toList();
goodsJpaRepository.save(
Goods.builder().name("leek").stockNumber(100).build());
CountDownLatch countDownLatch = new CountDownLatch(100);
// when : 구매자 100명이 PESSIMIST_WRITE 락이 필요한 트랜잭션 메소드를 사용한다.
List<Thread> threads = customers.stream().map(
customer -> new Thread(() -> {
marketDistributedLock.buyGoodsWithDistributedLock(
customer.getName(), "leek", 1);
countDownLatch.countDown();
})).toList();
threads.forEach(Thread::start);
countDownLatch.await();
// then : 재고가 0개다.
goodsJpaRepository.findByName("leek").ifPresent(
goods -> assertEquals(0,goods.getStockNumber()));
}
위 테스트는 깔꼼하게 통과합니다.

# REFERENCE
https://helloworld.kurly.com/blog/distributed-redisson-lock/
# 분산 서버 환경과 멀티스레드 환경에서 모두 간단히 적용 가능한 락이 없을까?
이전 글에서 사용했던 것과 같은 예시를 들겠습니다. 100명의 구매자가 100개의 상품을 동시에 1개씩 구매하는 상황입니다. 모든 사용자는 구매에 성공해야하고, 모든 사용자의 구매가 끝났을 때 상품의 재고는 0개가 되어야 합니다.

지난 글에서는 트랜잭션에 사용하는 데이터에 대해 PESSIMISTIC_WRITE를 걸어 해당 레코드에 다른 트랜잭션이 접근하지 못하게 만들었지만 이번에는 조금 다른 방법을 사용하고자 합니다.
사실은 쓰면 안되지만! synchronized를 쓰거나, 내부 락을 이용해서 어플리케이션 코드 내에서 멀티 스레드로 동작하여 동시성 문제를 나타낼 수 있는 문제를 해결할 수 있습니다(스프링 부트의 톰캣 기준으로 하나의 웹서버는 200개의 멀티 스레드로 동작하는 것이 기본 설정이기 때문에 단일 서버에서도 DB 동시성 이슈가 많이 발생합니다..). 조금 다른 주제이지만 주의해야 하는게, @Transactional을 사용했다고 해서 모든 트랜잭션이 트랜잭션의 독립성 원칙을 지키며 동작한다고 생각하면 안된다는 것입니다. @Transactional은 어노테이션이 붙은 메소드가 시작할 때 DBCP의 커넥션을 얻고 해당 메소드 내에서 이뤄지는 작업을 묶어서 원자성 처리를 보장하는 역할을 합니다. 메소드 실행이 완전히 끝나고 나서야 commit을 하고, 중간에 예외가 발생하면 roll back을 시켜 이전의 작업들을 모두 되돌리는 식으로 동작합니다.
Repository 메소드에서 PESSIMISTIC_WRITE를 쓰는 것은 쿼리를 날릴 때 for update를 붙이기 때문에 DB서버에서 동시성 문제를 처리합니다. 그러나 분산 환경 시스템에서 이는 한계가 있을 수 있는데요. 가령 DB 자체가 이중화되어있다거나, 이런 락 기능을 완전하게 제공하지 못하고 있다거나, DB 이외에 분산 서버들이 공유하는 다른 리소스가 있을 경우 각각에 대한 race condition을 고려하고 이에 대한 대책을 세우는 것은 어려울 것입니다.
이에 세션 불일치 문제를 공유 세션 서버가 해결해주듯, 분산 서버에서 공유된 락을 사용하기 위해 "분산락"이라는 해결책이 고안되었습니다. 여러 대의 서버에서 하나의 세션 서버를 공유하듯, 여러 대의 서버에서 하나의 락을 공유하는 것입니다. 같은 자원을 업데이트하려는 두 개의 서버 요청이 충돌할 때, 서버 A의 스레드1이 먼저 분산락을 집으면 서버 B의 스레드1은 A의 스레드1이 작업을 완료하고 락을 놓을 때까지 대기하다가 락이 놓아지면 해당 락을 갖고 기존에 하려던 요청을 계속하는 것입니다.

레디스의 분산락을 활용한 서비스 구조는 위와 같아집니다.
# Lettuce VS Redisson
- 분산락을 구현하는데 가장 널리 사용되기에 레퍼런스를 찾기 쉽고
- 이미 한 번 사용해봤기에 사용법에 익숙한 솔루션인
Redis를 분산락으로 사용할 데이터를 저장할 저장소로 채택했습니다. Redis 자체가 정확히! 싱글스레드로 동작하는 것은 아니지만, 사용자의 자료 요청은 O(1) 안에 응답을 주는 싱글 스레드로 동작한다고 생각해도 무방합니다.(그래서 long time 명령어를 무식하게 날리면 다른 요청들이 오래걸리게되고 어쩌구)
그러나 스프링 진영에서 레디스를 사용할 수 있도록 제공하는 인터페이스인 lettuce는 redisson에 비해 다음과 같은 한계점이 있습니다.
- Lettuce에선 레디스 데이터를 분산락으로 사용하기 위한 기본 인터페이스를 제공하지 않습니다. 따라서 setnx/setex(not exist/exist - 키값의 유무에 따라 어떻게 동작할지), time out, lease time등을 직접 개발자가 구현해야 합니다.
- 그렇기 때문에 락 획득에 대해서도 락의 사용가능 여부를 계속 묻는 spinlock 방식으로 사용할 수밖에 없습니다. 레디스 서버로 락의 가용여부를 묻는 서버 스레드도, 해당 요청을 계속 받는 레디스 서버에도 부담일 것입니다.
이에 비해 Redisson은 분산락 인터페이스를 제공하며, pub/sub 구조로 락을 사용 완료한 스레드가 대기 스레드들에게 사용 완료를 알리는 방식으로 구현되었기 때문에 리소스 낭비 걱정을 덜해도 됩니다. 따라서 Spring boot 진영에서 기본적으로 제공하는 redis 솔루션 구현체인 Lettuce 대신 Redisson을 분산락으로 사용하도록 하겠습니다.
# 분산락 사용해보기
분산락을 멋있게 사용한 여러 블로그 글들을 보면 어노테이션 기반 AOP를 사용하고 그러던데(사실 이게 맞는 것 같습니다. 락을 획득하는 것은 횡단 관심사이기 때문에..) 공부하는 입장에서 그럴 여력은 없고 Redisson 진영에서 제공하는 분산락 기능을 사용해보는 정도로 공부해보겠습니다.
일단 build.gradle에 아래를 추가하고, host와 port 번호를 설정 파일에 추가합니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.24.2'
configuration 컴포넌트에 redissonClient 빈을 만들어줍니다.
@Configuration
public class RedissonConfiguration {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Bean
public RedissonClient redissonClient() {
System.out.println(port);
Config config = new Config();
config.useSingleServer().setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
}
}
그리고 분산락 옵션 상수를 저장할 인터페이스도 만들었습니다. 락이 풀리기를 최대 3초간 기다리고, 락을 획득한 후 2초가 지나면 락을 놓도록 구성했습니다.
public interface LockOption {
long WAIT_TIME = 3L;
long LEASE_TIME = 2L;
}
이제 락을 얻는 메소드를 작성하겠습니다. buyGoodsWithDistributedLock()을 살펴봅시다!
@Component
@RequiredArgsConstructor
@Slf4j
public class MarketDistributedLock {
private final RedissonClient redissonClient;
private final MarketService marketService;
/**
* Redisson의 분산 락(distributed lock)을 이용해 race condition을 컨트롤한다.
* 동작 결과는 나머지 메소드와 일치한다.
* @param customerName 구매자 이름
* @param goodsName 상품 이름
* @param number 상품 개수
*/
public void buyGoodsWithDistributedLock(String customerName, String goodsName, int number) {
RLock lock = redissonClient.getLock(goodsName + ":lock");
try {
// 분산락 획득
if (!lock.tryLock(LockOption.WAIT_TIME,LockOption.LEASE_TIME, TimeUnit.SECONDS)) {
return;
}
marketService.buyGoodsWithoutLock(customerName, goodsName, number);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock.isLocked()) {
lock.unlock();
}
}
}
}
- RLock, 즉 Redisson Lock을 바라봅니다. 여기서 lock 이름은 상품이름 + ":lock"문자열로 설정했습니다. 만약 상품이름이 "leek"이라면 락 이름은 "leek:lock"이 되는 것입니다.
- tryLock()를 통해 분산락 획득을 시도합니다. tryLock은 wate time만큼 락 획득 시도를 합니다. 여기서 말하는 시도는 락의 subscriber로써, 먼저 락을 획득하고 작업 진행중인 스레드가 락을 놓기를 대기한다는 뜻입니다. 그리고 최대 lease time만큼 락을 사용합니다.
- marketService.buyGoodsWithoutLock()을 호출하여 다른 공유/배타락 설정을 해주지 않은 DB select 및 업데이트 쿼리를 날릴 것입니다.
marketService.buyGoodsWithoutLock()는 다음과 같습니다. 지난 포스트와 100%일치합니다. 전파 옵션을 제외하고!
/**
* customer가 number 개수의 goods를 구매한다. 별다른 동시성 제어를 하지 않는다.
* @param customerName 구매자 이름
* @param goodsName 상품 이름
* @param number 상품 개수
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void buyGoodsWithoutLock(String customerName, String goodsName, int number) {
Customer customer = customerJpaRepository.findByName(customerName)
.orElseThrow(EntityNotFoundException::new);
Goods goods = goodsJpaRepository.findByName(goodsName)
.orElseThrow(EntityNotFoundException::new);
customer.buy(goods, number);
}
propagation = Propagation.REQUIRES_NEW를 한 이유가 무엇일까요?
# 커밋 뒤에 락이 해제되어야 한다
Propagation.REQUIRES_NEW는 해당 메소드가 호출되었을 때 스레드가 트랜잭션중에 있든말든 상관없이 새로운 트랜잭션을 독립적으로 여는 전파 옵션입니다. 위의 예시에서, buyGoodsWithoutLock는 해당 메소드를 실행시키는 외부 환경관 상관없이 독립적으로 트랜잭션을 열고 메소드가 끝나면 해당 트랜잭션의 변경사항만 감지하여 커밋합니다. 이렇게 새로운 트랜잭션을 열지 말고, 기존에 락을 획득했던 메소드 안에서 이 과정을 전부 처리하면 안되었던 걸까요?
네 안됩니다. 트랜잭션 메소드가 끝나기 전에 락이 해제될 경우, 커밋 전에 락이 다른 스레드에게 점유되어 데이터 일관성이 깨질 수 있기 때문입니다.
MySQL이 채택하는 트랜잭션 격리 수준에선 커밋 전의 데이터 변경사항은 다른 트랜잭션이 읽을 수 없습니다. 레코드 a에 1을 더하는 트랜잭션 T1,T2가 동시에 쓰기 작업을 수행하는 다음과 같은 상황을 가정합시다.
- T1이 락을 획득하고 레코드 a에 1을 더해 a+1로 수정한다.
- T1이 락을 해제한다.
- T2가 락을 획득한 뒤 레코드 a를 읽는다.
- T1이 커밋을 하기 전이므로 레코드 a는 a인 그대로다.
- T1이 커밋한다. 레코드는 a+1로 변경되었다.
- T2가 레코드 a에 1을 더해 a+1로 수정한다.
- 원하는 결과는 a+2이지만 a+1의 결과가 되었다.
문제는 4번때문에 발생합니다. 커밋 전에 락이 해제되면, 락을 획득한 다른 트랜잭션은 커밋 전의 데이터를 보게되는 문제가 발생한 것입니다. 그렇기 때문에 락을 해제하기 전에! 커밋을 완료해야 합니다. 때문에 락을 획득하는 과정과 레코드에 접근하는 과정을 나누고, 레코드에 접근하는 부분만을 새로운 트랜잭션을 여는 옵션으로 설정하여 아래와 같이 안전한(?) 분산락을 구현할 수 있도록 합시다.

# 테스트
@Test
@DisplayName("분산락을 통해 동시성 이슈를 해결한다.")
public void distributedLockTest() throws InterruptedException {
// given : 구매자 100명과 재고가 100개인 대파가 존재한다.
List<Customer> customers = IntStream.range(0,100)
.mapToObj(i -> Customer.builder().name("구매자" + i).build())
.map(customer -> customerJpaRepository.save(customer)).toList();
goodsJpaRepository.save(
Goods.builder().name("leek").stockNumber(100).build());
CountDownLatch countDownLatch = new CountDownLatch(100);
// when : 구매자 100명이 PESSIMIST_WRITE 락이 필요한 트랜잭션 메소드를 사용한다.
List<Thread> threads = customers.stream().map(
customer -> new Thread(() -> {
marketDistributedLock.buyGoodsWithDistributedLock(
customer.getName(), "leek", 1);
countDownLatch.countDown();
})).toList();
threads.forEach(Thread::start);
countDownLatch.await();
// then : 재고가 0개다.
goodsJpaRepository.findByName("leek").ifPresent(
goods -> assertEquals(0,goods.getStockNumber()));
}
위 테스트는 깔꼼하게 통과합니다.

# REFERENCE
https://helloworld.kurly.com/blog/distributed-redisson-lock/