[Project]/[Momo]

[이슈 #3] 분산락을 활용한 중복 데이터 삽입 이슈 해결2

DevLoki 2022. 8. 2. 20:35

이전 이슈를 통해 동시 요청시 중복 데이터 삽입 문제를 해결할 수 있었습니다.

그러나 아직 몇 가지 개선해야 할 점이 남아있습니다.

  1. 하드코딩된 lockname 파라미터
  2. 메서드 분리와 추상화

하드코딩된 lockname 파라미터 개선

현재 작성된 분산락 어드바이저는 다음과 같은 방식으로 lockname을 가져옵니다.

String lockName = joinPoint.getArgs()[0].toString();

위 방식은 분산락 AOP를 사용하는 경우 항상 첫번째 파라미터를 lockname 파라미터로 고정시키는 방식입니다.

하드코딩은 의미를 파악하기 어려우며 유지보수가 어렵다는 단점이 있으므로 분명히 개선해야할 문제입니다.

 

따라서 기존에 선언한 @DistributedLock 적용한 DistributedLockPrefix처럼 lockname 속성을 추가해 보았습니다.

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface DistributedLock {
    DistributedLockPrefix prefix();
    String lockName; //추가!!
}

 

 

하지만 스프링부트는 CGLIB를 이용해 컴파일이 끝난 직후 프록시 객체를 생성합니다.
런타임에서 사용자의 요청을 받기 전에 lockname을 알고 애노테이션 속성으로 전달할 수 없습니다.

 

따라서 lockname 파라미터에 적용할 커스텀한 애노테이션을 생성하여 분산락이 실행될 때 마다 해당 어노테이션이 적용된 파라미터를 가져와 lockname으로 사용하도록 개선했습니다.

 

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
public @interface LockName {
}

 

@Slf4j
@Aspect
@Component
@Order(1)
@RequiredArgsConstructor
public class DistributedLockAspect2 {
    private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);
    private final DistributedLockManager distributedLockManager;
    //생략

    @Around("@annotation(distributedLock)")
    public Object handleDistributedLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String lockName = getLockName(joinPoint, distributedLock);
        //생략
    }

    private String getLockName(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String lockName = distributedLock.prefix().getValue();
        boolean hasLockName = false;
        Annotation[][] annotations = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameterAnnotations();
        Object[] args = joinPoint.getArgs();
        for (int i = 0; i < args.length; i++) {
            for (Annotation annotation : annotations[i]) {
                if (annotation instanceof LockName) {
                    hasLockName = true;
                    lockName += args[i];
                    break;
                }
            }
        }
        if (!hasLockName) {
            throw new DistributedLockException(ErrorCode.NO_LOCK_NAME_SET);
        }
        return lockName;
    }
    
    private void checkResultSetSuccess(ResultSet rs) throws SQLException, DistributedLockException {
        //생략
    }
}

동작 방식은 다음과 같습니다.

  1. getLock() 메서드는 JoinPoint의 MethodSignature를 사용해 실행될 비즈니스 로직의 파라미터와 애노테이션들을 가져옵니다.
  2. 반복문을 이용하여 애노테이션 배열을 순회하며 @LockName을 탐색합니다.
  3. @LockName을 발견하면 해당 파라미터의 value를 반납하고, @LockName을 발견하지 못한 경우 예외를 던집니다.

위 방식으로 LockName 파라미터의 선언을 원하는 위치에 선언할 수 있었으며, long 과 int 의 값들을 별도로 타입변환해야 하는 번거로움도 해결할 수 있었습니다.

분산락의 추상화

현재 가독성이 떨어지는 코드의 개선과 분산락 설계 시 고려했던 Redis, Zookeeper를 사용한 분산락으로의 변경용을 고려하여 추상화를 적용했습니다.

추상화 설계

그러나 레디스를 사용한 분산락은 별도의 커넥션을 사용하지 않고 비즈니스 로직이 수행되는 커넥션 하나만으로 분산락의 기능을 수행할 수 있었습니다. 즉 DistributedLockManager 인터페이스에서 다음과 같이 커넥션을 파라미터로 전달할 수 없었습니다.

public interface DistributedLockManager {
    Connection getLock(String lockName, Duration timeout);
    void releaseLock(String lockName, Connection connection);
}

커넥션 전달 불가 인터페이스

따라서 파라미터를 사용하지 않고 커넥션을 전달하는 방법을 고민하다, 이전에 적용한 Spring Security의 Authorization의 동작 원리를 자세히 살펴보았습니다. Spring Security는 ThreadLocal을 사용하여 SecurityContextHolder에 Authorization 인스턴스를 저장하고 사용합니다.

ThreadLocal을 활용한 커넥션 전달

위 방식을 도입해 커넥션을 저장하는 ThreadLocalConnectionHolder를 생성했습니다.

public class ThreadLocalConnectionHolder {
    private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();

    public static void save(Connection connection) {
        connectionHolder.set(connection);
    }

    public static Connection get() {
        return connectionHolder.get();
    }

    public static void clear() {
        connectionHolder.remove();
    }
}

 

ThreadLocalConnectionHolder와 DistributedLockManger를 적용한 코드는 다음과 같습니다.

@Around("@annotation(distributedLock)")
    public Object handleDistributedLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String lockName = getLockName(joinPoint, distributedLock);
        distributedLockManager.getLock(lockName, DEFAULT_TIMEOUT);
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            throw (BusinessException) throwable;
        } finally {
            distributedLockManager.releaseLock(lockName);
        }
    }
    
    private String getLockName(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String lockName = distributedLock.prefix().getValue();
        boolean hasLockName = false;
        Annotation[][] annotations = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameterAnnotations();
        Object[] args = joinPoint.getArgs();
        for (int i = 0; i < args.length; i++) {
            for (Annotation annotation : annotations[i]) {
                if (annotation instanceof LockName) {
                    hasLockName = true;
                    lockName += args[i];
                    break;
                }
            }
        }
        if (!hasLockName) {
            throw new DistributedLockException(ErrorCode.NO_LOCK_NAME_SET);
        }
        return lockName;
    }
}

 

추상화를 적용하기 전 코드와 비교했을 때, 약 200라인의 코드를 줄이고 분리할 수 있었습니다.