DevTools Network 탭에서 GraphQL 기반 페이지의 로딩 요청을 따라가다 보면 이상한 장면을 만난다. 찾고 있던 query { ... }는 어디에도 없고, 요청 본문에는 operationName, 64자 해시, 그리고 variables 묶음만 남아 있다.
무언가를 놓친 것이 아니다. 이는 persisted operation이다. 클라이언트는 더 이상 평문 쿼리를 보내지 않고, 미리 등록된 해시만 전송한다. 서버는 이 해시를 자체 레지스트리에서 조회해 실제 쿼리를 복원한 뒤 실행한다. 이 시점부터 패킷 캡처만으로 쿼리를 얻는 방식은 막힌다. 어떤 operation이 호출됐는지, 어떤 변수가 전달됐는지는 알 수 있지만, 이 operation이 어떤 필드를 선택하는지와 응답 구조는 네트워크에 드러나지 않는다.
이때 많은 사람이 처음부터 잘못된 방향으로 간다. 해시를 "크랙"해야 한다고 생각하는 것이다. 해시는 일방향 값이라 크랙할 수 없고, 그럴 필요도 없다. 핵심은 분류다. 지금 어떤 상황인지 먼저 판별하고, 그에 맞는 확보 방식을 골라야 한다. 두 방식의 비용은 한 자릿수 이상 차이 나며, 잘못 고르면 노력만 낭비하게 된다.
Persisted operation이 쿼리를 요청에서 없애는 방식
어떻게 접근할지 판단하려면, 우선 이 메커니즘이 왜 등장했는지부터 이해해야 한다.
평문 GraphQL에는 분명한 부담이 있다. 쿼리 문자열이 길고, 매 요청마다 전체 필드 트리를 전송하는 것은 비효율적이다. 서버가 임의의 쿼리를 받아들여야 한다는 점도 스키마 전체의 공격 표면을 넓힌다. Persisted operation은 이 두 문제를 함께 해결한다. 빌드 시점에 클라이언트가 사용할 모든 쿼리를 추출해 해시를 만들고, 서버에는 이를 화이트리스트로 등록한다. 런타임에는 클라이언트가 해시와 변수만 보내며, 서버는 등록된 해시만 허용하고 화이트리스트 밖의 쿼리는 거부한다. 이는 스크래핑 방지 장치라기보다 실제 성능 설계에 가깝다. Apollo의 Automatic Persisted Queries 문서도 평문 쿼리 대신 쿼리의 SHA-256을 쓰는 방식을 권장한다. 캡처에 쿼리가 보이지 않는 것은 그에 따른 부수 효과일 뿐이다.
리버스 엔지니어링 관점에서 이 부수 효과는 명확하다. 요청에서 "무엇을 가져올지"에 대한 정보가 빠진다. 손에 남는 것은 operation 식별자(해시 또는 읽기 쉬운 operationName), 변수 묶음, 응답뿐이다. 중간 계층인 "이 operation이 선택한 필드"는 와이어 위에 존재하지 않는다.
실제 프로젝트에서는 이 스펙트럼의 양 끝을 모두 마주친다. 한쪽 끝에는 persisted operation을 전혀 도입하지 않아 요청 본문에 평문 쿼리가 그대로 있는 플랫폼이 있다. 다른 쪽 끝에는 필드명 하나 읽을 수 없을 정도로 요청이 불투명한 봉투 형태로 압축된 플랫폼이 있다. 양쪽은 접근 방식이 다르며, 아래에서 각각 따로 다룬다.
비용이 한 자릿수 이상 차이 나는 두 가지 접근법
첫 번째는 클라이언트 빌드 안에 있는 평문 쿼리나 매핑 테이블을 찾아내는 방법이다. 두 번째는 평문을 찾으려 하지 않고 operation 전체를 블랙박스로 취급해 그대로 재현하는 방법이다.
첫 번째 방법이 더 철저해 보이기 때문에 대부분은 무심코 그 길부터 택한다. 하지만 낭비는 바로 여기서 시작된다. 이 방법은 평문이 실제로 클라이언트에 포함돼 있을 때만 저렴하며, 그 전제는 생각보다 자주 성립하지 않는다.
방법 1: 클라이언트 빌드에서 평문 또는 매핑 찾기
가장 비용이 낮은 경우는 애초에 평문이 숨겨지지 않은 경우다.
중국의 한 숏폼 비디오 플랫폼의 인기 순위는 이런 형태다. 단일 /graphql 엔드포인트를 사용하고, 요청 본문은 표준적인 {operationName, variables, query} 구성이다. query 필드에는 완전한 평문 GraphQL이 들어 있으며, operationName 역시 hotRankQuery처럼 읽을 수 있는 이름이다. 여기서는 따로 "확보"할 것이 없다. 캡처 한 번이면 모든 정보가 눈앞에 있다. Persisted operation을 도입하지 않은 가장 쉬운 쪽 끝의 사례다.
조금 더 수고가 필요한 경우는 persisted operation을 사용하지만 클라이언트가 매핑을 여전히 들고 있는 경우다. 해시를 보내려면 클라이언트도 어떤 operation이 어떤 해시에 대응하는지 알아야 한다. 이 operationName-해시 테이블은 평문 쿼리와 함께 프론트엔드 번들에 포함되는 경우가 많다. 빌드 도구가 매니페스트 파일로 만들기도 하고, 특정 모듈에 인라인하기도 한다. 이를 찾으면 평문과 해시를 한 번에 확보할 수 있고, 이후 필드를 추가하거나 selection set을 바꾸는 것도 가능하다.
문제는 찾는 행위 자체보다, 빌드 결과물이 수만 줄에 이르고 난독화·압축돼 있으며 매핑이 여러 곳에 나뉘어 인라인될 수 있다는 데 있다. 이 단계가 바로 이 글에서 모델이 가치를 발휘하는 지점이며, 뒤에서 별도로 다룬다. 필요한 것은 추론이 아니라 청크 단위 검색이다.
방법 1의 판별 기준은 간단하다. 평문이나 매핑이 클라이언트 어디엔가 있다면 먼저 10분 정도 들여 찾아본다. 찾기만 하면 가장 강력한 방법이며, 엔드포인트를 완전히 통제할 수 있다.
방법 2: 평문을 찾지 말고 블랙박스로 재현하기
문제는 평문이 클라이언트에 없을 때가 많다는 점이다.
제대로 구현된 persisted operation에서 클라이언트가 가진 것은 해시뿐이고, 평문 쿼리는 서버 레지스트리에만 존재한다. 번들을 아무리 뒤져도 찾을 수 없다. 애초에 전송된 적이 없기 때문이다. 이런 상황에서 방법 1을 고집하는 것은 존재하지 않는 대상을 찾는 일이다.
방법 2는 이 문제에서 가장 과소평가된 해법이다. 평문 쿼리가 꼭 필요한 것은 아니다. 필요한 것은 쿼리의 필드 트리가 아니라 응답이다. 따라서 operation 식별자(해시 또는 operationName)와 변수 봉투를 기록해 그대로 보내고, 관심 있는 입력 파라미터만 바꾸면 된다. 어떤 필드가 선택됐는지는 끝내 모르더라도 서버는 동일한 응답을 돌려준다. 대부분의 데이터 수집과 모니터링 작업에는 이 정도면 충분하다.
YouTube innertube는 이 방식의 전형적인 사례다. 이것은 GraphQL조차 아니다. youtubei/v1/{player,search,next}처럼 자기 설명적인 고정 엔드포인트가 있고, 요청 본문은 context 봉투(클라이언트 유형, 버전)와 파라미터 묶음으로 구성된다. YouTube 내부 쿼리 그래프를 "복원"하려는 사람은 없다. 가능하지도 않고, 그럴 가치도 없기 때문이다. 실제로는 현재 페이지 리소스에서 클라이언트 버전과 context를 한 번 읽은 뒤, 이후 요청마다 이 봉투를 그대로 유지하고 videoId나 검색어 같은 입력값만 바꿔 고정 엔드포인트를 호출한다. operation의 의미는 끝까지 블랙박스로 남는다. 정적 코드에 없는 핵심 값을 런타임 리소스에서 읽어야 하는 이 사례는 별도의 리버스 엔지니어링 문제이며, 별도 글에서 다뤘다.
방법 2의 장점은 평문이 클라이언트에 있는지와 무관하다는 것이다. 해시든 불투명한 봉투든 이해하려 들지 않고 충실히 재현하면 된다. 대신 클라이언트가 이미 보내는 요청에 묶인다. 클라이언트가 한 번도 요청하지 않는 필드가 필요하다면 블랙박스 재현으로는 얻을 수 없다.
재현이 깨지는 경우도 하나 더 있다. 봉투에 요청마다 새로 계산되고 만료되는 서명 필드가 포함된 경우다. 블랙박스 방식은 여기까지이며, 해당 필드를 별도로 풀어야 한다. 그 서명이 어느 알고리즘 계열에 속하는지 식별하는 문제는 다른 글의 주제다.
플랫폼별로 달라지는 선택지
앞선 두 실제 사례를 표로 묶으면, 어느 쪽으로 가야 하는지와 그 이유가 분명해진다.
플랫폼 사례 | 요청 형태 | 클라이언트에 평문이 있는가? | 적합한 방법 | 이유 |
|---|---|---|---|---|
숏폼 비디오 인기 순위 GraphQL |
| 예, 요청 본문에 평문이 직접 있음 | 방법 1(거의 비용 없음) | Persisted operation이 없고 operationName과 평문 쿼리가 읽히므로 캡처 한 번이면 충분함 |
YouTube innertube | 고정 엔드포인트 + | 사실상 평문 쿼리 자체가 없음 | 방법 2(블랙박스 재현) | GraphQL이 아니고 복원할 쿼리가 없으며, context 봉투를 한 번 읽어 그대로 유지하면 됨 |
양 끝의 대비가 보여주는 것은 하나다. 확보 방식은 내가 임의로 고르는 것이 아니라 플랫폼의 API 설계가 결정한다. 첫 번째 플랫폼은 다른 방식으로 악용을 막기 때문에 평문을 노출해도 괜찮았고, 덕분에 지나가듯 확보할 수 있다. 두 번째는 "무엇을 가져올지"를 불투명한 봉투로 만들었으므로 추적할 평문이 없고, 재현만 가능하다.
판단이 가장 중요한 구간은 그 사이에 있는 진짜 persisted GraphQL이다. 평문이 클라이언트에 있을 수도 있다. 번들에 매핑이 들어간 경우라면 방법 1이다. 반대로 서버에만 있고 클라이언트에는 해시만 있다면 방법 2다. 시작하기 전에 어느 쪽인지부터 분류해야 한다.
시작 전 확인할 것: 평문 쿼리가 정말 필요한가
한 자릿수 이상 벌어지는 비용 차이는 결국 하나의 판단에서 나온다.
플랫폼이 해시만 클라이언트에 보내고 평문은 서버에만 보관하는데도, 평문을 복구하겠다며 방법 1을 고집한다면 며칠 동안 번들을 파고들고도 결국 찾는 대상이 애초에 배포된 적 없었다는 사실만 확인하게 된다. 이는 난이도 문제가 아니라 방향 문제다. 아무리 노력해도 결과는 나오지 않는다.
반대도 마찬가지다. 쿼리를 수정해야 한다면, 즉 클라이언트가 요청하지 않는 필드를 가져와야 한다면 방법 2의 블랙박스 재현은 도움이 되지 않는다. 평문을 얻기 위해 방법 1로 돌아가야 하며, 구할 수 없다면 거기서 막힌다.
따라서 순서는 "쿼리를 어떻게 얻을까"가 아니라, 먼저 "내게 평문 쿼리가 실제로 필요한가"를 묻는 것이어야 한다.
클라이언트가 이미 보내는 요청을 재현하고 응답만 읽으면 된다면: 방법 2(블랙박스 재현)다. 가장 저렴하고 가장 자주 간과되며, 평문 존재 여부와 관계없이 동작한다. 기본 선택지로 삼아야 한다.
selection set을 바꾸거나 클라이언트가 보내지 않는 쿼리를 구성해야 한다면: 방법 1(평문 복구)이 필요하다. 비용은 클라이언트가 매핑을 배포했는지에 따라 달라진다. 배포하지 않았다면 비용이 한 자릿수 이상 뛰며, 이를 감수하거나 쿼리를 정말 바꿔야 하는지 다시 검토해야 한다.
이 판단을 맨 앞에 두면 "방법 1로 달려들어 사흘을 막힌 뒤 방법 2였다는 사실을 깨닫는" 낭비 대부분을 막을 수 있다. 이처럼 "다시 작성할 것인가, 블랙박스를 감수할 것인가"를 두고 생기는 성능 저하의 트레이드오프는 정제 사다리 글의 주제이기도 하다. 여기서는 어느 확보 방식을 택할지 결정하는 기준이 된다.
매핑 찾기는 추론이 아니라 청크 검색이다
방법 1에서 기술적으로 가장 까다로운 단계는 수만 줄짜리 프론트엔드 빌드 안에서 operation 선언과 매핑을 찾아내는 일이다. 바로 이 지점에서 모델이 실질적인 시간을 줄여준다. 다만 먼저 이 작업이 어떤 종류인지 분명히 해야 한다.
이것은 추론 작업이 아니다. 모델이 코드가 무엇을 계산하는지 이해할 필요는 없다. 대규모 텍스트 안에서 operationName-해시 매핑을 선언한 블록, 평문 쿼리를 인라인한 모듈, context 봉투를 조립하는 위치를 찾아야 할 뿐이다. 즉 청크 검색이다. 중요한 것은 스스로 논박하는 능력이 아니라, 충분한 컨텍스트를 한 번에 담고 그 안의 위치를 정확히 짚는 능력이다.
먼저 기계적인 분할 단계를 거친다. 스크립트로 빌드를 모듈 단위로 나누고 인덱싱한 뒤, polyfill과 무관한 비즈니스 모듈을 걸러낸다. 그 결과를 모델에 넘기면 과제는 "이 블록들에서 선언을 찾아라"로 깔끔하게 단순화된다.
이 흐름에서는 네 가지 티어가 필요한 역량에 따라 명확히 나뉜다.
단계 | 필요한 역량 | 선택 | model id |
|---|---|---|---|
수만 줄에서 매핑 / operation 선언 찾기 | 긴 컨텍스트로 큰 빌드 청크를 담고 정확한 위치를 지목하는 능력 | Kimi K3 |
|
방법 1과 방법 2 결정, 몇 개의 샘플로 어떤 봉투 필드가 변하는지 추론 | 강한 추론력, 구조를 읽고 트레이드오프를 판단하는 능력 | Claude Opus 5 |
|
수백 개 operation 일괄 라벨링, 재현 스텁 생성, 변수 타입 채우기 | 저렴한 비용, 높은 동시성 | Claude Sonnet 5 |
|
재현이 연결되지 않을 때 차이를 읽고 원인 귀속하기(context 필드 누락? 해시 버전 변경?) | 중간 수준의 추론력, 응답 차이를 근거로 설명하는 능력 | GPT-5.6 Sol |
|
첫 번째 티어가 이 글의 핵심 영역이다. 선언을 찾는 단계에서는 모델을 바꾸면 결과가 눈에 띄게 달라진다. 이 작업을 제한하는 것은 컨텍스트 윈도우이기 때문이다. 빌드는 수만 줄에 이르며, 짧은 컨텍스트 모델은 이를 다 담지 못해 잘라내야 한다. 그 과정에서 매핑이 잘려 나갈 수 있다. 이후 모델이 "찾을 수 없다"고 답하는 이유는 검색을 못해서가 아니라 애초에 보지 못했기 때문이다.
차이는 직접 확인해보면 된다.
실제 요청을 캡처하고 operation 식별자(operationName 또는 해시)와 변수를 저장한다.
스크립트로 프론트엔드 빌드를 청크로 나눈 뒤, 식별자와 함께
kimi-k3에 전달한다. "이 operation이 선언된 위치와, 대응하는 평문 쿼리 또는 해시 매핑이 있는 블록"을 찾도록 요청한다.한 가지만 본다. 모델이 바로 해당 줄로 이동하는가. 정확히 맞는지, 놓치는지, 아니면 인접한 엉뚱한 위치를 짚는지 확인한다.
대조군으로 같은 입력을 짧은 컨텍스트 모델에 넣고, 담을 수 없어 놓치는지 확인한다. 적중률이 선택 기준이다.
한 번만 해봐도 이 유형의 작업에서 긴 컨텍스트는 "조금 더 좋다"가 아니라 가능과 불가능을 가르는 차이라는 점을 알게 된다.
진짜 장벽은 모델 전환 비용이다
세 벤더의 네 모델, 세 SDK, 세 인증 방식, 세 가지 오류 형식이 있다. 검색·판단·일괄 처리·원인 귀속에 각각 다른 모델을 쓰려면, 순진한 방식으로는 세 클라이언트를 모두 연결해야 한다. 대부분은 계산해본 뒤 그만한 가치가 없다고 판단하고 처음부터 끝까지 하나의 모델만 쓴다. 그러다 장문 컨텍스트 검색 단계에 빌드를 담지 못하는 티어를 쓰고는 "모델이 못 찾는다"고 결론 내린다.
AIReiter는 이 계층을 평탄화한다. 키 하나, OpenAI 호환 인터페이스 하나로 네 티어를 모두 사용할 수 있고, 요청 본문의 model 필드만 바꾸면 전환된다.
# Locate the mapping: the long-context tier, swallows a big chunked build at once
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "kimi-k3",
"messages": [{"role": "user", "content": "<chunked frontend build + the operation identifier to locate>"}]
}'
# Label operations / generate replay stubs in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Replay attribution:
# "model": "gpt-5.6-sol"
이미 OpenAI SDK를 사용 중이라면 base_url을 https://aireiter.com/api/v1로 지정하면 되며, 나머지는 바꿀 필요가 없다. Anthropic SDK에서는 같은 키로 POST /api/v1/messages를 호출하면 된다.
가격 기준으로 Claude 모델은 정가 대비 30% 할인, GPT 모델은 반값이며, Kimi K3도 같은 키로 호출할 수 있다. 이 흐름의 비용은 두 곳에 집중된다. 방법 1에서 수십만 토큰 규모의 청크 빌드 전체를 입력하는 kimi-k3 기반의 위치 탐색, 그리고 호출량이 가장 많은 단계인 수백 개 operation의 일괄 라벨링과 재현 스텁 생성이다. 후자는 30% 할인된 claude-sonnet-5에서 처리한다. 할인 혜택이 가장 호출 밀도가 높은 배치 작업에 정확히 적용된다.
가입 없이 사용해 보기: 먼저 빌드 청크를 직접 넣어 장문 컨텍스트 티어가 한 번에 매핑을 찾는지 확인한 뒤, 연동 여부를 결정하면 된다.
마무리
문서가 없는 GraphQL API라고 해서 연동할 수 없는 것은 아니다. Persisted operation은 요청에서 "무엇을 가져올지"만 빼냈을 뿐이며, 그 정보는 두 곳 중 하나에 있다. 클라이언트 빌드에 있다면 찾아내면 되고(방법 1), 서버에만 있다면 평문을 쫓지 말고 operation을 블랙박스로 재현하면 된다(방법 2).
두 방법의 비용 차이는 한 자릿수 이상이지만, 무엇이 더 철저한지가 선택 기준은 아니다. 평문이 클라이언트에 있는지, 그리고 쿼리를 변경해야 하는지가 먼저 답해야 할 두 질문이다. 시작 전에 이 두 가지를 판단하면 불필요한 작업 대부분을 피할 수 있다.
여기서 모델의 역할도 분명하다. 방법 1의 "수만 줄에서 선언 찾기"는 순수한 검색 작업이며, 장문 컨텍스트 티어는 이를 한 번에 담아 정확한 위치를 짚을 수 있다. 며칠 걸리던 탐색을 몇 분으로 줄이는 역할이다. 어떤 방법을 선택할지 대신 판단해주는 것은 아니다. 그 판단은 이 글을 읽은 뒤 사용자가 해야 할 일이고, 모델은 매핑을 찾아내는 반복 작업을 맡는다.