이 포스트에서는 Redis로 분산락을 구현하여 동시성 이슈를 해결한 과정에 대해 상세히 다루고자 한다. 하지만 이것이 끝이 아니다. 왜냐하면 또다른 문제가 생겼기 때문이다.🙃
지금까지 동시성 이슈 해결을 위해 synchronized 사용, DB 락 잠금(링크) 등을 시도했다.
synchronized는 애플리케이션 서버와 데이터베이스 서버 모두 단일 서버로 운영될 때만 사용 가능하며, JAVA 언어에 종속적이라는 단점이 존재했다. 그리고 여러 로직을 처리해야하는 애플리케이션 서버가 병목 지점이 될 수 있는 것은 위험했다.
MySQL의 비관적 락을 사용하는 것은 MySQL이 병목 지점이 될 수 있는 단점이 있었다. 동일한 트래픽 처리를 위해 MySQL의 CPU 사용량이 80%까지 오르는 것을 확인했다.
그래서 그 다음 방법으로 분산락(Distributed Lock)을 적용해보았다.
분산락이란?
분산 시스템에서는 여러 노드가 동시에 동일한 데이터에 접근할 수 있다. 그 때, 분산락은 데이터의 무결성을 위해 사용되는 기술이다.
분산락은 대표적으로 MySQL, Redis, Zookeeper 등을 사용하여 구현한다.
이미 Redis를 사용하고 있는 서버가 있었고, Redis 6.0버전부터는 외부 요청에 대해 이벤트 루프 처리 방식을 사용하고 있어서 성능상의 이점이 있을 것 같다고 판단했다. MySQL을 이미 사용하고 있는 상황이면, 그냥 MySQL 네임드락을 사용하는 것으로 결정하는 경우도 많다고 한다. (참고글 : 우아한 형제들 기술 블로그 - MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리) 만일 규모가 큰 시스템이라서 Redis 갯수도 늘려야 한다면, Redlock 알고리즘을 적용한 Redis 분산락을 구현하는 것이 더 옳은 결정이라고 생각한다.
distributeLockExecutor.execute(RedisLockUtils.getCouponLockName(couponId), 3000, 3000, () -> {
couponIssueRedisService.checkIssuable(coupon, userId);
issueRequest(couponId, userId);
});
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();
}
}
}
터미널에서 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이 동일해도, 값은 매번 다른 것이다. 그래서 진행 중인 값이 갱신되는 등의 문제는 발생하지 않는다.
RPS : 108, Average Response Time : 9 secs

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