분산락 적용을 하면서 데이터 정합성이 깨지는 문제가 발생했다.
이로인해 Transaction의 ACID 원칙도 위반하게 되었다.
원인을 파악해보니 다음과 같은 시나리오가 발생하여 문제가 생긴것이다.
A, B 2개의 트랜잭션이 존재한다.
lock A -> A 트랜잭션에서 좌석 감소 -> unlock -> commit
lock B -> 대기 -> lock 획득 -> B 트랜잭션에서 좌석 감소 -> unlock -> commit
즉 2개 좌석이 감소해야 정상이나 A 트랜잭션이 커밋전 unlock을 하여 B가 lock을 획득하고 commit 전 좌석에서 감소하여 결국 1개의 좌석이 감소되는것이다.
해당 문제를 해결하려면 commit 이후 unlock을 하게 만들면 된다.
Annotation으로 관리하기 위해 우선 Annotation을 만들어 줍니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 10;
long leaseTime() default 3;
}
그리고 트랜잭션 관리를 위한 AOP Transaction 을 만듭니다.
@Component
public class DistributedTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
그리고 해당 Annotation에 적용할 공통 분산 메모리 관리 AOP를 작성해주면 됩니다.
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String HAZELCAST_KEY = "hazelcast-";
private final HazelcastInstance hazelcastInstance;
private final DistributedTransaction distributedTransaction;
@Around("@annotation(com.lotte.health.core.aop.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = HAZELCAST_KEY + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
var lock = hazelcastInstance.getCPSubsystem().getLock(key);
if (lock.tryLock(distributedLock.waitTime(), distributedLock.timeUnit())) {
try {
log.info("get lock {}", key);
return distributedTransaction.proceed(joinPoint);
} finally {
lock.unlock();
}
} else {
throw new CustomException(CustomErrorCode.LOCK_TIMEOUT);
}
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser expressionParser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return expressionParser.parseExpression(key).getValue(context, Object.class);
}
}
실제 서비스 코드에 적용하면 다음과 같습니다.
@Override
@DistributedLock(key = "#request.seat")
public String reserveSeat(ReserveSeatRequest request) {
String result = "좌석 예약에 성공하였습니다.";
int reservationCount = reservationCountPort.getReservationCount(ReservationStatus.USE, WorkType.OFFICE);
if (reservationCount == limit) {
reservationSavePort.reserveHome(request.getUserId());
result = "좌석이 모두 사용중입니다. 자동으로 재택근무로 변경됩니다.";
} else if (reservationCount < limit) {
reservationSavePort.reserve(request.getUserId(), request.getSeat());
}
return result;
}
이제 해당 좌석에 대한 트랜잭션이 완료후 다른 트랜잭션이 시작하므로 동시성과 데이터 정합성을 동시에 처리가 가능합니다.