15. 서버-서버 RPC 상관관계 ID 매칭과 늦은 응답 오배달 (C#)
난이도 상해설 — 서버-서버 RPC 상관관계 ID 매칭과 늦은 응답 오배달 (C#)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
멀티플렉스 RPC 의 상관관계 ID 매칭에 결함이 겹쳐 응답이 엉뚱한 요청에 배달된다.
(A) _nextId++ 가 비원자 + ushort 라 65536 건마다 순환한다. 진행 중 요청의 ID 가
재사용되면 _pending[id] = tcs 가 기존 대기 요청을 덮어써 옛 요청은 영영 안 깨고
(Task 영구 미완), 옛 응답은 새 요청이 받는다(오배달). (B) 타임아웃은 tcs.SetException
만 하고 _pending 에서 제거하지 않아 누수되며, 타임아웃 후 늦게 온 응답이 같은 ID 로
재사용된 다른 요청을 깨운다(교차 오배달). 또 타임아웃이 이미 완료한 TCS 에 OnResponse
가 SetResult 하면 InvalidOperationException(An attempt was made to transition a task
to a final state when it had already completed). (C) _pending/_nextId 를 송신·수신·
타이머 스레드가 락 없이 동시 접근해 Dictionary 손상. Timer 가 GC 되어 안 돌 위험도
있다(미보관).
정답 한 줄: ID 발급·등록·완료·타임아웃을 락으로 보호하고, "pending 에서 원자적으로 꺼낸
한 쪽만 완료"(TrySetResult/TrySetException + Remove)로 만들며, ID 공간을 넓히거나(또는
세대 토큰) 늦은 응답을 무효화한다.
문제점
(A) ID 발급: 비원자 증가 + 16비트 순환 → 충돌/재사용 (프로토콜·동시성) ★간판
- 증상:
_nextId++는 다중 송신 스레드 RMW 경합으로 같은 ID 중복 발급,ushort라 65536 건마다 wrap. 장기 지연 요청이 떠 있을 때 같은 ID 가 재발급되면_pending[id]=가 기존 엔트리를 덮어써 옛 요청은 미완(영구 await), 옛 응답은 새 요청으로 간다. - 재현조건: 고처리량 또는 일부 장기 지연 + 다중 송신 스레드.
- 근본 원인: ID 공간이 좁고 발급이 비원자이며 사용 중 여부 확인이 없다.
(B) 타임아웃·늦은 응답 — 누수 / 오배달 / TCS 이중 완료 (프로토콜·생명주기) ★간판
- 증상:
- 타임아웃이
_pending.Remove를 안 해 엔트리 누수. - 타임아웃 후 늦은 응답이, 같은 ID 로 재사용된 다른 요청을
SetResult로 깨움(교차 오배달). - 타임아웃이 완료한 TCS 에 응답이
SetResult→InvalidOperationException(SetResult는 이미 완료 시 던진다).Task.Run컨티뉴에이션 위라면 관측 안 된 예외로 묻힐 수도.
- 타임아웃이
- 근본 원인: 완료가 단일 지점에서 원자적으로 일어나지 않고, 늦은 응답을 식별/무효화할 수단이 없다.
(C) _pending/_nextId 무락 공유 — Dictionary 손상 (동시성) ★간판
- 증상: 송신(
_pending[id]=), 수신(TryGetValue/Remove), 타이머(SetException)가 같은Dictionary를 동시 접근 → 비스레드세이프라 리사이즈 중 손상/무한루프/엔트리 유실. - 근본 원인: 공유 상태 동기화 부재.
(보너스) Timer 미보관 / 완료 후 미해제 (메모리·정확성)
new Timer(...)를 어디에도 보관하지 않아 GC 되어 콜백이 안 돌 수 있고, 정상 응답 시 타이머를Dispose하지 않아 타임아웃 콜백이 뒤늦게 또 돈다. TCS 완료를 동기 컨텍스트에서 하면 컨티뉴에이션이 수신 스레드를 블록할 수도(→RunContinuationsAsynchronously).
수정안
핵심: ① 락으로 전 구간 보호, ② 완료는 "pending 에서 꺼낸 한 쪽"만(TrySet* + Remove),
③ 늦은 응답은 ID 부재로 자연 무시, ④ ID 는 넓게(또는 long), ⑤ 타이머 보관/해제,
⑥ RunContinuationsAsynchronously.
public class RpcClient
{
private readonly object _lock = new object();
private long _nextId = 1;
private readonly Dictionary<long, Pending> _pending = new();
private sealed class Pending
{
public TaskCompletionSource<byte[]> Tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Timer Timer;
}
public Task<byte[]> CallAsync(byte[] body, int timeoutMs)
{
long id;
var p = new Pending();
lock (_lock) { id = _nextId++; _pending[id] = p; } // 발급+등록 원자
SendFrame(id, body);
p.Timer = new Timer(_ => Complete(id, null, timeout: true),
null, timeoutMs, Timeout.Infinite);
return p.Tcs.Task;
}
public void OnResponse(long id, byte[] payload) => Complete(id, payload, timeout: false);
// pending 에서 꺼낸 한 쪽만 완료 — 타임아웃/응답 경쟁의 승자
private void Complete(long id, byte[] payload, bool timeout)
{
Pending p;
lock (_lock)
{
if (!_pending.TryGetValue(id, out p)) return; // 이미 처리됨/늦은 응답 → 무시
_pending.Remove(id); // 완료권 획득
}
p.Timer?.Dispose();
if (timeout) p.Tcs.TrySetException(new TimeoutException("rpc timeout"));
else p.Tcs.TrySetResult(payload);
}
private void SendFrame(long id, byte[] body) { /* 생략 */ }
}
불변식: pending 에서 엔트리를 꺼낸 자만 TCS 를 완료한다. 타임아웃과 응답 중 먼저
Remove한 쪽이 승자이므로 이중 완료 불가, 늦은 응답은 ID 가 없어 무시된다. wire 가 16비트 ID 로 고정이라면 세대 토큰을 함께 보내 응답 세대 불일치 시 폐기.
더 나은 설계
1) ID = 64비트 모노토닉 (또는 16비트+세대)
- 와이어가 허용하면
long으로 순환 제거. 16비트 고정이면(slot, generation)으로 슬롯 재사용 구분. 트레이드오프: 세대 검증 추가.
2) 완료의 단일 소유권 + 연결 종료 시 일괄 실패
- 연결이 끊기면 남은
_pending전부를TrySetException으로 비워 고아 Task 방지.
3) ConcurrentDictionary + 타이머 휠
- 락 경합을 줄이려면
ConcurrentDictionary.TryRemove로 완료권 획득. 요청별Timer대신 타이머 휠로 만료 비용 절감. 트레이드오프: 구현 복잡도.
4) 백프레셔/상한
- pending 상한·레이트 제한으로 다운스트림 지연 시 메모리 폭증 방지(fail-fast).
면접 포인트
- 핵심: 멀티플렉스 RPC 응답↔요청 매칭을 동시성/타임아웃/ID 재사용 하에서 정확히 — 단일 완료 소유권 + 넓은 ID/세대.
- 예상 질문:
- "타임아웃된 요청의 늦은 응답이 왜 다른 요청을 깨우나?" → ID 재사용 + pending 미정리. 꺼낸 자가 완료 + 부재 시 무시로 해결.
- "SetResult vs TrySetResult?" → 이중 완료 시
SetResult는 예외. 경쟁 상황은TrySet*. - "ushort ID 의 위험?" → 65536 순환으로 진행 중 요청과 충돌. long/세대로.
변별 메모: 기존 protocol6(RPC 메시지 ID↔핸들러, 정적 디스패치)과 달리 본 문제는 동적 요청-응답 상관관계의 생명주기(타임아웃·ID 재사용·늦은 응답) 가 축. §11/§13 신규 상황.
해설 — 서버-서버 RPC 상관관계 ID 매칭과 늦은 응답 오배달 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
멀티플렉스 RPC 의 상관관계 ID 매칭에 세 가지 치명 결함이 겹쳐 응답이 엉뚱한 요청에
배달된다. (A) nextId_++ 가 비원자 + uint16_t 라 65536 건마다 ID 가 순환한다.
아직 응답을 기다리는 요청의 ID 가 재사용되면, 새 요청이 그 슬롯을 덮어쓰고/혹은 옛 요청의
응답이 새 요청으로 간다. (B) 타임아웃은 promise 만 실패 처리하고 pending_ 에서
엔트리를 지우지 않는다(누수). 더 나쁘게는, 타임아웃 후 늦게 도착한 응답이 그 사이
같은 ID 로 재발급된 다른 요청에 set_value 되어 오배달된다. 또 타임아웃이 이미
set_exception 한 promise 에 OnResponse 가 다시 set_value 하면 std::future_error
(promise already satisfied) 예외. (C) pending_ 와 nextId_ 를 송신/수신/타이머 스레드가
락 없이 동시에 건드려 unordered_map 동시 변경 UB + use-after-free(타이머가 delete 한
pr 을 수신 스레드가 접근).
정답 한 줄: ID 발급·등록·완료·타임아웃을 뮤텍스로 보호하고, "완료/타임아웃 = pending 에서
원자적으로 제거(소유권 이전)"로 만들어 한 번만 완료시키며, ID 는 충분히 넓게(64비트) +
재사용 시 세대(generation) 토큰으로 늦은 응답을 무효화한다.
문제점
(A) ID 발급: 비원자 증가 + 16비트 순환 → ID 충돌/재사용 (프로토콜·동시성) ★간판
- 분류: 정수 오버플로/ID 재사용 + 데이터 레이스.
- 증상:
nextId_++는 (1) 여러 송신 스레드에서 RMW 경합으로 같은 ID 를 두 요청에 발급, (2)uint16_t라 65536 건마다 0 으로 wrap. 오래 걸리는 요청이 떠 있는 동안 같은 ID 가 재발급되면pending_[id] = pr가 기존 대기 요청을 덮어써 옛 요청은 영영 안 깨어나고 (future 영구 블록), 옛 요청의 응답이 오면 새 요청이 받는다(오배달). - 재현조건: 고처리량(초당 수만 RPC) 또는 일부 요청 장기 지연 + 다중 송신 스레드.
- 근본 원인: ID 공간이 좁고, 발급이 비원자이며, "이 ID 가 현재 사용 중인지" 확인이 없다.
(B) 타임아웃·늦은 응답 처리 — 누수 / 오배달 / promise 이중 완료 (프로토콜·생명주기) ★간판
- 증상:
- 타임아웃 콜백이
pending_.erase를 안 해 엔트리/PendingRequest누수. - 타임아웃 후 늦게 온 응답이, 그 사이 같은 ID 로 재사용된 다른 요청에 set_value → A 요청의 응답을 B 요청이 받는 교차 오배달.
- 타임아웃이
set_exception한 promise 에 응답이set_value하면std::future_error: promise_already_satisfied예외로 수신 스레드 손상.
- 타임아웃 콜백이
- 근본 원인: "완료"가 단일 지점에서 원자적으로 일어나지 않고, 타임아웃과 응답이 같은 요청을 두 번 완료시킬 수 있으며, 늦은 응답을 식별/무효화할 토큰이 없다.
(C) pending_/nextId_ 무락 공유 — UB / use-after-free (동시성·메모리) ★간판
- 증상: 송신 스레드(
pending_[id]=), 수신 스레드(find/erase/delete), 타이머 스레드 (set_exception)가 같은 맵/포인터를 동시 접근.unordered_map동시 변경은 UB. 타이머가pr을 처리하는 사이 수신 스레드가delete pr하면 use-after-free(또는 이중 delete). - 근본 원인: 공유 상태에 동기화·소유권 규칙 부재.
(보너스) raw new/delete 소유권 모호 (메모리·설계)
PendingRequest*를 raw 포인터로 두 콜백이 공유 → 누가 delete 할지 불명. RAII(스마트 포인터) + "pending 에서 빼낸 자가 소유" 규칙이 필요.
수정안
핵심: ① 뮤텍스로 전 구간 보호, ② ID 는 64비트(순환 회피) + 사용 중이면 회피, ③ 완료는 "pending 에서 원자적으로 꺼낸 단 한 쪽"만 수행(타임아웃이든 응답이든 먼저 꺼낸 쪽이 완료), ④ 늦은 응답은 ID 가 이미 pending 에 없으므로 자연히 무시, ⑤ RAII.
#include <mutex>
#include <memory>
class RpcClient {
public:
std::future<std::vector<uint8_t>> Call(const std::vector<uint8_t>& body, int timeoutMs) {
auto pr = std::make_shared<PendingRequest>();
auto fut = pr->prom.get_future();
uint64_t id;
{
std::lock_guard<std::mutex> lk(mtx_);
id = nextId_++; // 64비트: 실질 비순환
pending_.emplace(id, pr); // shared_ptr 보관
}
SendFrame(id, body);
ScheduleAfter(timeoutMs, [this, id]() {
std::shared_ptr<PendingRequest> victim;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = pending_.find(id);
if (it == pending_.end()) return; // 이미 응답으로 완료됨
victim = it->second;
pending_.erase(it); // 원자적 꺼내기 = 완료권 획득
}
victim->prom.set_exception(
std::make_exception_ptr(std::runtime_error("rpc timeout")));
});
return fut;
}
void OnResponse(uint64_t id, std::vector<uint8_t> payload) {
std::shared_ptr<PendingRequest> target;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = pending_.find(id);
if (it == pending_.end()) return; // 타임아웃돼 사라졌거나 모르는 ID → 무시
target = it->second;
pending_.erase(it); // 원자적 꺼내기 = 완료권 획득
}
target->prom.set_value(std::move(payload)); // 락 밖에서 완료(한 번만)
}
private:
void SendFrame(uint64_t id, const std::vector<uint8_t>& body);
void ScheduleAfter(int ms, std::function<void()> fn);
std::mutex mtx_;
uint64_t nextId_ = 1;
std::unordered_map<uint64_t, std::shared_ptr<PendingRequest>> pending_;
};
불변식: "pending 에서 엔트리를 꺼낸 자가 promise 를 완료시킬 유일한 주체." 타임아웃과 응답 중 먼저
erase에 성공한 쪽만 완료하므로 promise 이중 완료가 구조적으로 불가능하고, 늦은 응답은 ID 가 이미 없어 자연히 무시된다. 64비트 ID 로 순환 충돌도 사실상 제거. wire 프로토콜이 16비트 ID 로 고정이라면, 세대(generation) 카운터를 ID 와 함께 보내 응답의 세대가 현재 pending 세대와 다르면 폐기(늦은 응답 식별).
더 나은 설계
1) ID = 모노토닉 64비트(또는 16비트+세대)
- 와이어가 허용하면 64비트로 순환을 없앤다. 16비트 고정이면
(slot, generation)으로 슬롯 재사용을 세대로 구분. 트레이드오프: 세대 검증 로직 추가.
2) 완료의 단일 소유권(꺼낸 자가 완료)
- 타임아웃·응답·연결 종료(전체 실패 전파)가 모두 "pending 에서 꺼내 완료"라는 한 규칙을 공유. 연결이 끊기면 남은 pending 전부를 실패로 비운다(고아 future 방지).
3) 타임아웃 휠 + 일괄 만료
- 요청별 타이머 대신 타이머 휠/최소 힙으로 O(1)~O(log n) 만료. 대량 RPC 에서 타이머 폭증 방지. 트레이드오프: 구현 복잡도.
4) 백프레셔/상한
- pending 수 상한·요청 레이트 제한으로 다운스트림 지연 시 메모리 폭증 방지. 가득 차면 빠른 실패(fail-fast).
면접 포인트
- 핵심: 멀티플렉스 RPC 에서 응답↔요청 매칭의 정확성을 동시성/타임아웃/ID 재사용 하에서 어떻게 보장하나 — 단일 완료 소유권 + 충분한 ID 공간/세대.
- 예상 질문:
- "타임아웃된 요청의 늦은 응답이 왜 다른 요청을 깨우나?" → ID 가 재사용됐고 pending 을 안 비웠기 때문. 꺼낸 자가 완료 + ID 부재 시 무시로 해결.
- "16비트 ID 의 위험과 대안은?" → 65536 순환으로 진행 중 요청과 충돌. 64비트 또는 세대 토큰.
- "promise_already_satisfied 는 왜 나나?" → 타임아웃과 응답이 둘 다 완료 시도. 원자적 꺼내기로 한 쪽만 완료.
빌드/검증
g++ -std=c++17 -fsyntax-only problem.cpp
변별 메모: 본 문제는 §11/§13(프로토콜/서버-서버)의 신규 상황 — 기존 protocol6 (RPC 메시지 ID↔핸들러 매핑, 정적 디스패치)과 달리 동적 요청-응답 상관관계의 생명주기 (타임아웃·재사용·늦은 응답) 가 축이다. protocol12(게이트웨이 포워딩 변환)와도 무관.