1. 기본
- protocol의 역할
- 상속 대비 조합의 장점
- extension 기본 구현
이 문서는 protocol 기반 설계를 기본부터 심화까지 한 번에 학습하기 위한 마스터 문서다. 목표는 문법 암기가 아니라 “언제 protocol을 도입하고, 어디까지 추상화하고, 어떤 비용을 감수할지”를 결정할 수 있게 만드는 것이다.
2026-04-27
flowchart LR
A[Requirement] --> B{Stable Contract?}
B -->|yes| C[Define Protocol]
B -->|no| D[Keep Concrete Type]
C --> E[Inject at Boundary]
E --> F[Test Double Replacement]
F --> G[Monitor Complexity Cost]
protocol TokenStore {
func save(_ token: String)
func load() -> String?
}
final class KeychainTokenStore: TokenStore {
func save(_ token: String) { }
func load() -> String? { nil }
}
struct AuthService {
private let tokenStore: TokenStore
init(tokenStore: TokenStore) {
self.tokenStore = tokenStore
}
}| 항목 | 상속 중심 | protocol 조합 중심 |
|---|---|---|
| 변경 범위 | 부모 변경 영향이 넓음 | 인터페이스 경계 기준 영향 통제 |
| 테스트 대체 | subclass 준비 필요 | mock/stub로 즉시 대체 가능 |
| 설계 의도 | is-a 관계 중심 | can-do 관계 중심 |
“Array가 수많은 protocol을 준수한다”는 말은, Array가 한 부모 클래스에서 기능을 상속받는 방식이 아니라 여러 protocol 계약을 채택해서 다양한 기능을 제공한다는 뜻이다.
| 항목 | 의미 |
|---|---|
| Array<Element> | 제네릭 타입이라 Element 타입과 무관하게 같은 구조를 재사용한다. |
| 여러 protocol 채택 | Collection, MutableCollection, RandomAccessCollection, RangeReplaceableCollection 등 |
| 결과 | 타입에 상관없이 순회, 인덱싱, 변경, 필터링, 맵핑 같은 API를 일관된 방식으로 사용 가능 |
let ints = [1, 2, 3]
let strings = ["a", "b", "c"]
// Element가 달라도 Collection API를 동일하게 사용
print(ints.count)
print(strings.count)
let mapped = ints.map { $0 * 10 }
let filtered = strings.filter { $0 != "b" }protocol Repository {
associatedtype Entity
func fetch(by id: String) async throws -> Entity
}
1. 장점: Entity 타입 관계를 강하게 보존한다.
2. 비용: protocol 자체를 바로 타입으로 쓰기 어렵다.
| 표기 | 타입 의미 | 핵심 제약 | 적합한 곳 |
|---|---|---|---|
| any P | existential. "P를 만족하는 아무 타입" | 실행 시점에 실제 타입 확인, 제약 없는 혼합 저장 가능 | 서로 다른 구현체를 같은 배열/프로퍼티로 다룰 때 |
| some P | opaque type. "호출자는 모르는 단일 구체 타입" | 한 선언이 반환하는 실제 타입은 항상 하나로 고정 | 반환 타입은 숨기되 컴파일 타임 최적화 이점을 유지하고 싶을 때 |
protocol ImageLoading {
func load(from url: URL) async throws -> Data
}
struct URLImageLoader: ImageLoading {
func load(from url: URL) async throws -> Data { Data() }
}
struct CacheImageLoader: ImageLoading {
func load(from url: URL) async throws -> Data { Data() }
}
// any: 서로 다른 구체 타입을 같은 컨테이너에 보관 가능
let loaders: [any ImageLoading] = [URLImageLoader(), CacheImageLoader()]
// some: 호출자 입장에서 타입은 숨겨지지만, 실제 반환 타입은 하나로 고정
func makeDefaultLoader() -> some ImageLoading {
URLImageLoader() // 이 함수는 항상 URLImageLoader만 반환해야 함
}
// 아래처럼 분기마다 다른 구체 타입을 반환하면 컴파일 에러
// func makeLoader(useCache: Bool) -> some ImageLoading {
// if useCache { return CacheImageLoader() }
// return URLImageLoader()
// }
// any 매개변수: 다양한 구현체 주입 허용
func render(loader: any ImageLoading) async throws {
_ = try await loader.load(from: URL(string: "https://example.com")!)
}// 실무 패턴: 외부 API는 some으로 감추고, 내부 저장은 any로 받는 식으로 혼합
struct LoaderFactory {
static func make() -> some ImageLoading {
URLImageLoader()
}
}
final class ScreenInteractor {
private let loader: any ImageLoading
init(loader: any ImageLoading) {
self.loader = loader
}
}| 관점 | 이유 |
|---|---|
| 결합도 | 호출자는 구체 타입을 몰라도 되므로 구현체 교체 시 파급이 줄어든다. |
| 모듈 경계 | 반환 타입을 프로토콜 계약으로 제한해 내부 타입/모듈 노출을 줄인다. |
| API 단순성 | “무엇을 할 수 있는가”만 공개하므로 API 표면이 작고 이해가 쉽다. |
| 성능 예측 | 단일 구체 타입을 유지하므로 existential(any)보다 최적화 여지가 있다. |
가능하다. any도 호출자에게 구체 타입 이름을 숨긴다. 다만 some과 달리 "반환마다 다른 구체 타입"을 허용하는 existential 컨테이너라는 점이 다르다.
// any 반환: 호출자는 구체 타입을 모른다. 분기별로 다른 타입 반환 가능
func makeLoaderAny(useCache: Bool) -> any ImageLoading {
if useCache {
return CacheImageLoader()
}
return URLImageLoader()
}
// some 반환: 호출자는 구체 타입을 모른다. 하지만 함수 내부 실제 타입은 하나여야 함
func makeLoaderSome() -> some ImageLoading {
URLImageLoader()
}| 비교 포인트 | any ImageLoading | some ImageLoading |
|---|---|---|
| 구체 타입 숨김 | 가능 | 가능 |
| 분기별 다른 타입 반환 | 가능 | 불가(단일 타입 고정) |
| 주 용도 | 혼합 저장/동적 선택 | 반환 타입 캡슐화/안정적 성능 기대 |
associatedtype protocol을 컬렉션 저장/공통 인터페이스 전달에 쓰려면 type erasure가 필요할 때가 많다.
protocol EventEmitter {
associatedtype Event
func emit(_ event: Event)
}
struct AnyEventEmitter<E>: EventEmitter {
private let _emit: (E) -> Void
init<T: EventEmitter>(_ base: T) where T.Event == E {
_emit = base.emit
}
func emit(_ event: E) {
_emit(event)
}
}flowchart TD
C[ConcreteEmitter] --> A[AnyEventEmitter]
A --> X[Feature Module]
X --> T[Test with SpyEmitter]
| 케이스 | 호출 특성 | 체감 포인트 |
|---|---|---|
| 구체 타입 직접 호출 | 정적 최적화 유리 | 핫패스에서 예측 가능성 높음 |
| any protocol 호출 | 런타임 디스패치 경유 | 미세 오버헤드 존재 |
| type erasure 래핑 호출 | 클로저/박스 한 단계 추가 | 대량 호출 경로에서 누적 가능 |
associatedtype은 프로토콜에서 제네릭처럼 사용되는 타입 placeholder다. 구체 타입이 프로토콜을 채택할 때 실제 타입을 결정한다.
protocol Container {
associatedtype Item
var items: [Item] { get }
mutating func add(_ item: Item)
}
struct IntContainer: Container {
var items: [Int] = []
mutating func add(_ item: Int) {
items.append(item)
}
}
struct StringContainer: Container {
var items: [String] = []
mutating func add(_ item: String) {
items.append(item)
}
}여러 프로토콜을 &로 결합해서 "모든 조건을 만족하는 타입"을 요구할 수 있다. 단일 상속의 한계를 넘어 필요한 능력만 조합한다.
protocol Drawable {
func draw()
}
protocol Animatable {
func animate()
}
protocol Touchable {
func onTouch()
}
// 세 프로토콜을 모두 만족해야 함
func setupUIElement(_ element: Drawable & Animatable & Touchable) {
element.draw()
element.animate()
element.onTouch()
}
struct Button: Drawable, Animatable, Touchable {
func draw() { print("Drawing") }
func animate() { print("Animating") }
func onTouch() { print("Tapped") }
}
setupUIElement(Button()) // ✅where 절로 특정 조건을 만족할 때만 기능을 추가한다. 타입 안전성을 유지하면서 필요한 경우에만 API를 노출한다.
// Element가 Equatable일 때만 제공
extension Array where Element: Equatable {
func removeDuplicates() -> [Element] {
var result: [Element] = []
for item in self {
if !result.contains(item) {
result.append(item)
}
}
return result
}
}
[1, 2, 2, 3].removeDuplicates() // [1, 2, 3]
// Element가 Numeric일 때만 제공
extension Array where Element: Numeric {
func sum() -> Element {
return reduce(0, +)
}
}
[1, 2, 3].sum() // 6프로토콜에서 Self는 "프로토콜을 채택한 실제 타입"을 의미한다. 서브클래스에서도 타입이 정확히 유지되어야 할 때 유용하다.
protocol Copyable {
func copy() -> Self
}
class Document: Copyable {
var content: String
required init(content: String) {
self.content = content
}
func copy() -> Self {
return type(of: self).init(content: content)
}
}
class PDFDocument: Document {
var pageCount: Int = 0
}
let pdf = PDFDocument(content: "Test")
let copied = pdf.copy() // 타입: PDFDocument (Document가 아님)프로토콜도 상속이 가능하다. class와 달리 다중 상속이 가능하여 여러 기본 능력을 조합한 더 구체적인 계약을 만들 수 있다.
protocol Animal {
var name: String { get }
func makeSound()
}
protocol Flyable: Animal {
var wingSpan: Double { get }
func fly()
}
protocol Swimmable: Animal {
var swimSpeed: Double { get }
func swim()
}
// 다중 상속
protocol Amphibious: Flyable, Swimmable {
func transition()
}
struct Duck: Amphibious {
var name: String
var wingSpan: Double
var swimSpeed: Double
func makeSound() { print("Quack") }
func fly() { print("Flying") }
func swim() { print("Swimming") }
func transition() { print("Land to water") }
}상태를 타입 시스템으로 표현해서 컴파일 타임에 잘못된 호출을 막는다. 런타임 검사 없이 타입만으로 안전성을 보장한다.
enum Authenticated {}
enum Unauthenticated {}
protocol AuthState {}
extension Authenticated: AuthState {}
extension Unauthenticated: AuthState {}
struct Session<State: AuthState> {
let token: String?
init(token: String? = nil) {
self.token = token
}
}
// 인증 전에만 호출 가능
extension Session where State == Unauthenticated {
func login(username: String, password: String) -> Session<Authenticated> {
return Session<Authenticated>(token: "abc123")
}
}
// 인증 후에만 호출 가능
extension Session where State == Authenticated {
func fetchUserData() {
print("Fetching with token: \(token!)")
}
func logout() -> Session<Unauthenticated> {
return Session<Unauthenticated>()
}
}
// 사용
let unauthenticated = Session<Unauthenticated>()
// unauthenticated.fetchUserData() // ❌ 컴파일 에러
let authenticated = unauthenticated.login(username: "user", password: "pass")
authenticated.fetchUserData() // ✅Swift는 protocol 메서드 호출을 Protocol Witness Table(PWT)로 처리한다. 각 타입이 protocol을 채택하면 해당 타입의 구현을 가리키는 테이블이 생성된다.
protocol Drawable {
func draw()
}
struct Circle: Drawable {
func draw() { print("Drawing circle") }
}
struct Square: Drawable {
func draw() { print("Drawing square") }
}
// Protocol Witness Table 생성됨
// Circle의 PWT: draw() -> Circle.draw()
// Square의 PWT: draw() -> Square.draw()
let shapes: [Drawable] = [Circle(), Square()]
for shape in shapes {
shape.draw() // Dynamic dispatch (런타임 결정)
}
// 구체 타입 사용 시
let circle = Circle()
circle.draw() // Static dispatch (컴파일 타임 결정) - 더 빠름| 호출 방식 | Dispatch | 성능 | 적용 시점 |
|---|---|---|---|
| 구체 타입 직접 | Static | 최고 | 타입 확정 시 |
| Protocol 타입 | Dynamic (PWT) | 미세 오버헤드 | 다형성 필요 시 |
| any Protocol | Existential Container | 박싱 비용 추가 | 다양한 타입 보관 시 |
// 상속 기반 - 단일 상속의 한계
class Vehicle {
func move() { print("Moving") }
}
class Car: Vehicle {
override func move() { print("Driving") }
}
class Boat: Vehicle {
override func move() { print("Sailing") }
}
// 문제: AmphibiousCar를 만들려면?
// Car와 Boat 둘 다 상속 불가! 💥// 조합 기반 - 다중 채택 가능
protocol Drivable {
func drive()
}
protocol Sailable {
func sail()
}
extension Drivable {
func drive() { print("Driving on road") }
}
extension Sailable {
func sail() { print("Sailing on water") }
}
struct AmphibiousCar: Drivable, Sailable {
// 둘 다 사용 가능 ✅
}
let amphiCar = AmphibiousCar()
amphiCar.drive() // Driving on road
amphiCar.sail() // Sailing on water| 상황 | OOP 접근 | POP 접근 | 권장 |
|---|---|---|---|
| 여러 능력 조합 | 상속 트리 복잡화 | Protocol 다중 채택 | POP |
| 값 타입 공통 기능 | 불가능 (class 필요) | Protocol + Extension | POP |
| UIKit 계층 | 프레임워크 계약 | 상속 유지 | OOP |
| 도메인/Infra 경계 | 구현 결합도 높음 | Interface 기반 DI | POP |
flowchart LR
APP[Composition Root] --> IFACE[Protocols]
APP --> REAL[Concrete Implementations]
FEATURE[Feature] --> IFACE
TEST[Test Target] --> MOCK[Mock Implementations]
TEST --> IFACE
| 유형 | 용도 | 예시 |
|---|---|---|
| Stub | 정해진 값 반환 | API 응답 고정 |
| Spy | 호출 여부/인자 기록 | emit 호출 횟수 검증 |
| Mock | 행동+검증 결합 | 시나리오 기반 상호작용 검증 |
final class TokenStoreSpy: TokenStore {
private(set) var saved: [String] = []
var loaded: String?
func save(_ token: String) { saved.append(token) }
func load() -> String? { loaded }
}// before
protocol UserService {
func login()
func logout()
func loadProfile()
func uploadImage()
func trackEvent()
}
// after
protocol AuthService { func login(); func logout() }
protocol ProfileService { func loadProfile(); func uploadImage() }
protocol AnalyticsTracker { func trackEvent() }| 질문 | Yes면 | No면 |
|---|---|---|
| 단일 공유 identity가 중요한가 | class 후보 | struct 우선 |
| 교체 가능한 경계인가 | protocol 도입 | concrete 유지 |
| associatedtype 제약이 필요한가 | generic + protocol | existential 가능 |
| 다형 컬렉션 저장이 필요한가 | type erasure 검토 | 구체 타입 유지 |
| 질문 | 핵심 답 |
|---|---|
| self와 Self 차이 | self는 현재 인스턴스(또는 타입 값), Self는 현재 구체 타입을 의미하는 타입 키워드다. |
| copy() -> Self에서 왜 type(of: self).init(...)? | return self는 같은 참조를 반환하므로 복사가 아니다. 새 인스턴스를 만들어야 한다. |
| required init(...) 왜 필요? | Self 생성 경로에서 하위 타입도 같은 생성자 계약을 반드시 가지도록 강제하기 위해서다. |
| associatedtype면 typealias 필수? | 필수 아님. 구현 시그니처로 추론 가능하면 생략된다. 추론이 모호할 때 명시한다. |
| AnyEventEmitter<E>에서 <E> 생략 가능? | 고정 타입 래퍼로는 가능하지만, 여러 이벤트 타입 재사용을 원하면 제네릭 파라미터가 필요하다. |
| any P는 프로토콜에만 쓰나? | 의미상 existential 프로토콜 타입에 쓰는 문법이다. 실무에서는 any를 명시해 의도를 드러내는 것이 권장된다. |
| State == Unauthenticated에서 타입 종류 제한? | 클래스/구조체/열거형 모두 가능하다. 다만 제약(예: State: AuthState)을 만족해야 한다. |