당신의 스펙 파일은 거짓말을 하고 있습니다. 제 것도 그랬죠.
요약
서비스 간 결합도와 스펙 파일(spec file) 설계의 중요성을 다룹니다. 주문 서비스와 알림 서비스의 사례를 통해, 구현 단계의 숨겨진 결합이 스펙의 신뢰성을 어떻게 해치는지 설명합니다.
핵심 포인트
- 서비스 간의 의도적인 설계 결정이 스펙의 경계를 결정함
- 구현과 스펙이 일치하지 않는 '스펙 부채'의 위험성
- 결합(Coupling)을 피하기 위한 비동기적 설계의 중요성
- 스펙은 구현의 세부 사항으로부터 독립적이어야 함
서문 (Preface)
본론으로 들어가기 전에 한 가지 솔직하게 말씀드리고 싶습니다. 이 글에서 다루는 프레임워크 중 그 어떤 것도 제 것이 아닙니다. 여기에 담긴 아이디어는 저보다 훨씬 더 깊고 오래 이 문제를 고민해 온 두 분으로부터 온 것이며, 제가 한 마디 더 하기 전에 그분들에게 온전한 공로를 돌려야 마땅합니다.
Dan Shapiro — Glowforge의 CEO이자 Wharton Research Fellow이며, 이 모든 대화에 어휘를 제공한 분입니다. 그의 블로그 포스트인 “The Five Levels: from Spicy Autocomplete to the Dark Factory”는 제가 하려는 모든 말의 개념적 중추입니다. 원문을 읽어보세요. 짧고 날카로우며, 가장 좋은 방식으로 당신을 불편하게 만들 것입니다. danshapiro.com
지금까지의 모든 이슈는 하나의 서비스와 하나의 스펙 파일(spec file)로 작동했습니다. 이슈 #7은 그것을 바꿉니다. 두 번째 서비스가 등장합니다. 결제가 확인된 후 주문 서비스(order service)가 호출하는 알림 서비스(notification service)입니다. 그리고 이와 함께 성장하는 모든 시스템이 결국 직면하게 되는 질문이 따라옵니다. 스펙 파일의 경계(boundaries)는 어디에 두어야 하는가?
그 답은 보기보다 더 중요하게 작용한다는 것이 밝혀졌습니다. 그리고 이번 이슈 끝에 진행한 감사(audit) 결과, 이슈 #2부터 실행해 온 파일들에서 7개의 스펙 부채(spec debt) 항목이 발견되었습니다. 모두 통과(passing)되었지만, 모두 리스크를 안고 있었습니다.
알림 서비스 — 그리고 스펙에 영향을 미치는 설계 결정
새로운 서비스는 최소한의 기능만 갖추고 있습니다: POST /notifications/order-confirmed는 주문 ID(order id), 사용자 ID(user id), 그리고 총액(total)을 수락하고, 알림 ID(notification id)와 QUEUED 상태를 반환합니다. 충분히 간단합니다. 흥미로운 부분은 주문 서비스가 이를 호출하는 방식입니다.
호출 방식은 파이어 앤 포겟(fire-and-forget)입니다.
주문이 확인되면, 주문 서비스는 데몬 스레드(daemon thread)를 시작하여 알림 요청을 보내고, 알림이 성공할 때까지 기다리지 않고 즉시 CONFIRMED 응답을 반환합니다. 알림 서비스가 다운되었거나, 느리거나, 에러를 반환하더라도 주문은 여전히 확인됩니다. 고객은 확인 메시지를 받습니다. 알림은 도착할 수도 있고, 그렇지 않을 수도 있습니다.
이것은 의도적인 설계 결정 (design decision)입니다. 주문 서비스 (order service)는 트랜잭션 (transaction)을 소유합니다. 알림 서비스 (notification service)는 전달 (delivery)을 소유합니다. 주문 확인 응답을 알림 전달과 결합 (coupling)하는 것은, 불안정한 알림 서비스가 주문 생성을 차단할 수 있음을 의미합니다. 이는 알림이 누락되는 것보다 훨씬 더 나쁜 실패 모드 (failure mode)입니다.
하지만 이 결정은 스펙 (spec)에 직접적인 영향을 미칩니다. Then the order status is "CONFIRMED"라고 단언하는 모든 시나리오는 알림 서비스가 무엇을 하든 관계없이 참이어야 합니다. 스펙은 동시에 CONFIRMED를 요구하면서, CONFIRMED가 알림 성공 여부에 의존하게 만들 수는 없습니다. 그것은 숨겨진 결합 (hidden coupling)이 될 것입니다. 스펙은 독립적으로 보이지만 구현 (implementation)은 그렇지 않게 됩니다.
이것은 코드로 구현되기 전에 스펙에 포함되어야 하는 종류의 아키텍처 결정 (architectural decision)입니다. 일단 코드에 들어가고 나면 그것은 구전 지식 (folklore)이 되어 버립니다.
잘못된 방법부터: 하나의 커다란 스펙 파일
제대로 하기 전에, 저는 의도적으로 잘못된 방식을 시도했습니다. Issue #2부터 주문 생성을 다뤄온 기존 파일인 order_creation.feature 하단에 두 개의 알림 시나리오를 추가했습니다.
7개의 테스트가 모두 통과했습니다. 모든 항목이 초록색(pass)이었습니다. pytest는 스펙 아키텍처 (spec architecture)에 대해 어떠한 의견도 내놓지 않습니다.
문제는 기능적인 것이 아니라 구조적인 것입니다:
혼합된 소유권 (Mixed ownership). order_creation.feature의 1행은 Feature: Order Creation이라고 명시합니다. 하지만 48행에 이르면 알림 전달을 테스트하고 있습니다. 만약 알림 팀이 그들의 계약 (contract)을 변경한다면 — 예를 들어, 요청에 channel 필드를 추가한다면 — 그들은 이를 업데이트하기 위해 order_creation.feature를 열어야 합니다. 그 파일은 그들의 것이 아닙니다. 파일 이름, 기능 선언 (feature declaration), 그리고 기존 시나리오들은 모두
파일의 증가하는 문제(The growing file problem). 5개의 시나리오까지는 파일이 읽기 가능합니다. 7개가 되면 이상한 냄새가 나기 시작합니다. 이를 실제 시스템에 외삽해 봅시다. 다운스트림 서비스 10개, 각 서비스당 5~10개의 시나리오가 모두 원본 기능 파일(feature file)에 추가됩니다. 그 이유는 각각이 주문 생성 이벤트로
order_creation.feature: 5개의 시나리오, 모두 주문 생성 (order creation)에 관한 내용. 알림 (notifications)에 대한 참조 없음.notification_service.feature: 2개의 시나리오, 모두 알림 전달 동작 (notification delivery behaviour)에 관한 내용.
이제 파일의 경계는 계약의 경계 (contract boundary)가 됩니다. 이 파일들은 독립적으로 버전 관리(versioned)가 가능하고, 소유권(owned)을 가질 수 있으며, 서로 다른 에이전트 (agents)에게 전달될 수 있습니다.
경계가 지정된 스펙 파일 (Bounded spec files)은 단순히 깔끔함을 위한 선호도가 아닙니다. 이는 멀티 에이전트 시스템 (multi-agent systems)을 위한 정밀한 도구입니다. 스펙 파일이 하나의 서비스로 한정되면, 에이전트에게 정확히 해당 파일만을 할당하고 다른 것은 주지 않을 수 있습니다. 에이전트는 하나의 접점 (surface)을 구축하고, 하나의 계약 (contract)을 테스트한 뒤, 작업을 마칩니다. 하지만 스펙이 여러 서비스에 걸쳐 번지게 되면, 에이전트는 명시적으로 기록되지 않은 서비스 소유권에 대한 결정을 내려야만 합니다. 이러한 결정들은 구현 과정에서 숨겨진 가정 (hidden assumptions)으로 쌓이게 됩니다.
스펙 부채 감사 (The spec debt audit)
경계가 지정된 파일 구조를 갖춘 후, 저는 프로젝트의 네 가지 기능 파일 (feature files) 모두를 대상으로 스펙 부채 (spec debt) — 즉, 스펙이 테스트는 통과하지만 명시적으로 내려졌어야 할 결정을 남겨두는 지점들 — 를 감사했습니다.
7개의 항목이 발견되었습니다. 모두 테스트는 통과했습니다. 하지만 모두 위험을 내포하고 있습니다.
1. 모호한 타임아웃 측정 (Ambiguous timeout measurement)
파일: order_creation.feature — 시나리오: 결제 게이트웨이 (payment gateway) 타임아웃
단계: And the response is returned within 12 seconds (그리고 응답이 12초 이내에 반환된다)
언제를 기준으로 합니까? 클라이언트가 요청을 보낼 때인가요? 서버가 이를 수신할 때인가요? 마지막 재시도 (retry)가 실행될 때인가요? 두 명의 에이전트는 이를 서로 다르게 측정할 것이며, 둘 다 테스트를 통과할 것입니다. "주문이 제출된 후 12초 이내에" — 여기서 "제출 (submitted)"을 HTTP 요청 본문 (request body)이 전송되는 순간으로 정의하는 것 — 가 모호함을 제거합니다.
2. "재시도됨 (Retried)" vs "총 시도 횟수 (total attempts)"
파일: order_creation.feature — 시나리오: 결제 게이트웨이 (payment gateway) 타임아웃
단계: And the payment gateway is not retried more than 2 times (그리고 결제 게이트웨이는 2회 이상 재시도되지 않는다)
이것이 총 2회의 시도 (원본 1회 + 재시도 1회)를 의미합니까, 아니면 원본 외에 2회의 재시도 (총 3회)를 의미합니까? 영어 표현 자체가 진정으로 모호합니다. 에이전트는 하나를 선택할 것입니다. 테스트는 통과할 것입니다. 하지만 운영 시스템 (production system)은 의도와 다르게 동작할 것입니다.
수정: And the payment gateway receives no more than 2 charge requests total — "requests total"은 첫 번째 시도가 포함되는지에 대한 모든 모호성을 제거합니다.
3. "Released"는 메커니즘이 아닙니다
파일: order_creation.feature — 시나리오: 결제 거절 (payment declined)
단계: And the inventory reservation is released
"Released"는 정의되지 않았습니다. 인벤토리 서비스 (inventory service)가 DELETE 요청을 받나요? 릴리스 엔드포인트 (release endpoint)로 POST 요청을 받나요? TTL (Time To Live)이 작동하나요? 에이전트 (agent)는 가장 자연스러워 보이는 메커니즘을 무엇이든 구현할 것입니다. 두 명의 에이전트는 스펙 (spec)을 모두 통과하면서도 서로 호환되지 않는 구현물을 만들어낼 것입니다.
수정: 항목과 메커니즘의 이름을 명시하세요: And the inventory service receives a reservation release request for SHOE-RED-42 and BELT-BRN-M.
4. "Explicit user action"은 존재하지 않는 흐름을 설명합니다
파일: order_creation.feature — 시나리오: 부분적 가용성 (partial availability)
단계: And no order is confirmed without explicit user action
"Explicit user action (명시적인 사용자 작업)"은 스펙 어디에도 정의되어 있지 않습니다. 두 번째 API 호출인가요? UI 확인인가요? 웹훅 (webhook)인가요? 이 단계는 어떤 주문도 확인되지 않기 때문에—부정 조건이 부재에 의해 참이 되므로—아주 쉽게 통과됩니다. 하지만 이는 구축된 적도, 명세화된 적도, 검토된 적도 없는 후속 확인 흐름을 암시합니다. 만약 미래의 에이전트가 이 단계를 읽고 이를 충족하기 위해 확인 흐름을 구축한다면, 의도되지 않았던 무언가를 발명하게 될 것입니다.
수정: 후속 흐름이 범위 밖(out of scope)이라면 이를 제거하세요. 또는 구체적인 단계로 교체하세요: And a subsequent POST to /orders/{order_id}/confirm is required to complete the order.
5. 값 없는 존재 (Presence without value)
파일: order_status_bad.feature
단계: 값이나 타입 단언(assertion) 없는 필드 이름 단언
필드가 존재한다고 단언하는 것은 부재(absence)만을 잡아낼 뿐, 잘못된 존재(incorrect presence)는 잡아내지 못합니다. 에이전트는 {"status": null}을 반환하고도 통과할 수 있습니다. 스펙이 잘못된 것을 잡아내고 있는 것입니다.
수정: 단순히 필드 이름만 확인하지 말고, 명시적인 값과 함께 예상되는 전체 형태 (shape)를 단언하세요.
6. "An order exists"는 방법을 말해주지 않습니다
파일: order_status_good.feature
단계: Given an order exists with status "CONFIRMED"
"An order exists"는 주문이 어떻게 그 상태에 도달했는지 — 전체 생성 흐름(creation flow)을 거쳤는지, 아니면 스토어에 직접 시딩(seeded)되었는지 — 명시하지 않습니다. 이 두 가지 방법은 서로 다른 부작용(side effects)을 발생시킵니다. 테스트 하네스(test harness)를 구축하는 에이전트는 생성 흐름을 완전히 우회하여 주문을 직접 시딩할 수 있으며, 이는 상태 엔드포인트(status endpoint) 테스트가 실제 확인된 주문이 API를 통해 실제로 읽힐 수 있는지 전혀 검증하지 못함을 의미합니다.
해결책: Given a previously confirmed order created via POST /orders with id "{order_id}" — 또는 직접 시딩이 허용됨을 명시적으로 기술하세요.
7. "Correct"는 상대적입니다
파일: notification_service.feature
단계: And the notification contains the correct order id and total
무엇과 비교했을 때 "Correct"인가요? 만약 주문 총액(order total)이 계산되는 방식이라면, 두 명의 에이전트가 이를 서로 다르게 계산할 수 있으며, 두 에이전트 모두 각자의 계산 결과에 따라 "correct"를 통과할 수 있습니다.
해결책: 예상되는 값을 하드코딩하세요: And the notification request body contains order_id matching the confirmed order and total of 134.97.
이 일곱 가지 항목이 모두 통과(green)됨에도 불구하고 왜 중요한가
해당 감사(audit)의 모든 항목은 테스트를 통과합니다. 그것이 핵심입니다.
스펙 부채(Spec debt)는 통과된(green) CI 실행 결과에서는 보이지 않습니다. 그것은 오직 다음과 같은 질문을 던질 때만 보입니다: 두 번째 에이전트가 이 스펙을 보고 무엇을 만들 것인가?
"결제 게이트웨이는 2회 이상 재시도되지 않는다"라는 단계는 Issue #2 이후로 코드베이스에 존재해 왔습니다. 그것은 매 실행마다 통과되었습니다. 하지만 그것은 이를 새로 구현하는 모든 에이전트가 서로 다르게 해석할 모호함(ambiguity)을 내포하고 있습니다. "명시적인 사용자 작업 없이는 주문이 확인되지 않는다"라는 단계는 코드베이스 어디에도 존재하지 않는 흐름을 설명하고 있습니다. 이 단계는 부정 조건(negative condition)이 사소하게 참(trivially true)이기 때문에 통과됩니다.
만약 미래의 에이전트가 해당 단계를 읽고 이를 충족하기 위한 확인 흐름(confirmation flow)을 구축한다면, 그는 한 번도 스펙에 정의되지 않았고, 리뷰되지 않았으며, 통합되지 않은 무언가를 만들게 될 것입니다. 스펙이 그것을 유도했고, 테스트가 그것을 승인했습니다. 아무도 알아차리지 못했습니다.
이것이 바로 대규모 환경에서 AI 지원 개발 (AI-assisted development)을 신뢰할 수 없게 만드는 정확한 실패 모드 (failure mode)입니다. 정밀해 보이고, 테스트를 통과하며, 호환되지 않는 구현 (implementations)을 조용히 유도하는 스펙 (specs)들 말입니다. 부채 (debt)는 스스로를 알리지 않습니다. 그것은 복리로 쌓여갑니다.
프로젝트 현황
네 개의 경계가 지정된 기능 파일 (bounded feature files) 전체에서 15개의 테스트가 통과되었습니다. 알림 서비스 (notification service)는 통합되었습니다. 이번 세션 이전에 존재했던 Pact 계약 (Pact contracts)은 알림 호출이 트랜잭션 (transaction) 완료 후에 발생하기 때문에 깨지지 않은 상태로 유지됩니다. 새로운 서비스 경계 (service boundary)를 추가하는 과정에서 기존 계약을 수정할 필요는 없었습니다.
7개의 스펙 부채 (spec debt) 항목이 기록되었습니다. 아직 수정된 것은 없습니다. 수정 작업은 다음 이슈에서 다룹니다.
다음 이슈: 스펙 감사 (The Spec Audit) — 실제 존재하는 서비스에 부채 프레임워크 (debt framework)를 적용하고, 독자들이 자신의 코드베이스 (codebases)에 직접 사용할 수 있는 진단 도구를 구축합니다.
출처 및 추가 읽기
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기