AIREITER

코드에서 서명 로직이 안 보인다면? 애초에 코드에 없을 수도 있다

마지막 업데이트: 2026-07-31 07:45:33

webpack 번들을 통째로 덤프하고 AST를 잘라낸 뒤, 리프 프리미티브를 목록화해 이미 알고 있는 방식으로 핑거프린트를 읽어냈다고 하자. 이제 요청 헤더에서 매번 달라지는 서명값 하나를 마주한다. 이 값을 만드는 함수를 찾으려 비트 연산과 해시 구조를 의심하며 정적 에셋 전체를 뒤졌지만, 아무것도 나오지 않는다.

검색이 부족했던 게 아닐 수 있다. 덤프한 코드 안에 그 로직 자체가 없기 때문이다.

정적 분석으로는 원리상 답이 나오지 않는 서명 유형이 있다. 알고리즘이 런타임에만 나타나는 경우다. 이런 대상이라면 존재하지 않는 함수를 계속 찾을 게 아니라 전략을 바꿔야 한다. 알고리즘을 역분석하지 말고, 가능한 한 작은 범위에서 실행하거나 값을 읽어오면 된다.

정적 분석이 헛도는 두 가지 신호

무언가를 놓친 게 아니라 정말 이 유형의 대상인지부터 확인하자. 눈에 띄는 신호는 두 가지다.

첫 번째는 세션이나 요청마다 값이 바뀌는데, 정적 에셋 어디에도 이를 생성하는 코드가 없는 경우다. 네트워크 요청에서는 새로고침할 때마다 다른 값이 보인다. 모든 .js를 내려받아 전문 검색해도 조합 로직은 찾을 수 없다. 그 로직은 런타임에 전달된다.

두 번째는 번들에서 리터럴 문자열로 검색되지만, 어제 기록한 값이 오늘은 더 이상 작동하지 않는 경우다. 빌드 결과물에 평범한 문자열 상수로 들어 있어 그날 복사하면 쓸 수 있다. 그런데 며칠 뒤 엔드포인트가 오류를 내고, 다시 보면 그 “상수”는 또 다른 새 리터럴로 바뀌어 있다. 빌드 시점에는 하드코딩되지만 프런트엔드 릴리스마다 교체되는 값이다.

두 신호의 공통점은 하나다. 정적 뷰는 스냅샷이고, 실제 값은 시간이나 세션에 따라 흐르는 스트림이다. 코드를 검색해도 없거나, 찾았더라도 살아 움직인다. 어느 쪽이든 “한 번 정적으로 덤프해서 복사”하는 방식은 이미 효력을 잃었다. 이는 알고리즘 계열을 알아보지 못하는 문제와 다르다. 핑거프린트 매칭 능력이 부족한 게 아니다. 지금 손에 든 코드 사본에는 매칭할 대상이 없을 뿐이다.

유형 1: 서버가 실행할 알고리즘을 그때그때 내려준다

첫 번째 유형은 이렇다. 특정 엔드포인트에 접근하기 전, 서버가 일회성 JS 조각을 먼저 내려준다. 브라우저는 이 스크립트를 실행해 쿠키나 토큰을 만들고, 이를 지녀야 통과할 수 있다. 스크립트 내용은 대개 세션별로 다르고, 때로는 요청마다 달라진다.

정적 분석이 반드시 실패하는 이유는 명확하다. 로직이 런타임에 전송되므로 정적 번들에는 애초에 없다. 우연히 캡처한 스크립트는 그 시점의 인스턴스 하나일 뿐이다. 오늘 덤프한 코드와 내일 전송되는 코드는 서로 완전히 다를 수 있으며, 이를 역분석하는 일은 움직이는 표적을 쫓는 일이다.

정답은 블랙박스로 실행하는 것이다. 무엇을 계산하는지 이해할 필요는 없다. 결과가 나올 만큼만 실제에 가까운 환경을 제공하고, 결과만 가져오면 된다. 실무에서는 보통 다음처럼 접근한다.

  • 최소한의 브라우저 환경 shim을 만든다. document, location, navigator, cookie, 몇 가지 Observer와 타이머를 비어 있는 스텁으로 두고, 누락된 전역 객체 때문에 스크립트가 예외를 내지 않을 때까지만 채운다.

  • 전송받은 소스를 실행 시간 제한과 함께 로컬 Node/V8 샌드박스(node:vm 또는 execjs로 시작한 프로세스)에서 돌린다.

  • 스크립트는 실행 중 쿠키를 쓰거나 어떤 전역값에 값을 넣는다. 그 쓰기 동작을 가로채 필요한 토큰을 꺼낸다.

Zhihu의 방문자 검사가 바로 이 형태다. 전송된 스크립트가 브라우저 쿠키에 방문자 식별자를 기록하면, DOM 환경을 shim으로 구성해 실제 페이지에서 실행되는 것처럼 믿게 만들고 완료 후 값을 가져온다. 이 과정에서 알고리즘 한 줄도 역분석하지 않는다. 스크립트가 자기 역할을 수행할 수 있을 만큼 그럴듯한 무대만 제공할 뿐이다. 따라서 비용은 “알고리즘을 이해하는 일”이 아니라 “환경을 충분히 실제처럼 유지하는 일”에 든다. 스크립트는 navigator.webdriver를 확인하고, 특정 노드의 존재를 검사하며, 어떤 API가 값을 돌려주리라 기대한다. shim은 정확히 이를 속여야 하지만, 유지보수가 불가능할 만큼 커져서도 안 된다. 아래의 “최소 실행 표면”은 바로 이 균형을 다룬다.

유형 2: 코드 안에는 있지만 릴리스마다 바뀌는 동적 식별자

두 번째 유형은 반대다. 값은 실제로 정적 번들 안에 리터럴로 존재하지만, 빌드 시 생성되어 프런트엔드 릴리스마다 바뀐다.

대표 사례는 평문 GraphQL 대신 사전 등록 쿼리를 사용하는 현대적인 프런트엔드다. 타임라인 조회나 검색 결과 조회 같은 각 작업은 빌드 시점의 operation 또는 query id에 매핑되고, 이 id가 엔드포인트 경로에 담긴다. 번들에서 검색할 수는 있지만 프런트엔드가 새 릴리스를 배포하는 순간 동일 작업의 id는 새 값으로 바뀐다.

정적 분석은 여기서 가짜 성공감을 준다. 값을 찾아 복사하고 그날 바로 실행한 뒤 하드코딩한다. 2주 뒤 엔드포인트가 400을 반환하고서야, 그 “상수”가 살아 있는 값이었다는 사실을 깨닫는다. 이미 찾았다고 믿었다는 점에서 유형 1보다 더 교묘하다.

올바른 전략은 역분석이 아니다. 알고리즘이 아니라 빌드 상수이기 때문이다. 요청 시점에 현재 페이지에서 최신 값을 읽어 세션 동안 캐시해야 한다. 읽는 방법은 저비용부터 고비용까지 후보가 있으며, 앞쪽부터 시도하는 편이 좋다.

  1. 페이지가 이미 로드한 리소스를 본다. 페이지는 이 식별자를 담은 요청을 방금 보냈으므로, 현재 값은 그 URL에 이미 들어 있다. 리소스 기록에서 추출하면 된다. 알고리즘 한 줄을 건드리지 않고 존재하는 사실을 읽는 가장 저렴한 방법이다.

  2. 여기서 꺼낼 수 없다면 스크립트 소스에서 패턴으로 추출한다. 현재 번들에서 “이 operation 이름은 이 id에 매핑된다”는 선언을 찾아 값을 가져온다.

  3. 그마저 실패할 때만 번들러의 모듈 테이블을 파고들거나, 단서를 따라 관련 번들을 가져와 파싱한다. 비용이 가장 높으므로 마지막 수단이다.

Twitter의 타임라인과 검색 operation id는 정확히 이 방식으로 읽는다. 페이지가 이미 전송한 GraphQL 요청 URL에서 id를 꺼내고, 확보한 뒤에는 세션 동안 캐시해 계속 사용한다.

사전 등록 쿼리를 얻는 방법에는 무엇이 있는지, 각 방법의 비용은 어떤지, 상황별로 무엇을 선택할지는 persisted operation을 다룬 글에서 체계적으로 설명한다. 여기서는 이것이 런타임 파생값이라는 더 큰 범주에 속한다는 점만 짚어두겠다.

두 유형을 가르는 기준

두 유형을 나란히 놓으면 각각 무엇을 해야 할지 선명해진다.

유형 1: 챌린지

유형 2: 동적 식별자

실제 값이 있는 곳

런타임에 전송되며 정적 코드에는 없다

정적 코드에 있지만 릴리스마다 바뀐다

해야 할 일

실행: 실제 알고리즘을 돌리고 부수 효과를 가져온다

읽기: 상수를 찾아 읽고 알고리즘은 실행하지 않는다

실패 양상

환경 shim이 너무 얇아 코드가 끝까지 실행되지 않는다

번들 구조 변경 후 리더가 놓쳐 오래됐거나 빈 값을 얻는다

변경을 일으키는 쪽

서버, 언제든 가능

프런트엔드 릴리스, 배포 주기에 따라

한 줄로 정리하면 두 유형 모두 “한 번 정적으로 덤프해서 복사”를 깨뜨린다. 차이는 실제로 코드를 실행해야 하는지뿐이다. 어느 유형인지 알면 다음 단계가 샌드박스인지 추출기인지 곧바로 결정된다.

이런 대상에 정제를 억지로 적용하면 안 되는 이유

여기서 이런 반문이 나올 수 있다. 전송된 스크립트의 알고리즘을 완전히 역분석하거나, 동적 식별자의 생성 규칙을 완전히 재구성해 원래 런타임과 분리된 네이티브 구현으로 만들 수는 없을까? 이는 4단계 워크플로의 4단계에 해당하는 정제다. 한 번 투자해 장기적으로 의존성을 없애고 CI에 넣는 가장 깔끔한 형태다.

하지만 이 두 유형에서는 대체로 수지가 맞지 않는다. 거칠지만 쓸 만한 투자 회수 모델은 다음과 같다.

정제에는 일회성 비용 C가 든다(알고리즘 역분석과 differential testing 검증). 정제 후에는 “매번 실행하거나 읽기” 방식보다 단위 시간당 s를 절약한다. 투자 회수 기간은 대략 C / s다.

결정 변수는 C나 s가 아니다. 대상이 바뀌는 주기 T, 즉 변경 주기다.

  • T < C/s: 투자 회수 전에 대상이 바뀐다. 정제한 구현은 배포 후 며칠 안에 일치하지 않게 되고, 다시 작업해야 한다. 수익률은 음수다.

  • T가 C/s보다 훨씬 크다: 정제는 확실히 이득이다. 한 번의 투자가 오래가므로 정제 사다리를 따라 네이티브 재구현 단계로 올라가야 한다.

두 유형은 자연스럽게 서로 다른 위치에 놓인다.

  • 유형 1의 T는 서버가 통제하며 얼마든지 짧아질 수 있다. 서버는 전송하는 스크립트 로직을 언제든 바꿀 수 있고, 이를 예측할 수 없다. 따라서 거의 항상 “실행” 쪽에 놓인다. 상대가 내일 바꿀 알고리즘을 억지로 정제하면, 내 운명을 상대의 릴리스 주기에 맡기게 된다.

  • 유형 2의 T는 프런트엔드 릴리스 주기이며 며칠에서 몇 달이다. 리더를 더 견고하게 만들수록, 예를 들어 fallback을 늘리고 앵커를 안정적으로 잡을수록 C/s는 낮아진다. 어느 쪽도 합리적일 수 있으므로 실제로 계산해 봐야 한다.

이것이 제목이 뜻하는 바다. 코드에서 찾지 못했다고 반드시 무언가를 놓친 것은 아니다. 안정적으로 정제할 만한 알고리즘 자체가 없을 수도 있다.

최소 실행 표면을 설계하는 법

“정제” 대신 “실행”을 택했다면 엔지니어링 목표도 달라진다. 깔끔한 구현이 아니라, 실행 표면을 최소화하고 통제 가능하며 원인 추적 가능하게 만드는 것이 목표다. 핵심은 세 가지다.

필요한 조각만 실행한다. 페이지 전체 런타임을 옮겨오지 말자. 알고리즘이 실제로 의존하는 코드와 최소 shim만 넣는다. shim 크기에는 적정점이 있다. 코드가 예외 없이 실행될 정도로만 작아야 한다. 스텁 하나를 추가할 때마다 유지보수 부담이 늘어난다. 상대가 검사 방식을 바꾸면 따라가야 한다. 반대로 스텁 하나가 빠지면 즉시 깨진다. 편하다고 브라우저 환경 전체를 들여오지 마라. 그러면 유지하는 것은 서명기가 아니라 브라우저 반쪽이 된다.

컨텍스트를 잠근다. 컴파일된 V8/Node 컨텍스트는 스레드 안전하지 않다. 일회성 챌린지는 매번 새 샌드박스를 열면 자연스럽게 서로 간섭하지 않는다. 하지만 시작 비용을 줄이려고 컴파일한 컨텍스트를 재사용하거나, 요청 간 공유를 위해 동적 식별자 파서를 캐시하는 순간 동시 호출은 직렬화해야 한다.

class RuntimeSigner:
    def __init__(self, source: Path) -> None:
        self._context = execjs.get("Node").compile(source.read_text())
        self._lock = threading.Lock()  # V8 context is not thread-safe

    def call(self, fn: str, *args):
        with self._lock:
            return self._context.call(fn, *args)

실패 원인을 추적할 수 있어야 한다. 실행 방식으로 값을 가져올 때 가장 간과되지만 시간을 가장 많이 아껴주는 부분이다. 실패는 어느 계층의 문제인지 말해줘야 한다.

  • 환경이 너무 얇은 경우: 코드가 ReferenceError를 던지거나, 멈춰 있다가 시간 초과된다. shim에 필요한 무언가가 빠진 것이다.

  • 프로토콜이 바뀐 경우: 코드는 끝까지 실행되고 출력도 나오지만 형태가 맞지 않거나 JSON 파싱이 안 된다. 출력 형식이 바뀐 것이다.

  • 의미 조건을 충족하지 못한 경우: 값을 얻었고 파싱도 되지만 불완전하거나, 실제 사용 시 검증에 실패한다. 알고리즘 자체가 바뀐 것이다.

세 경우의 해결책은 완전히 다르다. shim을 보완하고, 프로토콜 변경을 따라가고, 알고리즘을 점검해야 한다. 무조건 “실패했습니다”만 보고하는 실행기는 매번 처음부터 조사하게 만든다. 성숙한 챌린지 실행기는 실행 실패, 파싱 불가 출력, 불완전한 결과, 시간 초과를 각각 별도 오류로 구분한다.

단계별로 어떤 모델을 쓸까

이 흐름에서 모델의 역할은 핑거프린팅 작업과 다르다. 핑거프린팅에서는 모델이 알고리즘 계열을 식별한다. 여기에는 식별할 알고리즘이 없고, 모델의 주된 역할은 “정제할지 실행할지”라는 아키텍처 판단을 돕고 실행 실패의 원인을 분류하는 데 있다. 네 하위 단계가 모델에 요구하는 능력은 서로 완전히 다르다.

하위 단계

필요한 역량

선택

model id

정제 vs 실행 결정(양쪽 논거 검토)

강한 추론, 자기 결론에 반대하는 논증 능력

Claude Opus 5

claude-opus-5

동적 식별자가 숨은 위치 찾기: 번들 전체에서 상수 주입 지점과 읽기 후보 탐색

긴 컨텍스트, 번들 전체를 한 번에 읽는 능력

Kimi K3

kimi-k3

대상 operation을 가질 법한 후보 스크립트 대량 필터링

저렴한 비용, 높은 동시성으로 수백 회 호출

Claude Sonnet 5

claude-sonnet-5

실행 실패 시 로그나 스택을 읽고 어느 계층이 실패했는지 판별

중간 수준 추론, 특정 오류를 근거로 설명

GPT-5.6 Sol

gpt-5.6-sol

특히 첫 행은 강조할 만하다. 이 글에서 모델을 바꾸면 결과가 눈에 띄게 달라지는 유일한 단계이기 때문이다. 여기서 시험하는 것은 양쪽을 모두 논증하고 자기 결론에 반론을 제기하는 능력이다. 핑거프린팅 작업의 반증 섹션과도 같은 역량이다. 약한 모델은 한쪽 경로를 골라 놓고 이를 뒷받침하는 이유만 쌓으며, 반대편을 진지하게 검토하지 않는다. 강한 추론 모델은 “정제”와 “실행”을 끝까지 각각 논증하고, 양쪽의 가장 강한 근거와 실패 조건을 적은 뒤 비교해 결론을 낸다.

차이는 직접 시험해 보면 된다.

  1. 이미 결론을 내린 실제 대상을 대조군으로 잡는다. 실행해야 할지 정제해야 할지 감으로도 알고 있는 대상이면 된다.

  2. 관찰한 사실(런타임에 로직이 전송되는지, 릴리스 상수인지, 얼마나 자주 바뀌는지, 환경 의존성이 얼마나 깊은지)을 claude-opus-5와 gpt-5.6-sol에 각각 넣고 “정제 vs 실행” 의사결정 메모를 작성하게 한다.

  3. 두 가지를 본다. 변경 주기를 결정 변수로 인식했는가? 구현 난이도만 비교했다면 탈락이다. 또 입장을 뒤집는 조건은 관찰 가능한가? “다음 릴리스에서 식별자가 더 이상 바뀌지 않으면 정제로 전환한다”처럼 발동 신호가 있어야 쓸 수 있다.

한 번만 해보면 실제로 결정을 내리는 모델과, 그저 당신 대신 결론만 말하는 모델을 구분할 수 있다.

진짜 장벽은 모델 교체 비용이다

세 벤더의 모델 네 개, SDK 세 개, 인증 방식 세 개, 오류 형식 세 개. 하위 단계마다 모델을 바꾸려고 클라이언트를 세 번 다시 작성하는 일은 가치가 없다. 그래서 대부분은 전 과정을 하나의 모델로 처리한다. “정제할지 실행할지” 단계에서 자기 결론에 반박하지 못하는 모델을 쓰고, 상대가 내일 바꿀 대상을 정제하는 데 뛰어든다. 출발부터 방향이 틀렸다는 사실을 깨닫기까지 한참 돌아가게 된다.

AIReiter는 이 계층을 없앤다. 키 하나와 OpenAI 호환 인터페이스 하나로 네 티어를 모두 사용하며, 요청 본문의 model 필드만 바꾸면 된다.

# Purify or execute: the reasoning tier arguing both sides
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<observed facts + argue the strongest case for both purify and execute>"}]
  }'

# Locate the dynamic identifier across the bundle: change the model field, leave the rest
#   "model": "kimi-k3"
# Bulk-filter candidate scripts:
#   "model": "claude-sonnet-5"
# Attribute an execution failure:
#   "model": "gpt-5.6-sol"

이미 OpenAI SDK를 쓴다면 base_url을 https://aireiter.com/api/v1로 지정하고 나머지는 바꾸지 않으면 된다. Anthropic SDK에서는 같은 키로 POST /api/v1/messages를 호출하면 된다.

가격 측면에서 이 흐름의 토큰 사용량은 특정 구간에 집중된다. Kimi K3는 동적 식별자를 찾기 위해 프런트엔드 번들 전체를 읽으므로 입력당 수십만 토큰이 들고, Claude Sonnet 5는 후보 스크립트를 대량 필터링하므로 수백 회 호출이 쉽게 발생한다. 이 둘이 비용 대부분을 결정한다. Claude 30% 할인은 대량 필터링(Sonnet)과 의사결정 논증(Opus)에 적용되고, GPT 반값은 실패 원인 분류(GPT-5.6 Sol)에 적용된다. 긴 컨텍스트로 번들 전체를 읽는 K3도 같은 키로 호출할 수 있다. 할인은 막연히 저렴한 모델이 아니라, 토큰 사용량이 가장 큰 배치 작업과 가장 비싼 추론 티어에 걸려 있다.

  • API 키 받기

  • 가입 없이 사용해 보기: 먼저 “정제 vs 실행” 의사결정 메모 몇 개를 직접 돌려 보고, 변경 주기 인식 능력에서 두 모델을 비교한 뒤 연동 여부를 결정하자.

마무리

정적 분석에서 아무것도 나오지 않는다고 해서 항상 실력이 부족한 것은 아니다. 대상 자체가 문제일 수 있다. 서명값은 런타임에 전송되거나, 프런트엔드가 릴리스될 때마다 바뀌기 때문에 코드 안에 안정적으로 존재하지 않는다.

이 두 유형에서는 존재하지 않는 함수를 찾는 일을 멈춰야 한다. 챌린지 유형이라면 결과가 나올 만큼만 실제적인 샌드박스를 세워 최소 범위에서 실행한다. 동적 식별자라면 런타임에 읽고 세션 동안 캐시한다. 두 경로 모두 먼저 인정해야 할 사실은, “정적 스냅샷”이라는 관점 자체가 더는 작동하지 않는다는 점이다.

그리고 “정제할지 실행할지”는 순수한 아키텍처 판단이다. 결정 변수는 하나, 대상의 변경 주기가 일회성 투자 회수 기간보다 짧은지다. 변경 주기가 짧은 대상, 특히 서버가 언제든 전송할 수 있는 유형에 정제를 강요하면 수익률은 음수가 된다. 양쪽 논거를 모두 검토하는 일을 자기 결론에 반박할 수 있는 추론 티어에 맡기고, 머릿속 직감만으로 결정하지 마라. 모델이 대신 정제하게 해서도 안 된다. 정제는 결정론적 엔지니어링이며, 검증은 differential testing에 속한다. 이는 4단계 워크플로의 3단계와 4단계에서 다룬다. 여기서 모델은 한 가지 생각을 돕는다. 이 대상을 정말 역분석할 가치가 있는가.