14. 벌크 스냅샷과 증분 이벤트의 버전 역전
난이도 상해설 — 벌크 스냅샷과 증분 이벤트의 버전 역전
난이도: 상
요약
TargetSyncStore.ApplySnapshot(A)은 스냅샷이 도착하면 현재 저장된 TargetState의 버전을 전혀 확인하지 않고 무조건 덮어쓴다. ApplyIncremental(B) 역시 이벤트가 도착한 순서 그대로 state.Version = evt.ResultVersion으로 반영할 뿐, IncrementalEvent가 이미 들고 있는 BaseVersion(이 이벤트가 적용되기 직전에 대상이 있어야 할 버전)을 현재 상태의 Version과 비교하지 않는다. 즉 이벤트는 자신이 어떤 버전 위에 적용되어야 하는지에 대한 정보를 이미 싣고 있는데도, 그 정보를 검증에 전혀 사용하지 않는다. 스냅샷과 증분 이벤트가 서로 다른 채널로 전달되어 도착 순서가 뒤바뀔 수 있는 이 시스템에서, 두 메서드 모두 "버전이 항상 오름차순으로 도착한다"는 근거 없는 가정 위에 서 있다. 그 결과 두 방향의 역전이 모두 가능하다: (1) 증분 이벤트들이 먼저 도착해 버전이 v10까지 올라간 상태에서, 원래 v5 시점에 뜬 스냅샷이 뒤늦게 도착하면 ApplySnapshot이 그대로 상태를 v5로 되돌려버린다(더 최신 갱신이 유실된다). (2) 반대로 아직 어떤 스냅샷도 도착하지 않은 대상에 증분 이벤트가 먼저 도착하면 ApplyIncremental이 Version = 0인 빈 상태를 만들어 그 위에 이벤트를 얹는데, 이 이벤트의 BaseVersion이 실제로는 아직 오지 않은 스냅샷(예: v8) 기준이었다면 스냅샷이 나중에 도착했을 때 (A)가 다시 그 상태를 덮어써 증분 이벤트가 반영한 값을 지워버릴 수 있다.
문제점
- 분류: 순서 검증 없는 다중 채널 상태 갱신 / 최신성(monotonicity) 위반
- 증상: 길드 창고나 필드 보스 상태가 순간적으로 과거 값으로 되돌아갔다가 이후 다시 최신 값으로 복구되거나(스냅샷이 늦게 도착), 반대로 최근에 반영된 증분 변경(예: 창고에서 아이템을 인출한 이벤트)이 스냅샷 재적용으로 조용히 사라지는 정합성 버그로 보고된다. 재현이 네트워크 타이밍에 의존하므로 특정 리전 쌍 사이의 지연이 크거나 재전송이 잦은 상황(장애 복구 직후)에서 집중적으로 나타난다.
- 재현 조건: 대상 X에 대해 (1) 버전 5 시점의 스냅샷 메시지와 (2)
BaseVersion이 5인 증분 이벤트(ResultVersion 6, 7 등)가 동시에 발생했는데, 네트워크 지연으로 증분 이벤트들이 스냅샷보다 먼저 도착하는 경우. 혹은 반대로 증분 이벤트가 그 이벤트의 기반이 되는 스냅샷보다 먼저 도착하는 경우. - 근본 원인:
- (A)
ApplySnapshot이 스냅샷의Version과 현재 저장된state.Version을 비교하지 않고 무조건 최신으로 간주해 덮어쓴다. 스냅샷은 그저 "어느 한 시점의 스냅"일 뿐, 그 스냅샷이 지금 도착했다는 사실이 "그 시점이 가장 최신"이라는 것을 보장하지 않는다. - (B)
ApplyIncremental이 이벤트에 이미 실려 있는BaseVersion을 현재 상태의Version과 비교하는 로직 자체가 없다. 증분 이벤트가 "직전 버전에 대한 델타"로 설계되어 그 전제 버전 정보를 갖고 있는데도, 그 전제가 지금 현재 상태와 실제로 맞는지(아직 도달하지 않았는지, 이미 지나간 상태인지)를 코드가 전혀 확인하지 않는다.
- (A)
수정안
두 갱신 경로 모두 "이 갱신이 현재 상태보다 더 최신인가"를 버전 비교로 검증하고, 더 낮은 버전의 갱신은 무시(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)가 왜 필요한지 논할 수 있어야 한다.
해설 — 벌크 스냅샷과 증분 이벤트의 버전 역전
난이도: 상
요약
TargetSyncStore::ApplySnapshot(A)은 스냅샷이 도착하면 현재 저장된 TargetState의 버전을 전혀 확인하지 않고 무조건 덮어쓴다. ApplyIncremental(B) 역시 이벤트가 도착한 순서 그대로 state.version = evt.resultVersion으로 반영할 뿐, IncrementalEvent가 이미 들고 있는 baseVersion(이 이벤트가 적용되기 직전에 대상이 있어야 할 버전)을 현재 상태의 version과 비교하지 않는다. 즉 이벤트는 자신이 어떤 버전 위에 적용되어야 하는지에 대한 정보를 이미 싣고 있는데도, 그 정보를 검증에 전혀 사용하지 않는다. 스냅샷과 증분 이벤트가 서로 다른 채널로 전달되어 도착 순서가 뒤바뀔 수 있는 이 시스템에서, 두 메서드 모두 "버전이 항상 오름차순으로 도착한다"는 근거 없는 가정 위에 서 있다. 그 결과 두 방향의 역전이 모두 가능하다: (1) 증분 이벤트들이 먼저 도착해 버전이 v10까지 올라간 상태에서, 원래 v5 시점에 뜬 스냅샷이 뒤늦게 도착하면 ApplySnapshot이 그대로 상태를 v5로 되돌려버린다(더 최신 갱신이 유실). (2) 반대로 아직 어떤 스냅샷도 도착하지 않은 대상에 증분 이벤트가 먼저 도착하면 ApplyIncremental이 version = 0인 빈 상태를 만들어 그 위에 이벤트를 얹는데, 이 이벤트의 baseVersion이 실제로는 아직 오지 않은 스냅샷(예: v8) 기준이었다면 스냅샷이 나중에 도착했을 때 (A)가 다시 그 상태를 덮어써 증분 이벤트가 반영한 값을 지워버릴 수 있다.
문제점
- 분류: 순서 검증 없는 다중 채널 상태 갱신 / 최신성(monotonicity) 위반
- 증상: 길드 창고나 필드 보스 상태가 순간적으로 과거 값으로 되돌아갔다가 이후 다시 최신 값으로 복구되거나(스냅샷이 늦게 도착), 반대로 최근에 반영된 증분 변경이 스냅샷 재적용으로 조용히 사라지는 정합성 버그로 보고된다. 재현이 네트워크 타이밍에 의존하므로 특정 리전 쌍 사이의 지연이 크거나 재전송이 잦은 상황(장애 복구 직후)에서 집중적으로 나타난다.
- 재현 조건: 대상 X에 대해 (1) 버전 5 시점의 스냅샷 메시지와 (2)
baseVersion이 5인 증분 이벤트(resultVersion 6, 7 등)가 동시에 발생했는데, 네트워크 지연으로 증분 이벤트들이 스냅샷보다 먼저 도착하는 경우. 혹은 반대로 증분 이벤트가 그 이벤트의 기반이 되는 스냅샷보다 먼저 도착하는 경우. - 근본 원인:
- (A)
ApplySnapshot이 스냅샷의version과 현재 저장된state.version을 비교하지 않고 무조건 최신으로 간주해 덮어쓴다. 스냅샷은 그저 "어느 한 시점의 스냅"일 뿐, 그 스냅샷이 지금 도착했다는 사실이 "그 시점이 가장 최신"이라는 것을 보장하지 않는다. - (B)
ApplyIncremental이 이벤트에 이미 실려 있는baseVersion을 현재 상태의version과 비교하는 로직 자체가 없다. 증분 이벤트가 "직전 버전에 대한 델타"로 설계되어 그 전제 버전 정보를 갖고 있는데도, 그 전제가 지금 현재 상태와 실제로 맞는지(아직 도달하지 않았는지, 이미 지나간 상태인지)를 코드가 전혀 확인하지 않는다.
- (A)
수정안
두 갱신 경로 모두 "이 갱신이 현재 상태보다 더 최신인가"를 버전 비교로 검증하고, 더 낮은 버전의 갱신은 무시(idempotent skip)한다. 증분 이벤트가 자신의 baseVersion이 아직 도달하지 않은 상태를 전제로 한다면(현재 버전과 갭이 있다면) 즉시 반영하지 않고 보류(pending) 큐에 쌓아두었다가, 그 갭을 메워줄 갱신이 도착했을 때 순서대로 재적용한다.
#include <algorithm>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
struct TargetState
{
std::string targetId;
int64_t version = 0;
std::unordered_map<std::string, std::string> fields;
};
struct SnapshotMessage
{
std::string targetId;
int64_t version = 0;
std::unordered_map<std::string, std::string> fullState;
};
struct IncrementalEvent
{
std::string targetId;
int64_t baseVersion = 0; // 이 이벤트가 전제로 하는 직전 버전.
int64_t resultVersion = 0; // 이 이벤트가 적용되면 도달하는 버전.
std::string fieldName;
std::string newValue;
};
class TargetSyncStore
{
public:
void ApplySnapshot(const SnapshotMessage& snapshot)
{
auto it = states_.find(snapshot.targetId);
// 이미 더 높은(혹은 같은) 버전이 반영되어 있다면 이 스냅샷은 낡은
// 정보이므로 무시한다 — 버전이 뒤로 가는 것을 막는다.
if (it != states_.end() && it->second.version >= snapshot.version)
return;
TargetState state;
state.targetId = snapshot.targetId;
state.version = snapshot.version;
state.fields = snapshot.fullState;
auto& stored = (states_[snapshot.targetId] = std::move(state));
DrainPending(stored);
}
void ApplyIncremental(const IncrementalEvent& evt)
{
auto it = states_.find(evt.targetId);
if (it == states_.end())
{
TargetState state;
state.targetId = evt.targetId;
state.version = 0;
it = states_.emplace(evt.targetId, std::move(state)).first;
}
TargetState& state = it->second;
// 이미 이 이벤트가 반영하려는 버전 이상으로 진행되어 있다면 무시한다.
if (state.version >= evt.resultVersion)
return;
// 이 이벤트가 전제로 하는 baseVersion 에 아직 도달하지 않았다면(중간
// 갱신 누락), 지금 반영하지 않고 보류해둔다.
if (state.version < evt.baseVersion)
{
pending_[evt.targetId].push_back(evt);
return;
}
ApplyDirectly(state, evt);
DrainPending(state);
}
private:
static void ApplyDirectly(TargetState& state, const IncrementalEvent& evt)
{
state.fields[evt.fieldName] = evt.newValue;
state.version = evt.resultVersion;
}
// 상태가 갱신될 때마다, 보류 중이던 이벤트 중 이제 적용 가능해진 것이
// 있는지 확인해 순서대로 적용한다.
void DrainPending(TargetState& state)
{
auto pendingIt = pending_.find(state.targetId);
if (pendingIt == pending_.end() || pendingIt->second.empty())
return;
auto& list = pendingIt->second;
bool appliedAny = true;
while (appliedAny && !list.empty())
{
appliedAny = false;
std::sort(list.begin(), list.end(), [](const IncrementalEvent& a, const IncrementalEvent& b)
{
return a.resultVersion < b.resultVersion;
});
for (size_t i = 0; i < list.size();)
{
const IncrementalEvent& evt = list[i];
if (state.version >= evt.resultVersion)
{
list.erase(list.begin() + i);
continue;
}
if (evt.baseVersion == state.version)
{
ApplyDirectly(state, evt);
list.erase(list.begin() + i);
appliedAny = true;
continue;
}
++i;
}
}
}
std::unordered_map<std::string, TargetState> states_;
// 아직 적용할 수 없는(더 낮은 버전이 먼저 필요한) 증분 이벤트를 보관한다.
std::unordered_map<std::string, std::vector<IncrementalEvent>> pending_;
};
더 나은 설계
- 버전 비교 + 보류 큐(채택안): 스냅샷과 증분 이벤트 양쪽 모두에 버전 검증을 넣고, 갭이 있는 증분 이벤트는 보류했다가 순서가 맞춰지면 재적용한다. 구현이 상대적으로 단순하고 어떤 도착 순서에도 최종 상태의 정합성을 보장하지만, 보류 큐가 무한정 커질 수 있으므로(스냅샷이 영영 오지 않는 경우 등) TTL/최대 크기 제한과 함께 스냅샷을 능동적으로 재요청하는 보완 로직이 필요하다.
- 벡터 시계 / 버전 벡터: 여러 소스가 독립적으로 갱신할 수 있는 필드 단위까지 인과관계를 추적하고 싶다면 벡터 시계를 도입해 "이 갱신이 저 갱신을 인과적으로 선행하는지"를 정밀하게 판단할 수 있다. 이 시나리오처럼 단일 정수 버전(전역 순서가 이미 서버 하나에서 부여되는 구조)이면 과설계가 될 수 있다.
- 단일 채널 강제: 애초에 스냅샷과 증분을 별도 채널로 분리하지 않고 하나의 순서 보장 스트림(예: 파티션 키 기준 순서가 보장되는 메시지 큐)으로 합치면 이 문제 자체가 근원적으로 사라진다. 다만 벌크 스냅샷은 페이로드가 커서 별도 채널로 분리하는 것이 일반적인 실무 트레이드오프이므로, 완전한 통합이 항상 실용적이지는 않다.
- 트레이드오프: 버전 비교+보류 큐는 기존 2채널 구조를 유지하면서 최소 침습적으로 정합성을 확보할 수 있어 실무에 적합하다. 벡터 시계는 다중 쓰기 소스 환경에서 더 정밀하지만 이 시나리오의 단일 버전 카운터 전제와는 잘 맞지 않는다. 채널 통합은 근본적이지만 인프라/프로토콜 변경 비용이 크다.
면접 포인트
- "메시지가 여러 채널로 온다면 도착 순서와 논리적(인과적) 순서가 다를 수 있다"는 전제를 항상 의심해야 한다는 점, 그리고 그런 상황에서 최종 상태의 단조성(monotonicity)을 보장하려면 반드시 버전/시퀀스 번호 비교가 필요하다는 점을 설명할 수 있어야 한다.
- 스냅샷을 "그 시점까지의 상태를 압축한 요약"으로 보고, 스냅샷 적용 자체도 "더 최신 버전으로 향하는 갱신 중 하나"로 취급해 다른 갱신과 동일한 버전 비교 규칙을 적용해야 한다는 관점을 설명할 수 있어야 한다.
- 증분 이벤트가 이미 자신의 전제 버전(baseVersion)을 싣고 있다면, 그 정보를 그냥 무시하지 않고 실제 검증에 활용해야 한다는 점 — "데이터는 있는데 안 쓰는" 결함이 실무에서 왜 흔히 발생하는지 논할 수 있어야 한다.
- 증분 이벤트를 즉시 적용하지 않고 보류하는 설계(gap 처리)가 어떤 트레이드오프(지연 증가, 메모리 사용)를 가지는지, 그리고 보류 큐가 영원히 안 풀리는 상황(스냅샷 유실)에 대한 방어(재요청, TTL)가 왜 필요한지 논할 수 있어야 한다.