🔥 프로토콜을 타입으로 사용하기

614자
7분

프로토콜은 그 자체로는 어떤 기능도 구현하지 않습니다. 그럼에도 불구하고, 프로토콜을 코드에서 타입으로 사용할 수 있어요.

프로토콜을 타입으로 사용하는 가장 일반적인 방법은 제네릭 제약 조건으로 프로토콜을 사용하는 것입니다. 제네릭 제약 조건이 있는 코드는 프로토콜을 준수하는 모든 타입과 함께 작동할 수 있으며, 특정 타입은 API를 사용하는 코드에 의해 선택됩니다. 예를 들어, 인자를 받는 함수를 호출할 때 해당 인자의 타입이 제네릭이라면, 호출하는 쪽에서 타입을 선택하게 됩니다.

protocol Greetable {
    func greet() -> String
}
 
class Person: Greetable {
    var name: String
 
    init(name: String) {
        self.name = name
    }
 
    func greet() -> String {
        return "Hello, my name is \(name)."
    }
}
 
class Robot: Greetable {
    var model: String
 
    init(model: String) {
        self.model = model
    }
 
    func greet() -> String {
        return "Beep boop, I am \(model)."
    }
}
 
func introduce<T: Greetable>(greeter: T) {
    print(greeter.greet())
}
 
let person = Person(name: "John")
let robot = Robot(model: "RX-78")
 
introduce(greeter: person) // "Hello, my name is John." 출력
introduce(greeter: robot)  // "Beep boop, I am RX-78." 출력
swift

위 코드에서 introduce 함수는 Greetable 프로토콜을 준수하는 어떤 타입이든 받을 수 있습니다. 호출하는 쪽에서 Person이나 Robot을 선택하여 전달할 수 있죠.

불투명 타입(Opaque type)을 사용하는 코드는 프로토콜을 준수하는 어떤 타입과도 작동합니다. 기본 타입은 컴파일 시점에 알려져 있고, API 구현부에서 해당 타입을 선택하지만, 해당 타입의 정체는 API의 클라이언트로부터 숨겨집니다. 불투명 타입을 사용하면 함수의 특정 반환 타입을 숨기고 값이 주어진 프로토콜을 준수한다는 것만 보장함으로써 API의 구현 세부 정보가 추상화 계층을 통해 누출되는 것을 방지할 수 있습니다.

protocol Shape {
    func draw() -> String
}
 
struct Triangle: Shape {
    var size: Int
    func draw() -> String {
        var result = [String]()
        for length in 1...size {
            result.append(String(repeating: "*", count: length))
        }
        return result.joined(separator: "\n")
    }
}
 
struct Square: Shape {
    var size: Int
    func draw() -> String {
        let line = String(repeating: "*", count: size)
        let result = Array(repeating: line, count: size)
        return result.joined(separator: "\n")
    }
}
 
struct FlippedShape<T: Shape>: Shape {
    var shape: T
    func draw() -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}
 
struct JoinedShape<T: Shape, U: Shape>: Shape {
    var top: T
    var bottom: U
    func draw() -> String {
        return top.draw() + "\n" + bottom.draw()
    }
}
 
func makeTrapezoid() -> some Shape {
    let top = Triangle(size: 2)
    let middle = Square(size: 2)
    let bottom = FlippedShape(shape: top)
    let trapezoid = JoinedShape(top: top, bottom: JoinedShape(top: middle, bottom: bottom))
    return trapezoid
}
 
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
/*
  *
  **
  **
  **
  **
  *
*/
swift

위 코드에서 makeTrapezoid 함수는 some Shape을 반환합니다. 이는 반환되는 값이 Shape 프로토콜을 준수한다는 것만 보장하고, 실제 타입은 숨깁니다.

박스 프로토콜 타입(Boxed protocol type)을 사용하는 코드는 런타임에 선택된 프로토콜을 준수하는 모든 타입과 작동합니다. 이런 런타임 유연성을 지원하기 위해 Swift는 필요할 때 box라고 알려진 간접층을 추가하는데, 이는 성능 비용이 발생합니다. 이런 유연성 때문에 Swift는 컴파일 시점에 기본 타입을 알 수 없으므로, 프로토콜에서 요구하는 멤버에만 접근할 수 있습니다. 기본 타입의 다른 API에 접근하려면 런타임에 캐스팅해야 합니다.

protocol Drawable {
    func draw() -> String
}
 
func drawACat(_ d: Drawable) {
    print(d.draw())
}
 
struct CatFace: Drawable {
    func draw() -> String {
        return "/\\\\_/\\\\\n( o o )\n \\\\~/"
    }
 
    func meow() {
        print("Meow!")
    }
}
 
let face = CatFace()
drawACat(face)
// face.meow() // 에러: Drawable 프로토콜에는 meow() 메서드가 없음
(face as CatFace).meow() // "Meow!" 출력 - 런타임 캐스팅 필요
swift

위 코드에서 drawACat 함수는 Drawable 프로토콜을 준수하는 어떤 타입이든 받을 수 있습니다. 하지만 Drawable 프로토콜에 정의되지 않은 메서드에 접근하려면 런타임에 실제 타입으로 캐스팅해야 합니다.

제네릭 제약 조건으로 프로토콜을 사용하는 방법에 대한 자세한 내용은 제네릭을 참조하세요. 불투명 타입과 박스 프로토콜 타입에 대한 자세한 내용은 불투명 타입과 박스 타입을 참조하시면 됩니다.

프로토콜을 타입으로 사용하는 기능은 Swift에서 추상화와 다형성을 강력하게 지원하는 핵심 요소입니다. 이를 통해 코드의 유연성과 재사용성을 크게 높일 수 있으니, 꼭 익혀두시는 것이 좋겠죠?