본문 바로가기

Project

[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (5)

이전 포스팅에서 데드락과 동시성 문제를 해결하는 시리즈를 마치려고 했습니다.

그런데 생각해 보니 데일리 리포트를 생성하는 부분뿐만 아니라 위클리 리포트를 생성하는 부분에서도 네임드 락 호출이 필요했습니다.

 

함께 고도화를 하고 있는 팀원들이 네임드 락을 사용할 때 반납하는 코드를 깜빡한다면 서비스 장애가 일어날 수도 있습니다. 비즈니스 로직에서 동기화가 필요한 부분에 `try-finally`문으로 반납하는 부분이 반복돼서 사용될 것도 예상되죠.

 

그래서 이번 포스팅에서는 `AOP`를 활용해 비즈니스 로직에서 네임드 락 관련 관심사를 분리하는 과정을 담았습니다.

 

관심사 분리

네임드 락의 사용 목적과 특성을 고려해야 한다

네임드 락의 사용 목적

네임드 락은 왜 사용할까요?

핵심은 `동기화`입니다.

저의 경우처럼 멀티 스레드 환경에서 트랜잭션 동기화가 필요할 때 사용할 수 있고, 분산 서비스에서 동기화가 필요할 때도 사용될 수 있습니다.

 

그럼 네임드 락은 어떻게 동기화를 할까요?

네임드 락 사용 구조

위의 그림은 트랜잭션 간 동기화가 필요할 때를 예시로 들었습니다.

`트랜잭션 1`과 `트랜잭션 2` 모두 비즈니스 로직이며, 비즈니스 로직이 시작되기 전에 네임드 락으로 동기화를 시도합니다.

 

여기서 중요한 부분은 "트랜잭션 시작 전에 네임드 락이 획득"돼야 하며, "트랜잭션 시작 후에 네임드 락이 반납"되는 구조입니다.

이 구조는 분산 환경에서 어떤 데이터에 대한 동기화가 필요할 때도 마찬가지입니다. 동기화가 수행되기 전에 잠금을 획득하고, 수행되고 나면 반납을 해야 하죠.

네임드 락의 특성

네임드 락의 가장 중요한 특성은 무엇일까요?

 

바로 네임드 락은 한 번 획득하고 나면 명시적으로 해제하지 않는 이상 해당 문자열에 대해 무한히 잠근다 특성입니다.

또한, 타임아웃 값이 음수가 들어가면 잠금을 획득할 때까지 무한히 대기하는 특성도 있습니다.

 

해제되지 않은 커넥션은 커넥션 풀로 반환되지 않으니까 커넥션 풀이 고갈되고, 다른 서비스의 성능 저하를 일으킬 수 있는 위험을 만듭니다.

아래 예시를 보겠습니다.

 

1. 세션 1에서 네임드 락 획득

네임드 락 획득

세션 1에서 네임드 락을 획득하려고 합니다. 최대 2초를 기다리겠지만 기존에 "test" 이름으로 된 네임드 락이 없으니 즉시 획득한 모습입니다.

 

2. 세션 2에서 네임드 락 획득

네임드 락 무한 대기..

세션 2에서 "test" 이름의 네임드 락을 획득을 시도합니다. 타임아웃 값이 -1 이므로 락을 획득할 때까지 무한 대기 상태에 빠집니다.

 

3. (약 1분 후) 세션 1에서 네임드 락 반납

네임드 락 해제

세션 1에서 "test" 이름의 네임드 락을 반납합니다.

 

4. 세션 2에서 네임드 락 획득

1분 7.25초 만에 네임드 락 획득

무한 대기에 빠진 후 1분 7.25초 만에 드디어 세션 2에서 네임드 락을 획득합니다.

Aspect 틀 잡기

이제 네임드 락의 사용 목적과 특성을 알았으니 관심사 분리를 위해 틀을 잡아보겠습니다.

트랜잭션 시작 전에 획득 / 트랜잭션 종료 후에 반납

사용 구조는 트랜잭션이 시작하기 전/후로 네임드 락을 획득하고 반납해야 합니다.

네임드 락 획득

네임드 락 획득

네임드 락 획득 부분은 이전에 사용했던 `NamedLockRepository`와 동일합니다.

영속성 컨텍스트 플러시 타이밍도 동기화하기 위해 `FlushModeType.COMMIT` 으로 설정합니다.

네임드 락 반납

네임드 락 반납

네임드 락 반납 부분도 이전에 사용했던 `NamedLockRepository`와 동일합니다.

반납 후 `FlushModeType.AUTO`로 원복시킵니다.

네임드 락 획득/반납 결과 확인

네임드 락의 획득과 반납 확인

네임드 락을 획득하거나 반납에 성공하면 `1`을 획득합니다.

이 부분을 공통으로 사용하기 때문에 메서드로 분리했습니다.

@NamedLock 작성

`@NamedLock` 애너테이션이 적용된 메서드만 적용할 수 있도록 합니다.

기존 서비스의 네임드 락 고유성 부여

기존 서비스 코드에서는 네임드 락의 고유성을 보장하기 위해 사용자의 아이디와 요청일을 네임드 락 이름의 일부로 사용했었습니다.

이와 비슷하게 적용하기 위해 유저의 아이디를 받는 애너테이션을 아래처럼 작성했습니다.

@NamedLock

  • lockName: 네임드 락의 이름으로 사용합니다.
  • keyFields: `joinPoint`에서 매개변수를 찾고, 전달받은 `keyFields`에 해당하는 필드를 네임드 락 이름의 일부로 사용합니다.
  • timeout: 네임드 락을 대기하는 최대 시간으로 사용합니다. 기본값을 2초로 했습니다.

위의 3가지 요소는 어드바이스에서 이렇게 활용됩니다.

@Around("@annotation(annotation) && allService()")
public Object doTransactionWithNamedLock(ProceedingJoinPoint joinPoint, NamedLock annotation) throws Throwable {
    String lockName = annotation.lockName();
    Object[] args = joinPoint.getArgs();
    String[] keyFields = annotation.keyFields();

    // lockName, args, keyFields 로 고유 키 생성
    final String finalLockName = generateUniqueKey(lockName, args, keyFields);

    // timeout 음수 검증
    int timeout = annotation.timeout();
    if (timeout < 0) {
        throw new IllegalArgumentException("timeout must not be negative");
    }
    
    // ... 생략
}

private String generateUniqueKey(String lockName, Object[] args, String[] keyFields) {
    StringBuilder uniqueKey = new StringBuilder();
    uniqueKey.append(lockName);
    if (args == null || args.length == 0) {
        return trimToMaxLength(uniqueKey);
    }

    for (String keyField : keyFields) {
        for (Object arg : args) {
            try {
                Field field = arg.getClass().getDeclaredField(keyField);
                field.setAccessible(true);
                Object value = field.get(arg);
                if (value != null) {
                    uniqueKey.append(value);
                }
            } catch (Exception ignored) {
            }
        }
    }

    return trimToMaxLength(uniqueKey);
}

private String trimToMaxLength(StringBuilder sb) {
    if (sb.length() > 64) {
        return sb.substring(0, 64);
    }
    return sb.toString();
}
  • 타임아웃 음수 검증: 타임아웃 값이 음수면 무한 대기를 시도하는 것이며 의도치 않은 문제를 일으킬 수 있습니다. 이를 검증하여 런타임에 예외를 던집니다.
  • generateUniqueKey(): 고유한 네임드 락 이름을 생성합니다.
    • 리플렉션을 이용해 `joinPoint`로 넘어온 메서드의 매개변수에서 `keyFileds`에 해당하는 필드들을 모두 찾고, 이를 네임드 락 이름의 일부로 사용합니다.
    • 만약 `keyFields`가 주어지지 않았거나 필드를 찾지 못하는 경우 `lockName`만 네임드 락의 이름으로 사용됩니다.
  • trimToMaxLength(): MySQL 네임드 락 최대 길이까지 문자열을 자릅니다.
    • MySQL에서 네임드 락의 이름은 최대 64글자로 제한됩니다.
      따라서 이름 길이가 64글자를 초과하면 64글자까지만 사용합니다.

@Pointcut 작성

포인트 컷

AspectJ 표현식으로 루트 패키지부터 하위 패키지까지 `*Service`로 끝나는 클래스(또는 인터페이스)에 적용을 합니다.

@Around 어드바이스 적용

네임드 락 어드바이스

  • 비즈니스 로직의 앞/뒤로 네임드 락이 획득되고 반납되야 하기 때문에 어드바이스는 `@Around`를 적용합니다.
  • 포인트 컷 부분에 `@annotation(annotation)`을 추가해서 `@NamedLock`의 정보를 어드바이스 내에서 활용합니다.

관심사 분리가 적용된 비즈니스 로직

Before

Before

After

After

비즈니스 로직이 있는 클라이언트 코드에서 네임드 락을 호출하면 `try-finally`문 안에 비즈니스 로직을 끼워 넣어야 했었는데요,

관심사 분리 적용으로 여러 서비스 클라이언트 코드에서 네임드 락을 쉽게 적용할 수 있을 뿐만 아니라 코드도 깔끔해졌습니다.

테스트 결과

1초 정도 걸린다

AOP 적용 후 기존 테스트 결과(900ms ~ 1s)와 비슷한 속도를 보여줍니다.

 

이로써 네임드 락 AOP 적용 과정을 마치겠습니다.

긴 글 읽어주셔서 감사합니다.