장쫄깃 기술블로그

[Spring Boot] AOP, Redis Transaction을 이용한 분산락(Distributed Lock)으로 동시성 해결하기 본문

Spring Framework/Spring Boot

[Spring Boot] AOP, Redis Transaction을 이용한 분산락(Distributed Lock)으로 동시성 해결하기

장쫄깃 2024. 10. 18. 15:44
728x90


분산락(Distributed Lock)의 필요성


분산락(Distributed Lock)은 분산 시스템에서 여러 노드 또는 프로세스가 동시에 공유 자원에 접근하려 할 때, 충돌을 방지하고 자원의 일관성을 유지하기 위해 사용하는 기술이다. 이를 통해 여러 컴퓨터 또는 서버가 동시에 동일한 자원에 접근할 때 발생할 수 있는 문제를 해결할 수 있다.

 

Redis는 기본적으로 싱글 스레드로 동작하므로, 단일 Redis 노드를 사용해도 동시성 문제는 발생하지 않는다. 따라서 리소스에 값을 설정하여, 값이 설정된 경우에는 다른 클라이언트의 접근을 차단할 수 있다.

 

필자는 분산 환경에서 선착순 신청 기능을 개발하면서, 처음에는 DB에 직접 락을 거는 비관적 락을 사용했다. 그러나 Deadlock이 자주 발생하자, 분산락으로 변경하여 문제를 해결할 수 있었다.

RDB 비관적 락 vs Redis를 이용한 분산락

  • RDB 비관적 락 (Pessimistic Lock)
    • 특징
      • 자원을 변경하거나 읽기 전 트랜잭션이 락을 획득
      • 락을 획득한 동안 다른 트랜잭션은 자원에 접근할 수 없음
    • 장점
      • 데이터 일관성 보장
      • 직관적이고 강력한 동시성 제어
    • 단점
      • 자원 점유 시간이 길어지면 다른 트랜잭션들이 대기 상태로 인해 성능 저하 발생
      • 락 해제 전에 장애가 발생하면 교착 상태(Deadlock)가 발생
  • Redis를 이용한 분산락 (Distributed Lock)
    • 특징
      • 분산 시스템에서 자원에 대한 동시 접근을 방지하기 위한 락
      • 여러 서버에서 동시에 자원에 접근할 수 있는 환경에서 사용
      • Redis의 빠른 처리 속도와 비동기 처리 능력을 기반으로 높은 성능 제공
      • 락을 획득할 때 타임아웃을 설정해 락을 영구적으로 점유하는 상황을 방지
    • 장점
      • 성능이 매우 빠름
      • 분산 환경에서 락 관리가 가능
    • 단점
      • Redis가 중단되거나 분산락 설정이 적절하지 않으면 락이 제대로 작동 X
      • 별도의 Redis 환경 필요
특징 RDB 비관적 락 Redis 분산락
사용 환경 단일 데이터베이스 시스템에서 주로 사용 분산 시스템이나 여러 인스턴스가 자원을 공유할 때 사용
락 관리 방식 트랜잭션 단위로 락을 걸고 해제 Redis의 SETNX와 EXPIRE를 사용
동시성 처리 자원 독점으로 인한 동시성 처리 제약 가능 높은 성능과 유연한 분산락 처리
성능 자원 점유 시간이 길면 성능 저하 발생 가능 매우 빠른 성능
장애 복구 및 안전성 트랜잭션 시스템 내에서 안전성이 보장됨 Redis 중단 시 락 상태 불안정할 수 있음
교착 상태 발생 가능성 있음 타임아웃 설정으로 방지 가능

 

 

Redisson 라이브러리 사용


Spring Boot에서 분산락은 일반적으로 Redisson, Lettuce로 구현이 가능하다. 필자는 Redisson 라이브러리를 사용했다.

 

그 이유는 다음과 같다.

 

Lock Interface 지원

Letttuce는 분산락을 사용하기 위해서는 setnx, setex 등을 이용해 분산락을 직접 구현해야 한다. 개발자가 직접 retry, timeout과 같은 기능을 구현해 주어야 한다는 번거로움이 있다.

 

Redisson은 별도의 Lock interface를 지원한다. 락에 대해 타임아웃과 같은 설정을 지원하기에 락을 보다 안전하게 사용할 수 있다.

 

Lock 획득 방식

Lettuce는 분산락 구현 시 setnx, setex과 같은 명령어를 이용해 지속적으로 Redis에게 lock이 해제되었는지 요청을 보내는 스핀락 방식으로 동작한다. 요청이 많을수록 Redis가 받는 부하는 커진다.


RedissonPub/Sub 방식을 이용한다. lock이 해제되면 lock을 subscribe 하는 클라이언트는 lock이 해제되었다는 신호를 받고 락 획득을 시도한다.

 

 

분산락 구현


build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.3'
	id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.jdh'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-aop'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	annotationProcessor 'org.projectlombok:lombok'

	// redisson
	implementation 'org.redisson:redisson-spring-boot-starter:3.20.0'
}

test {
	useJUnitPlatform()
}

 

RedissonConfig.java

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Redisson Config
 */
@Configuration
public class RedissonConfig {

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

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

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

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();

        config.useSingleServer()
                .setAddress("redis://" + host + ":" + port)
                .setPassword(password);

        return Redisson.create(config);
    }

}

 

DistributedLock Annotation

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
 * 분산락을 위한 어노테이션
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    // 분산락 key
    String key();

    // 대기시간
    int waitTime() default 10;

    // 소유시간
    int leaseTime() default 5;

    // TimeUnit
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

 

DistributedLockAspect.java

import com.jdh.distrbute_lock.config.lock.annotation.DistributedLock;
import com.jdh.distrbute_lock.config.lock.exception.DistributedLockException;
import com.jdh.distrbute_lock.config.lock.transaction.RequireNewTransactionAspect;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

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

    private final RedissonClient redissonClient;

    private final RequireNewTransactionAspect requireNewTransactionAspect;

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    /**
     * `@DistributedLock` 어노테이션이 선언된 메소드를 포인트컷으로 설정
     *
     * @param distributedLock 분산락 처리를 위한 어노테이션
     */
    @Pointcut("@annotation(distributedLock)")
    public void pointCut(DistributedLock distributedLock) {
    }

    /**
     * 분산 락을 사용하여 메소드를 감싸는 Around 어드바이스
     *
     * @param pjp ProceedingJoinPoint, 원래의 메소드를 나타냄
     * @param distributedLock 분산락 어노테이션
     * @return 메소드 실행 결과
     * @throws Throwable 예외 처리
     */
    @Around(value = "pointCut(distributedLock)", argNames = "pjp,distributedLock")
    public Object around(ProceedingJoinPoint pjp, DistributedLock distributedLock) throws Throwable {
        // 메소드 정보
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();

        // 분산락 key
        String key = REDISSON_LOCK_PREFIX + distributedLock.key();

        // 분산락 시도
        RLock rLock = redissonClient.getLock(key);
        try {
            // 락 획득 시도
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                // 락 획득 실패 시 예외처리
                log.error("lock not available [method:{}] [key:{}]", method, key);
                throw new DistributedLockException(); // custom exception
            }

            // 분산락을 획득하면 새로운 트랜잭션을 시작하여 비즈니스 로직 실행
            return requireNewTransactionAspect.proceed(pjp);
        } catch (InterruptedException e) {
            // 락 획득 중 인터럽트 발생 시 처리
            log.error("lock interrupted [method:{}] [key:{}]", method, key, e);
            Thread.currentThread().interrupt(); // 현재 스레드의 인터럽트 상태 복구
            throw new DistributedLockException(); // custom exception
        } finally {
            // 분산락 해제
            if (rLock.isLocked() && rLock.isHeldByCurrentThread()) { // 락이 현재 스레드에 의해 획득되었는지 확인
                rLock.unlock(); // 락 해제
            }
        }
    }

}

 

@DistributedLock 어노테이션이 선언된 메서드를 대상으로 동작하는 AOP를 구현한다. @DistributedLock 어노테이션의 파라미터값을 가져와 분산락을 획득하고, 어노테이션이 선언 메소드를 실행한다.

 

PointCut은 AOP에서 공통적으로 적용될 메소드를 지정하는 역할을 한다. 여기서는 @DistributedLock 어노테이션이 선언된 메소드를 대상으로 지정한다.

 

Around는 메소드 실행 전후에 분산락을 적용한다. 메소드 호출 전에 락을 획득하고, 메소드가 정상 실행되면 락을 해제한다.

 

around 메소드의 세부 기능은 다음과 같다.

  1. 메소드와 lock key 가져오기
    1. MethodSignature를 통해 현재 실행된 메서드의 정보 가져오기
    2. distributedLock.key()를 이용하여 락을 구분하는 key 설정
  2. lock 획득 시도
    1. redissonClient.getLock(key)로 lock 객체 생성
    2. rLock.tryLock()을 호출하여 락 획득을 시도
    3. 대기 시간, 임대 시간, 시간 단위를 DistributedLock 어노테이션에서 가져옴
  3. lock 획득 실패 시 예외처리
    1. 만약 lock을 획득하지 못하면, 로그를 남기고 DistributedLockException 발생
  4. 비즈니스 로직 실행
    1. lock을 성공적으로 획득하면 새로운 트랜잭션을 요구하여 비즈니스 로직을 실행
  5. lock 해제
    1. 비즈니스 로직 실행 후 락을 해제
    2. 현재 스레드가 lock을 소유한 경우에만 해제
  6. InterruptedException 처리
    1. lock 획득 중 인터럽트가 발생하면, 로그를 남기고 스레드의 인터럽트 상태를 복구한 뒤 예외 발생

이때, 새로운 트랜잭션에서 메소드 실행이 완료된 후, 트랜잭션이 커밋된 시점에 락을 해제한다. 이는 데이터의 정합성을 보장하기 위함이다. 락을 트랜잭션 커밋보다 먼저 해제하면, 대기 중인 다른 요청이 아직 커밋되지 않은 데이터를 사용할 수 있어 이를 방지하려는 목적이다.

 

 

RequireNewTransactionAspect.java

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class RequireNewTransactionAspect {

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

}

 

새로운 트랜잭션에서 메서드를 실행하도록 보장하는 클래스이다. Spring의 @Transactional 어노테이션을 사용하여 트랜잭션을 관리하며, Propagation.REQUIRES_NEW를 통해 항상 새로운 트랜잭션을 시작하는 방식으로 동작한다.

 

분산 락에서 lock을 획득한 후, 비즈니스 로직이 독립된 트랜잭션에서 안전하게 실행되도록 하기 위한 용도로 사용한다.

 

 

테스트


테스트를 통해 검증해 보도록 하겠다. 동시에 여러 요청을 테스트하기 위해 JMeter를 사용했다.

 

그리고 공용 static 변수에 동시에 접근하여 값을 1씩 올려주는 테스트 로직을 작성했다.

// 테스트용 변수
public int num = 1;

// random
private final Random random = new Random();

@Override
public void test(String key) {
    // 0부터 500 사이의 랜덤한 정수 생성 (0 포함, 500 미포함)
    int randomNumber = random.nextInt(501);

    try {
        // num 출력
        log.info("test::{}", num);

        // 0.5초 이내 랜덤 대기
        Thread.sleep(randomNumber);

        // num + 1
        num++;
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
 
20명의 사용자가 요청했을 때, 각 사용자에게 서로 다른 정수값을 할당하고, 그 정수값에 1을 더한 결과를 반환하며, 마지막 사용자의 결과값은 20이 되도록 한다.

 

 

분산락 없이 테스트를 진행하면 다음과 같은 결과가 발생한다.

 

이제 분산락을 적용하도록 하겠다. 위 테스트 코드에 @DistributedLock을 선언한다.

@Override
@DistributedLock(key = "#key", waitTime = 60, leaseTime = 10)
public void test(String key) {
	// ...
}

 

그리고 테스트를 진행하면 다음과 같이 정상적인 결과를 확인할 수 있다.

728x90