webpack이 뱉어낸 서명 SDK를 열어 보면 상황은 대개 비슷하다. 변수명은 a와 _0x3f2b뿐이고, 제어 흐름은 납작하게 펼쳐져 있으며, 문자열은 배열 인덱스로 꺼내 쓴다. 목표는 단순하다. 원본과 바이트 단위까지 정확히 같은 결과를 내는 독립 구현체를 만드는 일이다.
이때 가장 먼저 떠오르는 방법은 번들 전체를 LLM에 넣고 “이 코드가 뭘 하는 거지?”라고 묻는 것이다. 하지만 대부분 여기서 어긋난다. 모델의 설명은 그럴듯하고 구현도 가능해 보이지만, 막상 대상 결과와 비교하면 단 1바이트도 맞지 않는다.
문제는 모델의 성능이 아니라 역할 배분이다. 난독화 해제에서 모델은 패턴 인식과 가설 생성에는 강하지만 사실 검증에는 약하다. 비트 연산 더미 속에 묻힌 암호 프리미티브의 골격은 잘 찾아내지만, 그 판단이 맞는지는 알려주지 못한다. 그 결론은 반드시 테스트로 내려야 한다.
이 글에서는 이 역할 분담을 기준으로 한 4단계 워크플로와, 모델에 절대 넘기지 않는 세 가지 일을 정리한다.
1단계: 기계적인 분리는 모델에 맡기지 않는다
컨텍스트 윈도우가 충분히 크니 번들을 통째로 넣자는 생각부터 들 수 있다. 그러지 않는 편이 좋다. 이유는 두 가지다.
우선 낭비가 크다. 난독화된 번들 대부분은 타깃과 무관한 polyfill, 런타임 shim, 비즈니스 모듈이다. 그런 코드까지 컨텍스트에 밀어 넣으면 비용을 치르고도 희석된 주의력만 돌려받는다.
더 큰 문제는 컨텍스트가 커질수록 환각이 끼어들 지점도 늘어난다는 것이다. 모델은 서로 관계없는 두 모듈의 특징을 엮어 내부적으로는 일관된, 그러나 실제 코드에는 존재하지 않는 결론을 낼 수 있다. 대놓고 말이 안 되는 답보다 이런 오류가 훨씬 잡기 어렵다.
코드 분리는 결정론적인 작업이다. 스크립트로 처리하자.
AST 도구(
@babel/parser,acorn)로 번들을 모듈과 함수 단위로 나누고, 스코프별 인덱스를 만든다.모든 숫자 리터럴과 문자열 상수를 추출한 뒤, 출현 빈도와 비트 폭 기준으로 묶는다.
호출 그래프를 만들고 in-degree가 0인 노드(진입점)와 out-degree가 0인 노드(리프 프리미티브)를 표시한다.
비트 연산 밀도가 유난히 높은 함수를 찾는다. 한 함수 안에
^,>>>,<<,&가 몰려 있다면 대개 알고리즘 핵심부다.
모델에 보여줄 대상은 바로 이 리프 프리미티브다. 보통 수십 줄 규모이고 상위 계층 상태에 의존하지 않으며, 입출력 경계도 분명하다. 참조 상수와 함께 잘라낸 40줄짜리 리프 함수 정도가 모델이 안정적으로 처리하는 단위다.
이 단계를 마치면 “후보 프리미티브 목록”이 생긴다. 각 항목에는 함수 본문, 참조 상수, 호출 위치가 담긴다. 이후의 모든 모델 호출은 이 목록에서 항목 하나씩만 대상으로 삼는다.
2단계: 알고리즘 계열 판별은 모델에게 맡긴다
이 단계는 모델이 대체하기 어려운 영역이다.
암호화·인코딩 알고리즘에는 강한 지문이 있다. 특정 상수, 특정 시프트 폭 조합, 특정 루프 구조가 그렇다. 사람은 경험을 쌓아 이를 알아보지만, 모델은 공개 구현체를 폭넓게 접했기 때문에 이런 식별을 자연스럽게 해낸다.
알고리즘 지문이 어떤 모습인지, 공개된 사례 몇 가지를 보자.
0x811c9dc5와0x01000193가 함께 보인다면 32비트 FNV-1a 해시의 offset basis와 prime이다. 공동 저자 Landon Curt Noll이 관리하는 FNV reference page에 표준값으로 공개돼 있으며, 그곳에서는 각각 2166136261과 16777619라는 10진수로 제시된다.0x61707865, 0x3320646e, 0x79622d32, 0x6b206574는 ASCII 문자열"expand 32-byte k"를 리틀엔디언 워드로 읽은 값이다. ChaCha20 초기 상태 상수이며 RFC 8439 §2.3에 나온다.a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);같은 구조에 회전량 16/12/8/7이 결합되면 ChaCha20 quarter round의 지문이다. 형제 알고리즘인 Salsa20은 7/9/13/18을 쓰므로, 숫자 네 개만으로도 둘을 구분할 수 있다.0xd76aa478으로 시작하는 64개 상수 테이블은 MD5의 T-table이다(RFC 1321 §3.4).0x63, 0x7c, 0x77, 0x7b로 시작하는 256바이트 테이블은 AES S-box다(FIPS 197, Table 4).0xEDB88320은 반사형 CRC-32 다항식이다. gzip spec, RFC 1952에서 사용하는 값이기도 하다.
핵심은 질문 방식에 있다. “이 코드가 뭘 하나요?”라고 물으면 설명문이 돌아온다. 필요한 것은 검증 가능한 구조화된 판정이다. 따라서 프롬프트는 후보, 근거, 반증의 세 부분을 반드시 출력하게 만들어야 한다.
아래는 난독화된 번들에서 추출한 리프 함수와,
그 함수가 참조하는 모든 숫자 상수입니다.
<function>
{{function body}}
</function>
<constants>
{{constant list, with locations}}
</constants>
아래 구조로만 답하세요. 산문 형태의 설명은 작성하지 마세요.
1. 후보 알고리즘 계열(가능성 순으로 최대 3개)
각 후보에 대해 이름과 분류를 제시하세요.
(hash / stream cipher / block cipher / encoding / compression / checksum)
2. 뒷받침하는 근거
모든 근거는 위의 특정 상수값 또는 특정 코드 줄을 가리켜야 합니다.
"구조적으로 유사함"처럼 검증 불가능한 표현은 허용하지 않습니다.
3. 반증과 표준 구현에서 벗어난 지점
이것이 해당 알고리즘의 표준 구현이라면 있어야 하는데 없는 것은 무엇인가요?
반대로 표준 구현에는 절대 없어야 할 것은 무엇인가요?
이 차이는 "변형"인가요, 아니면 "내 가설이 틀렸다"는 신호인가요?
4. 이 가설을 판정할 최소 테스트
구체적인 입력 3개와, 가설이 맞을 경우 각 입력이 만들어야 할 출력 형태를 제시하세요.
경계 조건도 포함하세요.
이 프롬프트의 가치는 3번에서 나온다. 표준 구현과 다른 지점이야말로 코드가 수정된 부분이다. 커스텀 알파벳, 바뀐 상수, 달라진 라운드 수 같은 것들이다. 재구현할 때 실제로 공을 들여야 하는 것도 이 부분뿐이며, 나머지는 공개 구현체에서 가져오면 된다. 모델이 이런 차이를 하나도 빠뜨리지 않고 드러내게 하는 방법은 알고리즘 지문 식별 글에서 다룬다.
4번 항목은 모델의 판단을 바로 다음 테스트로 옮겨 주므로 한 번의 왕복을 줄여 준다.
이 단계에서는 비표준 인코딩도 자주 만난다. 판별 자체는 기계적이다. 알파벳 길이가 65개이고(문자 64개와 패딩 기호 1개), 6비트씩 묶으며 출력 길이가 ceil(n/3)*4라면 Base64 계열이다. 알파벳 순서가 바뀌었다면 커스텀 테이블이다. 마찬가지로 사전이 256에서 시작해 채워질수록 출력 코드 폭이 늘어난다면 LZW다. 이런 특성은 코드 한 줄을 읽지 않아도 입력·출력 길이 관계만으로 검증할 수 있다.
3단계: 가설을 차등 테스트로 바꾼다
모델이 낸 것은 가설이다. 테스트를 작성하기 전까지는 그저 문장 하나일 뿐이다.
여기에는 지름길이 없다. 그리고 전체 워크플로에서 환각을 막는 유일한 관문이기도 하다. 왜 이것이 유일한 관문인지는 차등 테스트 글에서 자세히 설명한다. 원본 구현을 블랙박스로 두고, 재작성한 코드와 케이스별로 비교한다.
# 차등 테스트 골격: 원본은 블랙박스, 재구현은 케이스별로 비교
CASES = [
b"", # 빈 입력: 초기 상태와 패딩 로직을 드러냄
b"\x00", # 0 바이트 하나
b"\xff", # 상위 비트가 선 바이트 하나: 부호 비트 처리를 확인
b"a" * 63, # 블록 경계 바로 아래
b"a" * 64, # 정확히 한 블록
b"a" * 65, # 경계 바로 위: 패딩과 캐리를 확인
bytes(range(256)), # 전체 바이트 범위: 알파벳 매핑을 확인
]
for case in CASES:
assert rewritten(case) == blackbox(case), case.hex()
실무에서 유용한 기준 몇 가지가 있다.
경계를 넘는 입력이 가장 많은 정보를 준다. 블록 알고리즘은 64n과 64n±1에서 패딩 로직을 가장 잘 드러낸다. 전체 실행 중 단 하나의 케이스만 실패했다면, 해당 입력 길이만으로도 어느 계층이 잘못됐는지 곧바로 좁힐 수 있다.
같은 입력을 두 번 실행한다. 결과가 달라진다면 난수나 타임스탬프가 섞여 있다는 뜻이다. 이 경우 주입 지점을 찾아 외부에서 덮어쓸 수 있게 만들어야 한다. 그렇지 않으면 차등 비교 자체가 불가능하다. 서명 로직을 재작성할 때 가장 흔히 막히는 지점도 여기다. 알고리즘이 틀린 게 아니라 엔트로피 소스를 분리하지 못한 경우가 많다.
전체를 한 번에 비교하지 말고 계층별로 벗긴다. 가장 안쪽 해시부터 맞춘 다음, 통과하면 인코딩 계층을 맞추고, 그 뒤 조립 계층으로 올라간다. 전체 출력만 다르면 어디가 문제인지 알 수 없다. 반면 계층을 벗겨 비교하면 처음 실패한 계층이 곧 문제 지점이다.
고정 벡터는 fixture로 커밋한다. 알려진 입력 → 알려진 출력 표는 업스트림 업데이트 후 “내 구현이 틀린 건가, 아니면 상대가 바꾼 건가?”를 빠르게 가르는 유일한 기준이다. 이 fixture의 가치는 시간이 갈수록 커진다.
이 단계에서 모델이 할 일은 테스트 케이스 생성과 차이 설명뿐이다. 옳고 그름을 판정하는 것은 모델이 아니다. 그 역할은 assert가 맡는다.
4단계: 전송 계층에 따라 점진적으로 후퇴하며 배포한다
모든 가설이 통과했다면, 이제 장기 운영 가능한 코드로 옮길 차례다. 우선순위가 있으며, 앞선 단계일수록 더 강하게 지켜야 한다.
타깃 언어로 작성한 네이티브 재구현. 원본 런타임과 완전히 분리하고 표준 라이브러리에만 의존한다. 추가 프로세스도, 추가 의존성도 없으며 CI에 넣기도 가장 깔끔하다.
최소 조각을 실행하는 로컬 JS 엔진. 단기간에 정제하기 지나치게 비싼 로직도 있다. 이때는 원본 JS의 작은 부분만 남겨 로컬 Node/V8에서 실행한다. 단, JS 엔진 컨텍스트는 thread-safe하지 않다. 하나의 컴파일된 컨텍스트를 여러 스레드에서 호출하려면 잠금이 필요하다.
class Signer:
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)
수동형 브라우저 브리지. 실제 페이지 런타임에서만 얻을 수 있는 상태라면, 당장은 브라우저가 유일한 선택지다. 이는 임시 형태다. 인터페이스 문서에 분명히 표시하고 기본 구현으로 굳어지지 않게 해야 한다.
이 후퇴 순서는 프로젝트 규약으로 문서화할 만하다. 한 단계 내려갈 때마다 의존성 표면, 장애 모드, 배포 비용이 모두 자릿수 단위로 커진다. 1단계는 순수 함수지만 3단계는 세션 유지를 위해 사람이 관리해야 하는 외부 프로세스다. 기본값으로 1단계를 지키려 하면 “일단 되게 만들자”가 조용히 쌓아 올리는 장기 비용 대부분을 막을 수 있다. 세 계층의 경계를 어떻게 나누고 수동형 브리지가 통제 불능으로 커지지 않게 할지는 정제 사다리 글에서 다룬다.
참고할 만한 수치 하나를 들면, 난독화된 서명 SDK를 네 단계를 거쳐 처리하면 런타임 내장 crypto에만 의존하는 600줄 미만의 독립 구현체로 정리할 수 있다. 이 압축 비율은 모델이 원본 파일을 “이해”해서 생기는 것이 아니다. 알고리즘 계열만 정확히 식별하면 코드 대부분은 공개 구현체에서 그대로 가져올 수 있기 때문이다.
단계별로 어떤 모델을 쓸까
네 단계가 요구하는 역량은 서로 다르다. 하나의 모델로 처음부터 끝까지 처리하면 비용이나 정확도 중 하나를 낭비하게 된다.
단계 | 실제로 필요한 역량 | 추천 모델 | model id |
|---|---|---|---|
분리 후 구조 매핑 | 긴 컨텍스트, 모듈 하나의 호출 그래프를 한 번에 읽는 능력 | Kimi K3 |
|
알고리즘 계열 식별과 반증 | 강한 추론 능력, 차이를 찾고 스스로의 가설을 반박하는 능력 | Claude Opus 5 |
|
대량 심볼 이름 변경과 주석 보강 | 저렴한 비용, 높은 동시성으로 수백 건 호출 처리 | Claude Sonnet 5 |
|
차이 원인 분석(테스트 실패 시 diff 읽기) | 중간 수준의 추론, 특정 바이트를 근거로 차이를 설명하는 능력 | GPT-5.6 Sol |
|
특히 2단계를 주목할 만하다. 알고리즘 계열 식별은 모델을 바꿨을 때 결과 차이가 눈에 띄게 나타나는 유일한 단계다. 이 단계가 시험하는 것은 “공개 구현체를 얼마나 많이 봤는가”와 “자기 가설을 반박할 의지가 있는가”이기 때문이다. 같은 리프 함수를 넣어도 약한 모델은 자신 있게 틀린 답을 내놓고, 강한 모델은 반증 섹션에서 스스로의 후보를 지운다.
이 차이는 직접 시험해 보면 된다. 절차는 다음과 같다.
직접 확보한 난독화 번들에서 리프 함수 3개를 고른다. 이 중 최소 1개는 정답을 이미 알고 있는 함수로 골라 대조군으로 쓴다.
2단계의 세 부분 프롬프트를 사용해 같은 입력을
claude-opus-5와gpt-5.6-sol에 각각 넣는다.두 가지만 본다. 후보 계열을 맞혔는가, 그리고 반증 섹션이 정말 자기 가설에 반론을 제기하는가, 아니면 후보를 말만 바꿔 반복하는가.
선택 기준은 반증 섹션의 품질이다. 이것이 3단계에서 쓸모없는 테스트를 몇 개나 작성하게 될지 직접 결정한다.
3단계와 4단계는 사실 모델 선택이 거의 중요하지 않다. 실행만 되면 무엇이든 괜찮다. 1단계에는 긴 컨텍스트 모델이 있으면 충분하다. 직접 청크 기반 검색을 구현하는 수고를 덜 수 있다.
진짜 마찰은 모델 선택이 아니라 전환 비용이다
세 벤더에 걸친 네 모델을 쓰려면 SDK도 세 개, 인증 방식도 세 개, 오류 형식도 세 개가 된다. 약간의 비용을 아끼겠다고 클라이언트를 다시 쓰는 일은 보통 가치가 없다. 그래서 대부분 결국 하나의 모델만 모든 작업에 쓰게 된다.
AIReiter는 이 계층을 없앤다. 키 하나, OpenAI 호환 인터페이스 하나로 네 모델을 모두 쓸 수 있고, 요청 본문의 model 필드만 바꾸면 모델 전환이 끝난다.
# 알고리즘 계열 식별: 추론 티어
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": "<Stage 2 prompt + leaf function + constants>"}]
}'
# 대량 심볼 이름 변경: model 필드만 바꾸고 나머지는 그대로 둔다
# "model": "claude-sonnet-5"
이미 OpenAI SDK를 쓰고 있다면 base_url만 https://aireiter.com/api/v1로 지정하면 된다. 그 밖에는 바꿀 것이 없다. Anthropic SDK를 쓴다면 같은 키로 POST /api/v1/messages를 호출하면 된다.
가격 기준으로 Claude 모델은 정가 대비 30% 할인, GPT 모델은 반값이다. 이 워크플로에서는 생각보다 큰 차이다. 3단계의 대량 심볼 이름 변경은 쉽게 수백 번 호출로 늘어나고, 1단계의 긴 컨텍스트 처리도 입력 하나당 수십만 토큰에 이른다. 이 둘이 비용 대부분을 차지하며, 할인은 가장 비싼 구간에 정확히 적용된다.
가입 없이 사용해 보기 — 먼저 프롬프트 몇 개를 직접 실행해 두 모델의 반증 섹션을 비교하고, 모델을 정한 뒤에만 스크립트로 자동화하자.
모델에 맡기지 말아야 할 세 가지
첫째, 최종 구현체를 바로 작성하게 하지 않는다. 모델에 “완전한 재구현”을 요청하면 완성돼 보이고 실행도 되는 코드를 받는다. 문제는 미세한 차이가 숨어 있을 수 있다는 점이다. 계층별로 직접 검증한 코드가 아니므로 어디가 어긋났는지도 찾기 어렵다. 올바른 방식은 프리미티브별 가설을 받되 하나씩 검증하고, 최종 조립은 직접 하는 것이다. 더 느리지만 모든 코드 줄의 이유를 알 수 있다.
둘째, 결과의 정합성 판정을 맡기지 않는다. “이 구현이 맞는지 확인해 줄 수 있나요?”는 막다른 질문이다. 모델은 대체로 사용자의 의견에 동의하는 방향으로 답한다. 옳고 그름을 판정할 권한은 차등 테스트에만 있다. 모델은 맞다고 했지만 테스트가 실패했다면 테스트가 이긴다. 모델은 틀렸다고 했지만 모든 테스트를 통과했다면, 그 경우에도 테스트가 이긴다.
셋째, 컴플라이언스 판단을 맡기지 않는다. 대상에 접근해도 되는지, 결론을 공개해도 되는지, 얻은 데이터를 어떤 방식으로 이용할 수 있는지는 모두 관할 지역, 대상의 약관, 구체적인 목적에 따라 달라진다. 모델은 이를 판단할 사실적 근거가 없다. 모델의 답변은 그저 학습한 면책 문구의 어조를 흉내 낸 것일 뿐이다. 이 판단은 본인이 내리거나 실제 법률 자문에게 맡겨야 한다.
마무리
이런 작업에서 모델의 자리는 분명하다. 공개 알고리즘 구현체를 폭넓게 본 패턴 인식기로서, 몇 초 만에 후보 가설을 제시하는 역할이다. 답의 원천도 아니고 검증자도 아니다.
이 워크플로의 골격은 AI 사용 여부와 무관하다. 기계적 분리, 가설, 검증, 정제라는 네 단계는 모델이 등장하기 전부터 존재하던 절차다. 모델이 줄여 주는 것은 “가설” 단계뿐이다. 레퍼런스를 파고들며 며칠 걸리던 일을 몇 분으로 압축해 준다. 나머지 세 단계의 비용은 예나 지금이나 같다.
이 네 단계의 모델 호출을 스크립트로 고정하면 전체 워크플로는 빠르게 돌아간다. 그때 남는 마찰은 모델 전환뿐이다. 이는 인프라 문제이며, 하나의 통합 인터페이스 위에서 모델을 선택하는 방식으로 해결할 수 있다.
