16. 해설 (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등).
더 나은 설계 (+트레이드오프)
- 단일 권위 시간 서비스 / 논리(틱) 시계: 만료를 권위 시각/틱 번호로 표현해 머신 간 절대 비교가 의미를 갖게. 트레이드오프: 시간 서비스 의존/지연.
- PTP/엄격 NTP + 경계 안전마진(±skew_bound): 동기 정밀도를 올리고 경계 오판을 슬랙으로 흡수. 트레이드오프: 인프라 요구/약간의 부정확.
- 버프/쿨다운 소유권을 한 서버에 고정: 핸드오프 메시지에 "남은 ms" 만 싣고 원 서버가 단일 진실원. 트레이드오프: 이관 프로토콜 복잡.
- 결정론 틱 기반 시뮬레이션: 만료를 틱 카운트로 표현해 벽시계 배제.
면접 포인트 (예상 질문)
- 왜 절대 만료 시각을 서버 간에 보내면 안 되고 남은 시간을 보내야 하는가? 시계 오프셋 δ 가 어떻게 오차로 들어가는가?
Stopwatch/TickCount64(단조) 와UtcNow(벽시계) 의 차이, 만료엔 무엇을 왜 쓰나?- 전송지연까지 보정하려면 무엇을 더 보내야 하고, 그래도 남는 오차의 원인은?
해설 (C++) — 서버 간 시계 차이로 인한 만료/쿨다운 판정 오류 (서버-서버)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
송신 서버 A 가 (A) 자기 벽시계 기준 절대 시각(now + duration)을 메시지에 싣고, 수신
서버 B 가 (B)(C) 자기 벽시계로 그 절대 시각과 비교한다. A·B 시계가 δ 만큼 어긋나 있으면
(NTP 오차/드리프트), B 가 보는 잔여시간이 통째로 δ 만큼 틀어진다. B 가 A 보다 앞서 있으면
버프/쿨다운이 즉시(또는 너무 빨리) 만료되고, B 가 뒤처져 있으면 버프가 과도하게 오래
남거나 쿨다운이 안 풀린다. 게다가 system_clock 은 단조가 아니라 NTP step/서머타임에 점프
가능, int durationMs 는 큰 지속시간에서 오버플로, 직렬화 시 폭/엔디안 합의가 없으면 두
머신이 다르게 해석한다. 정답 한 줄: 절대 벽시계 시각을 보내지 말고 "남은 지속시간(상대값)"
을 보내거나, 메시지에 송신측 now 를 함께 실어 수신측이 시계 오프셋을 보정해 단조 시계로
잔여시간을 재구성하라(또는 단일 권위 시간 서비스/논리시계 사용).
문제점
(A)+(B) 절대 벽시계 시각의 서버 간 직접 비교 — 시계 오차만큼 오판 (분산/시간) ★간판
- 증상: A 가
expireAtMs = A_now + 30000. B 의 시계가 A 보다 +500ms 앞서면 B 는B_now(=A_now+500) >= expireAtMs를 30초가 아니라 29.5초 만에(혹은 경계에선 즉시) 참으로 본다. B 가 -2s 뒤처지면 32초까지 안 끝난다. 핸드오프(A→B)에서 잔여시간이 δ 만큼 점프. - 재현조건: A·B 시계 오프셋 δ≠0(항상 어느 정도 존재). δ 가 클수록(수백 ms~초) 뚜렷.
- 근본 원인: "절대 시각" 은 그 시각을 만든 시계에서만 의미가 있다. 다른 시계로 비교하면 두 시계의 오프셋이 그대로 오차가 된다. 잔여시간(상대값)은 시계 무관하게 보존된다.
(B)(C) system_clock 사용 — 시계 점프 (시간/정확성) ★간판
- 증상:
system_clock은 NTP step 보정/수동 변경으로 뒤로/앞으로 점프한다. 만료 판정이 순간적으로 뒤집혀 버프가 갑자기 사라지거나(뒤로 점프 후 다시) 영원히 안 끝난다. - 근본 원인: 경과/잔여시간 판정은 단조 시계(
steady_clock)로 해야 한다. 벽시계는 "달력 시각" 용이다.
(직렬화) 폭/엔디안/단위 합의 부재 (프로토콜)
- 증상:
expireAtMs/readyAtMs를 그대로 바이트로 보내면 엔디안이 다른 머신에서 다르게 해석. ms 단위 가정도 양측이 공유해야 한다. (구조체 직렬화 시 정렬/패딩도 위험.) - 근본 원인: 서버-서버 정수는 고정폭+고정 엔디안으로 명시 직렬화해야 한다(§protocol7 참조).
(오버플로) int durationMs (정확성, 경미)
- 증상:
int지속시간은 ~24.8일 초과 시 오버플로. 영구/장기 버프엔 부적합. 64비트 권장.
수정안
핵심: 상대값(남은 ms)을 전송하고, 수신측이 도착 시점의 단조 시계로 만료 시각을 재구성. 네트워크 지연 보정이 필요하면 송신 now 를 함께 실어 오프셋을 계산한다.
#include <cstdint>
#include <chrono>
struct BuffGrantMsg {
std::int32_t buffId;
std::int64_t remainingMs; // 절대시각이 아니라 "남은 지속시간"
std::int64_t sentAtUnixMs; // (선택) 송신측 벽시계 — 전송지연 보정용
};
static std::int64_t SteadyNowMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static std::int64_t WallNowMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
}
// 송신(A): 남은 지속시간을 보낸다(자기 단조 시계로 계산).
BuffGrantMsg MakeBuffGrant(std::int32_t buffId, std::int64_t durationMs) {
return BuffGrantMsg{ buffId, durationMs, WallNowMs() };
}
// 수신(B): 도착 시점 단조 시계 + 남은시간 으로 "내 시계 기준 만료 시각" 을 재구성.
struct LocalBuff { std::int32_t buffId; std::int64_t localExpireSteadyMs; };
LocalBuff ApplyGrant(const BuffGrantMsg& g) {
std::int64_t remaining = g.remainingMs;
// (선택) 전송지연 보정: 양측 벽시계 오프셋이 작다고 가정하고 이동 중 경과분을 차감
if (g.sentAtUnixMs > 0) {
std::int64_t inFlight = WallNowMs() - g.sentAtUnixMs; // ≈ 전송지연(+시계오차)
if (inFlight > 0 && inFlight < remaining) remaining -= inFlight;
}
return LocalBuff{ g.buffId, SteadyNowMs() + remaining };
}
bool IsBuffExpired(const LocalBuff& b) {
return SteadyNowMs() >= b.localExpireSteadyMs; // 전부 B 의 단조 시계 기준
}
포인트
- 잔여시간(상대값) 은 시계 오프셋과 무관 → 핸드오프해도 잔여가 보존된다.
- 만료 판정은 수신 서버의 단조 시계 한 종류로만 → 시계 점프/오프셋 영향 제거.
- 전송지연이 중요하면 송신 now 를 실어 in-flight 경과분만 보정(시계 오차는 작게 유지).
- 직렬화는 고정폭(int32/int64)+고정 엔디안(예: 빅엔디안/네트워크 바이트오더)로 명시.
더 나은 설계 (+트레이드오프)
- 단일 권위 시간 서비스 / 논리 시계: 모든 만료를 한 권위 시각(또는 틱 번호)으로 표현하면 머신 간 절대 비교가 의미를 가진다. 트레이드오프: 시간 서비스 의존/지연.
- PTP 또는 엄격한 NTP + 경계 안전마진: 시계 동기를 ms 이하로 조이고, 만료엔 작은 슬랙(±skew_bound)을 둬 경계 오판을 흡수. 트레이드오프: 인프라 요구/약간의 부정확.
- 버프/쿨다운 소유권을 한 서버에 고정: 핸드오프 시 원 서버가 "남은 ms" 의 단일 진실원을 계속 제공(상태 이관 메시지에 잔여만 싣기). 트레이드오프: 이관 프로토콜 복잡.
- 결정론 틱 기반: 시뮬레이션을 고정 틱으로 돌리고 만료를 "틱 카운트" 로 표현하면 시계 자체를 배제. 트레이드오프: 틱 동기화 필요.
면접 포인트 (예상 질문)
- 왜 "절대 만료 시각" 을 서버 간에 보내면 안 되고 "남은 시간" 을 보내야 하는가? 시계 오프셋 δ 가 결과에 어떻게 들어가는가?
steady_clock과system_clock의 차이, 만료/쿨다운엔 무엇을 써야 하고 왜인가?- 전송지연(in-flight)까지 보정하려면 메시지에 무엇을 더 실어야 하나? 그래도 남는 오차는?
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과(문제 코드/수정안 모두).