본문으로 건너뛰기
Previous
Next
Swift 프로토콜과 확장 | Protocol, Extension

Swift 프로토콜과 확장 | Protocol, Extension

Swift 프로토콜과 확장 | Protocol, Extension

이 글의 핵심

protocol Drawable { func draw() var color: String { get set } }.

#05 | 📋 Swift 시리즈 목차 | 이전: #04 클래스·구조체 · 다음: #06 제네릭

실전 예제로 시작: 네트워크 경계와 HTTPClientProtocol

의존성을 URLSession.shared에 직접 박아 두면 테스트가 길고 취약해집니다. 먼저 프로토콜로 경계를 자른 작은 예를 보겠습니다. HTTPClientProtocol은 “요청 보내고 (Data, URLResponse)를 돌려준다”는 계약만 적고, 프로덕션에서는 URLSession이 그대로 준수하게 둡니다. 테스트에서는 페이로드를 고정한 MockHTTPClient만 꽂으면 됩니다.

import Foundation

protocol HTTPClientProtocol {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPClientProtocol {}

struct UserDTO: Codable {
    let id: Int
    let name: String
}

protocol UserProviding {
    func fetchUser(id: Int) async throws -> UserDTO
}

struct RemoteUserService: UserProviding {
    let baseURL: URL
    let client: HTTPClientProtocol
    func fetchUser(id: Int) async throws -> UserDTO {
        var req = URLRequest(url: baseURL.appendingPathComponent("users/\(id)"))
        let (data, _) = try await client.data(for: req)
        return try JSONDecoder().decode(UserDTO.self, from: data)
    }
}

struct MockHTTPClient: HTTPClientProtocol {
    let payload: Data
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        (payload, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
    }
}

여기서 프로토콜은 “기능 목록”이 아니라 팀이 합의한 경계입니다. UserDTO는 Wire 포맷이고, 도메인 User와 나누는 편이 장기적으로 안전합니다. 이후 절에서는 이 계약을 뒷받침하는 문법—요구 사항, 확장, any/some, 위트니스 테이블—을 차례로 풉니다. 클래스와 구조체에서 값·참조를 익혔다면 이번 글은 “어떤 능력을 갖는가”를 추상화하는 층이고, 이어지는 제네릭연관 타입과 겹쳐 읽는 것이 좋습니다.


SwiftUI를 배우며 — POP와 OOP 사이에서

SwiftUI 배우면서 프로토콜 지향의 위력을 처음 제대로 체감하는 경우가 많습니다. View는 프로토콜이고, bodysome View로 구체 타입을 감춥니다. 수정자 체인은 작은 프로토콜 요구를 조합한 결과에 가깝고, “거대한 베이스 뷰 컨트롤러를 상속해 온몸으로 재사용”하던 감각과는 결이 다릅니다.

그렇다고 OOP가 틀렸다고 말하고 싶지는 않습니다. UIKit·AppKit에서는 UIViewController 서브클래싱이 프레임워크가 요구하는 현실이었고, 지금도 해당 계층 안에서는 상속이 자연스럽습니다. 논쟁에서 흔히 빠지는 말은 “POP만 쓴다” 대신 “역할이 갈리면 프로토콜로 쪼개고, 프레임워크가 강제하는 곳은 상속을 받아들인다” 쪽이 맞습니다. POP는 상속 대신 조합으로 기능을 묶고 테스트 경계를 열기 쉽지만, 프로토콜이 잘게 쪼개져 읽을 수 있는 덩어리를 넘기면 가독성만 해칩니다. 팀이 읽을 수 있는 크기로 유지하는 일이 OOP·POP 공통의 숙제입니다.


프로토콜 확장을 남용하지 마세요

프로토콜 익스텐션에 기본 구현을 몰아넣으면 중복은 줄어듭니다. 다만 요구 사항으로 올리지 않은 메서드는 디스패치 규칙이 달라져, “내가 오버라이드한 줄 알았는데 다른 구현이 호출된다”는 사고로 이어지기 쉽습니다. 또 where 절 익스텐션이 여러 겹이면 어떤 확장이 승리하는지 추적 비용이 커집니다.

저는 이렇게 줄입니다. 첫째, 프로토콜 본문에 올릴 요구와 익스텐션에서만 두는 편의 메서드를 팀에서 구분한다. 둘째, “재사용된다”는 이유만으로 익스텐션 파일을 늘리지 않고, 한 책임에 속하는지 먼저 본다. 셋째, @objc optional을 쓰지 않는 순수 Swift 설계라면, 빈 기본 구현으로 “옵셔널 메서드”를 흉내 내기 전에 작은 프로토콜 분리를 검토한다. 익스텐션은 도구지, 설계의 전부가 되면 안 됩니다.


1. 프로토콜 개요 — Protocol-Oriented Programming

POP의 중심 아이디어는 다음과 같이 요약할 수 있습니다.

  • 기능을 프로토콜로 쪼갠다: 읽기·쓰기·동등 비교·직렬화 등, 역할이 다르면 프로토콜을 나눈다.
  • 기본 구현은 프로토콜 확장에 둔다: 여러 구체 타입이 공유하는 로직은 extension ProtocolName으로 한곳에 모은다.
  • 상속보다 조합을 우선한다: class B : A 한 줄이 모든 것을 끌고 오는 대신, 준수할 프로토콜을 골라 붙인다.
  • 값 타입(구조체)과 궁합이 좋다: 공용 베이스 클래스 없이도 동일한 추상에 여러 struct를 맞출 수 있다.

UIKit·AppKit 시대에는 UIViewController 서브클래싱이 중심이었지만, SwiftUI·모듈화된 도메인 계층에서는 프로토콜 + 제네릭 + 의존성 주입이 표준에 가깝게 자리 잡았습니다. “프로토콜 = 인터페이스”에 가깝지만, Swift는 기본 구현(프로토콜 익스텐션), Self/연관 타입, 조건부 준수까지 묶어 제공하므로, “인터페이스보다 약간 더 많은 것”으로 이해하면 됩니다.


2. 프로토콜 정의 — 요구 사항 선언

프로토콜은 저장/연산 프로퍼티, 메서드, 초기화 요구 사항 등을 선언할 수 있습니다. 아래는 한 프로토콜 안에 여러 가지를 넣은 예시입니다.

protocol Drawable {
    var color: String { get set }
    var identifier: String { get }  // 읽기 전용
    func draw()
    static func makeDefault() -> Self
}
  • { get } / { get set }최소한 어떤 접근을 제공해야 하는지 나타냅니다. 저장 프로퍼티든 var에 대한 get 전용 래퍼든, 요구를 만족하면 됩니다.
  • 타입 메서드 static은 프로토콜에서 static으로 선언할 수 있고, 클래스에서 준수할 때 class로 바꾸는 것도 가능합니다(규칙은 [Language Guide] 참고).
  • Self최종 준수 타입을 가리키므로, 팩토리 메서드에 자주 쓰입니다.

프로퍼티·메서드·변경(mutating) 요구

protocol Resettable {
    mutating func reset()
}

struct Counter: Resettable {
    var value = 0
    mutating func reset() { value = 0 }
}

구조체나 열거형이 가변 메서드로 내부 상태를 바꾸면, 프로토콜 쪽에도 mutating이 필요합니다. 클래스는 참조 의미론이라 인스턴스 메서드가 힙에 있는 객체를 바꿀 때에도 mutating이 붙지 않습니다.

protocol Named {
    var name: String { get }
    var fullName: String { get set }
}

다중 다이어그램에 넣을 때

작은 프로토콜일수록 재사용단위 테스트에 유리합니다. 예: Identifiable만, Equatable만, CustomStringConvertible만.


3. 프로토콜 채택 — Conformance

채택(Conformance)는 “이 타입이 이 프로토콜의 모든 요구를 만족한다”는 컴파일 시 검증 관계입니다. 구조체·열거형·클래스·actor가 준수할 수 있으며, 동일한 타입에 여러 프로토콜을 동시에 준수할 수 있습니다(다중 protocol 목록).

protocol Movable {
    func move(to x: Int, y: Int)
}

struct Shape: Drawable, Movable {
    var color: String
    var identifier: String { "shape-\(color)" }
    static func makeDefault() -> Self { Shape(color: "black") }
    func draw() { print("draw") }
    func move(to x: Int, y: Int) { print("go (\(x),\(y))") }
}
  • 필수 요구를 빠뜨리면 컴파일 오류가 납니다.
  • 조건부 준수(extension Array: Equatable where Element: Equatable 등)는 표준 라이브러리에서 광범위하게 쓰입니다(다음 섹션들과 제네릭 글 참고).

준수의 위치

  • 타입 본문에 바로 protocol 목록.
  • 익스텐션extension MyType: MyProtocol 형태로 분리 — 파일·모듈 경계에 따라 가독성이 좋아집니다.
struct User { let name: String }
protocol Describable { func describe() -> String }
extension User: Describable {
    func describe() -> String { name }
}

4. 프로토콜 상속 — Protocol inheritance

프로토콜은 다른 프로토콜을 상속해 요구를 이어받을 수 있습니다. CodableDecodableEncodable을 합성한 typealias인 것이 대표적입니다.

protocol Playable: Named {
    func play()
}
struct Track: Playable {
    var name: String
    func play() { print("Playing \(name)") }
}
  • 상속 체인이 길어질수록 이 프로토콜을 준수하는 타입이 부담해야 할 요구가 늘어납니다. “범용” 프로토콜을 얇게 유지하려면 하위에만 필요한 메서드는 별도 프로토콜로 쪼개는 방식이 낫습니다.

5. 클래스 전용 프로토콜 — AnyObject

참조 타입만 준수해야 할 때(예: weak 참조가 필요한 delegate 저장), AnyObject를 상속한 프로토콜로 제한할 수 있습니다.

protocol DataLoaderDelegate: AnyObject {
    func loaderDidFinish(_ data: Data)
}

final class ViewModel {
    weak var delegate: DataLoaderDelegate?
}
  • structAnyObject 프로토콜을 준수할 수 없습니다.
  • weakARC로 참조 사이클을 끊기 위한 것이므로, delegate 패턴의 클래스 쪽에서 자주 사용합니다.

6. 프로토콜 합성 — Protocol composition

런타임에 “여러 프로토콜을 동시에 만족하는 값”이 필요할 때 &로 합성할 수 있습니다.

func process(_ x: any Drawable & Movable) {
    x.draw()
    x.move(to: 0, y: 0)
}
  • Swift 5.6 이전에는 Drawable & Movable이 타입 위치에 쓰였고, existential(아래 10절)이 필요한 경우 any를 사용합니다(아래 “프로토콜을 타입으로” 참고).
  • 제네릭where로 같은 제약을 정적으로 줄 수도 있어, 성능·표현력 트레이드오프가 달라집니다.
func processStatic<T: Drawable & Movable>(_ x: T) {
    x.draw()
    x.move(to: 0, y: 0)
}

7. 옵셔널 요구 사항 — @objc optional

Objective-C 런타임에 노출되는 @objc 프로토콜에서, 일부 메서드를 옵셔널로 둘 수 있습니다. optional 키워드는 그 선언이 있는 프로토콜이 @objc이어야 합니다. 일반 Swift-only 프로토콜에는 optional이 없고, “선택적 분기”는 보통 프로토콜 확장의 빈 기본 구현이나 별도의 작은 프로토콜로 처리합니다.

@objc protocol OldStyleDelegate: AnyObject {
    @objc optional func shouldSkip() -> Bool
    func didFinish()
}
  • UIKit UITableViewDelegate 등 레거시 API에서 흔합니다.
  • 순수 Swift 설계라면, @objc optional에 의존하기보다 기본 구현이 있는 extension이나 protocol 분리 쪽이 추적하기 쉽습니다.

8. 프로토콜 확장 — Default implementations

프로토콜 본문에 요구만 적고, 익스텐션에 기본 구현을 두는 패턴이 POP의 핵심입니다.

protocol Greetable {
    var name: String { get }
    func greet()
}
extension Greetable {
    func greet() {
        print("안녕하세요, \(name)님!")
    }
}
struct Person: Greetable {
    let name: String
    // greet()는 기본 구현 사용
}
  • 준수 타입이 동일 시그니처greet()를 다시 쓰면, 정적/동적 디스패치 규칙에 따라 “어떤 구현이 호출되는지”를 이해해야 합니다. (프로토콜 요구로 선언된 메서드 vs 익스텐션에서만 추가된 메서드는 디스패치가 다를 수 있음: 자세한 것은 [공식 문서]와 WWDC를 참고하세요.)
  • mutating 요구 + 기본 구현을 섞을 때는 제네릭 제약이나 where Self: ...가 필요한 경우가 있습니다.

조건부 익스텐션 예:

extension Array where Element: Numeric {
    func sum() -> Element { reduce(0, +) }
}

9. 제네릭과 프로토콜 — Associated types

연관 타입(associatedtype)은 “이 프로토콜에 얽힌 가질 매개화된 타입”입니다. IteratorProtocolElement, SequenceElement가 대표적입니다.

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
  • 구체 타입이 준수할 때 typealias로 연관 타입을 밝히거나, 컴파일러가 추론합니다.
  • where절로 연관 타입에 제약을 걸 수 있습니다(예: Item: Hashable).

프로토콜에 연관 타입이 있으면, 과거에는 “프로토콜 그 자체”를 let c: Container에 넣는 데 제약이 있었고, 이 지점이 제네릭/타입 지우기/존재형( existentials)로 이어집니다(10, 11, 16절). 구체적인 where 패턴·제네릭 문법은 제네릭에서 이어집니다.

func printCount<C: Container>(_ c: C) where C.Item: CustomStringConvertible {
    print("count: \(c.count)")
}

10. 프로토콜을 타입으로 — Existential types

존재형(Existential)은 “특정 프로토콜을 준수하는 어떤 구체 타입이든 담는 상자”입니다. Swift 5.6+에서는 명시적으로 any 키워드를 씁니다.

let shapes: [any Drawable] = [
    Circle(radius: 1),
    Rectangle(width: 2, height: 3) // 둘 다 Drawable이면 OK
]
for s in shapes { s.draw() }
  • 동일한 배열/컬렉션에 서로 다른 구체 타입을 넣으려면 any가 필요한 경우가 많습니다.
  • 반대로 하나의 구체 T 다루는 알고리즘은 제네릭 <T: Drawable>이 더 맞는 경우가 많고, 정적 디스패치·특화에 유리합니다.
  • any동적 테이블(위트니스)을 끌고 갈 수 있어(18절), 핫 경로에서 맹목적으로 쓰지 않는 편이 좋습니다.

11. Opaque types — some 키워드

불투명 반환 타입 some PromisedProtocol는 “구체 타입을 호출자에게 숨기고, 컴파일 시점에 하나의 고정된 타입이 됨”을 표현합니다.

func makeDrawable() -> some Drawable {
    Circle(radius: 1)
}
  • 반환 타입이 한 가지 구체 타입(여기로 고정)일 때 some이 적합합니다. 조건마다 다른 구체 타입return하면 some으로는 안 됩니다 — 그때는 any Drawable, enum으로 케이스 캡슐화, 제네릭 등을 검토합니다.
  • SwiftUI의 some View는 “구체 View 타입은 매우 길지만, 밖에선 some View추상화”하는 데 쓰입니다.
  • someany의 선택은 “반환(저장) 타입을 컴파일 시 한 구체에 고정할 수 있는가”(불투명)와 “여러 구체를 한 타입에 담는가”(존재형)에 따라 갈라집니다(프로젝트·Swift 버전에 따라 세부 규칙이 조금씩 달라질 수 있으니 릴리스 노트·공식 가이드를 함께 보세요).

12. 표준 라이브러리 프로토콜 — Equatable, Hashable, Comparable

  • Equatable: == 연산. struct의 저장 프로퍼티가 전부 Equatable이면 합성이 가능한 경우가 많습니다(enum도 마찬가지).
  • Hashable: Set·Dictionary 키. Equatable을 포함합니다.
  • Comparable: <= 등 정렬. sort(), min()과 연결.
struct Point: Equatable, Hashable {
    var x: Int, y: Int
}

var set: Set<Point> = [.init(x: 0, y: 0)]
var dict: [Point: String] = [:]
  • 자동 합성이 안 되는 경우(예: “대소문자 무시 비교”)에는 == / hash(into:)직접 구현합니다. Comparablestatic func < 구현이 필요할 수 있습니다.

프로토콜로 정의돼 있으므로, 제네릭 제약 func f<T: Hashable>(_: T)처럼 로직Point뿐 아니라 String 등에 공유할 수 있는 점이 이점입니다.


13. Codable — Encoding/Decoding

Codabletypealias Codable = Decodable & Encodable 입니다. JSON, PlistEncoder / Decoder로 변환하는 계약입니다.

struct UserDTO: Codable {
    let id: Int
    let name: String
}

let data = try JSONEncoder().encode(UserDTO(id: 1, name: "Ann"))
let u = try JSONDecoder().decode(UserDTO.self, from: data)
  • 키 이름 매핑(CodingKeys), Date 전략, 중첩 타입, Decodable만 필요한 Decodable만 채택 등 실전 옵션이 매우 많습니다네트워크 DTO도메인 모델을 분리할 때 자주 씁니다.
  • 프로퍼티가 모두 Codable이면 합성이 가능한 경우가 많습니다.

14. Delegation 패턴 — Delegate 프로토콜

위임은 UIKit·비동기 작업·데이터 소스가 대표적입니다. 객체 1객체 2에게 “끝났을 때 알려 달라”는 식의 콜백을 protocol로 고정합니다.

protocol ImageCacheDelegate: AnyObject {
    func cache(_: ImageCache, didUpdateKey key: String)
}

final class ImageCache {
    weak var delegate: ImageCacheDelegate?
    func store(_ data: Data, for key: String) {
        // ...
        delegate?.cache(self, didUpdateKey: key)
    }
}
  • Delegate는 보통 class 전용 + weak — 메모리 사이클을 피하기 위함입니다.
  • SwiftUI·Combine·async/await로 단순 콜백이 클로저/스트림으로 대체되는 경우도 많지만, “여러 이벤트·명확한 계약”이 필요한 모듈 경계에선 프로토콜이 읽기 좋을 때가 있습니다.

15. 프로토콜 vs 상속 — 언제 무엇을 쓸지

공유 로직이 “역할”에 가깝다면 작은 프로토콜과 익스텐션으로 나누는 편이 읽기 쉬운 경우가 많습니다. 반대로 두꺼운 베이스 클래스는 UIKit 뷰 컨트롤러처럼 프레임워크가 상속을 전제로 할 때는 여전히 납득됩니다. 값 타입 중심이면 struct에 프로토콜을 붙이면 되고, 값 타입은 클래스처럼 상속할 수 없다는 점이 POP와 잘 맞습니다.

NSObjectUIViewController강제하는 환경에서는 상속이 불가피해도, 그 바깥 경계(네트워크, 저장소)는 프로토콜로 쪼개서 테스트를 분리할 수 있습니다. 런타임에 서로 다른 구체 타입을 한 컬렉션에 담을 때는 any Protocol이 필요해지는데, 이어지는 10절·18절에서 말하듯 박싱·동적 경로의 대가를 알고 쓰는 것이 좋습니다. 베이스 포인터 한 종류만 담는 OOP 스타일과 트레이드오프가 다릅니다.

OOP의 “super에 묶인 다이어그램”을 줄이는 데 POP이 강하다고만 기억하면, 오히려 “프로토콜이 너무 잘게 쪼개진 설계”로 가독성이 떨어질 수 있습니다. 팀이 읽을 수 있는 크기로 유지하세요.


16. Type erasure — AnySequence 같은 패턴

연관 타입이 있는 프로토콜(Sequence 등)은, 그 프로토콜만 타입으로 꺼내 쓰기가 까다로웠고, 이를 퍼스트 클래스 값으로 쓰기 위해 지우는(wrapping) 타입이 등장합니다. 대표가 AnySequence, AnyIterator입니다.

struct AnyIntSequence: Sequence {
    typealias Element = Int
    let _next: () -> Int?
    struct Iterator: IteratorProtocol {
        let _next: () -> Int?
        mutating func next() -> Int? { _next() }
    }
    func makeIterator() -> Iterator { Iterator(_next: _next) }
}
  • 실무에서는 AnyPublisher(Combine) 등, 퍼블리셔/스트림을 “구체 제네릭을 숨긴 하나의 타입”으로 만드는 경우가 많습니다.
  • 핵심은 “프로토콜(연관 타입)·제네릭이 너무 복잡한 반환/저장을 한 겹 감쌀 때 지우는 타입을 둔다는 점입니다. 무분별한 래핑은 보일러플레이트가 늘어나므로, some / any / 제네릭으로 줄일 수 있는지 먼저 검토합니다.

17. 실전 예제 — 도형과 데이터 처리

이 글 처음에 넣은 HTTPClientProtocol / UserProviding 예는 같은 패턴을 경계에 반복한 것입니다. 아래는 도메인 쪽 도형으로 같은 아이디어를 짧게 보는 경우입니다.

도형·설명(도메인 쪽 POP)

protocol Shape {
    func area() -> Double
    func perimeter() -> Double
}
extension Shape {
    func describe() {
        print("넓이: \(area()), 둘레: \(perimeter())")
    }
}
struct Circle: Shape {
    let radius: Double
    func area() -> Double { .pi * radius * radius }
    func perimeter() -> Double { 2 * .pi * radius }
}
struct Rectangle: Shape {
    let width: Double, height: Double
    func area() -> Double { width * height }
    func perimeter() -> Double { 2 * (width + height) }
}
let list: [any Shape] = [Circle(radius: 5), Rectangle(width: 10, height: 20)]
list.forEach { $0.describe() }
  • 본문 서두의 HTTP 예에서처럼, 의존성을 직접 타입에 고정하지 않고 프로토콜로 받으면 MockHTTPClient로 단위·통합 테스트를 단순화할 수 있고, UserDTOWire로 두고 도메인 User와 나누는 편이 안전합니다.

데이터 스트림(추상화 + 지연)

Sequence / AsyncSequence를 프로토콜로 두고, “어디서 오든 동일한 연산”을 map/filter 쪽에 맡기는 식이 흔합니다(구체·버전에 따라 API 명이 다르므로, 사용 중인 런타임 문서를 따르세요).


18. 성능 고려 — Protocol witness table

런타임에서 프로토콜을 준수하는지위트니스 테이블(witness table)로 연결됩니다. 구체 타입 T가 프로토콜 P의 요구(예: draw())를 만족하면, T의 해당 구현을 가리키는 테이블이 있고, any P에 대해 동적으로 적절한 구현이 선택될 수 있습니다.

  • 인라인·전문화(특화)에 유리한 것은, 가능하면 제네릭으로 한 구체 타입 T에 열결시키는 쪽입니다.
  • any를 남용하면 박싱/동적 경로로 인해, 뜨겁 루프에서 병목이 될 수 있습니다(항상이 아님: 측정이 우선).
  • “프로토콜 = 무조건 느리다”가 아니라, “존재형·다형성 + 탈구체(抽象) 뒤에 숨기는 대가를 이해하라”가 실무적 메시지입니다. Instruments(Instruments·Time Profiler)로 핫스팟을 확인하세요.

19. 개인적 가이드라인 (나의 기준)

아래는 “베스트 프랙티스 목록”이라기보다, 제가 코드 리뷰와 설계를 할 때 스스로 걸어 두는 기준입니다. 팀·프로젝트에 맞게 골라 쓰시면 됩니다.

설계에서는 프로토콜을 작게 쪼개되, 이름은 …Providing·…able 같이 역할이 듣기만 해도 드러나게 하려고 합니다. 팀이 한 번 합의해 두면 검색·리뷰가 쉬워집니다.

기본 구현extension에 두되, “요구에 올릴지 / 익스텐션 전용인지”를 의도적으로 정합니다. 오버라이드와 정적·동적 디스패치가 엇갈리지 않게요.

모듈 밖에 노출하는 건 public protocol이고, 요구마다 전제(스레드, 실패, 재진입)을 한 줄이라도 밝히려고 합니다. 제네릭 쪽은 성능·명확성을 위해, 될 수 있으면 any보다 some이나 T: Protocol을 먼저 봅니다.

테스트에서는 맨 위 HTTP 예처럼 네트워크·시계·UUID 같은 경계를 프로토콜로 뽑습니다. Apple 프레임워크에서는 weak delegate와 AnyObject 제한 같이 ARC에 익숙한 패턴을 따르고, 레거시@objc optional이 아닌 새 Swift-only API를 짤 때는 빈 기본 구현이나 프로토콜 분리로 대체할 수 있는지 먼저 봅니다.

자주 하는 실수

  • 연관 타입이 있는 프로토콜을 “그냥” 타입 프로퍼티에 넣으려다가 에러가 나는 경우 — 제네릭/타입지우기/any로 풀기.
  • 익스텐션 기본 구현구체 타입 구현이중으로 존재해 의도와 다른 메서드가 호출되는 경우 — 요구에 올릴지, 익스텐션 전용인지 구분.
  • Shape[Shape]처럼 쓰다가(구버전) 최신 any/some 모델과 혼동하는 경우.
  • mutating 요구 + 클래스를 섞을 때 mutating 불일치로 컴파일이 실패하는 경우.
  • Codable 자동 합성이 깨지는 커스텀 타입/중첩CodingKeys·전략 점검.

20. 일반적 문제 해결 (Troubleshooting)

Protocol can only be used as a generic constraint가 뜨면, 타입 위치에 존재형이 필요한지 확인하고 any를 쓰거나, 제네릭 제약 T: P로 바꿀 수 있는지 봅니다. Type 'T' does not conform to protocol 'P'이면 요구 사항 누락, mutating/static 시그니처, Self 요구를 순서대로 점검합니다.

weak이 안 붙는다면 AnyObject를 상속한 클래스 전용 프로토콜인지, 저장 대상이 참조 타입인지 확인합니다. optional 메서드를 Swift-only 프로토콜에 쓰려다 막혔다면, @objc 런타임이 아닌 이상 기본 구현이 붙은 익스텐션이나 프로토콜 분리를 씁니다.

Hashable 합성이 실패하면 저장 프로퍼티 중 Hashable이 아닌 것이 없는지, hash(into:)수동으로 맞출 필요가 없는지 봅니다. Codable은 키 불일치, enum 연관 값, Date/부동소수 전략을 의심합니다. 성능이 의심되면 any·클로저 캡처·ARC를 넓게 의심하되, 결론은 Instruments로 냅니다. 제네릭과 특화로 줄일 수 있으면 그때 옮깁니다.


심화: Selfwhere — 프로토콜 중심 제약

프로토콜 메서드가 “나와 동일한 타입”을 인자·반환에 쓰고 싶을 때 Self를 씁니다. Equatable==는 사실 Self에 대한 대칭 연산입니다(합성/규칙은 표준 라이브러리 정의).

protocol Clonable {
    func cloned() -> Self
}

struct Box<T>: Clonable {
    var value: T
    func cloned() -> Box<T> { Box(value: value) }
}
  • 반환Self이면, 구체 준수 타입의 다른 곳에서 “베이스 프로토콜만 안다”가 되면 Self의 실체를 추론하기 어려울 수 있으므로, API 설계 시 호출 흐름을 먼저 정리하는 편이 좋습니다.
  • where는 “특정 프로토콜을 만족하는 Self에서만” 익스텐션을 열겠다는 조건부 준수에도 쓰입니다(제네릭과 동일한 아이디어, 키워드만 프로토콜 쪽에 맞게).
extension Array where Element: Hashable {
    func uniqued() -> [Element] {
        var seen = Set<Element>()
        return filter { seen.insert($0).inserted }
    }
}

where“타입이 커질수록 if 분기가 늘지 않게” 공통된 연산을 가능한 타입에만 붙이는 데 쓰입니다. 조건이 복잡해지면 부속 프로토콜로 나누는 쪽이 읽기 쉬울 때가 있습니다.


심화: some Collection / 프로토콜 제약의 일상적 쓰임

func takeFirst(_ c: some Collection) 형태는 “Collection을 만족하는 구체 하나”를 컴파일 시에 고정합니다. Array, String.SubSequence서로 다른 구체한 API에 넣고 싶다면, 보통 (1) 오버로드 (2) 제네릭 (3) any흡수 중 하나를 고릅니다. 성능이 민감하면 any는 마지막에 두고, “정말로 잡기 힘든 다양성”일 때만 씁니다.


심화: 리포지토리 패턴(데이터 경계) 예시

도메인User 같은 순수 모델을, 인프라UserDTO + URLSession을 담당한다고 보면, 그 사이를 프로토콜로 끊을 수 있습니다.

protocol UserRepository {
    func user(id: Int) async throws -> UserDTO
    func save(_ user: UserDTO) async throws
}

struct APIUserRepository: UserRepository {
    let client: HTTPClientProtocol
    let base: URL
    func user(id: Int) async throws -> UserDTO {
        var r = URLRequest(url: base.appendingPathComponent("users/\(id)"))
        let (d, _) = try await client.data(for: r)
        return try JSONDecoder().decode(UserDTO.self, from: d)
    }
    func save(_ user: UserDTO) async throws {
        // POST/PUT 생략
    }
}
  • 뷰 모델·Use CaseUserRepository만 알고, 테스트에서는 인메모리 구현으로 바꿉니다.
  • DTO·도메인 변환User 이니셜라이저·매퍼에 둘지, 리포지토리 에 둘지는 팀의 클린 아키텍처 수준에 따라 갈리지만, “경계 = 프로토콜”은 동일합니다.

심화: SwiftUI·Combine과 프로토콜

  • SwiftUI에서 View·EnvironmentKey·PreferenceKey는 프로토콜과 제네릭이 깊게 얽혀 있으며, some View는 앞서 설명한 불투명 반환이 전면에 드러난 사례입니다. 뷰 바디는 구체 View 타입을 숨기고, 문법 수준에서 조합·제약이 보장되도록 합니다.
  • CombinePublisher연관 타입 Output, Failure를 가집니다. AnyPublisher<Output, Failure>16절에서 말한 지우는 래퍼에 해당합니다. “여러 Publisher 구체를 한 프로퍼티에” 넣는 상황에서 자주 쓰입니다(프로젝트의 Combine·Concurrency 혼용 정책에 유의).

비동기 시리즈AsyncSequence·Task프로토콜을 엮는 패턴도, 본질은 “추상화된 소비/생산 + 테스트 대체”입니다.


심화: public / package / 모듈 가시성

프로토콜다른 모듈에 공개하려면 public protocol이 필요하고, 기본 구현이 있는 extension똑같이 public으로 맞출 때가 있습니다. package(Swift 5.9+) 등 이름 맞는 가시성은 스위프트/엑스코드 버전에 맞춰 [공식 문서]의 Access control을 확인하세요.

  • 빈 프로토콜(마커)을 공개·내부에 두고 가드를 거는 팀도 있지만, 빈 프로토콜 남용은 “타입이 무엇에 준수하는지” 읽기만 어려워질 수 있습니다.

18절 보강: 정적/동적 분기(직관)

func f<T: P>(_: T)는 제네릭에 한 구체가 열결되면서 T마다 특화·인라인 여지가 생기고, 핫 루프나 범용 알고리즘에 자주 씁니다. func f(_: any P)존재형으로 박싱이 생기고 메서드가 동적으로 골라질 수 있어, 서로 다른 구체 배열이나 콜백에 담을 때 씁니다. func f() -> some P내부 구체가 종류로 고정되고 바깥에는 숨기므로, API 표면의 안정과 캡슐화에 쓰입니다.

이 대응은 “절대 법”이 아니라, 최적화바이너리·최적화 수준에 따라 달라질 수 있으므로, 의도를 잡는 데 쓰고 최종은 측정하세요.


17절 보강: Codable·네트 스키마와의 정렬

  • 필드 추가: 서버가 필드를 늘릴 때를 대비하려면 decode 전략(키 없음 무시 등)이 중요해질 수 있습니다. 반대로 필드 제거/이름 변경앱/서버 동시 배포·버전 절약 정책이 필요할 수 있습니다.
  • enum with associated values: Codable 합성이 자동으로 안 풀리면 init(from:) / encode(to:)한 번에 밝히는 편이 안전할 때가 있습니다.
  • 센서티브 필드는 DTO·로그에 남지 않게 CustomStringConvertible분리하거나, dump·po 습관을 팀 룰로 잡는 것이 좋습니다(프로토콜 자체가 아니라 팀의 데이터 경계 이야기이지만, Swift에서는 이런 분리가 프로토콜·타입으로 드러납니다).

실무 체크리스트: 프로토콜 설계 리뷰용

아래 항목은 코드 리뷰리팩터링 때 질문 형태로 쓰기 좋습니다.

  1. 이 프로토콜의 “하나의 책임”은 무엇인가? — 읽기·쓰기·파싱·표시가 섞였다면 나눌 수 있는지.
  2. 기본 구현이 익스텐션에 있는가? — 구체 타입을 억지로 늘리지 않고도 공통 로직을 한곳에 모을 수 있는지.
  3. any를 쓰는 이유가 “다형적 컬렉션” 때문인가? — 그렇다면 성능을 측정할 위치(루프·리스트)를 팀이 아는지.
  4. delegate / closure / async 스트림경계에 맞는 도구를 골랐는가? — 작은 앱은 클로저로도 충분하고, 인터페이스가 커질수록 프로토콜이 낫습니다.
  5. 테스트에서 대체 가능한가? — 프로토콜 추출 없이 URLSession 싱글톤에 붙이면, 통합 테스트만 남는 경우가 많습니다.
  6. Objective-C브릿징이 필요한가? — @objc·optional·dynamic이유가 있을 때만(새로운 Swift API에는 드뭄).

문서·네이밍(팀 합의)

  • -able·-ing·…Providing·…Repository 같이 역할이 드러나는 이름이 검색·리뷰에 유리합니다.
  • public으로 노출될 프로토콜은 요구 사항마다 한 줄 주석 또는 DocC(가능하다면)로 “전제(스레드, 재진입, 실패)을 남깁니다.

some / any / 제네릭 — 선택 흐름(의사 결정)

  1. 한 함수의 모든 반환이 사실 같은 구체 타입이냐? → some 후보(조건이 단순할 때).
  2. 다른 구체를 하나의 매개로 받아야 하냐? → T: Protocol 제네릭 먼저(특화·읽기 쉬움).
  3. 런타임에 “무슨 구체인지” 여러 혼재 + 저장/배열? → any를 고려(비용·디버깅은 인지).
  4. 연관 타입이 있어 any제한될 땐? — 언어 버전·제약(예: any with associated type 제한은 Swift 진화에 따라 풀리는 중)을 릴스 노트로 확인. 필요하면 type erasure·제네릭 래퍼로 우회.

이런 “분기”는 시험 삼아 Playground/마이크로 벤치마크에서 한 바퀴 돌리면 감이 잡힙니다. 프로덕션 핫 경로는 꼭 프로파일러로 확인하세요.


예제: 미니멀 Iterator 이해를 돕는 타입 지우기

아래는 개념을 보이기 위한 축소 예시입니다(실제 AnyIterator·AnySequence는 훨씬 잘 다듬어져 있습니다).

struct AnyIntIterator: IteratorProtocol {
    typealias Element = Int
    private let _next: () -> Int?
    init(_ next: @escaping () -> Int?) { _next = next }
    mutating func next() -> Int? { _next() }
}

// 사용 측은 "내부가 Int 시퀀스인지" 몰라도 next만 호출
var it = AnyIntIterator { nil }
_ = it.next()
  • “프로토콜(연관 타입) + 저장 프로퍼티” 를 동시에 만족하려다 보면 이런 클로저 저장형이 자주 나옵니다(Combine·비동기 스트림 래퍼도 같은 미학).
  • 과도Any 접두 타입이 생기면, 계층이 기울고 있다는 신호일 수 있으니, some/any/제네릭으로 한 번 더 압축해 볼 수 있는지 봅니다.

심화: actor·@MainActor와 프로토콜

동시성 모델이 들어오면, “프로토콜 요구어느 실행자(executor)에서 만족되는가”를 함께 설계합니다. actor격리(isolation)를 제공하므로, @MainActor 프로토콜·타입은 UI 갱신 API에 자주 쓰입니다.

  • 스레드 안전프로토콜 이름에 녹이기 어렵다면(예: …Unsafe), 문서·리뷰 룰로 “어느 큐/액터에서만 호출”을 고정하는 편이 안전할 때가 있습니다.
  • async 요구가 있는 프로토콜은 await 경계가 호출부 전체에 퍼집니다. 가짜 async를 피우지 말고, I/O가 있을 때만 async로 두는 쪽이 API 거짓말을 줄입니다(세부는 비동기 글).

Sendable과 프로토콜(개념만)

Swift Concurrency는 스레드 간 전달 가능 여부에 Sendable을 씁니다. 값·참조 타입이 Sendable채택하거나(조건부), 클로저·타입이 암시/명시로 검사받는 경우가 있습니다. 프로토콜을 설계할 때, “이 타입이 Task·actor 경계를 넘나?”를 미리 팀이 합의하면, 나중에 @unchecked Sendable에 기대는 범위를 줄일 수 있습니다(정확한 규칙은 사용 중인 Swift 버전 문서를 따르세요).


(참고) Swift 시리즈 번호 — 이 글은 #05

저장소의 파일 slug는 swift-series-05-protocols입니다. 시리즈 목차에서 #08비동기 글에 할당되어 있으니, “시리즈 여덟 번째 = 프로토콜”이 아닙니다 — 주제를 찾을 때 파일·URL의 번호를 함께 확인하시면 혼동이 줄어듭니다. 프로토콜 다음 단계는 #06 제네릭 입니다.


합성(Synthesis)이 되는 이유, 안 되는 이유

Swift 컴파일러는 struct / enum특정 프로토콜에 대해 기계적으로 ==·hash·Codable 멤버를 생성해 줄 합성 규칙을 가집니다(프로퍼티가 모조건을 만족할 때). 반대로,

  • class상속/일관성 이슈로 Equatable·Hashable 합성이 기본으로 켜지지 않는 경우가 많고,
  • 수동 ==에 맞는 hash(into:)를 쓰지 않으면 Hashable 불일치로 버그가 날 수 있으며,
  • NSObject 하위·Objective-C 브릿 타입은 다른 동등·해시 의미론이 섞일 수 있습니다.

따라서 “프로토콜 = 계약”이라는 말의 실무적 의미는, Equatable·Hashable같다UI 표시 기준인지, ID 기준인지, DB 키 기준인지 문장으로 고정하라는 뜻이기도 합니다.

struct Row: Hashable, Equatable {
    let id: UUID
    var title: String  // id만 같으면 같다고 볼지, title까지 볼지 팀 룰
}
// id만으로 동일성을 쓰고 싶다면, 보통 id만으로 hash·==를 수동 정의

프로퍼티 래퍼·@Observable·프로퍼티 투사(projection)가 있는 Swift 버전·코드베이스라면, 실제로 비교/코딩에 어떤 저장이 들어가는지(투시 $ 쪽)까지 리뷰가 필요할 수 있습니다.

자주 겹치는 질문(본문 키워드)

  • “프로토콜 익스텐션의 메서드가 왜 virtual처럼 안 보이죠?” — Swift는 요구(프로토콜에 적힌 것) vs 익스텐션 전용(요구에 없는 것) 에 따라 디스패치가 달라질 수 있어, 에서 protocol 본문에 무엇을 올릴지 합의하는 것이 중요합니다(세부: 공식 프로토콜 장 + WWDC).
  • extension끼리 충돌하면?” — 우선 더 구체적인 제약이 이기는 특성이 있지만, 모호하면 컴파일어느 쪽도 고르지 못하고 에러를 냅니다. where한쪽좁히세요.
  • “C++ concept 느낌이 나나요?”“타입이 요구를 만족하는가” 를 컴파일이 검사한다는 은 비슷하지만, 위트니스·합성·Objective-C까지 포함한 Swift 전체가 한 덩이입니다(문법만 1:1로 대응하진 않습니다).

용어 짧은 정리(검색용)

위트니스 테이블은 구체 타입이 프로토콜 요구를 어떤 구현으로 충족하는지 런타임이 연결하는 구조(개념적으로)를 가리킵니다. Existential(any P)은 P를 만족하는 알 수 없는 구체를 담는 상자이고, Swift 버전에 따라 쓰임이 조금씩 진화합니다. 지우기(Type erasure) 는 복잡한 제네릭·연관 타입을 한 겉 타입으로 감싸 저장·반환을 단순화하는 패턴입니다. 불투명 타입(some P)은 내부 구체가 종류이면서 API 표면에는 P로만 드러내는 것이고, 연관 타입은 프로토콜 안의 이름 붙은 자리로, 구체는 준수 타입이 정합니다.

읽는 순서 제안(시간이 제한될 때)

  1. 1~4·8·9·10·11절(정의·기본 구현·연관·existential·some)
  2. 12~14·17절(표준 Codable·의존성·실전)
  3. 18~20·심화 절(성능·트러블슈팅·Self·리포지토리)

시간이 맞으면 16절(지우기)과 Combine/SwiftUI 보강을 한 번씩만 훑어도, 실무 대화에 필요한 80%는 충족됩니다.

이 길이 기준 약 600줄 전후(코드 포함)이며, 읽는 시간은 프로젝트빌드/배포 루틴에 맞게 나누어 보시면 됩니다. 본문을 인쇄·PDF로 쓰실 경우, 코드 블록이 다음 쪽으로 잘리지 않게 조판만 확인하세요.

맨 위 frontmatterreadingMinutes·summaryTop·description는 사이트 메타/SEO에 쓰일 수 있으니, 원문을 그대로 두었을 때 콘솔/검수 도구로 한 번씩 미리보기해 보는 것이 좋습니다.

(본문·코드는 Swift 5.x~6.x 일반적 사용을 기준으로 썼으며, any/some·동시성 키워드는 툴체인 버전에 맞게 조정하세요.) 버전 고정이 필요한 팀은 Package.swift / Xcode Swift Language Version을 문서에 같이 밝히면 재현 토론이 줄어듭니다.


1~4절에서 다룬 기본 정의(요약 예제)

기본 프로토콜

protocol Drawable2 {
    func draw()
    var color: String { get set }
}
struct Circle2: Drawable2 {
    var color: String
    func draw() { print("\(color) 원 그리기") }
}
let circle2 = Circle2(color: "빨강")
circle2.draw()

extension (정수)

extension Int {
    func squared() -> Int { self * self }
    var isEven: Bool { self % 2 == 0 }
}
print(5.squared())
print(4.isEven)

기본 구현(인사)

protocol Greetable2 {
    var name: String { get }
    func greet()
}
extension Greetable2 {
    func greet() { print("안녕하세요, \(name)님!") }
}
struct Person2: Greetable2 { let name: String }
let person2 = Person2(name: "홍길동")
person2.greet()

정리

  1. Protocol: “무엇을 구현할지”의 계약. 값·참조 타입이 준수할 수 있다.
  2. Extension: 기본 구현·조건부 준수·가독성을 위한 구현 분리.
  3. POP: 상속 대신 프로토콜 조합 + 기본 구현으로 공통점을 담는다.
  4. any / some / 제네릭: “박싱/불투명/특화”의 트레이드오프를 의도에 맞게.
  5. 위트니스 테이블: 런타임 다형성의 비용을 과도한 any로 키우지 말고 측정하라.
  6. 실무: Codable DTO, HTTP 클라이언트 프로토콜, weak delegate, 작은 프로토콜로 테스트 경계를 연다.

다음 단계


비교·대안 (한눈에)

프로토콜과 구조체를 쓰면 값 타입을 유지하면서 조합·테스트가 쉬워지는 대신, any남용하면 비용과 인지 부하가 커질 수 있습니다. 상속 기반 베이스 클래스는 UIKit·레거시와 맞닿는 지점에선 여전히 정합이 좋고, 대신 결합과 거대한 베이스가 눈에 띄는 단점이 있습니다. 제네릭만 쓰면 정적 특화에 유리하지만, 시그니처가 길어지고 읽는 사람에게 부담이 될 수 있어, 프로젝트 팀이 감당할 복잡도 안에서 고릅니다.


추가 리소스


관련 글

배포 전 워크플로: 변경 사항을 git addgit commitgit push한 뒤, 프로젝트에 맞는 빌드·배포 스크립트(예: npm run deploy)를 실행하세요.