ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JSON-RPC의 id는 누가 정하고 충돌하면 어떻게 되나
    IT 2026. 6. 21. 21:00
    JSON-RPC의 id는 누가 정하고 충돌하면 어떻게 되나

    처음 JSON-RPC 요청을 손으로 만들어 보면 누구나 한 번은 멈칫하는 자리가 있다. id1을 적어 넣는 순간이다. "이걸 내가 1로 정해도 되나? 같은 서버에 접속한 다른 클라이언트도 1을 쓰면 응답이 섞이지 않나?" 그럴듯한 걱정이다. 그리고 이 걱정이 어디서 어긋났는지 따라가다 보면 JSON-RPC라는 프로토콜의 설계 의도가 거의 통째로 드러난다.

    이 글은 그 한 줄짜리 의문에서 출발한다. id는 누가, 어떻게 정하는가? 다른 클라이언트가 같은 id를 쓰면 정말 무슨 일이 벌어지는가? 그리고 보통 어떤 식으로 id를 정하며, 그 선택이 실제로 어떤 문제를 막아 주는지 — 사례 중심으로 풀어 본다.

    먼저, JSON-RPC 요청 한 개의 생김새

    JSON-RPC는 "함수 하나를 원격으로 호출하는 약속"을 JSON 한 덩어리로 표현한 프로토콜이다. RPC(Remote Procedure Call, 원격 프로시저 호출 — 네트워크 너머의 함수를 마치 로컬 함수처럼 부르는 방식)를 가장 얇게 구현한 축에 속한다. 요청 하나는 보통 이렇게 생겼다.

    // 클라이언트 → 서버 (요청)
    {
      "jsonrpc": "2.0",      // 프로토콜 버전 (고정)
      "method": "subtract",  // 부를 함수 이름
      "params": [42, 23],    // 인자
      "id": 1                // 이 요청의 식별표
    }
    
    // 서버 → 클라이언트 (응답)
    {
      "jsonrpc": "2.0",
      "result": 19,
      "id": 1                // 요청과 같은 값을 그대로 되돌려준다
    }

    diagram

    다이어그램 설명. JSON-RPC의 한 왕복을 보여준다. 클라이언트가 id=1을 붙여 요청을 보내면, 서버는 처리 결과에 같은 id를 그대로 다시 박아 돌려준다. 핵심은 점선으로 표시한 부분 — id를 발급한 클라이언트는 "1번 요청의 응답이 오면 이 콜백으로 넘긴다"는 매핑을 자기 쪽에 들고 있다. 즉 id는 서버가 관리하는 값이 아니라 클라이언트가 자기 응답을 되찾기 위해 붙이는 표다. 흔한 오해는 "서버가 id를 발급한다"고 생각하는 것인데, 명세상 id는 언제나 클라이언트가 정한다.

    id의 진짜 정체: 전역 ID가 아니라 "상관 토큰"

    여기서 첫 질문 — "id를 어떻게 정하는가"의 답이 나온다. JSON-RPC 2.0 명세는 id에 대해 딱 세 가지만 요구한다.

    • 타입: 문자열(String), 숫자(Number), 또는 null 중 하나여야 한다.
    • 되돌려주기: 서버는 응답에 반드시 요청과 같은 id를 넣어야 한다("MUST reply with the same value").
    • 권고: 숫자는 소수부를 두지 않는 게 좋고(부동소수점 비교 문제), 요청 id로 null은 피하는 게 좋다.

    명세 어디에도 "전역에서 유일해야 한다"거나 "다른 클라이언트와 겹치면 안 된다"는 말은 없다. id의 유일한 임무는 하나의 클라이언트가, 자신이 보낸 여러 요청 중 어느 것에 대한 응답인지 짝지어 내는 것이다. 이런 값을 상관 토큰(correlation token — 보낸 것과 받은 것을 짝짓기 위한 꼬리표)이라고 부른다. 송장 번호, 택배 운송장 번호와 같은 발상이다. 내 운송장 번호 12345와 옆집의 운송장 번호 12345가 같아도 아무 문제가 없는 이유와 똑같다 — 각자 자기 택배회사하고만 그 번호로 대화하니까.

    diagram

    다이어그램 설명. id가 왜 필요한지를 한 장으로 보여준다. 클라이언트는 응답을 기다리지 않고 요청 3개를 연달아 던졌다(비동기 다중 요청). 서버는 처리 시간이 달라 2 → 3 → 1 순으로 뒤죽박죽 응답한다. 만약 id가 없다면 클라이언트는 먼저 도착한 응답이 날씨인지 환율인지 알 길이 없다. id가 있기 때문에 "도착 순서"가 아니라 "id 일치"로 짝을 복원할 수 있다. 함정 하나 — TCP 같은 순서 보장 채널이라도 응답 완료 순서까지 보장되지는 않는다. 그래서 단일 연결 위에서도 id가 필요하다.

    "다른 클라이언트가 같은 id를 쓰면?" — 처음 질문에 대한 답

    이제 핵심 의문을 정면으로 본다. 클라이언트 A도 id=1, 클라이언트 B도 id=1로 같은 서버에 요청하면 응답이 섞일까? 답은 섞이지 않는다. 그리고 그 이유가 이 프로토콜에서 가장 중요한 포인트다.

    diagram

    다이어그램 설명. 같은 id=1을 쓰는 두 클라이언트가 서로 간섭하지 않는 이유를 보여준다. 핵심은 응답이 "연결(connection/session)"이라는 통로를 따라 되돌아간다는 점이다. 서버는 연결 #1로 들어온 요청의 응답을 연결 #1로만 내보낸다. id는 그 연결 안에서만 의미를 가지므로, 다른 연결의 같은 숫자와는 애초에 비교될 일이 없다. 그래서 id의 유효 범위(scope)는 "전역"이 아니라 "하나의 클라이언트-서버 채널"이다. 흔한 오해 — "서버가 모든 클라이언트의 id를 한 테이블에 모아 관리한다"고 상상하면 충돌이 무서워지지만, 실제로는 각 연결이 자기만의 작은 id 공간을 따로 갖는다.

    그러면 충돌은 영영 없는 걸까? 아니다. 진짜 위험한 충돌은 바깥(다른 클라이언트)이 아니라 안(한 클라이언트 내부)에 있다.

    진짜 충돌: 한 클라이언트가 in-flight 중에 id를 재사용할 때

    diagram

    다이어그램 설명. 실제로 사고가 나는 유일한 시나리오다. 클라이언트가 첫 id=1 요청의 응답을 아직 받지 못한 상태(in-flight, 처리 중)에서 같은 id=1로 두 번째 요청을 보냈다. 클라이언트 내부의 "id → 콜백" 테이블에는 키가 1 하나뿐이라 두 번째 요청이 첫 번째를 덮어써 버린다. 그래서 응답 id=1이 도착해도 그게 느린 작업의 결과인지 나중 작업의 결과인지 알 수 없다. 정리하면 — 지켜야 할 규칙은 "전역 유일"이 아니라 "한 채널 안에서 동시에 떠 있는(outstanding) 요청들끼리만 유일"이다. 이 범위만 지키면 id를 어떻게 정하든 자유다.

    그래서 id는 보통 어떻게 정하나 — 세 가지 전략

    "동시에 떠 있는 요청끼리만 안 겹치면 된다"는 조건을 만족시키는 방법은 현실에서 거의 세 갈래로 수렴한다.

    전략 방식 장점 약점 / 주의
    단조 증가 카운터 연결마다 0(또는 1)에서 시작해 요청 보낼 때마다 +1 가장 흔하고 단순. 로그에서 순서가 그대로 읽힘 멀티스레드면 증가 연산이 원자적(atomic)이어야 함. 연결 끊기면 리셋
    UUID / 랜덤 문자열 요청마다 충돌 확률이 사실상 0인 무작위 값 생성 상태(카운터)를 들 필요 없음. 분산·서버리스 환경에 적합 값이 길어 로그·트래픽이 커짐. 사람 눈으로 추적하기 불편
    접두사 + 카운터 "clientA-1"처럼 출처를 접두사로 묶음 여러 출처를 한 채널로 합칠 때(프록시) 충돌 방지 + 출처 식별 문자열이라 비교·매칭 로직을 직접 관리해야 함

    현실의 대부분은 첫 번째 — 연결마다 새로 시작하는 단조 증가 정수 카운터다. 이유는 단순하다. id의 유효 범위가 어차피 "이 연결 하나"이므로, 연결이 열릴 때 카운터를 0으로 두고 요청마다 1씩 올리면 그 안에서 절대 겹치지 않는다. 아래는 그 최소 구현이다.

    # 단계 1: 연결(세션)을 열 때 카운터를 0으로 초기화
    class JsonRpcClient:
        def __init__(self):
            self._next_id = itertools.count(1)   # 1, 2, 3, ... 무한 증가
            self._pending = {}                   # id → 응답을 기다리는 future
    
        # 단계 2: 요청을 보낼 때마다 새 id를 뽑아 콜백을 등록
        async def call(self, method, params):
            rid = next(self._next_id)            # 멀티스레드면 lock 필요
            fut = asyncio.get_event_loop().create_future()
            self._pending[rid] = fut             # "이 id 응답이 오면 이 future에"
            await self._send({"jsonrpc": "2.0", "method": method,
                              "params": params, "id": rid})
            return await fut                     # 응답 도착까지 대기
    
        # 단계 3: 응답이 오면 id로 짝을 찾아 future를 깨운다
        def _on_response(self, msg):
            fut = self._pending.pop(msg["id"])   # 짝을 찾고 테이블에서 제거
            fut.set_result(msg.get("result"))

    코드 설명. JSON-RPC 클라이언트의 심장부다. _next_id는 연결마다 새로 만들어지는 카운터라 이 연결 안에서만 유일하면 충분하고, _pending이 바로 앞 다이어그램의 "콜백 테이블"이다. 핵심 패턴은 세 줄로 요약된다 — (1) 보낼 때 새 id 발급 + 테이블 등록, (2) 응답 올 때 id로 조회, (3) 처리 후 테이블에서 제거. 주의할 함정은 next(self._next_id)가 멀티스레드에서 동시에 불리면 같은 값이 두 번 나올 수 있다는 점 — 그래서 스레드 환경에서는 카운터 증가를 lock으로 감싸거나 원자적 연산을 써야 앞서 본 "in-flight 재사용" 사고를 막는다.

    사례 1: 배치 요청 — id가 없으면 답을 못 찾는다

    JSON-RPC 2.0은 요청 여러 개를 배열로 묶어 한 번에 보내는 배치(batch)를 지원한다. 그런데 명세는 "서버가 응답을 어떤 순서로 돌려주든 상관없다"고 못 박는다. 이때 id가 없으면 정말로 답을 잃어버린다.

    diagram

    다이어그램 설명. 배치에서 id가 "유일한 단서"가 되는 상황을 보여준다. 요청 3개를 한 배열로 묶어 보냈지만, 서버는 병렬로 처리한 뒤 완료된 순서대로(3, 1, 2) 응답 배열을 돌려준다. 응답 배열의 위치(인덱스)는 요청 배열의 위치와 일치한다는 보장이 전혀 없다. 따라서 클라이언트는 배열 순서를 믿으면 안 되고 오직 각 응답의 id를 보고 원래 요청과 짝지어야 한다. 놓치기 쉬운 함정 — 배치 안에 알림(notification, id 없는 요청)을 섞으면 그 항목은 응답 배열에 아예 나타나지 않으므로, "요청 N개니까 응답도 N개"라고 가정하면 어긋난다.

    사례 2: 프록시/게이트웨이 — id를 다시 쓰는 곳

    id가 "연결 단위"라는 성질이 진짜 중요해지는 순간은 여러 클라이언트를 하나의 상위 연결로 합치는 프록시를 만들 때다. 두 클라이언트가 모두 id=1을 보내는데, 프록시가 이를 같은 상위 서버 연결로 전달하면 — 이제 한 연결 안에서 id가 겹친다. 앞서 본 "진짜 충돌"이 발생하는 것이다.

    diagram

    다이어그램 설명. 프록시가 id 충돌을 푸는 표준 방법 — id 재작성(rewriting)을 보여준다. 두 클라이언트가 모두 id=1을 보내지만, 프록시는 상위 서버로 넘기기 전에 출처를 구분할 수 있는 새 id(a-1, b-1)로 바꾸고, "새 id ↔ (어느 클라이언트, 원래 id)" 매핑을 자기 안에 저장한다(점선). 응답이 오면 그 매핑을 역참조해 원래 id로 되돌린 뒤 올바른 클라이언트에게 보낸다. 이게 앞 표의 "접두사 + 카운터" 전략이 실제로 쓰이는 자리다. 함정 — 프록시가 이 매핑을 빠뜨리고 id를 그대로 통과시키면, 상위 서버 입장에서는 한 연결에서 같은 id가 두 번 온 꼴이라 응답을 엉뚱한 클라이언트에게 라우팅하게 된다.

    사례 3: MCP — 명세가 id 규칙을 더 조인 이유

    요즘 가장 가까이서 JSON-RPC를 만나는 곳은 MCP(Model Context Protocol — AI 모델에 외부 도구·데이터를 연결하는 표준 프로토콜, AI 도구판 "USB-C 포트"에 비유되곤 한다)다. MCP는 전송 포맷으로 JSON-RPC 2.0을 그대로 쓴다. 그런데 MCP 명세는 기본 JSON-RPC보다 id 규칙을 더 엄격하게 못 박았다.

    • 기본 JSON-RPC는 요청 id로 null을 "권장하지 않을" 뿐이지만, MCP는 null을 아예 금지한다(MUST NOT be null).
    • 그리고 같은 세션 안에서 이전에 쓴 id를 다시 쓰는 것을 금지한다(MUST NOT have been previously used within the session).

    왜 이렇게 조였을까? 앞에서 본 "in-flight 재사용" 사고를 명세 수준에서 원천 차단하기 위해서다. MCP는 한 세션이 오래 살아 있고(클라이언트와 서버의 연결이 열려 있는 동안 — 대화를 비워도 연결은 대개 유지된다), 그 위로 도구 호출 요청이 비동기로 쉴 새 없이 오간다. 이 환경에서 id를 재사용하면 "어느 도구 호출의 결과인지 모르는" 사고가 그대로 사용자 경험으로 터진다. 그래서 "동시에 떠 있을 때만 유일"이라는 느슨한 기본 규칙을, "세션 동안 통째로 유일"이라는 더 안전한 규칙으로 끌어올린 것이다.

    왜 중요한가 — 같은 JSON-RPC라도 그 위에서 도는 워크로드의 성격에 따라 id 규칙을 강화하는 게 정상이라는 뜻이다. 짧은 단발 요청이면 카운터 리셋으로 충분하지만, 길게 살아 있는 세션·비동기 다중 호출 환경이라면 MCP처럼 "세션 내 전역 유일"까지 올리는 편이 사고를 막는다.

    에러가 나도 id는 살아남는다 — null id를 피하는 이유

    id의 쓸모는 정상 응답에만 있는 게 아니다. 요청이 거부돼 에러가 나도 응답에는 여전히 같은 id가 실린다. 덕분에 클라이언트는 "id=7 요청이 실패했다"는 한 줄만 보고도 수많은 요청 중 어느 것이 문제였는지 바로 짚어낸다. id는 정상 흐름뿐 아니라 에러 추적의 좌표이기도 하다.

    diagram

    다이어그램 설명. 에러 상황에서도 id가 어떻게 동작하는지, 그리고 딱 한 가지 예외를 함께 보여준다. 왼쪽 흐름 — 요청 내용이 틀려 서버가 거부해도, 에러 응답에는 원래 id(7)를 그대로 박아 돌려준다. 그래서 클라이언트는 "7번 요청이 깨졌다"를 즉시 특정한다. 오른쪽 흐름이 예외다 — JSON 문자열 자체가 깨져 서버가 id 필드를 읽기도 전에 실패하면, 되돌려줄 id가 없으니 에러 응답의 id는 null로 나간다. 여기서 글 앞부분의 "요청 id로 null을 피하라"는 권고의 진짜 이유가 드러난다 — 평소 요청에 null id를 쓰면, "정상 요청의 응답"과 "id를 못 읽어 null로 처리된 응답"이 구분되지 않아 좌표 기능이 무너진다. 함정 — null id가 금지가 아니라 권고 회피 대상이라는 점인데, 그래서 명세보다 더 안전을 원하는 MCP는 아예 null을 금지로 못 박았다(앞 사례 참고).

    정리: id는 분산 ID가 아니라 채널 안의 꼬리표다

    처음의 걱정 — "내 id가 남의 id와 부딪히면?" — 은 id를 전역 유일 식별자(분산 시스템의 글로벌 ID)로 오해한 데서 나온 것이었다. 정리하면 이렇다.

    • id는 클라이언트가 정한다. 서버는 받은 그대로 되돌려줄 뿐이다.
    • 유효 범위는 하나의 연결/세션이다. 다른 클라이언트의 같은 id와는 애초에 만날 일이 없다.
    • 지켜야 할 유일 규칙은 "동시에 떠 있는 요청끼리 안 겹치기"다. 그래서 연결마다 리셋되는 단조 증가 카운터가 가장 흔한 답이고, 상태를 들기 싫으면 UUID, 출처를 합치는 프록시라면 접두사+카운터를 쓴다.
    • 긴 세션·비동기 다중 호출 환경이면 MCP처럼 규칙을 "세션 내 전역 유일"로 조인다.
    • id는 에러 응답에서도 살아남아 "어느 요청이 깨졌는지"를 짚어 준다. 그래서 요청 id로는 null을 피한다 — null은 서버가 id를 못 읽었을 때를 위한 값이기 때문이다.

    이 한 줄짜리 의문이 의미 있었던 이유가 여기 있다. id를 "왜 1이라고 적어도 되는가"를 끝까지 따라가면, 결국 JSON-RPC가 상태를 최대한 클라이언트 쪽에 두고, 서버는 받은 표를 되돌려주기만 하는 얇고 단순한 프로토콜이라는 설계 철학에 도달한다. 작은 필드 하나에 프로토콜의 성격이 통째로 담겨 있는 셈이다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.