핸들러에 도달하기 전에 이미 결정이 끝나 있다
주문 생성 API의 핸들러를 열었더니 스무 줄이다. 그런데 이 엔드포인트에서 발생한 "쿠폰이 중복 적용됐다"는 버그를 고치려고 보니, 쿠폰 규칙이 핸들러에 없다. 규칙은 요청이 핸들러에 닿기 전에 지나가는 게이트웨이 미들웨어에 있다. 이 편의 Before다.
// gateway/middleware/coupon.ts — Before: 도메인 규칙이 게이트웨이 계층에 있다
export function applyCouponPolicy() {
return async (req: Request, res: Response, next: NextFunction) => {
const body = req.body as { items?: Item[]; couponCode?: string }
if (!body.couponCode) return next()
const coupon = await couponRepo.find(body.couponCode)
// 도메인 규칙이 게이트웨이에 있다
if (coupon.minAmount > sum(body.items)) return res.status(400).json({ code: "COUPON_MIN" })
if (coupon.category && !body.items?.every(i => i.category === coupon.category)) {
return res.status(400).json({ code: "COUPON_CATEGORY" })
}
body.discount = coupon.rate * sum(body.items) // 요청 본문을 변형해서 하류로 넘긴다
next()
}
}app.use() 목록에는 이런 미들웨어가 열두 개 있고, 그중 네 개가 요청 본문을 변형한다. 주문 서비스 팀은 자기 도메인 규칙의 일부를 읽을 수 없고, 게이트웨이를 관리하는 플랫폼 팀은 쿠폰 정책이 왜 그렇게 생겼는지 모른다. 이 코드의 소유자는 아무도 아니다.
이것이 2000년대 ESB(Enterprise Service Bus, 시스템 간 통신을 중앙에서 중개하는 미들웨어)가 만들었던 구조와 같다. 도구 이름만 바뀌었다.
경계를 만들었더니, 경계 사이에 중앙 장치가 생겼다
1막(2~5편)에서 본 것은 경계가 없는 시대였다. 하나의 프로세스, 하나의 DB. God Object가 자라고(2편), 로직이 DB로 흘러가고(5편), 계층은 이름만 남았다(4편). 공통 원인은 하나다. 경계를 긋는 비용이 0이었기 때문에 아무도 긋지 않았다.
2000년대의 대응은 경계를 실제 시스템 경계로 만드는 것이었다. 주문 시스템, 회계 시스템, 재고 시스템을 분리한다. 그런데 분리하고 나니 새 문제가 생겼다. 이 시스템들은 서로 다른 회사가 다른 해에 만들었다. 하나는 SOAP, 하나는 고정 길이 파일, 하나는 IBM MQ. 재고 시스템의 ITEM_CD는 주문 시스템의 productCode다.
ESB는 이 문제에 대한 정직한 답이었다. 중앙에 버스를 두고 여기서 프로토콜을 변환하고, 메시지를 매핑하고, 라우팅하고, 여러 시스템을 묶는 오케스트레이션을 한다. N개 시스템이 서로 직접 연결하면 연결선이 N(N−1)/2개지만, 버스를 거치면 N개다. 시스템 10개면 45개 대 10개. 4.5배 차이다. 이 계산은 지금도 틀리지 않았다.
그래서 ESB는 안티패턴으로 태어난 게 아니다. 이기종 통합이라는 구체적 문제를 실제로 풀었다. 문제는 버스가 변환만 하는 데서 멈추지 않았다는 것이다. 변환 로직을 넣을 수 있는 자리에는 조건문도 넣을 수 있고, 조건문을 넣을 수 있으면 비즈니스 규칙도 넣을 수 있다.
로직을 파이프에 넣으면 어느 팀도 그것을 소유하지 않는다.
소유자가 없는 코드는 삭제되지 않는다. 삭제하려면 그 코드가 무엇을 위한 것인지 아는 사람이 필요한데, 그 사람이 없기 때문이다. 그래서 버스 안의 로직은 단조 증가한다.
중앙 컴포넌트를 지나는 팀 수가 늘면 무엇이 터지는가
세 가지 비용이 동시에 오른다.
1. 변경 리드타임. 중앙 컴포넌트의 변경은 그 컴포넌트를 소유한 팀 한 곳이 처리한다. 플랫폼 팀이 주당 20건의 변경을 소화할 수 있다고 하자. 제품 팀이 8개이고 각 팀이 주당 2건을 요청하면 도착률은 16건/주다. 단일 창구 대기행렬의 평균 체류시간은 1/(처리율 − 도착률)이므로 1/(20−16) = 0.25주, 약 1.75일이다.
여기서 팀을 9.5개로 늘리면 도착률은 19건/주가 되고 체류시간은 1/(20−19) = 1주가 된다. 요청량이 19% 늘었는데 대기시간은 4배가 됐다. 대기행렬은 이용률이 1에 가까워질 때 선형이 아니라 발산한다. "게이트웨이 변경이 왜 이렇게 오래 걸리지"라는 질문의 답은 대개 조직도에 있다.
2. 배포 결합. 버스에 N개 팀의 로직이 들어 있으면 버스 배포 하나가 N개 팀의 코드를 동시에 움직인다. 팀 A의 변경 때문에 배포한 버전이 팀 B의 경로를 깨면, 팀 B는 자기 저장소에 커밋이 하나도 없는데 장애를 받는다. 이 순간부터 팀들은 버스 배포에 저항하기 시작하고, 저항은 배포 주기를 늘리고, 배포 주기가 늘면 한 번의 배포에 담기는 변경 수가 늘어 위험이 더 커진다.
3. 장애 반경. 모든 트래픽이 한 컴포넌트를 지나면 그 컴포넌트의 가용성이 전체 가용성의 상한이 된다. 하류 서비스 12개가 각각 99.95%여도, 앞단 게이트웨이가 99.9%면 전체는 99.9%를 넘을 수 없다.
게이트웨이에 도메인 로직이 들어가면 배포 빈도가 오른다는 점이 여기에 겹친다. 주 5회 배포에 배포당 30초의 연결 단절이 있다고 하면, 월 21.5회 × 30초 = 645초다. 월 43,200분 기준 99.975%. 이 수치 자체는 나쁘지 않지만, 문제는 그 645초 동안 영향받는 트래픽이 전체의 100%라는 것이다. 서비스 하나가 30초 죽으면 영향은 그 서비스 몫이지만, 파이프가 죽으면 전부다.
ESB를 쓰는 사람은 없지만 같은 구조는 살아 있다
ESB라는 제품명은 사라졌다. 구조는 네 가지 형태로 재현된다.
| 재현형 | 원래 역할 | 흡수하기 시작하는 것 |
|---|---|---|
| API Gateway | 라우팅, 인증, 레이트 리밋 | 응답 집계, 필드 변환, 도메인 검증 |
| BFF | 화면 단위 응답 조립 | 화면에 없는 상태 전이 규칙, 권한 판정 |
| 미들웨어 체인 | 횡단 관심사 | 요청 본문 변형, 비즈니스 분기 |
공용 core 패키지 | 공통 타입·유틸 | 도메인 규칙, 그리고 전 서비스의 배포 결합 |
마지막 줄이 가장 눈에 안 띈다. @company/core에 주문 상태 전이 규칙이 들어 있고 12개 서비스가 이 패키지에 의존하면, 규칙 하나를 바꿀 때 12개 서비스를 다시 배포해야 한다. 런타임 중앙 장치가 없어도 빌드타임 중앙 장치가 같은 결합을 만든다. 네트워크 홉이 없으니 성능 문제는 없고, 그래서 더 오래 자란다.
James Lewis와 Martin Fowler가 2014년 마이크로서비스 정의에서 제시한 원칙이 "smart endpoints and dumb pipes"다. 이 원칙은 성능 조언이 아니라 소유권 조언이다. 파이프는 소유자가 모호한 자리이므로, 소유자가 필요한 것을 그 자리에 두지 말라는 뜻이다.
게이트웨이가 해도 되는 일의 목록을 문서로 고정한다
가장 효과가 큰 조치는 기술적인 게 아니라 목록을 명시하는 것이다. 이 목록이 없으면 모든 요청이 "이것도 게이트웨이에서 하면 편하지 않나요"로 들어온다.
| 게이트웨이가 해도 되는 일 | 해서는 안 되는 일 |
|---|---|
| TLS 종료, 경로 기반 라우팅 | 도메인 검증(재고 확인, 한도 확인) |
| 토큰 서명 검증, 스코프 확인 | 리소스 단위 권한 판정 |
| 레이트 리밋, 요청 크기 제한 | 요청 본문 변형 |
| 트레이스 ID 주입, 접근 로그 | 여러 서비스 응답의 집계 |
판별 기준은 하나로 줄일 수 있다. 그 판단을 내리는 데 도메인 데이터가 필요하면 게이트웨이의 일이 아니다. 토큰 서명 검증은 공개키만 있으면 되지만, "이 사용자가 이 주문을 취소할 수 있는가"는 주문의 상태를 알아야 한다.
앞의 Before에서 쿠폰 미들웨어를 걷어내면 After는 두 조각으로 갈라진다. 먼저 게이트웨이에는 도메인 데이터가 필요 없는 것만 남는다.
// gateway/app.ts — After (1/2): 게이트웨이는 얇아진다
app.use(traceId()) // 관측 주입
app.use(verifyJwt()) // 서명·만료만 검증. 권한 판정은 하지 않는다
app.use(rateLimit({ max: 100, windowMs: 60_000 }))
app.use("/orders", proxy(ORDER_SERVICE_URL)) // 본문을 건드리지 않고 그대로 전달걷어낸 쿠폰 규칙은 사라지는 게 아니라 자리를 옮긴다. Before에서 미들웨어가 하던 최소금액·카테고리 검사가 After에서는 주문 도메인 안으로 들어간다.
// services/order/create-order.ts — After (2/2): 규칙은 소유한 서비스 안으로
export async function createOrder(cmd: CreateOrderCommand, actor: Actor) {
const order = Order.draft(cmd.items, actor.userId)
if (cmd.couponCode) {
const coupon = await couponRepo.find(cmd.couponCode)
// 할인 규칙이 주문 도메인 안에 있고, 테스트도 여기 있다
order.applyCoupon(coupon) // 최소금액·카테고리 위반 시 DomainError
}
await orderRepo.save(order)
return order.toResponse()
}차이는 줄 수가 아니라 위치다. order.applyCoupon은 주문 팀 저장소에 있고, 단위 테스트가 같은 저장소에 있고, 깨지면 주문 팀이 호출받는다. 게이트웨이 배포 없이 쿠폰 규칙을 바꿀 수 있다.
대가는 중복이다. 쿠폰 검증이 주문과 구독 두 서비스에 필요하면 비슷한 코드가 두 벌 생긴다. 이 중복을 없애려고 공용 패키지에 올리는 순간 다시 빌드타임 중앙 장치가 된다. 교환 조건은 이렇다. 중복은 비용이 선형으로 늘고(서비스 수만큼), 결합은 조율 비용이 대기행렬처럼 비선형으로 는다. 서비스가 3개 이하면 공용화가 싸고, 그 이상이면 대개 중복이 싸다. 규칙이 진짜로 하나라면 쿠폰 서비스를 별도 서비스로 두고 호출하는 선택지도 있는데, 이건 중복 대신 원격 호출 비용을 사는 것이다(7편).
판단 기준 — 파이프가 두꺼운 게 맞는 경우
레거시 통합 어댑터. 20년 된 시스템이 고정 길이 레코드를 뱉는다면, 그 변환 코드는 어딘가에 있어야 한다. 신규 서비스마다 넣는 것보다 어댑터 한 곳에 두는 게 맞다. 조건은 하나다. 변환만 하고 판단하지 않을 것.
실제로 프로토콜 변환만 하는 경우. gRPC를 HTTP/JSON으로 노출하는 계층, 프로토콜 버전만 올려주는 프록시. 여기에는 도메인 지식이 들어가지 않으므로 소유권 문제가 생기지 않는다.
팀이 하나인 조직. 소유권 분산이 문제의 핵심이므로, 소유자가 한 팀이면 비용의 절반이 사라진다. 게이트웨이에 로직이 있어도 그것을 고칠 사람과 서비스를 고칠 사람이 같다. 서비스 3개를 한 팀이 배포하는 조직에서 게이트웨이를 얇게 만드는 리팩터링은 순손실일 수 있다.
트래픽 전체에 적용되는 방어. WAF, DDoS 차단, IP 차단은 본질적으로 중앙에 있어야 한다. 각 서비스에 흩어놓으면 누락이 생기고, 누락은 방어에서 곧 구멍이다.
요약
| 항목 | 내용 |
|---|---|
| 증상 | 도메인 규칙이 핸들러가 아니라 게이트웨이·미들웨어·공용 패키지에 있다 |
| 초기 매력 | 연결선 N(N−1)/2 → N. 이기종 통합과 재사용에는 실제로 유효했다 |
| 붕괴 기전 | 소유자 없는 코드는 삭제되지 않고 누적된다. 중앙 큐 이용률이 1에 가까워지면 리드타임이 발산한다 |
| 수치 | 요청량 19% 증가 → 변경 대기시간 1.75일에서 7일로 4배. 파이프 장애의 영향 반경은 100% |
| 오늘의 형태 | 비대한 API Gateway, 도메인을 흡수한 BFF, 본문을 변형하는 미들웨어, 공용 core 패키지 |
| 탈출 | 게이트웨이 허용 목록을 문서로 고정. "도메인 데이터가 필요하면 게이트웨이 일이 아니다" |
| 대가 | 서비스별 중복. 중복 비용은 선형, 결합 비용은 비선형이라는 교환 |
| 오히려 정답 | 레거시 어댑터, 순수 프로토콜 변환, 단일 팀 조직, 트래픽 전역 방어 |
다음 편 — 7편. Fallacies of Distributed Computing — 네트워크를 함수 호출로 취급한 죄
파이프에서 로직을 걷어내면 서비스끼리 직접 호출하게 된다. 그 순간 await userClient.getUser(id) 한 줄이 로컬 함수 호출과 같은 얼굴로 네트워크를 숨긴다. 1994년에 정리된 8가지 오류를 실제 장애 시나리오로 번역하고, 백엔드 100개를 호출하는 요청의 63%가 왜 개별 p99에 지배되는지 계산으로 보인다.