분산락을 적용해 동시성 문제 해결하기

반응형

분산락이란?

분산된 서버에서 공유 자원에 동시에 접근 시 발생할 수 있는 문제를 제어하기 위해 사용되는 방식이다.

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로 분산 락을 적용하는 방식이다.

비관적 락과 차이점은 Lock 이름으로 애플리케이션에서 Lock을 관리한다는 점이다.

하지만 이 또한 운영에 사용하는 DB 부하는 동일하다.

그리고 Lock을 사용하기 위한 별도 커넥션 풀 구성이 필요하다.

배민의 서비스 중 MySQL을 사용해 분산락을 적용한 내용이 있어 첨부한다.

https://techblog.woowahan.com/2631/

 

Redis를 사용한 분산락 결정적 이유

현재 우리 서비스는 소요 시간 계산에 사용되는 외부 API들을 관리하기 위해 Redis로 TTL을 적용해 장애 대응 체계를 적용했다.

2024.11.19 - [◼ JAVA/Spring] - 외부 API 의존성 분리 및 장애 대응 체계 구축하기

따라서 Redis는 이미 도입되어 있어 부담이 없다.

 

낙관적 락은 제외하고 비관적 락을 고민해볼 수도 있지만 DB는 엄청 바쁘다.

그리고 무엇보다 중요한건 낙관적, 비관적 락은 특정 엔티티에 대한 동시 접근 제어이다.

따라서 이미 존재하는 엔티티에 대해서만 동시성을 제어할 수 있다.

 

하지만 현재 우리 로직에서 하루 단위로 API 호출 정보를 확인하기 위해

다음과 같이 현재 날짜에 조회 결과가 없으면 새로운 엔티티를 생성하고 저장한다.

private ApiCall findOrSaveFirstByClientTypeAndDate(ClientType clientType) {
    return apiCallRepository.findFirstByDateAndClientType(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에 많은 부하를 준다.

이는 곳 메모리 사용량 증가로 이어진다.

다양하게 이 문제점들을 다 해결해서 구현할 수도 있겠지만

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은 Redis 및 Valkey 클라우드 공급자와 호환된다.

Valkey를 사용해보진 않았지만 검색해보면 Redis를 대체할 유망주로 떠오르는 것 같다.

AWS의 ElastiCache에선 Redis, Memcached만 제공하다가 현재는 Valkey를 제공 중이고, 할인까지 해주며 열심히 마케팅? 중이다.

이 호환성 또한 큰 것 같다.

호환되는 공급자 목록은 아래 공식 문서에서 확인할 수 있다.

https://redisson.org/docs/overview/


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 설정 클래스

@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;
    }
}

 

분산 락 적용

일단 코드가 더러운데 분산 락 코드 흐름을 이해하기 편하도록 아래와 같이 코드를 구성했다.

(리팩토링은 다음 목차에서 진행한다.)

private static final long WAIT_TIME = 3L;
private static final long LEASE_TIME = 5L;

private final RedissonClient redissonClient;
...

@Transactional
public void increaseCountByClientType(String clientType) {
    String lockName = "LOCK:" + clientType;
    RLock lock = redissonClient.getLock(lockKey);
    log.debug("[분산락 시작] {} 획득 시도", lockName);
    
    try {
        boolean available = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
        if (!available) {
		        log.warn("[분산락 흭득 실패] {} {}초 대기 후 락 획득 실패", lockName, WAIT_TIME);
            throw new OdyServerErrorException("다른 요청을 처리 중 입니다. 잠시 후 다시 시도해주세요.");
        }
        // 새로운 트랜잭션으로 실행
        increaseCountNewTx(clientType);
        log.debug("[분산락 흭득 성공] {} (유효시간: {}초)", lockName, LEASE_TIME);
    } catch (InterruptedException exception) {
        Thread.currentThread().interrupt();
        log.error("[분산락 오류] {} 획득 중 인터럽트 발생", lockName, exception);
        throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.debug("[분산락 해제] {} 정상 해제", lockName);
        }
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
private void increaseCountNewTx(String clientType) {
    ApiCall apiCall = findOrSaveFirstByClientTypeAndDate(clientType);
    apiCall.increaseCount();
}

 

실제 로직을 Propagation.REQUIRES_NEW로 분리한 이유거 궁금할 것이다.

결론부터 말하면 정합성을 보장하기 위해 반드시 트랜잭션 커밋 이후 락이 해제하기 위해서이다.

만약 트랜잭션이 분리되어 있지 않으면 분산락을 반환하고 increaseCountByClientType() 메서드가 종료될 때 커밋이 된다.

트랜잭션을 분리하지 않을 경우

그럼 위 처럼 count가 2번 ++ 됐음에도 1번만 카운팅되어 데이터 정합성이 깨진다.

 

그럼 트랜잭션을 분리한다면 ?

새로운 트랜잭션에서 열린 increaseCountNewTx() 메서드가 커밋 된 이후에 락 해제가 보장된다.

 

동시성 테스트하기

테스트에서도 Redis에 접근하기 위해 Redis를 테스트 컨테이너로 실행해 테스트했다.

Redis 설정 방법에 대해서는 아래 포스팅을 참고하자.

(연결 설정에서 Redisson 클라이언트에 맞게 수정은 해줘야한다.)

2024.11.14 - [◼ JAVA/Spring] - [Spring] TestContainers로 Redis 테스트하기

 

@DisplayName("Redisson 분산락 동시성 테스트")
@Import(RedisTestContainersConfig.class) // Redis 테스트 컨테이너 설정
public class RedissonDistributedLockTest {

    @Autowired
    private ApiCallService apiCallService;

    @DisplayName("100명의 사용자가 동시에 API를 호출할 경우 정확히 count+100 한다.")
    @Test
    void concurrencyIncreaseCountByRouteClient() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(100);
        CountDownLatch countDownLatch = new CountDownLatch(100);

        fixtureGenerator.generateApiCall(ClientType.ODSAY, 0, LocalDate.now());

        ClientType clientType = new StubOdsayRouteClient().getClientType();
        for (int i = 1; i <= 100; i++) {
            executorService.execute(() -> {
                try {
                    apiCallService.increaseCountByClientType(clientType);
                } 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(100);
    }
    
    @DisplayName("동시에 100개 요청 중 절반이 예외가 발생하면 해당 트랜잭션은 롤백되어 count+50 한다.")
    @Test
    void concurrencyIncreaseCountByRouteClientAndRollBack() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(TOTAL_REQUESTS);
        CountDownLatch countDownLatch = new CountDownLatch(TOTAL_REQUESTS);
        ClientType clientType = new StubOdsayRouteClient().getClientType();

        for (int i = 1; i <= TOTAL_REQUESTS; i++) {
        	final int index = i;
            executorService.execute(() -> {
                try {
                    if (index % 2 == 0) {
                        throw new RuntimeException();
                    }
                    apiCallService.increaseCountByClientType(clientType);
                } 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 / 2);
    }
}

해당 테스트를 실행하면 count가 0에서 100까지 update 되는 것을 볼 수 있다.

 

참고로 위에서 increaseCountByClientType() 로직은 엔티티가 없을 때 새로운 엔티티를 생성한다 했다.

따라서 해당 테스트에서 fixtureGenerator.generateApiCall()는 제거해도 분산 락 테스트에서는 문제가 없다.

하지만 비관적 락을 적용한 뒤에 테스트하면 엔티티가 존재하지 않아 의도한 결과 나오지 않는다.


AOP로 리팩토링하기

위에서 설명한 분산락 코드는 비즈니스 로직과 분산락 로직이 섞여있다.

그리고 분산락을 적용할 코드는 다양하게 적용될 수 있다.

따라서 비즈니스 로직에만 집중하고 분산락과 관련된 모든 책임을 한 곳에 모아 사용할 수 있도록 AOP로 리팩토링 해보자.

 

만약 AOP에 대해 궁금하다면 아래 포스팅을 참고하자.

2023.05.12 - [◼ JAVA/Spring] - [Spring] 스프링 AOP(Aspect Oriented Programming)란? - @Aspect

 

AOP가 적용된 코드 먼저 보기

우선 AOP 코드 먼저 보면 헷갈릴 수 있어서 AOP 코드를 적용한 코드를 먼저 첨부한다.

Spring EL 표현식으로 Lock 이름을 자유롭게 전달할 수 있도록 다음과 같이 구성했다.

@Transactional
@DistributedLock(key = "#clientType")
public void increaseCountByClientType(String clientType) {
    ApiCall apiCall = findOrSaveFirstByClientTypeAndDate(clientType);
    apiCall.increaseCount();
}

 

DistributedLock 어노테이션 추가

분산락을 적용할 메서드에 붙일 어노테이션이다.

AOP를 적용할 메서드를 PointCut으로 구분하기 편하도록 어노테이션을 추가했다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    @NotNull
    String key();

    long waitTime() default 3L; // Lock 흭득 대기 시간

    long leaseTime() default 5L; // Lock 보유 시간 (부하 분산 서버 다운 문제 해결을 위한 시간)

    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

 

RedissonLockManager 추가

분산락 관리를 담당할 클래스이다.

RedissonClient를 의존성 주입해 락 획득과 해제를 담당한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockManager {

    private final RedissonClient redissonClient;
    private final TransactionTemplate transactionTemplate;

    public <T> T lock(Supplier<T> supplier, String lockName, DistributedLock distributedLock) {
        RLock rLock = redissonClient.getLock(lockName);
        log.debug("[분산락 시작] {} 획득 시도", lockName);

        try {
            acquireLock(rLock, lockName, distributedLock);
            return executeWithTransaction(supplier);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            log.error("[분산락 오류] {} 획득 중 인터럽트 발생", lockName, exception);
            throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
        } finally {
            releaseLock(rLock, lockName);
        }
    }

    private void acquireLock(RLock rLock,String lockName,DistributedLock distributedLock) throws InterruptedException {
        long waitTime = distributedLock.waitTime();
        long leaseTime = distributedLock.leaseTime();
        TimeUnit timeUnit = distributedLock.timeUnit();
        boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
        if (!available) {
            log.warn("[분산락 획득 실패] {} {}초 대기 후 락 획득 실패", lockName, waitTime);
            throw new OdyServerErrorException("다른 요청을 처리 중 입니다. 잠시 후 다시 시도해주세요.");
        }
        log.debug("[분산락 획득 성공] {} (유효시간: {}초)", lockName, leaseTime);
    }

    private <T> T executeWithTransaction(Supplier<T> supplier) {
        return transactionTemplate.execute(transactionStatus -> supplier.get());
    }

    private void releaseLock(RLock rLock, String lockName) {
        if (rLock.isHeldByCurrentThread()) {
            rLock.unlock();
            log.debug("[분산락 해제] {} 정상 해제", lockName);
        }
    }
}

 

TransactionTemplate을 의존성 주입한 이유

Propagation.REQUIRES_NEW로 트랜잭션을 분리했을 때 문제점이 있는데

커넥션을 하나 더 쓰고 이 커넥션이 부족하면 데드락으로 이어질 수도 있다.

 

여담이지만 트랜잭션이 분리되어 각 트랜잭션이 독립적으로 작업을 처리해 예외가 전파가 안된다고 생각했었는데

실제 테스트를 해보니 착각이였다.

실제론 두 개의 트랜잭션이 있는 것 처럼 보이지만 동일 스레드 내에서 별도의 트랜잭션을 생성해

REQUIRES_NEW로 분리한 트랜잭션이 예외가 발생해도 이를 호출한 트랜잭션이 롤백되었었다.

(이 문제 관련해서는 아래 포스팅에서 설명한다.)

2024.11.26 - [◼ JAVA/Spring] - REQUIRES_NEW에 대한 오해와 주의할 점

 

하여튼 로직을 보면 lock 메서드에서 return할 때말고는 Redis 관련 로직이고

Redis는 단일 스레드로 각 작업이 원자적으로 실행되어 트랜잭션이 필요가 없다.

즉, 실제 비즈니스 로직이 호출될 supplier.get()에서만 트랜잭션이 적용되면된다.

따라서 트랜잭션 템플릿을 사용해 실제 메서드를 호출하는 supplier.get() 메서드에만 트랜잭션이 적용되도록 했다.

단, 이렇게 적용하면 supplier.get()에 적용된 트랜잭션보다 선행된 트랜잭션이 없어야한다.

아래 코드를 보자.

 

DistributedLockAop Aspect 추가

@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonLockManager redissonLockManager;

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String lockName = REDISSON_LOCK_PREFIX + getDynamicValue(joinPoint, distributedLock.key());

        return redissonLockManager.lock(
                () -> proceedWithJoinPoint(joinPoint),
                lockName,
                distributedLock
        );
    }
    
    private Object proceedWithJoinPoint(ProceedingJoinPoint joinPoint) {
        try {
            return joinPoint.proceed();
        } catch (OdyException exception) {
            throw exception;
        } catch (Throwable throwable) {
            log.error("분산락 작업 처리중 에러 발생 : ", throwable);
            throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
        }
    }

	// Spring EL 표현식으로 되어 있는 key를 파싱하는 로직
    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);
    }
}

 

@Order(Ordered.HIGHEST_PRECEDENCE + 1)

스프링에서 빈이나 AOP 의 우선순위를 설정하는 것이다.

Ordered.HIGHHEST_PRECEDENCE에 가까울 수록 우선순위가 높다.

(+1 하지 않고 Ordered.HIGHHEST_PRECEDENCE로 지정할 수 있지 않냐할 수 있는데 스프링 내부 컴포넌트와 충돌날 수 있다.)

제일 높은 우선순위를 줌으로써 supplier.get()에 적용된 트랜잭션보다 선행된 트랜잭션이 없도록 했다.

이로써 서비스 클래스에선 @DistributedLock를 사용하는 메서드에 트랜잭션을 붙여도 안붙여도 된다. -> 유연해짐

제일 높은 우선순위를 가져 supplier.get() 트랜잭션이 제일 먼저 열리기 때문이다.


분산락을 적용하는 트랜잭션의 전파 레벨에 관해

락의 범위에 대해서 고민하는 것은 중요하다고 생각하고, 필자의 경우 전파 레벨 기본값인 REQUIRED를 사용해 분산락의 범위가 크긴하다.

REQUIRES_NEW로 트랜잭션을 분리해 분산락 적용 범위를 좁힐 수 있고 이것 또한 분산락의 장점이라고도 생각한다.

하지만 커넥션을 하나 더 쓰기 때문에 100번의 요청을 했다면 hikari 풀의 최대 사이즈는 10이라 요청 횟수를 줄일 필요가 있거나

테스트 상의 커넥션 풀 갯수를 고려해야한다.

 

현재 API 카운팅되는 로직들 중 '약속 참여 API'의 경우 다음과 같은 흐름을 갖는다.

1. Mate 저장
2. 외부 API를 호출해 소요 시간 계산
3. API 호출 횟수 카운팅
4. ETA(도착 예정 시간) 정보 저장
5. 입장 알림 저장 및 발송
6. 출발 알림 저장 및 발송 예약

 

도착지까지의 소요시간을 View에 보여주고 출발 알림 시간을 예약해 보내주는 것이 서비스의 핵심 기능이라

1 ~ 6번까지는 원자성이 보장되어야 생각할 수 있지만 API를 호출하면 호출을 취소하지 못한다 이미 호출해버린 것이다.

트랜잭션 범위가 크기 때문에 병목현상을 우려할 수 있지만

병목 현상이 그렇게 크게 체감되는 상황이 아직 발생하지 않아 쉽게 파악할 수 없었다.

 

병목 현상이 길어진다면 REQUIRES_NEW로 분산락 적용 범위를 좁혀보는 방식을 선택할 수 있다.

하지만 REQUIRES_NEW도 커넥션을 하나 더 써 추가적인 사이드 이펙트를 발생시킬 수도 있다.

또한 병목 현상으로 지연 시간이 불편할 정도로 길어지는 모니터링을 하지 않고는 쉽게 판단할 수 없다.

(트랜잭션을 분리하면 Ordered.HIGHHEST_PRECEDENCE를 지정하지 않아도 됨)

병목 현상에 대해서는 모니터링을 통해 시간을 판단할 필요가 있을 것 같다.

 

트랜잭션을 분리하는 것 말고 분산락의 범위를 효율적으로 좁힐 수 있는 다른 대안책들도 있는 듯 하다.

추후에 좋은 방법을 찾는다면 적용해 포스팅을 수정할 예정이다.


지금까지 분산락에 대해 알아보고 적용까지 해보았다.

마지막으로 분산락을 적용하면서 중요하게 생각했던 부분들을 체크해보았다.

 

1. 정합성이 보장되는가? O

  • 락을 획득한 후에만 작업을 수행하고, 트랜잭션 커밋 후 finally 블록에서 확실히 락을 해제
  • tryLock()에서 waitTime(대기 시간)과 leaseTime(락 점유 시간)을 설정하여 데드락 방지
  • 락 획득 실패 시 빠르게 예외를 던져서 응답

2. 네트워크 단절로 인해 시스템이 분리 문제(로드 밸런싱 헬스 체크 실패 등)가 해결 되는가? O

  • Redisson의 leaseTime(락 점유 시간) 설정으로 락의 자동 해제 보장

참고자료