본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 10. 11:47

거짓말을 하지 않는 명세 (The Spec That Doesn't Lie)

요약

에이전트에게 전달하는 명세(spec)의 품질이 구현 결과에 미치는 영향을 분석합니다. 모호하거나 내부 구현 세부 사항을 포함한 잘못된 명세가 어떻게 에이전트의 부정확한 구현을 유도하는지 사례를 통해 설명합니다.

핵심 포인트

  • 잘못된 명세는 구문론적으로는 완벽해 보여 에이전트가 오류 없이 실행할 수 있음
  • 내부 구현 세부 사항을 참조하는 '정보가 새어나가는 명세'는 에이전트의 유연성을 저해함
  • 에이전트 기반 개발에서 명세의 정밀함은 구현의 정확성을 결정하는 핵심 요소임

Level 5 Engineer — Issue #5

서문 (Preface)

본론으로 들어가기 전에 한 가지 솔직하게 말씀드리고 싶습니다. 이 글에서 다루는 프레임워크 중 그 어떤 것도 제 것이 아닙니다. 이곳의 아이디어들은 저보다 훨씬 더 깊고 오래 이 문제를 고민해 온 두 사람으로부터 왔으며, 제가 다른 말을 하기 전에 그들에게 온전한 공로를 돌려야 마땅합니다.

Dan Shapiro — Glowforge의 CEO이자 Wharton 연구원이며, 이 모든 대화에 어휘를 부여한 분입니다. 그의 블로그 포스트인 “The Five Levels: from Spicy Autocomplete to the Dark Factory”는 제가 말하고자 하는 모든 것의 개념적 중추입니다. 원문을 읽어보세요. 짧고 날카로우며, 가장 좋은 방식으로 당신을 불편하게 만들 것입니다. danshapiro.com

지금까지의 모든 이슈는 제가 겉으로 말하지 않았던 어떤 것을 전제로 해왔습니다. 바로 명세(spec)가 훌륭하다는 점입니다. Issue #2에서는 명세를 신중하게 작성했습니다. Issue #3에서는 명세를 에이전트(agent)에게 전달하고 그것이 올바르게 구축되는 것을 지켜보았습니다. Issue #4에서는 계약(contracts)이 제공자 드리프트(provider drift) 상황에서도 생존함을 증명했습니다.

하지만 명세가 좋지 않다면 어떻게 될까요? 망가진 것이 아닙니다. Gherkin 구문은 괜찮고, 테스트는 통과하며, 에이전트는 무언가를 구축합니다. 단지 부정확할 뿐입니다. 작성할 때는 정밀하게 느껴지지만, 실제로는 모호한 상태 말입니다.

이번 이슈는 의도적으로 그 작업을 수행함으로써 그 질문에 답하고자 합니다. 저는 일부러 잘못된 Gherkin을 작성하여 에이전트에게 전달했고, 그것이 무엇을 구축하는지 지켜보았습니다. 그런 다음 명세를 다시 작성하고 다시 시도했습니다. 두 구현(implementation) 사이의 차이점이 바로 이 글의 핵심입니다.

잘못된 명세의 가장 어려운 점

잘못된 명세는 작성할 때 완전해 보이기 때문에 찾아내기가 어렵습니다.

구현 세부 사항(implementation details)을 참조하는 시나리오는 합리적인 설명처럼 들립니다. 당신이 직접 구현을 작성했기 때문에 그 세부 사항들이 구체적인 정보처럼 느껴지기 때문입니다. 당신에게는 당연해 보이는 Given 절은 코드를 보지 못한 모든 독자에게 다르게 해석될 것입니다. Gherkin은 구문론적으로 정확합니다. 테스트도 통과합니다. 출력 결과 중 그 어떤 것도 무언가 잘못되었다는 신호를 보내지 않습니다.

이것이 바로 함정입니다. 나쁜 명세(spec)가 무언가를 망가뜨리는 것이 문제가 아닙니다. 나쁜 명세가 아무것도 망가뜨리지 않는다는 것이 문제입니다.

엔드포인트 (The endpoint)

저는 order-api 프로젝트에 새로운 엔드포인트인 GET /orders/{order_id}/status를 추가했습니다. 이 엔드포인트는 주문의 현재 상태와 관련 메타데이터를 반환합니다. 명세를 잘 작성하기가 충분히 간단할 정도로 단순합니다. 그렇기에 의도적으로 명세를 잘못 작성해 보기에 좋은 대상이 됩니다.

나쁜 명세들 (The bad specs)

두 가지 시나리오가 있습니다. 둘 다 구문론적으로 유효합니다. 둘 다 테스트를 통과합니다. 둘 다 서로 다른 방식으로 틀렸습니다.

# 나쁜 명세 1 — 정보가 새어나가는 명세 (The leaky spec)
# 문제: 호출자가 관찰하는 내용을 설명하는 대신, 내부 구현 개념(db_status, order_created_at)을 참조함. 에이전트는 이 이름들을 문자 그대로 사용함
...

둘 다 즉시 통과되었습니다:

tests/steps/test_order_status_bad.py::test_retrieving_status_for_a_confirmed_order PASSED
tests/steps/test_order_status_bad.py::test_retrieving_status_for_an_order_that_does_not_exist PASSED

...

모두 초록색(통과)입니다. 경고도 없습니다. 무언가 잘못되었다는 힌트도 전혀 없습니다.

에이전트가 나쁜 명세를 바탕으로 구축한 것

에이전트가 생성한 구현 코드는 다음과 같습니다:

@app.get("/orders/{order_id}/status")
def get_order_status(order_id: str):
    order = _orders.get(order_id)
...

이는 명세를 완벽하게 충족합니다. 하지만 명세에서 전혀 결정하지 않은 네 가지 결정을 내렸습니다.

결정 1: 응답의 필드 이름이 db_status로 지정됨.
명세에 db_status라고 되어 있었기에 에이전트는 db_status를 사용했습니다. 이것이 공개 API로 새어나온 내부 명칭인지에 대해 의문을 제기하지 않았습니다. 명세를 문자 그대로 충족했을 뿐입니다.

결정 2: 주문이 없는 경우 404를 반환함.
명세에는 "주문을 찾을 수 없음을 나타내라"라고 되어 있습니다. 404는 방어 가능한 해석입니다. 422, 403, 또는 NOT_FOUND 상태 필드를 포함한 200도 마찬가지입니다. 에이전트는 가장 관습적인 옵션을 선택했습니다. 하지만 명세가 이를 강제하지는 않았으며, FastAPI의 기본 404 바디(body)는 {"error": "Order not found"}가 아니라 {"detail": "Order not found"}입니다. response.json()["error"]를 확인하는 클라이언트는 KeyError를 받게 됩니다.

결정 3: 타임스탬프 필드 이름은 order_created_at이며 별도의 형식 요구 사항은 없습니다.
명세(Spec)에는 "주문 기록으로부터 채워진다"라고 되어 있습니다. 에이전트(Agent)는 datetime.utcnow().isoformat()이 생성하는 결과에 따라 order_created_at을 선택하고 ISO 문자열을 반환했습니다. 단계 정의(Step definition)는 해당 필드가 비어 있지 않은 문자열인지만을 확인했기 때문에, 어떤 형식이든 통과했을 것입니다. Unix 타임스탬프 정수(Integer)도 통과했을 것이고, "June 2nd"와 같이 사람이 읽을 수 있는 문자열도 통과했을 것입니다.

결정 4: 주문 저장소는 인메모리(In-memory) 방식입니다.
명세에는 영속성(Persistence)에 대해 아무런 언급이 없습니다. 인메모리 딕셔너리(Dict)는 테스트를 통과시키기에 가장 단순한 방법입니다. 운영 환경(Production)에서는 주문이 영속화됩니다. 인메모리 저장소는 재시작 시 사라지며 워커 프로세스(Worker processes) 간에 공유되지 않습니다.

이러한 결정들은 모두 그럴듯합니다. 에이전트는 매번 합리적인 판단을 내렸습니다. 그것이 문제는 아닙니다. 문제는 동일한 명세를 받았을 때 다른 에이전트가 또 다른 합리적인 판단을 내릴 수 있으며, 두 구현체 모두 동일한 테스트 스위트(Test suite)를 통과할 것이라는 점입니다.

재작성 (The rewrite)

좋은 명세를 작성하는 과정은 나쁜 명세가 암묵적으로 위임했던 모든 결정 사항을 강제하게 만들었습니다:

# 좋은 명세 1 — 구현(Implementation)이 아닌 호출자(Caller)의 관점
# 수정 사항: 필드 이름이 저장 계층(Storage layer)에서 부르는 이름(db_status, order_created_at)이 아니라,
# 호출자가 관찰하는 내용(status, placed_at)을 설명함.
...

무엇이 바뀌었는지 주목하십시오. 시나리오는 동일한 두 가지 상황을 설명합니다. 의도도 동일합니다. 하지만 이제 모든 결정 사항은 에이전트의 명세 해석에 맡겨지는 대신 명세 자체에 포함되어 있습니다.

에이전트가 좋은 명세를 바탕으로 구축한 것

@app.get("/orders/{order_id}/status")
def get_order_status(order_id: str):
    order = _orders.get(order_id)
...

동일한 엔드포인트(Endpoint). 동일한 로직. 하지만 다른 API입니다.

db_statusstatus가 되었습니다. order_created_atplaced_at이 되었습니다. 404 바디(Body)는 이제 detail이 아닌 error를 포함합니다. 타임스탬프는 이제 단순히 비어 있지 않은 것이 아니라 ISO 8601 형식임이 단언(Assert)됩니다.

이것은 단순한 외관상의 차이가 아닙니다. 이는 클라이언트가 기반하여 구축하는 서로 다른 계약 (Contract)입니다.

교차 실행 (The cross-run)

올바른 명세 (Good spec)를 바탕으로 빌드한 후, 잘못된 명세 (Bad-spec) 테스트를 새로운 구현체에 실행해 보았습니다:

tests/steps/test_order_status_bad.py::test_retrieving_status_for_a_confirmed_order FAILED
tests/steps/test_order_status_bad.py::test_retrieving_status_for_an_order_that_does_not_exist PASSED

...

누설된 테스트 (Leaky test)는 실패했습니다. db_status 필드는 올바른 구현체에 존재하지 않습니다. 이는 status로 이름이 변경되었으며, 이것이 호출자 (Caller)가 보아야 할 값입니다. 내부 이름을 확인하던 테스트는 이제 (정상적으로) 깨졌습니다.

모호한 테스트 (Vague test)는 통과했습니다. 두 구현체 모두 주문이 없을 때 404를 반환합니다. 올바른 구현체는 이번에 명시적인 이유로 동일한 결론에 도달했을 뿐입니다.

이러한 비대칭성은 시사하는 바가 큽니다. 모호한 Given은 우연히 정답을 만들어냈습니다. 누설된 Then은 구조적으로 잘못된 필드 이름을 만들어냈습니다. 하나는 운이었고, 하나는 내재되어 있었습니다.

이것이 중요한 이유

두 구현체 모두 각자의 테스트 스위트 (Test suite)를 통과합니다. 이것이 함정입니다.

잘못된 명세 테스트를 잘못된 명세 구현체에 실행하면: 통과 (Green). 올바른 명세 테스트를 올바른 명세 구현체에 실행하면: 통과 (Green). 차이점은 교차 실행 (Cross-run)을 할 때만 드러납니다. 그리고 프로덕션 (Production) 환경에서는 교차 실행을 하지 않습니다. 잘못된 구현체를 배포하고, 그것이 CI를 통과하면, 문제는 6개월 뒤 클라이언트의 예외 보고서 (Exception report)에 나타나게 됩니다.

여기에 구체적인 차이가 있습니다: 잘못된 명세 구현체는 형식 보장 없이 db_statusorder_created_at을 반환합니다. 올바른 명세 구현체는 필수적인 ISO 8601 형식을 갖춘 statusplaced_at을 반환합니다. 잘못된 명세를 받은 에이전트 (Agent)는 db_status가 틀렸다는 것을 알 방법이 없었습니다. 명세에 db_status라고 적혀 있었기 때문입니다. 올바른 명세를 받은 에이전트는 status를 생성할 수밖에 없었습니다. 명세에 status라고 적혀 있었기 때문입니다.

명세(Spec)의 품질은 테스트가 통과하느냐의 문제가 아닙니다. 그것은 명세 작성자가 구현(Implementation)의 어느 정도를 직접 작성했는지, 아니면 얼마나 많은 부분을 에이전트(Agent)에게 암묵적으로 위임했는지에 관한 문제입니다. 모든 암묵적인 위임은 동일한 명세를 받은 두 에이전트가 서로 다른 코드 — 둘 다 테스트는 통과하지만 계약(Contract)에 대해서는 서로 일치하지 않는 코드 — 를 생성하게 되는 지점이 됩니다.

수십 개의 엔드포인트(Endpoints), 수백 개의 시나리오와 같은 대규모 환경에서, 그러한 불일치는 곧 시스템 그 자체가 됩니다.

좋은 명세를 위한 실질적인 테스트

에이전트에게 어떤 시나리오를 전달하기 전에, 한 가지 질문을 던지십시오. 이 시나리오는 어떤 결정 사항들을 열어두고 있는가?

만약 대답이 "없음 — 모든 필드 이름, 형식(Format), 응답 코드(Response code), 그리고 바디 형태(Body shape)가 명시되어 있음"이라면, 그 명세는 준비된 것입니다. 만약 대답이 "몇 가지 합리적인 결정 사항들"이라면, 그 지점들이 바로 당신의 구현과 다음 에이전트의 구현이 암묵적으로 갈라지게 될 곳입니다.

에이전트는 항상 합리적인 결정을 내릴 것입니다. 그것은 문제가 아닙니다. 문제는 '합리적'인 것이 '명시된(Specified)' 것과 같지 않다는 점이며, 레벨 4(Level 4)에서는 오직 '명시된 것'만이 유효하다는 사실입니다.

다음 호: 가드레일(Guardrails) 연결하기 — GitHub Actions, Pact Broker, 그리고 계약 위반을 자동으로 머지 차단(Blocked merges)으로 전환하는 파이프라인.

출처 및 추가 읽기

이 기사는 AI 도구의 도움을 받아 작성되었습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0