코드는 Github에서 보실 수 있습니다.
이번 글에서는 Redis를 사용하여 분산락을 구현해보겠습니다.
Redis를 사용하여 분산락을 사용하면 다음과 같은 장점들이 있습니다.
Java의 Redis 클라이언트로는 Jedis, Lettuce, Radisson 등이 있으며, 각각의 특징들이 다릅니다.
이 중에서 Lettuce, Radisson 을 사용해서 분산락을 구현하도록 하겠습니다.
Jedis를 사용하지 않는 이유
멀티스레드 환경에서 성능 이슈, 커넥션 풀 관리 필요합니다.
- Jedis 인스턴스 자체는 thread-safe하지 않습니다
- 단일 Jedis 인스턴스를 여러 스레드에서 공유하여 사용하면 동시성 문제가 발생할 수 있습니다.
도커를 통해 로컬에 Redis 구성해보겠습니다.
(Docker가 로컬에 설치되어 있어야 합니다. https://www.docker.com/products/docker-desktop/)
우선 터미널에서
docker pull redis:latest # redis 도커 최신 이미지 내려받기
docker run --name redis -d -p 6379:6379 redis # redis 컨테이너 실행
docker exec -it redis redis-cli # redis 컨테이너 접속
application.yml 파일에 의존성을 추가해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
사실 Redis 서버가 없어도 테스트는 성공합니다. SpringBoot의 자동 구성(Auto-configuration) 덕분입니다.home brew로 설치한 redis가 계속 실행되고 있어서 테스트에 성공했던거였습니다.. ㅠㅠ 삽질했네요..
Lettuce는 Netty 기반의 비동기 Redis 클라이언트 라이브러리입니다.
위의 의존성을 추가했다면, 가본적으로 Lettuce 기반의 Redis Client가 제공됩니다.
이제 Redis를 통해 락을 획득하고 해제하는 기능을 구현하겠습니다.
@Repository
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Object key) {
return redisTemplate
.opsForValue()
.setIfAbsent(String.valueOf(key), "lock", Duration.ofMillis(3000));
}
public Boolean unlock(Object key) {
return redisTemplate.delete(String.valueOf(key));
}
}
redis의 SETNX (SET if Not eXists) 명령어를 이용하여 atomic 한 명령어를 사용할 수 있습니다. 이 명령어는 키가 존재하지 않을때만 값을 설정합니다.
redisTemplate.opsForValue().setIfAbsent()
메서드는 내부적으로 SETNX 명령어를 사용합니다. 따라서 이 메서드는 키가 없을 때만 값을 설정하고, 성공 여부를 Boolean 으로 반환합니다.
이어서 재고감소 로직 전후로 락을 획득하고 반환하는 로직을 구현하겠습니다.
@Service
@RequiredArgsConstructor
public class LettuceLockFacade {
private final RedisLockRepository redisLockRepository;
private final ProductService productService;
public void decrease(Long id) {
while (!redisLockRepository.lock(id)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
try {
productService.decrease(id);
} finally {
redisLockRepository.unlock(id);
}
}
}
위 코드는 스핀락 방식으로 구현했습니다.
스핀락은 락을 획득할 때까지 계속해서 시도하는 동기화 기법입니다.
스핀락을 사용하는 이유
- 빠른 응답 시간: 락이 곧 해제될 것으로 예상될 때 효과적입니다.
- 컨텍스트 스위칭 최소화: 스레드가 블로킹되지 않아 오버헤드가 적습니다.
- 분산 환경 적합: 여러 서버 간 동기화에 유용합니다.
- 간단한 구현: 복잡한 큐 관리 없이 구현 가능합니다.
테스트 코드를 작성해보겠습니다.
@SpringBootTest
class LettuceLockFacadeTest {
@Autowired private LettuceLockFacade lettuceLockFacade;
@Autowired private ProductRepository productRepository;
@Test
void Lettuce_락_테스트() throws InterruptedException {
// given
Long id = productRepository.saveAndFlush(new Product(1L, 100)).getId();
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
lettuceLockFacade.decrease(id);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Product product = productRepository.getById(id);
assertThat(product.getQuantity()).isEqualTo(0);
productRepository.deleteAll();
}
}
동시성에 대한 재고 감소가 정상적으로 이루어지는 걸 확인할 수 있습니다.
Lettuce는 Spring Boot Redis 의존성을 추가하는 경우 기본 Redis Client로 제공되므로, 별도의 설정 없이 간단히 구현할 수 있습니다.
하지만 구현 방식에서 스핀락 방식을 사용하기 때문에 Redis 에 부하를 줄 수 있다는 단점이 있습니다.
Redisson은 분산 락을 구현할 때 pub/sub 의 작동원리를 이미 내부적으로 사용하고 있습니다.
pub/sub (Publish/Subscribe): 메시지를 특정 채널에 발행(publish)하고, 해당 채널을 구독(subscribe)하는 시스템 간 통신 패턴
- 작동 방식
- 발행자(publisher)는 메시지를 특정 채널에 보냅니다.
- 구독자(subscriber)는 관심 있는 채널을 구독합니다.
- 채널에 메시지가 발행되면 모든 구독자에게 전달됩니다.
우선 Redisson 의존성을 추가겠습니다.
버전은 https://github.com/redisson/redisson/blob/master/redisson-spring-boot-starter/README.md 에서 확인하고 수정해주시면 됩니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.31.0'
앞서 Redisson은 이미 분산락을 구현하고 제공해주기 때문에 바로 재고 감소 로직을 구현하겠습니다.
@Component
@RequiredArgsConstructor
public class RedissonLockFacade {
private final RedissonClient redissonClient;
private final ProductService productService;
public void decrease(Long id) {
RLock lock = redissonClient.getLock(String.valueOf(id));
try {
boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!acquireLock) {
System.out.println("Lock 획득 실패");
return;
}
productService.decrease(id);
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
}
redissonClient.getLock()
으로 특정 ID에 대한 락을 얻고, tryLock()
으로 10초 동안 1초 간격으로 락 획득을 시도합니다. 락을 얻으면 productService.decrease()
를 실행하고, 작업 완료 후 unlock()
으로 락을 해제합니다.
테스트 코드를 작성하겠습니다.
@SpringBootTest
class RedissonLockFacadeTest {
@Autowired
private RedissonLockFacade redissonLockFacade;
@Autowired private ProductRepository productRepository;
@Test
void Redisson_락_테스트() throws InterruptedException {
// given
Long id = productRepository.saveAndFlush(new Product(1L, 100)).getId();
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
redissonLockFacade.decrease(id);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Product product = productRepository.getById(id);
assertThat(product.getQuantity()).isEqualTo(0);
productRepository.deleteAll();
}
}
재고 감소 로직이 정상적으로 작동하는 걸 확인할 수 있습니다.
Redisson은 pub/sub 기반의 효율적인 락 구현으로 Lettuce의 스핀락 방식보다 Redis 부하 감소시켜줍니다. 또한 이미 분산락을 이미 구현하고 제공하고 있으므로, 별도의 로직 구현이 필요하지 않다는 장점이 있습니다.
하지만 Redisson을 사용하기 위해 별도의 의존성 추가 및 설정이 필요하다는 단점이 있습니다.
MySQL의 USER-Leve Lock과 Redis의 분산락은 모두 동시성 제어에 사용되지만, 그 특성과 적용 범위에서 차이가 있습니다.
MySQL의 Lock은 단일 데이터베이스 내에서 작동하는 반면, Redis의 분산락은 여러 서버나 애플리케이션 간의 동기화에 적합합니다.
Redis의 분산락이 널리 사용되는 이유는 높은 성능, 확장성, 그리고 분산 시스템에서의 유연성 때문입니다. 특히 마이크로서비스 아키텍처나 클라우드 환경에서 Redis의 분산락은 효과적인 리소스 관리와 데이터 일관성 유지를 가능하게 합니다.
참고
https://ttl-blog.tistory.com/1581?category=906282
https://jojoldu.tistory.com/418
6. Spring Redis 분산락(Distribute Lock)을 활용한 동시성 처리