기본적으로 멱등성 유지: Python 백그라운드 작업을 위한 상태 머신 (State Machines)
요약
Python 백그라운드 작업에서 발생할 수 있는 멱등성 문제를 해결하기 위해 상태 머신(State Machine) 도입을 제안합니다. 단순한 조건문 대신 열거형(Enum)을 활용한 상태 모델링을 통해 작업의 안정성과 회복 탄력성을 높이는 방법을 다룹니다.
핵심 포인트
- 백그라운드 작업의 실패 시 중복 실행 및 데이터 불일치 위험 방지
- 제어 흐름이 아닌 데이터로서의 상태(Task State) 관리 중요성
- 열거형(Enum)을 활용한 명확한 작업 생명주기 모델링
- 가드된 전이(Guarded Transitions)를 통한 불변량 유지 및 오류 방지
"내 컴퓨터에서는 잘 되는데"의 진짜 비용
백그라운드 작업(Background tasks)은 다른 코드들이 받지 못하는 설계적 관용을 누립니다. 웹 엔드포인트(Web endpoint)는 검토되고, 입력값이 검증되며, 실패 경로(Failure path)가 추적됩니다. 테이블에 행을 쓰는 크론 잡(Cron job)은 예외입니다. 그 기본값은 문서상으로만 합의되어 있으며, 실패 동작은 나중에 생각할 문제로 치부됩니다.
대부분의 Python 백그라운드 작업은 다음과 같은 타이트한 루프(Tight loop)로 시작됩니다: ID를 가져오고(fetch), 작업을 수행하고, 완료로 표시하고(mark it done), 반복합니다. 이러한 형태는 해피 패스(Happy path)에 대해서는 정직하지만, 그 외의 모든 상황에 대해서는 부정직합니다. "가져옴(fetched)"과 "완료 표시(marked done)" 사이에서 프로세스가 종료되면, 루프는 재시작 시 동일한 레코드를 두 번째로 실행합니다. 데이터베이스에 일시적인 장애(Hiccup)가 발생한 후 워커(Worker)가 재연결되면, 어떤 단계가 이미 커밋(Commit)되었는지 알 수 없으므로 전체 배치(Batch)를 다시 실행합니다. 첫 번째 인스턴스가 느리게 실행되어 두 번째 인스턴스가 시작되면, 두 인스턴스 모두 동일한 행에 대한 권한을 주장합니다. 코드베이스는 충돌(Crash)이 발생할 가능성이 가장 높은 접점에서 정확히 작동하지만, 역설적으로 그 순간은 오류가 발생해서는 안 되는 단 한 번의 순간이기도 합니다.
이 글은 재앙으로 가는 길은 충돌 그 자체가 아니라고 주장합니다. 그것은 파편화(Scattered)입니다. "이 단위가 처리되었는가"라는 질문이 20개의 느슨한 분기(Branch)에 걸쳐 존재할 때, 모든 충돌은 수작업으로 새로운 재계산을 요구합니다. 안정적인 해결책은 작업 본체(Task body)를 진행의 단위로 취급하는 것을 멈추고, 단일 필드인 작업 상태(Task state)를 진실의 단위(Unit of truth)로 취급하기 시작하는 것입니다.
제어 흐름(Control Flow)이 아닌 데이터로서의 상태
해피 패스 루프는 독점성(Exclusivity)을 조건문 안에 묻어둡니다: if processed: skip else: process. 이러한 배치는 세 번째, 네 번째, 혹은 열여섯 번째 분기가 나타날 때까지 유지되며, 각 행의 출처(Provenance)는 팔림세스트(Palimpsest, 덧쓰인 기록)처럼 복잡해집니다.
회복 탄력성이 있는 대안은 전체 생명주기(Lifecycle)를 열거형 값(Enumerated value)으로 모델링하고, 그 값이 단일 디스패치(Single dispatch)를 주도하도록 하는 것입니다. 파이프라인의 모든 행은 한 번에 정확히 하나의 상태(State)만을 가집니다. 모든 핸들러(Handler)는 제자리에서 상태를 변경(Mutating in place)하는 대신 다음 상태를 반환하는 순수 함수(Pure function)입니다. "이것이 처리되었는가?"라는 질문은 12개의 분기를 돌아다니는 여정이 아니라, 테이블의 특정 셀 하나에 대한 쿼리가 됩니다.
from enum import Enum
class TaskState(str, Enum):
...
이 열거형(Enum)은 단순한 장식이 아니라 불변량(Invariant)입니다. 행을 WORKING에서 NEW로 바로 밀어넣으려는 모든 호출은 이력이 오염되기 전에 거부되며, 이 거부는 조용한 break가 아니라 명확한 예외(Exception)로 나타납니다.
가드된 전이(Guarded Transitions)가 주석보다 나은 이유
주석은 저자가 그날 의도했던 바를 요약한 것이지, 기계가 허용할 수 있는 것에 대한 선언이 아닙니다. 모든 규칙이 코드 내에 열거되어 있다면, 그 누구도 산문(Prose)으로부터 병렬적인 정신 모델(Mental model)을 재구성할 필요가 없습니다. 전이 맵(Transition map)은 데이터이기 때문에, 임포트 시점(Import time)에 짧은 도달 가능성 탐색(Reachability walk)을 통해 확인할 수 있습니다. 즉, 선언된 모든 상태가 적어도 하나의 엣지(Edge)의 소스(Source)로 나타나는지, 그리고 모든 엣지의 목적지(Destination)가 선언된 상태인지 확인(Assert)하는 것입니다.
이러한 작은 절차는 실무에서 가장 비용이 많이 드는 두 가지 버그를 잡아냅니다. 바로 어떤 엣지도 도달할 수 없는 죽은 상태(Dead states)와, 어떻게든 다시 WORKING 상태로 유휴 상태가 되는 최종 상태(Final states)입니다. 세 개의 모듈에 걸쳐 6개의 상태로 성장한 파이프라인에 이 방식을 적용했을 때, 탐색 과정에서 핫 루프(Hot loop) 내의 유령 전이(Ghost transition)를 발견했습니다. 이 오류는 DONE 행이 FAILED로 부활하여 중복 처리되게 만들었습니다. 인간의 검토(Human review)는 경로가 여러 파일에 흩어져 있었기 때문에 세 번의 코드 리뷰 동안 이를 그냥 지나쳤습니다.
트랜잭션이 충돌의 운명을 결정한다
상태 컬럼의 신뢰성은 그것을 설정하는 쓰기(Write) 작업의 신뢰성과 동일합니다. 전형적인 오류 상황은 다음과 같습니다: 워커(Worker)가 자신의 상태를 DONE으로 설정하지만, 쓰기와 커밋(Commit) 사이에서 프로세스가 충돌(Crash)하여 행이 다시 WORKING으로 표시되는 경우입니다. 재배포된 워커는 WORKING을 읽고, 행에 다시 락(Lock)을 걸며, 부수 효과(Side effects)가 이미 반영된 작업을 다시 처리하게 됩니다.
그 문제를 해결하려면 두 가지 도구가 필요합니다. 첫째, 범위를 좁히는 것입니다. 상태를 전환하는 커밋(commit)은 트랜잭션(transaction) 내의 마지막 쓰기 작업이어야 하며, 계산된 본문(body)을 함께 포함해야 합니다. 커밋이 반환될 때 DONE은 참(true)이 되며, 본문이 절반만 작성되는 일은 발생하지 않습니다. 둘째, 폴백(fallback)을 무해하게 만드는 것입니다.
모든 부수 효과(Side Effect)를 멱등하게 만들기
멱등성(Idempotency)은 상태 머신(state machine)의 인내심 있는 형제와 같습니다. 핸들러(handler)가 두 번 실행되더라도, 한 번 실행되었을 때와 동일한 관찰 가능한 결과를 생성해야 합니다. 삭제(delete)는 멱등합니다. 추가(append)는 멱등하지 않습니다.
원격 큐(remote queue)는 이러한 문제가 가장 먼저 발생하는 곳입니다. 왜냐하면 메시지를 점유(claim)한 후 응답 시간 초과(timeout)가 발생했을 때, 메시지가 도착했는지 여부를 알 수 없기 때문입니다. 유일하게 안전한 가정은 메시지가 도착했다는 것입니다. 따라서 네트워크가 신뢰할 수 있기를 요구하는 것이 아니라, 실패의 비용을 낮추는 데 중점을 두어야 합니다.
def run_once(key, action):
token = redis.set(key, "1", nx=True, ex=3600)
if token:
...
set 연산에서 승리한 워커(worker)만이 실행 권한을 가지며, 경합(race)에서 패배한 쪽은 단순히 키를 관찰하고 종료합니다. 이렇게 하면 재전송(redelivery)이 구조적으로 무해해지며, 정확히 한 번(exactly-once) 실행되는 원장(ledger)을 스케줄링하는 비용은 토큰 만료 시점으로 미뤄지는데, 이는 대부분의 작업에 있어 공정한 거래입니다.
트랜잭션의 가치가 있는 핵심 요소
이 패턴들을 종합하면, 하나의 워커가 배치(batch)를 읽고, 가드(guard)를 통해 각 행을 전환하며, 본문과 상태를 함께 커밋합니다.
UPDATE jobs
SET state = 'done',
body = ?
...
WHERE 절은 낙관적 잠금(optimistic lock) 역할을 합니다. 다른 워커가 이미 해당 행을 진행시킨 경우, 업데이트는 0개의 행을 변경하며 호출자는 이를 무시할 수 있습니다. 본문은 상태가 기록될 때에만 정확히 생존하는데, 이는 두 작업이 동일한 트랜잭션 내에서 이루어지기 때문입니다. 읽기 작업 또한 동일한 개념 하에 수행되며, 가드(guarded)된 step 함수는 상태 머신이 일관된 논리를 유지하도록 만드는 가교 역할을 합니다.
- **
SELECT ... FOR UPDATE를 사용한 클레임 (claim)**은 해당 행(row)을 잠그고, 동일한 트랜잭션 내에서 상태를WORKING으로 변경하며, 해당 단계를 위해 전체 단위를 유지합니다. - 반면, **토큰 클레임 (token claim)**은 수명이 짧은 토큰을 찍어두고 전환 시점에만 이를 확인합니다.
두 방식 모두 다음 네 가지 장애 상황에서도 정직하게 작동하는 워커(worker)를 반환합니다: 트랜잭션 도중 프로세스 사망, 전송 계층(transport)으로부터의 중복 전달, 동일한 행을 클레임하려는 두 번째 인스턴스, 그리고 재시도할 수 있을 만큼의 데이터베이스 다운 상황.
async def drain(db, batch_size):
async with db.cursor() as cur:
rows = await cur.fetch_many(batch_size)
...
루프는 자신이 보유한 책임 이상의 책임을 결코 떠맡지 않습니다. 충돌(crash)이 발생하면 트랜잭션이 경계를 복구합니다. 다음 회전(spin) 시 동일한 행이 마치 새로운 것처럼 워커에게 제공되며, 멱등성(idempotent)을 가진 부수 효과(side effect) 덕분에 전송 계층이 재실행되어도 피해가 발생하지 않습니다.
앞으로만 나아가는 재시도 (Retries)
장애에는 기분(mood)이 아닌 규칙이 필요합니다. FAILED 상태인 행이 조용히 NEW로 다시 바뀌어서는 안 됩니다. 해당 행은 카운터를 포함하는 재시도 홉(retry hop)을 거쳐야 하며, 작업이 두 상태 사이를 영원히 왕복하지 않도록 해당 카운터는 상한선(cap)이 정해져 있어야 합니다. 유계된(bounded) 재시도 루프는 유계된 시간 내에 종료됩니다.
def next_backoff(attempt, base=3, cap=300):
return min(cap, base * (2 ** attempt))
max_attempts를 모두 소진한 태스크는 DEAD 상태가 되며, 이는 인간이 읽었을 때 실제 상황임을 알 수 있게 합니다. 즉, 본래 실행될 수 없는 작업이거나, 핸들러가 재현 가능한 예외(exception)를 삼켜버리고 있는 것입니다. 이 둘을 구분하는 것은 한 줄의 로그일 뿐입니다. 핵심은 작성자가 시계(clock)를 신뢰하는 것을 멈추고 결정을 명시적으로 만들었다는 점입니다.
각 구간에서의 강제 종료(Kill)를 통한 검증
상태 머신(state machine)은 읽기가 아니라 트라우마(trauma)를 통해 증명됩니다. 파이프라인을 폴더 배포(folder-deploy)하고 네 가지 충돌 구간(crash windows) 각각에서 프로세스를 강제 종료한 뒤, 가시적인 상태가 예상한 상태인지 확인하십시오. 아주 작은 속성 테스트(property test)를 통해 무작위 시드(random seeds)로 도달 가능한 상태 공간을 훑으며, 도달 가능한 DONE 상태가 여전히 외부로 나가는 엣지(edge)를 발행하지 않는지 확인하십시오.
def test_no_done_edges(states):
assert EDGES[TaskState.DONE] == ()
...
이 두 테스트는 비용이 저렴하고 결정론적(deterministic)이며, 가장 후회스러운 두 가지 버그 클래스를 잡아냅니다. 즉, 아무것도 도달할 수 없는 상태와, 여전히 후속 상태를 지목하는 DONE 상태입니다. 이 테스트의 가치는 우아한 품격에 있는 것이 아니라, 이후의 모든 스키마 마이그레이션(schema migration) 과정에서 "내가 곧 전환하려는 새로운 엣지(edge)가 도중에 DONE 상태로 가는 길을 차단할 수 없다"는 것을 명확히 보여주는 데 있습니다.
해피 패스(Happy Path) 너머의 보상
단일 작업 경로를 상태 머신(state machine)으로 구성하는 데는 새로운 작업의 경우 약 40줄의 코드와, 규모가 커지는 작업의 경우 몇 시간이 소요됩니다. 하지만 그 배당금은 조용히 복리로 쌓입니다. 한때 55분째 재시도 시 저렴한 비용으로 재전송되던 크론 잡(cron job)은 이제 실패를 단 한 번만 기록합니다. 누락된 행(row)을 DONE 더미로 유출하던 라이터(writer)는 이제 본문과 상태를 함께 커밋(commit)합니다. 온콜(on-call) 런북(runbook)은 "그 배치(batch)가 두 번 실행되었나요?"라고 묻는 대신, "어떤 전이(transition)가 어떤 토큰(token) 하에서 중단되었습니까?"라고 묻기 시작합니다. 이는 실제적인 답변이 가능한 질문입니다.
이 중 어느 것도 새로운 연구는 아닙니다. 이는 15년 동안 축적된 데이터베이스 및 큐(queue) 규율을, 취미용 스크립트나 분주한 데이터 팀이 실제로 실행하는 반쯤 잊힌 컨테이너(containers) 속으로 옮겨오는 작업입니다. 이 작업이 가치 있는 단 하나의 사실은, 매력적이지 않은 단 하나의 백그라운드 라이터가 수면 위의 은행 원장(bank ledger)과 동일한 장애 도메인(failure domain)을 가진다는 점입니다. 즉, 부주의하게 중복된 성공은 관찰되고 보고된 손실보다 더 나쁜 결과입니다. 보호된 열거형(guarded enum), 공유 트랜잭션(shared transaction), 그리고 멱등성 있는 쓰기(idempotent write)는 해당 워커(worker)를 "아마도"에서 "절대로 두 번은 아님"으로 바꿔 놓으며, 그 확실성은 모든 배포 시 40줄의 코드를 투자할 가치가 있습니다.
원문은 Dispatch에 처음 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기