데이터베이스

원자적 카운터가 드리프트하는 이유와 조정(Reconciliation)으로 해결하는 방법

원자적 증가로 유지되는 카운터는 실제 행 수와 서서히 달라질 수 있습니다. 주기적인 조정 작업은 실제 값을 다시 계산하여 원래대로 수렴시킵니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

원자적 증가로 유지되는 파생 카운터는 원본(source of truth)에서 벗어나게 됩니다. 증가가 잘못되었기 때문이 아니라, 원자성은 별도의 레코드와의 일치가 아닌 단일 연산만을 보장하기 때문입니다. 충돌, 재시도, 부분적 실패는 각각 숫자를 약간씩 틀어지게 만들고, 그 오류는 누적됩니다. 해결책은 더 큰 락을 거는 것이 아닙니다. 카운터를 빠른 캐시로 취급하고, 원본으로부터 실제 값을 재계산하여 다시 쓰는 주기적인 작업을 실행하는 것입니다. 그러면 숫자가 잠시 동안 틀리더라도 결국에는 정확해집니다.

드리프트란 무엇이며, 원자성이 이를 막지 못하는 이유

댓글을 행으로 저장하고, 페이지를 로드할 때마다 댓글 컬렉션을 스캔하지 않고 모든 게시물에 댓글 수를 표시하고 싶다고 가정해 봅시다. 가장 확실한 최적화 방법은 파생 카운터를 사용하는 것입니다. 즉, 게시물에 comment_count 필드를 두고 댓글이 생성될 때마다 그 값을 증가시키는 것입니다.

// Fast path: bump the derived counter whenever a comment is created.
// This single update is atomic for this one document. Nothing here ties
// it to the actual number of comment rows that exist.
_, err := counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$inc": bson.M{"comment_count": 1}},
    options.Update().SetUpsert(true),
)

$inc는 원자적(atomic)입니다. 두 개의 동시적인 증가 연산은 읽기-수정-쓰기(read-modify-write) 방식처럼 업데이트를 유실하지 않습니다. 따라서 카운터가 정확하다고 결론 내리고 싶을 수 있습니다. 하지만 그렇지 않으며, 그 이유는 원자성이 보장하는 것에 대한 범주 오류(category error) 때문입니다.

원자성은 단일 연산의 속성입니다. 이는 이 하나의 증가 연산이 값을 손상시키는 인터리빙(interleaving) 없이 완전히 일어나거나 전혀 일어나지 않음을 의미합니다. 이는 증가 연산의 순서가 다른 컬렉션의 행 수와 일치하는지에 대해서는 아무것도 보장하지 않습니다. 카운터와 댓글 행은 두 개의 개별적인 상태 조각이며, 두 개의 개별적인 연산에 의해 업데이트됩니다. 그들 사이의 일관성은 두 연산 중 어느 것도 가지지 않는 속성입니다. 이 두 쓰기가 하나의 원자적 단위의 일부가 아닐 때마다(그리고 문서 저장소(document store)와 별도의 컬렉션에 걸쳐서는 보통 그렇지 않은데), 그들이 불일치할 여지가 생깁니다. 그 불일치가 바로 드리프트(drift)입니다.

드리프트는 어디에서 오는가

드리프트는 하나의 버그가 아닙니다. 이는 행 쓰기와 카운터 쓰기 사이의 작은 격차들의 집합이며, 각각의 격차는 예측 가능한 방향으로 개수를 밀어냅니다. 그 원인을 알면 조정 작업이 무엇을 수정해야 하는지, 그리고 실제 버그가 나타났을 때 메트릭이 어떻게 보여야 하는지를 알 수 있습니다.

원인 메커니즘 방향
두 쓰기 사이의 충돌 행이 삽입되고, $inc가 적용되기 전에 프로세스가 중단됨 과소계수
다른 순서에서의 충돌 카운터가 증가한 후, 행 삽입이 실패하거나 롤백됨 과다계수
최소 한 번 재시도 메시지가 두 번 전달되어, 동일한 이벤트가 카운터를 두 번 증가시킴 과다계수
감소 누락 행이 삭제되거나 소프트 삭제되었지만, 일치하는 감소가 실행되지 않음 과다계수
백필 또는 수동 수정 증가 경로를 우회하여 데이터베이스에서 직접 행을 가져오거나 복구함 과소계수
순서가 바뀐 소프트 삭제 및 복원 삭제 시 감소하고, 나중에 복원할 때 증가를 잊거나, 그 반대의 경우 둘 다 가능

두 가지가 두드러집니다. 첫째, 오류는 상쇄되지 않습니다. 일부 증가를 누락하고 다른 증가는 이중으로 적용하는 시스템은 평균적으로 정확해지지 않습니다. 결국 잘못된 어딘가에 도달하게 됩니다. 둘째, 오류의 방향은 진단 정보가 됩니다. 항상 높게만 집계되는 카운터는 중복 처리나 감소 누락을 가리킵니다. 항상 낮게만 집계되는 카운터는 행이 커밋된 후 실패하는 쓰기 경로를 가리킵니다. 숫자를 수정하는 조정 작업은 격차를 메우기 전에 기록해 둔다면 이러한 원인 중 어떤 것이 문제인지 알려줄 수도 있습니다.

카운터를 진실의 원천이 아닌 캐시로 취급하세요

이 모든 것을 관리 가능하게 만드는 사고 모델은 카운터를 사실로 생각하는 것을 멈추는 것입니다. 카운터는 사실의 캐시입니다. 사실은 댓글 행의 집합입니다. 카운터는 모든 페이지 로드마다 실행하고 싶지 않았던 질문에 대한 미리 계산된 답변입니다.

일단 카운터가 캐시가 되면 두 가지 규칙이 따릅니다. 캐시는 최신이 아닐 수 있으므로, 잠시 동안 개수가 틀리는 것은 위기라기보다는 허용 가능한 일입니다. 그리고 캐시는 캐시하는 대상으로부터 재구축될 방법이 있어야 합니다. 왜냐하면 재생성할 수 없는 캐시는 신뢰할 수 없는 기본 데이터일 뿐이기 때문입니다. 진실의 원천은 계속해서 권위를 가집니다. 진실의 원천은 카운터를 완전히 무시하고 직접 쿼리되는 행의 집합입니다. 의사 결정을 내려야 할 만큼 중요한 숫자가 필요할 때마다 행의 개수를 직접 셉니다. 카운터는 작은 일시적 오류가 아무런 비용을 발생시키지 않는 저렴한 읽기 작업에 사용됩니다.

이러한 관점의 전환이 전체 접근 방식을 정직하게 만듭니다. 당신은 항상 정확한 카운터를 약속하고는 조용히 그 약속을 지키지 못하는 것이 아닙니다. 당신은 빠른 근사치 카운터와, 언제나 의존할 수 있는 진실의 원천, 그리고 그 둘을 가깝게 유지하는 작업을 약속하는 것입니다.

재계산하고 덮어쓰는 조정 작업

원자 카운터가 드리프트로 오차를 쌓으면 주기 재조정 Job이 진실원본 rows를 재계산해 base를 덮어쓰고 카운터가 수렴하는 흐름

조정은 핫 경로를 벗어난 예약된 작업으로, 행에서 실제 개수를 재계산하여 카운터에 씁니다. 모든 요청이 아닌 타이머에 따라 실행되기 때문에, 핫 경로가 피하던 비용이 많이 드는 작업, 즉 실제로 개수를 세는 작업을 수행할 수 있습니다.

가장 단순한(naive) 버전은 세 줄입니다. 행의 개수를 세고, 숫자를 쓰고, 완료.

// Naive reconciliation: recompute from the source of truth and overwrite.
// Correct in isolation, but see the race in the next section.
trueCount, err := comments.CountDocuments(ctx, bson.M{
    "post_id":    postID,
    "deleted_at": bson.M{"$exists": false},
})
if err != nil {
    return err
}
_, err = counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$set": bson.M{
        "comment_count": trueCount,
        "reconciled_at": time.Now(),
    }},
)

이것은 이전 값을 전혀 신뢰하지 않기 때문에 과대 및 과소 계산을 포함한 모든 종류의 드리프트를 한 번에 수정합니다. 행으로부터 숫자를 다시 계산합니다. 이전 카운터의 값이 맞든 틀리든 그 값은 폐기됩니다.

한 가지 위험이 있으며, 이것이 바로 단순한 버전이 최종 버전이 아닌 이유입니다. CountDocuments가 반환되는 순간과 $set이 적용되는 순간 사이에 새로운 댓글이 도착할 수 있습니다. 핫 패스는 그 댓글들에 대한 카운터를 증가시킵니다. 그러면 $set은 해당 증가가 있기 전에 계산된 숫자로 카운터를 덮어쓰고, 그 증가분은 손실됩니다. 드리프트를 수정하기 위한 조정 작업이 오히려 드리프트를 새로 만들어 버린 것입니다.

진행 중인 쓰기가 유지되도록 워터마크에 맞춰 조정하기

동시 쓰기를 덮어쓰는 것을 피하는 깔끔한 방법은 데이터의 안정된 프리픽스만 조정하고 최근 쓰기는 그대로 두는 것입니다. 이를 위해서는 카운터가 저장되는 방식을 약간 변경해야 합니다. 카운터를 조정(reconciliation)이 소유하는 base_count와 핫 경로(hot path)가 소유하는 live_delta라는 두 개의 필드로 분할합니다. 표시되는 숫자는 이 둘의 합입니다.

핫 경로는 조정된 값을 건드리는 것을 멈춥니다. 오직 라이브 델타(live_delta)만 증가시킵니다.

// Hot path now only touches live_delta. Reconciliation never overwrites
// this field, so a concurrent increment can never be clobbered.
_, err := counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$inc": bson.M{"live_delta": 1}},
    options.Update().SetUpsert(true),
)

읽기는 두 필드를 추가합니다.

displayed := doc.BaseCount + doc.LiveDelta

조정은 워터마크, 즉 더 오래된 생성 시간을 가진 새 행이 절대 들어오지 않을 정도로 과거의 충분히 먼 타임스탬프를 선택합니다. 실제로는 쓰기 가시성 지연보다 오래되고 가장 오래된 열린 트랜잭션보다 오래되었음을 의미하므로, 보통 몇 초면 충분합니다. 해당 워터마크까지의 실제 값을 계산하고, 이를 새 베이스로 설정하며, 라이브 델타에서 이제 베이스가 포함하는 증분만큼을 정확히 제거합니다. 이 모든 쓰기는 하나의 원자적 업데이트로 발생하므로 필드들이 절대 불일치하지 않습니다.

// Settled boundary: no new row can appear with a timestamp older than this.
watermark := time.Now().Add(-30 * time.Second)

baseTrue, err := comments.CountDocuments(ctx, bson.M{
    "post_id":    postID,
    "deleted_at": bson.M{"$exists": false},
    "created_at": bson.M{"$lte": watermark},
})
if err != nil {
    return err
}

// Atomic pipeline update. Install the recomputed base, and subtract from
// live_delta the number of rows the base has just absorbed (baseTrue minus
// the old base_count). Increments for rows after the watermark stay in
// live_delta untouched.
_, err = counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.A{
        bson.M{"$set": bson.M{
            "live_delta": bson.M{"$subtract": bson.A{
                "$live_delta",
                bson.M{"$subtract": bson.A{baseTrue, "$base_count"}},
            }},
            "base_count":    baseTrue,
            "reconciled_at": "$$NOW",
        }},
    },
)

이것이 안전한 이유는 조정과 핫 패스가 이제 분리된 필드에 쓰기 때문입니다. 베이스는 행으로부터 재산출되므로, 이전 베이스에 축적된 모든 드리프트가 지워집니다. 라이브 델타는 가장 최근의 증분만 보유하며, 이 증분들 또한 워터마크를 지나면 다음 주기에서 베이스로 통합됩니다. 조정 중 동시 쓰기는 라이브 델타에 기록되며 덮어쓰기 경로에 절대 놓이지 않습니다. 카운터는 잠금 없이, 그리고 쓰기를 중단하지 않고도 수렴합니다.

가장 간단한 버전을 원하고 약간 더 큰 일시적 오류를 감내할 수 있다면, 단일 필드 덮어쓰기 방식을 유지하고, 조정과 경합하는 쓰기가 잠시 유실되었다가 다음 실행 시 수정될 수 있다는 점을 그냥 받아들이면 됩니다. 쓰기 빈도가 매우 낮은 카운터의 경우 이는 합당한 선택입니다. 워터마크와 분할 필드는 쓰기 속도가 충분히 높아 덮어쓰기가 중요해질 때 사용하는 방법입니다.

실제 버그가 드러나도록 드리프트를 측정하세요

조정 과정에서 버려지는 숫자는 기록되는 숫자보다 더 가치가 있습니다. 새로운 베이스를 설치하기 전에는 이전 값과 실제 값을 알 수 있습니다. 그 차이가 바로 드리프트이며, 이는 쓰기 경로의 건전성에 대한 직접적인 증거입니다.

drift := baseTrue - oldBaseCount
metrics.Observe("counter_drift", float64(drift),
    "collection", "comments")

카운터 유형별로 태그를 지정하여 게이지나 히스토그램으로 내보내십시오. 이제 신호가 생겼고, 그 신호는 스토리를 전달합니다. 0에 가까운 드리프트가 유지된다는 것은 핫 경로가 기본적으로 정확하고 조정 작업이 드문 충돌을 정리하는 역할만 한다는 것을 의미합니다. 실행 간에 드리프트가 증가한다는 것은 생각보다 증분값이 더 빨리 손실되거나 이중으로 적용되고 있다는 의미이며, 부호는 어느 쪽인지 알려줍니다. 갑작스러운 급증은 배포나 인시던트와 일치하며 쓰기 경로를 손상시킨 변경 사항을 직접 가리킵니다.

이것이 없으면 조정 작업이 버그를 숨깁니다. 매일 밤 조용히 손상된 증분 경로를 덮어버리므로, 아침에는 표시된 숫자가 항상 정상으로 보이기 때문에 경로가 손상되었다는 사실을 결코 알 수 없습니다. 이 메트릭은 조용한 수정을 경고로 바꿉니다. 작업은 여전히 증상을 해결하지만, 이제는 질병도 보고합니다. 만약 드리프트가 중요하게 생각하는 임계값을 넘으면 담당자를 호출하십시오. 그 시점에는 카운터가 야간 작업이 안전하게 가릴 수 있는 것보다 더 빨리 드리프트가 발생하고 있기 때문입니다.

근사 카운터가 충분하지 않은 경우

이 전체적인 접근 방식은 즉각적인 정확성을 저렴한 읽기 및 자가 치유와 맞바꿉니다. 그 거래는 많은 종류의 카운터에는 적합하지만 특정 종류에는 적합하지 않으며, 그 둘을 가르는 기준은 돈입니다.

통계, 순위, 표시 횟수, 조회수 총계, 댓글 수, 좋아요 집계, 팔로워 수의 경우, 몇 분 동안 몇 개 정도 차이가 나는 횟수는 사용자에게 보이지 않으며 아무런 비용도 발생시키지 않습니다. 분산 시스템에서 이러한 값들을 정확하고 실시간으로 유지하도록 강제하는 것은 가장 바쁜 쓰기 작업의 핫 경로에 트랜잭션이나 잠금을 거는 것을 의미하며, 그 비용은 트래픽에 따라 증가합니다. 주기적인 조정이 있는 빠른 근사 카운터는 실용적인 해답이며, 최종적 일관성은 충분히 강력한 보장입니다.

횟수가 실제적인 결과를 동반하는 결정을 좌우하는 모든 경우에 계산이 뒤바뀝니다. 지갑 잔액, 강제하는 유료 할당량, 초과 판매할 수 있는 재고, 음수가 되어서는 안 되는 신용 원장 등 이러한 것들은 지연 조정되는 캐시가 될 수 없습니다. 그러한 경우에는 신뢰할 수 있는 유일한 출처(source of truth)가 읽기 경로에 있어야 합니다. 결정 시점에 횟수를 세거나 권위 있는 잔액을 읽거나, 혹은 감소 자체를 제약 조건에 대해 트랜잭션으로 만들어야 합니다. 잠시 동안 틀린 카운터는 좋아요 집계에는 괜찮지만 금액에 대해서는 용납될 수 없습니다.

고려해야 할 운영 비용도 있습니다. 행을 세는 것은 공짜가 아니며, 거대한 컬렉션에 대해 모든 카운터를 재계산하는 조정 작업은 그 자체로 부하 문제가 될 수 있습니다. 일반적인 해결책은 마지막 실행 이후 변경된 문서만 조정하거나, 시간별로 스캔 범위를 정하거나, 한 번에 모든 것을 세지 않도록 시차를 두거나, 오차는 작게 유지될 만큼 자주 실행하되 스캔 비용은 저렴하게 유지될 만큼 드물게 실행하는 것입니다. 그 튜닝이 조정된 카운터를 운영하는 실제 작업입니다. 설계는 간단합니다. 데이터가 증가함에 따라 작업을 감당할 수 있는 수준으로 유지하는 것, 바로 그 부분에 엔지니어링이 들어갑니다.

관련 글