포인트 차감 버그가 들어왔다. 유저 포인트가 100인데 100짜리 상품을 두 번 결제했다는 것이다. 코드를 보면 잔액 확인 후 차감하고 synchronized도 있었다. 서버 로그를 보니 두 요청이 정확히 같은 시간에 들어왔다. 서버가 두 대였다.
synchronized를 썼는데 왜 서버 두 대에서 동시성 문제가 생기나?
synchronized는 JVM 메모리 안의 뮤텍스를 사용한다. 같은 JVM 프로세스 안에서 동시에 두 스레드가 임계 구역에 들어오는 걸 막는다.
서버가 A, B 두 대라면:
- 서버 A의 뮤텍스와 서버 B의 뮤텍스는 서로 독립된 객체다
- 서버 A는 서버 B가 임계 구역에 있는지 전혀 모른다
- 두 요청이 A와 B에 각각 도달하면 둘 다 잔액을 읽고, 둘 다 차감한다
// 단일 JVM에서는 안전하지만 멀티 서버에서는 의미 없다
public synchronized void decreasePoint(Long userId, int amount) {
int current = pointRepository.findByUserId(userId).getBalance();
// 서버 A: current = 100
// 서버 B: current = 100 (동시에 읽음)
if (current < amount) throw new InsufficientPointException();
pointRepository.updateBalance(userId, current - amount);
// 서버 A: 0으로 업데이트 성공
// 서버 B: 0으로 업데이트 성공 (둘 다 성공)
}
잔액 100에서 두 번 차감이 가능하다. 포인트가 -100이 된다.
JVM 밖에서 공유 상태를 보호하려면 JVM 밖에 있는 무언가가 락 역할을 해야 한다.
Redis로 분산락을 만들면 어떻게 동작하나?
기본 원리는 단순하다. “이 자원을 내가 쓰는 중”이라는 표시를 Redis에 저장한다. 다른 서버는 표시가 있으면 기다린다.
서버 A: Redis에 "lock:user:123" 저장 → 성공 → 임계 구역 진입
서버 B: Redis에 "lock:user:123" 저장 시도 → 실패 (이미 있음) → 대기
서버 A: 처리 완료 → "lock:user:123" 삭제
서버 B: 다시 시도 → 성공 → 임계 구역 진입
Redis가 단일 스레드로 명령을 처리하기 때문에 동시에 두 서버가 저장을 시도해도 하나만 성공한다. 서버가 몇 대가 되든 같다.
DB 비관적 락(SELECT FOR UPDATE)도 분산 환경에서 동작한다. 그럼 왜 Redis가 필요한가?
DB 비관적 락의 한계
@Transactional
public void decreasePoint(Long userId, int amount) {
Point point = pointRepository.findByUserIdForUpdate(userId); // 락 획득
// 임계 구역 안에 외부 API 호출이 있다면?
externalPaymentApi.charge(userId, amount); // 100ms ~ 수초
point.decrease(amount);
// 트랜잭션 커밋 = 락 해제
}
SELECT FOR UPDATE가 걸려 있는 동안 DB 커넥션이 점유된다. 외부 API 호출이 임계 구역 안에 있으면 API 응답 시간만큼 커넥션이 묶인다. HikariCP maximumPoolSize=10이면 동시에 10개 요청만 처리 가능하고 나머지는 커넥션 대기로 쌓인다. 커넥션 풀 고갈이다.
Redis 분산락은 DB 커넥션을 소비하지 않는다. 그리고 DB 외부 자원(외부 API, 파일, 메시지 큐)을 보호할 때는 DB 트랜잭션으로 락을 걸 수 없으니 Redis가 유일한 선택이다.
SETNX + EXPIRE를 따로 쓰면 왜 위험한가?
이렇게 구현하면 함정이 있다:
SETNX lock:user:123 "locked" # 키가 없으면 SET
EXPIRE lock:user:123 30 # 30초 만료
이 두 명령은 원자적이지 않다. 두 개의 별도 명령이다.
SETNX가 성공하고 EXPIRE를 실행하기 직전에 서버가 죽으면?
락 키가 만료 시간 없이 Redis에 영구히 남는다. 이 자원에 대한 락은 영원히 해제되지 않는다. 데드락이다. 운영 중에 이게 터지면 수동으로 Redis 키를 삭제하거나 Redis를 재시작해야 풀린다.
올바른 명령은 두 작업을 하나의 원자적 명령으로 합친 것이다:
SET lock:user:123 "my-value" NX PX 30000
# NX: 키가 없을 때만 SET (Not eXists)
# PX 30000: 30,000밀리초(30초) 후 자동 만료
# 단일 원자적 명령 = SETNX + EXPIRE를 분리했을 때의 타이밍 이슈 없음
서버가 SET 직후 죽어도 30초 후 자동으로 락이 해제된다.
SET key value NX PX로 해결됐다. 근데 value를 왜 UUID로 해야 하나?
“locked”나 “1” 같은 고정값을 쓰면 다른 서버의 락을 지우는 버그가 생긴다.
시나리오:
1. 서버 A가 락 획득: SET lock:user:123 "locked" NX PX 30000
2. 서버 A 처리가 오래 걸려 30초 초과 → TTL 만료, 락 자동 해제
3. 서버 B가 락 획득: SET lock:user:123 "locked" NX PX 30000
4. 서버 A가 처리를 마치고 DEL lock:user:123 실행
5. 서버 B의 락이 해제됨!
6. 서버 C가 락 획득 → 서버 B와 서버 C가 동시에 임계 구역에 있음
자신이 건 락인지 확인하고 삭제해야 한다. UUID를 value로 저장하면:
String lockValue = UUID.randomUUID().toString(); // 서버 A만 아는 고유값 예: "a3f2b1c4-..."
// 락 획득
redisTemplate.opsForValue().set(
"lock:user:" + userId,
lockValue, // "a3f2b1c4-..." (내가 건 락이라는 증거)
Duration.ofSeconds(30)
);
// 해제 시: Redis에 저장된 value가 내 UUID일 때만 삭제
서버 A의 UUID는 절대 서버 B의 UUID와 같지 않다. 서버 B가 락을 다시 획득한 상황에서 Redis에 저장된 value는 서버 B의 UUID다. 서버 A가 DEL을 시도해도, value를 먼저 확인하면 자신의 UUID가 아니므로 삭제하지 않는다.
락 해제를 왜 Lua 스크립트로 해야 하나? 그냥 DEL하면 안 되나?
“value 확인 후 DEL”을 GET과 DEL 두 명령으로 나누면 또 원자성 문제가 생긴다.
서버 A: GET lock:user:123 → "my-uuid" (내 값이 맞음)
← 이 순간 TTL 만료, 서버 B가 락 획득 →
서버 A: DEL lock:user:123 → 서버 B의 락을 삭제!
GET과 DEL 사이에 다른 클라이언트가 끼어들 수 있다.
Redis는 Lua 스크립트를 단일 명령처럼 원자적으로 실행한다. Redis가 단일 스레드로 명령을 처리하기 때문에 Lua 스크립트 실행 중에는 다른 명령이 끼어들지 않는다.
-- 락 해제 Lua 스크립트: 내 값일 때만 삭제
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public boolean releaseLock(String key, String myValue) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, List.of(key), myValue);
return Long.valueOf(1L).equals(result);
}
반환값 1: 내 락을 정상 해제. 반환값 0: TTL 만료로 이미 해제됐거나, 내 락이 아님. 이 상황이 자주 발생하면 TTL을 늘리거나 처리 로직을 최적화해야 한다.
Redis가 하나면 충분한가? Redlock이 왜 나왔나?
단일 Redis 마스터(또는 Sentinel 구성)에서 마스터가 장애로 죽으면:
1. 서버 A가 마스터에 락 획득
2. 마스터 → Replica 비동기 동기화가 완료되기 전에 마스터 장애 발생
3. Sentinel이 Replica를 새 마스터로 승격
4. 새 마스터에는 락 정보가 없음 (동기화 전에 마스터가 죽었으니까)
5. 서버 B가 새 마스터에 락 획득
6. 서버 A와 서버 B가 동시에 임계 구역에 있음
Redis replication은 비동기다. 쓰기 후 동기화 완료를 기다리지 않는다. 마스터 장애 타이밍에 따라 락 정보가 유실될 수 있다.
Redlock은 N개의 독립 Redis 노드를 사용해 과반수로 결정한다.
알고리즘 (N=5 기준):
- 락 시도 시작 시각 기록
- 5개 노드에 순서대로
SET key value NX PX ttl시도 - 과반수(3개 이상) 에서 성공 + 실제 소요 시간이 TTL보다 짧으면 락 획득
- 실제 사용 가능한 TTL =
설정한 TTL - 경과 시간 - 과반수 실패 또는 TTL 초과 시 모든 노드에서 즉시 해제
노드 2개가 죽어도 나머지 3개에서 과반수를 충족하므로 락이 동작한다. 5개 노드는 완전히 독립된 머신, 독립된 네트워크에 있어야 의미 있다. 같은 머신에 여러 Redis 프로세스를 띄우는 건 의미 없다.
Redlock이 GC pause에 취약하다는데 어떤 시나리오인가?
2016년 Martin Kleppmann이 Redlock을 분석하면서 제기한 시나리오다.
1. 서버 A가 Redlock 획득 (TTL 10초)
2. 서버 A에서 Stop-the-World GC pause 발생 (15초)
→ JVM 멈춤. 코드가 실행되지 않음. Redis TTL은 계속 흐름
3. TTL 10초 만료 → Redlock 자동 해제
4. 서버 B가 Redlock 획득
5. 서버 A의 GC pause 종료
6. 서버 A: "나는 락을 갖고 있다"고 믿고 임계 구역 계속 진행
7. 서버 A와 서버 B 모두 임계 구역에 있음
Java G1GC의 Full GC는 수백ms에서 수십초까지 걸릴 수 있다. 이 동안 JVM은 멈추고 Redis TTL은 흐른다.
Redis 창시자 Antirez는 반박했다: “15초 GC pause가 나는 JVM이 문제이고, 실용적으로 Redlock은 충분히 안전하다.”
내 판단: 이론적으로 Kleppmann이 옳다. 실용적으로는 도메인에 따라 판단해야 한다.
- 결제 최종 차감, 재고 최종 감소처럼 중복이 금전적 손실로 이어지는 경우: Redlock만으로 부족하다. DB 유니크 제약으로 최종 방어선을 따로 두어야 한다.
- 쿠폰 중복 발급 방지, 한정 수량 예약처럼 동시 접근이 UX 저하를 유발하는 경우: 단일 Redis 락이나 Redlock으로 충분하다.
분산락은 완벽한 안전장치가 아니다. 속도를 올려주는 1차 필터다. 최종 정합성은 DB 레벨에서 보장해야 한다.
Kleppmann이 제시한 진짜 해법 — fencing token
Kleppmann의 비판은 “Redlock이 약하다”에서 끝나지 않는다. 결론은 더 근본적이다. 어떤 락이든 락만으로는 위 시나리오를 막을 수 없고, 보호하려는 자원(스토리지) 쪽이 협력해야 한다는 것이다. 그 협력 수단이 fencing token이다.
아이디어는 단순하다. 락을 발급할 때마다 단조 증가하는 토큰 번호를 함께 발급한다. 락을 얻은 클라이언트는 보호 자원에 쓸 때 이 토큰을 같이 보낸다. 자원 쪽은 지금까지 본 가장 큰 토큰 번호를 기억해 두고, 그보다 작은 토큰의 쓰기는 거부한다.
1. 서버 A가 락 획득 → fencing token 33 발급
2. 서버 A에서 GC pause 발생 → 그사이 TTL 만료, 락 해제
3. 서버 B가 락 획득 → fencing token 34 발급
4. 서버 B가 스토리지에 write(token=34) → 자원이 34를 기록, 허용
5. 서버 A가 GC pause 종료 후 뒤늦게 write(token=33) 시도
6. 자원: "이미 34를 봤다. 33은 과거의 토큰이다" → 거부
핵심은 stale lock holder(서버 A)의 늦은 쓰기를 자원이 토큰 번호만으로 걸러낸다는 점이다. 서버 A가 자기가 여전히 락을 쥐고 있다고 착각해도, 토큰 33은 이미 추월당했으므로 데이터를 오염시킬 수 없다.
앞서 “DB 유니크 제약으로 최종 방어선을 둔다”고 한 것은 fencing token의 실용적 우회다. 유니크 제약·낙관적 락의 버전 컬럼은 결국 “오래된 쓰기를 자원 쪽에서 거부한다”는 같은 원리를 다른 형태로 구현한 것이다. 토큰을 명시적으로 발급할 수 없는 환경에서도, 보호 자원이 과거 상태의 쓰기를 거부할 수단을 갖추는 게 본질이다.
Redisson을 쓰면 뭐가 달라지나? Watchdog이 뭔가?
직접 구현한 분산락은 leaseTime 안에 처리가 끝나지 않으면 락이 자동 해제된다. 처리 시간이 불확실한 작업에서 문제가 된다.
Redisson의 Watchdog은 이 문제를 해결한다. leaseTime=-1(기본값)로 설정하면 활성화된다.
RLock lock = redissonClient.getLock("lock:user:" + userId);
lock.lock(); // leaseTime = -1 기본값 → Watchdog 활성화
Watchdog은 내부 백그라운드 스레드다. 기본 30초 TTL로 락이 생성되면, 10초마다 TTL을 다시 30초로 갱신한다. 처리가 30초 넘게 걸려도 락이 유지된다.
없으면 어떤 문제? leaseTime=10초로 설정했는데 처리가 12초 걸리면, 10초에 락이 자동 해제되고 다른 서버가 락을 획득한다. 동시성 문제가 재발한다.
Watchdog 주의사항: 외부 API가 응답 없이 멈추면 Watchdog이 계속 TTL을 갱신해서 락이 영구히 유지된다. 모든 외부 호출에 반드시 타임아웃을 설정해야 한다.
// 명시적 leaseTime 사용 (Watchdog 없음, 권장 기본값)
boolean acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
// 30초 후 처리가 안 끝나면 락 해제 → 이 자체가 이상한 상황임
try {
if (!acquired) {
throw new LockAcquisitionFailedException("락 획득 실패");
}
// 처리 로직
} finally {
// isHeldByCurrentThread() 확인이 중요하다
// leaseTime 만료로 자동 해제된 후 unlock() 호출하면 IllegalMonitorStateException
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
@DistributedLock 어노테이션을 어떻게 만드나? AOP 전체 구현
매 메서드마다 try-finally-unlock 패턴을 반복하면 코드가 지저분해진다. AOP로 추상화하자.
어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // SpEL 표현식 지원
long waitTime() default 3L; // 락 대기 시간
long leaseTime() default 10L; // 락 보유 시간
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
AOP Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {
private static final String LOCK_PREFIX = "lock:";
private final RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock)
throws Throwable {
String lockKey = LOCK_PREFIX + resolveKey(joinPoint, distributedLock.key());
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new LockAcquisitionFailedException("락 획득 실패: " + lockKey);
}
return joinPoint.proceed();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionFailedException("인터럽트 발생: " + lockKey);
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String resolveKey(ProceedingJoinPoint joinPoint, String keyExpression) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
StandardEvaluationContext context = new StandardEvaluationContext();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return parser.parseExpression(keyExpression).getValue(context, String.class);
}
}
사용 예시
// SpEL로 메서드 파라미터를 키에 활용
@DistributedLock(key = "'point:decrease:' + #userId")
public void decreasePoint(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId);
if (point.getBalance() < amount) throw new InsufficientPointException();
point.decrease(amount);
}
// waitTime=0: 락 못 얻으면 즉시 실패 (쿠폰 발급은 재시도보다 즉시 실패가 나음)
@DistributedLock(key = "'coupon:' + #couponId + ':user:' + #userId", waitTime = 0, leaseTime = 5)
public void issueCoupon(Long couponId, Long userId) {
// ...
}
Self-invocation 함정
같은 클래스 내에서 this.method()로 호출하면 AOP가 적용되지 않는다. Spring AOP는 프록시 기반이라 프록시를 거치지 않는 내부 호출은 Aspect가 실행되지 않는다.
// @DistributedLock이 동작하지 않는다
@Service
public class PointService {
public void doSomething(Long userId) {
this.decreasePoint(userId, 100); // 프록시 우회 → AOP 미적용
}
@DistributedLock(key = "'point:' + #userId")
public void decreasePoint(Long userId, int amount) { ... }
}
해결책: @DistributedLock이 붙은 메서드를 별도 서비스 클래스로 분리한다.
DB 비관적 락과 분산락 중 어떻게 선택하나?
기준은 임계 구역 안에 무엇이 있는지다.
| 상황 | 선택 | 이유 |
|---|---|---|
| 임계 구역이 DB 읽기-수정-쓰기만 있다 | DB 비관적 락 | 단순하고 트랜잭션과 일체화됨 |
| 임계 구역에 외부 API 호출이 있다 | Redis 분산락 | DB 커넥션을 점유하지 않음 |
| 임계 구역에 Kafka 발행이 있다 | Redis 분산락 | DB 트랜잭션 바깥의 자원 |
| 고TPS, DB 커넥션 풀 빠듯하다 | Redis 분산락 | 커넥션 소모 없음 |
| 정확성이 가장 중요한 연산 | DB 락 + DB 유니크 제약 | 분산락이 뚫려도 DB가 방어 |
// DB 비관적 락 (Spring Data JPA)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Point p WHERE p.userId = :userId")
Optional<Point> findByUserIdForUpdate(@Param("userId") Long userId);
@Transactional
public void decreasePoint(Long userId, int amount) {
Point point = pointRepository.findByUserIdForUpdate(userId).orElseThrow();
point.decrease(amount); // 트랜잭션 커밋 시 UPDATE + 락 해제
}
실전에서 가장 안전한 설계는 계층적 방어다:
[1차] Redis 분산락 → 동시 요청을 직렬화 (속도)
[2차] 비즈니스 로직 검증 → 잔액 확인, 재고 확인 (의미)
[3차] DB 유니크 제약 / 낙관적 락 → 분산락이 뚫려도 DB가 최후 방어 (정합성)
Redis 분산락이 GC pause나 네트워크 지연으로 뚫리는 극단적 상황에서도 DB 제약이 있으면 데이터 정합성이 깨지지 않는다.
최적화 정리
| 항목 | 설정 | 기준값 |
|---|---|---|
| 락 대기 시간 (HTTP 요청) | waitTime | 3~5초 |
| 락 보유 시간 | leaseTime | 처리 예상 시간 × 3 |
| Redisson 커넥션 풀 | connectionPoolSize | CPU 코어 수 × 2 |
Redis maxmemory-policy | redis.conf | noeviction (락 키 evict 방지) |
튜닝: 무엇을 측정하고 무엇을 바꾸나
락 경합 모니터링
// 락 획득 실패를 Micrometer로 카운팅
meterRegistry.counter("distributed.lock.failure", "key", lockKey).increment();
// 락 보유 시간 측정
Timer.Sample sample = Timer.start();
// ... 임계 구역 실행 ...
sample.stop(meterRegistry.timer("distributed.lock.held_time", "key", lockKey));
락 획득 실패율이 증가하면 waitTime을 늘리거나 임계 구역 처리를 최적화해야 한다.
Redis에서 락 키 모니터링
# 예상보다 많은 락 키가 남아 있다면 해제 누락 의심
redis-cli keys "lock:*" | wc -l
운영상 주의사항
락을 잡은 채 외부 API 호출하고 타임아웃 설정 안 한 경우
@DistributedLock(key = "'payment:' + #userId", leaseTime = 30)
public void processPayment(Long userId) {
paymentGateway.charge(userId, amount); // 타임아웃 없음 → 60초 대기 가능
// leaseTime 30초가 지나면 락 자동 해제 → 다른 서버가 락 획득 가능
}
모든 외부 호출에 타임아웃을 반드시 설정해야 한다. RestTemplate.setTimeout(), WebClient.timeout().
여러 락을 순서 없이 획득하면 데드락
서버 A: lock:user:123 획득 → lock:order:456 대기 중
서버 B: lock:order:456 획득 → lock:user:123 대기 중
→ 데드락 (둘 다 영원히 대기)
여러 락을 획득해야 할 때는 항상 정렬된 순서로 획득한다:
List<String> lockKeys = List.of("lock:user:" + userId, "lock:order:" + orderId);
lockKeys = lockKeys.stream().sorted().toList(); // 항상 같은 순서
Redis 장애 시 분산락 동작
Redis가 죽으면 redissonClient.getLock() 자체가 예외를 던진다. 이 예외를 어떻게 처리할지 미리 설계해야 한다. 락 없이 진행하면 동시성 문제가 그대로 발생한다.
try {
acquired = lock.tryLock(waitTime, leaseTime, timeUnit);
} catch (RedisConnectionFailureException e) {
// Redis 장애 → 락을 획득할 수 없음 → 요청 거부
log.error("Redis 연결 실패로 분산락 획득 불가: {}", lockKey, e);
throw new ServiceUnavailableException("잠시 후 다시 시도해주세요.");
}
대부분의 경우 Redis 장애 시에는 요청을 거부하고 즉시 알람을 울리는 게 맞다.