34. 공유 사전 압축 프로토콜의 DictAdd 유실·순서 역전 (C#)
난이도 상해설 — 공유 사전 압축 프로토콜의 DictAdd 유실·순서 역전 (C#)
난이도: 상
요약
클라이언트는 (A)에서 DictAdd 통지를 받는 즉시 무조건 _entries[index] = value 로 덮어써 반영하고, (B)의 Resolve 는 "인덱스가 존재하는지"만 확인할 뿐 "그 인덱스가 지금 이 DictRef 가 기대하는 항목과 같은 버전인지"는 전혀 검증하지 않는다. DictAdd가 신뢰할 수 없는 경로로 전달되어 유실되거나 다른 큐의 DictRef보다 늦게 도착할 수 있는 상황에서, 클라이언트 사전은 서버 사전과 조용히 어긋나고(desync) 같은 인덱스가 실제로는 다른 문자열을 가리키는데도 감지되지 않은 채 엉뚱한 문자열로 해석된다.
문제점
(A) DictAdd 유실 시 감지·복구 메커니즘 부재
- 증상: 서버가 인덱스 7에 "긴급 공지: 서버 점검"을 등록했지만 그
DictAdd패킷이 유실되면, 클라의_entries에는 인덱스 7이 아예 없다. 이후 다른 문자열이 인덱스 7을 재사용(사전이 순환/재사용 정책을 가질 경우)하거나, 그냥[UNKNOWN]으로 표시되어 사용자에게 아무 정보도 전달되지 않는다. 더 심각한 경우는 유실된 인덱스가 나중에 "다른"DictAdd로 같은 번호를 다시 쓰게 되면(사전 슬롯 재활용), 클라가 그 뒤늦은 통지를 마치 최초 등록인 것처럼 받아들여 이후 조용히 잘못된 매핑으로 굳어진다. - 재현 조건:
- 서버가 인덱스 7 = "A"를
DictAdd로 전송하지만 네트워크 구간에서 유실. - 서버는 성공적으로 전송됐다고 가정하고
DictRef(7)을 여러 번 전송. - 클라는 인덱스 7이 없으므로 매번
[UNKNOWN]을 반환 — 사용자에게 의미 있는 메시지가 전혀 전달되지 않는다. - 이후 서버가 사전 슬롯을 재사용하는 정책이라면(예: LRU 축출 후 인덱스 7을 "B"로 재할당), 클라는 뒤늦게 도착한 이
DictAdd를 정상 갱신으로 착각해 받아들이고, 과거에 캐시했을 수도 있는 문맥과 뒤섞여 완전히 다른 문자열을 노출한다.
- 서버가 인덱스 7 = "A"를
- 근본 원인: (A)가 "받은 것을 그대로 신뢰해 반영"하는 fire-and-forget 구조라 유실을 감지할 방법이 없다. 사전 갱신에 신뢰성 있는 전달(ack/재전송)이나 버전 확인 절차가 없다.
(B) Resolve 가 값의 "버전"을 확인하지 않음
- 증상:
DictRef메시지에 서버가 그 문자열을 등록한 시점의 버전(또는 그 문자열 자체의 체크섬)이 함께 실리지 않으므로, 클라는 자신이 들고 있는 인덱스 7의 값이 서버가 지금 이DictRef를 만들 때 참조한 그 값과 같은지 검증할 방법이 없다. 사전이 한 번이라도 어긋나면 이후 영구히 조용히 잘못된 문자열을 노출한다. - 재현 조건: 위 시나리오에서 클라가 우연히 다른 경로로 인덱스 7에 "C"라는 값을 먼저 채워놓은 상태(예: 순서 역전으로 더 나중에 등록된 DictAdd가 먼저 도착)라면,
Resolve(7)은 아무 오류 없이 "C"를 반환하지만 서버가 실제로 의도한 것은 "A"다. - 근본 원인: 인덱스만으로 값의 동일성을 보장하려면 등록·참조 양쪽에 같은 버전 식별자가 있어야 하는데 프로토콜에 그 필드가 없다.
수정안 (정확한 코드)
DictRef에 그 문자열의 짧은 검증 토큰(버전/체크섬)을 함께 실어, 클라가 자신의 사전 항목과 일치하는지 검증하고 불일치 시 안전하게 재동기화를 요청하도록 한다.
using System.Collections.Generic;
public sealed class DictEntry
{
public string Value;
public uint Version; // 서버가 이 문자열을 등록할 때 부여한 버전(단조 증가 또는 해시)
}
public sealed class ClientDictionary
{
private readonly Dictionary<int, DictEntry> _entries = new Dictionary<int, DictEntry>();
// 재동기화가 필요할 때 서버에 알리기 위한 콜백(전송 계층에 위임)
public System.Action<int> RequestResync;
public void OnDictAdd(int index, string value, uint version)
{
// 더 오래된(순서가 뒤바뀐) 통지는 무시 — 최신 버전만 반영
if (_entries.TryGetValue(index, out var existing) && existing.Version >= version)
return;
_entries[index] = new DictEntry { Value = value, Version = version };
}
// DictRef 에도 서버가 참조 시점에 알고 있던 버전을 함께 보낸다.
public string Resolve(int index, uint expectedVersion)
{
if (_entries.TryGetValue(index, out var entry) && entry.Version == expectedVersion)
return entry.Value;
// 인덱스가 없거나 버전이 다르면(=desync) 절대 엉뚱한 값을 노출하지 않고
// 재동기화를 요청한다.
RequestResync?.Invoke(index);
return "[SYNCING]";
}
}
public sealed class ServerDictionary
{
private readonly Dictionary<string, (int index, uint version)> _stringToEntry
= new Dictionary<string, (int, uint)>();
private readonly List<string> _indexToString = new List<string>();
private uint _nextVersion = 1;
public (int index, uint version, bool isNew) Intern(string value)
{
if (_stringToEntry.TryGetValue(value, out var existing))
return (existing.index, existing.version, false);
int newIndex = _indexToString.Count;
uint version = _nextVersion++;
_indexToString.Add(value);
_stringToEntry[value] = (newIndex, version);
return (newIndex, version, true);
}
// 클라의 재동기화 요청에 응답: 해당 인덱스의 현재 값을 다시 신뢰성 채널로 재전송한다.
public bool TryGetForResync(int index, out string value, out uint version)
{
if (index >= 0 && index < _indexToString.Count)
{
value = _indexToString[index];
version = _stringToEntry[value].version;
return true;
}
value = null;
version = 0;
return false;
}
}
핵심 변경:
DictAdd/DictRef양쪽에version을 실어, 클라가 "이 인덱스가 지금 내가 아는 것과 같은 등록인지"를 검증할 수 있게 했다.- 버전 불일치나 미등록 인덱스를 만나면 절대 추측하지 않고
RequestResync로 서버에 재전송을 요청한다 — "모르면 안전하게 실패"하는 원칙. OnDictAdd는 더 낮은 버전의 뒤늦은 통지(순서 역전)를 무시해 과거 상태로 되돌아가지 않게 한다.
더 나은 설계 (+ 트레이드오프)
- DictAdd를 신뢰성 채널(ack+재전송)로만 전송: 애초에 유실이 없도록 사전 갱신 메시지는 반드시 순서 보장·신뢰성 채널로 보내고,
DictRef만 비신뢰 채널을 써도 되게 분리한다. 다만 채널을 분리하면 여전히 두 채널 간 도착 순서는 보장되지 않으므로 버전 검증은 그대로 필요하다. - 사전 슬롯 재사용 금지(또는 세대 구분): 인덱스를 재활용하지 않거나, 재활용 시 세대(generation) 번호를 인덱스에 포함시켜 오래된 세대의 참조가 새 세대 값으로 절대 오인되지 않게 한다.
- 주기적 풀 스냅샷 동기화: 델타(DictAdd)만으로 영구히 정합성을 유지하기보다, 주기적으로 "현재 사전 전체"를 체크섬과 함께 보내 클라가 스스로 어긋남을 감지·복구하게 한다.
- 트레이드오프: 버전/체크섬을 매 참조에 실으면 패킷 크기가 약간 늘고(원래 목적인 대역폭 절약을 일부 상쇄), 재동기화 왕복 지연 동안 해당 문자열은
[SYNCING]으로 잠깐 노출된다. 하지만 엉뚱한 문자열이 영구히 노출되는 것보다는 훨씬 안전하다.
면접 포인트
- "인덱스로 참조하는 압축 사전"은 송수신 양측의 상태가 암묵적으로 동기화되어 있다는 가정에 의존한다 — 신뢰할 수 없는 전송 위에서는 그 가정이 깨질 수 있으므로 반드시 검증 수단(버전/체크섬)이 필요하다.
- 유실은 "감지하지 못하면 존재하지 않는 것"이 아니라 "조용히 잘못된 상태로 이어지는 것"이다 — fire-and-forget 갱신은 항상 desync 위험을 내포한다.
- 신뢰할 수 없는 채널 위의 상태 동기화 문제는 로컬 스레드 동시성 문제와 본질이 같다(공유 상태 + 비원자적 갱신) — 여기선 스레드 대신 네트워크 지연/유실이 인터리빙을 만든다.
해설 — 공유 사전 압축 프로토콜, 인덱스 연속 가정과 범위 밖 접근 (C++)
난이도: 최상
요약
RelayDictionary는 사전 인덱스가 "항상 0부터 빈틈없이 연속으로 채워진다"는 가정 하에 std::vector<std::string>을 배열처럼 사용한다. (A)에서 DictAdd가 유실돼 번호가 하나 건너뛰면, 뒤이어 더 큰 인덱스의 DictAdd가 먼저 도착해도 index == entries_.size()도 index < entries_.size()도 아니므로 조용히 무시되고, 그 인덱스는 이후 유실분이 뒤늦게 채워질 때까지(혹은 영원히) entries_에 구멍으로 남는다. 이후 (B) Resolve가 이 인덱스를 그대로 첨자로 사용해 operator[]에 접근하면 이는 std::vector의 범위를 벗어난 정의되지 않은 동작(UB) — 임의 메모리 읽기 또는 크래시로 이어진다. 순서 역전이 있어도 push_back은 항상 index == entries_.size()일 때만 실행되므로 문자열이 엉뚱한 인덱스에 들어가는 것은 아니지만, 유실된 낮은 인덱스가 뒤늦게 도착해 그 자리를 채우기 전까지 상위 인덱스들이 통째로 무시되면서 릴레이가 실제로는 등록된 여러 문자열을 계속 놓치는 구간이 생긴다는 점에서 위험은 동일하다.
문제점
(A) 인덱스 연속성 가정과 유실 시 "구멍"을 방치
- 증상: 인덱스 3의
DictAdd가 유실된 채 인덱스 4의DictAdd가 도착하면,index(4) != entries_.size()(3)이고index(4) < entries_.size()(3)도 거짓이라 두 조건 모두 해당하지 않아 아무 것도 하지 않고 조용히 무시된다. 인덱스 4는 영구히 등록되지 않는다. - 재현 조건:
- 인덱스 0,1,2가 정상 등록되어
entries_.size() == 3. - 인덱스 3의
DictAdd가 관리 채널 혼잡으로 유실. - 인덱스 4의
DictAdd가 먼저 도착 —4 != 3이므로 push도, 대입도 되지 않고 버려짐. - 이후 인덱스 3의
DictAdd가 뒤늦게 도착해도3 == entries_.size()(3)이라 push되어 인덱스 3에는 값이 들어가지만, 인덱스 4는 여전히 비어 있다.
- 인덱스 0,1,2가 정상 등록되어
- 근본 원인: 배열 첨자를 사전 키로 그대로 쓰면서 "키가 항상 순서대로, 빠짐없이 온다"는, 신뢰할 수 없는 전송 계층에서는 보장되지 않는 가정을 코드가 암묵적으로 깔고 있다.
(B) 존재하지 않는 인덱스에 대한 operator[] 범위 밖 접근 (UB)
- 증상: 위 시나리오에서 릴레이가 인덱스 4를 참조하는
DictRef를 처리하려고Resolve(4)를 호출하면,entries_.size()가 4(인덱스 0~3만 존재)인 상태에서entries_[4]에 접근 —std::vector::operator[]는 범위 검사를 하지 않으므로 이는 UB다. 릴리스 빌드에서는 크래시 없이 힙의 인접 메모리를 문자열로 잘못 해석해 반환할 수도 있어(정보 유출/오염 가능성)at()의 예외보다 훨씬 위험하다. - 재현 조건: 위 (A) 시나리오에 이어, 릴레이가 인덱스 4에 대한
DictRef를 처리. - 근본 원인:
Resolve가 인덱스 유효성을 검사하지 않고 신뢰할 수 없는 입력(네트워크에서 유실될 수 있는 사전 상태에 기반한 인덱스)을 그대로 컨테이너 첨자로 사용한다.
수정안 (정확한 코드)
사전을 "인덱스는 연속"이라는 가정에서 분리하고(unordered_map 사용), 버전(등록 시점의 문자열 자체와 매칭되는 토큰)을 참조 측에도 실어 유실·순서 역전을 감지·안전 처리한다.
#include <string>
#include <unordered_map>
#include <cstdint>
#include <optional>
struct DictEntry {
std::string value;
uint32_t version; // 등록 시 원본 서버가 부여한 단조 증가 버전
};
class RelayDictionary {
std::unordered_map<uint16_t, DictEntry> entries_; // 인덱스 연속성 가정 제거
public:
void OnDictAdd(uint16_t index, const std::string& value, uint32_t version)
{
auto it = entries_.find(index);
if (it != entries_.end() && it->second.version >= version) {
// 더 오래된(순서가 뒤바뀐) 통지는 무시 — 최신 버전만 반영
return;
}
entries_[index] = DictEntry{ value, version };
}
// index/version 이 유효하지 않으면 절대 UB 없이 std::nullopt 를 반환한다.
std::optional<std::string> Resolve(uint16_t index, uint32_t expectedVersion) const
{
auto it = entries_.find(index);
if (it == entries_.end() || it->second.version != expectedVersion) {
return std::nullopt; // 호출자가 재동기화 요청 등으로 처리
}
return it->second.value;
}
};
핵심 변경:
std::vector첨자 접근 대신std::unordered_map<uint16_t, DictEntry>로 바꿔, 인덱스가 연속이 아니어도(중간에 구멍이 있어도) 안전하게 저장·조회할 수 있게 했다 — 범위 밖 접근 자체가 불가능해진다.DictAdd/Resolve에version을 추가해, 유실로 인한 "구멍"이나 순서 역전으로 인한 "덮어쓰기"를 감지할 수 있게 했다.OnDictAdd는 더 낮은 버전의 뒤늦은 통지를 무시한다.Resolve는std::optional<std::string>을 반환해 미등록/버전 불일치를 정상 흐름으로 처리하도록 강제한다(예외나 UB 대신 값 없음을 명시적으로 표현).
더 나은 설계 (+ 트레이드오프)
- DictAdd를 신뢰성 채널(ack+재전송)로 전송: 애초에 유실이 없도록 사전 갱신은 반드시 순서 보장·재전송이 있는 채널로 보낸다. 다만 채널 분리 자체는 두 채널 간 상대 순서까지 보장하진 않으므로 버전 검증은 여전히 필요하다.
- 재동기화 프로토콜:
Resolve가 실패하면(=미등록/버전 불일치) 릴레이가 원본 서버에 "인덱스 N 재전송 요청"을 보내고, 그동안 해당 메시지 처리를 보류하거나 플레이스홀더로 대체한다. - 참조 카운트/만료 정책이 있는 경우
shared_ptr<const std::string>사용: 값 자체를 공유 포인터로 저장하면 참조가 살아있는 동안 사전에서 항목이 축출되어도 안전하게 유지할 수 있다(다만 이 문제의 핵심 결함과는 별개 이슈). - 트레이드오프:
unordered_map은vector첨자 접근보다 조회 비용이 다소 높고 메모리 오버헤드가 있지만, 신뢰할 수 없는 입력(유실 가능한 네트워크 프로토콜에서 온 인덱스)을 다루는 코드에서는 안전성이 그 비용을 상회한다. 성능이 극히 중요하다면vector를 유지하되 접근 전index < entries_.size()경계 검사와 "슬롯이 채워졌는지"를 나타내는std::optional<DictEntry>슬롯을 두는 절충안도 가능하다.
면접 포인트
- 신뢰할 수 없는 네트워크 입력(인덱스, 길이 등)을 컨테이너 첨자로 그대로 쓰면
operator[]의 무검사 특성과 결합해 UB로 직결된다 —at()이나 명시적 경계 검사, 혹은 애초에 맵 기반 저장으로 이 클래스의 버그 자체를 없애는 설계가 필요하다. - "인덱스가 연속"이라는 가정은 로컬에서는 자연스러워 보이지만, 유실 가능한 전송 계층 위에서는 성립하지 않는 가정이다 — 프로토콜 설계 시 전송 계층의 보장 범위를 항상 명시적으로 검토해야 한다.
- 압축을 위한 인덱스 참조 프로토콜은 필연적으로 "송수신 양측 상태의 암묵적 동기화"에 의존하므로, 신뢰성 전송만으로는 부족하고 참조 시점에 버전/체크섬으로 재검증하는 방어적 설계가 필요하다.