18. 채팅/길드 같은 글로벌 서비스의 이벤트 순서 보장 (서버-서버, 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 로 정렬하므로 송신측 재정렬 불필요.
- 멤버 스냅샷으로 팬아웃-멤버십 경합 차단.
더 나은 설계 (+트레이드오프)
- 샤딩 시퀀서: 채널 id 로 시퀀서 분산하되 "한 채널 = 한 시퀀서" 불변 유지(파티션 키). 핫스팟 채널 → 해당 파티션 부하.
- 로그 기반 전파: 채널을 append-only 로그(offset=seq)로, 수신자는 커서로 catch-up. 구멍/중복 자연 해결. 저장/리텐션 비용.
- 논리 시계(HLC/Lamport): 다중 시퀀서 불가피 시 인과 순서 보존(전순서는 아님).
- at-least-once + 멱등 수신: seq/메시지ID dedup, gap 재요청.
- 시퀀서 failover: 영속 카운터/펜싱 토큰으로 seq 연속성 보장.
면접 포인트 (예상 질문)
- 분산에서 벽시계로 전역 순서를 정하면 안 되는 이유는? NTP 가 있어도 왜 부족한가?
- 단일 권위 시퀀서의 장단점(SPOF/핫스팟)과 샤딩 시 지켜야 할 불변은?
- at-least-once 전송에서 수신자가 순서·중복을 회복하는 방법(lastSeq, gap 재요청)은?
- 시퀀서 장애 복구 시 seq 연속성(중복·역행 방지)을 어떻게 보장하나?
해설 — 채팅/길드 같은 글로벌 서비스의 이벤트 순서 보장 (서버-서버, C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
글로벌 채팅 서비스가 ① 채널 시퀀스를 비원자(seqByChannel_[ch]++)로 발급하고(A),
② 전역 순서를 발신 서버의 벽시계(originTsMs) 로 정렬하며(B), ③ 도착할 때마다 버퍼
전체를 즉시 팬아웃하고 비운다(C). 결과: 시계 차이가 있는 두 존 서버의 메시지가 순서
역전되고, 늦게 도착한(타임스탬프는 더 이른) 메시지는 이미 더 늦은 메시지를 보낸 뒤라
되돌릴 수 없다(정렬이 무의미). 권위 seq 와 실제 전달 순서가 불일치하고, 동시 Ingest
는 같은 seq 발급/맵 손상(데이터 경쟁)을 부른다. 수신자에는 구멍/중복 검출이 없다. 정답
한 줄: 순서는 벽시계가 아니라 채널별 단일 권위 시퀀서가 매기고, 그 seq 순서대로 전달하며,
수신자는 seq 기반 gap/dup 검출로 정합을 회복한다.
문제점
(B) 벽시계 기반 전역 정렬 — 분산 시간/순서 (정합) ★간판
- 분류 태그: distributed ordering / clock skew.
- 증상: 존 서버 A 시계가 B 보다 200ms 빠르면, B 에서 먼저 발화한 메시지가 더 큰
originTsMs를 받아 A 의 나중 메시지보다 뒤로 정렬된다 → 수신자가 보는 순서가 인과(실제 발화 순서)와 어긋난다. NTP 동기화도 ms 단위 오차·역행이 있어 신뢰 불가. - 재현조건: 서로 다른 존 서버에서 근접 시각에 발화 + 시계 오차.
- 근본 원인: 분산 노드의 물리 시계로 전역 순서를 정의했다. 물리 시계는 단조·동기 보장이 없다.
(B)(C) "즉시 정렬 후 즉시 전송" — 순서 확정 불가 (정합) ★간판
- 증상: Ingest 마다 buf 를 정렬·전송·
clear(). 그러나 이미 보낸 메시지는 회수 불가다. 다음 Ingest 로 더 이른 타임스탬프 메시지가 들어와도 앞 메시지는 이미 더 뒤에 보낸 뒤 → 정렬이 실질적 효과가 없다. 버퍼는 단발성이라 "정렬 윈도우" 역할을 못 한다. - 근본 원인: 재정렬에 필요한 "안정화 지연/워터마크" 없이 도착 즉시 방출. 전역 순서를 확정하기 전에 전송해버린다.
(A) 비원자 seq 발급 + 맵 데이터 경쟁 — 동시성 (UB) ★간판
- 분류 태그: data race / lost update.
- 증상:
seqByChannel_[ch]++는 읽기-수정-쓰기. 동시 Ingest 두 건이 같은 seq 를 받거나 증가가 유실된다.pending_/membersByChannel_(unordered_map) 동시 삽입은 rehash 중 UB·손상. 발급한 seq 와 전송 순서도 무관(타임스탬프 정렬이므로). - 근본 원인: 동기화 부재 + seq 발급과 순서 결정이 분리.
(C) 팬아웃과 멤버십 변경 경합 / 중복·구멍 무방비 — 정합
- 증상:
membersByChannel_를 락 없이 순회하며 전송 → 가입/탈퇴와 경합. 수신자 측에는 seq 검사가 없어 재전송 시 중복, 누락 시 조용한 구멍을 못 잡는다. - 근본 원인: at-least-once 전송에 대한 멱등/순서 검출 계약이 없다.
수정안
핵심: 채널별 단일 권위 시퀀서가 도착 순서로 seq 를 원자 발급 → 그 seq 순서대로만 전달 → 수신자는 seq 로 gap/dup 검출. 물리 시계는 표시용일 뿐 순서 근거가 아니다.
class GlobalChatService {
public:
void Ingest(const ChatEvent& ev) {
Channel& ch = GetChannel(ev.channelId);
std::uint64_t seq;
std::vector<std::uint64_t> members;
{
std::lock_guard<std::mutex> g(ch.mtx);
seq = ch.nextSeq++; // 단일 권위 시퀀서: 도착 순서 = 전역 순서
members = ch.members; // 스냅샷
}
// seq 가 전역 순서의 "권위" 다. 스레드별 물리 전송 순서는 인터리빙될 수 있으나,
// 수신자는 항상 seq 오름차순으로 적용하므로 모든 수신자가 동일 순서를 본다.
for (auto m : members) SendToMember(m, ev, seq);
}
private:
struct Channel {
std::uint64_t nextSeq = 0;
std::vector<std::uint64_t> members;
std::mutex mtx;
};
Channel& GetChannel(std::uint64_t id) {
std::lock_guard<std::mutex> g(mapMtx_);
return channels_[id]; // 안정 주소 보장 위해 node 기반/포인터 권장
}
std::mutex mapMtx_;
std::unordered_map<std::uint64_t, Channel> channels_;
void SendToMember(std::uint64_t, const ChatEvent&, std::uint64_t) {}
};
수신자(존 서버) 측 계약:
- 채널별 lastSeq 유지. 도착 seq == lastSeq+1 이면 즉시 적용·표시.
- seq <= lastSeq 면 중복 → 폐기(멱등).
- seq > lastSeq+1 이면 구멍 → 재요청(요청 범위) 또는 재정렬 버퍼에 보관 후 채워지면 방출.
포인트
- 전역 순서를 채널별 단일 시퀀서(논리적 카운터)로 정의 → 시계 무관, 단조 보장.
- 권위 순서는 seq. 물리 전송은 인터리빙돼도 수신자가 seq 로 정렬 적용하므로 "정렬 후 즉시 비움" 같은 송신측 재정렬은 불필요(무의미).
unordered_map<Channel>는 rehash 시 참조 무효 → 노드 기반(map) 또는unordered_map<id, unique_ptr<Channel>>로 주소 안정성 확보.
더 나은 설계 (+트레이드오프)
- 샤딩된 채널 시퀀서: 채널 id 해시로 시퀀서를 분산하되 "한 채널 = 한 시퀀서" 불변을 유지(같은 채널은 항상 같은 노드/파티션). 카프카 파티션 키, 또는 일관 해싱. 트레이드오프: 채널 핫스팟 → 그 파티션 부하 집중.
- 로그 기반 전파(append-only): 채널을 정렬 로그(offset=seq)로 모델링하고 수신자는 오프셋 커서로 따라잡기(catch-up)/재전송. 구멍·중복이 자연 해결. 트레이드오프: 저장/리텐션.
- 논리 시계(Lamport/HLC): 다중 시퀀서가 불가피하면 HLC 로 인과 순서를 보존(물리시계 보정 + 논리 카운터). 단일 시퀀서보다 약한 보장(전순서 아님). 트레이드오프: 복잡, 동률 처리.
- at-least-once + 멱등 수신: seq/메시지ID 로 dedup, gap 재요청. exactly-once 환상 대신 "최소 한 번 전송 + 멱등 적용" 으로 단순·견고하게.
- 시퀀서 장애 복구: 시퀀서 failover 시 seq 연속성(영속 카운터/펜싱 토큰)으로 중복 발급·역행 방지(분산 락 문제와 연결).
면접 포인트 (예상 질문)
- 왜 분산 환경에서 벽시계로 전역 순서를 정하면 안 되는가? NTP 가 있어도 안 되는 이유는?
- "단일 권위 시퀀서" 의 장점과 단점(SPOF·핫스팟)은? 샤딩 시 무엇을 불변으로 지켜야 하나?
- at-least-once 전송에서 수신자가 순서·중복을 어떻게 회복하나(lastSeq, gap 재요청)?
- 시퀀서가 죽고 새 시퀀서가 뜰 때 seq 연속성을 어떻게 보장하나(펜싱/영속 카운터)?