Swift 프로토콜 지향 프로그래밍 — Protocol과 Extension

프로토콜 지향 프로그래밍이란?

Swift는 클래스 상속 중심의 OOP 대신 프로토콜 지향 프로그래밍(Protocol-Oriented Programming, POP)을 권장합니다. 프로토콜은 타입이 따라야 할 인터페이스를 정의하고, 프로토콜 확장으로 기본 구현을 제공합니다. 구조체와 열거형에도 적용할 수 있어 참조 타입의 문제(의도치 않은 공유 상태)를 피할 수 있습니다.

이 글에서는 프로토콜의 기본부터 연관 타입, 프로토콜 합성까지 실전 패턴을 정리합니다.

프로토콜 기본

프로토콜은 프로퍼티와 메서드의 청사진을 정의합니다. 타입이 프로토콜을 채택(conform)하면 해당 요구사항을 반드시 구현해야 합니다.

import Foundation

// 프로토콜 정의
protocol Describable {
    var description: String { get }          // 읽기 전용 프로퍼티
    func summarize() -> String               // 메서드
}

protocol Identifiable {
    var id: String { get }
}

// 구조체에서 프로토콜 채택
struct User: Describable, Identifiable {
    let id: String
    let name: String
    let email: String

    var description: String {
        return "\(name) (\(email))"
    }

    func summarize() -> String {
        return "사용자 \(id): \(name)"
    }
}

struct Product: Describable, Identifiable {
    let id: String
    let name: String
    let price: Int

    var description: String {
        return "\(name)\(price)원"
    }

    func summarize() -> String {
        return "상품 \(id): \(name) (\(price)원)"
    }
}

// 프로토콜 타입으로 다형성 사용
func printSummaries(_ items: [Describable]) {
    for item in items {
        print(item.summarize())
    }
}

let user = User(id: "U001", name: "홍길동", email: "hong@example.com")
let product = Product(id: "P001", name: "키보드", price: 89000)

printSummaries([user, product])
// 실행 결과:
// 사용자 U001: 홍길동
// 상품 P001: 키보드 (89000원)

프로토콜 확장과 기본 구현

프로토콜 확장(Protocol Extension)으로 기본 구현을 제공하면, 채택하는 타입에서 별도 구현 없이 사용할 수 있습니다.

import Foundation

// 프로토콜 정의
protocol Loggable {
    var logPrefix: String { get }
}

// 프로토콜 확장 — 기본 구현 제공
extension Loggable {
    var logPrefix: String {
        return String(describing: type(of: self))
    }

    func log(_ message: String) {
        let timestamp = ISO8601DateFormatter().string(from: Date())
        print("[\(timestamp)] [\(logPrefix)] \(message)")
    }

    func logError(_ message: String) {
        log("ERROR: \(message)")
    }
}

// Comparable을 확장하여 범위 제한 기능 추가
extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        return min(max(self, range.lowerBound), range.upperBound)
    }
}

// Collection 확장 — 안전한 인덱스 접근
extension Collection {
    subscript(safe index: Index) -> Element? {
        return indices.contains(index) ? self[index] : nil
    }
}

// 프로토콜 채택 — 기본 구현 자동 사용
struct OrderService: Loggable {
    func processOrder(id: String) {
        log("주문 \(id) 처리 시작")
        // 비즈니스 로직...
        log("주문 \(id) 처리 완료")
    }
}

struct PaymentService: Loggable {
    let logPrefix = "Payment"  // 기본 구현 재정의

    func charge(amount: Int) {
        log("결제 \(amount)원 요청")
    }
}

let orderService = OrderService()
orderService.processOrder(id: "ORD-001")
// 실행 결과:
// [2026-02-22T15:00:00Z] [OrderService] 주문 ORD-001 처리 시작
// [2026-02-22T15:00:00Z] [OrderService] 주문 ORD-001 처리 완료

let paymentService = PaymentService()
paymentService.charge(amount: 50000)
// 실행 결과:
// [2026-02-22T15:00:00Z] [Payment] 결제 50000원 요청

// Comparable 확장 사용
let temperature = 35
let safe = temperature.clamped(to: 0...30)
print("원래: \(temperature), 제한: \(safe)")
// 실행 결과: 원래: 35, 제한: 30

// 안전한 인덱스 접근
let items = ["A", "B", "C"]
print("items[1]: \(items[safe: 1] ?? "없음")")  // B
print("items[5]: \(items[safe: 5] ?? "없음")")  // 없음
// 실행 결과:
// items[1]: B
// items[5]: 없음

연관 타입 (Associated Types)

연관 타입은 프로토콜 내부에서 사용할 타입을 추상화합니다. 제네릭과 결합하여 타입 안전한 인터페이스를 정의할 수 있습니다.

import Foundation

// 연관 타입을 가진 프로토콜
protocol Repository {
    associatedtype Entity       // 구현 시 구체 타입 결정
    associatedtype ID: Hashable

    func findById(_ id: ID) -> Entity?
    func findAll() -> [Entity]
    func save(_ entity: Entity) -> Entity
    func deleteById(_ id: ID) -> Bool
}

// 구체 타입으로 구현
struct Article {
    let id: Int
    var title: String
    var content: String
}

class ArticleRepository: Repository {
    // Entity = Article, ID = Int 으로 결정됨
    private var storage: [Int: Article] = [:]

    func findById(_ id: Int) -> Article? {
        return storage[id]
    }

    func findAll() -> [Article] {
        return Array(storage.values)
    }

    func save(_ entity: Article) -> Article {
        storage[entity.id] = entity
        return entity
    }

    func deleteById(_ id: Int) -> Bool {
        return storage.removeValue(forKey: id) != nil
    }
}

// 제네릭 함수에서 프로토콜 사용
func printAllEntities<R: Repository>(from repo: R) where R.Entity: CustomStringConvertible {
    let all = repo.findAll()
    for entity in all {
        print("  - \(entity)")
    }
}

// Article을 CustomStringConvertible로 확장
extension Article: CustomStringConvertible {
    var description: String {
        return "[\(id)] \(title)"
    }
}

let repo = ArticleRepository()
let _ = repo.save(Article(id: 1, title: "Swift POP 가이드", content: "..."))
let _ = repo.save(Article(id: 2, title: "SwiftUI 시작하기", content: "..."))

if let article = repo.findById(1) {
    print("찾은 게시글: \(article)")
}
// 실행 결과: 찾은 게시글: [1] Swift POP 가이드

print("전체 게시글:")
printAllEntities(from: repo)
// 실행 결과:
// 전체 게시글:
//   - [1] Swift POP 가이드
//   - [2] SwiftUI 시작하기

프로토콜 합성과 some/any

여러 프로토콜을 조합하여 타입 제약을 표현할 수 있습니다. Swift 5.7 이후 someany 키워드로 존재 타입과 불투명 타입을 구분합니다.

import Foundation

protocol Cacheable {
    var cacheKey: String { get }
    var ttl: Int { get }  // 초 단위
}

protocol Serializable {
    func toJSON() -> String
}

// 프로토콜 합성 — 여러 프로토콜 동시 채택
struct CachedUser: Cacheable, Serializable, CustomStringConvertible {
    let id: String
    let name: String

    var cacheKey: String { "user:\(id)" }
    var ttl: Int { 3600 }

    var description: String { "\(name) (캐시: \(cacheKey))" }

    func toJSON() -> String {
        return "{\"id\": \"\(id)\", \"name\": \"\(name)\"}"
    }
}

// 프로토콜 합성 타입으로 매개변수 정의
func cacheAndSerialize(_ item: any Cacheable & Serializable) {
    print("캐시 키: \(item.cacheKey), TTL: \(item.ttl)초")
    print("JSON: \(item.toJSON())")
}

// some — 불투명 반환 타입 (구체 타입 숨김, 타입 정보 유지)
func createDefaultUser() -> some Cacheable & Serializable {
    return CachedUser(id: "default", name: "기본 사용자")
}

let user = CachedUser(id: "U001", name: "홍길동")
cacheAndSerialize(user)
// 실행 결과:
// 캐시 키: user:U001, TTL: 3600초
// JSON: {"id": "U001", "name": "홍길동"}

let defaultUser = createDefaultUser()
print("기본 사용자 캐시 키: \(defaultUser.cacheKey)")
// 실행 결과: 기본 사용자 캐시 키: user:default

실전 팁

  • 구조체 + 프로토콜 우선: 클래스 상속보다 구조체 + 프로토콜 채택을 기본으로 사용합니다. 값 타입은 의도치 않은 상태 공유를 방지합니다.
  • 프로토콜 확장으로 기본 구현: 모든 채택 타입에 공통 로직이 있으면 프로토콜 확장에 기본 구현을 제공합니다.
  • 작은 프로토콜: 하나의 큰 프로토콜보다 여러 작은 프로토콜을 정의하고 합성합니다 (인터페이스 분리 원칙).
  • 연관 타입은 제네릭 대안: 프로토콜 내부에서 타입을 추상화할 때 연관 타입을 사용합니다.
  • some vs any: some은 구체 타입 정보가 유지되어 성능이 좋고, any는 다양한 구체 타입을 담을 수 있어 유연합니다. 기본적으로 some을 사용하고, 이종 컬렉션이 필요할 때 any를 사용합니다.

이 글이 도움이 되었나요?