[Spring] Redis Redisson과 AOP를 이용하여 분산락 구현하기

2023. 10. 13. 05:55

문제 상황

서비스 개발 중 여러 쓰레드에서 발생하는 작업에 대한 처리가 필요한 상황이 있습니다.

사실 우리 서비스는 아주 영세..해서 여러 사용자들의 동시 요청에 대한 처리를 할 필요는 없는데요.

하지만 학습은 좋은거니까..^^ redis에 대해서, 그리고 데이터 원자성을 보장하는 방법에 대해 공부할 겸 redis를 사용한 분산락을 구현하였습니다.

 

락(Lock)을 구현하는 방법

고유락/모니터락

자바는 멀티스레드 환경에서 동기화를 지원하기 위해 가장 기초적인 장치인 고유락(Intrinsic Lock)을 제공합니다.

Java의 synchronized 키워드가 이의 예시이며 synchronized 블록은 객체 단위로 락을 다룹니다.

Portfolio Entity에 synchronized 으로 락이 걸린 메소드를 정의하여 간단한 코드로 이를 테스트해보았습니다.

public synchronized void increaseLikeCount() { 
    this.likeCount++; 
}
@Test  
@RepeatedTest(3)  
public void test_concurrency_sync_method() throws Exception {  
    //given  
    int numberOfThreads = 1000;  
    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);  
    CountDownLatch latch = new CountDownLatch(numberOfThreads);  

    //when  
    for (int i = 0; i < numberOfThreads; i++) {  
        service.execute(() -> {  
            portfolios.increaseLikeCount();  
            latch.countDown();  
        });  
    }  

    //then  
    latch.await();  
    Assertions.assertThat(portfolios.getLikeCount()).isEqualTo(numberOfThreads); 
}
  1. synchronized 키워드를 걸지 않은 경우
  1. synchronized 키워드를 건 경우

 

그렇다면 Synchronized 키워드로 충분할까?

synchronized 키워드로 멀티 쓰레드 환경에서 발생하는 동시성 문제를 모두 해결할 수 있을까요? 아니오

synchronized 키워드는 단일 JVM(Java Virtual Machine) 내에서 스레드 간의 상호 배타적인 접근을 제어하기 위한 목적으로 사용됩니다. 즉, 하나의 JVM 내에서 실행 중인 스레드 간에만 동작하며 분산 환경에서는 작동하지 않는 것이죠.

다수의 서버 노드 또는 프로세스 간에 공유 자원에 대한 상호 배타적 액세스를 관리하기 위해서는 ZooKeeper, Redis, MySql와 같은 다른 도구를 활용해야 합니다.

우리 서비스에서는 auto scaling 된 다수의 EC2 서버, 그리고 추가적으로 배치 람다 서버를 두어 같은 데이터베이스에 접근하고자 하기 때문에 synchronized 키워드 말고 다른 대안을 적용해야 하였습니다.

 

분산락 구현 기술 선택

 

분산락이란?

경쟁 상황(race condition)에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic)을 보장하는 기법 입니다.

 

분산락 구현 기술

분산락을 구현할 수 있는 ZooKeeper, Redis, MySql 중 Redis, 그 중에서도 Redis Redisson을 선택하였다.

Redis Redisson을 사용한 가장 첫번째로 이미 해당 기술 스택을 사용 중이어서 추가 인프라 구축을 할 필요가 없다는 점이 가장 컸습니다.

MySQL도 사용 중이었지만 락을 사용하기 위해 별도의 커넥션 풀을 관리해야 한다는 점, 그리고 락에 관련된 부하를 RDS에서 받는다는 점이 큰 단점으로 여겨졌습니다.

 

MySQL에서 분산락을 구현하는 방법(네임드락)

MySQL을 이용하여 분산락을 구현하는 방법에 대해 간략하게 알아보자면

MySQL 네임드락의 GET_LOCK()을 통해 락을 획득할 수 있고, RELEASE_LOCK()을 통해 해제될 수 있으며 이를 이용해 분산락을 구현할 수 있습니다.

이를 Spring 프레임워크에서는 JdbcTemplate나 JPA를 통해 관리할 수 있습니다.

 

Redis Redisson을 선택한 이유

redis에서 분산락을 구현하기 위해서는 데이터의 원자성을 보장하는 SETNX 연산을 이용할 수 있습니다.

SETNX는 SET if NOT eXist의 줄임말로 키가 존재하지 않을 경우 값을 지정하는 방식으로 동작합니다.

기존에 사용하고 있던 redisTemplate의 setIfAbsent 함수를 사용하여 SETNX 명령어를 사용할 수 있습니다.

 

@Component 
@RequiredArgsConstructor 
public class Lock { 
    private final StringRedisTemplate redisTemplate; 

    public boolean lock(String key, long ttl) { 
        return redisTemplate.opsForValue().setIfAbsent(key, "locked", ttl);
    } 
    public void unlock(String key) { redisTemplate.delete(key); } }

 

하지만 Redisson 라이브러리를 사용하게 된 이유는,

 

1. Lettuce로 분산락을 사용하기 위해서는 setnx, setex 등을 이용해 분산락을 직접 구현해야 합니다. 개발자가 직접 retry, timeout과 같은 기능을 구현해 주어야 한다는 번거로움이 있습니다. 이에 비해 Redisson 은 별도의 Lock interface를 지원합니다. 락에 대해 타임아웃과 같은 설정을 지원하기에 락을 보다 안전하게 사용할 수 있습니다.

 

2.Lettuce는 분산락 구현 시 setnx, setex과 같은 명령어를 이용해 지속적으로 Redis에게 락이 해제되었는지 요청을 보내는 스핀락 방식으로 동작합니다. 요청이 많을수록 Redis가 받는 부하는 커지게 됩니다. 이에 비해 Redisson은 Pub/Sub 방식을 이용하기에 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 됩니다.

 

Redis Redisson으로 분산락 구현하기

 

분산락 처리 로직을 비즈니스 로직과 분리하기 위해 AOP와 커스텀 어노테이션을 활용하여 구현하였습니다.

 

build.gradle

프로젝트의 빌드 파일에는 org.redisson:redisson-spring-boot-starter 종속성을 추가하여 Redisson을 프로젝트에 포함시킵니다.

// redisson  
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'

RedissonConfig

redissonClient 빈을 생성하고 Redis 호스트 및 포트를 설정합니다.

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host}")
    private String redisHost;

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

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}

DistributedLockAop

AOP (Aspect-Oriented Programming)를 사용하여 분산 락을 적용합니다.
@Around 어노테이션을 사용하여 (@DistributedLock)이 붙은 메서드 주위에서 작동하고 DistributedLock 어노테이션을 사용하여 락을 설정하고 해제하는 메서드를 실행합니다.

 

IllegalMonitorStateException이 발생하는 이유는?

leaseTime이 지나 자동으로 해제된 lock을 다시 unlock 하려고 했기 때문입니다. 따라서 락은 이미 풀린 것이기 때문에 로그 남기고 넘어가면 되는 오류 입니다.

 

DistributedLock


락의 키(key), 시간 단위(timeUnit), 대기 시간(waitTime), 락 유지 시간(leaseTime)을 설정합니다.

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

    String key();  

    TimeUnit timeUnit() default TimeUnit.SECONDS;  

    long waitTime() default 5L;  

    long leaseTime() default 3L;  
}

 

AopForTransaction

 

REQUIRES_NEW를 적용하지 않으면, 위처럼 분산락을 진입하기 이전의 트랜잭션과 동일한 트랜잭션을 분산락 안에서 수행되게됩니다.

그렇게 되면 커밋할 때 까지가 분산락이 끝나는 시점이 아닌 , 상위 트랜잭션 영역까지 넓어지므로, 결국 동시성 이슈가 생기게 되어있습니다. ( 분산락 끝나자마자 다른 쓰레드가 들어와서 업데이트 쳐버린경우 나중에 덮어씌기가 되기 때문에)

따라서 분산 락을 사용하는 메서드가 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하여 각각의 독립적인 트랜잭션으로 실행되게 합니다.

이는 반드시 트랜잭션 커밋 후에 락이 해제되게끔 해줍니다. 트랜잭션 커밋 시점이 락 해제 시점보다 일러야 동시성 환경에서 데이터 정합성을 보장할 수 있습니다.

 

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("aop = {}"+joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

 

CustomSpringELParser

 

CustomSpringELParser 는 전달받은 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);
    }
}

 

테스트해보기

 

Redis를 사용한 테스트 환경을 구축하기 위해 TestContainer를 사용하였습니다.

@Test  
@DisplayName("좋아요 분산락 테스트")  
    public void test_like_distribution_lock_10명() throws InterruptedException {  
    int numberOfThreads = 10;  
    ExecutorService executorService = Executors.newFixedThreadPool(30);  
    CountDownLatch latch = new CountDownLatch(numberOfThreads);  

    for (int i = 0; i < numberOfThreads; i++) {  
        executorService.submit(() -> {  
            try {  
                // 분산락 적용 메서드 호출
                                    likesService.updateLikeCountByLock(String.valueOf(portfolios.getPortfolioId()),portfolios.getPortfolioId(), LikeEnum.PORTFOLIO, true);  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  
    latch.await();  

    Portfolios persistPortfolio = portfoliosRepository.findById(portfolios.getPortfolioId())  
        .orElseThrow(IllegalArgumentException::new);  

    Assertions.assertThat(persistPortfolio.getLikeCount()).isEqualTo(numberOfThreads);  
    System.out.println("좋아요 개수" + persistPortfolio.getLikeCount());  
}

@Test  
@DisplayName("좋아요 분산락 테스트")  
public void test_like_distribution_lock_100명() throws InterruptedException {  
    int numberOfThreads = 100;  
    ExecutorService executorService = Executors.newFixedThreadPool(30);  
    CountDownLatch latch = new CountDownLatch(numberOfThreads);  

    for (int i = 0; i < numberOfThreads; i++) {  
        executorService.submit(() -> {  
            try {  
                // 분산락 적용 메서드 호출
                                    likesService.updateLikeCountByLock(String.valueOf(portfolios.getPortfolioId()),portfolios.getPortfolioId(), LikeEnum.PORTFOLIO, true);  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  
    latch.await();  

    Portfolios persistPortfolio = portfoliosRepository.findById(portfolios.getPortfolioId())  
        .orElseThrow(IllegalArgumentException::new);  

    Assertions.assertThat(persistPortfolio.getLikeCount()).isEqualTo(numberOfThreads);  
    System.out.println("좋아요 개수" + persistPortfolio.getLikeCount());  
}

성공!

 

 

삽질일기

사실 분산락 자체를 구현하는 과정보다 그것을 테스트하는 환경을 구축하는 일에 시간을 많이 쏟았습니다.
redis를 테스트해야하기 때문에 redis를 테스트하는 방법에 대해 조사하였는데, 많은 경우 로컬 pc에서 직접 redis를 띄워서 테스트코드를 검증하는 방법을 추천하지 않았습니다. 그 이유는 테스트 코드는 어느 환경에서든 동일하게 실행되어야 하기 때문입니다.
Embedded RedisTest-Containers를 이용하여 테스트를 진행할 수 있고, 이 중 이미 서비스에서 활용중이던 도커를 사용하는 Test-Containers로 테스트 환경을 구축하기로 하였습니다.

 

Testcontainers 테스트 환경 구축하기

 

Testcontainers는 통합 테스트를 지원하기 위해 개발된 오픈소스 Java 라이브러리 입니다.
도커 컨테이너를 활용하여 외부 의존성들을 포함한 테스트 환경을 구축하고 관리하는 것을 간편하게 해 줍니다. 이를 통해 개발자들은 어플리케이션의 통합 테스트를 더 쉽고 빠르게 작성하고 실행할 수 있습니다.
즉, testcontainers는 테스트 환경에서 코드로 도커 컨테이너를 실행시킬 수 잇고, 도커만 설치되어 있다면 어떤 환경에서나 테스트를 실행시킬 수 있습니다.
Testcontainers를 사용하여 테스트하는 것이 처음이라 이 과정에서 시간을 많이 소모하였습니다.😭

 

 

RedisTestContainer.java

@DisplayName("Redis Test Containers")
@ActiveProfiles("test")
@Configuration
public class RedisTestContainer {
    private static final String REDIS_DOCKER_IMAGE = "redis:5.0.3-alpine";
    private static final int REDIS_PORT = 6379;

    static {
        GenericContainer<?> REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
            .withExposedPorts(REDIS_PORT)
            .withReuse(true);

        REDIS_CONTAINER.start();

        System.setProperty("spring.data.redis.host", REDIS_CONTAINER.getHost());
        System.setProperty("spring.data.redis.port", REDIS_CONTAINER.getMappedPort(REDIS_PORT).toString());
    }
}

application-test.yml

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8:///test
    hikari:
      maximum-pool-size: 30
    password:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update

 

test-container를 사용해보니 기존의 h2 database를 직접 연결하고 사용할 때보다 훨씬 간편하고 마음 편하게 데이터베이스에 접근하고 객체를 생성할 수 있어서, 그리고 우리 서비스 db인 mysql과 같은 환경에서 이를 test 해볼 수 있어서 아주 편리했습니다. 앞으로 통합테스트 환경을 구축할 때에는 필수적으로 사용할 듯 합니다!

 

private method에 AOP 붙임 이슈..

 

aop는 private method에서는 작동하지 않습니다.아무리 annotation을 걸어봤자 aop는 적용되지 않는데요.

spring 차원에서 오류가 발생하지 않아 실수를 파악하는데 시간이 많이 걸렸습니다.

@DistributedLock(key = "#lockName") 
private void updatePortfolioLikeCount(Long id, boolean isIncrement) {
}

 

private method에 AOP 붙임 이슈..
(aka 멍청함 이슈..)

 

이중 AOP 이슈..

 

또 한가지 엄청나게 삽질을 했던 이유는 LikeService 클래스에 붙인 @Transactional 어노테이션 때문입니다.
이 때문에 updateLikeCountByLock 함수는 다음과 같이 이중 aop를 사용하는 꼴이 되어 transaction을 새로 생성하는 DistributedLock aop의 기능이 제대로 작동하지 못하였습니다.

 

@Transactional 
@DistributedLock(key = "#lockName") 
public void updateLikeCountByLock(String lockName, Long id, LikeEnum likeEnum, boolean isIncrement) {
}

 

따라서 DistributedLockAop에 order=1 설정을 추가해주면서 이를 해결할 수 있었습니다.

@Order(1) 
@Aspect 
@Component 
@RequiredArgsConstructor 
@Slf4j 
public class DistributedLockAop {
}

 

처음에는 aop 내부 호출 이슈인 줄 알았지만 아니었고 aop 이중 호출로 인한 순서 혼돈 때문에 생기는 문제였습니다.

 

마치면서

 

Redis를 사용하여 분산락을 구현하면서, 멀티 스레드 환경에서 테스트를 해보고 그 와중에 AOP에 대해 깊게 공부해볼 수 있어서 좋은 경험이었습니다.
또 Lock을 구현하는 여러 방법과 그 차이에 대해 알 수 있어서 매우 흥미로웠습니다.
다음에는 어떤 락을 적용해볼까나...🤩

 

참조

BELATED ARTICLES

more