← 문제로

13. 리전 간 벽시계 타임스탬프로 이벤트 순서를 재구성하는 결함

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

해설 — 리전 간 벽시계 타임스탬프로 이벤트 순서를 재구성하는 결함

난이도: 상

요약

GlobalEventAggregator(B)는 각 리전 서버가 보낸 이벤트의 WallClockTimestamp 만을 기준으로 정렬해 "일어난 순서"를 재구성한다. 그러나 벽시계는 리전마다 독립적인 물리 클록이며, NTP 로 동기화되어 있어도 수십~수백 밀리초의 드리프트가 항상 존재한다. 만약 리전 A 에서 발생한 이벤트가 실제로는 리전 B 에서 발생한 이벤트보다 먼저 일어났고 그로 인해(인과관계로) B 의 이벤트가 유발되었더라도, A 서버의 시계가 B 서버의 시계보다 조금이라도 뒤처져 있다면 A 의 타임스탬프가 B 의 타임스탬프보다 더 늦은 값으로 찍힐 수 있다. 그 결과 (A)에서 수신 순서와 무관하게 버퍼링된 이벤트들이 (B)의 정렬 단계에서 실제 인과관계와 반대로 재배열되어 처리되는 근본적인 결함이 발생한다.

문제점

  • 분류: 인과적 순서(causal ordering) 위반 / 물리 시계(wall-clock) 기반 전역 순서 재구성의 근본적 한계
  • 증상: 예를 들어 리전 A 의 경매 입찰 이벤트가 실제로는 리전 B 의 "입찰 거부(중복 낙찰 방지)" 이벤트를 유발했는데도, 시계 드리프트로 B 의 타임스탬프가 A 보다 더 이른 값으로 기록되면 집계 서버는 B 이벤트를 먼저 처리한 뒤 A 이벤트를 나중에 처리한다 — 인과관계상 원인이 결과보다 나중에 처리되는 모순이 발생한다.
  • 재현 조건: 리전 A 와 리전 B 의 시스템 시각이 (NTP 동기화에도 불구하고) 예컨대 100ms 어긋나 있는 상태에서, 두 리전 사이에 100ms 이내의 시간차로 인과관계가 있는 이벤트 쌍이 발생하는 경우.
  • 근본 원인:
    • (A) 이벤트를 수신 순서대로만 버퍼에 쌓을 뿐, 각 리전이 이벤트 발생 시점에 다른 리전의 최신 이벤트를 "알고 있었는지"(인과적 선행 관계)에 대한 정보를 전혀 기록하지 않는다.
    • (B) 정렬 기준이 순수 물리 시계(wall-clock) 뿐이다. 물리 시계는 서로 다른 머신 간 순서를 "인과관계 보장" 수준으로 비교할 수 있는 도구가 아니다 — 이는 분산 시스템에서 널리 알려진 한계이며, 이를 보완하기 위한 논리 시계(Lamport clock)나 벡터 시계(vector clock) 가 이 코드에는 전혀 도입되어 있지 않다.

수정안

각 리전 서버가 이벤트를 생성/전파할 때 Lamport 논리 시계 값을 함께 실어 보내고, 집계 서버는 이 논리 시계를 우선 기준으로, 동률일 때만 리전 ID 등의 보조 기준으로 전역 순서를 정한다. Lamport 시계는 "이벤트 A 가 이벤트 B 를 인과적으로 선행한다"는 관계를 절대 어기지 않는 순서를 보장한다(반대로 인과관계가 없는 이벤트끼리의 순서는 임의로 정해질 수 있다는 한계는 있다).

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

public sealed class RegionEvent
{
    public string RegionId;
    public string EventId;
    public long   LamportClock;       // 발생 리전이 부여한 논리 시계 값
    public DateTime WallClockTimestamp; // 참고/로그용으로만 남기고 정렬 기준으로는 쓰지 않는다.
    public string Description;
}

// 리전 서버 측: 이벤트를 만들 때마다 호출해 논리 시계를 증가시키고,
// 다른 리전으로부터 이벤트를 수신하면 그 이벤트의 시계 값을 반영해
// 자신의 시계를 그보다 앞서도록 갱신한다(Lamport 규칙).
public sealed class LamportClockSource
{
    private long _counter;

    public long NextForLocalEvent()
    {
        return Interlocked.Increment(ref _counter);
    }

    // 다른 리전에서 수신한 이벤트의 시계 값을 반영한다.
    public long OnReceiveRemoteClock(long remoteClock)
    {
        long updated;
        long current;
        do
        {
            current = Interlocked.Read(ref _counter);
            updated = Math.Max(current, remoteClock) + 1;
        } while (Interlocked.CompareExchange(ref _counter, updated, current) != current);
        return updated;
    }
}

public sealed class GlobalEventAggregator
{
    private readonly List<RegionEvent> _buffer = new();

    public void OnEventReceived(RegionEvent evt)
    {
        _buffer.Add(evt);
    }

    public List<RegionEvent> DrainInOrder()
    {
        // Lamport 시계를 1차 기준으로 정렬한다: 인과관계가 있는 이벤트라면
        // 원인 이벤트의 시계 값이 결과 이벤트의 시계 값보다 항상 작으므로
        // 이 정렬은 실제 인과 순서를 절대 위반하지 않는다.
        // 시계 값이 같다면(동시성, 인과관계 없음) 리전 ID 로 결정적 타이브레이크.
        var ordered = _buffer
            .OrderBy(e => e.LamportClock)
            .ThenBy(e => e.RegionId, StringComparer.Ordinal)
            .ToList();
        _buffer.Clear();
        return ordered;
    }
}

리전 서버 측에서는 이벤트를 만들 때 LamportClockSource.NextForLocalEvent() 로 시계 값을 채우고, 다른 리전의 이벤트를 참조/응답하는 이벤트를 만들 때는 먼저 OnReceiveRemoteClock 으로 시계를 갱신한 뒤 그 값으로 새 이벤트의 시계를 채워야 한다 — 이 규칙을 지켜야 인과관계가 시계 값의 대소 관계로 정확히 표현된다.

더 나은 설계

  • Lamport 논리 시계(채택안): 구현이 간단(정수 카운터 하나)하고, "인과관계가 있으면 순서가 절대 뒤바뀌지 않는다"는 성질을 보장한다. 다만 두 이벤트의 시계 값만 보고는 그 둘이 실제로 인과관계가 있는지(happened-before) 없는지(concurrent) 구분할 수 없다는 한계가 있다 — 순서는 정해지지만 "왜 그 순서인지"는 알 수 없다.
  • 벡터 시계(Vector Clock): 리전 수만큼의 카운터 벡터를 각 이벤트에 실어, 두 이벤트가 인과관계인지(happened-before) 아니면 동시(concurrent, 인과관계 없음)인지까지 정확히 판별할 수 있다. 동시 이벤트끼리는 순서를 강제하지 않아도 되므로(애초에 순서가 중요하지 않은 이벤트 쌍이므로) 더 정교한 처리가 가능하지만, 리전 수에 비례해 각 이벤트에 실어야 할 데이터 크기가 커지고 구현 복잡도가 높다.
  • 하이브리드 논리 시계(HLC, Hybrid Logical Clock): 물리 시계와 논리 카운터를 결합해, 대부분의 경우 물리 시계에 가까운(사람이 읽기 쉬운, 대략적인 실제 시각과 상관관계가 있는) 값을 유지하면서도 Lamport 시계의 인과성 보장을 함께 얻는다. Google Spanner/CockroachDB 류의 분산 DB 에서 실제로 쓰이는 절충안이며, 로깅/디버깅 시 시계 값이 실제 시각과 크게 동떨어지지 않아 운영 편의성이 좋다.
  • 트레이드오프: 인과관계 판별까지 필요하면 벡터 시계, 실무적으로 "순서만 어기지 않으면 충분"하고 구현 단순성이 중요하면 Lamport, 로깅/운영 편의성과 인과성 보장을 함께 원하면 HLC 가 적합하다. 리전 수가 매우 많아지면 벡터 시계의 크기 증가가 부담이 될 수 있어, 대규모 시스템일수록 HLC 나 Lamport + 별도 인과 태그(예: 원인 이벤트 ID 참조) 조합이 더 실용적이다.

면접 포인트

  • 벽시계(physical/wall-clock)는 서로 다른 머신 간의 "동시에 발생했다/먼저 발생했다"를 인과관계 보장 수준으로 비교하는 도구가 아니다 — NTP 로 동기화해도 드리프트가 항상 존재한다는 사실 자체가 분산 시스템 설계의 기본 전제여야 한다.
  • Lamport 시계가 보장하는 것(a → b 이면 L(a) < L(b))과 보장하지 않는 것(L(a) < L(b) 라고 해서 a → b 인지는 알 수 없음, 즉 동시성 이벤트도 임의의 순서로 정렬될 뿐임)을 정확히 구분해서 설명할 수 있어야 한다.
  • 벡터 시계 vs Lamport 시계 vs HLC 의 트레이드오프(정확성/판별력 vs 데이터 크기/구현 복잡도)를 실제 분산 게임 서버의 리전 수, 이벤트 빈도 같은 구체적 제약과 연결해 설명할 수 있어야 한다.