분산 락 TTL과 자동 갱신 — 만료 뒤의 소유권까지 확인하기
목차
작업 A가 락을 잡고 멈췄고 10초 뒤 락이 만료되어 B가 작업을 끝냈다. A가 뒤늦게 깨어나 결과를 저장하면 락은 정상이어도 데이터는 A의 오래된 결과로 돌아갈 수 있다. 분산 작업의 안전성을 판단하려면 락의 유효 기간을 늘리는 일과 만료된 소유자의 쓰기를 막는 일을 구분해야 한다.
여기서 10초는 설명용 값이다. 실제 장애 시간이나 권장 설정이 아니다. 이 글에서는 가상의 두 작업과 SQLite 실험으로 락의 소유자를 보호하는 일과 데이터의 갱신 순서를 보호하는 일을 분리한다. 락의 유효 기간인 TTL(Time To Live)을 늘리거나 자동 갱신을 붙이기 전에 어느 쪽을 보장하려는지 정해야 한다.
TTL은 죽음을 확인하지 않고 소유권을 끝낸다
프로세스가 죽었는지, 일시적으로 멈췄는지, 네트워크만 느린지 락 서비스는 완벽하게 구분하기 어렵다. 계속 응답을 기다리면 다른 작업도 영원히 시작하지 못할 수 있다. 그래서 일정 시간 동안만 유효한 권리인 lease를 부여한다. 만료는 다음 작업이 진행할 수 있게 하는 장치다. 이전 작업을 강제로 종료하는 장치는 아니다.
다음 순서에서 락 서비스의 동작에는 문제가 없어도 두 작업이 동시에 실행될 수 있다.
| 시각 | 락 서비스 | 작업 A | 작업 B |
|---|---|---|---|
| 0 | A에 10초 동안 부여 | 입력을 읽음 | 대기 |
| 4 | A의 권리 유효 | 긴 정지에 들어감 | 대기 |
| 10 | A의 권리 만료 | 여전히 정지 | 획득 가능 |
| 11 | B에 새 권리 부여 | 여전히 정지 | 새 결과 저장 |
| 15 | B의 권리 유효 | 깨어나 옛 결과 저장 시도 | 이미 작업 완료 |
락의 유효 기간 안에서만 작업이 끝난다는 가정이 깨진 것이다. 타임아웃은 장애의 추정치이므로, 정확성이 중요한 쓰기에서는 시간이 지났다는 판단 뒤에도 늦은 작업이 도착할 수 있음을 설계에 포함해야 한다. 이 문제와 저장소 fencing의 관계는 Martin Kleppmann의 분산 락 분석에 구체적으로 설명돼 있다.
소유자 토큰은 남의 락을 지우지 않게 한다
획득할 때 요청마다 다른 임의 토큰을 넣고, 해제할 때 현재 값과 그 토큰을 비교하면 오래된 A가 B의 락을 지우는 일을 막을 수 있다. 비교와 삭제를 별도 요청으로 보내면 비교 직후 소유자가 바뀔 수 있으므로 한 원자적 연산 안에서 수행한다. 갱신도 같은 이유로 현재 소유자인지 확인해야 한다. Redis의 소유자 비교와 락 갱신 설명
잘못된 해제: 키가 있으면 삭제
필요한 해제: 키의 현재 토큰이 내 획득 토큰과 같을 때만 삭제이제 A의 해제는 거절된다. 그러나 A가 외부 DB에 보내는 UPDATE에는 Redis가 개입하지 않는다. A가 먼저 Redis에 소유권을 확인하더라도, 그 응답을 받은 뒤 DB 쓰기가 도착하기 전까지 다시 멈출 수 있다. 쓰기 직전에 한 번 확인하는 방식은 두 시스템 사이의 틈을 없애지 못한다.
이 차이를 놓치면 “락 해제는 안전하다”는 테스트를 “보호 대상도 안전하다”는 증거로 오해한다. 둘은 서로 다른 요청을 검사한다.
늦은 쓰기는 데이터를 받는 곳에서 막는다
fencing token은 소유권의 순서를 나타내는 증가하는 번호다. 임의 문자열인 소유자 토큰은 두 값의 동일성을 확인하고, fencing token은 어느 값이 더 새로운지를 비교한다. 아무 UUID 두 개를 정렬한다고 획득 순서가 생기지는 않는다.
저장소는 이미 받아들인 세대보다 오래된 쓰기를 거절한다. A가 세대 1, B가 세대 2라면 B의 쓰기를 받아들인 뒤 세대 1의 요청이 도착해도 데이터를 바꾸지 않는다. 검사와 변경은 저장소 안에서 원자적이어야 한다. 애플리케이션에서 번호를 읽고 비교한 다음 무조건 갱신하면 같은 경쟁이 다시 생긴다.
전체 실험 코드를 저장해 python3 lease_fence.py로 실행한다. Redis 연결 없이 획득 순서를 고정하고, 보호 대상의 조건부 갱신은 실제 SQLite에서 실행한다. 핵심 갱신은 다음과 같다.
with db:
changed = db.execute(
"UPDATE resource SET fence=?, value=? WHERE id=1 AND fence<=?",
(incoming_fence, value, incoming_fence),
).rowcount이 예제는 같은 세대의 추가 쓰기를 허용한다. rowcount가 0이면 더 최신 세대가 이미 반영돼 해당 요청을 거절한 것이다. 오류 없이 SQL이 실행됐다는 사실만 보고 성공으로 처리하면 이 구분이 사라진다.
owner comparison: B lease survives
unprotected resource: late A result
fenced resource: B result첫 줄은 소유자 비교로 B의 락이 남았음을 보여 준다. 그 상태에서도 보호하지 않은 데이터는 A가 덮어쓴다. 마지막 줄에서는 B의 세대가 반영된 뒤 들어온 A의 쓰기가 거절된다. 같은 실행 순서에서 저장소 검사 유무만 바꾼 결과다.
보장 범위도 정확히 좁혀 보자. 이 방식은 저장소가 더 큰 번호를 관측한 뒤 옛 번호를 거절한다. B가 새 lease만 획득하고 아직 저장소에 세대를 등록하지 않았다면, 저장소는 그 사실을 모른다. 획득 순간부터 이전 세대의 모든 쓰기를 금지하려면 소유권 전환과 저장소의 허용 세대를 연결하는 추가 계약이 필요하다.
또한 같은 세대의 요청을 두 번 보냈을 때 중복 효과를 막지는 않는다. 예를 들어 잔액 += 10은 같은 번호로 두 번 실행될 수 있다. 작업 식별자를 이용한 멱등성은 별도의 문제다. 이 실험의 증가 번호도 메모리 변수이므로 재시작·장애 조치에서 단조 증가를 보장하는 번호 발급기는 아니다. 실제 시스템은 번호의 영속성과 모든 writer의 검사 참여를 함께 보장해야 한다.
갱신 주기는 실패 후의 행동과 함께 정한다
작업 timeout, lease TTL, 갱신 주기는 서로 다른 시간을 제한한다. 작업 timeout은 사용자에게 얼마나 오래 기다리게 할지, TTL은 갱신이 사라진 소유권을 언제 끝낼지, 갱신 주기는 유효 기간 안에서 언제 연장을 시도할지를 정한다.
예를 들어 TTL 10초, 갱신 주기 3초라도 정지 시간이 12초이면 만료를 피할 수 없다. 갱신 요청의 timeout 역시 갱신 실패를 관측하는 시간이지 Redis에서 실제로 갱신되지 않았다는 증명은 아니다. 요청이 반영됐지만 응답이 유실됐을 수 있다.
이때 실행기는 더 이상 권리가 유효하다고 확인할 수 없다는 상태로 전환한다. 새 보호 작업을 중단하고, 진행 중인 작업에는 취소를 전달하며, 늦게 도착하는 쓰기는 저장소 조건으로 방어한다. 무조건 갱신을 재시도하면서 업무 쓰기를 계속하면 권리의 확인 실패가 무시된다. 반대로 매번 중단하면 일시적 지연에도 처리량을 잃는다. 정확성을 먼저 지킬 작업과 중복 수행 비용만 줄이면 되는 작업의 선택이 달라지는 이유다.
락 서비스의 시간도 살펴야 한다. Redis 공식 문서는 TTL에 벽시계의 영향을 받는 점과 fencing의 필요를 설명한다. 클라이언트의 한 번 측정한 경과 시간만으로 서버의 만료 시각을 완벽히 재구성할 수 있다고 가정하지 않는다. Redis의 consistency 주의점
모든 동시성 문제에 분산 락부터 추가할 필요는 없다
보호할 데이터가 하나의 DB에 있고 “아직 처리하지 않은 행만 처리”처럼 조건을 표현할 수 있다면 조건부 UPDATE, 고유 제약, 트랜잭션으로 요구를 끝낼 수 있는지 먼저 본다. 다른 시스템의 락을 추가하면 락 서비스의 장애와 데이터 저장소의 장애를 함께 다루게 된다.
반대로 비싼 계산의 중복 실행을 줄이거나 여러 작업의 순서를 조정하려면 lease가 유용하다. 다만 계산의 중복을 줄이는 목적과 결과 무결성을 보장하는 목적을 구분한다. 잠깐 중복 계산이 일어나도 최종 결과가 같아야 하는 작업이라면 비용 최적화용 락과 멱등 저장을 조합할 수 있다. 중복 자체가 허용되지 않는 외부 효과라면 대상 API가 제공하는 조건부 실행·operation ID 같은 계약이 필요하다.
연결 취소와 갱신 루프의 수명도 이어져야 한다
Go 1.24.5의 Request.Context()는 서버 요청의 연결 종료나 요청 취소, 핸들러 반환에 따라 취소된다. SSE가 단방향이라는 이유로 연결 종료를 전혀 알 수 없는 것은 아니다. 다만 요청에서 파생하지 않은 백그라운드 컨텍스트로 갱신 루프를 만들면 호출자는 떠났는데 lease는 계속 유지될 수 있다. Go 1.24.5 Request.Context
취소를 전달한 뒤 실제 worker가 종료됐는지도 관측한다. 취소와 자원 수명 글에서처럼 대기의 종료와 실행의 종료는 다르다. lease를 포기한 작업이 늦게 저장을 시도할 수 있다는 사실은 연결 종료 때도 그대로 남는다.
자신의 구현을 확인하려면 A의 갱신을 멈추고 B가 새 세대로 쓰게 한 뒤 A의 해제와 쓰기를 재개한다. B의 락이 남는지와 B의 데이터가 남는지를 각각 검사한다. 첫 검사만 통과했다면 아직 락 서비스 밖의 늦은 쓰기는 검증하지 않은 것이다.