개발놀이터

Spring AOP 와 Redis의 분산락이 만나면 본문

Spring/Spring

Spring AOP 와 Redis의 분산락이 만나면

마늘냄새폴폴 2024. 7. 6. 16:16

일단 포스팅은 아래의 링크를 참고하였습니다. 

 

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

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com

 

컬리에서 다양한 동시성 문제를 앓았다고 합니다. 

 

1. 동시에 요청하는 사용자

2. Kafka에서 동시에 보내는 메세지

등등

 

이런 상황에서 다양한 동시성 문제가 있었고 이를 처음엔 RDBMS의 분산락으로 해결했다고 하더군요. 하지만 락킹 매커니즘을 RDBMS가 담당해야 하기 때문에 데이터베이스에 부하가 심하게 걸린다는 사실을 알아내셨습니다. 

 

때문에 Redis의 분산락을 이용하기로 결정했고 이를 스프링과 잘 조합하여 성공적으로 동시성문제를 해결하셨다고 하시네요. 

 

이를 공부해보고 실습해서 결과까지 정리해봤습니다. 

 

동시성문제와 분산락

동시성 문제는 흔히 알려진 문제로 이는 데이터베이스의 트랜잭션과 관련된 문제입니다. 서로 다른 트랜잭션이 락킹 매커니즘 없이 데이터에 접근하여 데이터의 값이 꼬여버리는 상황이죠. 

 

운영체제에서, 그리고 컴퓨터공학에서 많이 언급되는 race condition입니다. 

 

race condition을 해결하는 방법은 여러가지가 있는데요. 운영체제에선 세마포어와 뮤텍스라는 개념이 나오고 Java에선 언어적으로 synchronized 키워드를 제공해주죠. 

 

그리고 이 synchronized 키워드를 적절히 사용한 JDBC 드라이버가 있습니다. 이 JDBC 드라이버를 이용해서 만든 JDBC Template이나 JPA같은 ORM, MyBatis와 같은 것들에서 다양한 락킹 매커니즘을 제공해줍니다. 

 

RDBMS에선 흔히 비관적락과 낙관적락을 얘기할 수 있을 것 같습니다. 

 

해당 내용은 아래의 링크에 자세히 설명되어있습니다. 

 

https://coding-review.tistory.com/386

 

스프링에서 동시성 문제 해결하기

프로젝트를 고도화하는 과정에서 동시성 문제에 대해 고민하게 되었습니다. 동시성 문제... 참 쉽지 않더군요... 우선 정말 추상적이고 해결 방법도 정말 많습니다. 이번 포스팅에선 동시성 문제

coding-review.tistory.com

 

동시성 문제가 발생하는 상황 하나를 실습해보고 처음엔 RDBMS의 비관적락을 이용해서 해결해보고 Redis의 분산락을 이용해서 해결해보도록 하겠습니다. 

 

@Transactional
public Item updatePrice() {
    Item item = itemRepository.findById(2L).orElse(null);
    int price = item.getPrice();
    price++;
    item.setPrice(price);
    itemRepository.save(item);
    return item;
}

 

updatePrice()를 호출하면 하나를 가져와서 price를 하나 올려주고 데이터를 저장하는 로직입니다 .

 

굉장히 간단합니다. 

 

이제 이걸 멀티스레드 환경에 넣고 돌려보겠습니다. 

 

@Test
public void concurrencyWithUpdateQuery() throws Exception {
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch latch = new CountDownLatch(100);

    for (int i = 0; i < 100; i++) {
        executorService.execute(() -> {
            try {
                itemService.updatePrice();
            }
            finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Item concurrency = itemRepository.findByItemName("concurrency");

    assertThat(concurrency.getPrice()).isEqualTo(100);
}

 

 

결과는 이렇게 원하는대로 나오지 않았습니다. 

 

예상가능한 상황이었죠. 이제 비관적락을 이용해서 락을 걸어보겠습니다. 

 

 

이렇게 읽기 쿼리에 Pessimistic Lock을 걸었습니다. 

 

이제 실행해보면?

 

 

정상적으로 완료가 된 것을 확인할 수 있습니다. 

 

이렇게 해결은 했지만 컬리의 백엔드 팀에선 이렇게 하는 경우 RDBMS에 부하가 걸려 좋은 상황은 아니라고 하더라구요. 이를 위해 Redis의 Redisson을 사용했다고 전해집니다. 

 

왜 Lettuce가 아니고 Redisson이냐하면 Lettuce는 자체적인 락킹 매커니즘을 가지고 있지 않아서 Redis에 수시로 Lock이 있는지 확인해야하는 작업을 거쳐야하기 때문에 RDBMS의 부하를 그대로 Redis가 가져가게 됩니다. 

 

하지만 Redisson은 Pub/Sub 메세징으로 Lock이 있는지 없는지 확인하기 때문에 Redis에 부하가 걸리지 않아 Redisson을 사용했다고 하더군요. 

 

이제 한번 Redis의 Redisson을 사용해서 해결해보겠습니다. 

 

Redis의 분산락을 이용해 동시성 문제 해결하기

우선 어노테이션을 먼저 만들어야합니다. 

 

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

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;
}

 

key는 실제로 key가될 값이고 waitTime은 락을 획득하기위해 커넥션이 기다리는 시간을 말합니다. leaseTime은 이 시간 이후에 락을 획득하고 락을 놓아주는 시간입니다. 

 

그리고 RedisConfig 파일에 다음과 같은 설정을 해줍니다. 

 

package com.example.firstapplication.config;

@Configuration
@EnableRedisHttpSession
@EnableRedisRepositories
@Slf4j
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(String.format("redis://%s:%d", host, port));
        redisson = Redisson.create(config);
        return redisson;
    }
}

 

그리고 이제 Parser를 하나 만들어줄겁니다. Parser는 전달받은 Lock의 이름을 Spring Expression Language로 파싱해서 읽어오기 위해 필요한 클래스입니다. 

 

public class CustomSpringELParser {

    private CustomSpringELParser() {}

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

 

그리고 AOP를 위한 Transaction을 하나 만들어주도록 하겠습니다. 

 

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

 

AOP를 이용할건데 AOP의 joinPoint에 트랜잭션을 걸기위해 사용하는 클래스입니다. 따로 빼주는 것 같네요. 

 

그리고 이제 제일 중요한 AOP를 만들어봅시다. 

 

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.example.firstapplication.annotation.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
        RLock rLock = redissonClient.getLock(key);

        boolean available = false;
        try {
            available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                return false;
            }

            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Thread was interrupted while acquiring lock", e);
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already Unlock {} {}", method.getName(), key);
            }
        }
    }
}

 

순서를 잘보시면 컬리팀의 숨겨진 디테일이 있습니다. 

 

  1. 락을 획득하고
  2. 락이 사용가능한지 확인한 뒤
  3. aopForTransaction.proceed()를 이용해 트랜잭션을 겁니다. 그리고 이 행동이 완료되면 커밋을 하겠죠.
  4. 그리고 락을 반납합니다. 

컬리팀에선 무조건 트랜잭션의 커밋을 락을 반납하기 전에 하도록 하였는데 이 순서가 매우 중요합니다. 

 

 

이렇게 락을 해제하고 커밋을 하게 되면 Client2가 락을 획득하고 트랜잭션을 시작했을 때 값이 맞지않아 문제가 생길 수 있습니다. 네, race condition이죠. 

 

그래서 커밋을 락을 해제하기 전에 반드시 해줌으로써 이 값이 꼬이지 않게 해주는 것 같습니다. 

 

 

자 이제 코드도 다 생성했고 원리도 이해했으니 실험을 해보겠습니다. 

 

package com.example.firstapplication.service;

import com.example.firstapplication.annotation.DistributedLock;
import com.example.firstapplication.entity.Item;
import com.example.firstapplication.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public Item updatePrice() {
        Item item = itemRepository.findById(2L).orElse(null);
        int price = item.getPrice();
        price++;
        item.setPrice(price);
        itemRepository.save(item);
        return item;
    }

    @DistributedLock(key = "#key")
    public Item distributedLock(String key) {
        Item item = itemRepository.findById(2L).orElse(null);
        int price = item.getPrice();
        price++;
        item.setPrice(price);
        return item;
    }
}

 

@Test
public void concurrencyControlWithDistributedLock() throws Exception {
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch latch = new CountDownLatch(100);

    for (int i = 0; i < 100; i++) {
        executorService.execute(() -> {
            try {
                itemService.distributedLock("key");
            }
            finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Item item = itemRepository.findById(2L).orElse(null);

    assertThat(item.getPrice()).isEqualTo(100);
}

 

이렇게 실험을 해보면? 

 

 

똑같이 성공하는 모습을 볼 수 있습니다. 

 

 

정리

RDBMS의 비관적 락과 Redis의 분산락을 이용해서 동시성 문제를 해결해봤습니다. 다시 정리하자면 다음과 같습니다. 

 

  • RDBMS의 비관적 락으로 동시성 문제를 해결하는 것은 RDBMS가 락킹 매커니즘을 처리해야하기 때문에 부하가 심하다.
  • 스프링 진영에서 Redis를 구현한 라이브러리인 Lettuce와 Redisson중 Redisson을 사용해야한다. 
  • 그 이유는 Lettuce는 스핀락이라고 하는 Redis에 지속적으로 Lock이 있는지 요청해야하기 때문이다. 
  • Redisson은 Pub/Sub 메세징으로 Lock을 검사해서 Redis에 부하가 없다. 
  • 락을 해제하는 시점은 트랜잭션이 커밋하고 난 뒤에 해야한다. 
  • try/catch문이 반복적으로 등장하는 공통 관심사를 AOP를 이용해서 깔끔하게 어노테이션으로 해결할 수 있다. 

 

또한, 실제로 부하 차이만 있지 성능 차이는 거의 비슷한 것으로 보여집니다. 

 

비관적락

 

Redis 분산락

 

 

마치며

나중에 시간이 된다면 JMeter나 NGrinder를 이용해서 서버 부하에 대한 성능 비교까지 해보도록 하겠습니다. 다음 주제는 그게 될 것 같네요. 

 

Redis... 이자식 생각보다 엄청나게 쓸모가 많네요. Pub/Sub 메세징이 지원된다는 것 자체가 엄청난 메리트가 있을 것 같습니다. 

 

오늘도 긴 글 읽어주셔서 감사합니다. 즐거운 하루 되세요~

 

 

출처

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

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com