- Published on
Spring Boot에서 Redis 분산락으로 중복 실행 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 수평 확장하면 언젠가 한 번은 겪습니다. 스케줄러가 두 대에서 동시에 돌거나, 같은 메시지가 재처리되거나, 결제/정산 같은 작업이 중복 실행되어 데이터가 꼬이는 문제입니다. 단일 인스턴스에서는 잘 보이지 않다가, 오토스케일링이나 롤링 배포 이후 갑자기 재현되는 경우가 많습니다.
이 글에서는 Spring Boot에서 Redis를 이용해 분산락(Distributed Lock) 을 구현해 중복 실행을 막는 방법을 실무 관점에서 정리합니다. 핵심은 단순히 락을 거는 것뿐 아니라,
- 락 획득 실패 시 어떻게 동작할지
- 락 TTL을 어떻게 잡을지
- 락 해제를 어떻게 안전하게 할지
- 장애나 재시작, 네트워크 지연에서 어떤 일이 생길지
까지 포함해 “운영 가능한 형태”로 만드는 것입니다.
참고로 분산 환경에서 데이터 정합성을 다루는 큰 그림은 Outbox나 SAGA 같은 패턴과도 연결됩니다. 분산락은 만능이 아니므로, 필요하면 실무 MSA에서 SAGA vs Outbox 선택 가이드도 함께 보시면 설계 판단에 도움이 됩니다.
왜 중복 실행이 생기나
대표적인 케이스는 아래와 같습니다.
Spring
@Scheduled- 스케줄은 각 인스턴스에서 독립적으로 동작합니다.
- 즉, Pod가 3개면 같은 작업이 3번 실행됩니다.
메시지 컨슈머 재처리
- Kafka나 SQS 같은 시스템은 “최소 한 번(at-least-once)” 전달을 흔히 사용합니다.
- 컨슈머가 처리 중 죽거나 ack 타이밍이 어긋나면 같은 메시지가 다시 올 수 있습니다.
HTTP 재시도/타임아웃
- 클라이언트나 게이트웨이가 타임아웃으로 재시도하면 서버는 같은 요청을 여러 번 받을 수 있습니다.
롤링 배포 중 중첩 실행
- 구버전 인스턴스와 신버전 인스턴스가 동시에 살아 있는 구간에서 스케줄이 겹칠 수 있습니다.
이런 문제를 “완전히” 없애려면 보통 멱등성 키(idempotency key) 와 DB 유니크 제약 같은 데이터 레벨 방어가 최종 보루가 됩니다. 하지만 스케줄러나 배치처럼 “한 번만 돌면 되는 작업”은 분산락이 가장 간단하고 효과적인 1차 방어선입니다.
Redis 분산락의 기본 원리
가장 기본적인 Redis 락은 아래 원리를 사용합니다.
- 락 키 예:
lock:billing:daily-settlement - Redis에
SET key value NX PX ttl로 저장NX: 키가 없을 때만 set (원자적)PX: TTL을 밀리초로 설정
락 value에는 보통 락 소유자 토큰(UUID 등)을 넣습니다. 그래야 “내가 잡은 락만 해제”할 수 있습니다.
반드시 지켜야 하는 2가지
- 락 획득은 원자적으로:
SET NX PX를 사용 - 락 해제는 소유자 확인 후: Lua 스크립트로
GET비교 후DEL
단순히 DEL lockKey를 해버리면, 아래 같은 사고가 생깁니다.
- A가 락 획득
- A가 오래 걸려 TTL 만료
- B가 락 획득
- A가 작업 끝나고
DEL실행 - B의 락이 지워져서 C가 또 락 획득
즉, “락을 잡은 놈만 풀어야” 합니다.
Spring Boot 구현: Lettuce + StringRedisTemplate
Spring Boot에서는 보통 Lettuce가 기본 Redis 클라이언트로 들어옵니다. 여기서는 StringRedisTemplate로 구현해보겠습니다.
의존성 예시
build.gradle 예시는 아래와 같습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter'
}
분산락 컴포넌트 구현
핵심은 아래 3개입니다.
tryLock:SET NX PXunlock: Lua로 소유자 검증 후 삭제withLock: 락 잡고 실행, finally에서 해제
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
@Component
public class RedisDistributedLock {
private final StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end",
Long.class
);
public RedisDistributedLock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public String tryLock(String lockKey, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean ok = redisTemplate.opsForValue().setIfAbsent(lockKey, token, ttl);
return Boolean.TRUE.equals(ok) ? token : null;
}
public boolean unlock(String lockKey, String token) {
Long result = redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), token);
return result != null && result == 1L;
}
public void withLock(String lockKey, Duration ttl, Runnable job) {
String token = tryLock(lockKey, ttl);
if (token == null) {
return; // 락 획득 실패: 중복 실행 방지
}
try {
job.run();
} finally {
unlock(lockKey, token);
}
}
}
이 구현은 “중복 실행 방지”라는 목적에는 충분히 강력합니다. 다만 TTL과 실행 시간이 엇갈릴 수 있으므로, 다음 섹션의 운영 포인트를 반드시 확인해야 합니다.
스케줄러 중복 실행 막기 예제
@Scheduled 작업을 한 번만 돌리고 싶다면, 스케줄 진입 지점에서 락을 잡으면 됩니다.
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class DailySettlementScheduler {
private final RedisDistributedLock lock;
public DailySettlementScheduler(RedisDistributedLock lock) {
this.lock = lock;
}
@Scheduled(cron = "0 0 2 * * *")
public void run() {
String lockKey = "lock:settlement:daily";
lock.withLock(lockKey, Duration.ofMinutes(30), () -> {
// 실제 정산 로직
doSettlement();
});
}
private void doSettlement() {
// ...
}
}
락 획득 실패 시 전략
락을 못 잡았을 때는 보통 3가지 중 하나를 선택합니다.
- 바로 종료(대부분의 스케줄러에 적합)
- 짧게 재시도(몇 초 간격으로 2~3회)
- 큐잉/지연 실행(락이 풀리면 실행해야 하는 성격일 때)
스케줄러는 대개 “해당 시각에 1번만” 돌면 되므로 1번이 가장 안전합니다.
TTL 설계: 실행 시간보다 길게, 그러나 무한은 금지
TTL은 “내가 죽었을 때 락이 영원히 남지 않게 하는 안전장치”입니다. 따라서 TTL을 너무 길게 잡으면 장애 시 복구가 느려지고, 너무 짧게 잡으면 실행 중 TTL 만료로 중복 실행이 가능해집니다.
실무에서는 다음 기준이 자주 쓰입니다.
ttl = P95 실행시간 + 여유분- 배치가 길게 돌 수 있으면
P99 + 여유분
예를 들어 정산 작업이 보통 5분, 가끔 12분이면 TTL 30분은 합리적입니다.
실행 시간이 TTL을 넘을 수 있으면
이 경우는 “락 연장(lease renewal)”이 필요합니다. 대표적으로는 watchdog 스레드가 주기적으로 TTL을 연장합니다.
다만 연장은 구현 난이도와 장애 모드가 늘어납니다. 가능하면 먼저 작업을 쪼개거나, 락을 잡는 구간을 최소화하거나, TTL을 합리적으로 늘리는 쪽을 우선 검토하세요.
락 키 설계: 충돌을 피하고 관찰 가능하게
락 키는 다음을 권장합니다.
- 프리픽스 통일:
lock: - 도메인/유스케이스 포함:
lock:billing:settlement:daily - 멀티테넌트면 tenant 포함:
lock:tenant:{tenantId}:job:...
키만 봐도 어떤 작업의 락인지 알 수 있어야 운영이 편합니다.
장애/운영에서 자주 터지는 포인트
1) Redis 장애 시 어떻게 할 것인가
Redis가 다운되면 락을 잡을 수 없습니다. 이때 선택지는 둘 중 하나입니다.
- Fail closed: 락을 못 잡으면 작업을 실행하지 않음 (중복 실행보다 미실행이 낫다)
- Fail open: 락이 없어도 실행 (미실행보다 실행이 낫다)
정산/결제/청구는 보통 fail closed가 맞고, 단순 통계 집계는 fail open도 고려할 수 있습니다.
2) Pod 재시작 루프에서 중복 실행
Pod가 계속 재시작되면 작업이 중간에 끊기고 다시 시작될 수 있습니다. 이때 TTL이 짧으면 중복 실행 빈도가 늘어납니다.
운영에서 재시작 루프를 빨리 잡아내는 것도 중요합니다. 쿠버네티스 환경이라면 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기처럼 원인별로 빠르게 진단하는 체계를 갖추는 게 좋습니다.
3) 락은 중복 실행을 줄이지만, 멱등성을 대체하지 않는다
락이 있어도 아래 상황에서는 중복 효과가 날 수 있습니다.
- TTL 만료 직후 다른 인스턴스가 락 획득
- 네트워크 지연으로 락 연장 실패
- Redis 클러스터/센티넬 failover 중 순간적인 타이밍 이슈
따라서 금전/재고 같은 강한 정합성 도메인은
- DB 유니크 키
- 상태 전이 머신
- Outbox 기반 이벤트 발행
같은 “데이터 레벨 안전장치”를 같이 두는 편이 좋습니다.
(선택) Redisson으로 더 쉽게: Lock API 사용
직접 구현이 부담되면 Redisson을 고려할 수 있습니다. Redisson은 분산락 API와 watchdog(자동 연장) 기능을 제공합니다.
의존성은 보통 아래처럼 추가합니다.
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
}
사용 예시는 다음과 같습니다.
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedissonLockJob {
private final RedissonClient redissonClient;
public RedissonLockJob(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
public void runOnce() throws InterruptedException {
RLock lock = redissonClient.getLock("lock:settlement:daily");
boolean locked = lock.tryLock(0, 30, TimeUnit.MINUTES);
if (!locked) return;
try {
doJob();
} finally {
lock.unlock();
}
}
private void doJob() {
// ...
}
}
주의할 점은 tryLock(waitTime, leaseTime, unit)에서 leaseTime을 주면 watchdog 자동 연장과의 관계가 달라질 수 있다는 점입니다. 팀 표준을 정해 “명시적 TTL”로 갈지, “watchdog 기반”으로 갈지 통일하는 편이 운영이 쉽습니다.
테스트 전략: 로컬에서 동시성 재현하기
분산락은 “안 되는 걸 확인”하는 게 중요합니다. 로컬에서도 멀티스레드로 동시 실행을 유도해 볼 수 있습니다.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@SpringBootTest
class RedisDistributedLockTest {
@Autowired
RedisDistributedLock lock;
@Test
void onlyOneShouldRun() throws Exception {
String lockKey = "lock:test:once";
AtomicInteger executed = new AtomicInteger(0);
int threads = 20;
var pool = Executors.newFixedThreadPool(threads);
CountDownLatch latch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
try {
lock.withLock(lockKey, Duration.ofSeconds(10), executed::incrementAndGet);
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
// 이상적으로는 1
System.out.println("executed=" + executed.get());
}
}
이 테스트는 완전한 분산 환경을 대체하진 못하지만, 최소한 “락이 동시 실행을 막는지”는 빠르게 확인할 수 있습니다.
체크리스트: 실무 적용 전 마지막 점검
- 락 획득은
SET NX PX인가 - 락 value에 토큰을 넣고, 해제는 Lua로 소유자 검증 후 삭제하는가
- TTL은 실행시간 P95 또는 P99 기준으로 설계했는가
- 락 획득 실패 시 동작(종료/재시도/지연)을 명확히 정의했는가
- Redis 장애 시 fail open 또는 fail closed 정책을 정했는가
- “락으로 중복 실행 방지” 외에, 데이터 레벨 멱등성 방어가 필요한 도메인인지 검토했는가
마무리
Spring Boot에서 Redis 분산락은 스케줄러, 배치, 이벤트 처리의 중복 실행을 현실적으로 줄이는 가장 간단한 방법입니다. 다만 락은 어디까지나 “동시 실행을 줄이는 도구”이고, TTL/해제 안전성/장애 정책을 함께 설계해야 운영에서 사고가 나지 않습니다.
정리하면, 최소 구성은 SET NX PX와 “토큰 기반 Lua 해제”이고, 작업 시간이 길거나 변동이 크면 Redisson 같은 라이브러리 또는 락 연장 전략을 검토하세요.