코드 안쪽의 이야기. 무엇과 싸우는지 규정하고(01), 그것을 가두는 상자를 만들고(02), 상자의 선을 어디에 그을지 판단한다(03). 이 4개 장은 나머지 전부의 문법이다.
왜 대부분의 원리가 결국 같은 이야기인가
소프트웨어 아키텍처를 공부하려고 책과 논문 목록을 펼치면 압도된다. Parnas, Brooks, Dijkstra, Fielding, Evans, Ousterhout, "Clean Architecture", "Domain-Driven Design", "Out of the Tar Pit"… 서로 다른 시대·언어·문제에서 나온 수십 편의 정전(canon)이 있다. 하지만 그것들을 나란히 놓고 읽으면 이상한 일이 벌어진다. 거의 전부가 같은 적을 다른 무기로 상대하고 있다.
그 적은 복잡도다. 그리고 인류가 지금까지 찾아낸 가장 강력한 무기는 하나다 — 경계를 긋고, 그 경계 뒤로 결정을 숨기는 것. 무엇을 숨길지, 무엇을 함께 두고 무엇을 떼어낼지, 경계를 어디에 그을지, 그 경계가 왜 조직·시간과 얽히는지. 정전들은 이 질문들의 서로 다른 좌표에 서 있을 뿐이다.
소프트웨어 설계의 거의 모든 원리는 “복잡도를 모듈 경계로 다스린다”는 하나의 문제를, 각도만 바꿔 공격한다. 이 한 줄을 축으로 잡으면 개별 개념들이 흩어지지 않는다.
두 정의를 겹쳐 두면 충분하다. 구조적으로(Bass·Clements·Kazman, SEI): 시스템을 이루는 요소, 요소 간 관계, 그리고 외부에 드러나는 속성의 집합. 실용적으로(Fowler·Booch): “나중에 바꾸기 비싼 결정들” — 되돌리기 어려운 선택일수록 아키텍처에 속한다. 이 자료의 모든 원리는 결국 그 비싼 결정을 어디에·어떻게 내릴 것인가에 대한 답이다.
아래 12개 장은 세 부로 나뉘어 이 그림을 따라 흐른다. Part I은 원리다 — 적의 정체(복잡도)를 규정하고 → 그 적을 가두는 기본 도구(모듈)를 익히고 → 경계를 어디에 그을지 판단한다. Part II는 그 원리를 현실의 물리 법칙과 충돌시킨다 — 지워지지 않는 데이터, 반드시 일어나는 장애, 그리고 큰 모양의 선택(스타일). Part III은 판단과 실천이다 — 누가 만드는가(사람·시간), 얼마나 설계할 것인가, 결정을 어떻게 남기고 검증할 것인가, 이미 있는 시스템을 어떻게 옮길 것인가. 마지막에 전체를 하나의 지도로 묶는다.
이 자료의 앞선 판은 핵심 20% — 코드 안쪽의 정적 구조와 조직까지를 2시간에 훑는 것이었다. 40% 판이 더한 것은 실제로 돌아가는 시스템의 나머지 절반이다: 데이터와 일관성(04), 실패와 운영(05), 스타일 지형도(06), 그리고 설계량 판단·기술·평가·레거시 이행(08~10). 원리만 아는 사람은 도면은 아름답지만 운영에서 무너지고, 패턴만 아는 사람은 카탈로그를 외운다. 두 절반을 한 번에 관통하는 것이 이 판의 목적이다. 그리고 원리가 코드에서 어떤 모양이 되는지 보이도록 Swift 예제 13개를 함께 실었다 — 전부 Swift 6 언어 모드(6.2.1)에서 컴파일을 확인한 코드다.
복잡도 — 무엇과 싸우는가
싸움을 시작하기 전에 적을 정의해야 한다. 소프트웨어에서 “복잡하다”는 건 “코드가 길다”가 아니다. John Ousterhout는 복잡도를 이렇게 정의한다: 시스템을 이해하거나 수정하기 어렵게 만드는 모든 것. 규모가 아니라 변경 비용이 기준이다.
Ousterhout · 복잡도의 3가지 증상
복잡도는 이렇게 모습을 드러낸다
복잡도는 추상적이지만, 그것이 남기는 발자국은 구체적이다. Ousterhout는 세 가지 증상을 꼽는다. 코드를 볼 때 이 셋 중 하나가 느껴진다면 복잡도가 쌓이고 있는 것이다.
변경 증폭
사소한 요구 하나를 바꾸는 데 여러 곳을 동시에 고쳐야 한다. 하나의 결정이 코드 곳곳에 흩어져 있다는 신호.
인지 부하
무언가를 하려면 알아야 할 것이 너무 많다. 개발자가 머릿속에 담아야 할 상태·규칙·예외가 넘친다.
모르는 줄도 모름
가장 위험한 증상. 무엇을 알아야 안전하게 고칠 수 있는지조차 알 수 없다. 지뢰밭 위에서 코딩하는 상태.
Brooks 1986 · No Silver Bullet
본질적 복잡도 vs 우발적 복잡도
그런데 모든 복잡도가 같은 종류는 아니다. Fred Brooks는 1986년 논문 “No Silver Bullet”에서 결정적인 구분을 세운다.
- 본질적(essential) 복잡도 — 문제 자체에 내재한다. 항공 관제 시스템이 다뤄야 할 규칙의 수는 우리가 어떤 언어를 쓰든 줄지 않는다. 제거 불가능하다.
- 우발적(accidental) 복잡도 — 문제가 아니라 우리가 선택한 도구·방식 때문에 생긴다. 어설픈 추상화, 중복된 상태, 얽힌 의존성. 제거 가능하다.
Brooks의 논증은 이 구분 위에 선다. 소프트웨어의 본질적 어려움(복잡성·순응성·가변성·비가시성)은 도구로 없앨 수 없으므로, “생산성을 10배로 올리는 은탄환(silver bullet)”은 존재하지 않는다. 우리가 실제로 공략할 수 있는 전장(戰場)은 우발적 복잡도뿐이다.
Moseley & Marks 2006 · Out of the Tar Pit
우발적 복잡도의 최대 원천: 상태와 제어
Brooks가 지도를 그렸다면, “Out of the Tar Pit”은 그 지도에서 가장 큰 늪이 어디인지 짚었다. 저자들은 우발적 복잡도의 두 주범을 지목한다:
- 상태(state) — 시스템의 어느 부분이 지금 어떤 값을 갖고 있는지가 경우의 수를 폭발시킨다. 가변 상태가 많을수록 “지금 무슨 일이 일어날 수 있는가”를 추론하기 어렵다.
- 제어(control) — 실행 순서·흐름에 대한 불필요한 명세. “무엇을” 원하는지가 아니라 “어떻게·어떤 순서로” 하라고 일일이 지시할수록 얽힌다.
이들의 처방은 상태와 제어를 최소화하는 방향 — 함수형·선언형 사고, 그리고 본질과 우발을 아키텍처 수준에서 분리하는 것이다. 이 논문이 오늘날까지 설계 토론에서 가장 많이 인용되는 현대 고전인 이유는, “복잡도를 줄여라”라는 구호를 “상태와 제어를 줄여라”라는 실행 가능한 지침으로 번역했기 때문이다.
“단순함을 얻기 어려운 게 아니다. 우리가 스스로 만들어낸 복잡함을 걷어내기가 어려울 뿐이다.”
— Out of the Tar Pit의 문제의식을 요약하며
모듈 — 복잡도를 가두는 상자
우발적 복잡도와 싸우는 근본 전략은 분해(decomposition)다. 큰 문제를 독립적으로 이해·교체·재사용할 수 있는 단위로 쪼갠다. 이 단위가 모듈이다. 하지만 “쪼갠다”는 말은 위험하다. 잘못 쪼개면 조각 수만 늘고 복잡도는 오히려 커진다. 이 장의 개념들은 전부 “어떻게 잘 쪼개는가”에 대한 답이다.
Parnas 1972 · 이 분야의 원전
정보 은닉 — 무엇을 기준으로 쪼갤 것인가
David Parnas의 1972년 논문 “On the Criteria To Be Used in Decomposing Systems into Modules”는 이 질문에 처음으로 정확한 답을 줬고, 이후 거의 모든 모듈 이론이 그 후손이다. 핵심 주장은 반직관적이다.
대부분의 사람은 시스템을 처리 순서(flowchart)로 나눈다: “입력을 읽는 모듈 → 처리하는 모듈 → 출력하는 모듈”. Parnas는 이게 나쁜 분해라고 말한다. 왜냐하면 각 단계가 서로의 자료구조를 알아야 하고, 그래서 한 결정(예: 데이터 저장 포맷)이 바뀌면 여러 모듈이 동시에 무너지기 때문이다.
좋은 분해의 기준은 “바뀔 가능성이 높은 설계 결정”이다. 각 모듈은 그런 결정 하나 — Parnas의 용어로 비밀(secret) — 을 감추고, 바깥에는 안정적인 인터페이스만 내민다. 그러면 그 결정이 바뀌어도 한 모듈만 고치면 된다. 이것이 정보 은닉(Information Hiding)이다.
정보 은닉은 이후 등장한 거의 모든 것의 뿌리다. 객체지향의 캡슐화, 추상 데이터 타입, 아래에서 볼 깊은 모듈과 의존성 규칙까지 — 전부 “변할 것을 안정된 인터페이스 뒤에 숨긴다”는 이 아이디어의 변주다.
Ousterhout · A Philosophy of Software Design
깊은 모듈 — 정보 은닉의 정량화
그렇다면 “좋은 모듈”을 어떻게 알아볼까? Ousterhout는 시각적인 은유를 준다. 모듈을 직사각형으로 그리자. 윗변은 인터페이스(다른 코드가 이 모듈을 쓰기 위해 알아야 하는 것), 넓이 전체는 기능(구현)이다.
- 깊은(deep) 모듈 — 윗변이 좁고(인터페이스가 작고) 몸통이 깊다(구현이 많다). 적게 알려주고 많이 해준다. 좋은 모듈.
- 얕은(shallow) 모듈 — 윗변이 넓고 몸통이 얕다. 알아야 할 건 많은데 감춰주는 건 거의 없다. 인터페이스 비용만 물리는 나쁜 모듈.
// ✕ 얕은 모듈 — 내부 절차가 인터페이스로 새어 나온다.
// 호출자가 "키를 만들고 → 있는지 보고 → 읽고 → 없으면 쓰고 → 용량을 정리한다"는
// 순서까지 알아야 한다. 실행 순서 합의 = 실행 커넥선스(위 표의 6번, 동적·강함).
protocol ImageCacheShallow {
func makeKey(for url: URL) -> String
func hasEntry(_ key: String) -> Bool
func readData(_ key: String) throws -> Data
func write(_ data: Data, key: String) throws
func evictIfNeeded(limit: Int)
}
// ✓ 깊은 모듈 — 윗변은 하나, 몸통은 깊다.
// 키 생성·중복 요청 합치기·만료·용량 관리·디스크 레이아웃이 전부 "비밀"이다.
protocol ImageCache: Sendable {
func image(for url: URL) async throws -> Data
}
이 관점은 실무 결정을 뒤집는다. “함수를 잘게 쪼개라”는 통념과 달리, 잘게 쪼갠 얕은 함수가 수십 개면 오히려 인터페이스 비용만 폭증한다. Ousterhout는 여기에 두 가지를 덧붙인다. 전술적 vs 전략적 프로그래밍 — 당장 돌아가게만 하는(tactical) 대신 좋은 설계에 조금씩 투자하라(strategic). 그리고 복잡도는 점진적으로 쌓이므로 무관용(zero-tolerance)으로 대하라 — 작은 지저분함 하나하나가 이자까지 쳐서 갚는 기술 부채(technical debt, Cunningham)가 되고, 그 방치된 극단이 바로 정전적 안티패턴 “큰 진흙 공(Big Ball of Mud)”이다.
깊은 모듈이 작동하는 근본 이유는 좋은 추상화 덕분이다 — “무엇을 하는가”만 남기고 “어떻게”를 지운 단순화. 다만 모든 추상화는 어느 지점에서 샌다(leaky abstraction, Spolsky): 성능·오류·한계처럼 감춰둔 세부가 결국 새어 나온다. 그래서 추상화에 기대되, 그 아래에서 무슨 일이 벌어지는지도 알고 있어야 한다.
두 축 · 좋은 구조의 척도
결합도와 응집도
정보 은닉과 깊은 모듈이 “한 모듈을 어떻게 만드는가”라면, 결합도(coupling)와 응집도(cohesion)는 “모듈들의 관계가 건강한가”를 재는 두 축이다. 1970년대 구조적 설계(Constantine·Yourdon, Structured Design)에서 정식화된 개념이다. 규칙은 짧다:
- 낮은 결합도 — 모듈 사이의 의존은 적고 얇게. 한 모듈을 바꿔도 다른 모듈이 흔들리지 않아야 한다.
- 높은 응집도 — 한 모듈 안의 요소들은 하나의 목적을 향해 모여 있어야 한다. “여기 있어야 할 이유”가 분명해야 한다.
Page-Jones · 결합도의 정밀 언어
커넥선스 — “결합도가 높다”를 9가지로 쪼개기
결합도·응집도는 강력하지만 거칠다. “이 코드는 결합도가 높다”는 말은 진단이라기보다 불평에 가깝다. 어떤 종류로 묶여 있고, 얼마나 고치기 어렵고, 얼마나 멀리 퍼져 있는지를 말해주지 않기 때문이다. Meilir Page-Jones가 제시한 커넥선스(connascence)는 바로 그 해상도를 올린다. 이름은 “함께 태어남”이라는 뜻으로, 한쪽을 바꾸면 다른 쪽도 같이 바꿔야 하는 관계를 가리킨다.
커넥선스는 컴파일 시점에 분석 가능한 정적(static) 5종과 실행 시점에만 드러나는 동적(dynamic) 4종으로 나뉜다. 아래로 갈수록 강하다 — 즉 고치기 어렵다.
| 종류 | 구분 | 무엇이 함께 묶이나 | 깨지는 예 |
|---|---|---|---|
| 이름 (Name) | 정적 | 같은 이름을 함께 알아야 함 | 변수·함수명을 바꾸면 호출부도 바꿔야 함 |
| 타입 (Type) | 정적 | 같은 타입에 합의 | int를 long으로 바꾸면 양쪽 수정 |
| 의미 (Meaning) | 정적 | 값의 뜻에 합의 | “상태 0 = 대기”라는 암묵 규약이 흩어져 있음 |
| 위치 (Position) | 정적 | 나열 순서에 합의 | 인자 순서를 바꾸면 모든 호출부가 조용히 깨짐 |
| 알고리즘 (Algorithm) | 정적 | 같은 계산 절차에 합의 | 해시 방식을 한쪽만 바꾸면 검증 실패 |
| 실행 (Execution) | 동적 | 실행 순서 | init() 전에 use()를 부르면 터짐 |
| 시점 (Timing) | 동적 | 실행 타이밍 | 경쟁 조건 — 100ms 안에 끝나야 동작 |
| 값 (Value) | 동적 | 여러 값이 동시에 유효해야 함 | 시작일 < 종료일 불변식이 두 곳에서 관리됨 |
| 정체성 (Identity) | 동적 | 같은 인스턴스를 가리켜야 함 | 두 컴포넌트가 같은 큐 객체를 공유해야만 동작 |
이 언어가 실무에서 즉시 유용한 이유는, 리팩터링을 “강한 커넥선스를 약한 커넥선스로 바꾸는 일”로 재정의해주기 때문이다. 예를 들어 인자 순서에 대한 합의(위치)는 이름에 대한 합의(이름)보다 강하다 — 그래서 순서 의존을 이름 의존으로 내리는 것만으로 결합이 실제로 약해진다.
// ① 위치 커넥선스 — 호출부와 정의가 "순서"를 함께 알아야 한다 (강함)
func createUser(_ city: String, _ name: String, _ age: Int) { }
createUser("서울", "김하나", 30) // 도시와 이름을 바꿔 써도 조용히 컴파일된다
// ② 이름 커넥선스 — 순서가 아니라 "이름"에 합의한다 (약함)
// Swift 의 인자 라벨은 이 강도 낮추기를 언어가 강제하는 장치다
func createUser(name: String, age: Int, city: String) { }
createUser(name: "김하나", age: 30, city: "서울")
// ③ 한 걸음 더 — 타입으로 승격하면 컴파일러가 대신 지켜준다
struct PersonName: Hashable { let raw: String }
struct CityName: Hashable { let raw: String }
func createUser(name: PersonName, age: Int, city: CityName) { }
createUser(name: PersonName(raw: "김하나"), age: 30, city: CityName(raw: "서울"))
// createUser(name: CityName(raw: "서울"), …) ← 이제 컴파일조차 되지 않는다
그리고 Page-Jones는 커넥선스를 세 축으로 평가하라고 말한다. 강도(strength) — 고치는 데 드는 노력. 지역성(locality) — 묶인 두 곳이 코드상 얼마나 가까운가. 정도(degree) — 몇 개가 함께 묶여 있는가. 여기서 이 장 전체를 관통하는 실천 규칙 하나가 나온다.
거리가 멀어질수록 약한 커넥선스만 쓴다. 같은 함수 안이라면 강한 커넥선스도 괜찮다 — 한눈에 보이고 한 번에 고칠 수 있으니까. 하지만 모듈을 넘고, 팀을 넘고, 네트워크를 넘을수록 강한 커넥선스는 재앙이 된다. 결합을 0으로 만드는 게 목표가 아니라, 거리에 맞는 강도로 관리하는 것이 목표다.
Martin · Clean Architecture Part IV
컴포넌트 응집 3원칙 — 무엇을 한 덩어리로 묶나
커넥선스가 “두 요소 사이”를 본다면, 다음 질문은 규모가 한 단계 크다: 어떤 클래스들을 한 배포 단위(컴포넌트·패키지·모듈)로 묶을 것인가? Robert C. Martin은 세 원칙을 준다.
- REP · 재사용/릴리스 등가 원칙 — 재사용의 단위는 릴리스의 단위다. 버전 번호로 추적되지 않는 것은 재사용할 수 없다. 함께 릴리스되는 것들을 함께 묶어라.
- CCP · 공통 폐쇄 원칙 — 같은 이유로, 같은 시점에 바뀌는 클래스들을 한 컴포넌트에 모아라. 단일 책임 원칙(SRP)의 컴포넌트판이다. 변경이 한 컴포넌트에 갇히게 하는 것이 목적이다.
- CRP · 공통 재사용 원칙 — 필요하지도 않은 것에 의존하도록 강요하지 마라. 인터페이스 분리 원칙(ISP)의 컴포넌트판이다. 함께 쓰이지 않는 것은 함께 묶지 마라.
여기서 결정적인 통찰은 이 셋을 동시에 최대화할 수 없다는 점이다. REP와 CCP는 포함적이라 컴포넌트를 크게 만들고, CRP는 배제적이라 작게 만든다. 셋은 서로를 당긴다. 그래서 Martin은 이를 긴장 삼각형(tension triangle)으로 그린다 — 어느 두 변을 택하면 나머지 하나의 비용을 물게 된다.
Dijkstra · 관심사의 분리
한 번에 하나의 관심사
이 모든 것의 상위 개념이 관심사의 분리(Separation of Concerns)다. Edsger Dijkstra가 1974년 정립한 이 원칙은 “서로 다른 관심사(예: 비즈니스 로직 / 화면 표현 / 데이터 저장)를 섞지 말고, 한 번에 하나에만 집중할 수 있게 나누라”는 것이다. 정보 은닉이 “무엇을 숨길까”라면, 관심사의 분리는 “애초에 무엇과 무엇을 같은 자리에 두지 말까”를 말한다. MVC, 계층형 아키텍처, 클린 아키텍처가 전부 이 원칙의 구체화다.
Swift의 프로토콜 지향 프로그래밍(POP)은 이 장의 원칙들을 언어 차원에서 구현한 한 사례다. 클래스 상속(강한 결합)을 피하고, 작은 프로토콜 + 값 타입의 조합으로 기능을 짓는다. “구현이 아니라 능력(인터페이스)에 의존하라”는 결합도·정보 은닉의 원칙이 문법이 된 형태다.
경계는 공짜가 아니다. 잘못 그은 경계는 없느니만 못하다 — 서로 얽힌 것을 억지로 갈라놓으면 인터페이스만 두꺼워지고 변경이 경계를 넘나든다(얕은 모듈, 나노서비스가 그 예다). 조기 추상화도 같다. 아직 변화의 축이 안 보이면 성급히 나누기보다 뭉쳐 두고 기다리는 편이 낫다. “언제 나눌지”는 “어떻게 나눌지”만큼 중요하다.
경계를 어디에 긋나
모듈이 좋은 도구라는 걸 알았다. 하지만 진짜 어려운 질문이 남았다: 선을 정확히 어디에 그을 것인가? 같은 시스템도 경계를 어디에 두느냐에 따라 천국과 지옥이 갈린다. 이 장의 세 개념은 “경계선을 긋는 세 가지 판단 기준”이다.
Evans · Domain-Driven Design
바운디드 컨텍스트 — 용어가 통하는 범위
대규모 시스템에서 가장 흔한 함정: “고객(Customer)”이라는 하나의 단어를 온 시스템이 공유하려 든다. 하지만 영업팀의 “고객”과 배송팀의 “고객”은 전혀 다른 것이다. 영업의 고객엔 신용등급·계약이, 배송의 고객엔 주소·수령 가능 시간이 중요하다. 이걸 하나의 거대 모델로 통합하려 하면 아무도 만족 못 하는 괴물이 태어난다.
Eric Evans의 바운디드 컨텍스트(Bounded Context)는 이렇게 답한다: 모델과 용어가 일관되게 통하는 명시적 경계를 그어라. 경계 안에서 “고객”은 하나의 뜻만 갖는다. 경계를 넘을 때는 번역(context map)을 거친다. 즉 경계는 물리적 코드 분할이기 전에 “말이 통하는 범위”다.
Evans · Context Mapping
경계에서 무슨 일이 벌어지나 — 컨텍스트 매핑과 안티커럽션 레이어
바운디드 컨텍스트를 그었다고 끝이 아니다. 실무에서 진짜 사고는 경계선 위에서 터진다. 두 컨텍스트가 만날 때 어느 쪽 모델이 이기는가, 누가 누구에게 맞춰주는가, 상대가 바뀌면 나는 얼마나 흔들리는가. Evans는 이 관계 유형에 이름을 붙여 컨텍스트 맵(context map)으로 그리라고 말한다. 관계에 이름이 붙는 순간 그것은 협상 가능한 대상이 된다.
| 관계 유형 | 누가 맞춰주나 | 언제 고르나 |
|---|---|---|
| 공유 커널 Shared Kernel | 양쪽이 모델 일부를 공유 | 두 팀이 정말 가깝고 함께 바꿀 수 있을 때만. 가장 비싼 결합이다. |
| 고객–공급자 Customer / Supplier | 상류가 하류 요구를 반영 | 상하 관계가 명확하고 상류가 협조할 의지가 있을 때. |
| 준수자 Conformist | 내가 상대 모델을 그대로 받음 | 상대가 협조하지 않고 내 협상력이 없을 때. 번역 비용을 아끼는 대신 모델 오염을 감수한다. |
| 안티커럽션 레이어 Anticorruption Layer | 내가 경계에 번역층을 세움 | 상대 모델이 내 도메인을 망가뜨릴 때. 레거시 연동의 기본값. |
| 공개 호스트 서비스 Open Host Service | 내가 안정된 공용 계약을 발행 | 소비자가 여럿일 때. 소비자별 특수 대응을 막는다. |
| 각자의 길 Separate Ways | 아무도 — 통합하지 않음 | 통합 이득이 비용보다 작을 때. 가장 저평가된 선택지다. |
이 중 실무에서 압도적으로 자주 필요한 것이 안티커럽션 레이어(ACL)다. 레거시 시스템이나 외부 API의 모델은 대개 우리 도메인 언어와 맞지 않는다. 그것을 그대로 받아들이면 — 즉 준수자가 되면 — 상대의 이상한 개념(status_cd = "03", 의미 없는 필드 40개)이 우리 코드 깊숙이 퍼져 들어온다. ACL은 경계에 번역층을 세워 그 침투를 막는다. 02장의 정보 은닉을 시스템 간 관계에 적용한 것이라고 봐도 된다 — 외부 모델이라는 “비밀”을 번역층 뒤에 가둔다.
// ── 레거시가 주는 모양. 우리 언어가 아니다.
struct LegacyOrderDTO: Decodable {
let ord_no: String
let status_cd: String // "01" 접수 · "02" 결제완료 · "03" 배송중 · "04" 배송완료
let amt: Int // 단위: 원(정수)
}
// ── 우리 도메인. 여기까지 status_cd 라는 단어가 들어오면 이미 진 것이다.
enum OrderStatus: Sendable { case received, paid, shipping, delivered }
struct DomainOrder: Sendable {
let number: String
let status: OrderStatus
let amount: Decimal
}
// ── 안티커럽션 레이어. 번역이 "이 한 곳"에만 있다.
struct LegacyOrderTranslator {
enum Failure: Error { case unknownStatus(String) }
private static let statusMap: [String: OrderStatus] = [
"01": .received, "02": .paid, "03": .shipping, "04": .delivered
]
func translate(_ dto: LegacyOrderDTO) throws -> DomainOrder {
guard let status = Self.statusMap[dto.status_cd] else {
// 모르는 코드를 조용히 삼키지 않는다 — 레거시가 늘어난 걸 즉시 알아야 한다
throw Failure.unknownStatus(dto.status_cd)
}
return DomainOrder(number: dto.ord_no, status: status, amount: Decimal(dto.amt))
}
}
// 레거시가 status_cd "05" 를 추가해도 고칠 곳은 이 파일 하나다.
Martin · Clean Architecture · SOLID
의존성 규칙 — 방향이 전부다
경계를 그었다면, 그 경계를 어느 방향으로 넘게 할 것인가가 남는다. Robert C. Martin의 클린 아키텍처는 이를 하나의 규칙으로 압축한다 — 의존성 규칙(Dependency Rule): 소스 코드 의존성은 항상 바깥(세부)에서 안쪽(정책)으로만 향한다.
동심원을 상상하자. 중심엔 가장 안정적이고 추상적인 업무 규칙(엔티티·유스케이스)이, 바깥으로 갈수록 잘 바뀌는 세부(DB·웹 프레임워크·UI)가 있다. 규칙: 안쪽은 바깥쪽의 존재를 몰라야 한다. 데이터베이스나 프레임워크는 “세부사항”일 뿐이며, 핵심 업무 규칙이 그것들에 의존해선 안 된다. 이렇게 하면 DB를 갈아치우거나 웹을 CLI로 바꿔도 중심은 무사하다.
이 규칙을 코드에서 지탱하는 도구가 SOLID 5원칙, 그중에서도 의존성 역전(DIP)이다: 구체 구현이 아니라 추상(인터페이스)에 의존하라. 그러면 화살표의 방향을 뒤집어 “안쪽이 정의한 인터페이스를 바깥이 구현”하게 만들 수 있다. 결국 이것도 정보 은닉의 확장 — 세부라는 “비밀”을 안정적 인터페이스 뒤로 숨기는 것이다. 같은 아이디어를 먼저 정식화한 것이 육각형 아키텍처(Ports & Adapters, Cockburn 2005)와 어니언 아키텍처이며, 클린 아키텍처는 이 계보를 종합한 이름표에 가깝다.
// ══ Domain 모듈 ══ Foundation 말고는 아무것도 import 하지 않는다.
// 인터페이스를 "안쪽"이 정의한다 — 이것이 화살표를 뒤집는 지점이다.
public struct Order: Identifiable, Sendable {
public let id: UUID
public let total: Decimal
}
public protocol OrderRepository: Sendable { // 안쪽이 요구하는 능력
func find(id: UUID) async throws -> Order?
func save(_ order: Order) async throws
}
public struct PlaceOrder: Sendable { // 유스케이스 — 세부를 모른다
private let repository: any OrderRepository // 구현이 아니라 능력에 의존
public init(repository: any OrderRepository) {
self.repository = repository
}
public func execute(_ order: Order) async throws {
try await repository.save(order)
}
}
// ══ Infrastructure 모듈 ══ 바깥이 안쪽의 프로토콜을 구현한다.
// Domain 은 이 파일의 존재도, GRDB 라는 이름도 모른다.
import Domain
import GRDB
struct SQLiteOrderRepository: OrderRepository {
func find(id: UUID) async throws -> Order? { /* SQL */ nil }
func save(_ order: Order) async throws { /* SQL */ }
}
// Domain 안에서 `import Infrastructure` 를 쓰면 순환 의존이 되어
// 빌드가 실패한다 — 의존성 규칙을 컴파일러가 지켜준다.
Martin · 컴포넌트 결합 3원칙
방향에 숫자를 붙이기 — ADP · SDP · SAP
의존성 규칙은 “안쪽으로”라는 방향을 준다. 하지만 실제 코드베이스에는 안팎이 명확하지 않은 컴포넌트가 수십 개 있다. 그것들 사이의 방향이 건강한지 측정할 수 있을까? Martin의 컴포넌트 결합 3원칙이 그 답이다. 앞서 본 응집 3원칙(REP·CCP·CRP)이 “무엇을 묶나”였다면, 이 셋은 “묶은 것들을 어떻게 잇나”다.
- ADP · 의존성 비순환 원칙 — 컴포넌트 의존 그래프에 순환이 없어야 한다. 순환이 생기면 그 안의 컴포넌트들은 사실상 하나의 거대 컴포넌트가 된다. 따로 빌드·테스트·배포할 수 없고, 아침마다 “누가 뭘 고쳤는지” 알 수 없어진다. 해소법은 둘 — 의존성 역전(DIP)으로 화살표를 뒤집기, 또는 양쪽이 의존할 새 컴포넌트를 추출하기.
- SDP · 안정 의존 원칙 — 안정성의 방향으로 의존하라. 자주 바뀔 컴포넌트가 안정된 컴포넌트에 의존해야 하고, 그 반대는 안 된다. 안정된 것에 기대야 흔들리지 않는다.
- SAP · 안정 추상화 원칙 — 안정된 컴포넌트는 추상적이어야 한다. 안정되어서 바꾸기 어려운데 구체적이기까지 하면, 아무도 그것을 확장할 수 없다. 안정성은 추상화로 유연성을 회복해야 한다.
SDP와 SAP는 함께 정량화된다. 컴포넌트마다 두 숫자를 잰다.
I=0은 나가는 의존이 없다는 뜻 — 남의 변경 때문에 내가 바뀔 일이 없으므로 최대 안정이다(대개 많은 것이 나에게 의존한다). I=1은 들어오는 의존이 없고 나만 남에게 의존하는 최대 불안정 상태다.
A=0은 전부 구체 구현, A=1은 전부 인터페이스·추상 타입.
이 좌표계의 진짜 가치는 두 개의 죽음의 구역을 보여준다는 데 있다.
데이터베이스 스키마는 거의 항상 고통의 구역에 있다. 모든 코드가 의존하므로 극도로 안정(I≈0)한데, 컬럼과 타입이라는 극도로 구체적인 형태(A≈0)를 갖는다. 그래서 스키마 변경이 시스템 전체를 흔든다. 다음 장(04)이 “데이터가 가장 비싼 결정”이라고 말하는 이유가 여기에 이미 수치로 나와 있다.
Saltzer, Reed & Clark 1984
종단 간 논증 — 기능을 어느 계층에 둘까
마지막 판단 기준은 네트워크·시스템 설계의 고전 종단 간 논증(End-to-End Argument)이다. 질문은 이렇다: 어떤 기능(예: 오류 검출·재전송)을 낮은 계층(네트워크)에 둘까, 종단(애플리케이션)에 둘까?
논증의 결론: 정확성을 완전히 책임질 수 있는 것은 종단뿐이므로, 그 기능은 종단에 두어야 한다. 중간 계층이 아무리 신뢰성 있게 데이터를 날라도, “내가 보낸 파일이 온전히 도착했는가”는 결국 양 끝의 애플리케이션이 검증해야만 보장된다. 낮은 계층에서 완벽을 추구하는 것은 대개 비용만 늘리고 정확성은 여전히 종단에서 다시 확인해야 한다.
이 원칙은 “기능을 아무 데나 두지 말고, 그것을 온전히 책임질 수 있는 곳에 두라”는 경계 배치의 판단 기준으로 일반화된다. 인터넷이 “멍청한 네트워크 + 똑똑한 종단” 구조로 설계된 사상적 뿌리이기도 하다.
Part I의 원리는 전부 옳지만, 그것만으로 무너지지 않는 시스템이 만들어지지는 않는다. 실제 시스템에는 지워지지 않는 데이터가 있고, 반드시 일어나는 장애가 있다. 이 3개 장은 원리를 현실의 물리 법칙과 충돌시킨다.
데이터라는 아키텍처
00장에서 아키텍처를 “나중에 바꾸기 비싼 결정들”이라고 정의했다. 그 정의를 진지하게 받아들이면 곧 불편한 결론에 도달한다. 가장 비싼 결정은 거의 항상 데이터에 관한 것이다.
이유는 비대칭에 있다. 코드는 틀렸으면 다시 쓰면 된다 — 어제 배포한 코드를 오늘 되돌릴 수 있다. 하지만 데이터는 축적된다. 스키마를 잘못 잡았다면 이미 쌓인 수억 건을 옮겨야 하고, 그 이행 중에도 서비스는 돌아가야 하며, 옮기는 동안 두 형태가 동시에 존재해야 한다. 프레임워크는 교체할 수 있지만 3년 치 데이터의 모양은 교체할 수 없다. 03장 끝에서 본 대로, 스키마는 구조적으로 고통의 구역(안정 + 구체)에 산다.
모듈 경계를 아무리 예쁘게 그어도, 데이터가 그 경계를 넘어 공유되고 있으면 경계는 존재하지 않는다. 진짜 경계는 코드 사이가 아니라 트랜잭션과 데이터 소유권 사이에 있다.
경계의 진짜 위치
트랜잭션 경계가 곧 모듈 경계다
“이 두 기능을 다른 모듈로 나눌 수 있는가”라는 질문은 대개 코드 의존성으로 판단된다. 하지만 더 날카로운 판별식이 있다. 둘이 하나의 트랜잭션 안에서 지켜야 하는 불변식(invariant)을 공유하는가?
“계좌 잔액은 절대 음수가 될 수 없다”는 규칙이 있다면, 잔액을 건드리는 모든 연산은 같은 트랜잭션 경계 안에 있어야 한다. 이 규칙을 두 서비스로 쪼개는 순간 — 잔액 확인은 A가, 차감은 B가 한다면 — 그 불변식은 더 이상 보장할 수 없다. 네트워크 지연 사이에 다른 요청이 끼어들 수 있기 때문이다. DDD가 이 단위를 애그리게이트(aggregate)라 부른다: 하나의 트랜잭션으로 일관성을 지켜야 하는 최소 덩어리.
그래서 실무의 순서는 흔한 통념과 반대다. 서비스를 먼저 나누고 데이터를 따라 나누는 게 아니라, 불변식으로 데이터 소유권을 먼저 확정하고 그 선을 따라 서비스를 나눈다. 03장의 바운디드 컨텍스트가 “말이 통하는 범위”였다면, 애그리게이트는 “동시에 참이어야 하는 범위”다.
// ✓ 불변식이 한 경계 안에 있다. 확인과 차감 사이에 await 가 없으므로
// 액터가 열리지 않고, 두 요청이 동시에 통과할 수 없다.
actor Account {
enum Failure: Error { case insufficientFunds(balance: Decimal, requested: Decimal) }
private var balance: Decimal
init(balance: Decimal) { self.balance = balance }
func withdraw(_ amount: Decimal) throws -> Decimal {
guard balance >= amount else {
throw Failure.insufficientFunds(balance: balance, requested: amount)
}
balance -= amount // ← guard 와 이 줄 사이에 중단점이 없다
return balance
}
}
// ✕ 확인과 차감을 서비스 경계로 갈라놓으면 불변식이 사라진다.
let current = try await accountService.balance(id) // ← 여기서 다른 요청이 끼어든다
if current >= amount {
try await accountService.debit(id, amount) // 두 요청이 모두 통과할 수 있다
}
// 잔액 100에서 80 인출 요청이 둘 동시에 오면 둘 다 통과해 -60 이 된다.
// "이 둘을 나눌 수 있는가"의 답은 코드 의존성이 아니라 이 불변식이 정한다.
서비스는 나눴는데 데이터베이스를 함께 쓰는 구조가 실무에서 가장 흔한 실패다. 겉보기엔 마이크로서비스인데, 실제로는 모두가 같은 테이블 스키마에 묶여 있다 — 커넥선스로 말하면 서비스 간 거리에서 “의미·타입 커넥선스”를 그대로 유지하는 것이다(FIG. 6). 한 팀이 컬럼 하나를 바꾸면 다른 팀이 새벽에 깨어난다. 독립 배포라는 편익은 0인데 분산의 비용은 전액 지불한다. 분산의 최소 조건은 코드 분리가 아니라 데이터 소유권 분리다.
Brewer · Abadi 2012
일관성의 스펙트럼 — CAP에서 PACELC로
데이터를 한 곳에 두면 위 규칙은 쉽다. 문제는 규모·가용성 때문에 데이터를 복제하는 순간 시작된다. 여기서 물리 법칙이 개입한다.
CAP 정리(Brewer)는 이렇게 말한다: 네트워크 분단(Partition)이 일어났을 때 — 그리고 분단은 반드시 일어난다 — 일관성(Consistency)과 가용성(Availability)을 동시에 지킬 수 없다. 노드들이 서로 대화할 수 없는데 쓰기 요청이 오면, 거절하고 일관성을 지키거나(CP) 받아주고 갈라짐을 감수하거나(AP) 둘 중 하나다.
그런데 CAP에는 실무자를 오래 괴롭힌 빈틈이 있었다. 분단이 없는 평상시에는 아무 말도 해주지 않는다. 하지만 시스템이 정상일 때도 우리는 매 요청마다 선택을 하고 있다 — 모든 복제본의 확인을 기다려 강한 일관성을 얻을 것인가(느림), 아니면 하나만 보고 바로 응답할 것인가(빠름, 대신 오래된 값 가능성). Daniel Abadi가 2010년 블로그에서 제기하고 2012년 논문으로 정식화한 PACELC가 이 빈틈을 메운다.
“복제 시스템의 일관성/지연 트레이드오프를 무시한 것은 [CAP의] 중대한 누락이다. 그 트레이드오프는 시스템이 운영되는 내내 존재하는데, CAP는 네트워크 분단이라는 — 따져보면 드문 — 경우에만 유효하다.”
— Daniel Abadi, “Consistency Tradeoffs in Modern Distributed Database System Design”, Computer 45(2), 2012
즉 PACELC는 두 개의 질문이다. 분단 시(P)엔 A냐 C냐. 그렇지 않을 때(E)엔 L(지연)이냐 C(일관성)냐. 이렇게 나누면 “우리 DB는 AP인가 CP인가” 같은 반쪽 질문이 아니라 네 개의 실제 좌표가 생긴다.
이 트레이드오프를 시스템 전체에 하나로 정하려 하는 것이 흔한 실수다. 같은 서비스 안에서도 요청마다 답이 다르다. 잔액 차감은 EC(기다려서 맞춘다)여야 하고, 게시물 좋아요 수는 EL(빨리 답하고 조금 틀려도 된다)이면 충분하다. 09장의 품질 속성 관점으로 말하면, 일관성은 시스템의 속성이 아니라 시나리오의 속성이다.
Garcia-Molina & Salem 1987
경계를 넘는 트랜잭션 — 사가와 보상
데이터 소유권을 서비스별로 갈라놓고 나면 곧 현실적인 문제가 온다. “주문 생성 → 재고 차감 → 결제 승인”처럼 여러 소유권을 걸치는 업무는 어떻게 하나? 하나의 ACID 트랜잭션으로 묶을 수는 없다. 서로 다른 데이터베이스이기 때문이다.
답은 놀랍게도 1987년에 나와 있었다. Hector Garcia-Molina와 Kenneth Salem의 SIGMOD 논문 “Sagas”가 그것이다. 원래 문제의식은 마이크로서비스가 아니라 오래 걸리는 트랜잭션(LLT)이 자원을 오래 잡아 짧은 트랜잭션들을 굶긴다는 것이었지만, 해법의 구조가 그대로 재활용된다.
사가의 정의는 이렇다: 긴 트랜잭션을 다른 트랜잭션과 끼어들 수 있는(interleave) 짧은 트랜잭션들의 시퀀스로 쓸 수 있다면 그것을 사가라 부른다. 그리고 시스템은 다음을 보장한다 — (a) 시퀀스의 모든 트랜잭션이 성공하거나, (b) 이미 성공한 것들의 효과를 취소하는 보상 트랜잭션(compensating transaction)이 실행된다.
이 마지막 문장이 사가의 진짜 비용이다. ACID 트랜잭션은 “실패하면 아무 일도 없었던 것처럼” 만들어주지만, 사가는 “실패했으니 되돌리는 일이 일어났다”는 흔적을 남긴다. 그래서 사가 도입은 기술 결정이 아니라 업무 결정이다 — 기획자와 “주문이 취소될 수 있음”을 합의해야 한다. 취소 통보를 감당할 수 없는 업무라면, 그 업무는 애초에 하나의 애그리게이트에 있어야 했다는 신호다.
struct OrderSaga {
let orders: OrderService
let inventory: InventoryService
let payments: PaymentService
func run(_ order: Order) async throws {
// 성공한 단계의 "되돌리기"만 쌓는다. 실패한 단계는 되돌릴 것이 없다.
var compensations: [() async -> Void] = []
do {
try await orders.create(order)
compensations.append { await orders.cancel(order.id) }
try await inventory.reserve(order.id)
compensations.append { await inventory.release(order.id) }
try await payments.charge(order.total) // ← 여기서 실패한다면
} catch {
for undo in compensations.reversed() { // 성공한 것만, 역순으로
await undo() // 보상은 실패해도 재시도되어야 한다
}
throw error
}
}
}
// 결정적 차이: compensations 는 DB 롤백이 아니라 새 트랜잭션들이다.
// inventory.release 는 재고를 되돌리지만, 이미 보낸 "주문 접수" 알림은 되돌릴 수 없다.
// 그래서 사가 도입은 기술 결정이 아니라 업무 결정이다.
Dual Write & Outbox
DB와 메시지를 동시에 쓸 수 없다는 문제
사가든 이벤트 기반이든, 서비스는 자기 데이터를 갱신하면서 그 사실을 밖에 알려야 한다. 순진한 코드는 이렇게 쓴다: 데이터베이스에 커밋하고, 그다음 메시지 브로커에 발행한다. 여기 조용한 버그가 있다.
두 저장소에 원자적으로 쓸 방법이 없다는 것 — 이것이 이중 쓰기(dual write) 문제다. 커밋 직후 프로세스가 죽으면 데이터는 바뀌었는데 아무도 모른다. 순서를 바꿔 발행을 먼저 하면, 발행 후 커밋이 실패해 일어나지 않은 일을 알린 셈이 된다. 어느 순서든 틀린다.
해법은 트랜잭셔널 아웃박스다. 발행할 메시지를 업무 데이터를 갱신하는 바로 그 트랜잭션 안에서 같은 데이터베이스의 outbox 테이블에 함께 기록한다. 이제 원자성이 확보된다 — 하나의 트랜잭션이니까. 그다음 별도의 릴레이 프로세스가 outbox를 읽어 브로커로 발행하고, 성공하면 그 레코드를 발행됨으로 표시하거나 삭제한다.
-- 하나의 트랜잭션: 업무 변경과 발행 의도를 함께 커밋
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 42;
INSERT INTO outbox (event_id, type, payload)
VALUES (gen_random_uuid(), 'OrderPaid', '{"orderId":42}');
COMMIT;
-- 별도 릴레이가 이후에: 읽고 → 발행하고 → 표시한다
그래서 아웃박스는 짝이 되는 규율을 요구한다. 멱등 컨슈머(idempotent consumer)다. 같은 이벤트를 여러 번 처리해도 결과 상태와 출력이 한 번 처리한 것과 같아야 한다. 구현은 단순하다 — 아웃박스가 부여한 event_id를 메시지에 실어 보내고, 컨슈머가 처리 전에 “이 id를 이미 봤는가”를 확인한다. 이 한 줄이 빠진 시스템은 재시도 한 번에 잔액을 두 번 차감한다.
// 액터는 "장부"만 맡는다. 부수효과는 액터 밖에서 — 액터를 붙잡지 않기 위해.
actor ProcessedEventLog {
private var seen: Set<UUID> = [] // 실무에서는 DB 유니크 인덱스가 이 역할을 한다
/// 처음 보는 이벤트면 true. 확인과 기록이 한 임계 영역에서 원자적으로 끝난다.
func claim(_ id: UUID) -> Bool { seen.insert(id).inserted }
func release(_ id: UUID) { seen.remove(id) }
}
func handle(_ event: OrderPaid, log: ProcessedEventLog) async throws {
guard await log.claim(event.id) else { return } // 두 번째 배달은 여기서 끝난다
do {
try await applyToLedger(event) // 실제 부수효과
} catch {
await log.release(event.id) // 실패했으면 재시도 가능하게 되돌린다
throw error
}
}
// 04장의 이 멱등성이 05장 재시도의 전제 조건이다.
// 멱등하지 않은 연산에 재시도를 붙이는 것이 실무 사고의 큰 지분을 차지한다.
메시지 시스템에서 진정한 exactly-once 전송은 불가능하다. 발행자는 “도착했다”는 응답을 못 받았을 때 재시도할지 포기할지 결정해야 하고, 어느 쪽이든 중복 또는 누락이 생긴다. 현실적 목표는 exactly-once 전송이 아니라 at-least-once 전송 + 멱등 처리 = exactly-once 효과다. “우리 브로커가 exactly-once를 지원한다”는 문구를 볼 때마다 그 범위(대개 특정 브로커 내부의 읽기–처리–쓰기 사이클)를 확인해야 한다.
Young · Fowler
읽기와 쓰기를 갈라야 할 때 — CQRS와 이벤트 소싱
마지막 도구는 강력하고, 그만큼 위험하다. CQRS(Command Query Responsibility Segregation, Greg Young)의 핵심은 한 문장이다: 정보를 갱신하는 데 쓰는 모델과 읽는 데 쓰는 모델을 다르게 둘 수 있다.
왜 그러고 싶을까. 쓰기와 읽기의 요구가 근본적으로 다른 경우가 있기 때문이다. 쓰기는 불변식을 지켜야 하니 정규화된 좁은 모델이 좋고, 읽기는 화면에 맞춰 여러 곳의 데이터를 이미 합쳐둔 넓은 모델이 좋다. 하나의 모델로 둘을 다 만족시키려면 어느 쪽도 만족하지 못한다 — 03장 바운디드 컨텍스트에서 본 “거대 통합 모델” 문제의 다른 얼굴이다.
이벤트 소싱(Event Sourcing)은 여기서 자연스럽게 이어진다. 현재 상태를 저장하는 대신 상태를 바꾼 사건들의 시퀀스를 저장하고, 현재 상태는 그것을 재생(replay)해서 얻는다. 잔액 100을 저장하는 게 아니라 “+50, +70, −20”을 저장한다. 얻는 것은 완전한 감사 로그와 “과거 어느 시점의 상태”를 재구성하는 능력이다.
| 얻는 것 | 대가로 지불하는 것 |
|---|---|
| 읽기·쓰기 각자 최적 모델 | 두 모델의 동기화 지연 — 쓰고 바로 읽으면 없을 수 있다(read-your-writes 문제) |
| 읽기 쪽 독립 확장 | 운영 컴포넌트 수·장애 지점 증가 |
| 완전한 감사 이력(이벤트 소싱) | 이벤트 스키마가 영구 계약이 된다 — 3년 전 이벤트도 지금 코드가 읽어야 함 |
| 시점 재구성 능력 | 스냅숏·이벤트 버저닝·개인정보 삭제 요구 대응 등 새 숙제 |
“어떤 상황에서는 이 분리가 가치 있지만, 대부분의 시스템에는 위험한 복잡도를 더한다.” CQRS는 복잡한 도메인 — DDD가 유효할 만큼 복잡한 종류 — 에 적합하다. 이 장의 다른 도구들(애그리게이트, 아웃박스, 멱등성)은 데이터를 나누기로 했다면 거의 항상 필요하지만, CQRS와 이벤트 소싱은 필요를 증명한 뒤에 도입하는 것이다. 08장에서 다룰 “얼마나 설계할 것인가”의 대표적 시험 문제가 바로 이것이다.
실패를 설계한다
지금까지의 모든 장은 암묵적으로 하나를 가정했다. 호출하면 답이 온다. 단일 프로세스 안에서는 이 가정이 거의 항상 참이다 — 함수를 부르면 돌아온다. 하지만 경계 위에 네트워크가 놓이는 순간 이 가정은 무너지고, 그때부터 아키텍처의 성패는 “잘 나눴는가”가 아니라 “부서질 때 어떻게 부서지는가”로 결정된다.
이 장의 개념들은 앞의 원리들과 성격이 다르다. 앞의 것들이 이해 가능성을 위한 도구였다면, 이것들은 생존을 위한 도구다. 그리고 대부분 하나의 태도에서 나온다 — 장애를 예외가 아니라 정상 상태로 취급하기.
Deutsch 1994 · Gosling 1997
분산 컴퓨팅의 8가지 오류 — 우리가 무심코 하는 거짓 가정
1994년 Sun의 L. Peter Deutsch가 7개의 가정을 정리했다. 분산 시스템을 만드는 사람들이 반드시 하게 되지만 결국 틀리는 가정들이다. 1997년 James Gosling이 여덟 번째를 추가한 것으로 알려져 있다. 이 목록의 가치는 우리가 그것을 “가정하고 있다는 사실조차 모른다”는 점을 폭로하는 데 있다 — 01장에서 본 복잡도의 세 번째 증상, “모르는 줄도 모름”의 교과서적 사례다.
| # | 틀린 가정 | 이 가정이 코드에 남기는 흔적 |
|---|---|---|
| 1 | 네트워크는 신뢰할 수 있다 | 재시도·타임아웃 없는 호출. 실패 경로가 아예 안 쓰여 있다. |
| 2 | 지연은 0이다 | 루프 안에서 원격 호출(N+1). 로컬에선 빠르고 운영에선 죽는다. |
| 3 | 대역폭은 무한하다 | 필요 없는 필드까지 전부 실어 보내는 응답. 페이지네이션 없음. |
| 4 | 네트워크는 안전하다 | 내부망이라 인증·암호화 생략. 한 곳 침해가 전체 침해가 된다. |
| 5 | 토폴로지는 바뀌지 않는다 | IP·호스트 하드코딩. 스케일 인/아웃 때 깨진다. |
| 6 | 관리자는 한 명이다 | “그 설정 누가 바꿨지?” 서로 다른 팀의 배포 일정이 충돌한다. |
| 7 | 전송 비용은 0이다 | 직렬화·역직렬화 CPU와 데이터 전송 요금이 예산에서 빠져 있다. |
| 8 | 네트워크는 균질하다 | 모든 노드가 같은 버전·같은 설정이라고 가정. 롤링 배포 중 사고. |
이 여덟 개를 정면으로 받아들이면, 원격 호출을 감싸는 코드가 로컬 호출과 같은 모양일 수 없다는 결론이 나온다. 아래 네 가지 패턴이 그 차이를 채운다. 대부분 Michael Nygard의 Release It!이 안정성 패턴(stability patterns)으로 정리한 것들이다.
AWS Builders' Library · Brooker
타임아웃 · 재시도 · 백오프 · 지터
타임아웃이 첫 번째다. 타임아웃 없는 호출은 “영원히 기다린다”는 뜻이고, 그 스레드는 영원히 반환되지 않는다. 스레드 풀이 바닥나면 느린 하나가 전체를 멈춘다. 그래서 타임아웃은 성능 튜닝이 아니라 격리 장치다.
struct TimeoutError: Error {}
/// 작업과 타이머를 경쟁시켜, 먼저 끝난 쪽을 택하고 진 쪽은 취소한다.
func withTimeout<T: Sendable>(
_ duration: Duration,
_ operation: @Sendable @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw TimeoutError()
}
guard let result = try await group.next() else { throw TimeoutError() }
group.cancelAll() // 남은 쪽을 반드시 취소한다 — 안 하면 그룹이 기다린다
return result
}
}
// 사용
let profile = try await withTimeout(.seconds(3)) {
try await api.fetchProfile(userID)
}
// 타임아웃 없는 호출은 "영원히 기다린다"는 뜻이고,
// 그 태스크는 영원히 반환되지 않는다. 느린 하나가 전체를 멈춘다.
타임아웃이 걸리면 재시도하고 싶다. 그런데 여기 함정이 있다. 장애의 원인이 “상대가 과부하”라면, 재시도는 과부하에 기름을 붓는다. 모든 클라이언트가 실패하고, 모두가 동시에 재시도하고, 그 파도가 이제 막 회복하려던 서버를 다시 죽인다. 이것이 재시도 폭풍(retry storm)이다.
해법은 두 단계다. 첫째 지수 백오프(exponential backoff) — 재시도 간격을 시도마다 지수적으로 늘린다. 하지만 Amazon Builders' Library가 지적하듯 지수함수는 빨리 커지므로 반드시 상한(cap)을 둬야 한다 — 그러지 않으면 10번째 재시도가 17분 뒤가 된다.
둘째가 결정적인데 흔히 빠뜨린다. 지터(jitter) — 대기 시간에 무작위성을 섞는 것이다. 백오프만 있으면 모든 클라이언트가 똑같은 곡선으로 똑같은 시점에 재시도하므로, 파도의 높이만 낮아지고 파도 자체는 그대로다. 지터가 그 군집을 흩는다.
enum Retry {
/// ⚠️ 멱등한 연산에만 쓴다 — 04장의 멱등성이 이 함수의 전제 조건이다.
static func run<T>(
maxAttempts: Int = 4,
base: Duration = .milliseconds(200),
cap: Duration = .seconds(8),
_ operation: () async throws -> T
) async throws -> T {
var attempt = 1
while true {
do {
return try await operation()
} catch {
try Task.checkCancellation() // 취소는 재시도 대상이 아니다
guard attempt < maxAttempts else { throw error }
// 지수 백오프 + 상한: base × 2^(n-1), 단 cap 을 넘지 않는다
let backoff = min(base * (1 << (attempt - 1)), cap)
// 풀 지터: 0…backoff 사이 균등 추출 → 클라이언트들이 시간축에 흩어진다
try await Task.sleep(for: backoff * Double.random(in: 0...1))
attempt += 1
}
}
}
}
// 사용 — 조회는 멱등하므로 안전하다
let items = try await Retry.run { try await api.fetchItems() }
// 상한이 없으면 10번째 재시도가 17분 뒤가 되고,
// 지터가 없으면 모든 클라이언트가 같은 곡선으로 같은 순간에 몰린다(FIG. 15 왼쪽).
재시도는 멱등한 연산에만 안전하다. “잔액 조회”는 몇 번 해도 되지만 “100원 이체”는 두 번 하면 200원이 나간다. 그래서 04장의 멱등성은 데이터 패턴이 아니라 재시도의 전제 조건이다. 멱등하지 않은 연산은 재시도하기 전에 멱등키를 도입해 멱등하게 만들어야 한다. 이 순서를 건너뛴 재시도 로직이 실무 사고의 큰 지분을 차지한다.
Nygard · Release It!
서킷 브레이커 — 실패를 빨리 인정하기
백오프와 지터는 “언제 다시 시도할까”를 다룬다. 그런데 상대가 완전히 죽어 있다면, 매 요청이 타임아웃까지 기다리는 것 자체가 낭비다. 스레드는 계속 묶이고, 사용자는 30초씩 기다리고, 결국 우리 서비스도 함께 죽는다 — 연쇄 장애(cascading failure)다.
서킷 브레이커는 전기 회로의 차단기에서 이름을 가져왔다. 원격 호출을 실패를 추적하는 객체로 감싼다. 실패가 임계치를 넘으면 차단기가 트립(trip)되고, 그 뒤로는 호출을 시도하지 않고 즉시 에러를 반환한다. 이것이 핵심 전환이다 — 느린 실패를 빠른 실패로 바꾼다. 빠른 실패는 스레드를 돌려주고, 사용자에게 즉시 대안을 줄 여지를 만든다.
actor CircuitBreaker {
struct Rejected: Error {}
private enum State {
case closed
case open(until: ContinuousClock.Instant)
case halfOpen
}
private var state: State = .closed
private var failures = 0
private let threshold: Int
private let resetAfter: Duration
private let clock = ContinuousClock()
init(threshold: Int = 5, resetAfter: Duration = .seconds(30)) {
self.threshold = threshold
self.resetAfter = resetAfter
}
/// 통과 여부만 판정한다. 메서드 안에 await 가 하나도 없다 — 재진입 틈이 없다.
func allow() -> Bool {
switch state {
case .closed, .halfOpen:
return true
case .open(let until):
guard clock.now >= until else { return false }
state = .halfOpen // 대기 시간 경과 → 한 건만 조심스럽게 흘려본다
return true
}
}
func recordSuccess() {
failures = 0
state = .closed
}
func recordFailure() {
failures += 1
if case .halfOpen = state {
state = .open(until: clock.now + resetAfter) // 떠본 게 실패 → 다시 닫는다
} else if failures >= threshold {
state = .open(until: clock.now + resetAfter) // 임계치 초과 → 트립
}
}
}
/// 보호 대상 호출은 액터 "밖"에서 한다.
func guarded<T>(
by breaker: CircuitBreaker,
_ work: () async throws -> T
) async throws -> T {
guard await breaker.allow() else { throw CircuitBreaker.Rejected() } // 빠른 실패
do {
let value = try await work()
await breaker.recordSuccess()
return value
} catch {
await breaker.recordFailure()
throw error
}
}
// 왜 work() 를 액터 메서드 안에서 await 하지 않는가:
// 액터는 await 하는 동안 열린다(재진입). 30초 걸리는 원격 호출을 액터 안에서
// 기다리면 그 시간 내내 다른 호출들이 낡은 state 를 보고 통과한다 —
// 차단기가 트립됐는데도 요청이 계속 나가는, 정확히 막으려던 상황이 된다.
// 그래서 액터는 장부만, 호출은 밖에서. 이것이 벌크헤드와 같은 원리다.
Nygard · 격리와 흐름 제어
벌크헤드와 배압 — 한 곳의 문제를 한 곳에 두기
벌크헤드(bulkhead)는 배의 격벽에서 온 이름이다. 선체를 여러 구획으로 나누면 한 구획에 물이 차도 배는 뜬다. 소프트웨어에서는 자원 풀을 용도별로 격리하는 것을 뜻한다. 스레드 풀·커넥션 풀을 하나로 공유하면, 느려진 하나의 하위 시스템이 모든 스레드를 삼켜 무관한 기능까지 죽인다. 용도별로 풀을 나눠 두면 피해가 그 구획에 갇힌다.
// ✕ 세마포어로 async 를 막지 않는다. 협조적 스레드 풀을 블로킹해 고갈시키고,
// 최악의 경우 교착에 빠진다. async 컨텍스트에서 lock·semaphore 는 금물이다.
// ✓ 태스크 그룹의 "슬라이딩 윈도" 로 동시 실행 수를 제한한다.
func mapConcurrently<Element: Sendable, T: Sendable>(
_ items: [Element],
limit: Int,
_ transform: @Sendable @escaping (Element) async throws -> T
) async throws -> [T] {
try await withThrowingTaskGroup(of: (Int, T).self) { group in
var results: [Int: T] = [:]
var next = 0
// 먼저 limit 개만 띄운다
while next < min(limit, items.count) {
let index = next
group.addTask {
let value = try await transform(items[index])
return (index, value)
}
next += 1
}
// 하나가 끝날 때마다 하나를 새로 넣는다 → 동시 실행 수는 항상 limit 이하
while let (index, value) = try await group.next() {
results[index] = value
if next < items.count {
let index = next
group.addTask {
let value = try await transform(items[index])
return (index, value)
}
next += 1
}
}
return items.indices.compactMap { results[$0] } // 입력 순서로 되돌린다
}
}
// 구획별로 다른 limit 을 주면 그것이 곧 벌크헤드다.
let thumbnails = try await mapConcurrently(urls, limit: 4) { try await load($0) }
벌크헤드가 공간을 나눈다면 배압(backpressure)은 속도를 조절한다. 생산자가 소비자보다 빠르면 대기열이 무한히 자라고, 결국 메모리가 터지거나 — 더 흔하게 — 큐에 몇 분씩 묶인 요청을 처리하느라 이미 포기한 사용자에게 답을 보내는 낭비가 생긴다. 배압은 “나 지금 감당 못 한다”를 상류로 되돌려 알리는 신호다. 큐에 상한을 두고, 넘치면 거절하고, 그 거절이 호출자의 속도를 늦추게 한다. 무한 큐는 배압의 부재이고, 배압의 부재는 지연된 붕괴다.
우아하게 지는 법
부분 실패를 부분 기능으로 바꾸기
위 패턴들은 모두 “실패를 빨리, 국소적으로 인정하기”였다. 그렇게 아낀 여유로 무엇을 할까. 우아한 성능 저하(graceful degradation) — 전부 죽는 대신 덜 좋은 상태로 계속 서비스하기다.
- 기능별 중요도를 미리 정한다. 상품 상세 페이지에서 가격·재고는 필수고, 추천 목록·리뷰 요약은 없어도 페이지가 성립한다. 이 등급이 없으면 장애 순간에 판단할 수 없다.
- 비필수 의존은 실패를 흡수한다. 추천 API가 죽으면 추천 영역을 비우고 나머지를 그린다. 서킷 브레이커의 “열림”이 곧 이 대체 경로의 신호다.
- 캐시된 낡은 값이 없는 값보다 낫다. 04장의 일관성 트레이드오프가 장애 시에 다시 등장한다 — 이 순간엔 거의 항상 EL(빨리, 조금 틀리게)이 정답이다.
이 준비는 코드가 아니라 의사결정을 미리 해두는 일이다. 장애 중에 “추천 없이 페이지를 띄워도 되나요?”를 물을 시간은 없다.
Google SRE
관측성과 에러 예산 — 신뢰성을 예산으로 다루기
마지막으로, 지금까지의 모든 장치는 지금 무슨 일이 벌어지는지 보이지 않으면 무의미하다. 관측성(observability)의 세 축을 구분해 두면 도구 선택이 쉬워진다. 메트릭은 “무언가 잘못됐다”를 싸게 알려주고(집계된 수치), 로그는 “그 순간 정확히 무슨 일이 있었나”를 알려주고(개별 사건), 분산 트레이스는 “요청이 어느 서비스에서 시간을 썼나”를 알려준다(경계를 넘는 인과). 마이크로서비스에서 트레이스가 없으면 지연의 범인을 찾는 데 며칠이 걸린다.
그리고 이 숫자들을 판단으로 바꾸는 장치가 Google SRE의 SLI/SLO/에러 예산이다.
에러 예산이 아키텍처 도구인 이유는 그것이 끝나지 않는 논쟁을 산수로 바꾸기 때문이다. “안정성이 중요하다” 대 “기능을 빨리 내야 한다”는 가치관 싸움이라 이길 수 없다. 하지만 예산은 잔액이 있다 — 남았으면 위험한 배포를 해도 되고, 다 썼으면 신규 기능을 멈추고 안정화에 쓴다. 신뢰성과 다른 엔지니어링 작업 사이의 균형을 정치가 아니라 회계로 다루는 것이다.
SLO는 04장과 이 장에서 정한 결정들을 계속 검증되는 형태로 못박는 방법이기도 하다. “p99 응답 300ms 이하”라는 SLO는 곧 07장에서 볼 적합도 함수(fitness function)의 한 사례다 — 아키텍처 특성이 문서가 아니라 실행되는 측정으로 존재하게 만드는 것.
스타일의 지형도
04·05장에서 데이터와 실패라는 물리 법칙을 봤다. 이제 그 법칙 위에서 어떤 큰 모양을 고를 것인가를 다룬다. 이 장의 목표는 스타일 목록을 외우는 게 아니다 — 각 스타일이 무엇을 제약해서 무엇을 얻고, 그 대가로 무엇을 내주는지를 같은 좌표계 위에 놓는 것이다. 그러면 “마이크로서비스가 좋은가”라는 질문이 왜 성립하지 않는지 보인다.
Fielding · REST의 뿌리
아키텍처 스타일 — 제약이 품질을 만든다
아키텍처 스타일(Architectural Style)이란 특정 품질을 얻기 위해 선택한 제약(constraint)의 집합이다. 놀라운 통찰은 방향이다: 자유를 늘리는 게 아니라 제약을 걸어서 원하는 특성을 얻는다.
Roy Fielding의 박사 논문이 정의한 REST가 교과서적 예다. Fielding은 “REST란 이런 API다”라고 시작하지 않았다. 대신 “확장성·독립적 진화·캐시 가능성” 같은 원하는 품질에서 출발해, 그것을 얻기 위한 제약(무상태, 통일된 인터페이스, 클라이언트-서버 분리, 캐시 가능성…)을 하나씩 도출했다. REST는 규격이 아니라 제약을 도출하는 방법론인 셈이다.
계층형(Layered)
관심사를 수직 계층으로. 이해·교체 쉬움 ↔ 계층 통과 비용.
파이프-필터
데이터가 필터를 통과. 조합·재사용 쉬움. 유닉스 파이프.
모놀리스
단순·빠른 개발 ↔ 규모가 커지면 결합·배포 부담.
모듈러 모놀리스
하나로 배포하되 내부 모듈 경계를 강제. 분산 비용 없이 경계를 얻는다.
서비스 기반
독립 배포되는 소수의 서비스, 대개 DB는 공유. 중간 지점.
마이크로서비스
독립 배포·팀 자율 ↔ 분산의 복잡성·운영 비용.
이벤트 기반
느슨한 결합·확장성 ↔ 흐름 추적이 어려움.
마이크로커널
작은 코어 + 플러그인. 기능 추가가 코어를 건드리지 않는다.
공간 기반
처리 유닛 + 복제 로컬 캐시로 DB 병목을 우회. 극단적 부하용.
REST
제약(무상태·통일 인터페이스)에서 확장성·진화성을 도출.
이 목록에서 가장 오해받는 축이 모놀리스와 마이크로서비스다. 둘은 반대말이 아니라 같은 축의 두 끝이며, 그 사이에 실무에서 훨씬 자주 정답이 되는 중간 지점들이 있다. Mark Richards와 Neal Ford의 Fundamentals of Software Architecture는 이 중간 지대에 이름을 붙여 지형을 채웠다.
// Package.swift — 모듈러 모놀리스의 경계는 문서가 아니라 빌드 그래프다.
// 규칙을 어기면 리뷰어가 아니라 컴파일러가 먼저 막는다.
let package = Package(
name: "Shop",
targets: [
// 도메인은 아무것도 의존하지 않는다 (03장 의존성 규칙)
.target(name: "OrderDomain", dependencies: []),
.target(name: "PaymentDomain", dependencies: []),
// 두 도메인은 서로를 import 할 수 없다.
// 협력이 필요하면 얇은 계약 모듈을 거친다 (03장 컨텍스트 맵)
.target(name: "OrderPaymentContract", dependencies: []),
// 세부는 안쪽을 의존한다. 반대 방향은 순환이 되어 빌드가 깨진다.
.target(name: "OrderInfra", dependencies: ["OrderDomain"]),
// 조립은 가장 바깥에서만
.target(name: "App", dependencies: [
"OrderDomain", "PaymentDomain", "OrderInfra", "OrderPaymentContract"
]),
// 도메인 테스트는 인프라 없이 돌아간다 — 경계가 살아 있다는 증거
.testTarget(name: "OrderDomainTests", dependencies: ["OrderDomain"]),
]
)
// 여기까지가 모듈러 모놀리스다. 배포는 하나, 경계는 진짜.
// 마이크로서비스로 가는 것은 이 그래프의 한 노드를 프로세스 밖으로 밀어내는 일이며,
// 그때 04·05장의 비용(네트워크·최종 일관성·실패 설계)이 청구된다.
이벤트 기반 스타일도 하나가 아니다. Richards·Ford는 두 토폴로지를 구분하는데, 이 구분이 실무에서 결정적이다. 미디에이터(mediator)는 모든 통신을 책임지는 단일 노드를 둔다 — 흐름이 한곳에 명시돼 있으니 보이고, 오류 처리와 보상(04장의 사가!)을 조율하기 쉽다. 대가는 그 노드가 병목이자 결합점이 된다는 것이다. 브로커(broker)는 모든 메시지를 관리하는 중앙 지점이 없다 — 각 컴포넌트가 이벤트를 듣고 자기 판단으로 반응한다. 결합은 최소지만 전체 흐름을 아는 사람이 아무도 없다.
나머지 두 스타일도 짧게 짚어둘 값어치가 있다. 마이크로커널은 작은 코어 시스템에 플러그인을 꽂는 구조로, Eclipse IDE·Jira·Jenkins가 대표적이다. 새 기능이 코어를 건드리지 않는다 — 02장의 정보 은닉을 확장 지점이라는 형태로 제도화한 것이다. 공간 기반은 처리 유닛들이 복제된 로컬 저장소(캐시)를 갖게 해 데이터베이스를 병목에서 빼내는 구조다. 극단적 동시 부하(티켓 오픈, 플래시 세일)를 위한 선택이고, 04장의 일관성 대가를 가장 크게 지불한다.
이제 이것들을 하나의 표로 겹쳐 보면, “좋은 스타일”이 없다는 말의 뜻이 구체적으로 보인다.
| 스타일 | 제약해서 얻는 것 | 대가 | 맞는 상황 |
|---|---|---|---|
| 모놀리스 | 단순성 · 트랜잭션 일관성 · 개발 속도 | 규모가 커지면 결합·배포 병목 | 초기 · 소규모 팀 · 도메인이 아직 유동적 |
| 모듈러 모놀리스 | 경계의 이점 + 분산 비용 0 | 경계 강제 규율이 필요(빌드 규칙·의존 검사) | 대부분의 팀의 기본값 |
| 서비스 기반 | 독립 배포 · 부분 확장 | DB 공유가 남긴 결합 | 배포 독립성만 급한데 데이터 분리는 아직 어려울 때 |
| 마이크로서비스 | 팀 자율 · 개별 확장 · 장애 격리 | 최종 일관성 · 운영·관측 부담 전액 | 팀이 여럿이고 조직 경계가 이미 명확할 때 |
| 이벤트 기반 | 느슨한 결합 · 확장성 · 반응성 | 흐름 추적 곤란 · 순서·중복 처리 숙제 | 파생 반응이 계속 늘어나는 도메인 |
| 마이크로커널 | 확장성(기능 추가가 코어 불변) | 플러그인 계약 설계가 어렵고 되돌리기 힘듦 | 제3자·다양한 변종을 수용해야 하는 제품 |
| 공간 기반 | 극단적 동시성 · DB 병목 제거 | 일관성·복잡도 대가가 가장 큼 | 예측 불가한 급격한 부하 폭증 |
이 표를 위에서 아래로 읽으며 고르는 것이 흔한 실수다. 올바른 순서는 거꾸로다 — 09장에서 볼 품질 속성 시나리오를 먼저 쓰고(“블랙프라이데이에 평시 20배 트래픽에서 p99 500ms”), 그 시나리오를 만족시키는 제약이 무엇인지 따진 뒤, 그 제약을 이미 품고 있는 스타일을 찾는다. Fielding이 REST를 도출한 방향이 정확히 이것이었다. 스타일에서 출발하면 “우리 문제”가 아니라 “그 스타일의 문제”를 풀게 된다.
원리를 알고 물리 법칙을 알아도 남는 질문들이 있다. 누가 만드는가(07), 얼마나 설계해야 충분한가(08), 결정을 어떻게 남기고 검증하는가(09), 이미 있는 시스템은 어떻게 옮기는가(10). 이 4개 장이 지식을 실천으로 바꾼다.
사람과 시간이라는 힘
지금까지는 코드 안의 이야기였다. 하지만 아키텍처는 진공에서 만들어지지 않는다. 그것을 만드는 사람(조직)과, 그것이 살아가는 시간이라는 두 힘이 구조를 끊임없이 밀고 당긴다. 이 힘을 모르면 “왜 우리 설계는 자꾸 이 모양이 되는가”를 영원히 이해하지 못한다.
Conway 1968 · Datamation
콘웨이의 법칙 — 조직이 구조를 복사한다
Melvin Conway의 관찰은 반세기가 지나도 무섭도록 정확하다: 시스템을 설계하는 조직은, 그 조직의 소통 구조를 그대로 닮은 시스템을 만든다. 세 팀이 만들면 세 덩어리짜리 시스템이 나온다. 팀 사이의 소통이 어려우면, 그 팀들이 만든 모듈 사이의 인터페이스도 어색하고 두꺼워진다.
실무적 함의는 강력하다. 원하는 시스템 구조가 있다면 먼저 조직을 그 모양으로 만들어라(inverse Conway maneuver). 마이크로서비스가 유행한 데는 “작고 자율적인 팀”이라는 조직 형태가 그런 시스템을 자연스럽게 낳기 때문이라는 배경이 있다.
Skelton & Pais 2019
팀 토폴로지 — 콘웨이의 법칙을 설계 도구로 바꾸기
콘웨이의 법칙은 관찰이다. “조직 구조가 시스템 구조로 복사된다”는 사실을 알려주지만, 그래서 조직을 어떻게 만들어야 하는지는 말해주지 않는다. 역 콘웨이 전략(“먼저 조직을 원하는 모양으로”)도 방향만 준다. Matthew Skelton과 Manuel Pais의 Team Topologies(2019)는 그 빈칸을 채운다 — 콘웨이의 법칙을 실제로 쓸 수 있는 어휘로 만든 것이다.
출발점이 되는 통찰은 아키텍처 책에서 좀처럼 보기 힘든 종류다. 팀의 인지 부하(cognitive load)가 아키텍처의 제약이다. 한 팀이 머릿속에 담을 수 있는 도메인의 크기에는 한계가 있고, 그 한계를 넘으면 어떤 훌륭한 설계도 유지되지 않는다. 01장에서 복잡도를 “이해하거나 수정하기 어렵게 만드는 모든 것”으로 정의했던 것을 기억하면 — 이해의 주체는 결국 사람이고, 사람은 팀 단위로 묶인다. 그래서 모듈의 적정 크기는 기술이 아니라 한 팀이 감당할 수 있는 양으로 결정된다.
이 원칙 위에 네 가지 팀 유형과 세 가지 상호작용 모드가 놓인다.
스트림 정렬 팀
가치 흐름(제품·기능 영역) 하나에 정렬된 팀. 기본형이며 대다수여야 한다. 나머지 세 유형은 이 팀의 부하를 덜어주려고 존재한다.
플랫폼 팀
내부 서비스를 제공해 스트림 팀의 인지 부하를 줄인다. 성공 지표는 “우리가 많이 쓰이는가”가 아니라 “저들이 우리를 몰라도 되는가”다.
활성화 팀
다른 팀이 부족한 역량을 획득하도록 돕는다. 대신 해주는 게 아니라 할 수 있게 만들고 떠난다 — 그래서 관여는 한시적이다.
복잡 하위시스템 팀
깊은 전문 지식이 필요한 부분(추천 엔진, 결제 정산, 코덱)을 소유한다. 예외적으로만 만든다 — 남용하면 조직이 기능 조직으로 되돌아간다.
그리고 팀들이 만나는 방식이 세 가지로 제한된다. 이 제한이 이 모델의 진짜 힘이다 — 06장에서 본 “스타일은 제약의 집합”과 정확히 같은 논리로, 상호작용 방식을 제한해서 흐름이라는 품질을 얻는다.
바운디드 컨텍스트(03)와 스트림 정렬 팀(07)은 같은 선이어야 한다. 컨텍스트 경계와 팀 경계가 어긋나면 — 한 컨텍스트를 두 팀이 나눠 갖거나, 한 팀이 다섯 컨텍스트를 담당하면 — 콘웨이의 법칙이 조용히 설계를 이긴다. 그래서 아키텍처 리뷰에서 반드시 물어야 하는 질문이 하나 늘어난다: “이 경계를 소유할 팀이 실제로 있는가?”
Brooks · The Mythical Man-Month
개념적 무결성 — 하나의 마음처럼
여러 사람이 만들면 필연적으로 생기는 문제: 시스템이 여러 개의 서로 다른 아이디어가 기워진 누더기가 된다. Brooks는 이에 맞서 개념적 무결성(Conceptual Integrity)을 최고의 설계 가치로 꼽는다.
“개념적 무결성은 시스템 설계에서 가장 중요한 고려사항이다. 서로 무관한 좋은 아이디어를 여럿 담기보다 — 설령 일부 기능을 덜어내더라도 — 하나의 설계 사상을 일관되게 반영한 시스템이 낫다.”
— Fred Brooks, The Mythical Man-Month (원문 의역)
즉 시스템은 마치 한 사람의 마음에서 나온 것처럼 일관된 개념 모델을 가져야 한다. 이를 위해 Brooks는 소수의 “아키텍트”가 개념적 일관성을 지키고, 나머지는 그 비전을 충실히 구현하는 역할 분담을 제안했다. 사용자가 시스템을 배우기 쉬운 이유의 8할은 기능의 많고 적음이 아니라 이 일관성이다.
Ford, Parsons & Kua
진화적 아키텍처 — 시간을 설계에 넣다
마지막 힘은 시간이다. 과거의 아키텍처는 “처음에 완벽히 설계하고 얼지 말라”는 것이었지만, 요구사항이 끊임없이 바뀌는 현실에서 이는 환상이다. 진화적 아키텍처(Evolutionary Architecture)는 관점을 뒤집는다: 변경을 예외가 아니라 상수(常數)로 받아들이고, 유도된 점진적 변경을 지원하도록 설계하라.
핵심 도구는 적합도 함수(fitness function)다. “이 아키텍처가 지켜야 할 특성”(예: 응답시간 100ms 이하, 서비스 간 순환 의존 0, 보안 규칙 준수)을 자동으로 측정 가능한 테스트로 만들어 CI에 걸어둔다. 그러면 시스템이 진화하면서도 중요한 특성을 잃지 않았는지 계속 검증된다. 아키텍처가 “문서”에서 “계속 실행되는 검증”으로 바뀌는 것이다.
얼마나 설계할 것인가
07개 장을 지나며 도구가 상당히 늘었다. 애그리게이트, 사가, 아웃박스, 서킷 브레이커, 벌크헤드, CQRS, 컨텍스트 맵, 컴포넌트 지표… 그리고 여기서 새로운 위험이 생긴다. 도구를 배운 사람은 그것을 쓰고 싶어진다.
02장에서 이미 경고를 봤다 — “잘못 그은 경계는 없느니만 못하다”, “조기 추상화도 같다”. 하지만 그건 경고였고, 이 장은 판단 기준이다. 아키텍처 문헌에서 가장 늦게 정리된 질문이 바로 이것이다: 얼마나 설계하면 충분한가? 너무 적으면 나중에 무너지고, 너무 많으면 지금 죽는다.
Fairbanks 2010 · Just Enough Software Architecture
리스크 주도 설계 — 노력을 리스크에 비례시키기
George Fairbanks의 Just Enough Software Architecture: A Risk-Driven Approach는 이 질문에 가장 실용적인 답을 준다. 핵심은 일률적인 프로세스를 거부하는 것이다 — 모든 프로젝트에 같은 양의 설계를 요구하는 방법론은 반드시 어느 쪽으로든 틀린다.
“리스크가 작을 때 꼼꼼한 설계는 필요하지 않고, 리스크가 성공을 위협할 때 엉성한 설계는 변명의 여지가 없다.”
— George Fairbanks, Just Enough Software Architecture (2010), 취지 요약
그래서 절차가 뒤집힌다. “설계 문서를 쓴다 → 검토한다 → 구현한다”가 아니라, 리스크를 먼저 나열하고 → 그 리스크를 줄이는 데 유효한 기법만 고르고 → 리스크가 충분히 줄었으면 멈추고 코드를 쓴다. 설계 활동의 목적이 “문서 산출”에서 “불확실성 감소”로 옮겨간다.
Poppendieck 2003 · Lean Software Development
마지막 책임 순간 — 언제까지 미룰 수 있나
리스크가 설계의 양을 정한다면, 다음 질문은 시점이다. Mary와 Tom Poppendieck의 Lean Software Development이 준 개념이 마지막 책임 순간(Last Responsible Moment)인데, 정의가 놀랍도록 정확하다.
마지막 책임 순간이란 — 결정을 내리지 않는 것이 중요한 대안을 없애버리는 순간이다.
이 정의가 좋은 이유는 “최대한 미뤄라”와 정반대의 오해를 막아주기 때문이다. 미루는 것 자체가 목적이 아니다. 대안이 사라지기 직전까지가 허용 범위이고, 그 순간을 넘기면 결정을 안 한 게 아니라 기본값으로 결정된 것이다. Poppendieck의 표현대로, 그때 결정은 “기본값에 의해(by default)” 내려지며 이는 좋은 의사결정 방식이 아니다. 코드베이스에 우연히 자리 잡은 구조가 곧 아키텍처가 되는 사고가 이렇게 일어난다.
그래서 실무 판단은 두 단계다. 첫째 이 결정의 마지막 책임 순간이 언제인지 알아낸다(무엇이 이 대안을 죽이는가?). 둘째 그 순간까지 정보를 모으고, 그 순간에는 반드시 내린다. 미루는 동안 해야 할 일도 명확하다 — 대안을 살려두는 비용이 얼마인지 확인하는 것. 대안을 열어두는 데 드는 비용이 결정을 늦춰 얻는 정보의 값어치보다 크면, 지금 결정하는 게 옳다.
되돌리기 비용의 비대칭
YAGNI가 적용되지 않는 결정들
YAGNI(You Aren't Gonna Need It)는 훌륭한 기본값이다. 쓰이지 않을 유연성을 미리 만드는 것은 대개 낭비다. 하지만 YAGNI를 모든 결정에 균일하게 적용하면 특정 종류의 재앙을 정확히 불러온다. 결정마다 되돌리기 비용이 근본적으로 다르기 때문이다.
| 결정 | 되돌리기 | 전략 |
|---|---|---|
| 라이브러리 선택 | 쉽다 — 어댑터 뒤에 두면 교체 가능 | YAGNI 적용. 지금 가장 단순한 것. |
| 내부 모듈 구조 | 보통 — 리팩터링으로 가능 | YAGNI 적용. 필요할 때 나눈다. |
| 동기 ↔ 비동기 통신 | 어렵다 — 호출자 전체의 오류 모델이 바뀐다 | 미리 판단. 05장의 실패 모델까지 함께 결정. |
| 데이터 소유권 · 스키마 | 매우 어렵다 — 축적된 데이터 이행 필요 | 가장 앞에서, 가장 신중히. 04장 전체가 이 이야기. |
| 공개 API · 이벤트 스키마 | 거의 불가 — 남의 코드가 의존한다 | 버저닝 전략을 처음부터 갖는다. |
| 서비스 경계(분산 여부) | 어렵다 — 합치는 것보다 나누는 게 쉬움 | 의심스러우면 합쳐 둔다(모듈러 모놀리스). |
마지막 줄이 06장의 스펙트럼과 이어지는 실천 규칙을 준다. 비대칭이 있을 때는 되돌리기 쉬운 쪽으로 기울여라. 모놀리스를 나누는 것은 어렵지만 가능하고(10장이 그 방법이다), 여러 서비스를 다시 합치는 것은 데이터가 흩어진 뒤라 훨씬 어렵다. 그래서 확신이 없을 때의 기본값은 덜 분산된 쪽이다 — 이것은 보수성이 아니라 옵션을 더 많이 남기는 선택이다.
Fowler · Technical Debt Quadrant
부채를 구분하기 — 모든 지저분함이 같지 않다
02장에서 기술 부채(Cunningham)를 봤다. 작은 지저분함이 이자까지 쳐서 갚아야 하는 빚이 된다는 은유다. 그런데 실무에서 이 은유는 종종 몽둥이로 쓰인다 — “저건 기술 부채야”가 모든 코드 비판의 만능 딱지가 된다. Martin Fowler의 기술 부채 4분면이 이 뭉툭함을 갈라준다. 두 축이다 — 의도적인가 무심한가, 그리고 분별있는가 무모한가.
추상적 원칙을 실행 가능하게 만드는 가장 단순한 장치는 시간 상한이다. “이 결정에 이틀을 쓴다. 이틀 뒤에는 가진 정보로 결정하고 09장의 ADR로 남긴다.” 이렇게 하면 두 가지가 동시에 해결된다 — 무한한 설계 회의(과설계)와 근거 없는 즉흥 결정(과소설계). 그리고 결정에 붙은 시간이 그 결정의 리스크 등급을 자연스럽게 드러낸다.
기술하고 평가하기
구조를 만들 줄 아는 것과, 그 구조를 설명하고 남과 비교·평가하고 지켜지는지 검증할 줄 아는 것은 다른 능력이다. 이 장은 그 공용어를 준다 — 무엇이 진짜 요구사항인지(품질 속성), 그것을 어떻게 측정 가능하게 쓰는지(시나리오), 구조를 어떻게 그리는지(C4·4+1), 결정을 어떻게 남기는지(ADR), 그리고 그 모든 것이 계속 지켜지는지 어떻게 자동 검증하는지(적합도 함수).
SEI · Software Architecture in Practice
품질 속성 — 아키텍처의 진짜 요구사항
흔한 오해: 아키텍처는 “기능”을 위해 존재한다. 아니다. 기능은 대개 어떤 구조로도 구현할 수 있다. 아키텍처를 진짜로 결정짓는 것은 품질 속성(Quality Attributes) — 즉 비기능 요구사항이다. 성능, 가용성, 수정 용이성, 보안, 확장성, 테스트 용이성… SEI(카네기멜런 소프트웨어 공학 연구소)의 정리에 따르면 아키텍처란 이 품질 속성들 사이의 트레이드오프를 내린 결정의 집합이다.
이 관점을 얻으면 설계 토론이 달라진다. “마이크로서비스가 좋아요, 모놀리스가 좋아요?” 같은 질문은 성립하지 않는다. 올바른 질문은 “우리 맥락에서 어떤 품질 속성이 최우선이고, 무엇을 포기할 수 있는가?”다. 아키텍처 결정은 언제나 무언가를 얻고 무언가를 내주는 거래다.
SEI · Quality Attribute Scenario
품질 속성 시나리오 — 요구를 측정 가능하게 쓰기
“성능이 중요합니다”는 요구사항이 아니다. 검증할 수 없고, 따라서 만족했는지 알 수 없고, 결국 회의에서 목소리 큰 사람이 이긴다. SEI는 품질 요구를 여섯 부분으로 이루어진 시나리오로 쓰라고 말한다. 이 형식이 하는 일은 단순하지만 강력하다 — 논쟁을 판정 가능한 명제로 바꾼다.
| 구성 요소 | 무엇을 적나 | 예 |
|---|---|---|
| 자극원 Source | 자극을 만드는 주체 | 모바일 앱 사용자 |
| 자극 Stimulus | 시스템에 도착하는 사건 | 상품 상세 조회 요청 |
| 환경 Environment | 그때 시스템의 상태 | 평시 20배 트래픽 · 추천 서비스 장애 중 |
| 대상물 Artifact | 자극을 받는 부분 | 상품 조회 API |
| 응답 Response | 일어나는 일 | 가격·재고를 포함한 페이지를 반환(추천 영역은 생략) |
| 응답 측정 Response measure | 정량 제약 | p99 500ms 이내 · 성공률 99.5% 이상 |
여섯 번째가 결정적이다. 응답 측정이 시나리오 충족 여부를 판정할 수 있는 정량 제약을 제공하기 때문이다. 이것이 없는 시나리오는 여전히 소망일 뿐이다. 그리고 위 예시를 다시 읽어 보면, 이 한 문장 안에 05장의 우아한 성능 저하와 06장의 스타일 선택 근거가 이미 다 들어 있다 — 좋은 시나리오는 설계를 강제한다.
SEI · Utility Tree & ATAM
무엇이 진짜 중요한지 합의하기
시나리오를 쓸 줄 알게 되면 곧 새 문제가 생긴다. 다 중요하다고 한다. 성능도, 보안도, 확장성도, 수정 용이성도 전부 “최우선”이 된다. SEI의 유틸리티 트리(utility tree)가 이 교착을 푸는 도구다. 품질 속성을 뿌리에서 잎(구체적 시나리오)까지 펼치고, 각 잎에 두 개의 등급을 매긴다 — 업무 중요도와 기술적 난이도.
Brown · Kruchten 1995
구조를 그리는 언어 — C4와 4+1 뷰
결정을 내렸다면 남에게 보여줘야 한다. 그런데 아키텍처 다이어그램은 대개 실패한다 — 상자와 선의 의미가 사람마다 다르고, 하나의 그림에 모든 것을 담으려다 아무것도 전하지 못한다. 두 개의 정전이 이 문제를 서로 다른 방식으로 해결한다.
C4 모델(Simon Brown, 2010년대 초)의 해법은 추상화 수준을 분리하는 것이다. UML 같은 무거운 표기법 대신, 네 개의 계층으로 줌인한다 — Context(시스템과 외부 세계) → Container(배포 단위: 앱·서비스·DB) → Component(컨테이너 내부 구성) → Code(클래스 수준). 핵심은 상위 레벨의 모든 요소를 “열면” 다음 레벨이 나온다는 명시적 관계다. 그래서 청중에 맞는 레벨만 보여줄 수 있다.
반면 Kruchten의 4+1 뷰(IEEE Software, 1995)는 관심사로 나눈다. 하나의 그림으로 모든 이해관계자를 만족시킬 수 없다는 전제에서, 다섯 개의 동시적 뷰를 둔다. 30년 전 논문이지만 “왜 우리 다이어그램은 항상 누군가에게 부족한가”라는 질문의 답이 여기 있다.
| 뷰 | 무엇을 보여주나 | 누가 필요로 하나 |
|---|---|---|
| 논리 Logical | 기능을 담는 구조 — 객체·도메인 모델 | 최종 사용자 관점의 기능 담당 |
| 프로세스 Process | 동시성과 동기화 — 스레드·프로세스·통신 | 성능·확장성 담당(05장의 영역) |
| 개발 Development | 코드의 조직 — 모듈·패키지·빌드 단위 | 개발자·빌드 담당(03장의 컴포넌트 지표) |
| 물리 Physical | 하드웨어 배치 — 노드·네트워크 매핑 | 인프라·운영 |
| +1 시나리오 | 유스케이스로 나머지 넷을 예시하고 검증 | 전원 — 뷰들이 서로 맞는지 확인하는 접착제 |
“+1”의 역할이 특히 중요하다. 네 개의 뷰는 서로 어긋날 수 있고, 실제로 자주 어긋난다. 시나리오는 그것들을 하나의 이야기로 관통시켜 모순을 드러내는 시험지다 — 위 유틸리티 트리의 시나리오가 여기서 다시 쓰인다.
Nygard 2011
ADR — 결정이 아니라 결정의 이유를 남기기
다이어그램은 무엇을 만들었는가를 보여준다. 하지만 6개월 뒤 새로 온 사람이 진짜로 묻는 것은 다른 질문이다: “왜 이렇게 했나요?” 이 질문에 답할 수 없으면 두 가지 중 하나가 일어난다 — 아무도 손대지 못해 굳어버리거나, 이유를 모르니 아무렇게나 뒤집어 같은 실수를 반복하거나.
Michael Nygard가 2011년 11월에 쓴 짧은 글이 그 답을 줬고, 형식이 지금까지 거의 바뀌지 않았다. ADR(Architecture Decision Record) — 결정 하나당 짧은 텍스트 파일 하나. 코드 저장소 안에, 코드와 함께 버전 관리된다.
- Title
- 주문 이벤트 발행에 트랜잭셔널 아웃박스를 사용한다
- Status
- Accepted (2026-03-11) — 0004를 대체함
- Context
- 주문 상태 변경을 재고·정산 서비스에 알려야 한다. 현재는 커밋 후 브로커에 직접 발행하는데, 배포 중 프로세스 종료로 이벤트 유실이 월 2~3건 발생하고 있다(이중 쓰기 문제).
- Decision
- 업무 트랜잭션 안에서 outbox 테이블에 함께 기록하고, 별도 릴레이가 발행한다. 컨슈머는 event_id 기반 멱등 처리를 필수로 한다.
- Consequences
- 유실은 사라지지만 중복 발행이 가능해진다(at-least-once). 모든 컨슈머에 멱등 처리 구현이 필요하고, 릴레이라는 운영 컴포넌트가 하나 늘어난다. 발행 지연이 폴링 주기만큼 생긴다(현재 설정 1초).
이 형식이 성공한 이유는 Consequences 칸에 있다. 대부분의 설계 문서는 좋은 점만 적지만, ADR은 대가를 함께 적도록 강제한다. 그래서 나중에 “이 결정을 뒤집어야 하나”를 판단할 때 필요한 정보가 이미 거기 있다. 그리고 결정을 취소하지 않고 새 ADR로 대체(supersede)한다는 규칙 덕분에, 저장소가 자연스럽게 의사결정의 역사가 된다. Nygard의 글이 나온 7년 뒤 Thoughtworks Technology Radar가 ADR을 Adopt로 올렸다.
모든 결정에 쓰면 아무도 안 읽는다. 기준은 08장에서 이미 나왔다 — 되돌리기 어려운 결정에만 쓴다(데이터 소유권, 동기/비동기, 공개 계약, 서비스 경계). 그리고 08장의 “설계 시간 상한”과 짝을 이룬다: 시간이 다 되면 결정하고, 그 결정을 ADR로 남긴다.
Ford, Parsons & Kua · 실행되는 검증
적합도 함수를 실제 코드로 쓰기
07장에서 적합도 함수를 개념으로 봤다 — 아키텍처가 지켜야 할 특성을 자동 측정 가능한 테스트로 만들어 CI에 걸어두는 것. 이 장의 마지막 조각은 그것을 구체화하는 일이다. 이 자료에서 다룬 원칙들은 거의 전부 실행 가능한 검증으로 바꿀 수 있다.
// 03장 의존성 규칙 — 도메인은 인프라를 몰라야 한다
@ArchTest
static final ArchRule domain_is_independent =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infra..");
// 03장 ADP — 컴포넌트 사이에 순환 의존이 없어야 한다
@ArchTest
static final ArchRule no_component_cycles =
slices().matching("..acme.(*)..").should().beFreeOfCycles();
Swift 진영에는 ArchUnit 같은 표준 도구가 없지만, 사실 그 절반은 이미 빌드 시스템이 해준다 — 06장의 Package.swift 타깃 그래프가 의존 방향과 순환을 컴파일 시점에 강제하기 때문이다. 그래서 Swift에서 적합도 함수로 쓸 값어치가 있는 것은 타깃 경계가 표현하지 못하는 규칙이다.
import Testing
import Foundation
// 타깃 경계로는 표현할 수 없는 규칙을 테스트로 못박는다.
// 예: 도메인 타입이 직렬화 형식에 묶이지 않는다 (02장 관심사의 분리)
@Test func 도메인_타입은_직렬화_관심사를_품지_않는다() throws {
for file in try SwiftSources.in("Sources/OrderDomain") {
#expect(!file.text.contains(": Codable"),
"\(file.name): 도메인 타입이 와이어 포맷에 묶였다 — DTO 로 분리하라")
}
}
// 예: 모든 원격 호출에 타임아웃이 걸려 있다 (05장)
@Test func 모든_원격_호출에_타임아웃이_있다() throws {
for file in try SwiftSources.in("Sources/OrderInfra") {
let calls = file.matches(of: /await\s+session\.data\(/)
#expect(calls.isEmpty || file.text.contains("withTimeout"),
"\(file.name): 타임아웃 없는 URLSession 호출")
}
}
// SwiftSources 는 소스 트리를 읽는 20줄짜리 테스트 헬퍼다.
// 요점은 도구가 아니라, 아키텍처 규칙이 위키가 아니라 CI 에 살아 있다는 것이다.
다른 원칙들도 같은 방식으로 못박을 수 있다. 04장의 데이터 소유권은 “다른 서비스의 테이블을 참조하는 쿼리가 없다”는 검사로, 05장의 실패 설계는 “모든 외부 호출에 타임아웃 설정이 있다”는 검사로, 이 장의 품질 시나리오는 부하 테스트의 p99 임계값으로. 여기서 아키텍처의 성질이 근본적으로 바뀐다.
아키텍처는 문서에서 실행되는 검증으로 이동한다. 위키의 다이어그램은 조용히 낡지만, CI에 걸린 적합도 함수는 어긋나는 순간 빨간불이 켜진다. 이 장의 도구들을 순서대로 놓으면 하나의 파이프라인이 된다 — 품질 속성 시나리오로 요구를 쓰고 → 유틸리티 트리로 우선순위를 합의하고 → C4·4+1로 구조를 그리고 → ADR로 이유를 남기고 → 적합도 함수로 지켜지는지 계속 확인한다.
레거시를 옮기는 법
지금까지의 아홉 장은 조용히 하나를 가정했다. 우리가 구조를 고를 수 있다는 것. 그런데 실무에서 백지에서 시작하는 경우는 드물다. 대부분의 아키텍처 작업은 이미 돌아가고 있고, 매출을 만들고 있고, 아무도 전부를 이해하지 못하는 시스템 안에서 시작한다.
이 장은 그래서 필요하다. 앞의 원리들은 “어떻게 만들 것인가”였고, 이 장은 “어떻게 여기서 저기로 갈 것인가”다. 그리고 이행에는 자기만의 원칙이 있다 — 가장 중요한 것은 돌아가는 시스템을 절대 멈추지 않는다는 제약이다.
순서의 문제
무엇을 먼저 떼어낼까 — 변경 빈도 × 결합도
“모놀리스를 나누자”는 결정보다 훨씬 중요한 것이 순서다. 잘못된 순서는 몇 달을 쓰고도 아무 이득이 없는 상태를 만든다. 판단 기준은 두 개면 충분하다.
변경 빈도 — 이 부분을 얼마나 자주 고치는가. 이것이 분리해서 얻는 이득을 결정한다. 자주 바뀌는 부분을 떼어내면 독립 배포의 가치가 즉시 실현된다. 반대로 3년간 안 바뀐 코드를 떼어내도 이득은 0이다. 좋은 점은 이 값을 추측하지 않아도 된다는 것이다 — 버전 관리 이력에 이미 다 있다.
결합도 — 이 부분이 나머지와 얼마나 엉켜 있는가(02장의 커넥선스와 04장의 데이터 소유권으로 측정). 이것이 분리하는 비용을 결정한다.
| 결합도 낮음 (비용 낮음) | 결합도 높음 (비용 높음) | |
|---|---|---|
| 변경 빈도 높음 이득 큼 |
① 최우선으로 분리 이득이 크고 비용이 낮다. 여기서 시작해 첫 성공 사례를 만든다. |
② 준비 후 분리 먼저 결합을 낮춘다 — ACL 삽입, 이벤트로 동기 호출 대체, 데이터 소유권 정리. 그다음 ①로 내려온다. |
| 변경 빈도 낮음 이득 작음 |
③ 나중에 / 안 해도 됨 쉽다는 이유로 여기서 시작하는 실수가 흔하다. 쉽지만 아무것도 얻지 못한다. |
④ 건드리지 않는다 비용은 최대, 이득은 0. 안정적으로 돌아간다면 그대로 두는 것이 옳은 아키텍처 결정이다. |
③ 사분면에서 시작하는 팀이 정말 많다. 결합이 낮아 떼어내기 쉬우니 “첫 마이크로서비스”로 좋아 보인다. 그렇게 6개월 뒤, 서비스는 늘었는데 배포 속도는 그대로다 — 자주 바뀌는 부분은 여전히 모놀리스 안에 있으니까. 이행의 성패는 얼마나 많이 쪼갰는가가 아니라 병목이 실제로 풀렸는가로 측정해야 한다.
Fowler · StranglerFigApplication
스트랭글러 무화과 — 감싸고, 빼내고, 걷어내기
순서를 정했다면 방법이 필요하다. Martin Fowler가 이름 붙인 스트랭글러 무화과(strangler fig)는 호주에서 본 식물에서 착안한 것이다. 이 무화과는 숙주 나무의 가지에서 싹이 나 아래로 뿌리를 내리고, 숙주를 감싸며 자라다가 마침내 그 자리를 차지한다. 숙주를 한 번에 베어내는 일은 없다.
소프트웨어 버전은 이렇게 작동한다. 신규 시스템이 레거시 앞에 서서 요청을 받고, 행위를 조금씩 레거시에서 빼내 온다. 매 단계마다 시스템은 온전히 동작하며, 어느 시점에도 “빅뱅 전환”이 없다.
Fowler · BranchByAbstraction
브랜치 바이 앱스트랙션 — 내부 교체를 위한 스트랭글러
스트랭글러는 시스템 앞에 라우터를 둘 수 있을 때 쓴다. 그런데 교체 대상이 시스템 내부 깊은 곳이면 어떻게 할까 — 예를 들어 온 코드가 직접 호출하는 데이터 접근 계층이나 ORM을 바꿔야 한다면? 이때 쓰는 것이 브랜치 바이 앱스트랙션이다. 이름이 말하듯, 버전 관리의 브랜치 대신 코드 안의 추상화로 분기를 만든다.
검증과 데이터
병행 실행 — “같은 답을 내는가”를 증명하기
이행에서 가장 무서운 순간은 “새 구현이 정말 같게 동작하는가”를 모를 때다. 테스트로는 부족하다 — 레거시의 진짜 행위에는 문서화되지 않은 버그와 그 버그에 의존하는 사용자가 포함되어 있다. 여기 쓰는 기법이 병행 실행(parallel run)이다.
- 둘 다 실행하고, 하나만 반영한다. 실제 트래픽을 신·구 양쪽에 흘려보내되, 사용자에게 돌려주는 것은 기존 구현의 결과다. 새 구현의 결과는 기록만 한다.
- 차이를 모은다. 두 결과가 다른 케이스를 로그로 쌓으면, 그것이 곧 진짜 명세와 우리 이해의 차이 목록이다. 대부분의 차이는 새 구현의 버그지만, 일부는 레거시의 버그다 — 그 구분이 업무 판단을 요구한다.
- 차이가 0에 수렴하면 전환한다. 이때 전환은 이미 증거에 기반한 결정이다.
- 비용을 계산한다. 병행 실행은 자원을 두 배 쓰고, 쓰기 연산에는 그대로 적용할 수 없다(양쪽이 다 쓰면 안 되므로). 조회·계산 로직에 가장 잘 맞는다.
데이터 이행도 같은 원리를 따른다. 04장에서 본 대로 데이터는 되돌리기 가장 어려운 자산이므로, 이행 중에는 두 형태가 동시에 존재하는 기간을 명시적으로 설계해야 한다 — 신규 쓰기를 양쪽에 반영하고(또는 변경 데이터 캡처로 복제하고), 읽기를 점진적으로 옮기고, 마지막에 구 스키마를 제거한다. 여기서도 각 단계가 독립적으로 되돌릴 수 있어야 한다.
Brooks · 두 번째 시스템 효과
하지 말아야 하는 것 — 빅뱅 재작성
이 장의 모든 기법은 하나의 유혹에 대한 대안이다. “처음부터 다시 만들자.” 이 유혹은 강력하다 — 레거시의 모든 결함이 눈에 보이고, 새 구조는 머릿속에서 아름답고, 점진적 이행은 지루하니까.
그런데 Brooks가 The Mythical Man-Month에서 반세기 전에 경고한 함정이 여기 있다. 두 번째 시스템 효과(second-system effect) — 첫 시스템에서 참았던 모든 아이디어를 두 번째 시스템에 쏟아붓게 되고, 그 결과 과설계된 시스템이 태어난다는 것이다. 04장의 관점을 더하면 위험이 하나 더 보인다: 재작성은 코드만 다시 쓰는 게 아니라 축적된 데이터와 문서화되지 않은 업무 규칙 전부를 다시 발견해야 한다. 그리고 그 작업이 진행되는 동안 레거시는 계속 변한다 — 목표가 움직인다.
이행의 목표는 “새 시스템”이 아니라 “돌아가는 시스템의 연속”이다. 그래서 좋은 이행 계획은 언제나 이 성질을 갖는다 — 모든 중간 상태가 출시 가능하고, 모든 단계가 되돌릴 수 있고, 각 단계가 그 자체로 가치를 낸다. 이 세 조건을 만족하지 못하는 계획은 계획이 아니라 도박이다.
하나의 지도로 묶기
지금까지 48개 개념을 지나왔다. 이제 처음의 명제로 돌아가 전체를 하나의 지도로 접는다. 20% 판이 다섯 갈래였다면 이제 여덟 갈래다 — 그리고 여덟 갈래 모두가 여전히 “복잡도를 모듈 경계로 다스린다”는 같은 뿌리에서 자란다.
① 적: 복잡도의 본질
무엇과 싸우는가. 줄일 수 있는 것과 없는 것.
② 모듈 경계의 원리
한 모듈을 어떻게 잘 만드는가.
③ 경계를 어디에 긋나
선을 어디에, 어느 방향으로, 어떤 관계로.
④ 데이터와 일관성
가장 비싼 결정. 진짜 경계가 사는 곳.
⑤ 실패와 운영
장애를 정상 상태로 취급하기.
⑥ 스타일 선택
제약을 골라 품질을 산다.
⑦ 조직과 시간
코드 바깥에서 구조를 미는 힘.
⑧ 판단 · 기술 · 이행
얼마나, 어떻게 남기고, 어떻게 옮기나.
이 지도의 아름다움은 여전히 화살표들이 서로를 가리킨다는 데 있다. 그리고 40% 판에서는 그 화살표가 훨씬 촘촘해졌다. 정보 은닉(②)을 잘하면 낮은 결합·높은 응집이 따라오고, 그 결합의 강도는 거리에 맞춰야 한다(②의 커넥선스). 경계를 컨텍스트(③)로 긋고 나면 진짜 시험은 데이터에서 온다(④) — 트랜잭션 경계를 공유하는 것들은 애초에 나눌 수 없었기 때문이다. 나누기로 했다면 네트워크가 개입하고, 그 순간 실패 설계(⑤)가 선택이 아니라 필수가 된다. 어떤 큰 모양을 고를지(⑥)는 그 비용을 감당할 수 있는지에 달려 있고, 그 답은 다시 조직(⑦)이 정한다 — 경계를 소유할 팀이 없으면 경계는 유지되지 않으니까. 그리고 이 모든 결정에 대해 얼마나 고민할지, 어떻게 남길지, 이미 있는 시스템을 어떻게 옮길지가 ⑧이다.
좋은 아키텍처란, 바뀔 것을 경계 뒤에 숨겨 복잡도를 국소화하고, 그 경계를 데이터 소유권 · 실패 모드 · 조직 · 시간 · 품질 요구에 맞게 배치한 뒤, 그 배치가 계속 지켜지는지 자동으로 검증하는 구조다. 나머지는 전부 이 문장의 각주다.
이 자료의 20% 판은 ①②③⑦⑥의 일부, 즉 정적 구조와 조직까지였다. 40% 판이 추가한 것은 실제로 돌아가는 시스템의 절반 — 데이터(④), 실패(⑤), 그리고 판단·기술·이행(⑧)이다. 이 순서에는 이유가 있다. ①~③ 없이 ④⑤를 배우면 패턴 카탈로그를 외우게 되고, ④⑤ 없이 ①~③만 알면 도면은 아름답지만 운영에서 무너진다.
더 깊이 — 읽기 경로
이 자료는 지형을 훑는 지도였다. 이제 실제 땅을 밟을 차례다. 정전은 수십 편이지만 순서가 있다. 먼저 딱 세 개를 권한다 — 나머지 개념 대부분의 뿌리이기 때문이다.
모든 모듈 이론의 원전. “처리 순서가 아니라 바뀔 결정으로 나누라.” 짧아서 실제로 완독할 수 있고, 읽고 나면 세상의 코드가 다르게 보인다. — 02장 정보 은닉의 출처.
Parnas의 현대적 계승. 깊은 모듈, 복잡도의 3증상, 전술 vs 전략 프로그래밍을 실전 감각으로 풀어낸다. 이 분야에서 가장 실용적인 한 권. — 01·02장의 뼈대.
“복잡도를 줄여라”를 “상태와 제어를 줄여라”로 번역한 현대 고전. 설계 토론에서 가장 많이 인용된다. Brooks의 No Silver Bullet과 함께 읽으면 완성된다. — 01장의 핵심.
이 셋은 Part I(원리)의 뿌리다. 40% 판이 추가한 Part II·III에는 각각의 원전이 따로 있고, 아래 세 권이 그 자리를 정확히 채운다. 앞의 셋을 소화한 뒤 읽으면 좋다.
05장의 원전. 서킷 브레이커·벌크헤드·타임아웃을 “안정성 패턴”으로 정리한 책이며, 실제 장애 사례에서 출발하기 때문에 읽는 동안 우리 시스템의 취약점이 계속 떠오른다. 운영을 하는 사람에게는 이 목록 중 가장 즉시 유용한 한 권. — 05장 전체의 뼈대.
06·09장의 지도. 스타일들을 같은 틀(품질 프로필)로 비교해주고, 아키텍처 특성을 어떻게 도출·측정하는지 다룬다. “어떤 스타일을 골라야 하나”라는 질문을 가진 사람에게 가장 효율적인 참조서. — 06장의 스타일 지형도, 09장의 평가 언어.
07장의 확장. 콘웨이의 법칙을 “팀 유형 4 + 상호작용 모드 3 + 인지 부하”라는 쓸 수 있는 도구로 바꾼다. 아키텍처 결정 권한이 조직 설계와 얽혀 있다는 것을 알고 있는 사람에게는 이 책이 가장 큰 지렛대다. — 07장의 팀 토폴로지.
그 뒤로는 관심 갈래를 따라 넓히면 된다. 아래 표는 이 자료의 각 장이 어느 원전으로 이어지는지의 지도다.
| 더 알고 싶은 것 | 이 자료의 장 | 다음에 읽을 것 |
|---|---|---|
| 모듈을 잘 만드는 법 | 01 · 02 | Parnas(1972) → Ousterhout APOSD → Page-Jones 연결성(커넥선스) |
| 경계를 어디에 그을지 | 03 | Evans Domain-Driven Design → Martin Clean Architecture(Part IV 컴포넌트 원칙) |
| 데이터와 분산 일관성 | 04 | Kleppmann Designing Data-Intensive Applications → Richardson Microservices Patterns |
| 장애를 견디는 시스템 | 05 | Nygard Release It! → Google SRE Book · SRE Workbook |
| 스타일 선택과 평가 | 06 · 09 | Richards & Ford Fundamentals → Bass·Clements·Kazman Software Architecture in Practice |
| 조직과 아키텍처 | 07 | Skelton & Pais Team Topologies → Ford·Parsons·Kua Building Evolutionary Architectures |
| 얼마나 설계할지 | 08 | Fairbanks Just Enough Software Architecture → Poppendieck Lean Software Development |
| 레거시 이행 | 10 | Feathers Working Effectively with Legacy Code → Newman Monolith to Microservices |
새 자료를 읽을 때마다 흩어진 메모로 남기지 말고, 개념 단위로 누적하자. 각 원전이 이 자료의 어느 개념에 속하는지 이미 지도(11장)가 있으니, 새로 읽은 사실을 그 개념 위에 덧붙이면 지식이 복리로 쌓인다.
이 자료가 여전히 다루지 않은 것들을 알아두는 편이 낫다. 보안 아키텍처(위협 모델링·제로 트러스트), 비용 아키텍처(FinOps — 아키텍처 결정의 청구서), 성능 공학(큐잉 이론·Little의 법칙·부하 모델링), 플랫폼 엔지니어링(내부 개발자 플랫폼), 데이터 플랫폼(스트리밍·레이크하우스·데이터 메시), 그리고 정형 기법(TLA⁺로 분산 프로토콜 검증). 이들은 각각 독립된 지형이고, 여기서 얻은 “무엇을 제약해서 무엇을 얻는가”라는 사고 습관이 그 지형들에도 그대로 통한다.
출처 · 참고자료
본문에서 인용한 1차 자료와 공식 문서. 20% 판에서 이름만 표기했던 출처들도 확인 가능한 링크로 정리했다.