프로토콜(Protocol)

moonazn·2025년 8월 25일

Swift

목록 보기
10/11

🪏 프로토콜

: 타입이 따라야 할 규약(약속, 인터페이스)

  • '이 메서드/프로퍼티는 반드시 구현해야 한다'고 강제하는 문서와 같다.
  • Swift는 단일 상속만 지원하기 때문에, 다중 상속이 필요한 상황에서 프로토콜을 활용할 수 있다.
  • Swift는 Protocol-Oriented Programming(POP)을 강조하는 만큼 프로토콜 활용을 적극 권장한다.
  • Swift의 모든 타입(class, structure, enum)이 프로토콜을 채택할 수 있다.

🧰 1. 역할

1) 행동 규약(Contract)

타입이 가져야 할 프로퍼티&메서드를 정의한다.

// 프로토콜 선언
protocol Animal {
    var name: String { get }      // 읽기 전용 프로퍼티
    func makeSound()              // 메서드 요구사항
}

// 프로토콜 채택
struct Dog: Animal {
    var name: String
    
    func makeSound() {
        print("멍멍!")
    }
}

struct Cat: Animal {
    var name: String
    
    func makeSound() {
        print("야옹~")
    }
}
  • Animal을 채택하면 name 속성과 makeSound() 메서드를 반드시 구현해야 한다.

2) 추상화 & 다형성

서로 다른 구체 타입을 같은 타입처럼 다룰 수 있다.

// 프로토콜을 타입처럼 사용
func greet(_ animal: Animal) {
    print("Hello, \(animal.name)")
    animal.makeSound()
}

let dog = Dog(name: "바둑이")
let cat = Cat(name: "나비")

greet(dog) // Hello, 바둑이 \n 멍멍!
greet(cat) // Hello, 나비 \n 야옹~
  • Dog, Cat처럼 서로 다른 타입이더라도 Animal 프로토콜을 채택하면 공통 인터페이스로 다룰 수 있다.

3) 구현과 선언 분리

"무엇을 할 수 있다"만 정의하고, "어떻게 한다"는 구현체가 책임진다.

4) 확장성과 유연성

라이브러리/프레임워크 코드를 바꾸지 않고도 새로운 타입이 프로토콜을 채택하면 기능을 연결할 수 있다.

  • 예시: Swift 표준 라이브러리의 Equatable, Hashable 등

🍚 2-1. 프로토콜 확장 (Protocol Extension)

공통 메서드에 대한 기본 구현을 제공할 수 있다.

extension Animal {
    func greet() {
        print("Hi, I'm \(name)")
    }
}

struct Dog: Animal {
    let name: String
    func makeSound() { print("멍멍") }
}

Dog(name: "바둑이").greet() // "Hi, I'm 바둑이"

🍚 2-2. 프로토콜 합성 (Protocol Composition)

여러 프로토콜을 동시에 요구하여 사용할 수 있다.

func describe(_ object: CustomStringConvertible & Equatable) {
    print(object.description)
}

🍚 2-3. Associated Type (연관 타입)

제네릭처럼 프로토콜 안에서 타입 파라미터를 선언하여 사용할 수 있다.

protocol Container {
    associatedtype Item
    func add(_ item: Item)
    func getAll() -> [Item]
}

🍚 2-4. 프로토콜 상속

다른 프로토콜을 상속받아 요구사항을 확장할 수 있다.

protocol Readable {
    func read()
}

protocol Writable: Readable {
    func write()
}

🍚 2-5. optional 프로토콜 선언 (Objective-C 스타일)

  • @objc protocol로 선언하면 옵셔널 프로퍼티(구현 필수 ❌, 선택적으로 원하는 경우에만 구현해도 됨)를 선언할 수 있다.
@objc protocol TableViewDelegate {
    @objc optional func numberOfRows() -> Int
    @objc optional func didSelectRow(index: Int)
}

class MyDelegate: TableViewDelegate {
    // 둘 중 원하는 것만 구현 가능
    func numberOfRows() -> Int { 10 }
}

let delegate: TableViewDelegate = MyDelegate()
delegate.didSelectRow?(index: 2) // ?로 안전하게 호출
  • UIKit의 UITableViewDelegate 같은 델리게이트 프로토콜이 이 방식을 사용한다.
  • ⚠️ struct나 enum에서는 사용할 수 없다. 오직 class에서만 가능 ⭐️
    • Objective-C에서 프로토콜은 class 전용에서만 채택할 수 있기 때문이다.
    • @objc protocol은 항상 class-only가 되고, 이는 : AnyObject 제약이 암묵적으로 추가된 것과 동일하다. (컴파일러 내부적으로 취급하는 방식)

➕ Swift스러운 선택적 구현 활용 방법 (@objc optional을 사용하지 않는 방법)

1. 프로토콜 확장 기본 구현

extension에 기본 동작을 넣고, 필요한 타입만 override한다.

2. 옵셔널 클로저 프로퍼티

var onTap: (() -> Void)? 같은 콜백을 두고, 필요할 때만 세팅한다.


🥇 1급 객체(First-Class Citizen)

  1. 변수/상수에 담을 수 있다.
  2. 함수의 인자로 전달할 수 있다.
  3. 함수의 반환값으로 사용할 수 있다.
  4. 동적으로 생성/할당 가능하다.

위의 조건을 만족한다면, 프로그래밍 언어에서 해당 요소를 1급 객체로 간주한다.

  • Swift에서 Int, String, 함수, 클로저와 같은 타입들은 1급 객체이다.

▶︎ 프로토콜은 1급 객체

Swift에서 프로토콜은 타입으로 취급되기 때문에, 변수 타입, 파라미터 타입, 반환 타입으로 사용이 가능하다.

➡️ 즉, 1급 객체로 동작

1. 변수에 담기

프로토콜을 타입으로 사용하여 변수에 담는 것은 해당 프로토콜을 채택한 구조체/클래스/열거형의 인스턴스를 프로토콜로 "타입 캐스팅" 해서 사용한다는 의미이다.

protocol Shape {
    func area() -> Double
}

struct Square: Shape {
    var length: Double
    func area() -> Double { length * length }
}

struct Circle: Shape {
    var radius: Double
    func area() -> Double { .pi * radius * radius }
}

// 프로토콜 타입 변수
var shape: Shape

shape = Square(length: 5)
print(shape.area()) // 25

shape = Circle(radius: 3)
print(shape.area()) // 28.27...

2. 함수 인자/반환값으로 전달

프로토콜은 자체적으로 인스턴스를 생성할 수 없기 때문에 해당 프로토콜을 준수하는 구조체/클래스/열거형 인스턴스를 "프로토콜 타입으로 캐스팅"해서 전달한다.

func printArea(of shape: Shape) {
    print("Area:", shape.area())
}

printArea(Square(length: 4))  // Area: 16
printArea(Circle(radius: 2))  // Area: 12.566...
  • 프로토콜을 함수 인자의 추상 타입으로 사용할 수 있다. (다형성✅)
func makeRandomShape() -> Shape {
    Bool.random() ? Square(length: 3) : Circle(radius: 5)
}

let randomShape = makeRandomShape()
print(randomShape.area())
  • 반환 타입을 Shape으로 지정하면, 어떤 구체 타입이 오더라도 Shape로 다룰 수 있다.

➡️ "존재 타입 (Existential)"

Swift에서 위와 같이 Shape를 변수/인자/반환에 직접 사용하는 것을 존재 타입이라고 한다.

let s: Shape = Square(length: 5) // Shape 타입 변수
print(s.area())
  • s는 Shape를 만족하는 어떤 것일 뿐, 정확히 무슨 타입인지 모름.
  • Swift는 Existential Container라는 래퍼를 만들고, 런타임에 실제 타입을 확인하여 메서드를 호출.
    • 👍🏻 장점: 다양한 타입을 한 배열에 섞어 담기가 가능하다.
    • 👎🏻 단점: 런타임 오버헤드가 조금 생긴다.
let shapes: [Shape] = [Square(length: 5), Circle(radius: 3)]
for shape in shapes { print(shape.area()) }

vs 1) Generics (컴파일 타임 확정❗️)

func doubleArea<T: Shape>(_ shape: T) -> Double {
    shape.area() * 2
}
  • T는 Shape를 준수하는 구체 타입으로 컴파일 타임에 확정된다.
    • 👍🏻 장점: 런타임 오버헤드가 없으며 최적화에 유리하다.
    • 👎🏻 단점: 위의 shapes 배열처럼 여러 다른 타입을 한 컨테이너에 담기는 힘들다.

vs 2) some Shape (Opaque Return Type)

func makeShape() -> some Shape {
    Square(length: 10)
}
  • 반환 타입을 구체적이지만 감춘다는 의미이다.
  • 컴파일러는 내부적으로 Square라고 확정하지만, 외부 사용자는 Shape를 반환한다는 것만 알 수 있음.
    • 👍🏻 장점: Generics처럼 성능 최적화에 유리하며 캡슐화 특성을 가진다.
    • 👎🏻 단점: 반드시 한 가지 고정 타입만 반환해야 한다.

🖋️ 비교 정리 표

위 세 가지 경우의 차이를 이해하고 적절한 상황에 적용해야 한다.

방식타입 결정 시점내부 동작장점단점
Shape (Existential)런타임Existential Container 생성 + 동적 디스패치다양한 타입을 한 컨테이너에 담기 가능런타임 오버헤드(성능 손해)
<T: Shape> (Generic)컴파일 타임구체 타입별로 코드가 생성됨성능 최적화, 강한 타입 안정성서로 다른 타입을 한 배열에 담기 어려움
some Shape (Opaque)컴파일 타임구체 타입 확정, 외부에는 숨김성능 최적화 + API 캡슐화반환 타입은 하나로 고정해야 함
profile
개발 공뷰

0개의 댓글