Spring Boot, Redisson 분산 락(Distributed Lock)
Synchronizer Control with Spring Redisson Distributed Lock
Jan 08, 2025
분산 락(Distributed Lock)
프로젝트를 만들다 보면, 여러 API 혹은 여러 서버에서 동일한 리소스에 대해서 동시에 접근할 일이 생긴다거나, 여러 단계의 트랜잭션이 필요한 경우 데이터 정합성 보장이 필요한 일이 생긴다거나 메세지나 이벤트의 순서를 보장해야 할 때 등의 문제가 발생합니다.
이 때, 분산 환경과 동시성 제어 등 올바른 프로세스를 보장하기 위해서 사용할 수 있는 것이 바로 분산 락(Distributed Lock) 입니다. 분산락은 다음과 같을 때 사용됩니다.
- 여러 서버/프로세스가 공유 자원에 접근할 때 동시성을 제어하는 메커니즘
- 하나의 프로세스만 특정 자원에 접근할 수 있도록 보장하는 동기화 도구
일반적으로 분산 환경을 지원하며, 여러 서버에 걸쳐 글로벌하게 동일한 잠금(Lock) 상태를 유지하여 순서를 보장하거나 공유자원에 대한 동시성 문제를 방지할 수 있습니다.
Tools
분산락 구현에 주로 사용되는 것은 다음과 같습니다.
- Redis
- 인 메모리 Key-Value 저장소로 빠른 성능을 보입니다.
- SET NX 명령어로 원자적 락을 구현할 수 있습니다.
- TTL 기능으로 Lock에 대한 만료를 관리합니다.
- 가장 널리 사용되며, 높은 성능과 간단한 구현으로도 적용할 수 있습니다.
- ZooKeeper
- 분산 코디네이션 서비스입니다.
- 임시노드를 사용할 수 있습니다.
- 자동 락 해제 기능을 지원합니다.
- 안정적 일관성을 제공하고, 순서를 보장합니다.
- SQL Database
- 만약 이미 프로젝트에서 RDBMS를 사용하고 있다면 별도 인프라가 불필요합니다.
- SELECT FOR UPDATE 기능을 통해 특정 ROW를 잠금할 수 있습니다.
- 성능에 제약이 발생할 수 있습니다.
- 확장성에 제한이 발생할 수 있고 성능 이슈가 발생할 수 있습니다.
대부분의 분산락을 구현할 때 요구되는 것은, 성능 / 일관성 / 운영의 복잡도 / 추가적인 인프라 비용 / 러닝 커브 등을 고려할 수 있습니다.
이 중 Redis가 가장 대중적으로 널리 사용되며, 위에 언급한 고려사항들을 모두 충족하는 툴이기 때문에 이 글에서는 Redis를 사용하도록 합니다.
Spring Boot Redis Client
Spring Boot에서는 Redis 서버와 통신하기 위해 Redis 클라이언트를 사용합니다. Redis 클라이언트는 Redis 서버와 연결된 내 주 서버와의 Connection을 관리하며, Redis 명령어를 각 서버가 이해할 수 있는 형식으로 변환을 도와주는 등 Spring Boot와 Redis간의 사용을 보조하는 라이브러리입니다.
대표적으로 사용하는 클라이언트는 총 3가지로 Lettuce, Jedis, Redisson이 있습니다. 먼저,
- Jedis
- Rros
- 가장 직관적으로, 사용이 쉽고 가벼운 구조로 이루어져 있다는 장점이 있습니다.
- Cons
- Connection Pool을 직접 관리해야하고, Thread-Safe 하지 않아 멀티 스레드 환경에서 강한 주의가 필요합니다.
- 비동기 처리가 Lettuce, Redisson 에 비해 제한적이라 대용량 처리에 적합하지 않습니다.
- Lettuce
- Pros
- 비동기에 대한 지원이 뛰어나며 Netty(비동기 이벤트 기반 네트워크 애플리케이션 프레임워크) 기반으로 높은 성능을 제공합니다.
- Netty는 다음과 같은 특징이 있습니다.
- 비동기 I/O (NIO) 사용
- Event Driven Architecture
- 높은 성능과 확장성
- 효율적인 메모리 관리
- 자동 재연결과 장애 조치(failover) 등 재해복구에 대한 기능이 내장되어 있습니다.
- Thread-Safe 하기 때문에 멀티 스레드 환경에서도 안전하게 사용 가능합니다.
- Spring Boot 2.0 부터 기본 Redis 클라이언트로 채택됐습니다.
- 클러스터, 센티널 등 고급 기능들이 지원됩니다.
- Jedis에 비해 TPS/CPU 사용률 응답 속도 등 많은 성능이 개선됐습니다.
- Cons
- Jedis에 비해 상대적으로 사용이 복잡합니다.
- 메모리 사용량이 Jedis에 비해 다소 높을 수 있습니다.
- Redisson
- Pros
- 분산 락, 세마포어 등 분산 환경에서 유용한 기능들을 제공합니다.
- Java 객체를 자동으로 직렬화/역직렬화 합니다.
- 다양한 분산 자료구조(Map, Queue, Lock 등)을 제공합니다.
- 편리한 캐시 추상화를 제공합니다.
- 스레드들이 요청한 순서를 엄격하게 보장
- 락을 획득할 때까지 대기 중인 스레드들의 순서를 유지
- 일부 스레드가 실패하거나 죽더라도 최대 5초간 대기
- Cons
- 다른 클라이언트들에 비해 기능이 많은 대신 상대적으로 더 무겁습니다.
- 높은 수준의 추상화로 인해 성능 오버헤드가 발생할 수 있습니다.
- 복잡한 기능으로 인해 러닝 커브가 발생할 수 있습니다.
3개의 클라이언트 중 분산락을 지원하는 클라이언트는 Lettuce와 Redisson 입니다. 여기서 일반적으로 추천하는 것은 Redisson입니다. 그 이유는,
- 단순한 Lock 구현으로 기본적인 분산 락 기능만 제공합니다.
- 수동으로 Lock 해제 관리가 필요합니다.
- Lock 획득 및 실패에 대한 재시도 및 timeout 로직을 보통 직접 구현해야 합니다.
- 락 획득 방식에 차이가 있습니다.
- Lettuce는 분산락 구현 시, setnx, setex 같은 명령어를 이용해 Redis에 Lock이 해제 됐는지 요청을 보내는 일종의 폴링 기법인 Spin Lock 방식으로 동작하는데 이는 요청이 많아질 수록 Redis가 받는 부하가 커지게 됩니다.
- Redisson은 Pub/Sub 방식을 통해 Lock을 얻는데 실패하면 구독(Subscribe) 중인 부분에서 다시 Lock을 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때 다시 Lock 획득을 시도합니다.
- Redisson은 자동 락해제 및 재시도, 다양한 Lock 타입 등 고급 기능을 지원합니다.
- Redisson의 분산 락은 기본적으로 공정성(fairness)을 고려하여 설계되었습니다.
- Redisson의 분산 락은 대기 중인 스레드들 사이에 공정한 순서를 제공합니다.
- 먼저 락을 요청한 스레드가 먼저 락을 획득할 수 있는 FIFO(First-In-First-Out) 방식을 기본으로 합니다.
위와 같은 이유로 분산 환경에서 락을 잡을 때는 많은 사람들이 Redisson을 추천하고 있습니다.
기본 설정(Config)
- 라이브러리 추가
- pom.xml
// Spring Boot Redis <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>3.1.2</version> </dependency> // Redisson <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.0</version> </dependency>
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.1.2' // spring boot redis implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // redisson
- Redisson Client Configuration 등록
@Configuration public class RedisConfig { @Value("${spring.data.redis.host}") private String HOST; @Value("${spring.data.redis.port}") private String PORT; @Bean public RedissonClient redissonClient() { RedissonClient redisson; Config config = new Config(); config.useSingleServer() .setAddress(getAddress(HOST, PORT)) .setConnectTimeout(60000) // 연결 타임아웃: 1분 .setIdleConnectionTimeout(60000) // 유휴 연결 타임아웃: 1분 .setRetryAttempts(3) // 재시도 횟수: 3회 .setRetryInterval(3000) // 재시도 간격: 3초 .setKeepAlive(true); // TCP KeepAlive 활성화 redisson = Redisson.create(config); return redisson; } private String getAddress(String host, String port) { return "redis://" + host + ":" + port; } }
- 그 외 컴포넌트
- AOP에서 사용하기 위한 Custom Annotation 생성
이 부분은 위 마켓컬리의 분산락 사용 방법을 참고하여 작성했습니다 위 글 참고 부탁드립니다.
데이터의 정합성을 위해 Lock안에서 Transaction을 실행시키도록 하여 트랜잭션 커밋이 먼저 실행되고 난 뒤 락이 해제되도록 만들기 위해 AopForTransaction을 사용합니다.
@Component public class AopForTransaction { @Transactional(propagation = Propagation.REQUIRES_NEW) public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { return joinPoint.proceed(); } }
제 경우는 user 마다의 Lock을 잡아야 하기 때문에, userId를 SecurityContextHolder에서 가져와 사용하도록 수정했습니다.
@Aspect @Component @RequiredArgsConstructor @Slf4j public class PointDistributedLockHandler { public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); PointDistributedLock pointDistributedLock = method.getAnnotation(PointDistributedLock.class); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String userId = authentication.getName(); String key = REDISSON_LOCK_PREFIX + userId; RLock rLock = redissonClient.getLock(key); try { boolean available = rLock.tryLock( pointDistributedLock.waitTime(), pointDistributedLock.leaseTime(), pointDistributedLock.timeUnit() ); if (!available) { return false; } log.info( "Lock has been acquired {} {}", "Is locked : " + rLock.isLocked(), "Method : " + method.getName() ); return aopForTransaction.proceed(joinPoint); } catch (InterruptedException e) { throw new InterruptedException(); } finally { try { rLock.unlock(); log.info( "Lock has been released {} {}", "Is locked : " + rLock.isLocked(), "Method : " + method.getName() ); } catch (IllegalMonitorStateException e) { log.info("Redisson Lock Error {} {}", "method name : " + method.getName(), "userId : " + key ); } } } }
테스트
예시를 들어보겠습니다. 포인트 적립시 Insert Only 전략을 사용하는 포인트 적립 프로젝트가 있습니다. insert Only이기 때문에 기존 데이터를 업데이트하지 않고 항상 최상단의 포인트가 최종 일관성을 가지도록 설계된 상태입니다.
문제는, 백그라운드에서 주기적으로 포인트 적립 요청을 보낸다는 것 입니다. 백그라운드 요청과 일반 유저의 Action이 맞물리면서 포인트 데이터의 정합성이 유지되지 않을 수 있습니다.
- 테스트 목적
- Inert Only 전략에서 포인트의 정합성 검증
- 백그라운드 적립과 Action 간의 동시성 제어 검증
- 분산락을 통한 동시성 문제 해결 검증
- 테스트 전제 조건
- 포인트 부족 상태에서 시작
- Action은 특정 포인트를 소모하여 실행
- 모든 포인트 작업은 분산락으로 제어되야 성공
- 마지막 insert가 최종 합계가 되어야 함
- 포인트 적립 완료 후 Action 성공 확인
시나리오 1: 적립과 차감 시 기본 분산락 없이 진행
- 목적: 분산락이 없을 때 Lock 유무에 따른 포인트 소모 실패
- 포인트 적립은 일반 트랜잭션, 소모는 Lock과 함께 실행
@Test @DisplayName("분산 락 없이 실행 시, 포인트 부족으로 업그레이드 실패") public void distributedPointLockingFailed() throws InterruptedException { CountDownLatch addStarted = new CountDownLatch(1); // add 스레드 시작 신호용 CountDownLatch completed = new CountDownLatch(2); // 두 스레드 완료 대기용 AtomicReference<Exception> addPoint = new AtomicReference<>(); AtomicReference<Exception> subPoint = new AtomicReference<>(); AddPointRequest req = new AddPointRequest(10L, 1, requirePoint + 30); SubPointRequest upRequest = new SubPointRequest(requireName, 1); Thread add = new Thread(() -> { runWithSecurityContext(() -> { try { addStarted.countDown(); pointService.addPoint( new MappingGameDto<>(req, userId, 2L) ); } catch (Exception e) { addPoint.set(e); } finally { completed.countDown(); } }); }); Thread sub = new Thread(() -> { runWithSecurityContext(() -> { try { addStarted.await(); userService.subPoint(userId, upRequest); } catch (Exception e) { subPoint.set(e); } finally { completed.countDown(); } }); }); // 스레드 시작 add.start(); sub.start(); completed.await(10, TimeUnit.SECONDS); assertNull(addPoint.get(), "포인트 저장은 성공"); assertNotNull(subPoint.get(), "포인트 소모는 실패"); System.out.println("SUB POINT: " + subPoint.get()); assertInstanceOf(BadRequestException.class, subPoint.get()); BadRequestException e = (BadRequestException) subPoint.get(); assertEquals(4000, e.getErrorCode()); assertEquals("don't have enough points", e.getAdditionalMessage()); }
결과는 아래와 같습니다.
addPoint start // addPoint 먼저 실행 Lock has been acquired Is locked : true Method : subPoint // addPoint 실행 후 subPoint 실행 don't have enough points 1 >> BadRequestException! // addPoint 완료 전 subPoint 실패 Lock has been released Is locked : false Method : subPoint
포인트 적립과 소비가 동시에 돌아갔을 때 적립 쪽에서 락이 걸려 있지 않다면, 소모 프로세스가 동시에 실행되면서 실패하게 됩니다.
시나리오 2: 적립과 차감 시 분산락과 함께 진행
- 목적: 분산락이 있을 때 차감 프로세스 성공
@Test @DisplayName("분산 락과 함께 실행 시, 실행 순서에 따라 성공 결과가 달라짐") public void distributedPointLockingSucceed() throws InterruptedException { CountDownLatch completed = new CountDownLatch(2); // 두 스레드 완료 대기용 AtomicReference<Exception> addPoint = new AtomicReference<>(); AtomicReference<Exception> subPoint = new AtomicReference<>(); AddPointRequest req = new AddPointRequest(10L, 1, requirePoint + 30); SubPointRequest upRequest = new SubPointRequest(requireName, 1); Thread add = new Thread(() -> { runWithSecurityContext(() -> { try { pointService.addPointWithLock( new MappingGameDto<>(req, userId, 2L) ); } catch (Exception e) { addPoint.set(e); } finally { completed.countDown(); } }); }); Thread sub = new Thread(() -> { runWithSecurityContext(() -> { try { userService.subPoint(userId, upRequest); } catch (Exception e) { subPoint.set(e); } finally { completed.countDown(); } }); }); // 스레드 시작 add.start(); sub.start(); completed.await(10, TimeUnit.SECONDS); Optional<UserEntity> um = userJpaRepository.findById(userId); Optional<PointEntity> p = pointRepository.findFirstByUser_IdOrderByIdDesc(userId); p.ifPresent(pEntity -> assertEquals(pEntity.getTotalPoint(), 30)); }
랜덤으로 위 프로세스가 실행됐다고 했을 때 결과는 아래와 같습니다.
// AddPoint가 먼저 일 때 Lock has been acquired Is locked : true Method : addPointWithLock key : POINT_LOCK:1 // log 상의 이유로 addPoint Lock 해제가 늦게 찍힘, subPoint acquire보다 Lock has been acquired Is locked : true Method : subPoint key : POINT_LOCK:1 Lock has been released Is locked : true Method : addPointWithLock key : POINT_LOCK:1 Lock has been released Is locked : false Method : subPoint key : POINT_LOCK:1 남은 포인트 30으로 성공 // SubPoint가 먼저 일 때 Lock has been acquired Is locked : true Method : subPoint key : POINT_LOCK:1 don't have enough points 1 >> BadRequestException! Lock has been acquired Is locked : true Method : addPointWithLock key : POINT_LOCK:1 Lock has been released Is locked : true Method : subPoint key : POINT_LOCK:1
랜덤으로 동시실행 했을 때, AddPoint가 먼저라면 성공하지만 SubPoint가 먼저 실행되면 반드시 실패하게 됩니다.
결론
분산락은 동시성 제어가 필요한 분산 환경에서 중요한 역할을 합니다.
분산락은 구현의 복잡성과 추가적인 인프라 비용이 발생할 수 있지만, 데이터 정합성이 중요한 시스템에서는 필수적인 요소입니다. 특히 포인트와 같은 민감한 데이터를 다루는 시스템에서는 안정적인 분산락 구현이 시스템의 신뢰성을 보장하는 핵심 요소가 됩니다.
Share article