역공학에서 모델 환각을 막는 유일한 관문, 차분 테스트

마지막 업데이트: 2026-07-30 10:58:49

리프 함수를 읽은 모델이 이렇게 결론을 내린다고 해 보자. "이건 ChaCha20이고, 라운드 수만 키에서 파생한 변수로 바꿨습니다." 그럴듯하다. 그대로 재구현 코드를 작성하고 싶은 충동도 든다.

하지만 멈춰야 한다. 그 문장을 실행되는 assert로 바꾸기 전까지는, 어디까지나 문장일 뿐이다. 모델이 공개 구현을 얼마나 많이 학습했든 이 대상이 정말 맞는지는 확인할 수 없다. 이는 언어의 문제가 아니라 사실의 문제이며, 답을 얻는 방법은 원본과 재구현의 출력을 나란히 놓고 바이트 단위로 비교하는 것뿐이다.

이 글에서 다룰 것은 그 질문에 답하는 유일한 장치, 차분 테스트다. 4단계 흐름의 3단계이자 변형 지점 추적에서 나온 모든 가설이 도착해야 할 종착점이다. 모델은 의심을 만들고, 차분 테스트는 판정을 내린다.

모델에게 “맞나요?”라고 묻는 순간 막힌다

초보자가 모델에 가장 자주 던지는 질문은 이것이다. "이 구현이 올바른지 확인해 줄 수 있나요?"

이 질문은 세 가지 이유로 막다른 길이다.

첫째, 모델은 사용자의 기대에 동조하도록 작동한다. "이 구현이 맞나요?"라는 질문 자체에 이미 ‘맞았으면 한다’는 전제가 깔려 있고, 모델은 그 뉘앙스를 읽어 "네"라고 답하는 경향이 있다.

둘째, 모델에게는 판정 근거가 없다. 올바름을 판단할 유일한 근거는 동일한 입력 묶음에 대한 블랙박스의 출력인데, 그 정보는 모델의 컨텍스트에 들어 있지 않다. 비교할 정답이 없으니 코드가 "그럴듯하게 읽히는지"로 추측할 수밖에 없다. 그리고 난독화 코드가 가장 잘하는 일이 სწორედ 그럴듯하게 읽히는 것이다.

셋째, 판정을 맡길 상대가 잘못됐다. 정확성은 협상 가능한 의견이 아니라, 어떤 등식이 성립하느냐의 사실이다. 모델이 아니라 assert에 질문을 넘겨라. 모델은 맞다고 했지만 테스트가 실패하면 테스트가 이기고, 모델은 틀렸다고 했지만 모든 테스트가 통과해도 역시 테스트가 이긴다. 판정을 모델에게 맡기는 순간, 환각 위에 쌓기 시작하는 셈이다.

정보가 나오는 경계 입력 고르기

차분 테스트의 뼈대는 단순하다. 원본을 블랙박스로 두고, 같은 입력 묶음에 대해 재구현 결과를 케이스별로 비교하면 된다. 중요한 것은 반복문이 아니라 그 안에 넣는 입력 묶음이다.

평범한 무작위 문자열 1만 개를 넣어 모두 통과했다고 해서 증명되는 것은 없다. 일반 입력은 주 경로만 타고, 변형 지점은 대개 모서리에 숨어 있다. 실제로 정보를 주는 것은 경계값이다.

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

각 케이스는 특정 분기를 겨냥한다. 상위 바이트인 \xff는 부호 비트 처리를 숨길 수 없게 만든다. JS의 >>>>> 차이, Python에 & 0xff가 빠졌는지 여부가 이 한 케이스에서 드러난다. 특히 강력한 것은 B-1 / B / B+1 세트다. 블록 패딩 로직은 바로 여기서만 정체를 드러낸다. 전체 바이트 범위 테스트는 커스텀 알파벳용이다. 매핑 테이블에 문자가 하나라도 많거나 적으면 이 케이스는 예외 없이 실패한다.

차분 테스트 자체는 10줄도 안 된다.

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

변형 지점 가설이 표준 알고리즘에 관한 것이라면, 원본 블랙박스가 항상 필요한 것도 아니다. 공개 표준에는 권위 있는 테스트 벡터가 함께 제공되는 경우가 많다. 예를 들어 RFC 8439 §2.1.1에는 ChaCha20 quarter round의 고정 입출력 쌍이 있다. a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567를 넣으면 a=0xea2a92f4, …가 나온다. 먼저 재구현이 표준 벡터를 통과하게 만든 다음 대상 블랙박스와 비교하면, "ChaCha 구현 자체를 잘못 작성했다"와 "대상이 ChaCha를 변경했다"라는 두 문제를 깔끔하게 분리할 수 있다.

여기서 assert 메시지에 len(case)를 넣는 점에 주목하자. 이 한 줄이 전체 스위트에서 가장 유용한 진단 정보다. 7개 중 B+1 케이스만 실패한다면 패딩이나 캐리 문제일 가능성이 매우 높다. 전체 바이트 범위 케이스만 실패한다면 알파벳 매핑 문제다. 실패한 케이스의 길이가 잘못된 레이어를 곧장 가리켜 주므로, 추측할 필요가 없다.

같은 입력을 반드시 두 번 돌려라

서명 로직을 재구현할 때 가장 흔히 막히는 원인은 알고리즘 오판이 아니다. 아직 분리하지 못한 엔트로피 소스다.

이를 잡아내는 데는 같은 입력을 두 번 실행하는 한 줄이면 충분하다.

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

두 결과가 다르다면 구현 어딘가에 time.time(), nonce, 자동 증가 카운터처럼 호출마다 바뀌는 값이 섞여 있다는 뜻이다. 이 상태에서는 차분 비교조차 할 수 없다. 블랙박스가 매번 다른 답을 내놓는데 무엇과 비교하겠는가.

해결책은 엔트로피 소스를 삭제하는 것이 아니다. 삭제하면 서명 자체가 틀어진다. 대신 이를 코드 바깥의 주입 가능한 파라미터로 끌어올리고, 차분 테스트 중에는 고정값으로 묶어야 한다.

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

대상 블랙박스에도 똑같이 적용해야 한다. 타임스탬프나 난수가 주입되는 지점을 찾아 고정할 방법을 마련한다. 페이지에서 Date.now를 후킹하거나 Node에 고정 시드를 전달하는 식이다. 양쪽의 엔트로피를 모두 고정하면 출력은 다시 결정적이 되고, 그때부터 diff가 의미를 갖는다. 재구현이 처음부터 끝까지 통과한 뒤에는 clockrng를 실제 구현으로 되돌리면 된다.

이 역시 모델이 대신해 줄 수 없는 단계다. 엔트로피가 어디에 숨어 있고 어떻게 주입되는지는 런타임 동작의 문제다. 코드를 읽어서가 아니라 두 번 실행해서 끌어내야 한다.

최종 출력 대신 레이어별로 비교하라

재구현의 최종 출력이 블랙박스와 다르다고 하자. 마지막 바이트 문자열에만 매달리지 말자. 그 결과는 여러 중첩 레이어의 산물이고, 어느 레이어가 틀렸는지 아직 알 수 없다.

서명기는 대개 계층 구조다. 가장 안쪽에는 해시나 블록 암호가 있고, 그 바깥을 Base64 계열·hex·커스텀 테이블 같은 인코딩 레이어가 감싼다. 가장 바깥에는 접두사를 붙이고 필드를 삽입하며 길이 헤더를 더하는 조립 레이어가 있다. 레이어를 벗겨낸다는 것은 안쪽부터 비교하고, 현재 레이어가 통과했을 때만 한 단계 바깥으로 나가는 방식이다.

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

그러려면 재구현이 각 레이어의 중간 상태를 덤프할 수 있어야 하고, 블랙박스에서도 대응하는 중간값을 꺼낼 수 있어야 한다. 보통은 런타임 계측이 필요하다. 그 수고는 충분히 값어치가 있다. 처음으로 갈라지는 레이어가 곧 오류가 있는 레이어이므로, 조사 범위가 즉시 좁혀진다.

이는 변형 지점의 네 가지 범주와 바로 연결된다. digest 레이어에서 갈라지면 대개 상수가 변형됐거나 라운드 수가 바뀐 경우다. encode 레이어라면 보통 알파벳 순서가 바뀌었고, assemble 레이어라면 대개 어떤 재료가 출력에 삽입된 경우다. 어느 레이어에서 갈라졌는지가 핑거프린팅 글에서 어느 범주를 찾아야 할지 알려 준다.

시간이 갈수록 가치가 커지는 픽스처

차분 테스트가 녹색으로 바뀌는 순간은 기분 좋다. 하지만 그 결과는 일회성이다. 내일 업스트림이 새 버전을 내면, 오늘 검증한 구현이 완전히 틀려질 수도 있다.

시간이 갈수록 가치가 쌓이는 산출물은 리포지터리에 커밋하는 픽스처, 즉 알려진 입력 → 알려진 출력 테이블이다.

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

업스트림이 바뀌는 순간 픽스처의 진가가 나온다. 어느 날 CI가 빨갛게 변했는데, 어제 손대지 않은 구현이 픽스처에서 실패한다면 얻는 정보는 매우 값지다. "내가 실수했다"를 배제하고 문제를 "업스트림이 바뀌었다"로 정확히 좁혀 준다. 픽스처가 없다면 내 코드가 틀린 건지 상대가 기준을 바꾼 건지 알 수 없어, 멀쩡한 코드를 반나절씩 디버깅하게 된다.

픽스처에 넣기 가장 좋은 입력은 앞서 본 경계 케이스다. 이미 가장 높은 커버리지를 제공하는 입력 집합이기 때문이다.

이 단계에서 모델의 역할은 딱 두 가지다

차분 테스트의 역할 분담을 명확히 하자. 모델은 두 가지 일을 할 뿐이며, 판정에는 관여하지 않는다.

첫째, 케이스 생성. 다양한 프리미티브에 걸친 경계 입력을 대량으로 구성하거나, 의심스러운 상수를 확인하기 위한 1비트 차이 입력 묶음을 만드는 작업이다. 추론이 아니라 열거 작업이므로 저렴하고 동시성이 높은 티어가 가장 경제적이다.

둘째, diff 설명. 케이스가 실패했을 때 양쪽 레이어 덤프를 제시하고, 어떤 바이트에서 처음 차이가 나는지와 네 가지 변형 범주 중 어디에 속할 가능성이 높은지를 설명하게 한다. 이 단계에는 중간 수준의 추론과 ‘바이트를 근거로 설명하는’ 능력이 필요하다. 이 글에서 모델이 실제로 일을 하는 지점도 바로 여기다.

판정은 assert가 내리며, 이 원칙은 바뀌지 않는다. 모델의 설명은 결론이 아니라 단서다. 단서가 엉뚱한 방향을 가리키는 일은 흔하고, 그때도 assert가 걸러낸다.

이 두 작업이 요구하는 모델 역량은 다르다. 전 과정을 하나의 티어로 처리하면 비용을 낭비하거나 정확도를 잃게 된다.

차분 테스트 하위 단계

필요한 역량

추천

model id

경계·대조 케이스 대량 생성(여러 프리미티브 대상)

저비용·고동시성, 열거에는 추론이 불필요

Claude Sonnet 5

claude-sonnet-5

실패한 단일 diff를 읽고 바이트 근거로 설명

중간 수준 추론, 특정 레이어로 원인 귀속

GPT-5.6 Sol

gpt-5.6-sol

원인 귀속이 수렴하지 않을 때 깊은 근본 원인 분석(상수 변형 등)

강한 추론, 여러 라운드의 중간 상태를 가로질러 추론

Claude Opus 5

claude-opus-5

다수의 레이어 덤프 또는 전체 픽스처 배치를 받아 차이점 탐색

긴 컨텍스트

Kimi K3

kimi-k3

실무의 주력은 두 번째 티어다. 차이 귀속을 위해 모델을 고를 가치가 있는지는 직접 한 번 테스트해 보면 알 수 있다. 절차는 짧다.

  1. 차분 스위트에서 실제로 실패한 케이스 하나와 양쪽 레이어 덤프, 즉 블랙박스와 재구현의 덤프를 고른다.

  2. 같은 diff를 gpt-5.6-solclaude-opus-5에 각각 넣고 두 가지만 묻는다. 첫 차이가 발생한 레이어는 어디인지, 그리고 네 가지 변형 범주 중 어디일 가능성이 높은지다.

  3. 귀속 결과가 특정 바이트와 특정 레이어를 정확히 짚는지, 아니면 "패딩 문제일 수도 있다" 같은 모호한 답을 내놓는지만 본다.

  4. 선택 기준은 귀속 정확도다. 이 케이스를 통과시키기까지 몇 번의 수정 라운드가 필요한지를 직접 결정한다.

어떤 벤치마크 리더보드보다 직접적인 방식으로, 한 번의 테스트만 해도 차이를 알 수 있다.

진짜 문제는 티어 전환 비용이다

이 네 티어는 세 벤더에 걸쳐 있다. SDK도 세 개, 인증 방식도 세 개, 오류 형식도 세 개다. 하위 단계마다 티어를 바꾸겠다고 클라이언트를 세 번 연결하는 것은 수고 대비 가치가 없다. 그래서 대부분은 결국 하나의 티어만 전 과정에 쓰게 된다. 차이 귀속에서는 모호한 말만 하는 모델을 쓰고, 이유도 모른 채 여러 번 코드를 수정하게 된다.

AIReiter는 이 계층을 평탄화한다. 키 하나와 OpenAI 호환 인터페이스 하나로 네 티어를 모두 사용할 수 있고, 전환은 요청 본문의 model 필드만 바꾸면 된다.

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

이미 OpenAI SDK를 쓰고 있다면 base_urlhttps://aireiter.com/api/v1로 지정하면 된다. 나머지는 바꿀 필요가 없다. Anthropic SDK를 쓴다면 같은 키로 POST /api/v1/messages를 호출하면 된다.

가격 측면에서 Claude 모델은 정가 대비 30% 할인, GPT 모델은 반값이다. 이 워크플로에서는 가장 호출이 집중되는 단계에 할인이 적용된다. 차이 귀속은 차분 테스트에서 가장 자주 호출하는 작업이다. 실패 케이스마다 한 라운드가 필요하고, 업스트림이 바뀔 때마다 픽스처를 다시 만들며 새 실패 케이스 묶음 전체의 원인을 다시 귀속해야 한다. 주력 모델인 gpt-5.6-sol은 GPT 모델이라 반값이므로 가장 밀도 높은 작업의 비용이 그대로 절반이 된다. 깊은 근본 원인을 파기 위해 가끔 claude-opus-5로 올리는 경우는 호출 수가 적지만, 이때도 Claude 모델 30% 할인이 적용된다.

마무리

역공학 재구현의 뼈대는 사실 두 개뿐이다. 모델이 가설을 내고, assert가 판정을 내린다.

모델은 공개 구현을 폭넓게 본 의심 생성기다. "여기가 바뀌었을 수 있다"는 말은 몇 초 만에 해 줄 수 있지만, 이번 대상에서 정말 맞는지는 절대 알지 못한다. 차분 테스트는 ‘아마도’를 ‘맞다/아니다’로 바꾸는 장치다. 경계 입력은 분기를 끌어내고, 같은 입력을 두 번 실행하면 엔트로피 소스가 드러나며, 레이어별 분리는 오류 레이어를 고정하고, 픽스처는 "내 구현이 틀렸다"와 "상대가 바뀌었다"를 구분한다.

4단계 흐름변형 지점 추적 글이 건네는 모든 판단은 결국 이 관문을 지나야 한다. 모델이 한 말은 아무것도 확정하지 않는다. 확정하는 것은 오직 assert다.