← 문제로

18. 채팅/길드 같은 글로벌 서비스의 이벤트 순서 보장 (서버-서버, C#)

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

해설 — 채팅/길드 같은 글로벌 서비스의 이벤트 순서 보장 (서버-서버, C#)

난이도: 최상

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

요약

글로벌 채팅 서비스가 ① 채널 시퀀스를 비원자(TryGetValue+1 저장)로 발급하고(A), ② 전역 순서를 발신 서버 벽시계(OriginTsMs) 로 정렬하며(B), ③ 도착할 때마다 버퍼 전체를 즉시 팬아웃하고 비운다(C). 결과: 시계 차이로 순서 역전, 늦게 도착한(타임스탬프는 이른) 메시지는 이미 더 늦은 것을 보낸 뒤라 되돌릴 수 없다(정렬 무의미), 권위 seq 와 전달 순서가 불일치, 동시 Ingest 가 같은 seq/컬렉션 손상을 부른다. 수신자에는 구멍/중복 검출이 없다. 정답 한 줄: 순서는 벽시계가 아니라 채널별 단일 권위 시퀀서가 매기고, 그 seq 순서대로 전달하며, 수신자는 seq 기반 gap/dup 검출로 정합을 회복한다.


문제점

(B) 벽시계 기반 전역 정렬 — 분산 시간/순서 (정합) ★간판

  • 분류 태그: distributed ordering / clock skew.
  • 증상: 존 서버 A 시계가 B 보다 빠르면 B 의 더 이른 발화가 더 큰 OriginTsMs 를 받아 뒤로 정렬 → 수신자가 보는 순서가 실제 발화 순서와 어긋난다. NTP 도 ms 오차·역행 존재.
  • 근본 원인: 분산 노드의 물리 시계로 전역 순서를 정의. 물리 시계는 단조·동기 보장 없음.

(B)(C) "즉시 정렬 후 즉시 전송" — 순서 확정 불가 (정합) ★간판

  • 증상: Ingest 마다 Sort 후 전송·Clear. 이미 보낸 메시지는 회수 불가라, 다음 Ingest 에 더 이른 타임스탬프가 와도 앞 메시지가 이미 뒤에 전송됨 → 정렬이 무의미.
  • 근본 원인: 안정화 지연/워터마크 없이 도착 즉시 방출. 순서 확정 전에 전송.

(A) 비원자 seq 발급 + 컬렉션 경쟁 — 동시성 ★간판

  • 분류 태그: data race / lost update.
  • 증상: TryGetValue+1 저장 사이 다른 스레드가 끼면 같은 seq 발급·증가 유실. _pending/_membersByChannel(Dictionary) 동시 변경 → InvalidOperationException/손상.
  • 근본 원인: 동기화 부재 + seq 발급과 순서 결정 분리.

(C) 팬아웃-멤버십 경합 / 중복·구멍 무방비 — 정합

  • 증상: 멤버 리스트를 락 없이 순회 전송 → 가입/탈퇴와 경합. 수신자에 seq 검사가 없어 재전송 시 중복, 누락 시 조용한 구멍.

수정안

핵심: 채널별 단일 권위 시퀀서가 도착 순서로 seq 를 원자 발급 → seq 순서대로 전달 → 수신자 gap/dup 검출. 물리 시계는 표시용.

public class GlobalChatService
{
    private class Channel
    {
        public ulong NextSeq;
        public List<ulong> Members = new();
        public readonly object Gate = new();
    }
    private readonly Dictionary<ulong, Channel> _channels = new();
    private readonly object _mapGate = new();

    public void Ingest(ChatEvent ev)
    {
        Channel ch;
        lock (_mapGate)
        {
            if (!_channels.TryGetValue(ev.ChannelId, out ch))
            { ch = new Channel(); _channels[ev.ChannelId] = ch; }
        }
        ulong seq; List<ulong> members;
        lock (ch.Gate)
        {
            seq = ch.NextSeq++;            // 단일 권위 시퀀서: 도착 순서 = 전역 순서
            members = new List<ulong>(ch.Members);   // 스냅샷
        }
        // seq 가 전역 순서의 권위. 물리 전송은 스레드 인터리빙될 수 있으나, 수신자가
        // 항상 seq 오름차순으로 적용하므로 모든 수신자가 동일 순서를 본다.
        foreach (var m in members) SendToMember(m, ev, seq);
    }
    private void SendToMember(ulong m, ChatEvent ev, ulong seq) { }
}

수신자(존 서버) 측 계약:

- 채널별 lastSeq 유지. seq == lastSeq+1 이면 즉시 적용.
- seq <= lastSeq 면 중복 → 폐기(멱등).
- seq > lastSeq+1 이면 구멍 → 재요청 또는 재정렬 버퍼 보관 후 채워지면 방출.

포인트

  • 전역 순서를 채널별 단일 시퀀서(논리 카운터)로 정의 → 시계 무관, 단조 보장.
  • 권위 순서는 seq. 물리 전송이 인터리빙돼도 수신자가 seq 로 정렬하므로 송신측 재정렬 불필요.
  • 멤버 스냅샷으로 팬아웃-멤버십 경합 차단.

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

  1. 샤딩 시퀀서: 채널 id 로 시퀀서 분산하되 "한 채널 = 한 시퀀서" 불변 유지(파티션 키). 핫스팟 채널 → 해당 파티션 부하.
  2. 로그 기반 전파: 채널을 append-only 로그(offset=seq)로, 수신자는 커서로 catch-up. 구멍/중복 자연 해결. 저장/리텐션 비용.
  3. 논리 시계(HLC/Lamport): 다중 시퀀서 불가피 시 인과 순서 보존(전순서는 아님).
  4. at-least-once + 멱등 수신: seq/메시지ID dedup, gap 재요청.
  5. 시퀀서 failover: 영속 카운터/펜싱 토큰으로 seq 연속성 보장.

면접 포인트 (예상 질문)

  1. 분산에서 벽시계로 전역 순서를 정하면 안 되는 이유는? NTP 가 있어도 왜 부족한가?
  2. 단일 권위 시퀀서의 장단점(SPOF/핫스팟)과 샤딩 시 지켜야 할 불변은?
  3. at-least-once 전송에서 수신자가 순서·중복을 회복하는 방법(lastSeq, gap 재요청)은?
  4. 시퀀서 장애 복구 시 seq 연속성(중복·역행 방지)을 어떻게 보장하나?