[업무일지] 5시간 동안 안 풀리던 분산락을 30초로 줄인 Watchdog
백엔드 세 서비스의 분산락 구현이 제각각이었습니다. 그중 제일 위험한 건 비정상 종료 시 락이 5시간 남는 패턴이었습니다.
@DistributedLock하나로 통일하고 Redisson Watchdog을 붙여 그 5시간을 30초로 줄였습니다. 통일 자체는 리팩토링인데, AOP 자기호출과 롤링 배포 중 split-brain에서 발이 걸렸던 기록입니다.
Java 21, Spring Boot 3.x, Redisson 3.4x 환경입니다. 여러 pod로 뜨는 서비스라, 같은 작업이 두 pod에서 동시에 도는 걸 막는 분산락이 곳곳에 있었습니다. 이 글의 클래스명과 락 키는 예시로 바꿨습니다.
락이 세 서비스에서 제각각이었다
분산락을 쓰는 곳을 전수조사했더니 방식이 세 가지였습니다.
한 서비스는 @RedisLock이라는 자체 AOP를 썼습니다. setIfAbsent + 5시간 TTL 기반이고, 서른 곳쯤에서 쓰고 있었죠. 다른 서비스는 executeWithLock(key, runnable) 람다 래퍼였고, 또 다른 서비스는 Redisson RLock을 직접 부르는 곳과 setIfAbsent로 캐시를 워밍업하는 곳이 섞여 있었습니다.
같은 회사 코드인데 락 하나 잡는 방법이 세 개였습니다. 새로 짜는 사람은 셋 중 뭘 따라야 할지 모릅니다.
여기에 조용한 버그도 하나 있었습니다. 자체 AOP가 락 키를 메서드 파라미터 이름으로 뽑고 있었거든요. 파라미터명이 규칙과 어긋난 메서드는 엉뚱한 키에 잠겼습니다. 어떤 메서드는 유저별로 잠겨야 하는데 상수 키에 전역으로 잠기고 있었고요. 컴파일도 되고 테스트도 안 짚으면 안 보입니다.
비정상 종료 시 락이 5시간 남았다
setIfAbsent + 긴 TTL 패턴은 대략 이렇게 생겼습니다.
1
2
3
4
5
6
7
8
9
10
// 기존 방식: setIfAbsent 로 잡고, TTL 로만 풀리게 둔다
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofHours(5));
if (!Boolean.TRUE.equals(ok)) {
return; // 이미 누가 잡음
}
try {
// 임계 구역
} finally {
redis.delete(key); // 정상 종료면 여기서 푼다
}
정상 흐름은 finally에서 풀립니다. 문제는 pod가 비정상 종료할 때입니다. finally가 안 돌면 락은 TTL까지 남습니다. TTL이 5시간이면 그 키가 지키던 작업이 5시간 동안 아무 pod에서도 안 돕니다.
TTL을 짧게 잡으면 반대 문제가 생깁니다. 작업이 TTL보다 오래 걸리면 락이 중간에 풀려서 두 pod가 동시에 임계 구역에 들어옵니다.
Redisson Watchdog은 이 딜레마를 다르게 풉니다. 락을 잡은 스레드가 살아 있는 동안 별도 스케줄러가 백그라운드에서 leaseTime을 기본 10초마다 계속 연장해주기 때문에, 작업이 5분이 걸리든 한 시간이 걸리든 그 스레드가 살아 있는 한 락은 중간에 안 풀립니다. 대신 pod가 죽으면 연장이 멈추고, 마지막 lease가 만료되면서 약 30초 안에 자동으로 풀리고요. 5시간이 30초가 됐습니다.
@DistributedLock 하나로 표준화했다
그래서 세 서비스에 같은 애노테이션을 심었습니다.
1
2
3
4
@DistributedLock(key = "'settle:' + #userId", waitTime = 5)
public void settle(Long userId) {
// 임계 구역
}
키는 파라미터 이름이 아니라 SpEL로 직접 씁니다. #userId가 뭘 가리키는지 애노테이션에 그대로 드러나니, 파라미터명이 바뀌어도 조용히 엉뚱한 키에 잠기지 않습니다.
Aspect의 핵심은 leaseTime 분기 하나입니다.
1
2
3
boolean acquired = (leaseTime < 0)
? lock.tryLock(waitTime, timeUnit) // Watchdog 자동 갱신
: lock.tryLock(waitTime, leaseTime, timeUnit); // 고정 lease (Watchdog off)
leaseTime을 안 주면(-1) 2-arg tryLock이 호출돼 Watchdog이 켜집니다. 대부분의 락은 이걸 기본으로 씁니다. 다만 캐시 워밍업처럼 “중복 실행보다 누락이 안전한” 작업은 일부러 고정 lease로 뒀습니다. 점유 중이면 그냥 건너뛰고 끝나는 게 맞는 의미니까요. 실패 시 예외를 던질지 건너뛸지는 onFailure 정책으로 호출처가 고릅니다.
@Transactional보다 바깥에 둬야 했다
Aspect 순서를 잘못 두면 락이 트랜잭션 안쪽에서 돕니다. 그러면 커밋 전에 락이 풀려서, 아직 커밋 안 된 상태를 다른 pod가 읽습니다.
그래서 락 Aspect를 @Transactional보다 바깥에 세웠습니다.
1
2
@Order(Ordered.LOWEST_PRECEDENCE - 1) // @Transactional(LOWEST_PRECEDENCE)보다 바깥
public class DistributedLockAspect { ... }
이러면 락 획득 → 트랜잭션 시작 → 커밋 → 락 해제 순서가 보장됩니다. 커밋이 끝난 뒤에야 락이 풀리니, 다음 pod는 항상 커밋된 상태를 봅니다.
AOP가 자기 호출엔 안 먹었다
여기서 한 번 걸렸습니다. 같은 빈 안에서 메서드를 부르면 @DistributedLock이 안 먹습니다.
Spring AOP는 프록시로 도는데, 같은 객체 내부 호출(this.method())은 프록시를 안 거치고 실제 객체로 바로 갑니다. 애노테이션이 붙어 있어도 Aspect가 낄 자리가 없는 거죠.
1
2
3
4
5
// ✗ outer 가 같은 빈의 locked 를 직접 부르면 프록시를 안 타 락이 안 걸린다
public void outer(Long id) { locked(id); }
@DistributedLock(key = "'x:' + #id")
public void locked(Long id) { ... }
인라인 람다 안에서 부르던 곳이 이 함정에 빠져 있었습니다. 두 가지로 풀었습니다. 하나는 임계 구역을 별도 빈으로 뽑아 그 빈을 주입받는 것, 다른 하나는 자기 자신을 @Lazy로 주입받아 self.locked(id)로 프록시를 태우는 것입니다. 순환 의존을 피하려고 @Lazy를 붙였고요.
롤링 배포 중 두 락이 같은 키를 다르게 잠갔다
제일 신경 쓴 건 배포였습니다. 롤링 배포 중에는 옛 코드 pod와 새 코드 pod가 잠깐 같이 떠 있습니다. 옛 pod는 setIfAbsent로 String 락을 잡고, 새 pod는 Redisson RLock(내부적으로 hash)을 잡습니다. 같은 키를 서로 다른 방식으로 잠그면 상호배제가 깨집니다. split-brain이죠.
두 가지로 막았습니다. 먼저 새 락의 키를 옛 키와 바이트까지 똑같이 맞췄습니다. prefix를 새로 지으면 두 방식이 서로를 아예 못 보니까요.
그리고 Redisson이 String 키에 hash 명령을 날려 WRONGTYPE 에러가 나면, 이걸 하드 에러로 터뜨리지 않고 “지금 옛 방식으로 누가 점유 중”이라는 뜻으로 해석해 락 경합 실패로 처리했습니다.
1
2
3
4
if (isWrongTypeError(e)) {
// 옛 setIfAbsent 락이 같은 키를 점유 중 → 경합 실패로 처리(상호배제 유지)
return onAcquireFailure(...);
}
배포 직전에 옛 방식이 남긴 stale 락 키를 정리하는 것도 절차에 넣었습니다. TTL 5시간짜리 String 락이 남아 있으면, 새 코드가 그 키를 5시간 기다릴 수 있거든요.
flowchart TD
subgraph 기존["서비스마다 제각각"]
A1["@RedisLock<br/>setIfAbsent + 5h TTL"]
A2["executeWithLock<br/>람다 래퍼"]
A3["Redisson 직접 호출<br/>+ setIfAbsent"]
end
subgraph 통일["@DistributedLock 하나"]
B["Redisson RLock<br/>+ Watchdog<br/>+ SpEL 키<br/>+ onFailure 정책"]
end
A1 --> B
A2 --> B
A3 --> B
B -->|"죽으면 ~30초 후 만료"| C(["5h → 30s"])
classDef fix fill:#2e7d32,color:#fff,stroke:#1b5e20
class C fix
맺음말
락 인프라를 하나로 모으고, 죽은 락이 5시간 남던 걸 30초로 줄였습니다. 통일하면서 파라미터명 의존 키 같은 조용한 버그도 같이 걷어냈고요.
포기한 것과 한계가 있습니다. 옛 setIfAbsent는 재진입이 안 됐는데 Redisson RLock은 같은 스레드면 재진입됩니다. 의도적으로 재진입을 막던 곳이 있었다면 동작이 미묘하게 바뀔 수 있어, 그런 지점은 따로 검토해야 했습니다.
카드사 연동 검토처럼 원래 상수 키에 전역으로 잠기던 곳도 있었습니다. 유저별 키로 바꾸는 게 맞지만, 그건 동시성 정책을 새로 정하는 일이라 동작을 그대로 보존하고 별도 과제로 미뤘습니다. 함부로 바꾸면 회귀가 나니까요.
그리고 이 락은 Redis 단일 노드에 얹혀 있습니다. 여러 pod의 동시성은 막지만, Redis 자체가 죽으면 락도 같이 죽습니다. 단일 실패점이 남아 있는 셈이라, 여기까지가 이번 작업의 경계였습니다.