Redis 분산 잠금: 10가지 공통적인 실수와 그 것을 피하는 방법
Ethan Miller
Product Engineer · Leapcell

Redis 분산 잠금은 일상적인 개발에서 동시 요청 시 데이터 읽기/쓰기 문제를 해결하기 위해 자주 사용됩니다. 그러나 Redis 분산 잠금을 사용하는 데는 많은 함정이 있습니다. 이 글에서는 Redis 분산 잠금의 10가지 함정을 분석하고 설명합니다.
1. 비원자적 연산 (setnx + expire)
Redis 분산 잠금을 구현할 때 많은 개발자가 즉시 setnx + expire
명령을 사용하는 것을 떠올립니다. 즉, setnx
를 사용하여 잠금을 획득하고, 성공하면 expire
를 사용하여 잠금에 만료 시간을 설정합니다.
가상 코드:
if (jedis.setnx(lock_key, lock_value) == 1) { // 잠금 획득 jedis.expire(lock_key, timeout); // 만료 시간 설정 doBusiness // 비즈니스 로직 }
이 코드에는 큰 함정이 있습니다. setnx
와 expire
가 별도로 실행되며 원자적이지 않습니다! setnx
를 실행한 직후, 그러나 expire
를 실행하기 전에 프로세스가 충돌하거나 재시작되면 잠금이 만료되지 않습니다. 결과적으로 다른 스레드는 잠금을 획득할 수 없습니다.
2. 다른 클라이언트의 요청에 의해 덮어쓰기 (setnx + value로 만료 시간 사용)
예외로 인해 잠금이 해제되지 않는 문제를 해결하기 위해 일부는 만료 타임스탬프를 setnx
값에 넣는 것을 제안합니다. 잠금 획득에 실패하면 저장된 값을 현재 시스템 시간과 비교하여 잠금이 만료되었는지 확인할 수 있습니다. 가상 코드 구현:
long expireTime = System.currentTimeMillis() + timeout; // 현재 시간 + 타임아웃 String expireTimeStr = String.valueOf(expireTime); // 문자열로 변환 // 잠금이 존재하지 않으면 true 반환 if (jedis.setnx(lock_key, expireTimeStr) == 1) { return true; } // 잠금이 존재하면 만료 시간 검색 String oldExpireTimeStr = jedis.get(lock_key); // 저장된 만료 시간이 현재 시간보다 작으면 만료됨 if (oldExpireTimeStr != null && Long.parseLong(oldExpireTimeStr) < System.currentTimeMillis()) { // 잠금이 만료되었습니다. 새 만료 시간으로 덮어쓰기 시도 String oldValueStr = jedis.getSet(lock_key, expireTimeStr); if (oldValueStr != null && oldValueStr.equals(oldExpireTimeStr)) { // 동시 시나리오에서 설정 값이 이전 값과 일치하는 스레드만 잠금을 획득합니다. return true; } } // 다른 모든 경우에서 잠금 획득 실패 return false;
이 접근 방식에도 함정이 있습니다. 잠금이 만료되고 여러 클라이언트가 동시에 jedis.getSet()
을 호출하면 하나만 잠금을 성공적으로 획득합니다. 그러나 해당 클라이언트의 만료 시간이 다른 클라이언트에 의해 덮어쓰여 불일치가 발생할 수 있습니다.
3. 만료 시간 설정 잊음
코드를 검토하는 동안 다음과 같은 분산 잠금 구현을 본 적이 있습니다.
try { if (jedis.setnx(lock_key, lock_value) == 1) { // 잠금 획득 doBusiness // 비즈니스 로직 return true; // 잠금 획득 및 비즈니스 로직 처리 } return false; // 잠금 획득 실패 } finally { unlock(lockKey); // 잠금 해제 }
여기서 무엇이 잘못되었습니까? 그렇습니다. 만료 시간이 누락되었습니다. 프로그램이 실행 중에 충돌하여 finally
블록에 도달하지 못하면 잠금이 삭제되지 않습니다. 이로 인해 잠금 해제가 신뢰할 수 없게 됩니다. 따라서 분산 잠금을 사용할 때는 항상 만료 시간을 설정하십시오.
4. 비즈니스 처리 후 잠금 해제를 잊음
많은 개발자가 확장된 매개변수와 함께 Redis의 set
명령을 사용하여 분산 잠금을 구현합니다.
SET key value
의 확장된 매개변수:
NX
: 키가 존재하지 않는 경우에만 설정하여 첫 번째 클라이언트만 잠금을 획득하도록 합니다.EX seconds
: 만료 시간을 초 단위로 설정합니다.PX milliseconds
: 만료 시간을 밀리초 단위로 설정합니다.XX
: 키가 존재하는 경우에만 설정합니다.
일부는 다음과 같은 가상 코드를 작성할 수 있습니다.
if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // 잠금 획득 doBusiness // 비즈니스 로직 return true; // 잠금 획득 및 비즈니스 로직 처리 } return false; // 잠금 획득 실패
언뜻 보면 괜찮아 보이지만 문제가 있습니다. 잠금 해제를 잊었습니다! 항상 만료가 잠금을 해제할 때까지 기다리면 효율성이 떨어집니다. 비즈니스 로직이 완료된 후 잠금을 해제해야 합니다.
올바른 사용법:
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // 잠금 획득 doBusiness // 비즈니스 로직 return true; // 잠금 획득 및 비즈니스 로직 처리 } return false; // 잠금 획득 실패 } finally { unlock(lockKey); // 잠금 해제 }
5. 스레드 A에 의해 스레드 B의 잠금이 해제됨
다음 가상 코드를 고려하십시오.
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // 잠금 획득 doBusiness // 비즈니스 로직 return true; // 잠금 획득 및 비즈니스 로직 처리 } return false; // 잠금 획득 실패 } finally { unlock(lockKey); // 잠금 해제 }
여기서 문제는 무엇입니까?
스레드 A와 B가 모두 잠금을 획득하려고 시도하는 동시 시나리오에서 스레드 A가 먼저 잠금을 획득한다고 가정합니다 (3초 후에 만료되도록 설정). 비즈니스 로직이 느리고 3초 이상 걸리면 Redis는 잠금을 자동 만료합니다. 그런 다음 스레드 B가 잠금을 획득하고 실행을 시작합니다. 스레드 A가 작업을 완료하고 잠금을 해제하면 실수로 스레드 B의 잠금을 해제합니다.
올바른 접근 방식은 잠금을 획득할 때 고유한 요청 식별자 (requestId
와 같은)를 추가하고 식별자가 일치하는 경우에만 잠금을 해제하는 것입니다.
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // 잠금 획득 doBusiness // 비즈니스 로직 return true; // 잠금 획득 및 비즈니스 로직 처리 } return false; // 잠금 획득 실패 } finally { if (requestId.equals(jedis.get(lockKey))) { // 동일한 requestId인지 확인 unlock(lockKey); // 잠금 해제 } }
6. 잠금 해제가 원자적이지 않음
이전 코드에도 결함이 있습니다.
if (requestId.equals(jedis.get(lockKey))) { // 동일한 requestId인지 확인 unlock(lockKey); // 잠금 해제 }
검사(get
)와 해제(del
)는 별도의 작업이므로 원자적이지 않습니다. unlock(lockKey)
가 호출될 때까지 잠금이 이미 만료된 경우 잠금이 다른 클라이언트에 의해 획득되었을 수 있습니다. 지금 해제하면 다른 사람의 잠금이 제거되어 위험합니다.
이는 일관성 문제를 야기합니다. 검사와 삭제는 원자적이어야 합니다. 잠금을 해제할 때 원자성을 보장하려면 다음과 같이 Redis + Lua 스크립트를 사용할 수 있습니다.
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
7. 잠금은 만료되었지만 비즈니스 로직이 완료되지 않았음
잠금을 획득한 후 시간 초과로 인해 잠금이 만료되면 Redis는 자동으로 삭제합니다. 그러나 비즈니스 로직이 아직 완료되지 않았을 수 있으며, 이로 인해 조기에 잠금이 해제될 수 있습니다.
일부 개발자는 더 긴 만료 시간을 설정하는 것이 간단한 해결책이라고 생각합니다. 그러나 이것을 고려하십시오. 잠금을 획득한 스레드에 대해 워치독 스레드를 시작할 수 있습니다. 이 스레드는 잠금이 여전히 존재하는지 주기적으로 확인하고, 존재하는 경우 조기 해제를 방지하기 위해 만료 시간을 연장할 수 있습니다.
이 문제는 오픈 소스 프레임워크인 Redisson에서 해결되었습니다.
스레드가 잠금을 획득하면 Redisson은 10초마다 잠금을 확인하는 백그라운드 스레드인 워치독을 시작합니다. 스레드가 여전히 잠금을 보유하고 있으면 워치독은 TTL을 계속 연장합니다. 이것이 Redisson이 비즈니스 로직이 완료되지 않았을 때 잠금 조기 만료 문제를 해결하는 방법입니다.
8. @Transactional
과 함께 사용하면 Redis 분산 잠금이 효과가 없어짐
이 가상 코드를 보십시오.
@Transactional public void updateDB(int lockKey) { boolean lockFlag = redisLock.lock(lockKey); if (!lockFlag) { throw new RuntimeException("나중에 다시 시도하십시오"); } doBusiness // 비즈니스 로직 redisLock.unlock(lockKey); }
이 경우 Redis 분산 잠금은 트랜잭션 메서드 내에서 사용됩니다. 이 메서드가 실행되면 다음과 같습니다.
- Spring의 AOP로 인해 트랜잭션이 시작됩니다.
- Redis 잠금이 획득됩니다.
- 비즈니스 로직이 실행된 후 Redis 잠금이 해제됩니다.
- 그런 다음 트랜잭션이 커밋됩니다.
이로 인해 문제가 발생합니다. 트랜잭션이 커밋되기 전에 잠금이 해제됩니다. 다른 스레드가 잠금을 획득하고 첫 번째 트랜잭션에서 아직 커밋되지 않은 오래된 데이터를 읽어 로직을 실행할 수 있습니다.
왜 이런 일이 발생할까요?
Spring AOP는 updateDB()
가 실행되기 전에 트랜잭션을 시작합니다. 그런 다음 Redis 잠금이 메서드 내에서 획득됩니다. 메서드가 완료되면 잠금이 해제되지만 트랜잭션은 아직 커밋되지 않았습니다.
올바른 접근 방식: 트랜잭션 메서드에 들어가기 전에, 트랜잭션이 시작되기 전에 잠금을 획득하십시오. 이렇게 하면 잠금으로 보호된 코드가 완전히 일관된 상태에 있게 됩니다.
9. 재진입 잠금
지금까지 논의한 Redis 분산 잠금은 재진입 불가능합니다.
재진입 불가능은 스레드가 이미 잠금을 보유하고 있고 (동일한 스레드 내에서) 다시 획득하려고 하면 차단되거나 실패함을 의미합니다. 즉, 스레드는 동일한 잠금을 한 번만 획득할 수 있습니다.
이러한 유형의 잠금은 대부분의 비즈니스 사례에서 작동하지만 일부 시나리오에서는 재진입이 필요합니다. 분산 잠금을 설계할 때 응용 프로그램에 재진입 분산 잠금이 필요한지 고려하십시오.
Redis에서 재진입 동작을 구현하려면 두 가지 문제를 해결해야 합니다.
- 현재 잠금을 보유하고 있는 스레드를 추적하는 방법.
- 잠금이 획득된 횟수 (재진입 횟수)를 유지하는 방법.
재진입 분산 잠금을 구축하려면 Java의 ReentrantLock
설계를 참조할 수 있습니다. 또는 재진입 잠금을 기본적으로 지원하는 Redisson을 사용할 수 있습니다.
10. Redis 마스터-슬레이브 복제로 인한 문제
Redis 분산 잠금을 구현할 때 Redis의 마스터-슬레이브 복제 설정으로 인해 발생하는 문제에 주의하십시오. Redis는 종종 클러스터로 배포됩니다.
스레드 A가 마스터 노드에서 잠금을 획득했지만 잠금 키가 아직 슬레이브 노드로 복제되지 않았다고 가정해 보겠습니다. 마스터 노드가 다운되면 슬레이브 중 하나가 마스터로 승격될 수 있습니다. 이제 스레드 B는 키가 새 마스터에 존재하지 않으므로 동일한 잠금 키를 획득할 수 있습니다. 그러나 스레드 A는 여전히 잠금을 보유하고 있다고 생각합니다. 이제 두 스레드가 모두 잠금을 가지고 있다고 생각합니다. 이로 인해 잠금 안전이 깨집니다.
이 문제를 해결하기 위해 Redis 작성자 antirez는 Redlock이라는 고급 분산 잠금 알고리즘을 제안했습니다.
Redlock의 핵심 아이디어:
고가용성을 보장하기 위해 여러 Redis 마스터 노드를 사용합니다. 이러한 노드는 완전히 독립적이며 노드 간에 복제가 없습니다. 동일한 잠금 로직 (획득/해제)이 각 마스터에 적용됩니다.
별도의 서버에 5개의 Redis 마스터 노드가 있다고 가정합니다. Redlock 단계:
- 5개의 모든 마스터 노드에서 순차적으로 잠금을 획득하려고 시도합니다.
- 노드에 연결할 수 없으면 (예: 네트워크 대기 시간) 시간 초과 후 건너뜁니다.
- 5개 노드 중 최소 3개에서 잠금 획득에 성공하고 사용된 총 시간이 잠금의 TTL보다 짧으면 잠금이 성공한 것으로 간주됩니다.
- 획득에 실패하면 이전에 획득한 모든 잠금을 해제합니다.
웹 백엔드 프로젝트 호스팅에 가장 적합한 선택, Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하십시오.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 비용을 지불하십시오. 요청이 없으면 요금이 부과되지 않습니다.
탁월한 비용 효율성
- 유휴 요금없이 사용한만큼 지불하십시오.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
능률적인 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하도록 자동 확장합니다.
- 운영 오버헤드가 없으므로 빌드에 집중하십시오.
설명서에서 자세히 알아보십시오!
X에서 팔로우하세요: @LeapcellHQ