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에서 잠금을 항상 테스트하세요.

redis분산-잠금spring-bootredisson동시성