Redis 분산락으로 동시성 이슈를 해결했지만, 여전히 문제는 남아있다.

shinny·2024년 7월 14일

[성능 개선]

목록 보기
3/8

이 포스트에서는 Redis로 분산락을 구현하여 동시성 이슈를 해결한 과정에 대해 상세히 다루고자 한다. 하지만 이것이 끝이 아니다. 왜냐하면 또다른 문제가 생겼기 때문이다.🙃

지금까지 동시성 이슈 해결을 위해 synchronized 사용, DB 락 잠금(링크) 등을 시도했다.

synchronized는 애플리케이션 서버와 데이터베이스 서버 모두 단일 서버로 운영될 때만 사용 가능하며, JAVA 언어에 종속적이라는 단점이 존재했다. 그리고 여러 로직을 처리해야하는 애플리케이션 서버가 병목 지점이 될 수 있는 것은 위험했다.

MySQL의 비관적 락을 사용하는 것은 MySQL이 병목 지점이 될 수 있는 단점이 있었다. 동일한 트래픽 처리를 위해 MySQL의 CPU 사용량이 80%까지 오르는 것을 확인했다.

그래서 그 다음 방법으로 분산락(Distributed Lock)을 적용해보았다.

분산락이란?
분산 시스템에서는 여러 노드가 동시에 동일한 데이터에 접근할 수 있다. 그 때, 분산락은 데이터의 무결성을 위해 사용되는 기술이다.

0️⃣ 의사 결정

분산락은 대표적으로 MySQL, Redis, Zookeeper 등을 사용하여 구현한다.

이미 Redis를 사용하고 있는 서버가 있었고, Redis 6.0버전부터는 외부 요청에 대해 이벤트 루프 처리 방식을 사용하고 있어서 성능상의 이점이 있을 것 같다고 판단했다. MySQL을 이미 사용하고 있는 상황이면, 그냥 MySQL 네임드락을 사용하는 것으로 결정하는 경우도 많다고 한다. (참고글 : 우아한 형제들 기술 블로그 - MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리) 만일 규모가 큰 시스템이라서 Redis 갯수도 늘려야 한다면, Redlock 알고리즘을 적용한 Redis 분산락을 구현하는 것이 더 옳은 결정이라고 생각한다.

  • Q. 🔥 왜 Lettuce가 아니라 Redisson을 사용했는가?
    A. Redisson은 RLock이라고 하는 Lock 전용 객체를 제공한다. Redisson은 Lock에 타임아웃을 명시하기에, 무한정 대기상태로 빠지지 않는다. 그리고 스핀락이 아닌 pub/sub을 사용한다. 스핀락을 사용하지 않고 pub/sub을 사용하는 것의 장점은 해당 락을 구독하는 클라이언트들이 락 획득을 위해 Redis로 무한정 요청을 보내지 않아, 서버와 Redis 양측의 부하가 덜하다는 점이다. 그리고 락 해제를 전달받았을 때만 락을 시도하기 때문에 재시도 로직도 필요없다.

1️⃣ 작성 코드

  • 1번. 특정 coupon id 값에 대해 Lock을 건다.
distributeLockExecutor.execute(RedisLockUtils.getCouponLockName(couponId), 3000, 3000, () -> {
       couponIssueRedisService.checkIssuable(coupon, userId);
       issueRequest(couponId, userId);
});
  • 2번. 락을 획득해서 처리한 후 해제한다.
public void execute(String lockName, long waitMilliSeconds, long releaseMilliSeconds, Runnable logic) {
        RLock lock = redissonClient.getLock(lockName);
        try {
            boolean isLocked = lock.tryLock(waitMilliSeconds, releaseMilliSeconds, TimeUnit.MILLISECONDS);
            if (!isLocked) {
                log.error("Lock 획득 실패, Lock Name : {}", lockName);
                throw new LockException(ErrorCode.LOCK_FAIL);
            }
            logic.run();
        } catch (InterruptedException exception) {
            log.error("Interrupted Exception - Lock 획득 실패, Lock Name : {}", lockName, exception.getMessage());
            throw new LockException(ErrorCode.LOCK_FAIL);
        } finally {
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

execute가 실행될 때 Redis에서 일어나는 일

터미널에서 docker exec -it coupon-redis redis-cli -p 6380 명령어를 실행하고, redis-cli에 들어가서 monitor를 실행했다.
그런 다음 execute 메서드를 실행하니, redis-cli에는 다음과 같은 쿼리가 찍혔다.

"EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock_1" "10000" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82"
"exists" "lock_1"
"hincrby" "lock_1" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82" "1"
"pexpire" "lock_1" "10000"
"HEXISTS" "lock_1" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82"
"EVAL" "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;" "2" "lock_1" "redisson_lock__channel:{lock_1}" "0" "10000" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82"
"hexists" "lock_1" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82"
"hincrby" "lock_1" "86eae567-b225-4a8e-8ce2-446a0cd2c22d:82" "-1"
"del" "lock_1"
"publish" "redisson_lock__channel:{lock_1}" "0"
  • EVAL : Redis에서 Lua Script를 실행할 수 있게 해준다. 이를 통해 명령어 집합으로 스크립트를 원자적으로 실행할 수 있다. EVAL 명령어를 사용하면 여러 Redis 명령어를 조합하여 스크립트를 실행할 수 있고, 이는 트랜잭션이나 복잡한 데이터 조작에 유용하다.
-- KEYS[1]: 락 키
-- ARGV[1]: 락의 만료 시간 (밀리초 단위)
-- ARGV[2]: 클라이언트 식별자 (예: UUID)

-- 키가 존재하지 않는 경우
if (redis.call('exists', KEYS[1]) == 0) then
    -- 클라이언트 식별자 필드를 1로 설정 (참조 카운트 증가)
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    -- 키의 TTL을 설정
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

-- 키가 존재하고, 해당 클라이언트 식별자가 필드로 존재하는 경우
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 클라이언트 식별자 필드의 값을 1로 증가 (참조 카운트 증가)
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    -- 키의 TTL을 갱신
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

-- 키가 존재하지만, 해당 클라이언트 식별자가 필드로 존재하지 않는 경우
-- 남은 TTL을 반환
return redis.call('pttl', KEYS[1])

요청마다 새로운 UUID가 생성되어 ARGV[2]로 전달되고 있기 때문에, 키가 존재하고 식별자가 필드로 존재하는 경우는 없다. hincrby로 식별자 필드에 대해 1을 추가하고, pexpire로 ttl도 등록해준 다음, logic.run()이 마무리되고서는, 해당 키에 대한 값을 조회하고 1을 감소시킨다. 키 값이 0이 되면, 해당 데이터를 삭제하고, 해당 락을 구독하는 채널에 메세지를 발행한다. 그러면 락이 해제되고, 다음 요청을 처리할 준비를 한다.

Q. 🔥 만일 동일한 Lockname이 들어오면 어떻게 될까?
A. 현재 구현된 방식에서는 요청마다 고유한 UUID 값을 가진다. 그래서 Lockname이 동일해도, 값은 매번 다른 것이다. 그래서 진행 중인 값이 갱신되는 등의 문제는 발생하지 않는다.

2️⃣ 평가

해결한 것

  1. 여러 개의 애플리케이션 서버가 있을 때도, 동시성 이슈 대응이 가능하다.
  2. MySQL의 CPU Usage를 30%대로 낮추었다.
  • RPS : 108, Average Response Time : 9 secs

  • MySQL CPU usage : 32%, Redis CPU Usage : 3%

해결하지 못한 것

  1. 현재 구현된 코드로 단일 Redis가 아닌 여러 개의 Redis 운영은 대응이 불가능하다. 그럴 때는 Redis의 Redlock 알고리즘을 사용해야 한다.(참고글 - 공식문서)
  2. 정확한 선착순 보장 혹은 순서 보장은 되지 않는다. 왜냐하면 RedissonClient의 락 획득이 non-fair locking 방식이기 때문이다. (다른 포스트에서 상세히 다루었습니다.)

Reference

  • 레디스를 활용한 분산 락(Distrubuted Lock) feat lettuce, redisson(링크)
profile
꾸준히, 성실하게, 탁월하게 매일 한다

0개의 댓글