← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-9-distributed-monolith.md

BACKEND8 min read

Distributed Monolith — 가장 비싼 실패

기능 하나에 서비스 일곱 개가 같은 날 배포되는 구조. 함께 바뀌는 것을 세어 진단하고 Modular Monolith로 후퇴하는 탈출 경로.

배포 파이프라인 일곱 개가 같은 날 돌아간다

2막(6~8편)에서 본 ESB 시대의 결론은 하나였다. 중앙에 지능을 모으면 그 중앙이 병목이자 단일 장애점이 된다. 마이크로서비스는 그 반대 방향의 응답이었다. 지능을 서비스 안으로 되돌리고, 파이프는 멍청하게 두고, 각 팀이 자기 일정으로 배포한다. 그 응답이 절반만 성공하면 이런 모양이 된다.

릴리스 노트에 "쿠폰 만료 정책 변경"이라는 한 줄이 있다. 그 한 줄을 내보내려고 coupon, order, payment, notification, user, catalog, 그리고 공용 타입이 든 common이 같은 날 배포된다. 배포 순서가 정해져 있고, 그 순서를 적어둔 문서가 있고, 그 순서를 확인하는 회의가 있다.

서비스는 일곱 개인데 릴리스 단위는 하나다.

Distributed Monolith는 배포는 분리됐는데 변경은 함께 나가야 하는 시스템이다.

모놀리식의 결합도와 분산 시스템의 운영 비용을 동시에 갖는다. 어느 쪽의 이득도 받지 못한다.

왜 이 상태가 만들어지는가

이 구조에 도달하는 경로는 대개 합리적인 결정의 연속이다. 모놀리식이 커져서 배포가 무서워졌고, 팀이 늘어 한 리포지토리에 열 명이 동시에 커밋하는 게 괴로워졌다. 그래서 쪼갰다. 쪼개는 기준은 당장 눈에 보이는 것 — 디렉터리 구조, 테이블 묶음, 조직도였다.

이 선택들 중 틀린 것은 없다. 문제는 경계를 긋는 시점에 그 경계가 맞는지 검증할 방법이 없었다는 것이다. 적절한 크기는 시스템이 몇 달 돌아간 뒤에야 드러난다. 그때 경계를 잘못 그었다는 걸 알게 되지만, 이미 서비스마다 리포지토리·파이프라인·온콜 로테이션이 붙어 있다.

여기에 공용 라이브러리가 얹힌다. 일곱 개가 같은 Order 타입, 같은 인증 미들웨어, 같은 에러 포맷을 쓴다. 중복을 없애는 건 언제나 옳아 보인다. 그런데 이 순간 일곱 개가 하나의 컴파일 단위로 다시 묶인다.

양쪽의 대가만 지불하는 구조

모놀리식과 마이크로서비스는 각각 분명한 이득이 있다. Distributed Monolith가 최악인 이유는 그 이득만 정확히 빠져 있기 때문이다.

모놀리식마이크로서비스Distributed Monolith
트랜잭션로컬 트랜잭션분산 (11편)분산
디버깅스택 트레이스 하나분산 추적 필요 (15편)분산 추적 필요
배포 단위1개서비스별 독립사실상 1개
장애 격리없음경계 단위없음 (동기 사슬, 12편)
운영 대상파이프라인 1, 온콜 1N개N개

마이크로서비스의 전제 조건은 하나다. 독립 배포 가능성. 이게 깨지면 남는 건 비용뿐이다.

비용은 셀 수 있다. 서비스 하나당 배포 파이프라인 1개, 대시보드와 알림 규칙 한 벌, 온콜 문서 한 벌, 네트워크 경계 하나, 의존성 업데이트 대상 하나가 붙는다. 서비스가 7개면 이 다섯 가지가 각각 7벌, 모놀리식이라면 각각 1벌이다.

네트워크 경계는 특히 비싸다. 프로세스 안의 함수 호출은 실패하지 않지만(7편), 프로세스 사이의 호출은 타임아웃·부분 실패·순서 뒤바뀜을 모두 처리해야 한다. 그 경계가 잘못 그어졌다면 늘어난 건 처리 코드뿐이고 얻은 건 없다.

진단: 함께 바뀌는 것을 세어본다

"우리 시스템이 Distributed Monolith인가"는 의견으로 답할 문제가 아니다. 다섯 가지를 측정하면 된다.

  1. 동시 배포 빈도 — 기능 릴리스 한 건에 평균 몇 개 서비스가 나가는가. 1.0에 가까울수록 건강하고, 2.0을 넘으면 경계를 의심한다.
  2. 서비스 간 커밋 상관도 — 같은 날 또는 같은 이슈 번호로 함께 바뀌는 리포지토리 쌍이 있는가.
  3. 릴리스 조율 회의의 존재 — 배포 순서를 사람이 정해야 한다면 그건 이미 하나의 배포 단위다.
  4. 공용 라이브러리 파급 — common 버전을 올릴 때 몇 개를 함께 배포해야 하는가.
  5. 로컬 기동 개수 — 기능 하나를 개발하려고 몇 개를 띄워야 하는가. 5개를 넘으면 경계가 유스케이스를 가로지른다는 신호다.

2번은 git 로그로 직접 잴 수 있다. 각 리포지토리의 커밋 날짜를 모아, 같은 날 함께 바뀐 쌍의 비율을 낸다.

bash
# tools/collect-commit-days.sh
# 각 서비스 리포지토리에서 최근 180일간 "커밋이 있었던 날"을 뽑는다
set -euo pipefail
OUT=/tmp/commit-days.tsv; : > "$OUT"
 
for repo in ../services/*/; do
  name=$(basename "$repo")
  git -C "$repo" log --since='180 days ago' --date=short --pretty=format:'%ad' \
    | sort -u | sed "s/^/${name}\t/" >> "$OUT"
done

날짜 목록이 모이면 쌍별 상관도를 계산한다. 지표는 자카드 계수 — 두 서비스가 함께 바뀐 날 수를 둘 중 하나라도 바뀐 날 수로 나눈 값이다.

ts
// tools/change-coupling.ts
import { readFileSync } from 'node:fs'
 
// "서비스\t날짜" 를 서비스별 날짜 집합으로 모은다
const days = new Map<string, Set<string>>()
for (const row of readFileSync('/tmp/commit-days.tsv', 'utf8').trim().split('\n')) {
  const [svc, day] = row.split('\t')
  days.set(svc, (days.get(svc) ?? new Set()).add(day))
}
 
const names = [...days.keys()].sort()
const pairs: Array<{ pair: string; both: number; jaccard: number }> = []
for (let i = 0; i < names.length; i++)
  for (let j = i + 1; j < names.length; j++) {
    const a = days.get(names[i])!, b = days.get(names[j])!
    const both = [...a].filter((d) => b.has(d)).length
    const either = new Set([...a, ...b]).size
    if (both) pairs.push({ pair: `${names[i]}+${names[j]}`, both, jaccard: both / either })
  }
 
// 자카드 0.4 이상 = 열흘 중 나흘을 함께 바뀐 쌍. 경계 재검토 후보.
console.table(pairs.sort((x, y) => y.jaccard - x.jaccard).slice(0, 15))

자카드 0.4는 "둘 중 하나라도 바뀐 날 열흘 중 나흘은 둘 다 바뀌었다"는 뜻이다. 이 정도로 붙어 있다면 별도 프로세스로 둘 이유보다 함께 둘 이유가 크다. 이 목록이 곧 합병 후보이자 10편에서 다룰 경계 재설정의 출발점이다.

같은 날 커밋이 우연일 수는 있다. 다만 우연으로 자카드 0.4가 나오려면 두 서비스 모두 거의 매일 커밋되어야 하므로, 상위 쌍은 대체로 실제 결합이다. 이슈 번호로 묶으면 정확도가 더 올라간다.

공용 라이브러리라는 지름길

측정에서 거의 항상 상위에 올라오는 쌍은 공용 패키지를 매개로 붙어 있다. 도메인 타입을 공유 패키지에 두면 타입 안정성은 얻지만, 그 타입의 변경이 전 서비스 재배포를 요구한다.

ts
// Before — packages/common/src/order.ts (모든 서비스가 import)
export interface Order {
  id: string
  status: 'PENDING' | 'PAID' | 'SHIPPED'  // 'REFUNDED'를 추가하는 순간
  items: OrderItem[]                      // 일곱 개가 재빌드·재배포 대상이 된다
}

탈출은 타입을 지우는 게 아니라 소유권을 옮기는 것이다. 각 서비스가 필요한 필드만 자기 리포지토리에 정의하고 모르는 필드는 무시한다. 8편의 Tolerant Reader를 적용한 것이다.

ts
// After — services/notification/src/contracts/order.ts (이 서비스가 소유)
import { z } from 'zod'
 
// 알림 서비스가 실제로 쓰는 필드만 선언하고 나머지는 통과시킨다
export const OrderEvent = z.object({
  id: z.string(),
  status: z.string(),   // 유니온으로 좁히지 않는다 — 모르는 값이 와도 깨지지 않게
  buyerEmail: z.string().email(),
}).passthrough()
 
// 생산자가 'REFUNDED'를 추가해도 재배포가 필요 없다.
// 처리하지 않은 값은 기본 동작(알림 생략)으로 떨어진다.

대가는 중복이다. Order의 모양이 일곱 군데에 조금씩 다르게 존재한다. 이 중복은 낭비가 아니라 독립 배포 가능성을 사는 값이다 — 10편에서 같은 논증을 한 번 더 한다.

탈출 경로: Modular Monolith로의 후퇴

경계가 틀렸다면 선택지는 둘이다. 경계를 옮기거나, 경계를 프로세스 밖으로 되돌리거나.

Martin Fowler는 2015년 글 "MonolithFirst"에서 성공적인 마이크로서비스 시스템 상당수가 모놀리식에서 출발했고 처음부터 마이크로서비스로 시작한 사례는 자주 곤란에 빠졌다고 정리했다. 경계를 알아내려면 도메인을 알아야 하고, 도메인은 만들어보기 전에는 모른다.

그래서 현실적인 탈출은 Modular Monolith(하나의 배포 단위 안에서 모듈 경계를 강제하는 구조)다. 순서가 중요하다.

  1. 자카드 상위 쌍을 하나의 프로세스로 합친다.
  2. 합친 안에서 모듈 경계를 코드로 강제한다. 모듈 간 직접 import 금지를 린트 규칙으로 걸고 공개 인터페이스를 명시한다.
  3. 몇 달간 그 경계를 넘는 변경 빈도를 다시 센다. 경계 위반이 줄어들면 그 경계는 안정된 것이다.
  4. 안정된 경계만 프로세스로 쪼갠다.

코드 안의 모듈 경계는 틀렸을 때 바꾸는 비용이 리팩터링 한 번이다. 프로세스 경계는 틀렸을 때 바꾸는 비용이 리포지토리 이전·데이터 이관·팀 재편이다. 싼 곳에서 먼저 틀려보는 게 합리적이다.

이 후퇴의 대가는 작지 않다. 되돌리는 작업 자체가 크고, 무엇보다 조직이 이미 서비스 단위로 나뉘어 있으면 코드만 합칠 수 없다. Conway's Law(조직의 소통 구조가 시스템 구조로 복제된다) 때문이다. 팀 세 개가 소유하던 서비스 셋을 합치면 소유자가 셋인 코드베이스가 생긴다. 기술 결정이 아니라 조직 결정이고, 16편에서 다룬다.

판단 기준 — 한시적 Distributed Monolith는 학습 비용이다

이 구조가 항상 실패인 것은 아니다.

경계를 탐색하는 과도기. 일단 쪼개보고 함께 바뀌는 쌍을 측정해 경계를 조정하는 중이라면, 그 기간의 Distributed Monolith는 학습 비용이다. 조건은 둘이다. 측정하고 있을 것, 기한이 있을 것. 측정도 기한도 없으면 탐색이 아니라 방치다.

서비스 수가 적을 때. 서비스가 2~3개고 배포 조율이 슬랙 메시지 한 줄로 끝난다면, 위에 적은 비용이 대부분 발생하지 않는다. 이 상태를 억지로 완전 독립 배포로 만드는 데 드는 설계 비용이 오히려 크다.

규제·보안 경계. 결제 정보나 개인 식별 정보를 별도 프로세스로 분리하는 것은 변경이 함께 나가더라도 정당하다. 목적이 독립 배포가 아니라 접근 통제이므로 기준이 다르다.

핵심은 왜 이 경계가 프로세스 경계여야 하는지 한 문장으로 답할 수 있느냐다. 답이 "마이크로서비스니까"라면 그 경계는 비용만 만든다.

요약

항목내용
정의배포는 분리됐으나 변경은 함께 나가야 하는 시스템
초기 매력모놀리식 배포 공포와 팀 간 커밋 충돌의 즉각적 해소
손실모놀리식의 결합도 + 분산의 운영 비용. 파이프라인·온콜·경계가 서비스 수만큼
진단기능당 동시 배포 수, 커밋 자카드 계수, 릴리스 조율 회의, 로컬 기동 개수
주요 원인잘못 그은 경계·공유 DB(10편), 공용 라이브러리, 동기 호출 사슬(12편)
탈출Modular Monolith로 후퇴 → 코드에서 경계 검증 → 안정된 경계만 프로세스로 분리
탈출의 대가되돌리는 비용, Conway's Law 탓에 조직 재편 없이는 합쳐지지 않음(16편)
정답인 경우기한과 측정이 있는 과도기, 서비스 2~3개, 규제·보안 격리 경계

다음 편 — 10편. Shared Database와 Entity Service — 경계를 데이터로 그을 때

이번 편에서 "잘못 그은 경계"라고만 적고 넘어간 부분을 연다. 여러 서비스가 같은 테이블을 쓰는 Shared Database와 명사 단위로 서비스를 나누는 Entity Service는 다른 증상처럼 보이지만 같은 실수의 두 얼굴이다. 둘 다 경계를 데이터 구조로 그었다. 대안으로 Bounded Context와 "변경 이유의 동일성"을 놓고, 데이터 중복이 왜 비용이 아니라 결합도를 사는 값인지 따져본다.

COMMENTS (…)

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

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..