← 문제로

15. 서버-서버 RPC 상관관계 ID 매칭과 늦은 응답 오배달 (C#)

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

해설 — 서버-서버 RPC 상관관계 ID 매칭과 늦은 응답 오배달 (C#)

난이도: 상

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

요약

멀티플렉스 RPC 의 상관관계 ID 매칭에 결함이 겹쳐 응답이 엉뚱한 요청에 배달된다. (A) _nextId++ 가 비원자 + ushort 라 65536 건마다 순환한다. 진행 중 요청의 ID 가 재사용되면 _pending[id] = tcs 가 기존 대기 요청을 덮어써 옛 요청은 영영 안 깨고 (Task 영구 미완), 옛 응답은 새 요청이 받는다(오배달). (B) 타임아웃은 tcs.SetException 만 하고 _pending 에서 제거하지 않아 누수되며, 타임아웃 후 늦게 온 응답이 같은 ID 로 재사용된 다른 요청을 깨운다(교차 오배달). 또 타임아웃이 이미 완료한 TCS 에 OnResponseSetResult 하면 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 에 응답이 SetResultInvalidOperationException(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/세대.
  • 예상 질문:
    1. "타임아웃된 요청의 늦은 응답이 왜 다른 요청을 깨우나?" → ID 재사용 + pending 미정리. 꺼낸 자가 완료 + 부재 시 무시로 해결.
    2. "SetResult vs TrySetResult?" → 이중 완료 시 SetResult 는 예외. 경쟁 상황은 TrySet*.
    3. "ushort ID 의 위험?" → 65536 순환으로 진행 중 요청과 충돌. long/세대로.

변별 메모: 기존 protocol6(RPC 메시지 ID↔핸들러, 정적 디스패치)과 달리 본 문제는 동적 요청-응답 상관관계의 생명주기(타임아웃·ID 재사용·늦은 응답) 가 축. §11/§13 신규 상황.