Spring Boot에서 Redis 분산 잠금
1. 개요
애플리케이션이 여러 인스턴스에서 실행될 때는 synchronized나 ReentrantLock으로 부족합니다. 이 잠금들은 하나의 JVM 내에서만 작동합니다. 이 글에서는 Redis를 사용하여 분산 잠금을 구현하는 두 가지 방법을 소개합니다: Spring Data Redis로 직접 구현하기와 Redisson 라이브러리 사용. 읽고 나면 안전하게 잠금을 획득하고 해제하는 방법, 잠금을 테스트하는 방법, 그리고 Redis 잠금이 충분하지 않은 경우들을 알게 될 것입니다.
2. 분산 잠금이 필요한 이유
OrderService가 3개의 pod에서 실행된다고 가정하겠습니다. 두 고객이 재고에 남은 마지막 상품을 주문합니다. 두 요청이 서로 다른 pod으로 들어갑니다. 둘 다 stock = 1을 읽고 재고를 차감하면 결과적으로 과다 판매하게 됩니다.
또 다른 예는 새벽 2시에 보고서를 생성하는 @Scheduled job입니다. 3개의 인스턴스가 있으면 job은 3번 실행되고 고객은 정확히 같은 이메일을 3개 받습니다.
두 경우 모두 모든 인스턴스가 볼 수 있는 잠금이 필요합니다. Redis는 다음 세 가지 이유로 이 역할에 적합합니다:
- Redis는 단일 스레드에서 명령을 순차적으로 처리하므로 각 명령은 원자적입니다.
- 키에 TTL을 설정할 수 있으므로 잠금을 보유한 프로세스가 크래시되면 잠금이 자동으로 만료됩니다.
- 낮은 지연시간과 대부분의 시스템에 이미 Redis가 있습니다.
3. 원리: SET NX PX 명령
전체 메커니즘은 단 하나의 명령에 기반합니다:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
각 부분의 의미:
NX: 키가 아직 존재하지 않을 때만 쓰기. 이것이 “잠금 획득” 단계입니다.PX 10000: 키는 10초 후 자동으로 만료됩니다. 그래서 프로세스가 중간에 죽으면 잠금이 영구적으로 잠기지 않습니다."c1f9-token": 각 잠금 획득마다 무작위로 생성되는 고유한 값입니다. 이 값은 누가 잠금을 소유하고 있는지 식별하는 데 사용됩니다.
위 명령을 연속으로 두 번 실행하면 다음과 같이 표시됩니다:
OK
(nil)
첫 번째는 OK를 반환하므로 잠금을 획득했습니다. 두 번째는 (nil)을 반환하는데 이는 키가 이미 존재하기 때문입니다.
주의:
SETNX를 사용한 후 별도로EXPIRE를 호출하지 마세요. 두 명령 사이에 프로세스가 죽으면 키가 절대 만료되지 않아 데드락이 발생합니다.
4. Spring Data Redis로 직접 구현
4.1. 의존성
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.properties에서 연결 설정:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. 잠금 획득 및 해제
@Component
public class RedisLock {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<String> tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
}
public boolean unlock(String key, String token) {
Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
return deleted != null && deleted == 1L;
}
}
설명:
setIfAbsent(key, token, ttl)은 정확히SET key token NX PX ttl명령을 생성합니다. 결과true는 잠금을 획득했음을 의미합니다.tryLock은 성공하면 token을 반환합니다. 나중에 잠금을 해제하려면 이 token을 보관해야 합니다.unlock은 Lua 스크립트를 실행합니다. 스크립트는 키의 값이 당신의 token과 일치하는지 확인하고 일치할 때만 키를 삭제합니다.
왜 직접 DEL을 호출하지 않을까요? 다음 시나리오를 생각해보세요. 인스턴스 A가 TTL 10초로 잠금을 획득하지만 처리에 12초가 걸립니다. 10초 후 잠금이 만료되고 인스턴스 B가 잠금을 획득합니다. 12초에 A는 DEL을 호출하여 B의 잠금을 삭제합니다. 이제 인스턴스 C가 진입할 수 있고 B와 C가 동시에 실행됩니다.
GET과 DEL을 분리하면 두 명령 사이에 여전히 간격이 있습니다. Redis는 Lua 스크립트를 원자적으로 실행하므로 그 간격이 없습니다.
4.3. 서비스에서 사용
public record Order(long productId, int quantity) {}
@Service
public class OrderService {
private final RedisLock redisLock;
private final InventoryRepository inventory;
public OrderService(RedisLock redisLock, InventoryRepository inventory) {
this.redisLock = redisLock;
this.inventory = inventory;
}
public void placeOrder(Order order) {
String key = "lock:product:" + order.productId();
String token = redisLock.tryLock(key, Duration.ofSeconds(10))
.orElseThrow(() -> new IllegalStateException("상품이 처리 중입니다. 다시 시도해주세요"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
세 가지 주의할 점:
- 잠금 키는 리소스별로 구체적이어야 합니다. 여기서는
lock:product:{id}입니다. 모든 제품에 공통 잠금을 사용하면 모든 주문이 순차적으로 대기하게 됩니다. unlock은 항상finally블록에 있어야 하므로 예외가 발생해도 잠금이 해제됩니다.- 이 예제는 잠금을 획득하지 못할 때 즉시 실패합니다. 대기하려면 짧은 백오프로 몇 번 재시도할 수 있습니다. 무제한
while(true)루프를 사용하면 안 됩니다.
InventoryRepository는 프로젝트의 모든 저장소입니다(예: Spring Data JPA 저장소).
5. Redisson 사용
직접 구현하면 메커니즘을 이해할 수 있지만 몇 가지 기능이 부족합니다: 장시간 처리할 때 잠금 연장, 재진입 가능성, 폴링 대신 pub/sub으로 잠금 대기. Redisson은 이러한 기능들을 제공합니다.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
프로젝트가 사용 중인 Spring Boot와 호환되는 최신 버전을 확인해야 합니다. 이 starter는 spring.data.redis.* 설정을 읽고 RedissonClient 빈을 자동으로 생성합니다.
@Service
public class DailyReportJob {
private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
private final RedissonClient redisson;
public DailyReportJob(RedissonClient redisson) {
this.redisson = redisson;
}
@Scheduled(cron = "0 0 2 * * *")
public void generate() throws InterruptedException {
RLock lock = redisson.getLock("lock:daily-report");
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
log.info("다른 인스턴스가 보고서를 실행 중이므로 건너뜁니다");
return;
}
try {
log.info("일일 보고서 생성 시작");
// 데이터 조회, 파일 내보내기, 이메일 전송...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
job이 3개의 인스턴스에서 실행될 때 로그는 다음과 같이 표시됩니다:
[pod-1] 일일 보고서 생성 시작
[pod-2] 다른 인스턴스가 보고서를 실행 중이므로 건너뜁니다
[pod-3] 다른 인스턴스가 보고서를 실행 중이므로 건너뜁니다
중요한 포인트:
tryLock(0, TimeUnit.SECONDS)은 대기하지 않습니다. 잠금이 보유 중이면 함수는 즉시false를 반환합니다.leaseTime을 전달하지 않으면 Redisson이 watchdog을 활성화합니다. 잠금은 기본 30초의 TTL(lockWatchdogTimeout)을 가지며 스레드가 잠금을 유지하는 한 약 10초마다 갱신됩니다. JVM이 죽으면 갱신이 중단되고 잠금이 자동으로 만료됩니다.- 구체적인
leaseTime으로tryLock(wait, lease, unit)을 호출하면 watchdog이 비활성화됩니다. 작업 완료 여부와 상관없이 잠금은lease후에 만료됩니다. RLock은 스레드에 바인딩되고 재진입 가능합니다. 다른 스레드에서unlock()을 호출하면IllegalMonitorStateException을 받게 됩니다. 이는CompletableFuture또는 리액티브 코드를 사용할 때 특히 주의해야 합니다.
주의:
RLock이 스레드에 바인딩되기 때문에 virtual thread 및 중간에 스레드를 전환하는 프레임워크에서 사용할 때 주의해야 합니다. 잠금은 같은 스레드에서 획득하고 해제해야 합니다.
6. Testcontainers로 테스트 작성
Redis 실제 인스턴스에서 잠금을 테스트해야 하며 mock을 사용해서는 안 됩니다. 다음 테스트 의존성을 추가하세요(Spring Boot에서 관리하는 버전):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
RedisLock redisLock;
@Autowired
StringRedisTemplate template;
@Test
void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
.<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
.toList();
try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
long acquired = executor.invokeAll(tasks).stream()
.map(Future::resultNow)
.filter(Optional::isPresent)
.count();
assertThat(acquired).isEqualTo(1);
}
}
@Test
void givenWrongToken_whenUnlock_thenLockIsKept() {
String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();
assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
assertThat(template.hasKey("lock:owner")).isTrue();
assertThat(redisLock.unlock("lock:owner", token)).isTrue();
assertThat(template.hasKey("lock:owner")).isFalse();
}
}
설명:
@DataRedisTest는 Redis 설정 부분만 로드하므로 테스트가 빠르게 실행됩니다.@Import(RedisLock.class)는 테스트할 빈을 컨텍스트에 가져옵니다.@ServiceConnection(name = "redis")은StringRedisTemplate을 방금 시작한 컨테이너로 자동 연결합니다. 호스트와 포트를 수동으로 선언할 필요가 없습니다.- 첫 번째 테스트는 20개의 스레드가 하나의 잠금을 놓고 경쟁하게 하고 정확히 1개의 스레드만 성공했는지 확인합니다.
- 두 번째 테스트는 Lua 스크립트가 제대로 작동함을 확인합니다: 잘못된 token은 잠금을 삭제할 수 없고 올바른 token은 삭제할 수 있습니다.
7. 일반적인 오류 및 제한 사항
7.1. 처리 시간보다 짧은 TTL
이것이 가장 일반적인 오류입니다. 작업이 아직 실행 중인데 잠금이 만료되고 다른 인스턴스가 개입합니다. 원인이 느린 코드만은 아닙니다. GC 일시 중지, 느린 네트워크 또는 연결 풀 대기도 TTL을 초과할 수 있습니다.
두 가지 처리 방법이 있습니다. 첫 번째는 Redisson의 watchdog을 사용하는 것입니다. 두 번째는 충분한 여유를 두고 TTL을 설정하는 것입니다(예: 처리 시간의 p99의 몇 배).
7.2. 잠금이 절대 보장하지 않음
Redis 복제는 비동기입니다. master가 잠금을 받지만 replica로 복제하기 전에 크래시된다고 가정합니다. replica가 새 master로 승격되고 거기에 lock 키가 없습니다. 이제 다른 클라이언트가 동일한 잠금을 획득할 수 있습니다.
Redlock 알고리즘(N개의 독립적인 노드 중 다수에서 잠금 획득)은 이 위험을 줄입니다. 그러나 Redlock은 클록 및 지연에 대한 가정에 의존하기 때문에 여전히 논쟁의 여지가 있습니다. Redisson에서 RedissonRedLock은 현재 deprecated로 표시되어 있습니다.
데이터 오류로 인한 결과가 심각한 경우(예: 두 번 결제), 잠금에만 의존하면 안 됩니다. fencing token과 결합하세요: 매번 잠금을 획득할 때 증가하는 숫자를 받습니다(예: INCR lock:product:42:fence). 저장소 계층은 이전 토큰의 쓰기를 거부합니다:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
UPDATE 문이 0행을 수정하면 더 새로운 token을 가진 프로세스가 이미 쓰기를 수행했다는 의미입니다. 이전 요청은 거부됩니다.
7.3. 기타 오류
- 모든 리소스에 공통 키를 사용하면 처리량이 급격히 감소합니다.
finally를 잊어버리면 TTL이 만료될 때만 잠금이 해제됩니다.- 잠금이 더 이상 없을 때 Redisson의
unlock()을 호출합니다(TTL 만료 때문에). 호출하기 전에isHeldByCurrentThread()를 확인하세요. - 백오프 없이 계속 잠금 재시도하면 Redis에 요청이 몰립니다.
7.4. 사용하면 안 되는 경우
- 애플리케이션이 단일 인스턴스만 가짐:
ReentrantLock으로 충분하고 훨씬 간단합니다. - 데이터가 관계형 데이터베이스에 있음: optimistic locking(
@Version),SELECT ... FOR UPDATE또는 unique constraint가 종종 더 안전합니다. 이들은 데이터와 동일한 트랜잭션에 있습니다. - 절대적 정확성이 필요함: ZooKeeper 또는 etcd와 같은 합의 시스템을 fencing token과 함께 사용하는 것을 고려하세요.
- 각 메시지를 한 번만 처리해야 함: idempotency key 또는 메시지 브로커의 consumer group이 잠금보다 더 적합한 경우가 많습니다.
8. 결론
Redis를 사용한 분산 잠금은 세 가지 원칙을 기반으로 합니다: 원자적 SET NX PX 명령으로 잠금 획득, 각 잠금 획득마다 고유한 token 첨부, token 확인이 있는 Lua 스크립트로 잠금 해제. StringRedisTemplate을 사용하여 수십 줄로 직접 구현하거나 Redisson을 사용하여 watchdog, 재진입 가능성 및 효율적인 대기 메커니즘을 추가할 수 있습니다.
Redis 잠금이 대부분의 경우 중복 처리를 방지하는 데 도움이 되지만 절대 보장하지는 않음을 기억하세요. 중요한 작업의 경우 잠금을 fencing token 또는 데이터베이스 계층의 제약과 결합해야 합니다. 또한 Testcontainers를 사용하여 실제 Redis에서 잠금을 항상 테스트하세요.