LabHub
배우기 러닝패스 코스

멱등성 — 두 번 눌러도 한 번만 결제되게 · 멈췄던 워커가 돌아와 결과를 덮어썼다 · 이론

멈췄던 워커가 돌아와 결과를 덮어썼다의 설계 원리

LabHub 에서 이어서 보기

한 줄 요약

SQLite에서 소유권 임대와 증가하는 fencing token을 구현합니다.

개념 지도: 한 줄 요약 · 왜 이게 필요했나 · 어떻게 동작하나 · 계약을 읽고 실패를 예측하는 워크시트

왜 이게 필요했나

워커 A가 일을 가져간 뒤 멈췄다. 임대가 만료되자 B가 같은 일을 가져가 완료했지만 뒤늦게 살아난 A도 완료 결과를 썼다. 임대 시간을 정하는 것만으로 오래된 작업자의 쓰기를 막을 수 없다. 저장 시점에도 소유자와 세대 번호를 확인해야 한다.

어떻게 동작하나

jobs는 id·owner·until·fence·result·done을 저장한다. claim은 BEGIN IMMEDIATE 안에서 현재 상태를 읽고 만료된 작업만 가져가며 fence를 증가시킨다. 갱신과 완료는 현재 owner와 fence가 모두 같고 임대가 아직 유효할 때만 허용한다. exactly-once 실행을 보장하지는 않지만, 저장된 결과를 과거 세대가 덮는 경로를 닫는다.

claim A fence=1 → 만료 → claim B fence=2 → 완료              A의 fence=1 완료 ───────────→ 거절

계약을 읽고 실패를 예측하는 워크시트

다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.

1. 임대 상태 테이블을 만든다

init_db(path)는 jobs(id TEXT PRIMARY KEY, owner TEXT, until REAL NOT NULL DEFAULT 0, fence INTEGER NOT NULL DEFAULT 0, result TEXT, done INTEGER NOT NULL DEFAULT 0)를 멱등 생성합니다.

판단의 근거: 워커 재시작 시 세대 번호를 초기화하면 오래된 토큰이 다시 유효해집니다.

리뷰할 잘못된 변경 조각:

CREATE TABLE jobs

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

2. 최초 작업만 등록한다

enqueue(path, job_id)는 없는 id를 기본 상태로 넣고 True, 이미 있으면 상태를 바꾸지 않고 False입니다.

판단의 근거: 재등록이 진행 중인 임대를 초기화하지 않게 INSERT OR IGNORE를 사용합니다.

리뷰할 잘못된 변경 조각:

INSERT OR REPLACE INTO jobs

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

3. 현재 상태를 읽는다

state(path, job_id)는 행을 id, owner, until, fence, result, done 키의 dict로 반환하고 없으면 None입니다.

판단의 근거: 별도 DB 연결에서 저장 상태를 읽어 프로세스 안의 캐시와 구별합니다.

리뷰할 잘못된 변경 조각:

if row else {}

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

4. 만료된 임대를 원자적으로 가져간다

claim(path, job_id, owner, now, ttl)는 ttl<=0이면 ValueError입니다. 없는 작업·완료된 작업·until>now인 작업은 None입니다. 나머지는 owner와 until=now+ttl을 저장하고 fence를 1 올려 새 fence를 반환합니다.

판단의 근거: 읽기와 갱신은 하나의 BEGIN IMMEDIATE 안에서 수행합니다. 경계 now==until은 재할당 가능합니다.

리뷰할 잘못된 변경 조각:

row[0] >= now

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

5. 현재 세대만 임대를 갱신한다

renew(path, job_id, owner, fence, now, ttl)는 ttl<=0이면 ValueError입니다. owner·fence가 일치하고 done=0, until>now일 때만 until=now+ttl로 바꾸어 True, 아니면 False입니다.

판단의 근거: 이미 만료된 소유권을 renew로 되살리면 새 워커와 충돌합니다.

리뷰할 잘못된 변경 조각:

AND until>=?", (now+ttl

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

6. 오래된 완료를 거절한다

complete(path, job_id, owner, fence, now, result)는 현재 임대(owner·fence 일치, done=0, until>now)일 때만 result와 done=1을 저장해 True입니다. 나머지는 False이며 기존 결과를 보존합니다.

판단의 근거: 완료 쓰기에도 임대 검사가 있어야 늦게 돌아온 워커를 막습니다.

리뷰할 잘못된 변경 조각:

AND done>=0 AND until>?", (result

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

7. 현재 워커만 임대를 반납한다

release(path, job_id, owner, fence)는 owner·fence가 같고 done=0인 행의 owner=NULL, until=0으로 바꾸고 True입니다. fence는 보존합니다. 나머지는 False입니다.

판단의 근거: 반납 때 fence까지 초기화하면 과거 토큰 번호를 재사용하게 됩니다.

리뷰할 잘못된 변경 조각:

SET owner=NULL,until=0,fence=0 WHERE

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

8. 동시 claim에 승자는 하나다

claim_many(path, job_id, owners, now, ttl)는 ThreadPoolExecutor로 각 owner의 claim을 동시에 호출해 입력 순서의 반환값 리스트를 돌려줍니다. 새 작업은 딱 하나만 fence를 얻고 나머지는 None이어야 합니다.

판단의 근거: 사전 조회 후 연결을 닫고 갱신하지 마세요. 잠금이 두 연산을 함께 보호해야 합니다.

리뷰할 잘못된 변경 조각:

return [claim(path,job_id,owners[0],now,ttl)]

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

현장에서 만나는 모습

시계는 호출자가 주는 비감소 숫자이며 여러 서버 간 시계 동기화는 모델링하지 않는다. 업무의 외부 부작용까지 자동으로 fencing되는 것은 아니다. 저장소 외부 시스템에도 같은 토큰 검증이나 별도 멱등 처리가 필요하다. SQLite는 실제 잠금과 트랜잭션을 쓰지만 대규모 분산 큐의 처리량을 대표하지 않는다.

다음 실습에서 할 것

여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 임대 상태 테이블을 만든다 → 최초 작업만 등록한다 → 현재 상태를 읽는다 → 만료된 임대를 원자적으로 가져간다 → 현재 세대만 임대를 갱신한다 → 오래된 완료를 거절한다 → 현재 워커만 임대를 반납한다 → 동시 claim에 승자는 하나다.

각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.