← posts/b.log()

blog92@web:~$ cat posts/backend-design-patterns-10-service-decomposition.md

BACKEND9 min read

서비스 경계를 긋는 법 — Bounded Context와 분해 전략

Bounded Context와 Context Mapping으로 경계를 긋고 커밋 로그의 동시 변경으로 후보를 검증한다. 조율 쌍 K(K−1)/2, 잘못 그은 경계의 수정 비용, Modular Monolith가 먼저인 이유.

"몇 개로 쪼갤까요"에 답할 기준이 없다

2막(7~9편)의 패턴들은 전부 경계가 이미 있다는 전제 위에 있었다. Remote Facade는 경계에서 대화 횟수를 줄이는 기술이고, Anticorruption Layer는 경계 너머의 모델을 막는 벽이다. 3막은 그 전제 자체를 연다. 경계를 누가, 무엇을 기준으로 긋는가.

설계 회의에서 실제로 나오는 문장은 "주문이랑 결제로 나누죠"이고, 그 다음 질문에서 막힌다. 쿠폰은 어디에 넣나. 배송비 계산은 주문인가 배송인가. 결제 대기 상태는 주문이 소유하는가 결제가 소유하는가. 기준이 없으면 답은 조직도나 테이블 목록에서 온다.

이 질문을 미룰 수 없는 이유는 뒤의 세 편이 전부 여기에 얹히기 때문이다. Saga의 단계 수(11편), 읽기 모델의 개수(12편), 방어 장치를 달아야 할 호출 지점 수(13편)는 모두 경계가 정한다. 경계가 틀리면 그 위의 패턴들은 틀린 문제를 정확하게 푸는 도구가 된다.

같은 단어가 다른 것을 가리켜도 되는 범위

Eric Evans가 Domain-Driven Design(2003)에서 제시한 Bounded Context는 하나의 모델이 일관되게 통하는 범위다. 그 안에서는 모든 용어가 한 가지 뜻을 갖고, 범위를 넘으면 같은 단어가 다른 것을 가리켜도 된다. 이건 타협이 아니라 명시적 허용이다.

"고객"이 전형적인 예다. 영업 컨텍스트의 고객은 아직 계약하지 않은 대상을 포함하고 등급·담당자·마지막 접촉일을 갖는다. 배송 컨텍스트의 고객은 받는 사람이고 주소·연락처·부재 시 처리 방법을 갖는다. 정산 컨텍스트의 고객은 세금계산서 발행 대상이고 사업자번호·결제 조건을 갖는다. 셋이 같은 사람을 가리키지만, 셋의 식별자조차 다를 수 있다. 배송은 수령인 기준이라 한 사람이 주소 세 개로 세 번 등장한다.

셋을 하나의 Customer로 합치면 필드 15개짜리 타입이 생기고 각 화면은 그중 다섯 개만 쓴다. 나머지 열 개는 "이 맥락에서는 의미 없음"이라는 규칙으로 남는데, 그 규칙은 타입에 적혀 있지 않다.

ts
// packages/common/customer.ts — Before: 세 컨텍스트가 공유하는 하나의 타입
export interface Customer {
  id: string
  name: string
  stage: 'LEAD' | 'PROSPECT' | 'ACTIVE' | null  // 배송·정산에서는 의미 없음
  ownerEmployeeId: string | null                // 영업 담당자. 나머지는 null
  lastContactedAt: Date | null                  // 영업만
  address: string | null                        // 배송만
  absenceHandling: 'DOOR' | 'OFFICE' | null     // 배송만
  bizNo: string | null                          // 정산만
  paymentTerms: 'PREPAID' | 'NET30' | null      // 정산만
}

아홉 개 필드 중 각 화면이 쓰는 것은 두세 개이고, 나머지가 null인 이유는 타입 어디에도 적혀 있지 않다. 경계를 긋는다는 것은 이 타입을 쪼개고, 쪼갠 타입들이 같은 이름을 쓰는 것을 허용하는 일이다.

ts
// modules/sales/model.ts — After: 영업 컨텍스트의 고객
export type LeadId = string & { readonly __brand: 'LeadId' }
export interface Customer {
  id: LeadId
  name: string
  stage: 'LEAD' | 'PROSPECT' | 'ACTIVE'
  owner: { employeeId: string; lastContactedAt: Date }
}
 
// modules/shipping/model.ts — After: 배송 컨텍스트의 고객은 "수령인"이다
export type RecipientId = string & { readonly __brand: 'RecipientId' }
export interface Customer {
  id: RecipientId          // 같은 사람이 주소마다 다른 id로 여러 번 존재한다
  name: string
  address: string
  absenceHandling: 'DOOR' | 'OFFICE'
}

null 가능 필드가 전부 사라졌다. 더 중요한 것은 식별자다. 브랜드 타입을 쓰면 LeadId를 RecipientId 자리에 넣는 코드가 컴파일되지 않는다. 두 컨텍스트의 고객 수가 애초에 다르다는 사실이 타입으로 강제된다. 경계를 넘을 때는 번역이 필요하고, 그 번역기가 Anticorruption Layer(9편)다.

컨텍스트 사이의 관계는 조직 관계의 복사본이다

Evans는 같은 책에서 컨텍스트 간 관계를 유형으로 정리했다. Context Mapping이다. 유형을 고르는 일은 기술 선택처럼 보이지만 실제로는 누가 누구에게 요구할 수 있는가를 확정하는 일이다.

관계구조성립 조건
Shared Kernel모델 일부를 두 팀이 공유하고 함께 소유두 팀이 같은 배포 주기를 받아들임
Customer-Supplier상류가 하류의 요구를 계획에 반영하류가 우선순위에 영향을 줄 힘이 있음
Conformist하류가 상류 모델을 그대로 수용상류가 협상에 응하지 않음. 번역 비용 0, 오염 최대
Anticorruption Layer하류가 상류 모델을 자기 언어로 번역번역 계층 유지 비용을 하류가 지불
Open Host Service상류가 다수 하류를 위한 공개 프로토콜 제공하류가 여럿이어서 개별 대응이 불가능
Separate Ways통합하지 않고 각자 구현통합 이득보다 통합 비용이 큼

여기서 놓치기 쉬운 것은 Conformist와 Separate Ways가 정당한 선택지라는 점이다. 외부 SaaS처럼 협상 불가능한 상류에 Anticorruption Layer를 세우는 것이 늘 옳지는 않다. 상류 모델을 그대로 받아들이면 번역 계층이 없어지고, 상류가 바뀔 때마다 하류가 따라 바뀌는 비용만 남는다. 그 비용이 번역 계층 유지비보다 작으면 Conformist가 싸다.

관계를 정하지 않으면 기본값이 정해진다. 같은 DB를 보는 구조로 수렴한다(안티패턴 10편).

경계를 긋는 축과, 실제로 쓰이는 축

거론되는 축은 비즈니스 능력, 서브도메인, 변경 이유의 동일성 셋이고 목록 자체는 안티패턴 10편에 있다. 여기서 볼 것은 왜 셋 중 마지막만 실제로 쓰이느냐다. 앞의 둘은 도메인을 이미 이해하고 있어야 적용되는데, 경계를 그어야 하는 시점은 대개 그 이해가 없는 시점이다.

변경 이유를 기준으로 삼는 근거는 David Parnas가 1972년 논문 "On the Criteria To Be Used in Decomposing Systems into Modules"에서 제시했다. 당시의 통념은 처리 흐름을 단계로 쪼개 모듈을 만드는 것이었고, Parnas는 대신 바뀔 것으로 예상되는 결정 하나를 모듈이 감추어야 한다고 주장했다. 두 방식으로 같은 프로그램을 분해해 놓고, 요구사항이 바뀔 때 손대야 하는 모듈 수를 비교한 것이 논증이었다.

이 기준이 실용적인 이유는 도메인 지식 없이도 근사할 수 있기 때문이다. 어떤 결정이 자주 바뀌는지는 도메인 전문가에게 묻지 않아도 커밋 로그에 이미 기록되어 있다. 사전에 유스케이스별 경계 횡단 수를 세는 방법(안티패턴 10편)과 달리, 이건 아직 쪼개지 않은 단일 저장소 안에서도 돌아간다.

bash
# tools/module-cochange.sh — 한 커밋에서 함께 바뀐 모듈 디렉터리 쌍을 센다 (gawk 필요)
# 쪼개기 전에 경계 후보를 검증하는 용도다. 저장소가 하나여도 돌아간다.
set -euo pipefail
git log --since='180 days ago' --name-only --pretty=format:'@%H' -- src/modules \
| gawk '
  function flush(   i, j, n, s) {
    n = asorti(cur, s)
    for (i = 1; i < n; i++)
      for (j = i + 1; j <= n; j++) pair[s[i] "+" s[j]]++
    delete cur
  }
  /^@/          { flush(); next }
  /^src\/modules\// { split($0, p, "/"); cur[p[3]] = 1 }   # src/modules/<모듈>/...
  END           { flush(); for (k in pair) if (pair[k] >= 5) print pair[k], k }
' | sort -rn | head -20

상위에 올라온 쌍은 경계 후보가 아니라 합병 후보다. 같은 커밋에 계속 함께 바뀐다면 둘은 하나의 변경 이유를 공유하고 있고, 그 사이에 프로세스 경계를 세우면 모든 변경이 두 번의 배포가 된다.

경계가 실제로 가두는 것

경계의 값은 코드 정리가 아니라 조율 비용의 국소화다. 경계 안의 변경은 한 팀이 끝내고, 경계를 넘는 변경만 사람 사이의 합의를 요구한다.

이건 셀 수 있다. 하나의 유스케이스가 K개 경계를 넘고 각 경계를 다른 팀이 소유하면, 그 변경을 내보내는 데 필요한 팀 간 합의 쌍은 K(K−1)/2다. K=2면 1쌍, K=4면 6쌍, K=6면 15쌍이다. 경계를 하나 더 넘게 만드는 설계 결정은 조율 쌍을 선형이 아니라 제곱으로 늘린다.

Database per Service — 서비스마다 자기 저장소를 갖고 남의 테이블을 직접 읽지 않는 원칙 — 는 이 국소화를 데이터까지 밀어붙인 것이다. 애플리케이션 코드만 나누고 테이블을 공유하면, 스키마 변경이 그대로 경계를 넘어 전파되어 조율 쌍 계산이 무효가 된다. 이 원칙이 계속 깨지는 이유도 같은 계산으로 설명된다. 조인 한 줄의 편의는 지금 확실하고, 조율 쌍 6개의 비용은 나중에 분산되어 청구된다.

무엇을 내주는가

경계를 넘는 조회가 조인에서 호출로 바뀐다. 같은 DB의 조인이 2ms라면, 세 서비스를 병렬 호출해 합성하는 경로는 p50 8ms에 p99 60ms다. 지연보다 코드량이 더 든다. 조인 한 줄이 호출 세 개 + 타임아웃 설정 + 부분 실패 처리 + 응답 병합 + DTO 매핑으로 늘어나 60~80줄이 된다. 각 호출의 p99 초과 확률이 1%면 셋 중 하나라도 느릴 확률은 1 − 0.99³ = 2.97%다.

데이터 중복이 전제가 된다. 1편에서 세 번째 통화로 분류한 거래가 여기서 실제로 청구된다. 중복 자체의 정당성은 거기서 다뤘고, 여기서 추가되는 것은 운영 항목이다. 복제본마다 재동기화 경로를 만들어야 하고, 원본과 복제본의 차이를 재는 대조 배치가 필요하다.

최종 일관성이 제품 요구사항으로 올라온다. 이건 코드 밖의 대가이고, 허용 지연을 숫자로 합의하는 협상이 실제 작업이다(안티패턴 10편). 합의가 없으면 경계는 첫 CS 문의에서 무너진다.

경계를 잘못 그으면 되돌리기가 비싸다. 이 편에서 가장 큰 대가다. 코드 안의 모듈 경계를 옮기는 것은 PR 하나이고 반나절이다. 프로세스 경계를 옮기는 것은 다른 작업이다. 테이블 12개를 다른 서비스로 옮긴다면 스키마 복제 → 이중 쓰기 → 과거 데이터 백필 → 읽기 경로 전환 → 구 경로 제거의 5단계를 거치고, 각 단계마다 배포 한 번과 검증 기간이 붙어 2주씩만 잡아도 10주다. 행이 2억 개면 백필만 며칠이다. 그리고 여기에 조직 변경이 따라온다. 소유 팀이 바뀌고 온콜 로테이션이 바뀐다. 경계 수정은 리팩터링이 아니라 데이터 마이그레이션 프로젝트 + 조직 개편이다.

서비스마다 고정 운영비가 붙는다. 파이프라인 유지 1시간, 의존성 업데이트 2시간, 대시보드·알림 규칙 1시간, 런북 갱신 1시간으로 월 5시간을 잡으면 서비스 10개는 월 50시간이다. 월 근무 160시간 기준으로 0.31 FTE가 경계 유지에만 상시 투입된다.

쓰지 말아야 할 때

도메인 이해가 얕은 초기에 프로세스 경계를 긋지 않는다. 경계를 정확히 그으려면 어떤 결정이 자주 바뀌는지 알아야 하고, 그건 시스템을 몇 달 돌려본 뒤에 드러난다. 위의 계산에서 틀린 경계의 수정 비용이 10주라면, 첫 경계가 맞을 확률이 50%일 때 기댓값은 5주다.

팀이 하나면 경계를 프로세스로 만들 이유가 없다. 조율 쌍 K(K−1)/2에서 팀이 하나면 K와 무관하게 조율 쌍은 0이다. 얻을 것이 없는데 운영비 월 5시간 × 서비스 수는 그대로 나간다.

순서는 Modular Monolith가 먼저다. 절차 자체는 안티패턴 9편에 있으므로 여기서는 근거만 적는다. 경계 수정 비용이 코드 안에서는 반나절, 프로세스 밖에서는 10주다. 작업 시간으로 환산하면 4시간 대 400시간(10주 × 주 40시간)으로 100배다. 이만큼 차이 나는 두 장소가 있고 어느 쪽이 맞는지 아직 모른다면, 틀릴 가능성이 남아 있는 동안은 싼 쪽에서 틀리는 것이 기댓값상 유리하다. 위 스크립트로 잰 경계 횡단 빈도가 몇 달간 떨어진 경계만 프로세스로 옮기면 되고, 떨어지지 않는 경계는 애초에 옮길 대상이 아니었다는 정보를 반나절짜리 실수로 얻은 셈이 된다.

새 기능이 기존 경계 어디에도 맞지 않으면 억지로 넣지 않는다. 어느 서비스에 넣어도 어색한 기능은 대개 새 컨텍스트다. 기존 서비스에 밀어 넣으면 그 서비스의 모델이 두 가지 뜻을 갖기 시작하고, Bounded Context가 무너지는 전형적 경로가 된다.

요약

항목내용
Bounded Context하나의 모델이 일관되게 통하는 범위(Evans, 2003). 같은 단어의 다른 뜻을 허용
Context Mapping6가지 관계 유형. 선택은 기술이 아니라 조직 간 요구 가능성의 확정
분해 축비즈니스 능력 / 서브도메인 / 변경 이유의 동일성. 마지막이 측정 가능
근거모듈은 처리 순서가 아니라 바뀔 결정을 감싼다(Parnas, 1972)
왜 통하는가조율을 국소화. 유스케이스가 K개 경계를 넘으면 합의 쌍 K(K−1)/2. K=4면 6쌍
대가 1조인 2ms → 병렬 호출 p50 8ms·p99 60ms, 코드 1줄 → 60~80줄
대가 2잘못 그은 경계의 수정은 5단계 마이그레이션 10주 + 조직 개편
대가 3서비스당 월 5시간 운영비. 10개면 0.31 FTE 상시
쓰지 말아야 할 때도메인 이해가 얕은 초기, 단일 팀. Modular Monolith로 먼저 검증

다음 편 — 11편. 분산 쓰기의 정합성 — Saga, Transactional Outbox, Idempotent Receiver

경계를 그었으면 하나의 비즈니스 트랜잭션이 여러 경계를 넘는 상황이 곧 온다. 원자성을 포기하고 최종 일관성을 사는 거래이고, 그 가격표에는 코드 밖의 항목이 들어 있다. 보상 트랜잭션의 코드량, 중간 상태를 UI와 CS 매뉴얼에 반영하는 일, 단계 N개에서 N + N(N−1)/2로 늘어나는 테스트 경로를 따져본다. Orchestration과 Choreography 중 무엇을 고를지도 이 계산으로 갈린다.

COMMENTS (…)

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

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..