13. Split-brain: 두 존 서버가 같은 플레이어를 동시에 활성화 — C#
난이도 최상해설 — Split-brain: 두 존 서버가 같은 플레이어를 동시에 활성화 — C#
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
분산 소유권에 펜싱 토큰(단조 epoch) 과 옛 소유자 무효화가 없어 split-brain 을 못 막는다.
(A) Activate 가 단조 epoch 없이 owner 를 덮어쓰기만 해서 재정렬된 늦은 옛 이전이 새
소유권을 회귀시킬 수 있고(누가 최신인지 판단 불가), (B) 명령 적용을 로컬 캐시 _cachedOwner
로 판정해서 핸드오프 후에도 옛 소유 서버 A 의 캐시가 여전히 "내가 주인" → A 가 in-flight
명령을 계속 적용(B 도 적용 → 동시 쓰기). C# 고유로, _cachedOwner[playerId] 인덱서는 키가
없으면 KeyNotFoundException 을 던져(C++ 의 묵시 삽입과 달리) 첫 명령에서 서버 스레드가
죽는다(가용성 사고). 정답 한 줄: 소유권은 단조 epoch lease 로 관리하고, 모든 권위 쓰기는
그 epoch 를 권위 저장소 기준으로 펜싱하며, 핸드오프는 '옛 소유자 동결/무효화 → 새 소유자
활성화' 순서를 지킨다.
문제점
(A) epoch 없는 소유권 덮어쓰기 — 재정렬 취약 / 펜싱 부재 (분산 정확성) ★간판
- 증상: 늦게 도착·재정렬된 옛
Activate(A)가 최신 소유자 B 를 덮어써 소유권이 과거로 회귀. 최신 판단 기준(단조값)이 없다. - 재현 조건: A→B 핸드오프 직후, 예전 경로의
Activate(player, A)가 지연 도착 →_owner[player] = A. 또는 동시 이중 핸드오프에서 마지막 writer 가 이긴다(보장 없음). - 근본 원인: 소유권 이전이 monotonic fencing token 없이 last-write-wins. 리더십 이전의 기본(오래된 리더 펜싱)이 빠졌다.
(B) 로컬 캐시로 소유권 판정 — 무효화 부재 → split-brain (분산 정확성) ★간판
- 증상: 핸드오프 후 A 가
_cachedOwner[player] == A를 보고 in-flight 명령을 계속 적용. B 도 적용 → 두 서버가 동시 쓰기(골드 이중 가산/아이템 복제/위치 충돌). - 재현 조건: A 가 큐에 쌓인 명령을 처리하는 동안 B 가 TakeOver. A 캐시 무효화 경로 없음.
- 근본 원인: 캐시 무효화/일관성 메커니즘 부재. 권위 판정을 권위 저장소가 아닌 로컬 스냅샷으로 하며, 그 스냅샷을 만료시키지 않는다. 게다가 (A) 때문에 저장소 값 자체도 신뢰 불가.
(B) 인덱서 미존재 키 — C# 고유 / 크래시 (가용성)
- 증상: 캐시에 없는 playerId 로
_cachedOwner[playerId]를 읽으면KeyNotFoundException. 소유하지 않은(=캐시에 없는) 플레이어의 명령이 오면 서버 스레드가 죽거나 세션이 끊긴다(DoS). (C++ 트윈은 0 을 묵시 삽입해 "서버0 소유" 로 오판 — 언어별로 증상이 다르나 둘 다 결함.) - 근본 원인: 읽기에 인덱서 사용 + 미소유 케이스를 정상 분기로 다루지 않음.
TryGetValue로 검증해야.
(A)+(B) 검사-적용 비원자 / TOCTOU (분산 동시성)
- 소유권 확인과 상태 적용이 분리돼, 확인 직후 핸드오프가 끼면 옛 소유자가 적용한다. 적용은 소유 epoch 를 조건으로 한 원자 쓰기여야 한다(조건부 쓰기/CAS).
(보조) 드레이닝/순서 부재
- 핸드오프는 "A in-flight 동결/드레이닝 → 상태 이관 → B 활성화" 순서여야 하나, B 가 곧장 TakeOver 하고 A 는 통지받지 않는다.
수정안
핵심: ① 소유권에 단조 epoch(fencing token), ② Activate 는 epoch 증가 + 교체, ③ 권위
쓰기는 보유 epoch 를 레지스트리와 대조해 원자 적용, ④ 인덱서 대신 TryGetValue, ⑤
핸드오프 순서 준수.
public struct OwnerRec { public int ServerId; public long Epoch; }
public class OwnershipRegistry // 실제로는 원자 CAS 분산 KV(Redis Lua/etcd 등)
{
private readonly object _gate = new object();
private readonly Dictionary<long, OwnerRec> _owner = new Dictionary<long, OwnerRec>();
// 소유권 이전: epoch 단조 증가 + 새 토큰 반환(펜싱 토큰)
public long Activate(long playerId, int serverId)
{
lock (_gate)
{
_owner.TryGetValue(playerId, out var rec);
rec.Epoch += 1; // 늦은 옛 이전을 항상 패배시킴
rec.ServerId = serverId;
_owner[playerId] = rec;
return rec.Epoch;
}
}
// 권위 쓰기 펜싱: 호출자 epoch 가 현재와 일치할 때만 통과
public bool Validate(long playerId, int serverId, long epoch)
{
lock (_gate)
return _owner.TryGetValue(playerId, out var r)
&& r.ServerId == serverId && r.Epoch == epoch;
}
}
public class ZoneServer
{
private readonly int _myId;
private readonly OwnershipRegistry _reg;
private readonly Dictionary<long, long> _myEpoch = new Dictionary<long, long>();
public ZoneServer(int myId, OwnershipRegistry reg) { _myId = myId; _reg = reg; }
public void TakeOver(long playerId)
{
long token = _reg.Activate(playerId, _myId); // 새 epoch 획득
_myEpoch[playerId] = token;
// (이전 단계에서 A 의 상태를 직렬화 받아 적재했다고 가정)
}
public bool ApplyCommand(long playerId, Command c, PlayerState st)
{
if (!_myEpoch.TryGetValue(playerId, out long ep)) return false; // 인덱서 X
if (!_reg.Validate(playerId, _myId, ep)) return false; // 펜싱
st.Gold += c.GoldDelta;
return true;
}
}
Validate와 적용을 분리하면 그 사이 핸드오프가 끼는 잔여 TOCTOU 가 있으므로, 실전에서는 상태를 소유권과 같은 저장소에 두고 "epoch 일치 시에만 갱신"하는 조건부 쓰기(Redis Lua, etcd txn, DBWHERE epoch=?)로 검증·적용을 한 원자 연산으로 묶는다. 펜싱 토큰은 분산 락(이 카탈로그 concurrency p12)과 같은 원리이며, 여기서는 "플레이어 소유권 이전"이라는 서버-서버 핸드오프 맥락에 적용된 점이 핵심.
핸드오프 순서: A freeze 통지 → A 드레이닝/이후 거부 → 상태 직렬화 이관 → B TakeOver(새 epoch) → 라우팅 전환. A 의 늦은 명령은 epoch 불일치로 자동 펜싱.
더 나은 설계
1) 단일 라이터 + 펜싱 토큰
- "정확히 하나의 라이터"는 lease + monotonic fencing token 으로(etcd/ZooKeeper/Chubby 패턴). 늦은 옛 리더 쓰기는 토큰으로 거부. 이 문제의 정답 골격.
2) 상태·소유권 공동 저장 + 조건부 쓰기
- 권위 상태를 소유권 epoch 와 같은 트랜잭션 경계에 두고
update ... where epoch=?로만 변경. 검증/적용 분리 TOCTOU 제거. 트레이드오프: 쓰기마다 조건부 연산 비용 vs 정확성.
3) 드레이닝 핸드오프 프로토콜
- A: freeze → drain → snapshot → handoff(B). 이관 완료 전 B 는 권위 쓰기 금지. graceful migration(이 카탈로그 session p10) 과 연계.
4) 멱등 명령 + 시퀀스
- 명령에 (ownerEpoch, seq) 를 실어 재전송/재정렬 중복 적용 방지. 옛 epoch 명령은 드롭.
5) 시계 비의존
- lease 만료를 벽시계 절대시각이 아니라 단조 시계/epoch 로 판단(서버 간 시계 편차에 강함).
면접 포인트
- 핵심: 분산 소유권/리더십 이전의 정답은 **펜싱 토큰(단조 epoch) + 옛 소유자 무효화/드레이닝
- epoch 조건부 원자 쓰기**. "로컬 캐시 권위 판정" 과 "epoch 없는 last-write-wins" 가 split-brain 의 전형.
- 예상 질문:
- "두 서버 동시 쓰기 인터리빙은?" → B TakeOver 후에도 A 캐시 유효 + A in-flight 적용.
- "펜싱 토큰이 왜 필요?" → 늦은 옛 리더 쓰기 거부에 '최신 리더'를 단조값으로 판단해야. last-write-wins 는 재정렬에 무력.
- "C# 인덱서와 C++ operator[] 차이가 이 버그에 어떻게 다르게 나타나나?" → C# 은
KeyNotFoundException(크래시), C++ 은 0 묵시 삽입(서버0 오판). 둘 다 결함.
해설 — Split-brain: 두 존 서버가 같은 플레이어를 동시에 활성화 — C++
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
이 설계는 분산 소유권에서 펜싱 토큰(단조 증가 epoch) 과 옛 소유자 무효화가 전혀 없어
split-brain 을 막지 못한다. 핵심은 (A) Activate 가 단조 epoch 없이 owner 를 덮어쓰기만
해서, 재정렬로 늦게 도착한 옛 이전이 새 소유권을 되돌릴 수 있고(누가 최신인지 판단 불가),
(B) 명령 적용이 로컬 캐시 cachedOwner_ 로만 소유권을 판정해서, 핸드오프 후에도 옛 소유
서버 A 의 캐시는 여전히 "내가 주인" 이라 in-flight 명령을 계속 적용한다(B 도 적용 → 동시
쓰기). 게다가 cachedOwner_[playerId] 는 operator[] 라 키가 없으면 0 을 삽입 —
serverId 0 서버는 아무 플레이어나 소유한 것으로 오판한다. 정답 한 줄: 소유권은 단조 epoch
를 가진 lease 로 관리하고, 모든 권위 쓰기는 그 epoch 를 권위 저장소 기준으로 펜싱한다. 핸드오프는
'옛 소유자 드레이닝/무효화 → 새 소유자 활성화'의 순서를 지킨다.
문제점
(A) epoch 없는 소유권 덮어쓰기 — 재정렬 취약 / 펜싱 부재 (분산 정확성) ★간판
- 증상: 늦게 도착하거나 재정렬된 옛
Activate(A)가 최신 소유자 B 를 덮어써 소유권이 과거로 회귀한다. 누가 최신인지 판단할 단조 기준이 없다. - 재현 조건: A→B 핸드오프 직후, 네트워크 지연으로 예전 경로의
Activate(player, A)가 뒤늦게 레지스트리에 도착 →owner_[player] = A로 되돌림. 또는 두 서버가 거의 동시에 TakeOver 를 호출(이중 핸드오프)할 때 마지막 writer 가 이긴다(정확성 보장 없음). - 근본 원인: 소유권 이전이 monotonic fencing token(단조 증가 epoch/term) 없이 단순 last-write-wins. 분산 합의/리더십 이전의 기본(오래된 리더의 쓰기를 펜싱)이 빠졌다.
(B) 로컬 캐시로 소유권 판정 — 무효화 부재 → split-brain (분산 정확성) ★간판
- 증상: 핸드오프 후에도 옛 소유 서버 A 가
cachedOwner_[player] == A를 보고 in-flight 명령을 계속 적용한다. 동시에 B 도 적용 → 두 서버가 같은 플레이어에 동시 쓰기(골드 이중 가산/아이템 복제/위치 충돌). - 재현 조건: A 가 명령 큐에 쌓인 명령을 처리하는 동안 B 가 TakeOver. A 의 캐시는 무효화되지 않으므로 A 의 남은 명령이 그대로 적용된다.
- 근본 원인: 캐시 일관성/무효화 메커니즘이 없다. 권위 판정을 권위 저장소가 아니라 로컬 스냅샷으로 하면서, 그 스냅샷을 갱신/만료시키는 경로가 없다. 게다가 권위 저장소를 본다 해도 (A) 때문에 그 값 자체가 신뢰 불가.
(B) cachedOwner_[playerId] 묵시 삽입 — C++ 고유 / 오판 (정확성)
- 증상: 캐시에 없는 playerId 를
operator[]로 읽으면 0 을 삽입.myId_ == 0인 서버는 이 비교가 참이 되어 소유하지도 않은 플레이어의 명령을 적용한다. - 근본 원인: 읽기에
operator[]사용(묵시 삽입). 0 이 "미소유" 와 "서버0 소유" 모두를 의미하는 센티넬 충돌.
(A)+(B) 검사-적용 비원자 / TOCTOU (분산 동시성)
- 소유권 확인과 상태 적용이 분리돼, 확인 직후 핸드오프가 끼면 옛 소유자가 "확인 통과 후" 적용한다. 적용은 소유 epoch 를 조건으로 한 원자적 쓰기여야 한다(조건부 쓰기/CAS).
(보조) 드레이닝/순서 부재
- 핸드오프는 "A 의 in-flight 드레이닝·동결 → 상태 직렬화 이관 → B 활성화" 순서를 지켜야 하는데, 여기서는 B 가 곧장 TakeOver 하고 A 는 아무 통지도 받지 않는다.
수정안
핵심: ① 소유권에 단조 epoch(fencing token) 부여, ② Activate 는 epoch 증가 + 조건부
교체, ③ 모든 권위 쓰기는 보유 epoch 를 레지스트리 현재 epoch 와 대조해 원자적으로 적용,
④ operator[] 대신 find, ⑤ 핸드오프는 옛 소유자 동결 → 이관 → 활성화 순서.
#include <mutex>
struct OwnerRec { int serverId = -1; uint64_t epoch = 0; };
class OwnershipRegistry { // 실제로는 원자 CAS 가능한 분산 KV(Redis Lua/etcd 등)
public:
// 소유권 이전: epoch 를 단조 증가시키고 새 토큰을 반환(펜싱 토큰).
uint64_t Activate(int64_t playerId, int serverId) {
std::lock_guard<std::mutex> lk(mtx_);
auto& rec = owner_[playerId];
rec.epoch += 1; // 단조 증가 → 늦은 옛 이전을 항상 패배시킴
rec.serverId = serverId;
return rec.epoch; // 이 토큰을 가진 자만 이 epoch 동안 권위자
}
// 권위 쓰기 펜싱: 호출자의 epoch 가 현재와 일치할 때만 통과.
bool Validate(int64_t playerId, int serverId, uint64_t epoch) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = owner_.find(playerId);
return it != owner_.end() &&
it->second.serverId == serverId && it->second.epoch == epoch;
}
private:
std::mutex mtx_;
std::unordered_map<int64_t, OwnerRec> owner_;
};
class ZoneServer {
public:
ZoneServer(int myId, OwnershipRegistry& reg) : myId_(myId), reg_(reg) {}
void TakeOver(int64_t playerId) {
uint64_t token = reg_.Activate(playerId, myId_); // 새 epoch 획득
myEpoch_[playerId] = token; // 내 펜싱 토큰 보관
// (이전 단계에서 A 의 상태를 직렬화 받아 적재했다고 가정)
}
bool ApplyCommand(int64_t playerId, const Command& c, PlayerState& st) {
auto it = myEpoch_.find(playerId);
if (it == myEpoch_.end()) return false; // 묵시 삽입 방지
// 권위 저장소 기준 펜싱: 내 epoch 가 최신일 때만 적용(원자 조건부 쓰기 권장)
if (!reg_.Validate(playerId, myId_, it->second)) return false;
st.gold += c.goldDelta;
return true;
}
private:
int myId_;
OwnershipRegistry& reg_;
std::unordered_map<int64_t, uint64_t> myEpoch_;
};
실전에서는
Validate+ 적용을 별개로 두면 그 사이에 핸드오프가 끼는 잔여 TOCTOU 가 있으므로, 상태를 소유권과 같은 저장소에 두고 "epoch 일치 시에만 갱신"하는 조건부 쓰기 (Redis Lua, etcd txn, DBWHERE epoch=?)로 검증과 적용을 하나의 원자 연산으로 묶는 것이 정석이다. 펜싱 토큰은 분산 락(이 카탈로그 concurrency p12)과 같은 원리지만, 여기서는 "플레이어 소유권 이전"이라는 서버-서버 핸드오프 맥락에 적용된 점이 핵심이다.
핸드오프 순서: (1) A 에 freeze 통지 → A 가 in-flight 드레이닝·이후 거부 → (2) 최종 상태 직렬화 이관 → (3) B 가 TakeOver(새 epoch) → (4) 라우팅 전환. A 의 늦은 명령은 epoch 불일치로 자동 펜싱된다.
더 나은 설계
1) 단일 라이터 원칙 + 펜싱 토큰
- 분산에서 "정확히 하나의 라이터"는 lease + monotonic fencing token 으로 보장 (Chubby/etcd/ZooKeeper 패턴). 늦은 옛 리더의 쓰기는 토큰으로 거부. 이 문제의 정답 골격.
2) 상태와 소유권의 공동 저장 + 조건부 쓰기
- 권위 상태를 소유권 epoch 와 같은 트랜잭션 경계에 두고
update ... where epoch=?로만 변경. 검증/적용 분리로 생기는 TOCTOU 를 원천 제거. 트레이드오프: 쓰기마다 조건부 연산 비용 vs 정확성.
3) 드레이닝 핸드오프 프로토콜
- A: freeze → drain(in-flight 완료/취소) → snapshot → handoff(B). 이관 완료 전엔 B 가 권위 쓰기를 하지 않는다. graceful migration(이 카탈로그 session p10) 과 연계.
4) 멱등 명령 + 시퀀스
- 명령에 (ownerEpoch, seq) 를 실어 재전송/재정렬에도 중복 적용을 방지. 옛 epoch 명령은 드롭.
5) 시계 비의존
- lease 만료를 벽시계 절대시각이 아니라 단조 시계 / epoch 카운터로 판단해 서버 간 시계 편차(이 카탈로그 13.서버간 시계 차이)에 강하게.
면접 포인트
- 핵심: 분산 소유권/리더십 이전의 정답은 펜싱 토큰(단조 epoch) + 옛 소유자 무효화/ 드레이닝 + 상태와 epoch 의 조건부 원자 쓰기. "로컬 캐시로 권위 판정" 과 "epoch 없는 last-write-wins" 가 split-brain 의 전형적 원인.
- 예상 질문:
- "두 서버가 동시에 쓰는 정확한 인터리빙은?" → B TakeOver 후에도 A 의 캐시가 유효 + A in-flight 명령 적용. 무효화/펜싱이 없어서.
- "epoch(펜싱 토큰)가 왜 필요한가?" → 늦은 옛 소유자의 쓰기를 거부하려면 '누가 최신 리더인가'를 단조값으로 판단해야. last-write-wins 는 재정렬에 무력.
- "검증 후 적용 사이에 또 핸드오프가 끼면?" → 검증·적용을 분리하면 TOCTOU. 상태를 소유권과 같이 두고 epoch 조건부 쓰기로 원자화.
- "벽시계로 lease 만료를 보면?" → 서버 간 시계 편차로 옛 리더가 자기를 유효로 오판. 단조 시계/epoch 로.