결합·응집, 순수성, 의존 규칙, 공유 가변 상태, 추상화 — 이 다섯 개념은 서로 다른 이름을 달고 있지만 결국 한 가지 질문에 답한다. "내일 요구사항이 바뀌었을 때, 코드의 몇 군데를 어떤 확신으로 고쳐야 하는가." 아키텍처는 미학이 아니라 변경 비용을 지배하는 공학이다.
Q1. 결합도와 응집도는 무엇이며 코드 변경 비용에 어떤 영향을 주는가?
결합도(coupling)는 모듈 사이 의존의 강도이고, 응집도(cohesion)는 한 모듈 안 요소들이 하나의 책임에 얼마나 모여 있는지다. 좋은 설계의 방향은 "낮은 결합, 높은 응집". 변경 비용은 대체로 결합이 지배한다 — 한 곳을 고칠 때 같이 건드려야 하는 코드의 수(파급 범위)가 결합이 높을수록 폭발적으로 늘기 때문이다. 응집이 높으면 "무엇을 바꿀지"가 한 곳에 모여 변경 지점을 찾기 쉽고, 낮으면 하나의 변경이 여러 모듈로 흩어진다. 핵심은 결합을 0으로 만드는 게 아니라, 약한 형태(data coupling)로 낮추고 안정적인 경계에 집중시키는 것이다.
CS 원리
두 용어는 Stevens·Myers·Constantine의 "Structured Design"(IBM Systems Journal, 1974)에서 나왔다. 결합에는 강도 순서가 있다. 나쁜 쪽부터: content(다른 모듈 내부를 직접 조작) → common(전역 가변 상태 공유) → external → control(동작을 바꾸는 플래그 전달) → stamp(필요 없는 것까지 담긴 구조체 통째 전달) → data(꼭 필요한 값만 인자로). 응집도 마찬가지로 coincidental(우연히 한데 묶임)에서 functional(모든 요소가 하나의 임무에 기여)까지 스펙트럼이 있다.
변경 비용을 결합이 지배하는 이유는 파급(ripple) 때문이다. Connascence(Meilir Page-Jones)는 이걸 더 정밀하게 본다 — 두 요소가 한쪽을 바꾸면 정합성을 위해 다른 쪽도 바꿔야 할 때 "연결(connascent)"돼 있다고 하고, 그 강도(strength)·정도(degree)·거리(locality)로 비용을 가늠한다. 같은 종류의 결합이라도 거리가 멀수록(모듈 경계를 넘을수록) 비싸다. 응집은 반대로 변경의 지역성을 만든다. 응집이 높다는 건 "바뀌는 이유가 하나"라는 뜻이고, 이건 단일 책임 원칙(SRP)과 같은 말이다.
iOS에서는
Swift에서 결합의 표면은 접근 제어와 모듈 경계로 드러난다. public으로 노출한 API의 표면이 다른 모듈이 이 모듈에 걸 수 있는 의존(afferent coupling)의 상한을 정한다. SPM 타깃이나 Tuist 모듈로 쪼갠 앱에서는 결합이 빌드 시간으로 환산된다 — public 시그니처 하나를 바꾸면 그 모듈에 의존하는 모든 타깃이 재컴파일된다. 대표적인 나쁜 결합은 UserDefaults.standard나 앱 전역 싱글턴 같은 common coupling, 그리고 viewController.configure(isEditing: true)처럼 동작을 밖에서 스위칭하는 control coupling이다. 응집 쪽의 안티패턴은 그 유명한 Massive View Controller — 네트워킹·레이아웃·내비게이션·포매팅이 한 타입에 뭉쳐 "바뀌는 이유"가 대여섯 개인 상태다. ReactorKit·MVVM이 이 책임들을 갈라 응집을 올린다.
// ⚠️ control coupling: 호출부가 내부 분기를 flag로 조종한다
func render(_ item: Item, isCompact: Bool, showsBadge: Bool) { /* if 범벅 */ }
// ✅ 동작이 다르면 타입으로 분리 → 호출부는 무엇을 원하는지만 말한다
protocol ItemRenderer { func render(_ item: Item) }
struct CompactRenderer: ItemRenderer { func render(_ item: Item) { /* ... */ } }
struct DetailRenderer: ItemRenderer { func render(_ item: Item) { /* ... */ } }실험 · 도구
결합의 진짜 크기는 import 그래프만으로는 안 보인다 — 함께 바뀌는 정도(temporal coupling)를 git 히스토리에서 측정하면 숨은 결합이 드러난다. code-maat 같은 도구로 "항상 같은 커밋에 등장하는 파일 쌍"을 뽑아 보면, import 관계가 전혀 없는데도 늘 같이 수정되는 파일들이 나온다. 이게 리팩터 1순위 후보다. 정적으로는 Robert Martin의 불안정성 지표 I = Ce / (Ca + Ce)(나가는 의존 ÷ 총 의존)를 모듈별로 계산해 "안정적이어야 할 코어 모듈이 불안정한지"를 본다. 간단한 실험: public 프로퍼티의 타입을 하나 바꾸고 컴파일 실패하는 파일 수를 세면 그게 그 심볼의 afferent coupling이다.
프로젝트 적용
public표면을 최소화한다(기본internal). 모듈 간에는 프로토콜과 최소 DTO로만 노출.- Bool·enum "모드" 플래그로 동작을 분기시키지 말고 타입을 분리해 control coupling을 제거.
- 전역 싱글턴에 가변 상태를 두고 공유하지 않는다 — 명시적 주입으로 common coupling을 끊는다.
- 하나의 타입이 "바뀌는 이유"가 둘 이상이면 쪼갠다(응집 = SRP).
- import 의존은 없는데 git에서 항상 같이 바뀌는 두 파일 = 숨은 결합. 이름을 붙여 명시적 경계로 끌어올린다.
"결합은 무조건 나쁘니 0으로 만들어야 한다"는 오해. 시스템이 협력하려면 결합은 필수다 — 목표는 제거가 아니라 약한 형태로 낮추고 안정적 경계에 모으는 것이다. "응집을 높인다 = 파일을 잘게 쪼갠다"도 틀렸다. 강하게 얽힌 걸 억지로 쪼개면 오히려 결합이 늘고(경계를 넘는 호출 증가), 관련 없는 걸 한데 모으면 응집이 떨어진다. 쪼개기·합치기는 항상 결합↔응집 트레이드오프를 보고 판단한다.
꼬리 질문
- data coupling과 stamp coupling의 차이를 예로 들어 보라. stamp coupling이 오히려 정당한 경우는?
- 두 모듈이 import 의존은 없는데 항상 같은 커밋에서 바뀐다면, 무슨 결합이며 어떻게 진단·해소하겠는가?
- 응집을 높이려 클래스를 쪼갰더니 모듈 경계를 넘는 호출이 늘어 결합이 증가했다. 이 트레이드오프는 어떤 기준으로 판단하는가?
Q2. 상태와 부수 효과를 분리하면 테스트와 동시성이 왜 쉬워지는가?
순수 함수(같은 입력 → 항상 같은 출력, 바깥을 건드리지 않음)는 참조 투명성을 가져 "입력만 주면 결과가 결정"된다. 부수 효과(네트워크·파일·시계·전역 변경·화면 갱신)를 바깥의 얇은 층으로 몰아내고 핵심 로직을 순수하게 유지하면, 테스트는 mock 없이 값만 비교하면 되고(빠르고 결정적), 동시성은 공유 가변 상태가 없어 데이터 레이스가 원천적으로 불가능해진다(불변 데이터는 자유롭게 공유·병렬 처리 가능). 이 구조가 Gary Bernhardt의 "Functional Core, Imperative Shell"이다.
CS 원리
부수 효과란 값을 반환하는 것 말고 바깥 세계와 관찰 가능한 상호작용을 하는 것이다 — 전역 변수 수정, I/O, 현재 시각 읽기, 난수. 순수 함수는 이게 없어 결정적(deterministic)이다. 테스트가 쉬워지는 이유는 여기서 곧장 나온다: 입력을 만들고 출력만 단언하면 끝이라, 시간·순서·외부 상태에 흔들리지 않고 병렬로 수만 개를 돌려도 안전하다. 반면 효과가 섞인 코드는 test double이나 통합 테스트가 필요하다. 동시성 쪽은 더 근본적이다 — 데이터 레이스는 공유 + 가변 + 동시 + 최소 한 번의 쓰기 네 조건이 모두 모여야 생기는데, 순수 함수는 공유 가변 상태를 만들지도 바꾸지도 않으므로 이 조건이 성립할 수 없다. 불변 데이터는 여러 스레드가 동시에 읽어도 안전하다. Bernhardt의 통찰은 "결정(순수)은 안으로, 행동(효과)은 밖으로" — 코어는 의존이 없고 경로가 많으며, 셸은 의존이 있지만 경로가 적다.
iOS에서는
값 타입(struct/enum) + let이 이 원리의 언어적 도구다. 값 시맨틱이라 경계를 넘으면 복사되고, 모든 멤버가 Sendable이면 그 타입도 Sendable이라 스레드 사이로 안전하게 오간다. 컴파일러가 함수의 순수성을 강제하진 않지만, 설계로 지킨다 — 특히 Date()·UUID()·난수·전역 접근을 로직 함수 안에서 직접 부르지 말고 인자로 주입해야 결정성이 산다. ReactorKit의 reduce(state:mutation:) -> State가 정확히 이 순수 코어다(이전 상태 + 변화 → 새 상태, 효과 없음). 효과는 mutate()가 담당한다. SwiftUI의 body도 "상태의 순수 함수"로 선언되고, 실제 렌더링이라는 효과는 프레임워크가 맡는다. 순수 코어는 actor로 감쌀 필요조차 없다 — 공유 가변 상태가 없으니 격리할 대상이 없다.
// ⚠️ 로직 안에서 Date()를 부르면 테스트가 매번 달라진다
func isExpired(_ token: Token) -> Bool { token.expiry < Date() }
// ✅ 순수: 시각을 인자로 받는다 → 테스트에서 원하는 시각을 넣는다
func isExpired(_ token: Token, now: Date) -> Bool { token.expiry < now }
// 셸(효과)에서만 실제 시계를 읽어 코어에 값으로 주입한다
let expired = isExpired(token, now: Date()) // 경계에서 한 번실험 · 도구
같은 로직을 두 버전으로 두고 Thread Sanitizer(TSan)로 비교해 보면 원리가 눈으로 보인다. 불변 값을 두 태스크가 동시에 읽는 코어는 TSan이 아무것도 보고하지 않는다. 반대로 셸에 공유 var를 두고 두 태스크가 갱신하면 즉시 race로 잡힌다. 순수 코어의 테스트는 async도 sleep도 없이 마이크로초 단위로 끝나고 Swift Testing에서 병렬로 돌아간다. 시계·난수를 주입하면 스냅샷 테스트가 결정적이 된다는 것도 직접 확인할 수 있다 — 주입 전에는 실패가 간헐적(flaky), 주입 후에는 항상 같은 결과.
프로젝트 적용
- 계산·판단 로직은 순수 함수로, 필요한 입력을 전부 인자로 받게 한다.
Date()·UUID()·랜덤·전역 접근을 함수 안에서 직접 호출하지 않는다. - 부수 효과는 경계(네트워크 클라이언트, repository,
@MainActorUI)로 밀어낸다. - 순수 코어를 굳이 actor로 감싸지 않는다 — 불필요한 suspension hop과 재진입 복잡성만 늘어난다.
- 테스트 피라미드: 순수 코어에 빠른 유닛 테스트를 대량으로, 얇은 셸에 소수의 통합 테스트를.
"부수 효과를 없애야 한다"는 오해. 프로그램의 존재 이유가 효과(화면·저장·네트워크)다 — 목표는 제거가 아니라 격리(경계로 밀어내기)다. "actor를 쓰면 동시성은 다 해결"이라는 것도 오해다. actor는 가변 상태를 안전하게 만들 뿐, 상태를 아예 안 만드는 것보다 느리고 await 지점마다 재진입 가능성을 부른다. 공유 가변 상태를 줄이는 게 먼저이고 actor는 그다음이다.
꼬리 질문
- "순수 함수"인데 테스트가 느리거나 간헐적으로 실패한다면 어디를 의심하겠는가?
Date()를 로직 안에서 부르면 테스트에 구체적으로 어떤 문제가 생기고, iOS 16의Clock타입으로는 어떻게 주입하겠는가?- 순수 코어를 습관적으로 actor로 감쌌다. 성능과 정확성 측면에서 각각 무슨 일이 생기는가?
Q3. Dependency Inversion과 Dependency Injection은 무엇이 다른가?
둘은 층위가 다르다. Dependency Inversion(DIP, 의존 역전 원칙)은 설계 원칙이다 — 고수준 정책 모듈이 저수준 세부 모듈에 직접 의존하지 말고, 둘 다 추상(프로토콜)에 의존하게 해서 소스코드 의존 방향을 뒤집는다. Dependency Injection(DI, 의존성 주입)은 구현 기법이다 — 객체가 자기 의존을 스스로 만들지 않고 밖에서 넘겨받는 것(생성자·프로퍼티·메서드). DIP는 "무엇을 이룰지"(목표), DI는 "어떻게 이룰지"(수단 중 하나). DIP 없이 DI만 할 수도 있고(구체 타입을 주입), DI 없이 DIP를 만족시킬 수도 있어(Service Locator 등) 자주 함께 쓰이지만 같은 게 아니다.
CS 원리
Robert C. Martin의 DIP(SOLID의 'D')는 두 문장이다. (a) 고수준 모듈은 저수준 모듈에 의존해선 안 되고, 둘 다 추상에 의존해야 한다. (b) 추상은 세부에 의존해선 안 되고, 세부가 추상에 의존해야 한다. "역전"인 이유는, 순진하게 짜면 고수준이 저수준을 호출하니 소스 의존도 고수준→저수준으로 흐르는데, 추상을 고수준 쪽이 소유하게 만들면 소스 의존이 제어 흐름과 반대로 뒤집히기 때문이다(저수준이 고수준의 추상을 구현·의존). DI는 이와 별개로 "인스턴스를 누가 공급하는가"의 문제다. Martin Fowler의 IoC/DI 글은 DI를 생성자·세터·인터페이스 주입으로 나누고, 같은 문제를 푸는 다른 방식인 Service Locator와 구분한다. 핵심 대비: DIP는 컴파일타임 의존의 방향, DI는 런타임 인스턴스의 출처를 말한다.
| Dependency Inversion (DIP) | Dependency Injection (DI) | |
|---|---|---|
| 정체 | 설계 원칙 (SOLID의 D) | 구현 기법 / 패턴 |
| 다루는 것 | 소스코드 의존의 방향 (컴파일타임) | 의존 인스턴스를 누가 공급하나 (런타임) |
| 수단 | 추상(프로토콜)을 고수준이 소유 | 생성자·프로퍼티·메서드로 전달 |
| 답하는 질문 | "무엇을 향해 의존이 흘러야 하나" | "이 객체는 협력자를 어디서 얻나" |
| 서로 없이 가능? | 가능 (Service Locator로 역전만) | 가능 (구체 타입을 주입) |
iOS에서는
DIP는 고수준이 소유한 프로토콜로 구현한다. Finda류 모듈 구조의 Interface/Implementation 분리가 정확히 이 형태다 — Interface 타깃에 PaymentGateway 프로토콜을 두고, Implementation 타깃의 StripeGateway가 Interface를 import해 구현한다. 그러면 소스 의존은 Implementation → Interface로 흐르고, 고수준은 구체 타입의 존재조차 모른다. DI는 별개로 생성자 주입이 기본이다: init(gateway: PaymentGateway). Swinject 같은 컨테이너 없이 순수 init 주입 + Factory로 조립하며, 최종 배선은 composition root(App/SceneDelegate)에서 한다. SwiftUI의 @Environment·@EnvironmentObject도 환경을 통한 DI의 한 형태다. Preview에서 #if DEBUG로 목을 주입할 수 있는 것도 DIP가 seam(이음매)을 만들어 두고 DI가 실제 값을 배달하기 때문이다.
// Interface 모듈 — 고수준이 소유하는 추상
protocol PaymentGateway { func charge(_ amount: Decimal) async throws }
// 고수준 정책 — Stripe를 전혀 모른다 (컴파일타임 의존 없음)
struct Checkout {
let gateway: PaymentGateway // ← 추상에만 의존 (DIP)
init(gateway: PaymentGateway) { self.gateway = gateway } // ← 주입 (DI)
func pay(_ amount: Decimal) async throws { try await gateway.charge(amount) }
}
// Implementation 모듈 — 추상을 구현 (소스 의존이 위를 향한다)
struct StripeGateway: PaymentGateway { func charge(_ amount: Decimal) async throws { /* ... */ } }
// composition root에서 배선
let checkout = Checkout(gateway: StripeGateway())
// 테스트: 같은 추상에 목을 주입 (네트워크 없이)
struct MockGateway: PaymentGateway { func charge(_ amount: Decimal) async throws {} }
let sut = Checkout(gateway: MockGateway())프로젝트 적용
- 생성자 주입을 기본으로. 프로퍼티 주입은 UIKit VC처럼 생성 시점을 제어 못 할 때만.
- 추상(프로토콜)은 사용하는 고수준이 소유·정의하고 "필요한 최소 인터페이스"만 담는다(ISP). 구현 모듈이 인터페이스 모듈을 의존한다.
- Service Locator·전역 DI 컨테이너 남용 금지 — 의존이 시그니처에 안 드러나 숨은 결합과 테스트 어려움을 만든다.
- 모든 것에 프로토콜을 만들지 않는다. 구현이 하나뿐이고 교체·테스트 대체가 필요 없으면 구체 타입을 그대로 주입해도 된다(Q5로 이어진다).
"DI 컨테이너(Swinject 등)를 쓰면 곧 DIP"라는 오해. 컨테이너는 DI의 배선을 자동화하는 도구일 뿐이고, 구체 타입을 등록·주입하면 역전이 아니다. 반대로 "프로토콜만 만들면 DIP"도 오해다. 추상을 고수준이 아니라 저수준이 소유해서 구현 세부에 맞춘 프로토콜을 만들면, 소스 의존 방향이 그대로라 이름만 바꾼 결합이지 역전이 아니다. 역전의 핵심은 추상의 소유권이 정책(고수준) 쪽에 있는가이다.
꼬리 질문
- 구현이 평생 하나뿐일 의존도 프로토콜로 역전해야 하는가? 판단 기준은 무엇인가?
- 프로토콜을 고수준이 소유할 때와 저수준이 소유할 때, 소스 의존 방향은 각각 어떻게 되는가?
- Service Locator와 생성자 주입은 둘 다 DI인데, 테스트 용이성과 가독성에서 무엇이 갈리는가?
Q4. Mutable shared state가 왜 복잡성과 동시성 문제를 만드는가?
공유 가변 상태 = 여러 곳에서 접근 가능한(shared) + 바뀌는(mutable) 데이터. 두 가지가 문제다. (1) 복잡성: 어떤 코드의 동작을 이해하려면 "지금 그 상태 값이 뭔지" 알아야 하는데, 그 값은 실행 중 아무 데서나 바뀔 수 있어 지역적으로 추론할 수 없다. 가능한 상태 조합이 곱셈으로 폭발한다. (2) 동시성: 두 실행 흐름이 동시에 접근하고 최소 하나가 쓰면 data race — 읽기·쓰기가 원자적이지 않아 값이 찢어지거나 갱신이 유실되고, 메모리 모델상 정의되지 않은 동작이 된다. 데이터 레이스는 공유·가변·동시·쓰기 네 조건이 모두 모여야 생기므로, 아무거나 하나만 끊어도 사라진다(불변으로 / 공유 안 함으로 / 직렬화로).
CS 원리
data race와 race condition은 다르다. data race는 동기화 없이 같은 메모리에 동시 접근하며 최소 하나가 쓰기인 상태로, 대부분의 메모리 모델에서 정의되지 않은 동작(UB)이다. race condition은 타이밍에 따라 결과가 달라지는 논리 버그로, data race가 없어도(각 접근이 원자적이어도) "확인 후 사용" 사이에 상태가 바뀌면 생긴다. 복잡성 쪽 근거는 비지역성이다 — 불리언 플래그 N개면 상태 공간이 2ⁿ으로 커지고, 공유 가변 상태는 "이 값이 여기까지 오는 동안 누가 바꿨나"를 코드만 봐선 알 수 없게 만든다(그래서 재현 안 되는 Heisenbug가 나온다). 해결책은 세 갈래다: 공유하지 않기(값 복사·격리), 바꾸지 않기(불변), 직렬화하기(락·actor·큐 — 대신 deadlock·우선순위 역전·경합이라는 새 복잡성을 부른다).
iOS에서는
참조 타입(class)의 var가 태스크·스레드를 넘어 공유되면 전형적인 공유 가변 상태다(struct는 복사되지만 struct 안의 class는 여전히 공유된다). Swift 6의 동시성 모델은 이걸 컴파일타임에 막는다 — Sendable과 actor 격리로 데이터 레이스를 아예 타입 시스템으로 금지한다. @MainActor는 메인 스레드로, actor는 그 인스턴스의 큐로 접근을 직렬화한다. 과거에는 직렬 DispatchQueue, NSLock, os_unfair_lock로 손수 막았고, 지금은 iOS 18의 Mutex와 Atomic도 있다. 함정 하나: actor 재진입. actor 메서드가 await로 멈춘 사이 다른 호출이 그 actor에 들어와 상태를 바꿀 수 있어, await 앞에서 확인한 불변식이 뒤에서 깨질 수 있다(actor는 각 연산의 원자성만 보장하지, await를 가로지르는 원자성은 보장하지 않는다).
// ⚠️ 공유 가변 + 동시 쓰기 → 갱신 유실 (이 공유·변이가 Swift 6에서는 컴파일 에러로 차단됨)
final class Counter { var value = 0 }
// 두 태스크가 c.value += 1 을 동시에 → read-modify-write가 겹쳐 값이 샌다
// ✅ actor로 접근을 직렬화
actor SafeCounter {
private var value = 0
func increment() { value += 1 } // 이 메서드 하나는 원자적
var current: Int { value }
}
// ⚠️ 재진입 함정: await를 가로지르면 원자성이 깨진다
actor Bank {
private var balance = 0
func withdraw(_ n: Int) async {
guard balance >= n else { return } // 확인
await audit() // ← 여기서 다른 withdraw가 끼어들 수 있다
balance -= n // 확인이 더는 유효하지 않을 수 있음 → 재확인 필요
}
}실험 · 도구
가장 확실한 도구는 두 가지다. 런타임에서는 TSan: 공유 var counter를 두 태스크가 각각 수십만 번 증가시키면 최종값이 기대치에 못 미치고(갱신 유실) TSan이 정확한 스택으로 race를 짚어 준다. 빌드타임에서는 Swift 6 언어 모드 / strict concurrency 검사: 실행조차 하기 전에 컴파일러가 "이 가변 상태가 격리 밖에서 공유된다"고 진단한다. actor 버전으로 바꾸면 두 도구 모두 조용해지는 걸로 수정이 검증된다. 재진입 함정은 await 사이에 상태를 바꾸는 테스트를 넣어 재현한다.
프로젝트 적용
- 기본은 값 타입 + 불변(
let). 공유가 불가피하면 소유자를 하나로 좁히고(single source of truth) actor·@MainActor로 격리한다. - 락을 잡은 채
await하거나 재진입을 유발하는 호출을 하지 않는다(deadlock·재진입 버그). - read-modify-write를 여러 스텝으로 쪼개지 말고 원자적 단위(actor 메서드 하나,
withLock블록) 안에서 끝낸다. - 전역
static var가변 상태를 피한다. Swift 6 strict concurrency로 컴파일타임에 잡히게 한다.
"락만 잘 걸면 동시성 문제는 끝"이라는 오해. 락은 data race는 막아도 race condition(순서 의존 논리 버그)·deadlock·우선순위 역전·성능 저하를 새로 만든다. "actor면 무조건 안전"도 오해다 — actor는 개별 연산의 원자성만 보장하고, await를 사이에 두면 재진입으로 상태가 바뀌어 "확인 후 사용" 불변식이 깨진다. 그래서 await 뒤에서는 앞서 확인한 조건을 다시 확인해야 한다.
꼬리 질문
- data race와 race condition의 차이를 예로 들어 보라. 락으로도 못 막는 race condition은?
- actor 메서드 안에서
await뒤에 앞서 확인한 상태를 다시 확인해야 하는 이유는? - 공유 가변 상태를 "애초에 안 만드는" 방법 세 가지와 각각의 iOS 대응(값 타입·불변·격리)을 설명하라.
Q5. 추상화가 많을수록 좋은 설계인가?
아니다. 추상화는 공짜가 아니라 비용을 치르고 사는 것이다 — 간접(indirection) 한 겹마다 읽는 사람이 따라가야 할 점프가 늘고, 잘못된 추상화는 중복보다 나쁘다. 좋은 추상화는 "실제로 바뀌는 것"을 감춰 변경을 국소화할 때만 값을 한다. 아직 오지 않은 미래를 위한 추상화(YAGNI 위반)나 구현이 하나뿐인데 만든 프로토콜은 이해 비용만 늘리는 순손해다. 판단 기준은 "추상화가 감추는 게 실제로 변하는가, 그리고 감춤으로써 호출부가 정말 단순해지는가". 설계의 질은 추상화의 개수가 아니라 적절한 경계에 놓인 추상화의 질이 만든다.
CS 원리
추상화의 정당한 목적은 Parnas의 정보 은닉(1972)이다 — 모듈은 "바뀔 가능성이 큰 설계 결정"을 감춰야 한다. 즉 좋은 추상화가 감추는 대상은 "변화 축"이다. 반대로 비용은 실재한다: 간접으로 인한 인지 부하, 새는 추상화(Joel Spolsky), 그리고 성급한 일반화. Sandi Metz의 "The Wrong Abstraction"이 핵심을 찌른다 — DRY에 쫓겨 성급히 뽑은 추상화는 관련 없는 코드를 묶고, 요구가 갈라지면 사람들은 플래그·파라미터를 덧대다 결국 썩는다. "중복은 잘못된 추상화보다 훨씬 싸다." 처방은 YAGNI와 "세 번 반복되면 그때 뽑는다(rule of three)", 그리고 잘못 든 추상화는 덧대지 말고 되돌려(inline) 다시 뽑는 것이다.
iOS에서는
Protocol-Oriented Programming(WWDC 2015)은 강력하지만 과용되기 쉽다. "테스트를 위해" 클래스마다 구현이 하나뿐인 프로토콜을 뽑는 관행이 대표적이다 — 대개 구체 타입으로도 테스트할 수 있거나 더 가벼운 seam으로 충분하다. 성능 측면도 있다: 존재 타입(any P)은 boxing과 동적 디스패치 비용을, 프로토콜 경계를 넘는 ARC retain/release는 추가 오버헤드를 부른다(some P는 특수화되지만 코드 크기가 는다). 물론 대부분의 경우 진짜 비용은 CPU가 아니라 사람이다 — "요청 하나가 어디서 처리되나"를 알려고 Manager→Service→Provider→Factory를 다 열어야 한다면, 그 층들은 값을 못 하고 있는 것이다. SwiftUI에서 뷰를 지나치게 잘게 쪼개 클로저를 잔뜩 주입하는 것도 같은 함정이다.
// ⚠️ 구현이 평생 하나뿐인데 "테스트 때문에" 뽑은 프로토콜 — 순손해
protocol DateFormatterProviding { func string(from: Date) -> String }
struct DefaultDateFormatterProvider: DateFormatterProviding { /* ... */ }
// ✅ 교체·다형성·테스트 대체가 실제로 필요 없으면 구체 타입 + 순수 함수로 충분
enum DateText {
static func short(_ date: Date, calendar: Calendar = .current) -> String { /* ... */ }
}
// 순수 함수라 목 없이도 테스트가 결정적 (Q2와 같은 원리)프로젝트 적용
- rule of three: 세 번 반복되기 전엔 추상화하지 않는다. 미래 대비용 추상화(YAGNI)를 만들지 않는다.
- 프로토콜은 실제 교체·다형성·테스트 대체 필요가 있을 때만. 구현 하나 + 대체 불필요면 구체 타입.
- 추상화가 감추는 게 "바뀌지 않는 것"이면 그건 손해다 — 감춤의 대상은 반드시 변화 축이어야 한다.
- 잘못 든 추상화는 플래그·파라미터로 덧대지 말고 되돌려(inline) 다시 뽑는다.
- 얕고 넓게 여러 겹보다, 경계는 적게·안정적으로 둔다.
"추상화·레이어가 많을수록 깨끗한 아키텍처"라는 오해. Clean Architecture조차 목적은 레이어 개수가 아니라 "변경 이유가 다른 것의 분리"다 — 불필요한 간접은 clean이 아니라 잡음이다. "DRY = 코드 중복 0"도 오해다. 우연히 같아 보이는(coincidental) 코드를 합치면 잘못된 추상화가 된다. DRY가 제거하려는 건 코드 형태의 일치가 아니라 지식(knowledge)의 중복이다.
꼬리 질문
- "테스트 때문에" 구현이 하나뿐인 프로토콜을 만들자는 제안을 받았다. 어떤 기준으로 수용·거절하겠는가?
- 거의 같아 보이는 코드 두 곳을 발견했다. 합칠지 둘지 결정하는 기준은 무엇인가?
- 추상화의 "비용"을 비개발 동료에게 설명한다면, 어떤 지표나 현상을 근거로 들겠는가?