iOS Development Guide

Protocol Oriented Programming Master

이 문서는 protocol 기반 설계를 기본부터 심화까지 한 번에 학습하기 위한 마스터 문서다. 목표는 문법 암기가 아니라 “언제 protocol을 도입하고, 어디까지 추상화하고, 어떤 비용을 감수할지”를 결정할 수 있게 만드는 것이다.

Swift protocol model DI and test boundaries Knowledge item #10 expanded
학습 날짜

2026-04-27

1. 학습 로드맵

1. 기본

  • protocol의 역할
  • 상속 대비 조합의 장점
  • extension 기본 구현

2. 중급

  • associatedtype / where
  • any vs some
  • existential 제약

3. 심화

  • type erasure
  • dispatch 비용 이해
  • Protocol Composition
  • Conditional Extension
  • Phantom Types
  • PWT와 성능
  • 안티패턴과 개선
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]
        

2. 기본: protocol의 본질

  • protocol은 “이 타입이 무엇인가”가 아니라 “무엇을 할 수 있는가”를 정의한다.
  • 상속 트리를 늘리는 대신 능력(역할) 단위로 조합한다.
  • 핵심은 유연성이 아니라 경계의 명시성이다.
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
    }
}

3. 기본: 상속과 조합 비교

항목상속 중심protocol 조합 중심
변경 범위부모 변경 영향이 넓음인터페이스 경계 기준 영향 통제
테스트 대체subclass 준비 필요mock/stub로 즉시 대체 가능
설계 의도is-a 관계 중심can-do 관계 중심
1. UIKit 계층처럼 프레임워크 계약이 상속인 곳은 상속이 자연스럽다.
2. 도메인/infra 경계는 protocol 조합이 일반적으로 유지보수 비용이 낮다.
3. Class는 단일 상속이라 수직 계층을 고려해야 하지만, protocol은 여러 개를 채택해 블록처럼 기능을 조합할 수 있다.
4. 값 타입의 상속 효과: 값 타입도 protocol + extension 조합으로 공통 기능을 쉽게 구현할 수 있다.
5. 출처: Protocol Oriented Programming (POP)

3-1. Array 예시가 의미하는 것

“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" }
1. 핵심은 “타입에 관계없이 만들 수 있다”가 아니라, “Element만 바꿔도 같은 protocol 기반 기능을 그대로 쓸 수 있다”이다.

4. 중급: associatedtype, any, some

1. associatedtype

protocol Repository {
    associatedtype Entity
    func fetch(by id: String) async throws -> Entity
}

1. 장점: Entity 타입 관계를 강하게 보존한다.
2. 비용: protocol 자체를 바로 타입으로 쓰기 어렵다.

2. any P vs some P

표기타입 의미핵심 제약적합한 곳
any P existential. "P를 만족하는 아무 타입" 실행 시점에 실제 타입 확인, 제약 없는 혼합 저장 가능 서로 다른 구현체를 같은 배열/프로퍼티로 다룰 때
some P opaque type. "호출자는 모르는 단일 구체 타입" 한 선언이 반환하는 실제 타입은 항상 하나로 고정 반환 타입은 숨기되 컴파일 타임 최적화 이점을 유지하고 싶을 때
1. any는 "타입이 매번 달라도 된다".
2. some은 "타입 이름은 숨기지만 실제 타입은 하나로 고정된다".
3. 그래서 any는 저장/전달에 강하고, some은 반환 캡슐화에 강하다.
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
    }
}

2-1. 왜 외부 API에서 some으로 감추는가

관점이유
결합도호출자는 구체 타입을 몰라도 되므로 구현체 교체 시 파급이 줄어든다.
모듈 경계반환 타입을 프로토콜 계약으로 제한해 내부 타입/모듈 노출을 줄인다.
API 단순성“무엇을 할 수 있는가”만 공개하므로 API 표면이 작고 이해가 쉽다.
성능 예측단일 구체 타입을 유지하므로 existential(any)보다 최적화 여지가 있다.
1. some은 “숨김”이 목적이 아니라 “구현 변경 여지 확보”가 목적이다.
2. 저장/주입/혼합 컬렉션이 필요하면 any를 쓰고, 외부 반환 API는 some을 우선 검토한다.

2-2. any로도 숨길 수 있는가

가능하다. any도 호출자에게 구체 타입 이름을 숨긴다. 다만 some과 달리 "반환마다 다른 구체 타입"을 허용하는 existential 컨테이너라는 점이 다르다.

// any 반환: 호출자는 구체 타입을 모른다. 분기별로 다른 타입 반환 가능
func makeLoaderAny(useCache: Bool) -> any ImageLoading {
    if useCache {
        return CacheImageLoader()
    }
    return URLImageLoader()
}

// some 반환: 호출자는 구체 타입을 모른다. 하지만 함수 내부 실제 타입은 하나여야 함
func makeLoaderSome() -> some ImageLoading {
    URLImageLoader()
}
비교 포인트any ImageLoadingsome ImageLoading
구체 타입 숨김가능가능
분기별 다른 타입 반환가능불가(단일 타입 고정)
주 용도혼합 저장/동적 선택반환 타입 캡슐화/안정적 성능 기대
1. "숨김"만 보면 둘 다 가능하다.
2. "반환 타입의 일관성 보장"이 필요하면 some, "런타임 동적 선택"이 필요하면 any가 맞다.

5. 심화: type erasure

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)
    }
}
1. type erasure는 “고급스럽게 보이게 하는 도구”가 아니라 “타입 정보를 의도적으로 감추는 어댑터”다.
2. 과용하면 디버깅과 가독성 비용이 급증한다.
flowchart TD
          C[ConcreteEmitter] --> A[AnyEventEmitter]
          A --> X[Feature Module]
          X --> T[Test with SpyEmitter]
        

6. 심화: dispatch와 성능 관점

케이스호출 특성체감 포인트
구체 타입 직접 호출정적 최적화 유리핫패스에서 예측 가능성 높음
any protocol 호출런타임 디스패치 경유미세 오버헤드 존재
type erasure 래핑 호출클로저/박스 한 단계 추가대량 호출 경로에서 누적 가능
1. 일반 앱 로직에서 protocol 오버헤드는 대부분 병목이 아니다.
2. 렌더링 루프/파서 루프/대량 데이터 변환 같은 핫패스에서는 측정 기반으로 선택한다.

6-1. POP 심화 패턴

1. Associated Type 심화

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)
    }
}

2. Protocol Composition (프로토콜 조합)

여러 프로토콜을 &로 결합해서 "모든 조건을 만족하는 타입"을 요구할 수 있다. 단일 상속의 한계를 넘어 필요한 능력만 조합한다.

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())  // ✅

3. Conditional Extension (조건부 확장)

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

4. Self 요구사항

프로토콜에서 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가 아님)

5. Protocol Inheritance (프로토콜 상속)

프로토콜도 상속이 가능하다. 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") }
}

6. Phantom Types (타입 안전성 패턴)

상태를 타입 시스템으로 표현해서 컴파일 타임에 잘못된 호출을 막는다. 런타임 검사 없이 타입만으로 안전성을 보장한다.

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()  // ✅
1. 핵심: 런타임 상태 검사를 컴파일 타임 타입 검사로 옮겨서 버그를 조기 발견한다.
2. 비용: 타입 복잡도가 증가하므로 중요한 상태 전환 경계에만 적용한다.

6-2. Protocol Witness Table과 Dispatch

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 ProtocolExistential Container박싱 비용 추가다양한 타입 보관 시
1. 일반 앱 로직에서는 dispatch 비용이 문제되지 않는다.
2. 렌더링 루프, 대량 데이터 처리 같은 핫패스에서만 프로파일링 기반으로 최적화한다.

6-3. POP vs OOP 실전 비교

OOP 방식의 한계

// 상속 기반 - 단일 상속의 한계
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 둘 다 상속 불가! 💥

POP 방식의 해결

// 조합 기반 - 다중 채택 가능
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 + ExtensionPOP
UIKit 계층프레임워크 계약상속 유지OOP
도메인/Infra 경계구현 결합도 높음Interface 기반 DIPOP
1. POP는 OOP를 대체하는 것이 아니라 보완하는 것이다.
2. 상속이 자연스러운 곳(UIKit, Core Data)은 그대로 사용한다.
3. 조합이 유리한 곳(비즈니스 로직, DI 경계)은 protocol을 적극 활용한다.

7. 실무: DI 경계 설계

  • 변경 가능성이 큰 외부 의존(네트워크, 저장소, 로깅, 분석)을 먼저 protocol화한다.
  • 도메인 내부 순수 계산 모델은 무리하게 protocol화하지 않는다.
  • Composition Root에서만 concrete를 알고, 하위 모듈은 protocol만 알게 한다.
flowchart LR
            APP[Composition Root] --> IFACE[Protocols]
            APP --> REAL[Concrete Implementations]
            FEATURE[Feature] --> IFACE
            TEST[Test Target] --> MOCK[Mock Implementations]
            TEST --> IFACE
          

8. 실무: 테스트 더블 전략

유형용도예시
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 }
}

9. 안티패턴과 개선

1. 너무 큰 protocol (God Protocol)

// 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() }

2. extension 기본 구현 과다

3. 불필요한 추상화

10. 선택 매트릭스

질문Yes면No면
단일 공유 identity가 중요한가class 후보struct 우선
교체 가능한 경계인가protocol 도입concrete 유지
associatedtype 제약이 필요한가generic + protocolexistential 가능
다형 컬렉션 저장이 필요한가type erasure 검토구체 타입 유지
1. 기본 원칙: 가능한 단순하게 시작하고, 변경 비용이 관측되는 경계부터 추상화한다.
2. 성능 판단: 추측이 아니라 프로파일링으로 검증한다.

11. 체크 질문 (학습 점검)

  1. 왜 모든 타입을 protocol로 만들지 않는가?
    추상화 비용(가독성/디버깅/코드량)이 이득보다 큰 영역이 있기 때문이다.
  2. anysome의 핵심 차이는?
    any는 existential 다형성, some은 단일 구체 타입 은닉이다.
  3. type erasure는 언제 필요한가?
    associatedtype protocol을 타입으로 저장/전달해야 할 때 필요하다.
  4. 실무에서 첫 protocol 도입 지점은?
    외부 I/O 경계(네트워크/스토리지/로깅/분석)다.
  5. Protocol Composition을 사용하는 이유는?
    단일 상속의 한계를 넘어 여러 능력을 조합해 필요한 기능만 요구할 수 있기 때문이다.
  6. Conditional Extension은 언제 유용한가?
    특정 조건을 만족하는 타입에만 API를 제공해 타입 안전성을 유지할 때 유용하다.
  7. Phantom Types의 핵심 가치는?
    런타임 상태 검사를 컴파일 타임 타입 검사로 옮겨 버그를 조기 발견하는 것이다.
  8. Protocol Witness Table은 무엇인가?
    각 타입의 protocol 구현을 가리키는 테이블로, 런타임 동적 디스패치에 사용된다.
  9. POP와 OOP 중 언제 어떤 것을 선택하는가?
    프레임워크 계약은 OOP(상속), 도메인/경계 조합은 POP(protocol)를 선택한다.

11-1. 최근 질문 Q&A 정리 (타입 시스템)

질문핵심 답
selfSelf 차이self는 현재 인스턴스(또는 타입 값), Self는 현재 구체 타입을 의미하는 타입 키워드다.
copy() -> Self에서 왜 type(of: self).init(...)?return self는 같은 참조를 반환하므로 복사가 아니다. 새 인스턴스를 만들어야 한다.
required init(...) 왜 필요?Self 생성 경로에서 하위 타입도 같은 생성자 계약을 반드시 가지도록 강제하기 위해서다.
associatedtypetypealias 필수?필수 아님. 구현 시그니처로 추론 가능하면 생략된다. 추론이 모호할 때 명시한다.
AnyEventEmitter<E>에서 <E> 생략 가능?고정 타입 래퍼로는 가능하지만, 여러 이벤트 타입 재사용을 원하면 제네릭 파라미터가 필요하다.
any P는 프로토콜에만 쓰나?의미상 existential 프로토콜 타입에 쓰는 문법이다. 실무에서는 any를 명시해 의도를 드러내는 것이 권장된다.
State == Unauthenticated에서 타입 종류 제한?클래스/구조체/열거형 모두 가능하다. 다만 제약(예: State: AuthState)을 만족해야 한다.