토이 프로젝트에 공간에 대한 예약 기능이 있는데 동시성 문제가 발생할 수 있기 때문에 Lock을 통해 해결해야 했다.
나의 경우 사용하고 있던 Redis가 있어서 그대로 Redis를 사용했지만 RDBMS에도 있기 때문에 Lock을 위해 Redis가 추가로 필요할지는 고민해봐야 한다.
Redisson
Redisson은 Jedis, Lettuce 같은 자바 레디스 클라이언트다.
Redisson은 Lock interface를 제공하여 Lettuce와 다르게 분산락을 직접 구현하지 않고 편리하게 사용할 수 있다.
스핀락 방식이 아닌 pub/sub 방식의 Lock이다.
Redisson 의존성 추가
// redisson Lock
implementation 'org.redisson:redisson-spring-boot-starter:3.28.0'
// Lock 시도
boolean tryLock();
// Lock 시도 (+ 획득 대기 시간, 점유 시간)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
Redisson의 RLock 객체는 위와 같은 메소드를 제공한다.
- waitTime : Lock을 기다리는 시간
- leaseTime : Lock 점유 후 반납하는 시간
Custom Annotation 및 SpringELParser
토이 프로젝트의 공간 예약은 하나의 공간에 대해서 같은 날 같은 시간에 중복 예약이 불가능하다.
Lock을 잡을 key의 이름을 현재 시간과 공간 ID를 조합해서 설정할 예정이다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long waitTime() default 5L;
long leaseTime() default 5L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Lock을 걸고 싶은 메소드에 어노테이션을 붙이기 위해 Custom 어노테이션을 만든다.
waitTime, leaseTime, timeUnit은 기본값으로 5초로 설정했다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class 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);
}
}
Custom 어노테이션의 key로 전달받은 값을 Spring Expression Language로 파싱해주는 Parser이다.
Request DTO의 필드를 조합하기 위해 사용한다.
동시성 문제를 해결하기 위한 Transaction 분리
예약 신청이 들어왔을 때 예약 데이터가 DB에 반영되고 Lock을 해제하기 위해 트랜잭션을 분리해야 한다.
신청이 동시에 들어와도 같은 시간에 대해서 Lock이 잡혀있기 때문에 동시성 문제를 해결할 수 있다.
예약 Flow는 대략 아래처럼 진행된다.
1. 1번 사용자 예약 신청
2. 2번 사용자 예약 신청
3. 1번 Lock 획득
4. 1번 예약 데이터 DB save
5. 1번 Lock 해제
6. 2번 Lock 획득
@Aspect
@Slf4j
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(ms.toy.my_service.aop.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) {
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 lock = redissonClient.getLock(key);
try {
boolean available = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (Throwable e) {
throw new RuntimeException(e);
} finally {
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock");
}
}
}
}
@DistributedLock 어노테이션이 붙은 메소드를 AOP Around로 해당 메소드를 감싸준다.
key로 전달받은 값으로 key를 설정하고, RLock 객체를 얻어 tryLock()을 통해 Lock을 획득한다.
@Component
public class AopForTransaction {
// 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작
// Propagation.REQUIRES_NEW : 현재 트랜잭션이 종료될 때까지 대기한 후 새로운 트랜잭션을 생성하고 실행
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@Transaction의 propagation 중 REQUIRED_NEW를 사용하여 Lock을 획득한 요청이 별도의 트랜잭션으로 동작한다.
Lock 사용
@Transactional(rollbackFor = Exception.class)
@DistributedLock(key = "#reservationRequestDto.getReservationDate().concat('-').concat(#reservationRequestDto.getSpaceId())")
public ReservationDto saveReservation(ReservationRequestDto reservationRequestDto, SiteType siteType, MemberInfo memberInfo) {
..
}
이제 메소드에 @DistributedLock 어노테이션을 붙여 동시성 문제를 해결할 수 있게 되었다.
(SpringELParser를 구현하여 key로 전달하는 값에 위 코드처럼 '#'을 사용해서 변수에 접근이 가능할 수 있다.)
동시성 문제를 해결할 수 있는 방법은 여러 가지가 있지만 이번 포스트에서는 Lock을 사용하였고, 그중에서도 Redis의 분산 락 기능을 사용했다. 방법은 여러 가지가 있으니 프로젝트 상황에 맞는 방법을 찾아서 적용하도록 하자.
참고
https://www.baeldung.com/redis-redisson
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.
helloworld.kurly.com
'Spring Boot' 카테고리의 다른 글
| [Spring Boot] Filter와 Interceptor (0) | 2025.02.02 |
|---|---|
| [Spring Boot] OTP 2차 인증 (0) | 2024.04.30 |
| [Spring Boot] MapStruct (0) | 2024.04.26 |