에이전트에게 제품을 쥐여줄 때, 진짜 문제는 충실도다
제품의 관리 콘솔을 MCP 도구로 감쌌다. 도구마다 단위 테스트를 붙였고, 전부 초록불이었다. 그런데 에이전트에게 데이터 암호화 키를 만들라고 시키자, 나온 결과가 사람이 콘솔에서 만든 것과 달랐다. 어떤 값은 비어 있었고, 어떤 경우엔 사람이었다면 화면이 막았을 입력이 그대로 통과했다.
이상한 것은 테스트였다. 도구의 테스트도, 콘솔 화면의 테스트도 모두 통과하고 있었다. 각자의 테스트는 각자의 코드가 시킨 대로 도는지만 확인했지, 둘이 같은 결과에 도달하는지는 아무도 확인하지 않았다. 테스트가 통과한 것과 제품이 올바른 것은 다른 문제였다.
제품을 에이전트가 다루게 하는 일에서 도구를 만드는 것은 쉬운 축에 속한다. 어려운 것은 그 도구가 사람이 화면으로 하던 조작과 똑같은 결과에 도달하도록 만드는 것이다. 나는 이것을 충실도(faithfulness)라 부르기 시작했다. 그리고 충실도가 없으면, 에이전트가 아무리 많은 테스트를 돌려도 그것은 사람이 실제로 밟는 경로를 검증하지 못한다.
초록불이 보증하지 않는 것
도구에는 단위 테스트가, 콘솔 화면에는 E2E 테스트가 붙어 있었다. 둘 다 초록불이었다. 그런데 이 초록불이 정확히 무엇을 보증하는지 따져보면 문제가 드러난다.
모든 테스트는 저마다 오라클(oracle) — 무엇을 정답으로 볼지에 대한 기준 — 을 갖는다. 도구의 단위 테스트가 쥔 오라클은 “도구가 만든 요청이 내가 적어 둔 기대값과 같은가” 였다. 화면 E2E 의 오라클은 “폼을 조작하면 화면이 기대대로 반응하는가” 였다. 두 오라클은 각자의 경로 안에서 완결적이다. 도구는 도구의 기대를, 폼은 폼의 기대를 충족한다. 각자의 세계 안에서는 아무 문제가 없다.
이것이 병렬 구현 표류(parallel implementation drift)의 전형이다. 같은 의도 — “키를 만든다” — 를 두 벌의 코드가 각각 구현하고, 각자 자기 테스트를 들고 있다. 함정은 여기 있다. 한쪽에 테스트를 더 촘촘히 붙일수록 그 경로에 대한 확신은 커지지만, 어느 테스트도 두 경로를 서로 견주지 않는다. 양쪽 다 초록불이고 커버리지도 높으니 확신은 이중으로 쌓이는데, 정작 “두 경로가 일치하는가” 라는 질문은 누구의 사정거리에도 없다. 확신의 총량이 늘어날수록 갭은 오히려 더 깊이 묻힌다.
빠진 오라클의 정체는 분명하다. “도구가 만든 결과와 폼이 만든 결과가 서로 같은가”, 즉 두 경로의 등가성(equivalence)이다. 이 기준은 어느 쪽 테스트에도 없었다. 도구의 골든 테스트(golden test)는 도구의 출력을 도구 자신의 스냅샷에 고정할 뿐, 그 스냅샷이 폼의 출력과 일치하는지에는 관여하지 않는다. 더 뼈아픈 것은 그 스냅샷을 처음 적은 사람이 도구를 짠 나 자신이었다는 점이다. 나는 “마스킹 문자는 비어도 된다” 는 잘못된 가정을 기대값으로 적었고, 골든 테스트는 그 가정을 충실히 지켰다. 통과하는 테스트가 곧 옳은 테스트는 아니다. 틀린 기대값을 고정한 테스트는 버그를 놓치는 데 그치지 않고, 버그를 정답이라 우기며 방어한다.
마스킹 문자가 정확히 그 틈으로 빠져나갔다. 이 값은 사용자가 고르는 항목이 아니라 제품이 정해 둔 고정 기본값이라, 콘솔 폼에는 입력칸조차 없다. 폼은 사람이 신경 쓰지 않아도 알아서 기본값을 채워 API 로 보낸다. 도구에는 그 층이 없어 값을 비운 채 보냈다. 도구의 단위 테스트는 “비운 값이 그대로 실려 나가는가” 를 확인했으니 통과했고, 폼의 E2E 는 자기 경로에서 통과했다. 라인 커버리지도 브랜치 커버리지도 높았다. 그러나 커버리지는 어떤 코드가 실행됐는지를 셀 뿐, 두 경로가 같은 결과로 수렴하는지는 세지 않는다. 실사용에서 에이전트가 도구 경로로 제품을 몰기 시작하자, 그제야 두 경로가 벌어져 있었다는 게 드러났다.
갭은 경로가 갈라진 데서 온다
왜 두 경로가 갈라졌나. 사람의 조작과 API 사이에는 화면이라는 암묵적 계약 층이 끼어 있기 때문이다.
콘솔 폼은 단순한 입력창이 아니다. 사람이 값을 넣는 순간부터 요청이 떠나기까지, 폼은 여러 일을 조용히 대신한다. 비어 있는 필드에 제품 기본값을 채우고(default injection), 입력이 규칙에 맞는지 검증하고(validation), 사람이 고른 AES-256-CBC 같은 라벨을 실제 암호 타입·키 길이·운영 모드 파라미터로 풀어내고(cipher-suite resolution), 여러 단계로 나뉜 작업을 하나의 순서로 묶는다(orchestration). 사람은 이 계약의 존재를 의식하지 못한 채 혜택만 받는다. 화면에서 키 하나를 만드는 단순한 동작 뒤에, 이만큼의 보이지 않는 처리가 서 있다.
문제는 기존 테스트가 이 계약 층을 어느 쪽에서도 정면으로 겨누지 않았다는 데 있다. 백엔드 테스트는 파라미터가 이미 다 풀린 요청을 받는 지점부터 검증했으니, 계약이 실행되기 전을 본다. 화면 E2E 는 계약이 프론트 코드 안에 녹아 있으니 폼을 통해 우회적으로만 건드린다. 그 사이 도구는 계약 층을 통째로 건너뛰고 API 를 직접 때렸다. 계약은 어느 테스트에서도 일급(first-class) 검증 대상이 아니라, 프론트엔드 코드 곳곳에 흩뿌려진 채 폼을 거칠 때만 우연히 실행되는 무언가였다. 도구가 재현해야 하는 것은 입력 필드가 아니라 그 필드 뒤에 숨은 계약이었는데, 그 계약을 명시적으로 붙든 테스트가 애초에 없었다.
여기서 관점을 하나 바로잡아야 했다. 무엇을 기준(reference)으로 삼을 것인가. 폼은 사용자가 실제로 쓰는 경로이고, 현장에서 오래 검증돼 온 사실상의 명세(de facto spec)다. 그러니 도구의 옳고 그름은 도구 자신의 기대값이 아니라 폼의 실제 동작에 비추어 판정돼야 한다. 그동안 도구는 잘못된 기준 — 자기가 적어 둔 픽스처 — 에 대고 스스로를 측정하고 있었다. 버그의 본질은 값이 틀린 것이 아니라, 틀린 기준에 맞춰 초록불을 켜고 있었다는 데 있었다.
더 나쁜 경우도 있었다. 초기 도구에는 암호 타입·키 길이·운영 모드를 숫자로 직접 지정하는 raw 경로가 있었다. 사람은 결코 그렇게 입력하지 않는다. 드롭다운에서 스위트를 고를 뿐이다. 이 raw 경로는 계약 층을 우회하는 뒷문이자, 사람의 경로에는 대응점이 아예 없는 코드였다. 이런 경로를 테스트한다는 것은 존재하지 않는 사용자의 행동을 검증하는 셈이다. 테스트가 늘어날수록 실물과의 거리가 오히려 멀어지는 방향이었다.
두 경로를 하나로 합쳤다
갭을 하나씩 개별로 막는 것은 두더지 잡기다. 마스킹 문자를 채우고, 검증을 더하고, 스위트 해석을 맞춰도, 다음에 폼이 규칙을 하나 바꾸면 도구는 다시 벌어진다. 근본 원인은 개별 누락이 아니라 계약 층이 두 벌로 존재한다는 것이었다. 폼 안에 한 벌, 도구 안에 또 한 벌. 같은 규칙을 두 곳에 두면 언젠가 반드시 어긋난다. 그러니 고쳐야 할 것은 값이 아니라 구조였다.
그래서 방향을 바꿨다. 계약을 한 벌로 만들었다. 요청 본문을 만드는 빌더, 입력을 검증하는 규칙, 스위트를 파라미터로 푸는 해석기를 프론트 코드에서 떼어내, 폼·서버·도구가 함께 의존하는 단일 원천(single source of truth)으로 끌어올렸다. 도구는 더 이상 자기만의 빌더를 갖지 않는다. 폼이 부르는 그 함수를 그대로 부른다.
# before — the contract lives twice; each copy drifts on its own
form: build_dek_request_fe(input) # defaults + validation + suite-resolution (copy A)
tool: build_dek_request_mcp(input) # defaults + validation + suite-resolution (copy B)
# after — one contract; both callers pass through the same code
shared.build_dek_request(input) # defaults + validation + suite-resolution (the only copy)
├─ form calls it
└─ tool calls it이것은 겉보기의 중복 제거(dedup)가 아니라, 규칙이 사는 위치를 옮기는 일이었다. 계약은 원래 표현 계층(presentation layer), 곧 화면 컴포넌트 안에 살고 있었다. 그것을 화면과 무관한 공용 모듈로 끌어내려, 폼도 도구도 그 모듈을 수입해 쓰게 했다. 비즈니스 규칙을 UI 에서 분리한다는 익숙한 원칙이, 여기서는 “에이전트도 사람과 같은 규칙을 지나게 한다” 는 구체적 목적을 갖는다.
다만 단일 원천이 “모든 규칙을 한 함수에 몰아넣는다” 는 뜻은 아니다. 어떤 규칙은 특정 계층에만 살 수 있다. 예컨대 입력 인코딩을 아는 검증은 입력 계층에만 둘 수 있다 — 서버는 키의 인코딩을 저장하지 않기 때문에, 그 검증을 뒤로 미루면 정보가 이미 사라진 뒤가 된다. 그래서 원칙은 “규칙마다 집이 하나이고, 모든 호출자가 그 집을 거친다” 가 된다. 한 함수가 아니라, 규칙마다 유일한 소유자를 두고 도구와 폼이 같은 소유자를 공유하는 구조다.
이 합류의 효과는 개별 버그 수정과 질이 다르다. 전에는 “도구가 폼과 같게 동작하기를 바란다” 였다면, 이제는 “도구가 폼과 다르게 동작할 코드 자체가 없다” 가 된다. 등가성이 테스트로 확인하는 속성에서 구조로 보장되는 속성으로 옮겨간 것이다. 폼이 기본값을 바꾸면 도구도 같은 함수를 지나며 자동으로 따라가고, 사람의 경로에 대응점이 없던 raw 뒷문은 제거됐다. 갭을 뒤쫓아 메우는 대신, 갭이 생길 자리를 없앤 것이다.
그러자 에이전트 테스트가 사람 테스트와 같아졌다
경로를 하나로 합치자, 앞서 빠져 있던 등가성 오라클을 비로소 세울 수 있게 됐다. 도구가 실제로 키를 만들고 그 결과를 폼이 만든 결과와 대조하는 차분 테스트(differential test)를 회귀 세트에 넣었다. 같은 입력을 두 경로에 흘려 서로의 출력을 맞대 보는 방식이다. 두 경로가 같은 요청으로 수렴하는지를 매 빌드가 확인하고, 마스킹 문자 같은 어긋남은 이제 실사용이 아니라 빌드에서, 그것도 정확히 “두 경로가 갈라졌다” 는 형태로 잡힌다. 앞 절에서 이름만 있고 자리는 없던 오라클이, 이제 테스트 스위트 안에 명시적으로 들어앉은 것이다.
코드를 공유하는데 왜 굳이 대조 테스트까지 두느냐고 물을 수 있다. 구조적 보장과 별개로, 공유는 규율이지 자물쇠가 아니기 때문이다. 누군가 나중에 도구에만 필요한 분기를 다시 심을 수도 있고, 공용 함수가 호출자별 플래그를 받으며 슬그머니 둘로 갈라질 수도 있다. 차분 테스트는 코드가 어떻게 조직됐는지와 무관하게 “두 경로가 수렴한다” 는 불변식(invariant) 자체를 지킨다. 구조는 갈라짐을 어렵게 만들고, 테스트는 그래도 갈라지는 순간을 요란하게 만든다. 트립와이어(tripwire)를 하나 더 건 셈이다.
여기서 더 큰 쓸모가 열린다. 도구 경로와 사람 경로가 같은 계약을 지나게 되니, 에이전트가 도구로 밟는 시나리오가 사람이 화면으로 밟는 시나리오와 같은 코드를 지난다. 그동안 사람이 손으로 클릭하며 확인하던 유저 테스트를, 이제 에이전트가 도구 호출로 그대로 재생할 수 있다. 같은 계약을 지나니 결과도 같고, 그래서 에이전트가 통과시킨 시나리오는 사람이 통과시킨 것과 같은 것을 검증했다고 믿을 수 있다. 이 등가가 성립하기 전까지 에이전트의 검증은 참고 자료였지 근거가 아니었다.
그 신뢰가 쌓이면 사람이 서 있는 자리가 바뀐다. 회귀 검증의 상당 부분을 에이전트에게 넘기고, 사람은 모든 시나리오를 손수 밟는 대신 에이전트가 갈라졌다고 표시한 예외만 들여다본다. 실행에서 감독으로 물러서는 이 이동은, 도구가 사람과 같은 경로를 지난다는 사실 위에서만 안전하다. 반대로 충실도가 없다면 에이전트가 아무리 많은 시나리오를 돌려도 그것은 계약을 건너뛴 독자 경로 — 사람이 밟지 않는 길 — 를 검증하는 헛수고다. 검증의 양이 아무리 늘어도 검증의 대상이 실물과 다르면 소용이 없다. 두 경로를 하나로 합치는 일은 그래서 단순한 리팩터링이 아니라, 에이전트 기반 검증이 성립하기 위한 전제 조건이었다.
마무리
시작은 ‘테스트가 다 통과하는데 왜 실제로는 틀리지’ 라는 착시였고, 끝은 두 경로를 하나로 합치는 구조 변경이었다. 그 과정에서 얻은 것은 버그 몇 개의 수정이 아니라, 등가성을 바라는 속성에서 보장되는 속성으로 바꿔 에이전트가 돌리는 검증을 믿을 수 있게 됐다는 사실이다.
‘에이전트가 제품을 다룬다’ 는 말은 가볍게 쓰이지만, 그 안에는 무거운 전제가 있다. 에이전트가 쥔 도구가 사람과 같은 길을 걸어야 한다는 것이다. 그러지 못한 도구는 빠르고 반복적으로 사람이 밟지 않는 경로를 검증하는 기계가 된다. 사람이 실행에서 감독으로 물러설 수 있으려면, 도구가 사람이 서 있던 자리를 정확히 이어받아야 한다. 에이전트가 제품을 다루는 일이 늘어날수록 이 문제는 제품마다 되돌아올 것이고, 그때 필요한 것은 도구를 더 빨리 찍어내는 손이 아니라 도구가 사람과 같은 결과에 도달하는지 매번 되묻는 습관이다. 에이전트에게 제품을 쥐여줄 때 진짜 어려운 문제는 도구를 만드는 것이 아니라, 그 도구가 사람과 같은 길을 걷게 만드는 것이다. 그래야 에이전트의 테스트가 사람의 테스트가 된다.
댓글 0