본문 바로가기
Research Notes (개인)

분산락 GET → DEL 버그 상세 설명


분산락이 뭔지부터

분산락 = "내가 작업 중이니 다른 서버는 손대지 마라"

락 획득: SET lock:order "내꺼" NX PX 3000  (3초 유효)
락 해제: 작업 끝나면 lock:order 삭제

문제 상황: GET → DEL로 해제할 때

// ❌ 잘못된 분산락 해제 코드
public void releaseLock(String key) {
    String value = redisTemplate.opsForValue().get(key);  // ① GET
    if (value != null) {
        redisTemplate.delete(key);                         // ② DEL
    }
}

타임라인으로 보면

Server A (나)                    Server B (다른 서버)
─────────────────────────────────────────────────────
① 락 획득
  SET lock "A소유" NX PX 3000

② 작업 수행 중...
   (3초 넘어버림!)

                                 ③ 락 만료됨 (TTL 0)

                                 ④ Server B가 락 획득
                                   SET lock "B소유" NX PX 3000

⑤ Server A: GET lock
            → "B소유" 반환
            (내 꺼인지 확인 안 함!)

⑥ Server A: DEL lock   ← B의 락을 삭제해버림!!

                                 ⑦ Server B는 락이 있다고 생각하고
                                    작업 중인데 락이 사라짐

⑧ Server C가 락 획득 가능
   → B, C 동시 작업 → 데이터 꼬임!

핵심 문제: GET과 DEL 사이의 틈

GET lock   → "B소유" 확인
             ↑ 여기서 시간이 흐름 (아주 짧아도!)
DEL lock   → 삭제

이 사이에 상황이 바뀔 수 있음
→ 원자적이지 않음
→ "내 락인지 확인"과 "삭제" 가 분리되어 있음

올바른 해결: Lua Script로 원자적 처리

핵심 아이디어:
  락을 걸 때 "내 고유 토큰(UUID)"을 값으로 저장
  해제할 때 "내 토큰인지 확인 + 삭제"를 원자적으로 실행
// 락 획득 시
String myToken = UUID.randomUUID().toString();  // "abc-123-xyz"
redisTemplate.opsForValue()
    .setIfAbsent("lock:order", myToken, Duration.ofSeconds(3));

// ────────────────────────────────────────────
// 락 해제 시 (Lua Script)
String script =
    "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('DEL', KEYS[1]) " +  // 내 토큰일 때만 삭제
    "else " +
    "    return 0 " +                            // 남의 토큰이면 무시
    "end";

redisTemplate.execute(
    new DefaultRedisScript<>(script, Long.class),
    List.of("lock:order"),
    myToken  // 내 토큰 전달
);

Lua Script로 해결되는 이유

Server A (나)                    Server B (다른 서버)
─────────────────────────────────────────────────────
① 락 획득
  SET lock "token-A" NX PX 3000

② 작업 수행 중...
   (3초 넘어버림!)

                                 ③ 락 만료

                                 ④ Server B 락 획득
                                   SET lock "token-B" NX PX 3000

⑤ Server A: Lua 실행 (원자적!)
   GET lock     → "token-B"
   "token-B" == "token-A" ?
   → NO → DEL 실행 안 함! ✅

                                 ⑥ Server B 락 유지됨 ✅
                                    안전하게 작업 완료

한 줄 요약

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  GET → DEL : "확인"과 "삭제" 사이에 틈 존재             │
│               → 남의 락을 지울 수 있음                   │
│                                                         │
│  Lua Script: "내 토큰 확인 + 삭제"가 하나의 원자 단위    │
│               → 틈이 없음 → 내 락만 삭제 보장            │
│                                                         │
└─────────────────────────────────────────────────────────┘

아주 쉽게 설명


화장실 칸 비유로 이해하기

분산락 = 화장실 칸 잠금장치

문제 상황

① A씨가 화장실 들어가서 문 잠금 (락 획득)
   문에 "A사용중" 이라고 붙여놓음

② A씨가 너무 오래 있음
   → 자동으로 잠금 해제됨 (TTL 만료)

③ B씨가 들어와서 문 잠금
   문에 "B사용중" 으로 바꿔 붙임

④ A씨가 뒤늦게 나오면서
   문에 붙은 거 확인 → "사용중이네"
   그냥 떼버림 ← 이게 DEL!!
   (B꺼인데 A가 떼버린 것!)

⑤ B씨는 아직 안에 있는데
   문이 잠금 해제된 상태
   → C씨도 들어올 수 있음
   → B, C 동시에 사용 → 대참사!

GET → DEL이 왜 문제인가

A씨의 잘못된 행동:

GET  → "누가 사용중인지 확인"    "B사용중"
                ↕
           (이 사이에 상황 바뀔 수 있음)
DEL  → "그냥 삭제"              B꺼인데 삭제!

= "내 것인지 확인" 과 "삭제" 가 분리되어 있음

올바른 방법

락 걸 때:
  그냥 "사용중" X
  "A-고유번호-사용중" 으로 붙임  ← UUID 토큰

락 해제할 때 (Lua Script):
  "붙어있는 게 내 번호가 맞으면 → 제거"
  "남의 번호면 → 손대지 않음"
  
  이 두 동작을 동시에 (원자적으로) 실행
④ A씨가 나오면서
   문 확인 → "B-번호-사용중"
   "어? 내 번호 아니네" → 그냥 나감 ✅

   B씨 락 유지됨 ✅

한 줄 요약

┌──────────────────────────────────────────────────┐
│                                                  │
│  GET → DEL                                       │
│  = 문에 붙은 게 내 꺼인지 확인도 안 하고 떼버림  │
│                                                  │
│  Lua Script                                      │
│  = "내 번호 맞으면 뗌, 아니면 손 안 댐"을        │
│     한 동작으로 처리                              │
│                                                  │
└──────────────────────────────────────────────────┘

분산락 Java 예시 코드


전체 코드

@Service
public class DistributedLockService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // ❌ 잘못된 방법: GET → DEL
    // ============================================
    public void wrongReleaseLock(String key) {
        String value = redisTemplate.opsForValue().get(key);  // ① GET
        if (value != null) {
            redisTemplate.delete(key);  // ② DEL (남의 락도 삭제 가능!)
        }
    }

    // ============================================
    // ✅ 올바른 방법: Lua Script
    // ============================================
    private static final String RELEASE_LOCK_SCRIPT =
        "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('DEL', KEYS[1]) " +  // 내 토큰이면 삭제
        "else " +
        "    return 0 " +                            // 남의 토큰이면 무시
        "end";

    public boolean acquireLock(String key, String token, long ttlSeconds) {
        // SET key token NX EX ttl (원자적 획득)
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds));
        return Boolean.TRUE.equals(result);
    }

    public boolean releaseLock(String key, String token) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class),
            List.of(key),
            token  // 내 고유 토큰 전달
        );
        return Long.valueOf(1L).equals(result);
    }
}

실제 사용 예시

@Service
public class OrderService {

    private final DistributedLockService lockService;
    private final OrderRepository orderRepository;

    public void processOrder(Long orderId) {
        String lockKey = "lock:order:" + orderId;
        String myToken = UUID.randomUUID().toString();  // 내 고유 토큰

        // ① 락 획득 시도
        boolean acquired = lockService.acquireLock(lockKey, myToken, 30);

        if (!acquired) {
            throw new RuntimeException("다른 서버에서 처리 중입니다.");
        }

        try {
            // ② 비즈니스 로직 실행
            orderRepository.process(orderId);

        } finally {
            // ③ 반드시 finally에서 락 해제
            boolean released = lockService.releaseLock(lockKey, myToken);

            if (!released) {
                // 내 토큰이 아님 = TTL 만료 후 다른 서버가 락 가져간 상태
                log.warn("락이 이미 만료됨. orderId: {}", orderId);
            }
        }
    }
}

시나리오별 동작 확인

@SpringBootTest
class DistributedLockTest {

    @Autowired DistributedLockService lockService;
    @Autowired StringRedisTemplate redisTemplate;

    @Test
    @DisplayName("❌ 잘못된 방법: 남의 락을 삭제하는 버그 재현")
    void wrongRelease_deletesOthersLock() {
        String key = "lock:test";

        // Server A가 락 획득
        redisTemplate.opsForValue()
            .set(key, "token-A", Duration.ofSeconds(30));

        // (TTL 만료 후) Server B가 락 획득
        redisTemplate.opsForValue()
            .set(key, "token-B", Duration.ofSeconds(30));

        // Server A가 잘못된 방식으로 해제 시도
        lockService.wrongReleaseLock(key);

        // token-B인데 삭제됨! → null
        String remaining = redisTemplate.opsForValue().get(key);
        System.out.println("남은 락: " + remaining);  // null ← B의 락이 삭제됨!!
    }

    @Test
    @DisplayName("✅ 올바른 방법: 남의 락은 건드리지 않음")
    void correctRelease_doesNotDeleteOthersLock() {
        String key = "lock:test";

        // Server A가 락 획득
        String tokenA = UUID.randomUUID().toString();
        redisTemplate.opsForValue()
            .set(key, tokenA, Duration.ofSeconds(30));

        // (TTL 만료 후) Server B가 락 획득
        String tokenB = UUID.randomUUID().toString();
        redisTemplate.opsForValue()
            .set(key, tokenB, Duration.ofSeconds(30));

        // Server A가 올바른 방식으로 해제 시도 (tokenA로 시도)
        boolean released = lockService.releaseLock(key, tokenA);

        System.out.println("해제 성공 여부: " + released);  // false ← 내 토큰 아님

        // token-B 그대로 유지됨!
        String remaining = redisTemplate.opsForValue().get(key);
        System.out.println("남은 락: " + remaining);  // token-B ← 유지됨 ✅
    }

    @Test
    @DisplayName("✅ 내 토큰이면 정상 해제")
    void correctRelease_deletesOwnLock() {
        String key = "lock:test";
        String myToken = UUID.randomUUID().toString();

        // 내가 락 획득
        lockService.acquireLock(key, myToken, 30);

        // 내 토큰으로 해제
        boolean released = lockService.releaseLock(key, myToken);

        System.out.println("해제 성공 여부: " + released);  // true ✅

        String remaining = redisTemplate.opsForValue().get(key);
        System.out.println("남은 락: " + remaining);  // null (정상 해제)
    }
}

실행 결과 비교

❌ 잘못된 방법 실행 결과:
  남은 락: null   ← token-B였는데 삭제됨! (버그!)

✅ 올바른 방법 실행 결과:
  해제 성공 여부: false   ← 내 토큰 아님, 건드리지 않음
  남은 락: token-B        ← B의 락 유지됨 ✅

✅ 내 토큰 해제 실행 결과:
  해제 성공 여부: true    ← 정상 해제
  남은 락: null           ← 깔끔하게 삭제 ✅

핵심 요약

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  ❌ GET → DEL                                           │
│     value = redis.get(key)   // "token-B"               │
│     redis.delete(key)        // token-B든 뭐든 그냥 삭제 │
│                                                         │
│  ✅ Lua Script                                          │
│     GET key → token-B                                   │
│     token-B == token-A? → NO → 삭제 안 함              │
│     (확인 + 삭제가 원자적으로 실행)                      │
│                                                         │
└─────────────────────────────────────────────────────────┘

정확히 이해하셨습니다!


정확한 시나리오

Server A 코드:
  String value = redis.get(key);   // ① GET
  if (value != null) {
      redis.delete(key);           // ② DEL
  }
타임라인:

Server A                          Server B
────────────────────────────────────────────────────────
① redis.get("lock:order")
  → "token-A" 반환 (아직 살아있음)

  [GET과 DEL 사이 찰나의 순간]
      ↓
  TTL 만료! (lock:order 키 소멸)
                                  ② redis.setIfAbsent("lock:order", "token-B")
                                    → 성공! (키 없으니까)
                                    → B가 락 획득

③ redis.delete("lock:order")     ← value != null 조건 통과했으니 실행!
  → token-B 를 삭제해버림!!

                                  ④ B는 락 갖고 있다고 생각하고
                                     작업 중인데 락이 사라진 상태

⑤ Server C도 락 획득 가능
   → B, C 동시 작업 → 💥

핵심 포인트

value != null 체크는 "GET 시점"의 상태

GET  시점: key 존재 → value = "token-A" (null 아님)
DEL  시점: key = "token-B" (이미 B 꺼!)

→ null 체크가 의미 없어짐
→ GET과 DEL 사이에 세상이 바뀜

"찰나의 순간"이 실제로 발생하는 이유

Server A 코드 실행 흐름:

redis.get(key)
    │
    │  ← 이 사이에:
    │    1. 네트워크 응답 대기 (수 ms)
    │    2. JVM 코드 실행
    │    3. if 조건 평가
    │    (수십 ms도 가능)
    │
redis.delete(key)

TTL이 짧거나 (예: 100ms)
서버 부하가 높으면
충분히 발생 가능한 시나리오

Lua Script가 해결하는 이유

-- 이 전체가 하나의 원자 단위
-- GET과 DEL 사이에 아무것도 끼어들 수 없음

if redis.call('GET', KEYS[1]) == ARGV[1] then
    -- GET 결과가 내 토큰과 같을 때만 DEL
    return redis.call('DEL', KEYS[1])
else
    return 0
end
Lua 실행 중:

redis.call('GET', key)   → "token-B"
                            ↑
                    이 사이에 아무것도 못 들어옴
                    (Redis 단일 스레드 보장)
redis.call('DEL', key)   → "token-B" == "token-A" ? NO → DEL 안 함

정리

┌─────────────────────────────────────────────────────┐
│                                                     │
│  질문하신 것: GET 후 만료 → 다른 스레드 락 획득      │
│              → DEL로 남의 락 삭제                   │
│                                                     │
│  답: 네, 정확합니다 ✅                               │
│                                                     │
│  GET → DEL: 두 명령 사이에 "시간의 틈" 존재          │
│             그 틈에서 세상이 바뀔 수 있음            │
│                                                     │
│  Lua Script: GET + 비교 + DEL을 틈 없이 실행         │
│              = 세상이 바뀔 틈 자체를 없앰            │
│                                                     │
└─────────────────────────────────────────────────────┘