← 문제로

14. 벌크 스냅샷과 증분 이벤트의 버전 역전

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 벌크 스냅샷과 증분 이벤트의 버전 역전

난이도: 상

요약

TargetSyncStore.ApplySnapshot(A)은 스냅샷이 도착하면 현재 저장된 TargetState의 버전을 전혀 확인하지 않고 무조건 덮어쓴다. ApplyIncremental(B) 역시 이벤트가 도착한 순서 그대로 state.Version = evt.ResultVersion으로 반영할 뿐, IncrementalEvent가 이미 들고 있는 BaseVersion(이 이벤트가 적용되기 직전에 대상이 있어야 할 버전)을 현재 상태의 Version과 비교하지 않는다. 즉 이벤트는 자신이 어떤 버전 위에 적용되어야 하는지에 대한 정보를 이미 싣고 있는데도, 그 정보를 검증에 전혀 사용하지 않는다. 스냅샷과 증분 이벤트가 서로 다른 채널로 전달되어 도착 순서가 뒤바뀔 수 있는 이 시스템에서, 두 메서드 모두 "버전이 항상 오름차순으로 도착한다"는 근거 없는 가정 위에 서 있다. 그 결과 두 방향의 역전이 모두 가능하다: (1) 증분 이벤트들이 먼저 도착해 버전이 v10까지 올라간 상태에서, 원래 v5 시점에 뜬 스냅샷이 뒤늦게 도착하면 ApplySnapshot이 그대로 상태를 v5로 되돌려버린다(더 최신 갱신이 유실된다). (2) 반대로 아직 어떤 스냅샷도 도착하지 않은 대상에 증분 이벤트가 먼저 도착하면 ApplyIncrementalVersion = 0인 빈 상태를 만들어 그 위에 이벤트를 얹는데, 이 이벤트의 BaseVersion이 실제로는 아직 오지 않은 스냅샷(예: v8) 기준이었다면 스냅샷이 나중에 도착했을 때 (A)가 다시 그 상태를 덮어써 증분 이벤트가 반영한 값을 지워버릴 수 있다.

문제점

  • 분류: 순서 검증 없는 다중 채널 상태 갱신 / 최신성(monotonicity) 위반
  • 증상: 길드 창고나 필드 보스 상태가 순간적으로 과거 값으로 되돌아갔다가 이후 다시 최신 값으로 복구되거나(스냅샷이 늦게 도착), 반대로 최근에 반영된 증분 변경(예: 창고에서 아이템을 인출한 이벤트)이 스냅샷 재적용으로 조용히 사라지는 정합성 버그로 보고된다. 재현이 네트워크 타이밍에 의존하므로 특정 리전 쌍 사이의 지연이 크거나 재전송이 잦은 상황(장애 복구 직후)에서 집중적으로 나타난다.
  • 재현 조건: 대상 X에 대해 (1) 버전 5 시점의 스냅샷 메시지와 (2) BaseVersion이 5인 증분 이벤트(ResultVersion 6, 7 등)가 동시에 발생했는데, 네트워크 지연으로 증분 이벤트들이 스냅샷보다 먼저 도착하는 경우. 혹은 반대로 증분 이벤트가 그 이벤트의 기반이 되는 스냅샷보다 먼저 도착하는 경우.
  • 근본 원인:
    • (A) ApplySnapshot이 스냅샷의 Version과 현재 저장된 state.Version을 비교하지 않고 무조건 최신으로 간주해 덮어쓴다. 스냅샷은 그저 "어느 한 시점의 스냅"일 뿐, 그 스냅샷이 지금 도착했다는 사실이 "그 시점이 가장 최신"이라는 것을 보장하지 않는다.
    • (B) ApplyIncremental이 이벤트에 이미 실려 있는 BaseVersion을 현재 상태의 Version과 비교하는 로직 자체가 없다. 증분 이벤트가 "직전 버전에 대한 델타"로 설계되어 그 전제 버전 정보를 갖고 있는데도, 그 전제가 지금 현재 상태와 실제로 맞는지(아직 도달하지 않았는지, 이미 지나간 상태인지)를 코드가 전혀 확인하지 않는다.

수정안

두 갱신 경로 모두 "이 갱신이 현재 상태보다 더 최신인가"를 버전 비교로 검증하고, 더 낮은 버전의 갱신은 무시(idempotent skip)한다. 또한 증분 이벤트가 자신의 BaseVersion이 아직 도달하지 않은 상태를 전제로 한다면(현재 버전과 갭이 있다면) 즉시 반영하지 않고 보류(pending) 큐에 쌓아두었다가, 그 갭을 메워줄 갱신(스냅샷 또는 더 앞선 증분 이벤트)이 도착했을 때 순서대로 재적용한다.

using System.Collections.Generic;
using System.Linq;

public sealed class TargetState
{
    public string TargetId;
    public long Version;
    public Dictionary<string, object> Fields = new();
}

public sealed class SnapshotMessage
{
    public string TargetId;
    public long Version;
    public Dictionary<string, object> FullState;
}

public sealed class IncrementalEvent
{
    public string TargetId;
    public long BaseVersion;   // 이 이벤트가 전제로 하는 직전 버전.
    public long ResultVersion; // 이 이벤트가 적용되면 도달하는 버전.
    public string FieldName;
    public object NewValue;
}

public sealed class TargetSyncStore
{
    private readonly Dictionary<string, TargetState> _states = new();
    // 아직 적용할 수 없는(더 낮은 버전이 먼저 필요한) 증분 이벤트를 보관한다.
    private readonly Dictionary<string, List<IncrementalEvent>> _pending = new();

    public void ApplySnapshot(SnapshotMessage snapshot)
    {
        _states.TryGetValue(snapshot.TargetId, out var current);

        // 이미 더 높은(혹은 같은) 버전이 반영되어 있다면 이 스냅샷은 낡은
        // 정보이므로 무시한다 — 버전이 뒤로 가는 것을 막는다.
        if (current != null && current.Version >= snapshot.Version)
            return;

        var state = new TargetState
        {
            TargetId = snapshot.TargetId,
            Version = snapshot.Version,
            Fields = new Dictionary<string, object>(snapshot.FullState),
        };
        _states[snapshot.TargetId] = state;

        DrainPending(state);
    }

    public void ApplyIncremental(IncrementalEvent evt)
    {
        if (!_states.TryGetValue(evt.TargetId, out var state))
        {
            state = new TargetState { TargetId = evt.TargetId, Version = 0 };
            _states[evt.TargetId] = state;
        }

        // 이미 이 이벤트가 반영하려는 버전 이상으로 진행되어 있다면 무시한다.
        if (state.Version >= evt.ResultVersion)
            return;

        // 이 이벤트가 전제로 하는 BaseVersion 에 아직 도달하지 않았다면(중간
        // 갱신 누락), 지금 반영하지 않고 보류해둔다.
        if (state.Version < evt.BaseVersion)
        {
            if (!_pending.TryGetValue(evt.TargetId, out var list))
            {
                list = new List<IncrementalEvent>();
                _pending[evt.TargetId] = list;
            }
            list.Add(evt);
            return;
        }

        ApplyDirectly(state, evt);
        DrainPending(state);
    }

    private static void ApplyDirectly(TargetState state, IncrementalEvent evt)
    {
        state.Fields[evt.FieldName] = evt.NewValue;
        state.Version = evt.ResultVersion;
    }

    // 상태가 갱신될 때마다, 보류 중이던 이벤트 중 이제 적용 가능해진 것이
    // 있는지 확인해 순서대로 적용한다.
    private void DrainPending(TargetState state)
    {
        if (!_pending.TryGetValue(state.TargetId, out var list) || list.Count == 0)
            return;

        bool appliedAny;
        do
        {
            appliedAny = false;
            var ready = list.Where(e => e.BaseVersion == state.Version).OrderBy(e => e.ResultVersion).ToList();
            foreach (var evt in ready)
            {
                if (state.Version >= evt.ResultVersion)
                {
                    list.Remove(evt);
                    continue;
                }
                ApplyDirectly(state, evt);
                list.Remove(evt);
                appliedAny = true;
            }
        } while (appliedAny && list.Count > 0);
    }
}

더 나은 설계

  • 버전 비교 + 보류 큐(채택안): 스냅샷과 증분 이벤트 양쪽 모두에 버전 검증을 넣고, 갭이 있는 증분 이벤트는 보류했다가 순서가 맞춰지면 재적용한다. 구현이 상대적으로 단순하고 어떤 도착 순서에도 최종 상태의 정합성을 보장하지만, 보류 큐가 무한정 커질 수 있으므로(스냅샷이 영영 오지 않는 경우 등) TTL/최대 크기 제한과 함께 스냅샷을 능동적으로 재요청하는 보완 로직이 필요하다.
  • 벡터 시계 / 버전 벡터: 여러 소스가 독립적으로 갱신할 수 있는 필드 단위까지 인과관계를 추적하고 싶다면 벡터 시계를 도입해 "이 갱신이 저 갱신을 인과적으로 선행하는지"를 정밀하게 판단할 수 있다. 이 시나리오처럼 단일 정수 버전(전역 순서가 이미 서버 하나에서 부여되는 구조)이면 과설계가 될 수 있다.
  • 단일 채널 강제(증분 이벤트에 스냅샷을 포함시키지 않고, 스냅샷 요청/응답도 같은 순서 보장 채널로 통일): 애초에 스냅샷과 증분을 별도 채널로 분리하지 않고 하나의 순서 보장 스트림(예: 파티션 키 기준 순서가 보장되는 메시지 큐)으로 합치면 이 문제 자체가 근원적으로 사라진다. 다만 벌크 스냅샷은 페이로드가 커서 별도 채널(예: 벌크 전송용 채널)로 분리하는 것이 일반적인 실무 트레이드오프이므로, 완전한 통합이 항상 실용적이지는 않다.
  • 트레이드오프: 버전 비교+보류 큐는 기존 2채널 구조를 유지하면서 최소 침습적으로 정합성을 확보할 수 있어 실무에 적합하다. 벡터 시계는 다중 쓰기 소스 환경에서 더 정밀하지만 이 시나리오의 단일 버전 카운터 전제와는 잘 맞지 않는다. 채널 통합은 근본적이지만 인프라/프로토콜 변경 비용이 크다.

면접 포인트

  • "메시지가 여러 채널로 온다면 도착 순서와 논리적(인과적) 순서가 다를 수 있다"는 전제를 항상 의심해야 한다는 점, 그리고 그런 상황에서 최종 상태의 단조성(monotonicity)을 보장하려면 반드시 버전/시퀀스 번호 비교가 필요하다는 점을 설명할 수 있어야 한다.
  • 스냅샷을 "그 시점까지의 상태를 압축한 요약"으로 보고, 스냅샷 적용 자체도 "더 최신 버전으로 향하는 갱신 중 하나"로 취급해 다른 갱신과 동일한 버전 비교 규칙을 적용해야 한다는 관점을 설명할 수 있어야 한다.
  • 증분 이벤트가 이미 자신의 전제 버전(BaseVersion)을 싣고 있다면, 그 정보를 그냥 무시하지 않고 실제 검증에 활용해야 한다는 점 — "데이터는 있는데 안 쓰는" 결함이 실무에서 왜 흔히 발생하는지 논할 수 있어야 한다.
  • 증분 이벤트를 즉시 적용하지 않고 보류하는 설계(gap 처리)가 어떤 트레이드오프(지연 증가, 메모리 사용)를 가지는지, 그리고 보류 큐가 영원히 안 풀리는 상황(스냅샷 유실)에 대한 방어(재요청, TTL)가 왜 필요한지 논할 수 있어야 한다.