← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-8-contract-collapse.md

BACKEND8 min read

계약의 붕괴 — Chatty Interface, 버전 없는 API, Over-fetching

Chatty Interface, 버전 없는 API, Over-fetching이 한 화면에 모이는 이유와 유스케이스 기반 엔드포인트·런타임 계약으로 계약을 고정하는 법.

세 가지 증상이 한 화면에 있다

주문 목록 화면을 그리는 BFF 코드다.

ts
// bff/order-list.ts
const orders = await api.get<Order[]>("/orders?userId=" + userId)   // 50건
const rows = await Promise.all(orders.map(async (o) => {
  const detail = await api.get<OrderDetail>(`/orders/${o.id}`)      // 50회 추가 호출
  return { id: o.id, title: detail.items[0].name, total: detail.totalAmount }
}))

여기에 세 가지가 동시에 있다. 목록을 받고 건마다 상세를 다시 부르는 Chatty Interface, 세 개 필드를 쓰려고 상세 전체를 받는 Over-fetching, 그리고 api.get<OrderDetail>이라는 타입 단언 — 서버가 실제로 무엇을 주는지에 대한 검증이 한 줄도 없다.

세 증상은 별개로 보이지만 원인이 하나다. 이 API는 계약으로 설계된 게 아니라 데이터 접근 통로로 설계됐다. orders 테이블이 있으니 /orders가 있고, 행 하나를 꺼내야 하니 /orders/:id가 있다. 누가 무엇을 하려고 부르는지는 설계에 들어가지 않았다.

테이블을 그대로 노출하는 것은 한때 정답이었다

리소스 단위 CRUD를 그대로 여는 설계에는 분명한 근거가 있었다. 첫째, 엔드포인트를 설계하는 회의가 필요 없다. 테이블이 결정하므로 논쟁이 생기지 않는다. 둘째, 어떤 클라이언트가 올지 모를 때 가장 일반적인 인터페이스다. 화면이 바뀌어도 서버를 안 고쳐도 된다. 셋째, 대부분의 프레임워크가 이 형태를 스캐폴딩으로 제공했다.

세 번째 근거가 특히 강했다. 클라이언트가 웹 하나였고, 같은 팀이 배포했고, 요청이 같은 데이터센터 안에서 1ms 안에 왕복하던 맥락에서는 호출을 50번 하든 1번 하든 차이가 보이지 않았다. 계약이라는 개념이 필요 없었던 이유는, 계약의 양쪽이 같은 저장소에 있었기 때문이다.

무너진 것은 그 전제다. 클라이언트가 웹·iOS·Android·파트너사 넷이 되고, 각자 다른 주기로 배포하고, 모바일 회선의 왕복이 50ms가 되는 순간 같은 API가 전혀 다른 물건이 된다.

프로세스 경계를 넘는 순간 N+1의 단가가 5만 배가 된다

프로세스 안의 N+1은 함수 호출 51번이다. 호출 하나가 100나노초 수준이면 전체는 약 5마이크로초다. 같은 구조가 네트워크를 넘으면 왕복 51번이 된다. 데이터센터 내부 왕복을 5ms로 잡으면 51 × 5ms = 255ms다. 5마이크로초와 255ms의 차이는 약 5만 배다.

여기에 7편의 꼬리 지연이 겹친다. 51번의 호출이 각각 독립적으로 p99를 가지면, 적어도 하나가 p99에 걸릴 확률은 1 − 0.99⁵¹ = 40%다. 즉 이 화면의 응답시간 분포는 평균이 아니라 하류 p99가 지배한다. 호출 수를 1로 줄이면 같은 확률이 1%가 된다.

Chatty Interface가 성능 문제로만 보이는 동안에는 캐시로 덮게 된다. 실제 문제는 클라이언트가 데이터를 조립하는 책임을 떠맡았다는 것이고, 캐시는 그 책임을 옮겨주지 않는다.

소비자를 모르면 필드를 지울 수 없다

버전 없는 API의 비용은 배포 시점이 아니라 삭제 시점에 청구된다. Order 응답에 3년 전 화면을 위해 넣은 legacyStatusCode 필드가 있다. 지우려면 아무도 안 쓴다는 걸 확인해야 하는데, 소비자 목록이 없으면 확인 자체가 불가능하다.

소비자가 6개이고 각 팀의 변경 리드타임이 3주라면, 필드 하나를 지우는 데 최소 3주(모든 팀이 병렬로 움직였을 때) 걸린다. 순차로 진행되면 18주다. 그런데 진짜 문제는 소비자가 6개인지 7개인지 모른다는 것이다. 모르는 소비자가 하나라도 있으면 필드는 영구적으로 지울 수 없고, 응답은 단조 증가한다.

확장과 파괴적 변경의 경계선을 표로 고정해두면 이 논쟁이 매번 반복되지 않는다.

변경판정근거
선택적 필드 추가안전기존 소비자는 모르는 필드를 무시한다
필드 삭제파괴읽던 쪽이 undefined를 만난다
타입 변경(number→string)파괴조용히 잘못된 값이 된다. 최악의 형태
선택적 필드를 필수로파괴요청 쪽에서 기존 호출이 전부 거부된다
필수 필드를 선택적으로응답은 파괴, 요청은 안전방향에 따라 판정이 뒤집힌다
enum 값 추가소비자 구현에 달렸다아래 참조

마지막 행이 실제로 가장 자주 사고를 낸다. status에 "REFUNDING"을 추가하는 것은 서버 입장에선 확장이다. 하지만 소비자가 switch의 default에서 예외를 던지면 파괴적 변경이고, 미지의 값을 "기타"로 표시하면 안전한 확장이다. 같은 서버 변경이 소비자 코드에 따라 안전하기도 하고 장애이기도 하다. 그래서 계약은 서버 스키마만으로 정의되지 않는다.

CRUD를 노출하면 상태 전이 규칙이 클라이언트로 흩어진다

이 요청을 보자.

text
PATCH /orders/:id  { "status": "shipped" }

서버는 필드를 바꿔준다. "배송 중으로 바꿔도 되는가"는 판단하지 않는다. 결제 완료 상태에서만 가능한지, 재고 차감이 선행돼야 하는지, 취소된 주문은 안 되는지 — 이 규칙은 호출하기 전에 판단돼야 하므로 클라이언트로 간다.

클라이언트가 웹·iOS·Android 셋이면 같은 규칙이 세 벌이다. 전이 규칙이 8개라면 검증 지점은 8 × 3 = 24곳이고, 규칙 하나가 바뀔 때 세 앱이 각자의 심사 주기를 거쳐 배포돼야 한다. 그동안 세 벌의 규칙은 서로 다른 버전으로 살아 있다. 서버에는 규칙이 없으므로 불일치를 막을 곳도 없다.

Over-fetching도 같은 뿌리다. 상세 응답 200KB 중 실제로 쓰는 게 5KB면 97.5%가 낭비이고, 그 낭비는 직렬화 CPU와 리전 간 전송 요금으로 청구된다(7편). GraphQL은 클라이언트가 필요한 필드만 고르게 해서 이 항목을 줄이지만, N+1을 없애지는 않고 리졸버 안으로 옮긴다. 중첩 필드 하나가 건마다 DB를 치면 같은 51회가 그대로 재현되고, 이번에는 서버 안에 있어서 클라이언트 쪽 지표에 안 잡힌다. 배치 로더로 덮어야 하는데, 그건 해결이 아니라 대응이다.

유스케이스로 엔드포인트를 짓고, 계약을 런타임에 고정한다

세 증상에 대한 조치는 하나로 묶인다. 엔드포인트를 테이블이 아니라 유스케이스로 짓고, 그 계약을 코드에 고정한다.

PATCH /orders/:id를 POST /orders/:id/ship으로 바꾸면 세 가지가 동시에 해결된다. 전이 규칙이 서버 한 곳에 모이고, 의도가 이름에 드러나 로그와 권한 정책이 명확해지며, 클라이언트는 규칙을 몰라도 된다. 목록 화면은 GET /orders/summary?userId=처럼 화면이 필요한 것만 한 번에 돌려주는 엔드포인트로 바꾼다. 51회가 1회가 된다.

계약 고정은 타입 단언이 아니라 런타임 파싱이어야 한다. TypeScript의 타입은 컴파일 후 사라지므로, 서버가 다른 걸 줘도 as는 아무것도 막지 못한다.

ts
// bff/order-summary.ts — After
import { z } from "zod"
 
// 아는 필드만 선언하고 모르는 필드는 통과시킨다 (Tolerant Reader)
const OrderSummary = z.object({
  id: z.string(),
  title: z.string(),
  total: z.number(),
  // 서버가 모르는 enum 값을 줘도 터지지 않고 UNKNOWN으로 떨어진다
  status: z.enum(["PAID", "SHIPPED", "CANCELED", "UNKNOWN"]).catch("UNKNOWN"),
})
const Response = z.object({ items: z.array(OrderSummary) })
 
export async function getOrderSummary(userId: string) {
  const res = await fetch(`${API}/orders/summary?userId=${userId}`)
  // 파싱 실패는 여기서 잡힌다. 화면 렌더링 중이 아니라
  return Response.parse(await res.json()).items
}

핵심은 z.object가 선언하지 않은 필드를 기본적으로 통과시킨다는 점이다. 서버가 필드를 추가해도 이 소비자는 깨지지 않는다. Martin Fowler가 Tolerant Reader로 정리한 원칙이고, 더 거슬러 올라가면 Jon Postel이 RFC 761(1980)에 쓴 "보내는 것은 엄격하게, 받는 것은 너그럽게"다. catch로 미지의 enum 값에 기본값을 주는 것도 같은 원칙의 적용이다.

여기서 한 걸음 더 가면 소비자 주도 계약(Consumer-Driven Contracts)이다. Ian Robinson이 정리한 이 방식은 각 소비자가 "나는 이 필드들을 이렇게 쓴다"를 실행 가능한 테스트로 제출하고, 서버 CI가 그 테스트를 전부 통과해야 배포되게 한다. 소비자 목록이 문서가 아니라 CI 게이트가 되므로, 앞의 "지울 수 있는지 모른다" 문제가 기계적으로 답해진다.

버전 전략은 세 가지가 있고 교환 조건이 다르다. URL 버전(/v2/orders)은 가장 단순하지만 v1과 v2를 동시에 운영해야 한다. 미디어 타입 협상은 URL이 안정적이지만 캐시와 디버깅이 번거롭다. 필드 단위 진화 — 새 필드를 추가하고 옛 필드를 기한을 정해 유지 — 는 버전이 늘지 않지만 응답에 과도기 필드가 쌓인다.

대가는 세 가지다. 유스케이스 엔드포인트는 개수를 늘린다. /orders 하나가 ship, cancel, refund, summary로 늘어나고 문서화 대상도 늘어난다. 계약 테스트는 CI 시간을 쓰고, 소비자가 추가될 때마다 유지보수 대상이 하나 늘어난다. 런타임 파싱은 CPU를 쓴다. 큰 응답을 초당 수천 번 파싱하면 이벤트 루프 점유가 측정 가능한 수준이 된다(14편).

판단 기준 — 계약을 세우지 않는 게 싼 경우

소비자가 하나이고 같은 팀이 함께 배포하는 내부 API. 계약의 목적은 독립 배포를 가능하게 하는 것이다. 두 쪽이 항상 같이 배포된다면 버전도 계약 테스트도 순수 비용이다. 타입을 공유하고 함께 배포하는 쪽이 싸다.

변경이 끝난 API. 1년 넘게 스키마가 안 바뀌었고 앞으로도 그럴 API에 진화 전략을 설계하는 것은 순손실이다.

진짜로 범용 조회가 필요한 경우. 관리자 도구나 데이터 탐색 화면처럼 질의 형태를 미리 알 수 없으면 유스케이스 엔드포인트를 만들 수 없다. 이때는 GraphQL이나 필터 파라미터가 맞는 답이다. 단, 여기에도 조회 복잡도 상한과 페이지네이션은 필요하다.

프로토타입. 소비자가 없는 단계에서 계약을 먼저 설계하면 아직 모르는 유스케이스를 추측으로 고정하게 된다.

요약

항목내용
증상Chatty Interface, 버전 없는 API, Over-fetching이 동시에 나타난다
공통 원인API를 계약이 아니라 데이터 접근 통로로 설계했다
초기 매력테이블이 설계를 결정하므로 논쟁이 없고, 클라이언트가 하나일 때는 비용이 안 보인다
수치프로세스 내 N+1은 51회 × 100ns ≈ 5µs, 네트워크 N+1은 51회 × 5ms = 255ms. 약 5만 배
숨은 비용소비자를 모르면 필드를 영구히 지울 수 없다. enum 값 추가의 안전성은 소비자 구현에 달렸다
탈출유스케이스 엔드포인트, Zod 런타임 파싱, Tolerant Reader, Consumer-Driven Contracts
대가엔드포인트 수 증가, 계약 테스트의 CI 비용, 런타임 파싱의 CPU
오히려 정답단일 소비자·동시 배포 내부 API, 변경이 끝난 API, 범용 조회, 프로토타입

다음 편 — 9편. Distributed Monolith — 가장 비싼 실패 (3막 시작)

2막은 중앙 장치를 없애는 이야기였다. 3막은 경계를 실제 프로세스 경계로 만든 뒤의 이야기다. 서비스를 12개로 쪼갰는데 하나를 배포하려면 나머지 11개를 같이 배포해야 한다면, 모놀리식의 결합은 그대로 두고 네트워크 비용만 추가한 것이다. 이 구조가 왜 모놀리식보다 엄격하게 나쁜지, 그리고 어디에 경계를 그었어야 했는지를 다룬다.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..