1. 들어가며
저번 포스트에서 선물박스 열기 로직에 발생할 수 있는 동시성 문제에 대해 알아보고, 이를 해결할 수 있는 방법에 대해 찾아본 뒤 최종적으로 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
분산 락 코드를 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로 지정하여 부모 트랜잭션의 유무와 관계없이 별도의 트랜잭션으로 동작하여 트랜잭션이 커밋된 이후 락이 해제되도록 한다. 그 이유는 아래 그림으로 확인할 수 있다.
왼쪽은 락의 해제 시점이 커밋보다 먼저인 경우, 오른쪽은 락의 해제 시점이 커밋 이후인 경우이다.
왼쪽 시나리오에서는 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 어노테이션 사용에 대해 정리가 잘 되어 있어 정말 도움이 되었다.
Reference
- https://hudi.blog/distributed-lock-with-redis/
- https://helloworld.kurly.com/blog/distributed-redisson-lock/
- https://devnm.tistory.com/37
- https://kirinman.tistory.com/97
- https://devnm.tistory.com/24