쓰기스큐 방지를 위해 분산락 AOP 적용 🔥

초록·2023년 11월 23일
1
post-thumbnail

요약

여러개의 WAS와 캐시, DB가 복잡하게 얽힌 [중복확인-삽입] 로직을 상호 배타적으로 처리하기 위해 분산락을 적용했습니다. 성능이 좋고 적용이 간편한 Redisson 분산락을 사용했고, 재활용성이 높아보여 AOP로 만들었습니다. 분산락을 공부하며 분산락의 여러 종류와 장단점에 대해 알게되었고, AOP를 만들어보며 애너테이션과 메서드 파라미터를 활용하는 방법을 알게되었습니다.

문제 : [확인-삽입]로직에서 쓰기스큐가 발생한다

// addIfNotExist
checkDuplicate(storeId, cigaretteId); // 캐시-DB에서 중복 확인 (look-aside)
CigaretteOnList saved = writeThrough(storeId, cigaretteOnList); // 캐시-DB에 저장 (writeThrough)

이 앱에선 각 가게별로 담배 목록을 관리합니다. 담배 목록에 담배를 추가할 때, 같은 제품의 중복 추가를 막기위해 중복 검사를 먼저한 뒤 추가하는 로직이 있습니다. Write-skew 이슈 때문에 '중복검사-추가' 이 두 단계는 한 번에 한 사용자만 실행해야만 합니다. 그런데 어려운 점은, 각 스텝은 내부적으로 캐시와 DB를 조회하기 때문에 두 저장소 모두에 변화가 없음을 보장해야하는 복잡성이 있었습니다.

분산락을 선택한 이유

@Transactional은 Redis 락을 제공하지 않고, SessionCallback은 레디스에 낙관락을 제공하지만 레디스 트랜잭션은 Jpa Transaction과 연계될 수 없었습니다. 분산락은 한 곳에 락을 저장해두고, 락을 획득해야만 특정 임계영역에 접근할 수 있게 함으로써, 여러 서버에서 임계영역에 대한 상호배타를 지키는 방식입니다. 예를 들면 담배 추가 로직을 실행하기 위해선 먼저 분산락을 획득해야만 담배 추가 로직을 실행할 수 있고, 처리가 끝나면 분산락을 놓아주어 다른 스레드가 담배 추가로직을 실행할 수 있게 됩니다. 이 분산락은 모든 서버가 공용으로 이용하는 곳에 두기 때문에 모든 서버의 스레드가 상호배타를 지킬수 있게 됩니다.

비슷하는 코드 단위로 상호배타를 수행하는 synchorinzed가 있는데요, synchronized의 락은 해당 서버 내에서 만들어지기 때문에, 서버가 여러대일 때는 해당 코드에 대한 상호배타가 지켜지지 않습니다. 제가 진행한 담배200의 경우 서버 스케일아웃을 하기 때문에 분산락을 사용했습니다.

Redisson 분산락을 사용

분산락에도 여러가지 종류가 있지만 Redisson을 활용한 분산락을 채택했습니다. 왜냐하면 MySQL의 Named-Lock은 디스크기반이라 인메모리인 Redis에 비해 속도가 좀 느리기도하고, 락 용 DB 커넥션풀을 따로 만들어줘야하고, Lettuce를 사용한 Spinlock 방식은 타임아웃 구현이 어렵고 busy-waiting하기 때문에 부하가 따르기 때문입니다. Redisson은 타임아웃 구현도 쉽고, Pub/Sub 방식을 통해 lock이 풀리면 client로 메시지가 오는 방식을 채택해 부하가 적습니다.

AOP를 활용한 분산락 적용

Redisson으로 분산락을 처리하는 코드를 훑어보니 서비스 로직 앞뒤로 락을 획득하고 놓아줌을 알 수 있었습니다. 분산락 적용은 앱의 여러 부분에서 활용될 수 있기 때문에 분산락 관련 로직을 AOP로 만들었습니다.

일단은 락 종류별 이름형식과 타임아웃을 enum으로 정리하고, 해당 락 타입에 식별자가 추가된 lock key를 추출하는 메서드를 넣었습니다.

@AllArgsConstructor
@Getter
public enum RLockType {
    CIGARETTE_LIST(CacheType.CIGARETTE_LIST.getCacheName() + ":RLock:%s", 2, 5)
    ;

    private String format;
    private long waitSeconds; // lock을 얻기 위해 기다리는 시간(second)
    private long leaseSeconds; // lock을 보관하고 있는 시간(second)

    public String getLockKey(String key){
        return String.format(getFormat(), key);
    }

}

메서드에 적용되는 애너테이션임을 표시하고, 애너테이션 파라미터에서 락 타입을 지정할 type메서드를 정의했습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RLockAop {
    RLockType type(); // RLock에 사용되는 정보
}

애너테이션에 넘겨진 락 타입과 메서드 파라미터로 넘겨진 key 이름을 기반으로 락을 획득하고, 메서드 수행하고, 락을 놓아주고 있습니다.

@Order(1)
@Aspect
@Component
@RequiredArgsConstructor
public class RLockAspect {
    private final RedissonClient redissonClient;

    @Around("@annotation(RLock) && args(key, ..)") // 첫번째 파라미터가 key여야 함
    public Object aroundRLock(ProceedingJoinPoint joinPoint, String key) throws Throwable {

        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        RLockAop rlockAop = methodSignature.getMethod().getAnnotation(RLockAop.class);
        RLockType lockType = rlockAop.type();
        String lockKey = lockType.getLockKey(key);
        RLock lock = redissonClient.getLock(lockKey);

        // 락 획득
        // 락 타입별로 타임아웃 정보를 lockType enum에 적어 놓았음
        if (!lock.tryLock(lockType.getWaitSeconds(), lockType.getLeaseSeconds(), TimeUnit.SECONDS)) {
            throw new RLockTimeoutException(); // 타임아웃되면 클라이언트가 재시도하게끔 유도
        }
        Object ret = null;
        try {
            ret = joinPoint.proceed();  // AOP가 적용된 메서드가 실행되는 부분
        } finally {
            if (lock != null && lock.isLocked())
                lock.unlock(); // 락 놓기
        }

        return ret;
    }

}

그렇게 AOP가 적용된 모습입니다. 이 메서드는 @Transactional이 적용된 다른 메서드 내부에서 쓰일 수도 있는데, 만약 두 메서드가 같은 클래스 안에 있다면 Self-Invocation 때문에 분산락 AOP 적용이 안될 수 있기 때문에, 따로 클래스를 만들고 주입해서 사용할 수 있게끔 했습니다.


@RLockAop(type = RLockType.CIGARETTE_LIST) // 애너테이션의 type값과, 파라미터의 key값을 이용해 락을 획득
public CigaretteOnList addIfNotExist(String key, Long storeId, Long cigaretteId, CigaretteOnList cigaretteOnList){
    checkDuplicate(storeId, cigaretteId);
    CigaretteOnList saved = writeThrough(storeId, cigaretteOnList);
    return saved;
}

배운 점

분산락을 공부하며 분산락의 여러 종류와 장단점에 대해 알게되었고, AOP를 만들어보며 애너테이션과 메서드 파라미터를 활용하는 방법을 알게되었습니다.

profile
몰입하고 성장하는 삶을 동경합니다

0개의 댓글