← 문제로

16. 해설 (C#) — 서버 간 시계 차이로 인한 만료/쿨다운 판정 오류 (서버-서버)

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

해설 (C#) — 서버 간 시계 차이로 인한 만료/쿨다운 판정 오류 (서버-서버)

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

송신 서버 A 가 (A) 자기 벽시계 기준 절대 시각(UtcNow + duration)을 메시지에 싣고, 수신 서버 B 가 (B)(C) 자기 벽시계로 그 절대 시각과 비교한다. A·B 시계가 δ 만큼 어긋나면(NTP 오차/드리프트) B 가 보는 잔여시간이 δ 만큼 통째로 틀어진다 — B 가 앞서 있으면 버프/쿨다운이 너무 빨리(경계에선 즉시) 만료되고, B 가 뒤처지면 과도하게 오래 남는다. DateTimeOffset.UtcNow 는 단조가 아니라 NTP step 보정에 점프할 수 있고, 직렬화 시 폭/엔디안 합의가 없으면 두 머신이 다르게 해석한다. 정답 한 줄: 절대 시각이 아니라 "남은 지속시간(상대값)" 을 보내고, 수신측이 도착 시점의 단조 시계(Stopwatch/Environment.TickCount64)로 만료 시각을 재구성하라(또는 단일 권위 시간/논리시계).


문제점

(A)+(B) 절대 벽시계 시각의 서버 간 직접 비교 — 시계 오차만큼 오판 (분산/시간) ★간판

  • 증상: A 가 ExpireAtMs = A_now + 30000. B 시계가 +500ms 앞서면 B 는 29.5초 만에 만료로 본다(경계에선 즉시). B 가 -2s 뒤처지면 32초까지 안 끝난다. 핸드오프 시 잔여시간이 δ 만큼 점프.
  • 근본 원인: 절대 시각은 그것을 만든 시계에서만 의미가 있다. 다른 시계로 비교하면 두 시계 오프셋이 그대로 오차가 된다. 잔여시간(상대값)은 시계 무관하게 보존된다.

(B)(C) DateTimeOffset.UtcNow 사용 — 시계 점프 (시간/정확성) ★간판

  • 증상: UtcNow 는 벽시계라 NTP step/수동 변경에 점프. 만료 판정이 순간 뒤집힌다.
  • 근본 원인: 경과/잔여시간 판정은 단조 시계(Stopwatch.GetTimestamp/ Environment.TickCount64)로 해야 한다.

(직렬화) 폭/엔디안/단위 합의 부재 (프로토콜)

  • 증상: long 절대값을 그대로 바이트로 보내면 엔디안이 다른 머신에서 오해석. ms 단위 가정도 양측 공유 필요.
  • 근본 원인: 서버-서버 정수는 고정폭+고정 엔디안으로 명시 직렬화.

(오버플로) int durationMs (정확성, 경미)

  • int 지속시간은 ~24.8일 초과 시 오버플로. 장기/영구 버프엔 64비트 사용.

수정안

핵심: 상대값(남은 ms)을 전송하고, 수신측이 도착 시점 단조 시계로 만료를 재구성.

using System;
using System.Diagnostics;

public struct BuffGrantMsg
{
    public int  BuffId;
    public long RemainingMs;    // 절대시각이 아니라 "남은 지속시간"
    public long SentAtUnixMs;   // (선택) 송신측 벽시계 — 전송지연 보정용
}

public struct LocalBuff
{
    public int  BuffId;
    public long LocalExpireTick;   // 수신 서버의 단조 시계 기준 만료 틱(ms)
}

public static class TimeFix
{
    private static long SteadyMs() => Environment.TickCount64;             // 단조
    private static long WallMs()   => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    public static BuffGrantMsg MakeBuffGrant(int buffId, long durationMs)
        => new BuffGrantMsg { BuffId = buffId, RemainingMs = durationMs, SentAtUnixMs = WallMs() };

    public static LocalBuff ApplyGrant(in BuffGrantMsg g)
    {
        long remaining = g.RemainingMs;
        if (g.SentAtUnixMs > 0)
        {
            long inFlight = WallMs() - g.SentAtUnixMs;   // ≈ 전송지연(+시계오차)
            if (inFlight > 0 && inFlight < remaining) remaining -= inFlight;
        }
        return new LocalBuff { BuffId = g.BuffId, LocalExpireTick = SteadyMs() + remaining };
    }

    public static bool IsBuffExpired(in LocalBuff b) => SteadyMs() >= b.LocalExpireTick;
}

포인트

  • 잔여시간(상대값) 은 시계 오프셋 무관 → 핸드오프해도 보존.
  • 만료 판정은 수신 서버 단조 시계 한 종류로만 → 점프/오프셋 영향 제거.
  • 전송지연이 중요하면 송신 now 로 in-flight 경과분만 보정.
  • 직렬화는 고정폭(Int32/Int64)+고정 엔디안 명시(BinaryPrimitives.WriteInt64BigEndian 등).

더 나은 설계 (+트레이드오프)

  1. 단일 권위 시간 서비스 / 논리(틱) 시계: 만료를 권위 시각/틱 번호로 표현해 머신 간 절대 비교가 의미를 갖게. 트레이드오프: 시간 서비스 의존/지연.
  2. PTP/엄격 NTP + 경계 안전마진(±skew_bound): 동기 정밀도를 올리고 경계 오판을 슬랙으로 흡수. 트레이드오프: 인프라 요구/약간의 부정확.
  3. 버프/쿨다운 소유권을 한 서버에 고정: 핸드오프 메시지에 "남은 ms" 만 싣고 원 서버가 단일 진실원. 트레이드오프: 이관 프로토콜 복잡.
  4. 결정론 틱 기반 시뮬레이션: 만료를 틱 카운트로 표현해 벽시계 배제.

면접 포인트 (예상 질문)

  1. 왜 절대 만료 시각을 서버 간에 보내면 안 되고 남은 시간을 보내야 하는가? 시계 오프셋 δ 가 어떻게 오차로 들어가는가?
  2. Stopwatch/TickCount64(단조) 와 UtcNow(벽시계) 의 차이, 만료엔 무엇을 왜 쓰나?
  3. 전송지연까지 보정하려면 무엇을 더 보내야 하고, 그래도 남는 오차의 원인은?