결합·응집, 순수성, 의존 규칙, 공유 가변 상태, 추상화 — 이 다섯 개념은 서로 다른 이름을 달고 있지만 결국 한 가지 질문에 답한다. "내일 요구사항이 바뀌었을 때, 코드의 몇 군데를 어떤 확신으로 고쳐야 하는가." 아키텍처는 미학이 아니라 변경 비용을 지배하는 공학이다.
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로만 노출.
// 모듈 밖으로 노출된 것은 전부 '고칠 때 남을 건드리는' 결합이다.
// Swift의 기본 접근 수준이 internal인 것은 좋은 기본값이다.
import Foundation
// ✅ 밖으로 나가는 것은 최소 DTO와 프로토콜뿐
public struct FeedItem: Sendable, Identifiable {
public let id: UUID
public let title: String
public let createdAt: Date
// ✅ 이니셜라이저도 의도적으로 노출한다 (public 타입의 기본 init은 internal이다)
public init(id: UUID, title: String, createdAt: Date) {
self.id = id
self.title = title
self.createdAt = createdAt
}
}
public protocol FeedLoading: Sendable {
func load() async throws -> [FeedItem]
}
// ✅ 구현 세부는 전부 internal — 밖에서 알 필요가 없다
struct FeedResponseDTO: Decodable { // 서버 응답 형태
let items: [ItemDTO]
struct ItemDTO: Decodable { let id: String; let title: String }
}
struct FeedMapper { // 변환 로직
func map(_ dto: FeedResponseDTO) -> [FeedItem] { [] }
}
final class FeedCache { } // 캐시 구현
// ✅ 공개 진입점 하나만 public
public final class FeedService: FeedLoading {
private let mapper = FeedMapper()
private let cache = FeedCache()
public init() {}
public func load() async throws -> [FeedItem] { [] }
}
/* ⚠️ public을 붙이는 순간 그건 계약이 된다 — 바꾸면 소비자가 깨진다.
"혹시 필요할까 봐" 노출하지 않는다. 필요해지면 그때 올린다.
✅ 노출 범위를 좁히는 다른 수단
· internal 같은 모듈 안에서만 (기본값)
· fileprivate/private 더 좁게
· package 같은 패키지 안에서만 (Swift 5.9+)
· @_spi(Internal) 특정 소비자에게만 (테스트 등) */② Bool·enum "모드" 플래그로 동작을 분기시키지 말고 타입을 분리해 control coupling을 제거.
import Foundation
// control coupling — 호출자가 "내부에서 어떻게 갈라지는지"를 알아야 하는 결합.
// 파라미터가 늘어나면 조합이 폭발하고, 어떤 조합이 유효한지 아무도 모른다.
// ❌ 플래그로 동작을 바꾼다
func fetchBad(useCache: Bool,
isAdmin: Bool,
silent: Bool,
includeDeleted: Bool) async throws -> [Item] {
// 2⁴ = 16가지 조합. 그중 몇 개가 유효한지 시그니처로는 알 수 없다.
[]
}
// ✅ ① 타입으로 분리하면 분기가 사라진다
protocol FeedSource {
func load() async throws -> [Item]
}
struct CachedFeedSource: FeedSource {
let cache: FeedCache
func load() async throws -> [Item] { [] }
}
struct LiveFeedSource: FeedSource {
let client: APIClient
func load() async throws -> [Item] { [] }
}
struct AdminFeedSource: FeedSource { // 권한별로도 타입이 다르다
let client: APIClient
func load() async throws -> [Item] { [] }
}
// ✅ ② 조합이 필요하면 값 타입으로 묶어 유효한 것만 표현한다
struct FeedQuery {
var scope: Scope = .published
var ordering: Ordering = .newest
enum Scope { case published, includingDeleted } // Bool 두 개보다 명확하다
enum Ordering { case newest, popular }
}
func fetch(_ query: FeedQuery) async throws -> [Item] { [] }
// ✅ ③ 부수적 관심사(로깅·조용히)는 파라미터가 아니라 데코레이터로
struct SilentFeedSource: FeedSource {
let wrapped: FeedSource
func load() async throws -> [Item] {
(try? await wrapped.load()) ?? [] // 실패를 삼킨다
}
}
struct Item {}
final class FeedCache {}
final class APIClient {}③ 전역 싱글턴에 가변 상태를 두고 공유하지 않는다 — 명시적 주입으로 common coupling을 끊는다.
import Foundation
// common coupling — 여러 모듈이 같은 전역 상태를 읽고 쓰는 결합.
// 누가 언제 바꾸는지 추적할 수 없고, 테스트가 서로 간섭한다.
// ❌ 전역 가변 싱글턴
final class BadSession {
static let shared = BadSession()
var currentUser: User? // 💥 아무나 어디서나 바꾼다
var authToken: String?
}
// 문제
// · 이 값을 누가 바꿨는지 추적 불가
// · 테스트마다 상태가 남아 순서에 의존하게 된다
// · 동시성 안전하지 않다
// · 의존이 시그니처에 안 드러나 숨은 결합이 된다
// ✅ ① 명시적 주입 — 의존이 타입에 보인다
protocol SessionReading: Sendable {
var currentUser: User? { get async }
}
actor Session: SessionReading {
private var user: User?
var currentUser: User? { user }
func signIn(_ u: User) { user = u }
func signOut() { user = nil }
}
struct ProfileService {
private let session: SessionReading // ✅ 필요한 최소 인터페이스만
init(session: SessionReading) { self.session = session }
func loadProfile() async throws -> Profile {
guard let user = await session.currentUser else { throw AppError.notSignedIn }
return try await fetch(user.id)
}
private func fetch(_ id: UUID) async throws -> Profile { Profile() }
}
// ✅ ② 테스트가 쉬워진다 — 전역을 건드리지 않는다
func test_로그인_안된_경우() async {
let service = ProfileService(session: StubSession(user: nil))
// 다른 테스트에 영향을 주지 않는다
}
struct StubSession: SessionReading {
let user: User?
var currentUser: User? { user }
}
// ⚠️ 싱글턴 자체가 나쁜 게 아니다. '전역 가변 상태'가 문제다.
// 불변 설정(Configuration)이나 actor로 격리된 것은 괜찮다.
struct User { let id: UUID }
struct Profile {}
enum AppError: Error { case notSignedIn }④ 하나의 타입이 "바뀌는 이유"가 둘 이상이면 쪼갠다(응집 = SRP).
import Foundation
// 응집도 = 한 모듈의 요소들이 하나의 책임에 모여 있는 정도.
// 판별 기준은 "이 타입이 바뀌는 이유가 몇 개인가"다(SRP).
// ❌ 바뀌는 이유가 넷이다
final class BadUserManager {
// ① 서버 API가 바뀌면
func fetchUser(id: UUID) async throws -> User { User() }
// ② DB 스키마가 바뀌면
func saveToDatabase(_ user: User) throws {}
// ③ 표시 형식이 바뀌면
func formattedName(_ user: User) -> String { "" }
// ④ 검증 규칙이 바뀌면
func validate(_ email: String) -> Bool { true }
}
// ✅ 이유별로 쪼갠다
struct UserAPI { // 서버 계약이 바뀔 때만
func fetch(id: UUID) async throws -> User { User() }
}
struct UserRepository { // 저장 방식이 바뀔 때만
func save(_ user: User) throws {}
func load(id: UUID) throws -> User? { nil }
}
struct UserFormatter { // 표시 규칙이 바뀔 때만
func displayName(_ user: User) -> String { "" }
}
struct EmailValidator { // 검증 규칙이 바뀔 때만
func isValid(_ email: String) -> Bool { true }
}
// ⚠️ 반대 방향 과잉도 조심한다. 함께 바뀌는 것을 억지로 쪼개면
// 변경할 때마다 여러 파일을 오가야 해서 오히려 결합이 는다.
// "함께 바뀌는 것은 함께 둔다"가 응집의 정의다.
// ✅ 판별 질문
// "이 파일을 여는 이유가 몇 가지인가?"
// "이 타입을 고치는 사람이 서로 다른 역할인가?" (서버 담당 vs 디자인 담당)
struct User {}⑤ import 의존은 없는데 git에서 항상 같이 바뀌는 두 파일 = 숨은 결합. 이름을 붙여 명시적 경계로 끌어올린다.
# import 의존은 없는데 항상 같이 바뀌는 두 파일 = 이름 없는 결합.
# 코드로는 안 보이지만 실제로는 묶여 있다.
# ✅ ① 먼저 파일별 변경 빈도를 센다 — 자주 바뀌는 파일이 후보다
git log --format=format: --name-only --since="6 months ago" \
| grep -v '^$' \
| sort | uniq -c | sort -rn | head -30
# 각 파일의 변경 빈도 — 자주 바뀌는 파일이 후보다
# ✅ ② 실제로 '같은 커밋에' 등장한 쌍을 뽑는다
git log --format='%H' --since="6 months ago" | while read commit; do
git show --format= --name-only "$commit" \
| grep '\.swift$' | sort | paste -sd, -
done | sort | uniq -c | sort -rn | head -20
# 출력 예:
# 47 Sources/Feed/FeedViewModel.swift,Sources/Analytics/EventLogger.swift
# → import 관계가 없는데 47번이나 함께 바뀌었다 = 숨은 결합
# ✅ ③ 해석과 대응
# · 이벤트 이름 문자열을 양쪽에서 하드코딩하고 있다
# → 공유 타입(enum)으로 끌어올려 명시적 의존으로 만든다
# · 서버 응답 필드를 두 곳에서 각자 파싱한다
# → DTO를 한 곳에 두고 공유한다
# · 같은 비즈니스 규칙이 두 곳에 복제돼 있다
# → 한 곳으로 모은다
# ✅ ④ 도구를 쓰면 더 쉽다
# · code-maat (Adam Tornhill) — logical coupling 분석
# · CodeScene — 시각화
# ⚠️ 함께 바뀌는 것이 항상 나쁜 건 아니다.
# 한 기능의 뷰·뷰모델·테스트가 함께 바뀌는 건 정상이다(응집).
# 문제는 '서로 다른 관심사'가 함께 바뀔 때다."결합은 무조건 나쁘니 0으로 만들어야 한다"는 오해. 시스템이 협력하려면 결합은 필수다 — 목표는 제거가 아니라 약한 형태로 낮추고 안정적 경계에 모으는 것이다. "응집을 높인다 = 파일을 잘게 쪼갠다"도 틀렸다. 강하게 얽힌 걸 억지로 쪼개면 오히려 결합이 늘고(경계를 넘는 호출 증가), 관련 없는 걸 한데 모으면 응집이 떨어진다. 쪼개기·합치기는 항상 결합↔응집 트레이드오프를 보고 판단한다.
부엌 서랍을 떠올려 보자. 응집(cohesion)이 높다는 건 칼 서랍엔 칼만, 수저 서랍엔 수저만 들어 있다는 뜻이다. 반대로 "잡동사니 서랍"은 응집이 낮다 — 건전지, 영수증, 케이블이 뒤섞여 있어서 뭘 바꾸려 해도 어디를 뒤져야 할지 모른다. 결합(coupling)은 서랍끼리 얼마나 끈으로 묶여 있느냐다. 칼 서랍을 여는 순간 수저 서랍이 딸려 나온다면 결합이 센 거고, 그러면 칼 하나 바꾸는 데도 온 부엌을 다시 정리하게 된다.
여기서 제일 많이 걸려 넘어지는 게 "그럼 끈을 다 끊으면 되잖아"다. 요리를 하려면 서랍들은 어차피 협력해야 한다. 그러니 목표는 끈을 없애는 게 아니라, 끈을 하나로 줄이고 그 끈을 눈에 잘 띄는 자리에 두는 것이다 — 코드로 치면 "이 모듈은 이 프로토콜로만 대화한다"는 계약 하나. 비유가 깨지는 지점도 하나 있다. 서랍은 눈에 보이는 끈만 있지만 코드에는 보이지 않는 끈이 생긴다. import 한 줄 없는데도 git 기록에서 늘 같은 커밋에 함께 등장한다면, 그게 바로 보이지 않는 끈이다.
꼬리 질문
data coupling과 stamp coupling의 차이를 예로 들어 보라. stamp coupling이 오히려 정당한 경우는?
data coupling은 꼭 필요한 값만 인자로 넘기는 것(charge(amount: Decimal)), stamp coupling은 필요 없는 필드까지 담긴 구조체를 통째로 넘기는 것(총액만 쓰면서 User 전체를 넘김)이다. 받는 쪽이 안 쓰는 필드에까지 묶여 그 타입이 바뀌면 같이 흔들린다. 다만 필드들이 하나의 개념으로 항상 함께 다녀야 의미가 설 때는 묶어 넘기는 게 오히려 안전하다 — 예컨대 위경도를 따로 넘기면 둘의 정합성(같은 좌표계·같은 시점)이 깨질 수 있으니 CLLocationCoordinate2D 같은 값 타입 하나로 넘긴다. 판단 기준은 "받는 쪽이 그 묶음의 불변식에 실제로 의존하는가"다.
두 모듈이 import 의존은 없는데 항상 같은 커밋에서 바뀐다면, 무슨 결합이며 어떻게 진단·해소하겠는가?
정적 import 그래프엔 안 보이지만 늘 함께 바뀌면 시간적 결합(temporal/logical coupling), Connascence로 말하면 거리가 먼 원격 결합이다. 진단은 코드가 아니라 git 히스토리에서 한다 — code-maat나 간단한 스크립트로 "같은 커밋에 등장하는 파일 쌍"의 co-change 빈도를 뽑으면 숨은 쌍이 드러난다. 해소는 둘이 암묵적으로 공유하는 지식을 명시적 타입·경계로 끌어올리는 것이다. 인코더와 디코더가 늘 같이 바뀐다면 그 둘이 공유하는 포맷 정의를 단일 소스로 뽑아 양쪽이 그것에 의존하게 만든다.
응집을 높이려 클래스를 쪼갰더니 모듈 경계를 넘는 호출이 늘어 결합이 증가했다. 이 트레이드오프는 어떤 기준으로 판단하는가?
기준은 "쪼갠 두 조각이 서로 다른 이유로 바뀌는가"다. 변경 이유가 정말 다르면(포매팅 규칙 vs 네트워크 정책) 경계를 넘는 호출 비용을 치를 값이 있지만, 늘 같이 바뀐다면 원래 한 책임이었던 것이므로 합쳐 두는 편이 낫다. Connascence 관점에서, 분리 탓에 강한 결합(같은 알고리즘·같은 불변식 공유)이 모듈 경계 밖으로 밀려나 거리가 멀어졌다면 오히려 악화된 것이다. 즉 응집을 위해 강한 결합을 먼 곳으로 흩뿌리는 분리는 하지 않는다.
Q2. 상태와 부수 효과를 분리하면 테스트와 동시성이 왜 쉬워지는가?
순수 함수(같은 입력 → 항상 같은 출력, 바깥을 건드리지 않음)는 참조 투명성을 가져 "입력만 주면 결과가 결정"된다. 부수 효과(네트워크·파일·시계·전역 변경·화면 갱신)를 바깥의 얇은 층으로 몰아내고 핵심 로직을 순수하게 유지하면, 테스트는 mock 없이 값만 비교하면 되고(빠르고 결정적), 동시성은 공유 가변 상태가 없어 데이터 레이스가 원천적으로 불가능해진다(불변 데이터는 자유롭게 공유·병렬 처리 가능). 이 구조가 Gary Bernhardt의 "Functional Core, Imperative Shell"이다.
CS 원리
부수 효과란 값을 반환하는 것 말고 바깥 세계와 관찰 가능한 상호작용을 하는 것이다 — 전역 변수 수정, I/O, 현재 시각 읽기, 난수. 순수 함수는 이게 없어 결정적(deterministic)이다. 테스트가 쉬워지는 이유는 여기서 곧장 나온다: 입력을 만들고 출력만 단언하면 끝이라, 시간·순서·외부 상태에 흔들리지 않고 병렬로 수만 개를 돌려도 안전하다. 반면 효과가 섞인 코드는 test double이나 통합 테스트가 필요하다. 동시성 쪽은 더 근본적이다 — 데이터 레이스는 공유 + 가변 + 동시 + 최소 한 번의 쓰기 네 조건이 모두 모여야 생기는데, 순수 함수는 공유 가변 상태를 만들지도 바꾸지도 않으므로 이 조건이 성립할 수 없다. 불변 데이터는 여러 스레드가 동시에 읽어도 안전하다. Bernhardt의 통찰은 "결정(순수)은 안으로, 행동(효과)은 밖으로" — 코어는 의존이 없고 경로가 많으며, 셸은 의존이 있지만 경로가 적다.
iOS에서는
값 타입(struct/enum) + let이 이 원리의 언어적 도구다. 값 시맨틱이라 경계를 넘으면 복사되고, 저장 프로퍼티가 전부 Sendable이면 internal 이하의 struct·enum에는 컴파일러가 Sendable을 자동으로 붙여 준다 — 다만 public(비 frozen) 타입은 그 적합성이 곧 API 계약이 되므로 추론에서 제외되어 : 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()·랜덤·전역 접근을 함수 안에서 직접 호출하지 않는다.
import Foundation
// 순수 함수는 같은 입력에 항상 같은 출력을 낸다.
// 그러려면 함수 안에서 '바깥'을 읽으면 안 된다.
// ❌ 숨은 입력이 셋이다 — 테스트할 수 없다
struct BadPricing {
func total(items: [Item], coupon: Coupon?) -> Money {
let base = items.reduce(Money.zero) { $0 + $1.price }
guard let coupon,
coupon.expiresAt > Date() // ① 현재 시각
else { return base }
let rate = RemoteConfig.shared.maxDiscountRate // ② 전역 설정
let bonus = Bool.random() ? 0.05 : 0 // ③ 난수
let applied = Decimal(min(coupon.rate, rate) + bonus)
return base * (1 - applied)
}
}
// ✅ 필요한 모든 것을 인자로 받는다
struct Pricing {
static func total(items: [Item],
coupon: Coupon?,
now: Date,
maxDiscountRate: Double) -> Money {
let base = items.reduce(Money.zero) { $0 + $1.price }
guard let coupon, coupon.expiresAt > now else { return base }
let applied = Decimal(min(coupon.rate, maxDiscountRate))
return base * (1 - applied)
}
}
// ✅ 테스트가 목 없이 값 비교만으로 끝난다
func test_만료된_쿠폰은_무시된다() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let expired = Coupon(rate: 0.2, expiresAt: now.addingTimeInterval(-1))
let result = Pricing.total(items: [Item(price: 1000)],
coupon: expired,
now: now,
maxDiscountRate: 0.3)
assert(result == 1000)
}
// ⚠️ 함수 안에서 부르면 안 되는 것들
// Date() · UUID() · Bool.random() · UserDefaults · 전역 싱글턴
// Locale.current · TimeZone.current · FileManager · URLSession
struct Item { let price: Money }
struct Coupon { let rate: Double; let expiresAt: Date }
typealias Money = Decimal
extension Decimal { static var zero: Decimal { 0 } }
enum RemoteConfig { static let shared = RemoteConfigImpl() }
struct RemoteConfigImpl { let maxDiscountRate = 0.3 }② 부수 효과는 경계(네트워크 클라이언트, repository, @MainActor UI)로 밀어낸다.
import Foundation
import Combine
// Functional Core, Imperative Shell:
// 안쪽(코어)은 순수한 계산, 바깥쪽(셸)만 효과를 낸다.
// ── 코어: 순수. 테스트가 값 비교로 끝난다.
struct CheckoutCore {
struct Input {
let items: [Item]
let coupon: Coupon?
let now: Date
let userTier: Tier
}
struct Output: Equatable {
let total: Decimal
let appliedDiscount: Decimal
let warnings: [String]
}
static func compute(_ input: Input) -> Output {
let base = input.items.reduce(Decimal.zero) { $0 + $1.price }
var discount = Decimal.zero
var warnings: [String] = []
if let coupon = input.coupon {
if coupon.expiresAt <= input.now {
warnings.append("쿠폰이 만료되었습니다")
} else {
discount = base * Decimal(coupon.rate)
}
}
if input.items.isEmpty { warnings.append("장바구니가 비어 있습니다") }
return Output(total: base - discount,
appliedDiscount: discount,
warnings: warnings)
}
}
// ── 셸: 효과만 담당. 로직은 코어에 위임한다.
@MainActor
final class CheckoutViewModel: ObservableObject {
@Published private(set) var output: CheckoutCore.Output?
private let repository: CartRepository // 효과 (네트워크·DB)
private let now: () -> Date // 효과 (시계)
init(repository: CartRepository, now: @escaping () -> Date = Date.init) {
self.repository = repository
self.now = now
}
func refresh() async throws {
let items = try await repository.items() // 효과
let coupon = try await repository.activeCoupon() // 효과
output = CheckoutCore.compute( // 순수 계산
.init(items: items, coupon: coupon, now: now(), userTier: .basic))
}
}
// ⚠️ 순수 코어를 굳이 actor로 감싸지 않는다 — 불필요한 suspension hop과
// 재진입 복잡성만 늘어난다. 순수 함수는 애초에 스레드 안전하다.
struct Item { let price: Decimal }
struct Coupon { let rate: Double; let expiresAt: Date }
enum Tier { case basic, premium }
protocol CartRepository {
func items() async throws -> [Item]
func activeCoupon() async throws -> Coupon?
}③ 순수 코어를 굳이 actor로 감싸지 않는다 — 불필요한 suspension hop과 재진입 복잡성만 늘어난다.
import Foundation
// 순수 함수는 상태가 없으므로 이미 스레드 안전하다.
// actor로 감싸면 얻는 것 없이 suspension hop과 재진입 복잡성만 는다.
// ❌ 계산만 하는 것을 actor로
actor BadCalculator {
func total(_ items: [Item]) -> Decimal {
items.reduce(0) { $0 + $1.price }
}
}
// 호출부: await calculator.total(items)
// · 매 호출마다 actor hop (스케줄링 비용)
// · await가 생겨 호출부가 async가 되어야 한다
// · 얻는 안전성은 0 (애초에 공유 상태가 없다)
// ✅ 순수 함수는 그냥 함수로 — 어디서든 동시에 불러도 안전하다
enum Calculator {
static func total(_ items: [Item]) -> Decimal {
items.reduce(0) { $0 + $1.price }
}
}
// 호출부: Calculator.total(items) — 동기, 비용 0
// ✅ actor가 필요한 경우: '가변 상태를 보호'할 때뿐이다
actor OrderQueue {
private var pending: [Order] = [] // ← 가변 상태가 있다
func enqueue(_ order: Order) { pending.append(order) }
func drain() -> [Order] { defer { pending = [] }; return pending }
}
// ✅ 상태를 가진 타입 안에서도 계산은 순수 함수에 위임한다
actor Cart {
private var items: [Item] = []
func add(_ item: Item) { items.append(item) }
var total: Decimal {
Calculator.total(items) // ✅ 순수 함수 재사용
}
}
// 판별 질문: "이 타입에 var 저장 프로퍼티가 있고, 여러 곳에서 동시에 접근하는가?"
// 아니오 → 그냥 함수/struct
// 예 → actor 또는 락
struct Item { let price: Decimal }
struct Order {}④ 테스트 피라미드: 순수 코어에 빠른 유닛 테스트를 대량으로, 얇은 셸에 소수의 통합 테스트를.
import Testing
import Foundation
// 순수 코어는 테스트가 값 비교라 빠르고 결정적이다 → 많이 쓴다.
// 셸은 목·환경이 필요해 느리다 → 소수만 쓴다.
// ── 코어 테스트: 대량으로, 밀리초 단위
@Test(arguments: [
(9_999, 0.0),
(10_000, 0.1),
(49_999, 0.1),
(50_000, 0.2),
(999_999, 0.2),
])
func 할인율_경계값(amount: Int, expected: Double) {
#expect(Pricing.discountRate(for: Decimal(amount)) == Decimal(expected))
}
@Test func 만료된_쿠폰은_경고를_남긴다() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let output = CheckoutCore.compute(.init(
items: [Item(price: 1000)],
coupon: Coupon(rate: 0.2, expiresAt: now.addingTimeInterval(-1)),
now: now))
#expect(output.total == 1000)
#expect(output.warnings.contains("쿠폰이 만료되었습니다"))
}
@Test func 빈_장바구니() {
let output = CheckoutCore.compute(.init(items: [], coupon: nil, now: .now))
#expect(output.total == 0)
#expect(output.warnings.count == 1)
}
// … 이런 케이스를 수십 개 넣어도 전체가 1초 안에 끝난다
// ── 셸 테스트: 소수만. '배선이 맞는가'만 확인한다.
@Test func 뷰모델이_저장소와_코어를_연결한다() async throws {
let repository = StubCartRepository(items: [Item(price: 1000)], coupon: nil)
let viewModel = await CheckoutViewModel(repository: repository,
now: { Date(timeIntervalSince1970: 0) })
try await viewModel.refresh()
#expect(await viewModel.output?.total == 1000)
}
// 계산 규칙은 이미 코어 테스트가 검증했다 — 여기서 또 검증하지 않는다
struct StubCartRepository: CartRepository {
let items: [Item]; let coupon: Coupon?
func items() async throws -> [Item] { items }
func activeCoupon() async throws -> Coupon? { coupon }
}
struct Item { let price: Decimal }
struct Coupon { let rate: Double; let expiresAt: Date }
enum Pricing { static func discountRate(for a: Decimal) -> Decimal { 0 } }
enum CheckoutCore {
struct Input { let items: [Item]; let coupon: Coupon?; let now: Date }
struct Output { let total: Decimal; let warnings: [String] }
static func compute(_ i: Input) -> Output { Output(total: 0, warnings: []) }
}
protocol CartRepository {
func items() async throws -> [Item]
func activeCoupon() async throws -> Coupon?
}
@MainActor final class CheckoutViewModel {
var output: CheckoutCore.Output?
init(repository: CartRepository, now: @escaping () -> Date) {}
func refresh() async throws {}
}"부수 효과를 없애야 한다"는 오해. 프로그램의 존재 이유가 효과(화면·저장·네트워크)다 — 목표는 제거가 아니라 격리(경계로 밀어내기)다. "actor를 쓰면 동시성은 다 해결"이라는 것도 오해다. actor는 가변 상태를 안전하게 만들 뿐, 상태를 아예 안 만드는 것보다 느리고 await 지점마다 재진입 가능성을 부른다. 공유 가변 상태를 줄이는 게 먼저이고 actor는 그다음이다.
보드게임을 한다고 하자. 규칙책은 "주사위가 4가 나왔고 말이 여기 있으면, 다음은 저기"만 알려 준다. 주사위를 대신 굴려 주지도, 말을 옮겨 주지도 않는다. 이게 순수 함수다 — 같은 상황을 백 번 물어도 답이 똑같다. 주사위를 굴리고 말을 옮기고 점수판을 지우는 건 사람 몫이고, 그게 부수 효과이자 imperative shell이다.
이렇게 나눠 두면 규칙이 맞는지 확인할 때 실제 게임판을 차릴 필요가 없다. 상황을 말로 불러 주고 답만 맞춰 보면 끝이라 테스트가 빠르고 흔들리지 않는다. 동시성이 쉬워지는 이유도 같다. 규칙책은 여러 사람이 동시에 펼쳐 봐도 아무 일이 없다 — 읽기만 하니까. 문제는 규칙책이 몰래 손목시계를 볼 때 생긴다. 로직 안에서 Date()를 부르는 게 딱 그거라서, 어제 통과하던 테스트가 오늘 자정 근처에 갑자기 깨진다. 그래서 "지금 몇 시"는 규칙책이 보는 게 아니라 밖에서 값으로 적어 넣어 준다. 비유의 한계 하나: 규칙책은 종이라 절대 안 변하지만, 코드는 실수로 전역 변수를 건드리는 순간 조용히 순수함을 잃는다. 컴파일러가 대신 지켜 주지 않으니 설계로 지켜야 한다.
꼬리 질문
"순수 함수"인데 테스트가 느리거나 간헐적으로 실패한다면 어디를 의심하겠는가?
순수 함수는 정의상 결정적이라 flaky할 수 없으므로 "순수하다는 가정이 틀렸다"를 먼저 의심한다. 함수 안 어딘가에서 Date()·UUID()·난수·전역 var·파일/DB를 몰래 건드리고 있거나(숨은 효과), Dictionary/Set 순회처럼 순서가 비결정적인 자료구조 반복에 결과가 의존하고 있을 가능성이 높다. 느림 쪽은 매 호출마다 큰 값 타입을 깊게 복사하거나 캐시 없이 재계산하는 알고리즘 문제를 의심한다.
Date()를 로직 안에서 부르면 테스트에 구체적으로 어떤 문제가 생기고, iOS 16의 Clock 타입으로는 어떻게 주입하겠는가?
Date()를 안에서 부르면 실행할 때마다 "지금"이 달라져 만료·경과시간·자정 경계 로직이 시각에 따라 통과와 실패를 오가는 flaky 테스트가 된다. Clock으로는 로직이 시각·지연을 Clock과 Instant로 주입받게 바꾸고, 테스트에선 원하는 값을 내도록 제어 가능한 fake clock을 넣는다. 다만 단순 판단 로직이라면 Clock 프로토콜까지 갈 것 없이 now: Date를 인자로 받는 편이 더 가볍고 결정적이다 — Clock은 sleep·스케줄링이 얽힌 async 흐름을 결정적으로 만들 때 값을 한다.
now: Date 하나로 충분하고, Clock은 "몇 초 기다렸다가" 같은 게 얽힌 복잡한 흐름에서나 필요하다.순수 코어를 습관적으로 actor로 감쌌다. 성능과 정확성 측면에서 각각 무슨 일이 생기는가?
정확성엔 아무 이득이 없다 — 순수 코어는 지킬 공유 가변 상태가 없으니 actor가 보장하는 불변식 자체가 존재하지 않는다. 대신 성능·복잡성엔 손해다. 모든 접근이 async가 되어 await가 호출부로 전염되고, 매 호출마다 actor executor로의 suspension hop이 생긴다. 다만 순수 코어는 내부에 await가 없어 실행이 중단되지 않으므로 재진입 자체는 일어나지 않는다 — 재진입 추론 부담은 actor 메서드가 내부에서 await를 할 때 붙는 별도 비용이다. 공유 가변 상태가 없으면 nonisolated 순수 함수로 두는 게 맞다.
await hop)만 생긴다. 지킬 공유 상태가 없으면 경비원 없이 그냥 열어 두는 게 맞다.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처럼 생성 시점을 제어 못 할 때만.
import UIKit
// 생성자 주입은 "이 타입이 무엇 없이는 못 산다"를 컴파일러가 강제하게 한다.
// ✅ 의존이 시그니처에 전부 드러난다
struct CheckoutService {
private let repository: OrderRepository
private let payment: PaymentGateway
private let clock: () -> Date
init(repository: OrderRepository,
payment: PaymentGateway,
clock: @escaping () -> Date = Date.init) {
self.repository = repository
self.payment = payment
self.clock = clock
}
func checkout(_ order: Order) async throws -> Receipt {
let receipt = try await payment.charge(order.total)
try await repository.save(order, at: clock())
return receipt
}
}
// 장점
// · 초기화 후 항상 유효한 상태 (Optional·IUO가 필요 없다)
// · 테스트에서 교체가 자명하다
// · 의존을 빠뜨리면 컴파일 에러
// ⚠️ 프로퍼티 주입은 생성 시점을 제어할 수 없을 때만
final class CheckoutViewController: UIViewController {
// 스토리보드가 init을 대신 부르므로 생성자 주입이 불가능하다
var service: CheckoutService! // 어쩔 수 없는 경우
// ✅ 코드로 만든다면 생성자 주입이 가능하다
init(service: CheckoutService) {
self.service = service
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("코드로 생성하세요") }
}
// ✅ 의존이 많아지면 그 자체가 신호다 — 타입이 너무 많은 일을 한다
// 5개를 넘어가면 쪼갤 때가 된 것이다
protocol OrderRepository { func save(_ o: Order, at date: Date) async throws }
protocol PaymentGateway { func charge(_ amount: Decimal) async throws -> Receipt }
struct Order { let total: Decimal }
struct Receipt {}② 추상(프로토콜)은 사용하는 고수준이 소유·정의하고 "필요한 최소 인터페이스"만 담는다(ISP). 구현 모듈이 인터페이스 모듈을 의존한다.
import Foundation
// ── Domain 모듈 (고수준 정책) — 여기가 추상의 소유자다
// "내가 필요한 건 이것뿐"을 정의한다
/// ✅ 고수준이 필요로 하는 최소 인터페이스만 (ISP)
public protocol OrderPersisting: Sendable {
func save(_ order: Order) async throws
func find(id: Order.ID) async throws -> Order?
}
public struct CheckoutService {
private let persistence: OrderPersisting // ✅ Domain이 정의한 추상에 의존
public init(persistence: OrderPersisting) { self.persistence = persistence }
public func checkout(_ order: Order) async throws {
try await persistence.save(order)
}
}
// ── Infrastructure 모듈 (저수준 세부) — Domain을 import 한다
// 의존 방향이 뒤집혔다: 저수준 → 고수준
struct CoreDataOrderStore: OrderPersisting {
func save(_ order: Order) async throws { }
func find(id: Order.ID) async throws -> Order? { nil }
}
struct APIOrderStore: OrderPersisting {
func save(_ order: Order) async throws { }
func find(id: Order.ID) async throws -> Order? { nil }
}
// ❌ 저수준이 인터페이스를 소유하면 역전이 아니다
// (Domain이 Networking·CoreData를 import하게 된다)
// protocol NetworkClient { ... } ← Networking 모듈에 있고
// struct CheckoutService { let client: NetworkClient } ← Domain이 그걸 의존
// ❌ 인터페이스가 구현에 맞춰져 있어도 역전이 아니다 — 이름만 프로토콜이다
protocol BadOrderStore {
func executeSQL(_ query: String) throws -> [[String: Any]] // 💥 SQL이 새어 나온다
var managedObjectContext: AnyObject { get } // 💥 Core Data가 새어 나온다
}
// ✅ 핵심 판별: "이 프로토콜을 보고 구현 기술을 알 수 있는가?"
// 알 수 있으면 추상화가 실패한 것이다.
public struct Order: Identifiable, Sendable {
public let id: UUID
}③ Service Locator·전역 DI 컨테이너 남용 금지 — 의존이 시그니처에 안 드러나 숨은 결합과 테스트 어려움을 만든다.
import Foundation
// 컨테이너에서 꺼내 쓰면 의존이 시그니처에 드러나지 않는다.
// 컴파일러가 도와줄 수 없고, 런타임에야 누락을 알게 된다.
// ❌ Service Locator — 무엇이 필요한지 밖에서 알 수 없다
final class BadCheckoutService {
func checkout(_ order: Order) async throws {
let repo = Container.shared.resolve(OrderRepository.self)! // 💥 런타임 크래시 가능
let payment = Container.shared.resolve(PaymentGateway.self)!
_ = try await payment.charge(order.total)
try await repo.save(order)
}
}
/* 문제
· 이 타입을 쓰려면 무엇을 등록해야 하는지 코드를 읽어야 안다
· 테스트에서 전역 컨테이너를 바꿔야 해서 테스트끼리 간섭한다
· 등록을 빠뜨리면 런타임에 크래시한다
· 의존 그래프가 코드에 안 드러나 리팩터링이 어렵다 */
// ✅ 생성자 주입 — 컴파일러가 검증한다
struct CheckoutService {
let repository: OrderRepository
let payment: PaymentGateway
func checkout(_ order: Order) async throws {
_ = try await payment.charge(order.total)
try await repository.save(order)
}
}
// ✅ 조립은 앱 진입점(Composition Root) 한 곳에서만 한다
@MainActor
enum CompositionRoot {
static func makeCheckoutScreen() -> CheckoutViewController {
let repository = CoreDataOrderRepository()
let payment = StripeGateway(apiClient: makeAPIClient())
let service = CheckoutService(repository: repository, payment: payment)
return CheckoutViewController(service: service)
}
private static func makeAPIClient() -> APIClient { APIClient() }
}
// 의존 그래프 전체가 이 한 파일에 보인다 — 읽으면 구조가 이해된다
// ⚠️ DI 컨테이너가 항상 나쁜 건 아니다. 모듈이 아주 많아 수동 조립이
// 관리 불가능해질 때는 도구가 필요하다. 다만 그때도 '조립은 한 곳에서'를 지킨다.
protocol OrderRepository { func save(_ o: Order) async throws }
protocol PaymentGateway { func charge(_ a: Decimal) async throws -> Receipt }
struct Order { let total: Decimal }
struct Receipt {}
struct CoreDataOrderRepository: OrderRepository { func save(_ o: Order) async throws {} }
struct StripeGateway: PaymentGateway {
let apiClient: APIClient
func charge(_ a: Decimal) async throws -> Receipt { Receipt() }
}
final class APIClient {}
enum Container { static let shared = ContainerImpl() }
struct ContainerImpl { func resolve<T>(_ t: T.Type) -> T? { nil } }
@MainActor final class CheckoutViewController {
init(service: CheckoutService) {}
}④ 모든 것에 프로토콜을 만들지 않는다. 구현이 하나뿐이고 교체·테스트 대체가 필요 없으면 구체 타입을 그대로 주입해도 된다(Q5로 이어진다).
import Foundation
// 구현이 하나뿐이고 교체·테스트 대체가 필요 없으면 프로토콜은 순손해다.
// 점프가 한 겹 늘고, 정의를 찾아 파일을 오가야 한다.
// ❌ 구현이 영원히 하나인데 만든 프로토콜
protocol DateFormatting {
func string(from date: Date) -> String
}
struct AppDateFormatter: DateFormatting {
func string(from date: Date) -> String { "" }
}
// 이 프로토콜이 존재해서 얻는 것: 없다
// 잃는 것: 파일 하나, 점프 한 겹, "구현체가 여럿인가?" 하는 혼란
// ✅ 구체 타입을 그대로 주입한다
struct ReceiptRenderer {
let formatter: AppDateFormatter // 구체 타입
func render(_ receipt: Receipt) -> String { "" }
}
// ✅ 프로토콜이 정당한 경우
// ① 실제로 구현이 여럿이다
protocol PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt
}
struct StripeGateway: PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt { Receipt() }
}
struct TossGateway: PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt { Receipt() }
}
// ② 테스트에서 반드시 대체해야 한다 (네트워크·시계·파일)
protocol OrderRepository {
func save(_ order: Order) async throws
}
// ③ 모듈 경계에서 의존 방향을 뒤집어야 한다 (DIP)
// ✅ 순수 함수는 프로토콜 없이도 테스트된다 — 대체할 게 없기 때문이다
enum Pricing {
static func total(_ items: [Item]) -> Decimal {
items.reduce(0) { $0 + $1.price }
}
}
func test_합계() {
assert(Pricing.total([Item(price: 100), Item(price: 200)]) == 300)
// 목도, 프로토콜도, 주입도 필요 없다
}
// 판별: "이 프로토콜의 구현체를 지금 두 개 이상 쓸 수 있는가?"
// 아니오이고 테스트 대체도 불필요 → 구체 타입
struct Receipt {}; struct Order {}; struct Item { let price: Decimal }"DI 컨테이너(Swinject 등)를 쓰면 곧 DIP"라는 오해. 컨테이너는 DI의 배선을 자동화하는 도구일 뿐이고, 구체 타입을 등록·주입하면 역전이 아니다. 반대로 "프로토콜만 만들면 DIP"도 오해다. 추상을 고수준이 아니라 저수준이 소유해서 구현 세부에 맞춘 프로토콜을 만들면, 소스 의존 방향이 그대로라 이름만 바꾼 결합이지 역전이 아니다. 역전의 핵심은 추상의 소유권이 정책(고수준) 쪽에 있는가이다.
집 벽에 붙은 콘센트를 생각해 보자. 집이 먼저 "우리 집엔 이 모양 구멍만 있다"고 정해 둔다. 그러면 청소기든 선풍기든 만드는 쪽이 그 구멍에 맞춰 플러그를 단다. 규격을 정한 집은 어떤 기기가 꽂힐지 전혀 모른다. 이게 Dependency Inversion(DIP)이다. 전기는 벽에서 기기로 흐르는데, "맞춰 주는" 방향은 반대로 기기에서 벽으로 간다. 그래서 뒤집혔다(inversion)고 부른다.
Dependency Injection(DI)은 훨씬 소박한 얘기다 — 오늘 저녁에 실제로 선풍기를 꽂는 그 행동. 여기서 사람들이 제일 많이 헷갈리는 게 "프로토콜만 만들면 DIP"라는 생각인데, 청소기 회사가 자기 전용 규격을 만들고 집이 거기 맞춰 벽을 뚫었다면 그건 역전이 아니라 이름만 예쁜 종속이다. 구멍 모양을 누가 정했느냐, 그게 전부다. 비유가 살짝 어긋나는 곳도 짚어 두자. 콘센트는 규격이 나라 단위로 고정돼 있지만, 코드의 프로토콜은 고수준 모듈이 "지금 내게 필요한 최소한"만 담아 직접 깎아 쓰는 게 맞다.
꼬리 질문
생성자 주입을 쓰다 보니 한 타입의 의존이 예닐곱 개를 넘어가기 시작했다. 이건 무슨 설계 냄새이고, 프로토콜을 더 잘게 쪼개는 것 vs 협력자들을 하나로 묶는 파사드/애그리게이트 타입을 도입하는 것 중 무엇으로 대응하겠는가?
생성자 인자가 불어나는 건 대개 그 타입이 서로 다른 이유로 바뀌는 여러 책임을 떠안고 있다는 응집 저하 신호다(constructor over-injection = SRP 위반의 증상). 그래서 먼저 물을 것은 "이 협력자들이 하나의 책임에 봉사하는가, 아니면 두세 덩어리로 갈리는가"다 — 갈린다면 타입 자체를 그 축으로 쪼개는 게 근본 처방이고, 프로토콜을 더 잘게 나누는 건 오히려 주입할 인자 수만 늘려 냄새를 악화시킨다. 반대로 책임은 하나인데 저수준 협력자가 늘 함께 다니는 한 덩어리라면(예: 네트워크·캐시·직렬화가 한 repository를 이루는 경우) 그것들을 파사드/애그리게이트로 묶어 고수준이 seam 하나에만 의존하게 하는 편이 낫다. 즉 판단축은 "인자 수를 줄이는 것"이 아니라 "변경 이유가 몇 개인가"다.
프로토콜을 고수준이 소유할 때와 저수준이 소유할 때, 소스 의존 방향은 각각 어떻게 되는가?
고수준이 소유하면(추상을 정책 모듈에 두면) 저수준 구현이 그 추상을 import·구현하므로 소스 의존이 저수준→고수준으로 흘러, 제어 흐름(고수준이 호출)과 반대로 뒤집힌다 — 이게 진짜 DIP다. 저수준이 소유하면(구현 세부에 맞춘 프로토콜을 저수준 모듈에 두면) 고수준이 그걸 import하므로 소스 의존이 고수준→저수준으로 그대로 흘러, 이름만 추상일 뿐 역전이 아니다. 핵심은 프로토콜의 존재가 아니라 소유권이 정책 쪽에 있는가다.
Service Locator와 생성자 주입은 둘 다 DI인데, 테스트 용이성과 가독성에서 무엇이 갈리는가?
둘 다 인스턴스를 밖에서 얻지만, 생성자 주입은 의존이 init 시그니처에 드러나 "이 타입이 무엇을 필요로 하는지"가 타입만 봐도 보이고 테스트에선 목을 그냥 넣으면 된다. Service Locator는 객체가 전역 컨테이너에서 스스로 꺼내므로 의존이 시그니처에 숨고, 테스트마다 컨테이너 상태를 세팅·정리해야 하며 등록 누락·초기화 순서 문제가 런타임에야 터진다. 가독성·컴파일타임 안전성·테스트 격리 모두 생성자 주입이 낫고, Service Locator는 생성 시점을 제어 못 하는 프레임워크 진입점 정도로 제한하는 게 좋다.
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로 격리한다.
import Foundation
// 공유 가변 상태의 네 조건(공유·가변·동시·쓰기) 중 하나만 끊어도 문제가 사라진다.
// 가장 싼 방법은 '가변'을 끊는 것이다.
// ✅ 값 타입 + let — 애초에 공유도 변경도 없다
struct Order: Sendable, Equatable {
let id: UUID
let items: [Item]
let createdAt: Date
// 변경은 '새 값을 만드는' 방식으로
func adding(_ item: Item) -> Order {
Order(id: id, items: items + [item], createdAt: createdAt)
}
}
struct Item: Sendable, Equatable {
let sku: String
let quantity: Int
}
// ✅ 상태 전이를 enum으로 표현하면 유효하지 않은 조합이 아예 없다
enum LoadState: Sendable, Equatable {
case idle
case loading
case loaded([Item])
case failed(String)
}
// ❌ 이렇게 두면 isLoading=true인데 error도 있는 상태가 표현 가능하다
// struct BadState { var isLoading: Bool; var items: [Item]; var error: String? }
// ✅ 참조 타입이 필요하면 불변으로 만들어 공유를 안전하게 한다
final class Configuration: Sendable {
let baseURL: URL
let timeout: TimeInterval
init(baseURL: URL, timeout: TimeInterval) {
self.baseURL = baseURL
self.timeout = timeout
}
}
// ✅ 스냅샷도 그냥 대입이다
func snapshotting(_ order: Order) {
let before = order // 복사
let after = order.adding(Item(sku: "X", quantity: 1))
assert(before != after) // 원본은 그대로
}② 없앨 수 없는 공유 상태는 소유자를 하나로 좁혀(single source of truth) actor·@MainActor로 격리한다.
import Foundation
import Combine
// 없앨 수 없는 공유 상태는 '한 곳'이 소유하고 나머지는 그곳을 통해서만 접근한다.
// ❌ 여러 곳이 각자 상태를 들고 동기화하려 한다
final class BadCartA { var items: [Item] = [] }
final class BadCartB { var items: [Item] = [] } // 💥 어느 게 진실인가?
// ✅ single source of truth — 소유자는 하나
actor CartStore {
private var items: [Item] = []
private var continuations: [UUID: AsyncStream<[Item]>.Continuation] = [:]
func add(_ item: Item) {
items.append(item)
broadcast()
}
func remove(sku: String) {
items.removeAll { $0.sku == sku }
broadcast()
}
var snapshot: [Item] { items }
/// 관찰자는 스트림으로 변경을 받는다 — 각자 사본을 유지하지 않는다
func observe() -> AsyncStream<[Item]> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.yield(items)
continuation.onTermination = { _ in
Task { await self.removeObserver(id) }
}
}
}
private func removeObserver(_ id: UUID) { continuations[id] = nil }
private func broadcast() { continuations.values.forEach { $0.yield(items) } }
}
// ✅ UI 상태는 @MainActor로 격리한다
@MainActor
final class CartViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
private let store: CartStore
init(store: CartStore) {
self.store = store
// ⚠️ self를 강하게 캡처하고 스트림이 끝나지 않으면 ViewModel이 해제되지 않는다.
// 태스크를 보관해 두고 deinit에서 취소해야 한다.
observation = Task { [weak self] in
for await snapshot in await store.observe() { self?.items = snapshot }
}
}
private var observation: Task<Void, Never>?
deinit { observation?.cancel() }
}
struct Item: Sendable { let sku: String }③ read-modify-write를 여러 스텝으로 쪼개지 말고 원자적 단위(actor 메서드 하나, withLock 블록) 안에서 끝낸다.
import Foundation
import os
// 락을 쥔 채 남의 코드를 부르면 그쪽이 무엇을 할지 알 수 없다.
// 다른 락을 잡으면 교착이고, 우선순위가 낮으면 역전이다.
final class MetricsStore {
private let state = OSAllocatedUnfairLock(initialState: [String: Int]())
weak var delegate: MetricsDelegate?
// ❌ 락 안에서 외부 콜백
func recordBad(_ key: String) {
state.withLock { dict in
dict[key, default: 0] += 1
self.delegate?.didUpdate() // 💥 이 안에서 무엇을 할지 모른다
}
}
// ✅ 락 안에서는 순수 계산만, 부수효과는 밖에서
func record(_ key: String) {
let snapshot: [String: Int] = state.withLock { dict in
dict[key, default: 0] += 1
return dict // 필요한 값만 복사해 나온다
}
delegate?.didUpdate(snapshot) // ✅ 락을 놓은 뒤
}
}
// actor도 같은 원리다 — await 지점이 재진입 창을 연다
actor OrderProcessor {
private var stock: [String: Int] = [:]
// ❌ 상태 확인과 변경 사이에 await
func processBad(_ order: Order) async throws {
guard stock[order.sku, default: 0] >= order.quantity else { throw ShopError.soldOut }
await payment.charge(order) // ⚠️ 여기서 다른 호출이 끼어든다
stock[order.sku]! -= order.quantity // 이미 다 팔렸을 수 있다
}
// ✅ 상태 변경은 await 없는 구간에서 원자적으로
func reserve(_ order: Order) throws {
let left = stock[order.sku, default: 0]
guard left >= order.quantity else { throw ShopError.soldOut }
stock[order.sku] = left - order.quantity // await 없음 → 끼어들 틈 없음
}
func release(_ order: Order) { stock[order.sku, default: 0] += order.quantity }
}
// 긴 흐름은 actor 밖에서 조립하고, 실패하면 보상한다
func placeOrder(_ order: Order, in processor: OrderProcessor) async throws {
try await processor.reserve(order)
do { try await payment.charge(order) }
catch { await processor.release(order); throw error }
}
protocol MetricsDelegate: AnyObject {
func didUpdate()
func didUpdate(_ snapshot: [String: Int])
}
struct Order { let sku: String; let quantity: Int }
enum ShopError: Error { case soldOut }
enum payment { static func charge(_ o: Order) async throws {} }④ 전역 static var 가변 상태를 피한다. Swift 6 strict concurrency로 컴파일타임에 잡히게 한다.
import Foundation
// 전역 가변 상태는 어디서 바뀌는지 추적할 수 없고 동시성 안전하지도 않다.
// Swift 6 strict concurrency는 이걸 컴파일 타임에 잡아 준다.
// ❌ Swift 6에서 컴파일 에러가 난다
// error: static property 'pendingEvents' is not concurrency-safe because
// it is nonisolated global shared mutable state
// final class BadAnalytics {
// static var pendingEvents: [Event] = []
// }
// ✅ ① 불변으로 만든다 (가장 단순)
enum Config {
static let apiBaseURL = URL(string: "https://api.example.com")!
static let timeout: TimeInterval = 30
}
// ✅ ② 가변이 필요하면 actor로 격리한다
actor EventQueue {
static let shared = EventQueue()
private var pending: [Event] = []
func enqueue(_ event: Event) { pending.append(event) }
func drain(upTo limit: Int) -> [Event] {
let batch = Array(pending.prefix(limit))
pending.removeFirst(batch.count)
return batch
}
}
// ✅ ③ UI 관련이면 @MainActor
@MainActor
final class AppRouter {
static let shared = AppRouter()
private(set) var currentScreen: Screen = .home
func navigate(to screen: Screen) { currentScreen = screen }
}
// ✅ ④ 정말 락으로 해야 하면 근거를 남긴 @unchecked
import os
/// 스레드 안전성: 모든 접근이 `state` 락을 통과한다. 아래가 유일한 진입점이다.
final class Counter: @unchecked Sendable {
static let shared = Counter()
private let state = OSAllocatedUnfairLock(initialState: 0)
func increment() { state.withLock { $0 += 1 } }
func value() -> Int { state.withLock { $0 } }
}
/* ✅ strict concurrency 켜기
Package.swift:
swiftSettings: [.enableUpcomingFeature("StrictConcurrency")]
Xcode:
Build Settings → Swift Compiler - Concurrency
→ Strict Concurrency Checking → Complete */
struct Event: Sendable {}
enum Screen { case home, detail }"락만 잘 걸면 동시성 문제는 끝"이라는 오해. 락은 data race는 막아도 race condition(순서 의존 논리 버그)·deadlock·우선순위 역전·성능 저하를 새로 만든다. "actor면 무조건 안전"도 오해다 — actor는 개별 연산의 원자성만 보장하고, await를 사이에 두면 재진입으로 상태가 바뀌어 "확인 후 사용" 불변식이 깨진다. 그래서 await 뒤에서는 앞서 확인한 조건을 다시 확인해야 한다.
화이트보드에 "3"이라고 적혀 있고, 두 사람이 각자 1씩 더하기로 했다. 둘이 거의 동시에 보드를 보고 "3"을 읽고, 각자 머릿속에서 4를 만들고, 각자 4를 적는다. 5가 나와야 하는데 4다. 한 사람의 일이 통째로 사라졌다. 범인은 "읽고 → 더하고 → 쓰고"가 한 동작이 아니라 세 동작이라서, 그 틈으로 남이 들어올 수 있다는 점이다.
끊는 방법은 셋 중 아무거나 하나면 된다.
- 보드를 안 나눠 갖는다 — 각자 자기 종이에 쓴다(값 복사).
- 보드를 아예 안 고친다 — 읽기만 한다(불변).
- 보드 앞에 한 번에 한 명만 서게 한다 — 이게 actor나 락이다.
마지막 방법에는 함정이 하나 숨어 있다. 보드 앞에 서서 "잔액이 충분하네" 확인했는데 잠깐 전화를 받으러 자리를 뜨면(이게 await다), 그 틈에 다음 사람이 들어와 숫자를 바꿔 놓는다. 돌아온 나는 아까 본 숫자를 그대로 믿으면 안 된다. actor는 "한 명씩"만 보장하지, "내가 나갔다 올 동안 아무도 못 건드림"까지는 보장하지 않기 때문이다.
꼬리 질문
data race와 race condition의 차이를 예로 들어 보라. 락으로도 못 막는 race condition은?
data race는 동기화 없이 같은 메모리를 동시 접근하며 최소 하나가 쓰기인 상태(두 스레드가 같은 var counter를 증가)로, 대부분의 메모리 모델에서 정의되지 않은 동작이다. race condition은 각 접근이 원자적이어도 순서·타이밍에 결과가 갈리는 논리 버그다. 락으로도 못 막는 예는 check-then-act다 — if !exists { create() }에서 확인과 생성을 각각 락으로 원자화해도, 두 흐름이 순차로 "없음"을 확인한 뒤 둘 다 생성하면 중복이 만들어진다. 원자성 단위를 확인+행동 전체로 넓히거나(compare-and-set) 애초에 공유를 없애야 한다.
actor 메서드 안에서 await 뒤에 앞서 확인한 상태를 다시 확인해야 하는 이유는?
actor는 개별 메서드 호출의 원자성만 보장하고, await로 실행이 중단된 사이 다른 호출이 그 actor에 진입해 상태를 바꿀 수 있기 때문이다(reentrancy). 그래서 await 앞에서 확인한 조건(balance >= n)이 뒤에서는 더 이상 참이 아닐 수 있고, 재확인 없이 그 값을 쓰면 잔액이 음수가 되는 식으로 불변식이 깨진다. 규칙은 불변식에 의존하는 결정과 그에 따른 상태 변경 사이에 await를 두지 않거나, 부득이하면 뒤에서 반드시 재검증하는 것이다.
await) 아무도 못 건드리게까지는 안 해 준다. 그러니 나갔다 돌아오면 아까 본 잔액을 그대로 믿지 말고, 다시 한 번 확인하고 나서 돈을 빼야 한다.값 타입 struct는 경계를 넘으면 복사된다는데, 그런 struct가 오히려 공유 가변 상태가 되어 버리는 경우가 있는가? Sendable 관점에서 컴파일러는 그런 struct를 어떻게 다루는가?
있다. struct를 복사할 때 복사되는 건 저장 프로퍼티의 값이지 그 값이 가리키는 대상이 아니어서, struct가 클래스 참조를 품고 있으면 두 복사본이 같은 힙 객체를 함께 가리킨다 — 값 타입의 껍데기 안으로 참조 시맨틱이 새어 나와, 한쪽에서 그 객체를 바꾸면 다른 쪽에도 보이는 공유 가변 상태가 된다(가변 상태를 캡처한 클로저를 담아도 마찬가지). Sendable은 이걸 정확히 겨냥한다: struct는 저장 프로퍼티가 전부 Sendable일 때만 암묵적으로 Sendable로 인정되므로, non-Sendable 클래스를 품은 struct는 Sendable이 아니고 strict concurrency에서 actor/Task 경계를 넘기려 하면 컴파일러가 거부한다. 여기서 @unchecked Sendable로 경고를 눌러 버리면 검사만 무력화한 채 그 공유 가변 상태를 그대로 경계 밖으로 내보내 data race를 되살리는 셈이다.
Sendable 검사는 이런 struct를 스레드 경계 밖으로 못 나가게 막아 주는데, @unchecked Sendable로 그 검사를 꺼 버리면 위험을 그대로 통과시키는 것이다.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) -> String { /* ... */ }
}
// 순수 함수라 목 없이도 테스트가 결정적 (Q2와 같은 원리)프로젝트 적용
① rule of three: 세 번 반복되기 전엔 추상화하지 않는다. 미래 대비용 추상화(YAGNI)를 만들지 않는다.
import Foundation
// 두 번의 유사함은 우연일 때가 많다(coincidental duplication).
// 세 번째에서야 '변화 축'이 보인다.
// 1회차 — 그냥 쓴다
func loadProfile() async throws -> Profile {
var request = URLRequest(url: URL(string: "https://api.example.com/profile")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Profile.self, from: data)
}
// 2회차 — 비슷해 보이지만 아직 추상화하지 않는다
func loadFeed() async throws -> [Post] {
var request = URLRequest(url: URL(string: "https://api.example.com/feed")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode([Post].self, from: data)
}
// 3회차 — 이제 공통 축이 보인다: (경로, 응답 타입)
// 그리고 세 번을 보니 헤더·디코딩 전략도 늘 같다는 걸 알겠다
struct APIClient {
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
func get<T: Decodable>(_ path: String, as type: T.Type) async throws -> T {
var request = URLRequest(url: baseURL.appending(path: path))
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
try validate(response)
return try decoder.decode(T.self, from: data)
}
private func validate(_ response: URLResponse) throws {}
}
// 사용
func loadAll(_ client: APIClient) async throws {
let profile = try await client.get("/profile", as: Profile.self)
let feed = try await client.get("/feed", as: [Post].self)
let orders = try await client.get("/orders", as: [Order].self)
_ = (profile, feed, orders)
}
// ⚠️ 2회차에서 뽑았다면 '경로만 다른 함수'를 만들었을 것이고,
// 3회차에서 헤더가 다른 요청이 나타나면 플래그를 덧대게 됐을 것이다.
struct Profile: Decodable {}; struct Post: Decodable {}; struct Order: Decodable {}② 프로토콜은 실제 교체·다형성·테스트 대체 필요가 있을 때만. 구현 하나 + 대체 불필요면 구체 타입.
import Foundation
// 프로토콜은 공짜가 아니다. 정의 파일이 하나 늘고, 구현을 찾으려면
// "이 프로토콜을 준수하는 게 뭐지?"를 매번 되물어야 한다.
// ❌ 구현이 하나뿐이고 앞으로도 하나일 것
protocol StringTrimming {
func trim(_ s: String) -> String
}
struct DefaultTrimmer: StringTrimming {
func trim(_ s: String) -> String { s.trimmingCharacters(in: .whitespaces) }
}
// 얻는 것: 없음. 잃는 것: 파일·점프·간접 디스패치.
// ✅ 그냥 함수나 구체 타입
enum StringUtil {
static func trim(_ s: String) -> String {
s.trimmingCharacters(in: .whitespaces)
}
}
// ✅ 프로토콜이 정당한 세 경우
// ① 구현이 실제로 여럿이다
protocol PaymentGateway: Sendable {
func charge(_ amount: Decimal) async throws -> Receipt
}
struct StripeGateway: PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt { Receipt() }
}
struct TossGateway: PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt { Receipt() }
}
// ② 테스트에서 반드시 대체해야 한다 (네트워크·시계·파일·난수처럼 통제 불가한 것)
protocol OrderRepository: Sendable {
func save(_ order: Order) async throws
}
// non-mutating 요구사항이라 struct로는 기록할 수 없다 — actor로 둔다
actor SpyOrderRepository: OrderRepository {
private(set) var saved: [Order] = []
func save(_ order: Order) async throws { }
}
// ③ 모듈 경계에서 의존 방향을 뒤집어야 한다 (DIP)
// ✅ 순수 함수는 프로토콜 없이도 완벽히 테스트된다 — 대체할 게 없기 때문이다
func test_트림() {
assert(StringUtil.trim(" a ") == "a") // 목도 주입도 필요 없다
}
// 판별: "지금 이 프로토콜의 구현체를 둘 이상 만들 수 있는가?
// 테스트에서 대체해야 하는가?" — 둘 다 아니면 구체 타입.
struct Receipt {}; struct Order {}③ 추상화가 감추는 게 "바뀌지 않는 것"이면 그건 손해다 — 감춤의 대상은 반드시 변화 축이어야 한다.
import Foundation
// 좋은 추상화는 '변화 축'을 감춘다. 변하지 않는 것을 감추면 손해만 남는다.
// ❌ 변하지 않는 것을 감춘 추상화
protocol StringReversing {
func reverse(_ s: String) -> String
}
struct DefaultStringReverser: StringReversing {
func reverse(_ s: String) -> String { String(s.reversed()) }
}
// 문자열 뒤집기는 앞으로도 바뀌지 않는다.
// 얻는 것: 없음. 잃는 것: 파일·점프·프로토콜 준수 확인 비용.
// ✅ 실제로 변하는 축을 감춘 추상화
protocol PaymentGateway {
func charge(_ amount: Decimal) async throws -> Receipt
}
// 결제사는 실제로 바뀐다 (Stripe → Toss → 자체 PG)
// 국가별로 다르고, A/B 테스트도 한다 → 변화 축이 명확하다
// ✅ 판별 질문 셋
// ① 이 추상화의 구현체가 지금 둘 이상인가?
// ② 앞으로 1년 안에 바뀔 구체적 계획이 있는가?
// ③ 테스트에서 반드시 대체해야 하는가? (네트워크·시계·파일·난수)
// → 셋 다 아니오면 구체 타입이 맞다
// ✅ 변화 축을 찾는 실전 방법: git 히스토리를 본다
// git log --follow --oneline Sources/Payment/
// → 자주 바뀌는 곳이 진짜 변화 축이다
// ⚠️ "혹시 나중에 필요할까 봐"는 변화 축의 근거가 아니다.
// 실제로 필요해졌을 때 뽑는 비용이, 지금 잘못 뽑는 비용보다 훨씬 싸다.
struct Receipt {}④ 잘못 든 추상화는 플래그·파라미터로 덧대지 말고 되돌려(inline) 다시 뽑는다.
import Foundation
// 파라미터가 계속 늘어나는 것은 "추상화가 틀렸다"는 가장 확실한 신호다.
// ❌ 처음엔 깔끔했던 함수가 이렇게 변해 간다
func renderBad(_ item: Item,
compact: Bool = false,
showsBadge: Bool = true,
legacyLayout: Bool = false,
isHighlighted: Bool = false,
forExport: Bool = false) -> View {
// 내부는 if의 미로가 되어 있다
View()
}
// 조합 2⁵ = 32가지. 그중 실제로 쓰는 건 4가지쯤이고,
// 어떤 조합이 유효한지 아무도 모른다.
// ✅ ① 먼저 호출부에 인라인해 되돌린다
// 각 호출부가 실제로 무엇을 하는지 드러난다
func renderCompactRow(_ item: Item) -> View { View() }
func renderDetailHeader(_ item: Item) -> View { View() }
func renderExportCell(_ item: Item) -> View { View() }
// ✅ ② 되돌리고 나서 보면 진짜 축이 보인다
// "compact/legacy/export"는 플래그가 아니라 '서로 다른 렌더러'였다
protocol ItemRenderer {
func render(_ item: Item) -> View
}
struct CompactRenderer: ItemRenderer { func render(_ item: Item) -> View { View() } }
struct DetailRenderer: ItemRenderer { func render(_ item: Item) -> View { View() } }
struct ExportRenderer: ItemRenderer { func render(_ item: Item) -> View { View() } }
// ⚠️ 되돌리기가 두려워 플래그를 계속 덧대면 부채가 복리로 쌓인다.
// 인라인 → 관찰 → 재추출은 정상적인 리팩터링 사이클이다.
// ✅ 되돌리기를 안전하게 하는 조건: 테스트가 있어야 한다
// 테스트가 동작을 고정해 주면 구조를 자유롭게 바꿀 수 있다
// ⚠️ 경계는 적게·안정적으로 둔다.
// 얕고 넓게 여러 겹을 쌓는 것보다, 잘 고른 경계 하나가 낫다.
struct Item {}; struct View {}⑤ 얕고 넓게 여러 겹보다, 경계는 적게·안정적으로 둔다.
import Foundation
import Combine
// 레이어를 늘릴수록 한 변경이 지나가야 하는 관문이 늘어난다.
// ❌ 과도한 레이어링 — 필드 하나 추가에 파일 6개를 고쳐야 한다
// ViewController → Presenter → Interactor → UseCase
// → Repository → DataSource → APIClient
// 각 층마다 DTO 변환이 있으면 그것도 6번이다
// ✅ 경계는 '변경 이유가 다른 곳'에만 둔다
// 실무에서 대부분의 앱에 필요한 경계는 셋 정도다:
// ① UI ↔ 상태
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var state: LoadState = .idle
private let repository: FeedRepository
init(repository: FeedRepository) { self.repository = repository }
func load() async {
state = .loading
do { state = .loaded(try await repository.items()) }
catch { state = .failed(error.localizedDescription) }
}
}
// ② 상태 ↔ 데이터 (여기서 네트워크·DB가 감춰진다)
protocol FeedRepository: Sendable {
func items() async throws -> [Item]
}
// ③ 순수 계산 (경계가 아니라 그냥 함수)
enum FeedRules {
static func visible(_ items: [Item], blocked: Set<String>) -> [Item] {
items.filter { !blocked.contains($0.authorID) }
}
}
// ✅ 구현
struct RemoteFeedRepository: FeedRepository {
let client: APIClient
func items() async throws -> [Item] { try await client.get("/feed") }
}
// ⚠️ "Clean Architecture니까 레이어가 많아야 한다"는 오해다.
// 그 책의 목적도 레이어 개수가 아니라 '변경 이유가 다른 것의 분리'다.
// 레이어가 변경 이유를 나누지 못하면 그건 clean이 아니라 잡음이다.
// ✅ 판별: "이 층을 없애면 무엇이 어려워지는가?"
// 답이 안 나오면 없애도 된다.
enum LoadState { case idle, loading, loaded([Item]), failed(String) }
struct Item: Sendable, Decodable { let authorID: String }
struct APIClient: Sendable {
func get<T: Decodable>(_ path: String) async throws -> T {
fatalError("예시")
}
}"추상화·레이어가 많을수록 깨끗한 아키텍처"라는 오해. Clean Architecture조차 목적은 레이어 개수가 아니라 "변경 이유가 다른 것의 분리"다 — 불필요한 간접은 clean이 아니라 잡음이다. "DRY = 코드 중복 0"도 오해다. 우연히 같아 보이는(coincidental) 코드를 합치면 잘못된 추상화가 된다. DRY가 제거하려는 건 코드 형태의 일치가 아니라 지식(knowledge)의 중복이다.
리모컨은 훌륭한 추상화다. TV 안 회로를 몰라도 전원 버튼 하나면 되니까. 그런데 TV를 켜려고 리모컨 A로 리모컨 B를 켜고, B로 다시 C를 켜야 한다면? 감춰 준 건 하나도 늘지 않았는데 손만 세 번 더 움직인 셈이다. 코드에서 층이 하나 늘어난다는 건 딱 이것이다 — 읽는 사람이 파일을 한 번 더 열어야 한다.
그래서 판단은 의외로 단순하다. 이 층이 감춰 주는 게 앞으로 진짜 바뀌느냐만 물으면 된다. TV 회사가 안쪽 칩을 통째로 바꿔도 리모컨 버튼은 그대로니까 리모컨은 값을 한다. 반대로 평생 하나뿐일 게 뻔한 걸 감싸면 감춘 것 없이 문만 하나 더 만든 꼴이다. 하나 더 조심할 것 — 비슷하게 생긴 코드 두 개를 급히 합치는 건 여름옷과 겨울옷이 닮았다고 한 벌로 꿰맨 다음, 계절이 바뀔 때마다 지퍼와 단추를 덧대는 일이 된다. 옷은 뜯어서 되돌리기라도 쉽지만 잘못 든 추상화는 이미 여러 곳이 그걸 쓰고 있어서 되돌리는 값이 비싸다. "중복이 잘못된 추상화보다 싸다"는 말은 그래서 나온다.
꼬리 질문
"테스트 때문에" 구현이 하나뿐인 프로토콜을 만들자는 제안을 받았다. 어떤 기준으로 수용·거절하겠는가?
수용 기준은 "그 seam이 실제 값을 하는가"다. 이 협력자가 네트워크·디스크·시계 같은 효과 경계라 테스트에서 반드시 대체해야 하거나 곧 다른 구현이 실제로 생길 축이면 만든다. 거절: 구현이 하나뿐이고 순수하거나 결정적이면 구체 타입으로도 테스트되니 프로토콜은 간접 비용만 남긴다. 대안으로 시계·의존을 인자로 주입하는 더 가벼운 seam(now: Date 주입 등)을 제시한다.
거의 같아 보이는 코드 두 곳을 발견했다. 합칠지 둘지 결정하는 기준은 무엇인가?
형태가 같은지가 아니라 "표현하는 지식이 같은지"로 판단한다. 둘이 같은 규칙(동일한 세금 계산 등)을 표현하고 앞으로도 항상 함께 바뀔 운명이면 합치고, 우연히 지금만 닮았을 뿐 서로 다른 이유로 바뀔 것이면 둘로 둔다. rule of three로 세 번째 반복까지 변화 방향을 관찰한 뒤 뽑는 게 안전하며, 합쳤다가 요구가 갈라져 플래그·파라미터를 덧대기 시작하면 잘못된 추상화 신호이므로 되돌린다 — 중복이 잘못된 추상화보다 싸다.
any P(존재 타입)와 some P(오파크 타입)는 추상화의 런타임 비용 측면에서 각각 무엇을 치르는가? 새 프로토콜 경계를 하나 둘 때 기본값으로 어느 쪽을 택하겠는가?
any P는 구체 타입을 지워 존재 컨테이너(existential box)에 담기 때문에 값이 세 워드 인라인 버퍼를 넘으면 힙에 박싱되고, 호출은 witness table을 통한 동적 디스패치로 런타임에 해석된다 — 그만큼 인라인·특수화가 막힌다. some P는 컴파일타임에 구체 타입 하나로 고정되므로 존재 박싱이 없고, 제네릭 특수화가 이뤄지면 witness table 조회까지 사라져 정적 디스패치·인라인이 가능하다(특수화되기 전 단계에서는 여전히 witness table을 넘기는 제네릭 호출이다). 대신 특수화된 코드가 타입별로 복제돼 코드 크기가 늘고, 그 경계는 한 번에 딱 한 종류의 구현만 통과시킨다. 기본값은 some P(또는 제네릭)로 두는 게 맞다 — 이질적인 여러 구현을 한 배열/프로퍼티에 섞어 담거나 런타임에 타입을 지워야 하는 명확한 이유가 있을 때만 any P의 비용을 치른다.
any P는 물건을 상자에 담아 "뚜껑을 열어 봐야 안에 뭐가 들었는지 아는" 방식이라 실행 중에 매번 확인 비용이 든다. some P는 "안에 든 게 컴파일할 때 이미 정해진" 방식이라 빠르지만, 종류마다 코드가 복제돼 덩치가 커진다. 여러 종류를 한 바구니에 섞어 담을 이유가 없으면 기본은 some P다.