형태 1: 상태 복구. 형태 2: 낭비적인 리포트.
박동하는 심장은 필요에 따라 뛰는 것이 아니라, 변화에 따라 뛰는 것도 아니라, 타이머에 따라 규칙적으로 뛴다.
두 가지 형태는 하나의 근본 원인을 공유한다: 올바른 설계를 대신하는 예약 작업.
형태 1: 상태 복구
상태 전환이 원자적으로 완료되지 못한다. 전환을 수정하는 대신, 백그라운드 작업이 지연 후 실행되어 상태를 조정한다. 사용자는 조정 기간 동안 깨진 상태를 보게 된다.
GitHub 예시 (2026-04-08): 풀 리퀘스트의 업스트림 저장소가 비공개로 전환되었습니다. GitHub는 상태 전환을 시도했습니다: PR을 닫고, 브랜치 상태를 업데이트하며, 병합 상태를 초기화했습니다. 이 전환은 원자적으로 완료되지 않았습니다. PR 상태는 'branch-forced-closed'와 'Merge status cannot be loaded'가 동시에 표시되었습니다. Sidekiq 백그라운드 작업이 몇 분 후 실행되어 조정을 완료했습니다. 관찰자들은 해당 기간 동안 깨진 상태를 보았습니다.
The Metered Heart: Sidekiq 작업은 일정에 따라 실행되었습니다. 깨진 상태를 감지했기 때문이 아니라 타이머가 울렸기 때문입니다. PR을 실시간으로 보고 있던 사용자는 다음 작업 실행 전까지 모순된 PR을 보았습니다.
Form 2: 낭비되는 보고서
보고서 또는 집계가 고정된 간격으로 처음부터 다시 계산됩니다. 캐시 확인 없음. 멱등성 가드 없음. 증분 업데이트 없음. 모든 실행: 전체 스캔.
예시: 매일 밤 실행되는 cron 작업이 모든 주문 내역을 처음부터 스캔하여 각 사용자의 총 구매 금액을 다시 계산합니다. 매일 실행되는 분석 작업이 원시 이벤트 로그에서 대시보드를 다시 생성합니다. 매주 실행되는 요약 이메일이 활동 테이블의 모든 행을 조회합니다.
각 작업은 마지막 실행 이후 데이터가 변경되었는지와 관계없이 실행됩니다. 지난 24시간 동안의 새로운 데이터만 존재하더라도 전체 기록을 스캔합니다. 각 작업은 증분 설계 대신 예약된 반복으로 대체합니다.
The Shared Root
Metered Heart는 자신의 상태에 대해 진실을 말할 수 없습니다. 오직 시계만을 압니다. Form 1: 상태 복구 작업은 T+0 시점에 상태가 깨졌는지와 관계없이 T+5분에 실행됩니다. Form 2: 보고서 작업은 어제 이후 데이터가 변경되었는지와 관계없이 새벽 2시에 실행됩니다.
시계는 해야 할 일에 대한 정보를 가지고 있지 않습니다. 이벤트가 그 정보를 전달합니다: '상태 전환이 실패했습니다', '새로운 주문이 도착했습니다.' Metered Heart는 이 정보를 버리고 대신 일정으로 대체합니다.
자본 유출
Metered Heart는 살아 있는 자본을 소모합니다: 깨진 상태 사고에 대비해 대기하는 엔지니어들. 사회적 신뢰를 약화시킵니다: 사용자는 일관되지 않은 데이터를 보고, 스스로 해결되는 결함을 신고합니다. 다른 MOAD를 증폭시킵니다: 깨진 상태를 찾기 위해 모든 레코드를 스캔하는 상태 복구 작업은 종종 MOAD-0001(O(N²) 스캔)을 포함합니다. 콜드 데이터를 재계산하는 보고서 작업은 MOAD-0005(캐시 스탬피드)를 유발할 수 있습니다. MOAD-0009는 다른 결함을 복합적으로 만듭니다.
공유된 근본 원인
Form 1과 Form 2는 표면적으로는 다르게 보입니다: 하나는 상태를 복구하고, 다른 하나는 데이터를 재계산합니다. 그러나 근본 원인은 이 둘을 연결합니다.
변경 시 실행, 시계에 의한 실행 아님
이벤트 기반 설계는 무언가가 변경될 때 실행됩니다. 상태 변경이 이벤트입니다. 이벤트가 트리거입니다.
형태 1: 원자적 전환이 복구 작업을 대체합니다.
상태 전환이 시스템을 깨진 중간 상태로 남길 수 있다면, 결함은 전환 자체에 있으며 복구 작업이 없다는 데 있지 않습니다. 전환이 원자적으로(또는 트랜잭션으로) 완료되도록 수정하세요. 전환이 원자적으로 완료되면 깨진 상태는 존재하지 않습니다. 복구 작업이 복구할 대상이 없습니다.
# 결함: 비원자적 전환이 깨진 상태를 남김
def close_pr_on_repo_private(pr_id):
pr = PR.get(pr_id)
pr.status = 'branch-forced-closed' # step 1: partial state
pr.save() # visible to users NOW
# ... other steps may fail ...
pr.merge_status = 'not_applicable'
pr.save() # step 2: now consistent
# Sidekiq job reconciles if step 2 fails
# FIX: 원자적 전환; 중간 상태가 보이지 않음
def close_pr_on_repo_private(pr_id):
with db.transaction():
pr = PR.get(pr_id)
pr.status = 'branch-forced-closed'
pr.merge_status = 'not_applicable'
pr.save() # 두 필드가 원자적으로 커밋됨; 절대 반만 기록되지 않음
형식 2: 증분 업데이트가 전체 재계산을 대체합니다.
스크래치부터 다시 계산하는 보고서는 기존 데이터 + 새 데이터 = 새 결과로 실행됩니다. 그러나 기존 결과 + 델타 = 동일한 새 결과이며, 증분 방식으로 계산됩니다. 이벤트: 새 데이터가 도착했습니다. 트리거: 새 데이터에 대해서만 집계를 업데이트합니다.
# 결함: 스케줄에 따른 전체 재계산
def nightly_totals_job():
for user in all_users():
total = sum(o.amount for o in user.orders) # 모든 시간 스캔
user.total_purchases = total
user.save()
# FIX: 이벤트 기반 증분 업데이트
def on_order_placed(order):
order.user.total_purchases += order.amount # 델타만
order.user.save()
증분 업데이트는 새벽 2시가 아니라 주문이 도착할 때 실행됩니다. 영향을 받는 사용자만 업데이트하며, 전체 주문이 아닌 새 주문만 읽습니다. 야간 작업은 사라집니다.
Form 1이 깨진 전환을 드러내는 이유
Form 1 Metered Heart는 상태 전환이 완료되지 않았음을 보여줍니다. 복구 작업은 엔지니어가 깨진 상태를 발견하고 전환을 수정하는 대신 조정 메커니즘을 추가했기 때문에 존재합니다. 복구 작업은 깨진 아키텍처 결정에 대한 임시방편입니다.
MOAD-0009의 증폭 효과
MOAD-0009는 다른 MOAD를 증폭시킵니다. 깨진 상태를 찾기 위해 모든 레코드를 스캔하는 상태 복구 작업은 MOAD-0001(O(N) 또는 O(N²) 스캔)을 유발합니다. 모든 것을 처음부터 다시 계산하는 보고서 작업은 MOAD-0005(작업 시작 시 따뜻한 상류 시스템에 대한 캐시 스탬피드)를 유발합니다. MOAD-0009는 그 자체로도 해롭지만, 다른 MOAD를 정기적으로 전달합니다.
진단 및 재설계
한 팀이 매일 새벽 2시에 cron 작업을 실행합니다. 이 작업은 모든 사용자의 모든 주문을 스캔하여 각 사용자의 총 구매 금액을 처음부터 다시 계산합니다. 작업은 4시간이 걸리며, 오전 6시가 되면 대시보드에 최신 합계가 표시됩니다. 새벽 2시부터 6시 사이에는 대시보드에 전날의 합계가 표시됩니다.