분산락이란?
분산된 서버에서 공유 자원에 동시에 접근 시 발생할 수 있는 문제를 제어하기 위해 사용되는 방식이다.
Lock에 대한 정보를 분산된 서버가 접근 가능한 공통적인 저장소에 저장하고 분산 서버들은 저장된 Lock을 보고 접근 여부를 판단한다.
이 공통적인 저장소는 Redis, Mysql, Zookeeper를 활용하여 분산 락을 적용할 수 있다.
분산락 적용 계기...
현재 우리 서비스의 주 기능인 출발 알림 시간, 도착 예정 시간 목록 기능은 외부 API를 사용하고 있다.
현재 사용하는 외부 API는 호출 횟수에 따라 정지, 과금 될 수 있어 정확한 호출 횟수가 필요하다.
그렇기 때문에 API 호출 시 마다 호출 횟수를 카운팅해 DB에 저장하고 이를 관리자 페이지에서 확인하고 있다.
현재 우리 서비스의 핵심 기능(출발 알림, 도착 예정 시간 조회)들은 외부 API를 사용해 기능이 구현되어 있고
외부 API를 자주 호출하면서 데이터 업데이트가 매우 빈번하게 발생한다.
이로 인해 동시성 문제가 발생할 확률이 높았다.
따라서 외부 API 호출 카운팅에 대해 다음과 같은 요구사항이 필요했다.
- 외부 API 호출 성공 시 호출 횟수를 카운팅해야 함
- 동시 요청 상황에서도 호출 횟수 데이터 정합성 보장 필요
현재 우리 서비스는 아키텍처 구조상 Redis와 MySQL을 사용하고 있다.
그래서 Zookeeper는 현재 우리 서비스 인프라에 적합하지 않아 이를 도입하는 것은 비용문제로 이어질 수 있었다.
그렇다면 MySQL을 선택할 수 있지도 않았나? 비관적, 낙관적 락을 사용할 수 있지 않나? 할 수 있다.
선택하지 않은 이유는 다음과 같다.
비관적 락
- 데드락 문제 ? → 현재 로직상 Lock 희득 순서가 일관되서 가능성 X
- 데이터 일관성을 보장하지만 락 대기 시간으로 인해 지연 시간이 발생
- 동시 요청이 많이 발생할 때마다 DB의 락 관리로 부하를 줄 수 있다.
- 레코드에 락을 걸기 때문에 존재하지 않는 레코드에는 비관적 락을 걸 수 없다.
- Lock을 거는 메서드를 사용하는 로직은 현재 하나이지만 다른 메서드에서 사용할 경우 불필요한 Lock 흭득이 발생할 수 있다.
낙관적 락
- Version 관리로 최초 커밋만 인정하기 때문에 비즈니스 예외 상황이 아님에도 불구하고 요청이 실패할 수도 있어 재시도 로직이 필요
- 충돌이 많은 상황일 경우 오히려 재시도 로직으로 DB에 굉장히 많은 요청을 보낼 수 있다.
- 위에서 설명했듯이 동시성 문제가 발생할 확률이 높다 판단해 해당 방식은 적절하지 않다고 판단.
MySQL의 네임드 락을 사용한 분산락
추가 인프라 구축이 불필요할 경우 사용 중인 MySQL의 네임드 락을 사용해 분산 락을 적용하는 방식이다.
네임드 락은 MySQL의 메모리에 저장하고 락 획득 시 타임아웃을 설정할 수도 있다.
네임드 락은 비관적 락과 다르게 커넥션에 락을 걸기 때문에 레코드의 존재 여부와 관계없이 Lock 이름을 문자열로 지정하여 락을 걸수 있다.
네임드 락에 관해서는 아래 포스팅의 '네임드 락' 목차에서 설명한다.
2024.12.01 - [◼ DB] - MySQL의 Lock 종류와 동작 방식을 파헤쳐 보자
네임드 락을 사용하지 않은 이유는 이미 우리는 외부 API 의존성 관리를 위해 Redis가 도입되어 있어
네임드 락을 사용해 DB 부하를 가속화하고 커넥션 고갈 문제를 만들고 싶지 않았다.
하지만 이러한 추가적 인프라 도입 또한 비용이고 고가용성을 위한 유지가 필요하기에 아키텍처에 맞게 적절히 도입할 필요가 있다.
배민의 서비스 중 MySQL을 사용해 분산락을 적용한 내용이 있어 첨부한다.
https://techblog.woowahan.com/2631/
Redis를 사용한 분산락 결정적 이유
커넥션 고갈 문제, 확장성 제한이 우려된다면 추가 인프라(Redis, Kafka)를 도입하여 분산락을 구현할 수 있다.
특히 Redis의 경우에는 DB와 분리된 락 저장소로 활용해 Redis 연결은 메인 DB 커넥션과 별도로 관리된다.
때문에 락을 획득 가능할 경우에만 DB 커넥션을 사용해 작업을 처리하고
획득하지 못하는 경우에는 DB 커넥션을 사용하지 않고 대기해 DB 커넥션 풀에 영향을 주지 않는다.
또한 모든 애플리케이션 인스턴스가 동일한 Redis 서버를 바라보므로 일관된 락 상태를 유지하여 확장성에 영향을 받지 않는다.
현재 우리 서비스는 소요 시간 계산에 사용되는 외부 API들을 관리하기 위해 Redis로 TTL을 적용해 장애 대응 체계를 적용했다.
2024.11.19 - [◼ JAVA/Spring] - 외부 API 의존성 분리 및 장애 대응 체계 구축하기
따라서 Redis는 이미 도입되어 있어 부담이 없다.
낙관적 락과 비관적 락을 고민해볼 수도 있었지만
그리고 무엇보다 중요한건 낙관적, 비관적 락은 특정 엔티티에 대한 동시 접근 제어이다.
따라서 이미 존재하는 엔티티에 대해서만 동시성을 제어할 수 있다.
하지만 현재 우리 로직에서 하루 단위로 API 호출 정보를 확인하기 위해
다음과 같이 현재 날짜에 조회 결과가 없으면 새로운 엔티티를 생성하고 저장한다.
@Transactional
public void increaseCountByClientType(ClientType clientType) {
ApiCall apiCall = findOrSaveTodayApiCallByClientType(clientType);
apiCall.increaseCount();
}
private ApiCall findOrSaveTodayApiCallByClientType(ClientType clientType) {
return apiCallRepository.findByDateAndClientType(LocalDate.now(), clientType)
.orElseGet(() -> apiCallRepository.save(new ApiCall(clientType)));
}
비관적 락을 적용한다 한들 위 처럼 조회결과가 없는 상황에서는 동시성을 제어할 수 없다.
이런 문제점들을 바탕으로 필자는 Redis를 사용한 분산 락을 적용하기로 하였다.
더 정확하게는 분산 락을 관리하는 클라이언트로 Redisson을 사용했는데 Redis와 다르게 어떤 특징이 있는지 확인해보자.
Redisson이란?
Redisson은 Redis를 Java에서 더 쉽게 사용할 수 있게 해주는 클라이언트 라이브러리다.
- 분산 락 등 고급 기능 제공
- Java 객체를 자동으로 직렬화/역직렬화
- 다양한 자료구조(Map, Queue 등) 제공
추가로 제공되는 기능들에 더 자세하게 알아보고 싶다면 아래 문서를 참고하자.
http://redisgate.kr/redis/clients/redisson_intro.php
Redisson을 사용하면 어떤 차이점이 좀더 자세히 알아보자
Lettuce기반 Redis 클라이언트로 분산락을 적용했을 때 문제점
Lettuce기반인 Spring-data-Redis로 분산락을 적용하기 위해선 직접 분산락을 구현해야한다.
(Jedis도 있는데 Lettuce를 쓰는 이유는 -> https://jojoldu.tistory.com/418)
직접 구현을 떠나서 Lettuce로 분산 락을 구현하려면 스핀 락의 형태로 구현하게 되는데
스핀 락은 락을 획득하기 위해 SETNX라는 명령어로 계속 Redis에 락 획득 요청을 보내 Redis에 많은 부하를 준다.
이는 곳 락 정보에 대한 메모리 접근과 업데이트가 빈번하게 필요하기 때문에 CPU 사용량 증가로 이어진다.
다양하게 이 문제점들을 다 해결해서 구현할 수도 있겠지만
Redisson은 이 문제들을 해결한 pub/sub 기반 락 기능을 제공한다.
Redisson의 Pub/Sub Lock
우선 각 방식의 차이점을 간단히 비유하자면 다음과 같다.
스핀락은 "계속해서 물어보는 방식" : "락 있나요?" -> "없어요" -> 잠깐 쉬었다가 -> "락 있나요?" -> ...
Pub/Sub은 "테이블링 예약하고 기다리는 방식" : "락 있나요?" -> "없으니 대기번호 받고 기다리세요" -> 알림 울릴때까지 대기 → 알림이 오면 한번만 더 시도
벌써 스핀락은 정신 없이 너무 많은 요청을 하고 있다…
Pub/Sub 방식에 대해 좀 더 자세히 보면 다음과 같이 동작한다.
// 초기 상태
클라이언트 A: 락 시도 -> 성공 (락 획득)
클라이언트 B: 락 시도 -> 실패 -> 구독 신청 -> 대기
클라이언트 C: 락 시도 -> 실패 -> 구독 신청 -> 대기
// A가 작업 완료 후 락 해제
A: unlock 호출 -> Redis가 구독자들에게 메시지 발송
B, C: 메시지 수신 -> 락 재시도
// B가 락 획득 성공
B: 락 획득 -> 작업 수행
C: 락 획득 실패 -> 계속 대기 (추가 요청 없음)
이런 이유로 Pub/Sub은 효율적으로 Lock 리소스를 사용해 Redis에 부담을 주지 않을 수 있다.
이외의 Redisson 클라이언트의 특징
다양한 공급자 호환
Redisson은 Redis 및 Valkey 클라우드 공급자와 호환된다.
Valkey를 사용해보진 않았지만 검색해보면 Redis를 대체할 유망주로 떠오르는 것 같다.
AWS의 ElastiCache에선 Redis, Memcached만 제공하다가 현재는 Valkey를 제공 중이고, 할인까지 해주며 열심히 마케팅? 중이다.
이 호환성 또한 큰 것 같다.
호환되는 공급자 목록은 아래 공식 문서에서 확인할 수 있다.
https://redisson.org/docs/overview/
내장된 RLock 인터페이스 제공
RLock은 Redisson 라이브러리에서 제공하는 분산 락 인터페이스이다.
이는 Java의 동시성 제어를 위한 인터페이스인 java.util.concurrent.locks.Lock를 확장하여 분산 환경에서 동작하도록 설계되었다.
RLock의 주요 특징은 다음과 같다.
1. 재진입 가능(Reentrant)
같은 스레드에서 이미 획득한 락을 다시 획득할 수 있다.
이는 락을 획득한 메서드가 내부적으로 같은 락을 필요로 하는 다른 메서드를 호출할 때 데드락을 방지한다.
2. 락 획득 시도에 타임아웃 및 유지 시간 설정 가능
// 최대 10초간 락 획득 시도, 성공 시 30초 동안 락 유지
boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
이를 통해 무한 대기 방지 및 클라이언트가 장애가 발생하더라도 Lock을 영원히 갖지 않고 해제되어 영구적인 데드락을 방지할 수 있다.
또한 실수로 try-finally문 내에서 unlock() 호출을 누락해도 TTL에 의해 결국 락이 안전하게 해제될 수 있다.
하지만 명시적으로 unlock() 호출이 권장된다.
3. 워치독 메커니즘
다음과 같이 TTL을 설정하지 않으면 워치독 메커니즘이 활성화 된다.
RLock lock = redisson.getLock("myLock");
// lock() 호출 시 워치독 자동 활성화
lock.lock();
락을 소유한 클라이언트가 정상 작동 중일 때, 백그라운드 스레드(워치독)가 주기적으로 락의 만료 시간(TTL)을 갱신한다.
기본적으로 30초의 TTL로 설정되어 TTL의 1/3 시간마다 (10초) 락의 TTL을 다시 30초로 갱신한다.
이를 통해 긴 작업이나 실행 시간이 가변적인 작업도 락 타임아웃 걱정 없이 안전하게 수행할 수 있다.
만약 클라이언트가 비정상 종료되면 워치독 스레드가 중지되면서 TTL 갱신이 중단되여
Redis에 설정된 만료시간이 지나면 Lock은 해제된다.
4. Redis 클러스터 호환성
Redis의 트랜잭션 기능은 단일 트랜잭션이 서로 다른 노드에 있는 키를 다룰 수 없어 한계가 있지만
Redisson은 {mylock}:value, {mylock}:expiration 같은 방식으로 관련 키들이 같은 해시 슬롯에 할당되도록 하여
같은 해시 슬롯 내 키들은 같은 노드에 위치하므로 트랜잭션 원자성 보장이 가능하다.
5. 이외에도 다양한 Lock 지원
FIFO를 보장하는 페어 락, 여러 리소스에 대한 락을 원자적으로 획득하는 멀티 락 등 다양한 잠금 매커니즘을 제공한다.
https://redisson.pro/docs/data-and-services/locks-and-synchronizers/
Redisson으로 분산 락 적용하기전 설정
우선 다음과 같이 의존성을 추가해주자.
필자의 경우 spring-data-redis 의존성이 있었는데 Redisson 클라이언트를 사용하기 위해 변경해줬다.
(아래 의존성에 Redis 라이브러리도 포함)
implementation 'org.redisson:redisson-spring-boot-starter:3.38.1'
참고로 Spring Boot 버전에 따라 지원되는 의존성 버전이 다른데 아래 문서에서 확인할 수 있다.
https://redisson.org/docs/integration-with-spring/
Redis 설정 클래스
Redisson 클라이언트를 사용해 분산락을 적용할 것이기에 다음과 같이 설정했다.
@Configuration
@EnableCaching
public class RedisConfig {
// SSL/TLS 사용 시 "rediss://"로 수정
private static final String REDISSON_HOST_PREFIX = "redis://";
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer() // 단일 Redis 서버 구성
.setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort)
.setConnectTimeout(3000)
.setRetryAttempts(3);
return Redisson.create(config);
}
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofHours(1L));
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration())
.build();
}
// Redis 명령어를 직접 실행할 필요가 없다면 추가 X
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(new RedissonConnectionFactory(redissonClient()));
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
분산락 AOP 포인트컷이 될 어노테이션 생성
분산락을 적용할 코드는 다양하게 적용될 수 있다.
따라서 비즈니스 로직에만 집중하고 분산락과 관련된 모든 책임을 한 곳에 모아 사용할 수 있도록 AOP로 리팩토링 해보자.
아래는 분산락을 적용할 메서드에 붙일 어노테이션이다.
AOP를 적용할 메서드를 PointCut으로 구분하기 편하도록 어노테이션을 추가했다.
(만약 AOP에 대해 궁금하다면 아래 포스팅을 참고)
2023.05.12 - [◼ JAVA/Spring] - [Spring] 스프링 AOP(Aspect Oriented Programming)란? - @Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
@NotNull
String key();
long waitTime() default 5L; // Lock 흭득 대기 시간
long leaseTime() default 3L; // Lock 만료 시간
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
락 획득 대기 시간은 만료 시간 보다 길어야한다.
획득 대기 시간이 만료 시간이 짧다면 만료 시간까지 채워서 작업이 끝날 경우 락을 획득하지 못해 실패하는 상황이 생기기 때문이다.
만료 시간은 "로직 실행 시간 + 네트워크 지연 시간 고려"로 설정하고
획득 대기 시간은 만료 시간 보다 길면서 네트워크 지연 시간을 고려한 시간으로 설정하면 될 것이다.
TransactionHandlerForAop
Aop에서 트랜잭션을 조작하기 위한 클래스이다.
조인 포인트를 분리된 새 트랜잭션에서 실행하기 위한 메서드를 담고 있다.
@Component
@RequiredArgsConstructor
public class TransactionHandlerForAop {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceedInNewTx(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
실제 로직을 Propagation.REQUIRES_NEW로 분리한 이유거 궁금할 것이다.
결론부터 말하면 정합성을 보장하기 위해 반드시 트랜잭션 커밋 이후 락이 해제하기 위해서이다.
만약 트랜잭션이 분리되어 있지 않으면 분산락을 반환하고 increaseCountByClientType() 메서드가 종료될 때 커밋이 된다.
그럼 위 처럼 count가 2번 ++ 됐음에도 1번만 카운팅되어 데이터 정합성이 깨진다.
그럼 트랜잭션을 분리한다면 ?
새로운 트랜잭션에서 열린 increaseCountNewTx() 메서드가 커밋 된 이후에 락 해제가 보장된다.
뿐만 아니라 분산락을 적용할 범위를 특정 범위에만 적용할 수 있어 락 점유 시간을 줄일 수 있다.
DistributedLockAop Aspect 추가
@Around로 @DistributedLock 어노테이션이 붙은 메서드의 실행 전후에 RedissonLockManager를 활용하여 락을 관리하도록 했다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final TransactionHandlerForAop transactionHandlerForAop;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
String lockName = REDISSON_LOCK_PREFIX + getDynamicValue(joinPoint, distributedLock.key());
RLock rLock = redissonClient.getLock(lockName);
return acquireLock(rLock, joinPoint, distributedLock);
}
private Object acquireLock(RLock rLock, ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
String lockName = rLock.getName();
try {
log.debug("[분산락 시작] {} 획득 시도", lockName);
boolean acquired = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(),
distributedLock.timeUnit());
if (!acquired) {
log.warn("[분산락 획득 실패] {} {}초 대기 후 락 획득 실패", lockName, distributedLock.waitTime());
throw new OdyServerErrorException("다른 요청을 처리 중 입니다. 잠시 후 다시 시도해주세요.");
}
log.debug("[분산락 획득 성공] {} (유효시간: {}초)", lockName, distributedLock.leaseTime());
return transactionHandlerForAop.proceedInNewTx(joinPoint);
} catch (OdyException exception) {
throw exception;
} catch (Throwable exception) {
log.error("분산락 {} 획득 중 오류 발생", lockName, exception);
throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
} finally {
releaseLock(rLock);
}
}
private void releaseLock(RLock rLock) {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
log.debug("[분산락 해제] {}", rLock.getName());
}
}
private String getDynamicValue(ProceedingJoinPoint joinPoint, String key) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < signature.getParameterNames().length; i++) {
context.setVariable(signature.getParameterNames()[i], joinPoint.getArgs()[i]);
}
return parser.parseExpression(key).getValue(context, String.class);
}
}
Spring EL 파싱 로직의 경우 key마다 메서드 파라미터 값으로
동적인 key 값을 할당해야하는 상황이 있으면 사용할 수 있겠지만 그렇지 않다면 제거해도 된다.
분산락을 적용할 코드에 AOP 적용하기
Spring EL 표현식으로 key를 파싱하기 때문에 다음과 같이 API 클라이언트 별로 키명을 작성했다.
@Service
@RequiredArgsConstructor
public class ApiCallService {
... 생략
@DistributedLock(key = "'API_CALL_' + #clientType.name()")
public void increaseCountByClientType(ClientType clientType) {
ApiCall apiCall = findOrSaveTodayApiCallByClientType(clientType);
apiCall.increaseCount();
}
... 생략
}
동시성 테스트하기
테스트에서도 Redis에 접근하기 위해 Redis를 테스트 컨테이너로 실행해 테스트했다.
Redis 설정 방법에 대해서는 아래 포스팅을 참고하자.
(연결 설정에서 Redisson 클라이언트에 맞게 수정은 해줘야한다.)
2024.11.14 - [◼ JAVA/Spring] - [Spring] TestContainers로 Redis 테스트하기
@Import(RedisTestContainersConfig.class) // Redis 테스트 컨테이너 설정
public class ApiCallServiceTest {
@Autowired
private ApiCallService apiCallService;
@DisplayName("n명의 사용자가 API를 호출할 경우 정확히 n번 카운팅 한다.")
@Test
void concurrencyIncreaseCountByClientType() throws InterruptedException {
int TOTAL_REQUESTS = 100;
ExecutorService executorService = Executors.newFixedThreadPool(TOTAL_REQUESTS);
CountDownLatch countDownLatch = new CountDownLatch(TOTAL_REQUESTS);
for (int i = 1; i <= TOTAL_REQUESTS; i++) {
executorService.execute(() -> {
try {
apiCallService.increaseCountByClientType(ClientType.ODSAY);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await(3, TimeUnit.SECONDS);
executorService.shutdown();
executorService.awaitTermination(3, TimeUnit.SECONDS);
int actual = apiCallService.countApiCall(ClientType.ODSAY).count();
assertThat(actual).isEqualTo(TOTAL_REQUESTS);
}
}
해당 테스트를 실행하면 count가 요청한 만큼 카운팅 되는 것을 볼 수 있다.
주의사항
이 부분에서 많은 이슈를 겪어서 남겨 놓고자 한다.
Redisson을 사용한 분산락은 Lock을 획득하고 REQUIRES_NEW로 새로운 트랜잭션을 열어 원본 메서드를 실행한다.
근데 만약 increaseCountByClientType() 메서드에 분산락 어노테이션 뿐 아니라 트랜잭션 메서드도 있다면?
아니면 increaseCountByClientType() 메서드를 포함하는 상위 트랜잭션 메서드를 테스트한다면?
ExecutorService에서 동시 요청을 n번할 때마다 분산락 AOP보다 트랜잭션 AOP가 먼저 적용되서
n개의 스레드마다 커넥션을 하나씩 점유하고 시작한다.
Hikari 커넥션 풀의 기본 값은 10개인데 10번의 요청을 한다면, 10개의 커넥션이 점유되고
분산락 AOP로 새로 열리는 커넥션은 커넥션 대기 풀에 들어간다.
나머지 동시 요청들은 커넥션 대기 풀에 있는 스레드가 Lock을 해제하지 않고 그대로 있기 때문에 Lock 타임아웃으로 실패하게 된다.
즉, 해당 분산락 어노테이션이 적용된 메서드에 트랜잭션 어노테이션이 있느냐 없느냐로 테스트 결과가 달라질 수 있으니 주의하자.
필자의 프로젝트에는 클래스 상위에 readOnly 트랜잭션을 붙여 있었는데, 이 때문에 예상치 못한 결과가 나왔어서 남겨본다.
분산락 단점
지금까지 분산락에 대해 알아보고 적용까지 해보았다.
DB 부하를 줄여주고 커넥션 부족 현상을 방지한다는 측면에서는 장점이 많아보인다.
이렇게 보면 아주 좋아보이지만 당연히 완벽한 기술은 없듯이 단점도 존재한다.
분산락을 위해 Redis를 도입?
분산락을 위해 Redis를 도입하는 것은 고려해봐야한다.
추가 인프라를 도입하는 것도 비용이고 Redis에 장애가 발생하면 분산락 자체를 사용못하기 때문에
가용성을 위한 비용 그리고 관리 또한 필요하다.
따라서 현재 인프라의 환경에 맞춰 도입 여부를 결정하자.
클라이언트 비정상 종료
클라이언트가 락을 획득한 후 비정상 종료되면 락이 영구적으로 남아있을 수 있다.
이를 방지하기 위해서 만료 시간 설정이 필요하다.
Redisson의 경우에는 만료 시간 설정도 누락할 경우를 방지해준다.
만료 시간을 설정하지 않는다면 Redisson은 Watchdog 매커니즘을 사용한다.
Watchdog은 클라이언트가 살아있는 동안만 락을 유지하고, 클라이언트가 죽으면 락이 자동으로 해제되도록 보장해준다.
네트워크 파티션 문제 (스플릿 브레인)
네트워크 장애로 Redis 클러스터가 서로 통신할 수 없는 파티션으로 분리되면
레플리카가 마스터로 승격되어 두 개의 마스터가 독립적으로 작동할 가능성이 있다.
이게 바로 스플릿 브레인 현상이다.
이때 기존 Source 노드와 새로 승격된 Source는 동일한 해시 슬롯을 가지게 되는데
동일한 리소스에 대한 분산락을 동시에 획득할 수 있어 데드락이 발생할 수 있다.
이 문제를 해결하기 위해선 클러스터 source 노드를 3개 이상의 홀수개로 구성하여 과반수로 정상 노드를 판별할 수 있게 할 수 있다.
하지만 이것만으로는 완벽하게 해결하지 못한다.
CAP 이론에 따르면 CP는 챙기나 A는 어느정도 희생해야함으로 정의할 수 있을 것 같다.
스플릿 브레인 현상이 아닌 잠시 네트워크 연결이 끊겼다가 복구되는 상황이라면
Redisson의 경우 Watchdog 매커니즘을 통해서 다시 락 갱신을 계속할 수 있다.
데이터 유실 가능성
Redis의 복제본이 있다면 소스 노드에 장애 발생시 레플리카 노드가 승격되게 된다.
Redis는 전체적으로 비동기 복제 방식과 RDB 방식을 사용하는데
RDB는 설정한 시점에 대해서만 스냅샷을 갖고 있다.
따라서 소스 노드가 장애가 발생한 시점에 레플리카 노드로 아직 복제되지 않은 최신 락 정보가 있을 수 있다.
이런 경우 해당 락 정보는 유실된다.
이를 방지하기 위해 데이터 영속성을 위한 옵션 추가가 고려될 수 있다.
참고자료
'◼ Spring' 카테고리의 다른 글
분산 서버 환경에서 스케줄링 작업 중복 실행 제어하기 (0) | 2025.03.14 |
---|---|
REQUIRES_NEW에 대한 오해와 주의할 점 (1) | 2024.11.26 |
외부 API 의존성 분리 및 장애 대응 체계 구축하기 (0) | 2024.11.19 |
[Spring] TestContainers로 Redis 테스트하기 (2) | 2024.11.14 |
개발 운영 환경과 비슷한 로컬 환경 구축하기 (feat. TestContainer) (4) | 2024.10.16 |