Spring

[Packy] 선물박스 열기 동시성 문제 해결 2 - Redisson으로 분산락 구현

짱정연 2024. 8. 29. 10:55
반응형

1. 들어가며

 

[Packy] 선물박스 열기 동시성 문제 해결 1 - 분산 락을 선택한 이유

1. 들어가며 패키에서는 선물박스를 받게 되면 Receiver 테이블에 받은 유저 ID와 선물박스 ID를 저장한다.지금은 하나의 선물박스를 1명만 받을 수 있지만(PRIVATE), 나중에 하나의 선물박스를 여러

leeeeeyeon-dev.tistory.com

 

저번 포스트에서 선물박스 열기 로직에 발생할 수 있는 동시성 문제에 대해 알아보고, 이를 해결할 수 있는 방법에 대해 찾아본 뒤 최종적으로 Redis의 Message Broker 방식을 활용한 분산 락으로 해결하기로 결정하였다.

 

이번 포스트에서는 Redisson으로 분산 락을 구현하는 과정을 코드와 함께 살펴보자.

 

2. 의존성 추가

Redisson 라이브러리를 build.gradle에 추가해준다

implementation 'org.redisson:redisson-spring-boot-starter:3.34.1'

 

다른 테크 블로그를 참고하여 구현하며 3.27.0 버전을 사용하였는데 아래와 같이 빈을 생성하지 못하는 에러가 발생했는데 공식 문서 상 최신 버전은 3.34.1로 업그레이드하여 해결하였다.

Error creating bean with name 'redissonClient' defined in class path resource [com/dilly/global/config/RedissonConfig.class]: Failed to instantiate [org.redisson.api.RedissonClient]: Factory method 'redissonClient' threw exception with message: 'io.netty.resolver.dns.DnsNameResolverBuilder io.netty.resolver.dns.DnsNameResolverBuilder.socketChannelType(java.lang.Class, boolean)'

 

3. RedissonConfig

RedissonClient를 사용하기 위한 설정을 빈으로 등록한다.

@Configuration
public class RedissonConfig {

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

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

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

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String address = REDISSION_HOST_PREFIX + host + ":" + port;
        config.useSingleServer().setAddress(address);

        return Redisson.create(config);
    }
}

 

 

4. RedissonLock

 

AOP와 Spring AOP에 대해 알아보자

1. AOP란? AOP는 Aspect Oriented Programming의 약자로, 관점 지향 프로그래밍을 의미한다. 핵심적인 비즈니스 로직으로부터 횡단 관심사를 분리하는 것을 목적으로 한다. 로깅, 보안, 트랜잭션 처리와 같

leeeeeyeon-dev.tistory.com

 

분산 락 코드를 Service 클래스에 작성할 경우 비즈니스 로직(선물박스 열기)과 관련이 없는 코드가 섞이게 되고, 추후 다른 로직에 분산 락을 적용할 경우 중복되는 코드가 생길 수 있어 AOP를 활용하여 분산 락을 구현할 것이다.

AOP에 대한 개념은 이전에 정리해둔 포스트가 있으니 위에 있는 포스트를 참고하자!

 

@RedissonLock이라는 커스텀 어노테이션을 만들고, 해당 어노테이션이 있는 메서드가 실행되면 AOP를 수행하도록 한다.

 

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

    String value(); // 락의 이름
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS; // 시간 단위
    long waitTime() default 5_000L; // 락 획득을 위해 waitTime만큼 대기
    long leaseTime() default 5_000L; // 락 획득 후 leaseTime만큼 락 유지
}

 

Redisson Lock은 value, timeUnit, waitTime, leaseTime 네 가지 값이 있으며 각 값의 역할은 주석에 적은 것과 같다.

value 외의 값들은 값을 명시하지 않을 경우 기본값을 사용할 수 있도록 기본값을 지정해주었다.

 

waitTime이 짧을 경우 선착순에 들었지만 대기 시간이 짧아 기회가 박탈될 수 있고, leaseTime이 짧을 경우 로직이 진행되다가 락이 해제되는 경우가 발생할 수 있으므로 적절하게 시간을 조절해주어야 한다.

 

5. RedissonLockAspect

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

    @Value("${spring.profiles.active}")
    private String profilePrefix;

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.dilly.global.aop.RedissonLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock annotation = method.getAnnotation(RedissonLock.class);

        String lockKey =
            profilePrefix + ":" + method.getName() + CustomSpringELParser.getDynamicValue(
            signature.getParameterNames(), joinPoint.getArgs(), annotation.value());

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(),
                annotation.timeUnit());
            if (!lockable) {
                log.info("Lock 획득 실패: {}", lockKey);
                throw new ConcurrencyFailedException();
            }

            log.info("Lock 획득 성공: {}", lockKey);
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            log.info("에러 발생");
            throw e;
        } finally {
            if (lock != null && lock.isHeldByCurrentThread()) {
                log.info("Lock 해제");
                lock.unlock();
            }
        }
    }
}

 

 

Redisson이라는 라이브러리에 분산 락 메커니즘과 재시도 로직이 구현되어 있기 때문에 해당 라이브러리를 사용하여 분산 락을 구현할 수 있다. 우리는 락 획득 시도, 락 획득 성공 후 처리, 락 획득 실패 로직을 구현하면 된다.

 

AOP를 활용하여 구현한 분산 락 로직이다. 락 획득 시도, 락 획득 성공 후 처리, 락 획득 실패 로직을 포함하고 있다.

 

서버비를 아끼기 위해 ㅠuㅠ ElastiCache를 하나만 사용하려고 하기 때문에 profile 값(dev, prod)를 lockKey 이름에 포함하여 구분하였다.

CustomSpringELParser는 Spring 표현식을 좀 더 자세하게 파싱할 수 있도록 만든 파서로, 구체적인 코드는 아래를 확인하면 된다.

 

redissonClient.getLock()으로 락을 획득하고, tryLock()으로 락 획득을 시도한다.

락 획득에 성공했다면 선물박스 열기 로직을 수행하고, 락 획득에 실패했으면 ConcurrencyFailedException이라는 커스텀 비즈니스 예외를 던지도록 하였다.

 

AOP의 기본 proceed() 메소드를 사용하지 않고, ApoForTransaction 클래스의 proceed() 메소드를 사용했음을 주의하자. 관련하여 자세한 내용은 잠시 후 나온다.

 

마지막으로 finally 구문에서는 만약 락을 획득한 상태라면 락을 해제하도록 하였다.

 

6. CustomSpringELParser

public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        SpelExpressionParser 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);
    }
}

 

 

7. AopForTransaction

@Component
public class AopForTransaction {

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

}

 

@RedissonLock이 선언된 메소드는 트랜잭션 옵션을 REQUIRES_NEW로 지정하여 부모 트랜잭션의 유무와 관계없이 별도의 트랜잭션으로 동작하여 트랜잭션이 커밋된 이후 락이 해제되도록 한다. 그 이유는 아래 그림으로 확인할 수 있다.

왼쪽은 락의 해제 시점이 커밋보다 먼저인 경우, 오른쪽은 락의 해제 시점이 커밋 이후인 경우이다.

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

 

왼쪽 시나리오에서는 Client 1과 Client 2가 동시에 재고를 차감했으므로 2개가 차감되어야 하는데 1개만 차감되어 데이터 정합성이 깨지는 문제가 발생하였다.

반면 오른쪽 시나리오에서는 정상적으로 2개가 차감되는 것을 확인할 수 있다.

 

8. 기존 테스트 코드에 분산 락 적용되지 않도록 하는 방법

패키 프로젝트의 테스트 코드는

  • 테스트의 편리함을 위해 @Transactional 어노테이션을 사용
  • 비즈니스 계층(Service)와 영속성 계층(Repository)을 합쳐 통합 테스트를 진행하고 있다

라는 특징이 있다. 통합 테스트를 위해 선물박스 접근 권한이나 삭제 로직을 테스트할 때 openGiftBox 메소드(선물박스 열기 로직)로 미리 선물박스를 저장한다.

하지만 openGiftBox 메소드를 사용하던 기존 테스트 코드들이 분산 락 도입 이후 실패하는 상황이 발생하였다.

 

AopForTransaction에서 별도의 트랜잭션을 사용하면서 테스트 레이어의 트랜잭션과 openGiftBox 메소드의 트랜잭션 범위가 달라져 발생한 문제인 것 같다. 그래서 기존 테스트 코드에서는 분산 락을 적용하지 않도록 결정하였다.

 

만약 동시성이나 분산 락 관련 로직을 테스트할 일이 있으면 GiftBoxConcurrencyTest 클래스나 새로운 테스트 클래스에서 테스트를 진행하고, 통합 테스트에서는 단일 요청을 기준으로 테스트 코드를 작성하는 방식으로 테스트 코드를 운영하고자 한다.

 

@ConditionalOnExpression("${ableRedissonLock:true}")
public class RedissonLockAspect {
	// 중략 ...
}

 

@ConditionalOnExpression 어노테이션을 사용하여 ableRedissonLock라는 환경변수가 true일 때만 동작하도록 하였다.

만약 속성값이 없으면 true가 default 값이 된다.

 

그리고 통합 테스트를 관리하는 추상 클래스인 IntegrationTestSupport 클래스에서는 @TestPropertySource 어노테이션을 사용하여 ableRedissonLock을 false로 지정해주었다.

@TestPropertySource(properties = "ableRedissonLock=false")
public abstract class IntegrationTestSupport {
	// 중략 ...
}

 

 

9. 마치며

저번 포스트에서 알아본 동시성 문제와 분산 락에 이어 이번 포스트에서는 직접 코드로 분산 락을 구현해보았다.

 

멀티 스레드, 락, 트랜잭션, AOP 등 다양한 CS와 Spring 이론이 필요했던 문제 해결 과정이었다. 특히 트랜잭션 관련 삽질을 많이 하면서 트랜잭션에 대한 공부가 많이 되었던 시간이었다.

 

그 중 예시로, 테스트 코드를 작성하며 테스트 코드의 @Transactional과 Service의 @Transactional이 다르다는 충격적인 사실을 배울 수 있었다. 아래 글이 테스트 코드에서의 @Transactional 어노테이션 사용에 대해 정리가 잘 되어 있어 정말 도움이 되었다.

 

 

@Transactional 이모저모 1 - 테스트코드와 @Transactional

이 포스팅 보고 테스트 짰다면 얼마나 좋았을까?

velog.io

 

Reference

 

반응형