Post

분산락을 통한 동시성 문제 해결 예시

기존에 진행중이던 프로젝트의 댓글 좋아요 기능을 DB 쓰기락 기반 JPA 비관적락으로 동시성을 제어했습니다. 락 매커니즘 비교 대상에서 분산락은 오버엔지니어링의 관점에서 제거했지만, 만약 도입한다면 어떤 식으로 코드를 적용할 수 있을지 경험을 공유하고자 합니다.

AOP를 사용한 분산락 구현

그렇다면 분산락을 코드에 어떻게 적용했는지 살펴보겠습니다. 저는 분산락 적용을 위해 AOP를 사용했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(cmc.mellyserver.common.aspect.lock.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        
        // ------- 1
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock lock = method.getAnnotation(DistributedLock.class);
        String key = LockKeyParser.parse(signature.getParameterNames(), joinPoint.getArgs(), lock.key());
        
        // ------- 2
        RLock rLock = redissonClient.getLock(key);
        try {

            boolean available = rLock.tryLock(lock.waitTime(), lock.leaseTime(), lock.timeUnit());
            
            if (!available) {
                throw new BusinessException(ErrorCode.SERVER_ERROR);
            }
            
            return joinPoint.proceed();
            
        }
        catch (WriteRedisConnectionException e) { // ------- 3
          log.error(REDISSON_CONNECTION_FAIL);
          throw new BusinessException(ErrorCode.SERVER_ERROR);
        } catch (InterruptedException e) {
          throw new BusinessException(ErrorCode.SERVER_ERROR);
        } finally {
          if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
            rLock.unlock();
          }
        }
    }
}

AOP로 분산락을 사용하기 위한 어드바이저 코드입니다. AOP의 타겟 메서드인 joinGroup()의 앞뒤로 AOP를 적용할 것이기에 포인트컷으로 @Around를 사용했고, 따라서 ProceedingJoinPoint가 적용됩니다. 코드 상에 구분한 1번 섹터와 2번 섹터로 나눠서 자세히 살펴보겠습니다.

1
2
3
4
MethodSignature signature = (MethodSignature)joinPoint.getSignature(); // ---- 1
Method method = signature.getMethod(); // ---- 2
DistributedLock lock = method.getAnnotation(DistributedLock.class); // ---- 3
String key = LockKeyParser.parse(signature.getParameterNames(), joinPoint.getArgs(), lock.key()); // ---- 4
  1. 타겟 메서드의 시그니처 정보를 가져옵니다. 내부에는 메서드 자체의 정보와 리턴 타입 정보가 포함되어 있습니다.
  2. 메서드 자체의 정보를 가져옵니다.
  3. 타겟 메서드에 붙어있는 DistributedLock 어노테이션의 정보를 가져옵니다.
  4. 타겟 메서드의 파라미터 정보, 어노테이션의 값 등을 사용해서 Lock Key를 생성합니다.

여기서 LockKeyParser의 동작 원리와 구현 이유가 아직 명확하지 않은데요, 조금 더 자세히 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class LockKeyParser {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    public static String parse(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]);
        }
        
        Expression expression = parser.parseExpression(key);
        String keyValue = expression.getValue(context, String.class);
        return REDISSON_LOCK_PREFIX + keyValue;
    }
}

parse()의 파라미터로 parameterNames, args, key가 들어옵니다. 각각의 요소가 어떤 값을 나타내는지는 타겟 메서드를 한번 살펴봐야 이해가 될 것 같습니다.

1
2
3
4
5
@DistributedLock(key = "#groupId")
@Transactional
public void joinGroup(final Long userId, final Long groupId){
    ...
}

parse()의 파라미터로 넘어오는 값들과 매칭해보자면 #groupId는 key, userId와 groupId는 parameterNames 그리고 파라미터에 들어오는 값들이 args가 됩니다. 다시 LockKeyParser로 돌아가보자면 parameterNames와 args를 매칭한 SpelContext를 만든뒤, #groupId라는 Expression을 통해 groupId의 실제 값을 가져옵니다.

이 방식을 사용한 이유가 뭘까요? 저는 분산락 기능의 확장성을 위해 이러한 Key 생성 방식을 채택했습니다. 분산락이 지금 당장은 그룹 참여 로직에만 사용되지만, 기능이 추가된다면 다른 로직에도 충분히 사용될 가능성이 있습니다. 그때 Lock Key로 사용될 수 있는 값은 groupId 이외에도 userId, memoryId등 여러 값이 들어올 수 있습니다. 따라서 필요할때마다 표현식을 통해 원하는 값을 파싱할 수 있는 구조를 만들어봤습니다. Spring이 제공해주는 @Cacheable 에 사용되는 표현식을 떠올려보면 구현 의도를 쉽게 이해할 수 있을 것입니다.

1
2
3
4
5
6
7
8
9
RLock rLock = redissonClient.getLock(key);

boolean available = rLock.tryLock(lock.waitTime(), lock.leaseTime(), lock.timeUnit());
            
if (!available) {
    throw new BusinessException(ErrorCode.SERVER_ERROR);
}
            
return joinPoint.proceed();

이제 락을 획득하는 과정을 살펴보겠습니다. Redisson 라이브러리는 tryLock() 메서드를 통해 Lock을 획득 가능하고, 락 소유 타임아웃, 락 대기 타임아웃을 따로 설정할 수 있습니다. 락을 획득한다면 리턴값으로 true를 반환합니다.

반면 상대 서버가 이미 Lock을 획득해서 Lock을 대기해야 하는 경우에는 tryLock() 메서드에서 waitTime동안 대기하게 됩니다. 만약 waitTime이 지나도 락을 획득하지 못했다면 리턴값으로 false를 반환하게 되고 아래의 조건문에서 실패 예외를 반환합니다.

만약 처음에 락을 정상적으로 획득했다면 joinPoint.proceed()를 통해 타겟 메서드를 실행합니다. 현재 분산락을 적용하는 joinGroup() 메서드는 리턴값이 없지만 차후 분산락을 적용할 다른 로직들은 리턴값이 존재할 수 있기에 joinPoint.proceed()를 return하는 형태로 만들었습니다.

1
2
3
4
5
6
7
8
9
 catch (WriteRedisConnectionException e) { // ------ 1
    log.error(REDISSON_CONNECTION_FAIL);
    throw new BusinessException(ErrorCode.SERVER_ERROR);
} 
finally {
    if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
        rLock.unlock();
    }
}

마지막 예외 처리와 락 해제 로직을 살펴보겠습니다.

우리는 Redis라는 외부 저장소를 사용하기 때문에 필연적으로 Redis가 다운된 경우를 생각해야 합니다. Redis 클러스터를 사용한다면 RedLock이라는 알고리즘을 사용해 N대의 노드 중 k개의 노드에서 락을 획득하는 경우 성공으로 판단 이런 식으로 가용성을 보장할 수 있습니다. 하지만 우리는 standalone 환경도 고려를 해야 합니다. 락 하나만을 위해 레디스 클러스터를 구축할 수는 없으니깐요.

1
2
3
4
5
6
7
8
9
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
       .setAddress(REDISSON_HOST_PREFIX + host + ":" + port)
       .setConnectTimeout(3000); // default 10s

    return Redisson.create(config);
}

우선 redisson 클라이언트의 커넥션 타임아웃을 지정했습니다. default인 10초 동안 기다리는 것 보다는 빠르게 실패 메세지를 던져주는게 Lock 기능에 적합하다고 판단했기에 타임아웃을 3초로 설정했습니다.

3초로 설정한 기준은 TCP/IP 커넥션을 맺을때 SYN Packet Retransmission의 InitRTO가 1초인걸 감안해서 1번 정도의 재시도를 감안한 시간입니다. 만약 타임아웃이 발생하면 WriteRedisConnectionException을 반환하고 밖으로 예외를 던집니다. 즉, Redis 서버에 문제가 있는 동안에는 Fail Fast로 예외를 던져서 동시성 문제를 방지하고자 했습니다.

다음으로는 finally 구문의 lock 해제 구간입니다. rLock.unlock()을 호출하면 pub/sub 기반으로 대기하고 있던 클라이언트들에게 락 해제 토픽을 전파합니다. 뒤에서 자세히 보겠지만 락을 획득한 트랜잭션이 커밋 전에 락을 먼저 반환해버리는 현상이 종종 발생합니다. 이때 락을 들고 있지 않은 상태에서 finally 구문으로 진입 후 unlock()을 호출하면 예외를 반환합니다. 따라서 이를 방지하기 위해 rLock.isLocked() && rLock.isHeldByCurrentThread()이라는 조건을 추가해서 현재 락을 소유하고 있는 클라이언트만 락을 해제할 수 있도록 만들었습니다.

지금까지 분산락을 구현한 코드에 대해 자세히 살펴봤는데요. 이번에는 분산락을 사용하면서 발생할 수 있는 한가지 예외 케이스에 대해 살펴보겠습니다.

분산락이 트랜잭션보다 먼저 해제되는 경우를 대비한 낙관적락 적용

분산락만 적용하더라도 동시성 이슈는 대부분 발생하지 않을 것입니다. 하지만 락을 자동으로 해제하는 타임아웃을 지정한 만큼 대비해야 하는 예외 상황이 존재합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@DistributedLock(key = "#commentId")
@Transactional
public void saveCommentLike(final Long userId, final Long commentId) {

    // 의도치 않은 네트워크 지연 발생
    Comment comment = commentReader.findById(commentId);
    
    // 의도치 않은 네트워크 지연 발생, 락 해제 타임아웃 경과해서 다른 트랜잭션이 락 획득
    commentLikeValidator.validateDuplicatedLike(commentId, userId);
    
    comment.addLike();
    commentLikeWriter.save(userId, comment);
}

위의 코드처럼 DB는 제 어플리케이션 외부 환경이고 네트워크를 통해 통신하기 때문에 중간에 어떤 지연이 발생할지 모릅니다. 또한 GC와 힙메모리가 최적화되지 않아서 STW(Stop The World)가 1~3초 동안 발생한다면 그동안 타임아웃이 발생해서 락이 해제될 수도 있습니다.

만약 그런 현상이 발생한다면 갱신 손실이 발생해서 좋아요 개수가 정확히 카운트 안될 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Value("${testing.value}")
private int testNum;

@DistributedLock(key = "#commentId", leaseTime = 2L)
@Transactional
public void saveCommentLike(final Long userId, final Long commentId) {

    Comment comment = commentReader.findByIdWithLock(commentId);
    commentLikeValidator.validateDuplicatedLike(commentId, userId);

    if (testNum == 1) {
        try {
            System.out.println("sleep 진입");
            Thread.sleep(2500L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    comment.addLike();
    commentLikeWriter.save(userId, comment);
}

갱신 손실이 정말로 발생하는지 간단한 테스트를 진행했습니다. 로컬에 2개의 서버를 띄우고 각각 환경 변수로 testNum의 값을 넘겨줍니다. 만약 testNum 값을 1로 넘겨주면 2.5초 동안 sleep 상태에 들어갑니다. 현재 락 해제 타임아웃을 2초로 설정했기에 sleep에 빠져있는동안 자동으로 락을 반환하게 됩니다.

동시에 두 개의 서버에 요청을 보낸 결과, sleep 상태에 빠진 서버가 다른 서버의 갱신 결과를 덮어씌워서 2개가 카운트 되야 하는 좋아요 개수가 1개만 카운트됐습니다.

이 현상은 자주는 아니더라도 한번씩은 발생할 수 있기에 대비를 해야 합니다. 저는 해결 방법으로 낙관적 락을 선택했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Slf4j
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 2) // @Transaction보다 먼저 AOP가 호출
@RequiredArgsConstructor
public class OptimisticLockAspect {

    @Around("@annotation(cmc.mellyserver.common.aspect.lock.OptimisticLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {

        log.info(OPTIMISTIC_LOCK_AOP_ENTRY); // 낙관적 락 진입 확인

        // @OptimisticLock 어노테이션 값 획득
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        OptimisticLock lock = method.getAnnotation(OptimisticLock.class);

        int retryCount = lock.retryCount();
        long waitTime = lock.waitTime();

        for (int i = 0; i < retryCount; i++) {

            try {
                return joinPoint.proceed();

            } catch (OptimisticLockingFailureException | CannotAcquireLockException ex) {
                log.info(OPTIMISTIC_LOCK_RETRY);
                Thread.sleep(waitTime);
            }
        }

        log.error(OPTIMISTIC_LOCK_ACQUIRE_FAIL);
        throw new BusinessException(ErrorCode.SERVER_ERROR);
    }
}

낙관적 락도 AOP를 사용해서 재시도 로직을 구현했습니다. 코드에 대한 자세한 내용은 프로젝트에 낙관적락을 적용한 사례를 보면 자세히 알 수 있습니다. 여기서 집중해야 하는 부분은 클래스 상단의 @Ordered(Ordered.LOWEST_PRECEDENCE-2)입니다.

스크린샷 2023-12-27 오후 9 34 24

우리가 구현해야 하는 코드의 구조입니다. 분산락 외부에 낙관적락이 위치해야 version 충돌로 인한 예외가 발생했을때 다시 분산락을 잡고 재시도를 할 수 있습니다. AOP에서 @Order는 숫자가 작을수록 우선 순위가 높습니다. 그리고 Default는 Ordered.LOWESTED_PRESEDENCEInteger.MAX_VALUE가 설정됩니다. 즉, 우선순위가 가장 낮게 설정되어 있는 것이죠. 저는 낙관적 락의 우선순위를 높게 설정하기 위해 @Order(Ordered.LOWEST_PRECEDENCE-2)를 설정하고 분산락에는 @Order(Ordered.LOWEST_PRECEDENCE-1)을 설정했습니다.

해당 설정을 한 후의 실행 로그를 살펴보겠습니다.

스크린샷 2023-12-27 오후 9 40 45

처음 AOP에 진입한 뒤 sleep 조건에 부합하여 대기 상태로 들어갑니다. 이때 락이 자동으로 해제되고, 다른 트랜잭션이 커밋을 진행했기에 낙관적 락 버전 충돌이 발생합니다. 이후 재시도 로직이 동작하여 다시 분산락을 획득하고 작업을 진행하는걸 볼 수 있습니다.

위의 2가지 락을 혼합해서 사용함으로써 동시성 문제가 발생할 수 있는 상황을 제어할 수 있습니다.

This post is licensed under CC BY 4.0 by the author.