[iOS/Swift - POP] 프로토콜 지향 프로그래밍(Protocol Oriented Programing) 알아보기 - 1

프로토콜(Protocol)

프로토콜 지향 프로그래밍(POP, Protocol Oriented Programing)를 정리해보려고 해요 😗
첫 번째로 프로토콜에 대해 얘기를 해보고, 차차 익스텐션, 제네릭 등 ... 알아보도록 할게요 !

애플은 2015년 WWDC에서 스위프트 2를 소개하면서 세계 최초의 프로토콜지향 프로그래밍 언어라고 발표했다.

프로토콜지향 프로그래밍은 단지 프로토콜뿐만이 아닌 더 많은 것을 포함하고 있다.

프로토콜지향 프로그래밍은 애플리케이션을 개발하는 새로운 방법일 뿐만 아니라
애플리케이션 설계에 대해 어떻게 생각해야 하는지에 관한 새로운 방법이기도 하다.

객체지향? 프로토콜지향? 🤔

객체지향

객체지향 방식으로 애플리케이션을 설계하는 경우 보통은 클래스 계층 구조와 객체가 어떻게 상호작용하는지에 중점을 두고 설계를 시작한다.

여기서 객체 란 프로퍼티 형태로 개체의 속성에 관한 정보를 가지며,
메소드 형태로 객체가 수행하는 행위를 포함하는 자료구조이다.

객체에 기대하는 속성과 행위가 무엇인지를 애플리케이션에게 전달해주는 청사진 없이는 객체를 생성할 수 없다.
대부분의 객체지향 언어에서 이러한 청사진은클래스 형태로 나타난다.
클래스는 객체의 프로퍼티와 행위를 단일 타입으로 캡슐화하는 구성체다.

프로토콜 지향

프로토콜지향 방식으로 애플리케이션을 설계하는 것은 객체지향 방식으로 설계하는 것과는 상당한 차이가 있다.

프로토콜지향 설계에서는 클래스 계층 구조로 시작하는 대신 프로토콜로 시작해야 한다.
프로토콜지향 설계는 단지 프로토콜뿐만이 아닌 더 많은 것을 포함하고 있기는 하지만,
프로토콜이 이러한 설계의 기반이라고 생각할 수 있다.


프로토콜이란?

프로토콜은 특정 역할을 하기 위한 메서드, 프로터티, 기타 요구사항 등의 청사진을 정의한다.

구조체, 클래스, 열거형은 프로토콜을 채택해서 특정 기능을 실행하기 위한 프로토콜의 요구사항을 실제로 구현할 수 있다.
어떤 프로토콜의 요구사항을 모두 따르는 타입은 '해당 프로토콜을 준수한다'고 표현한다.

타입에서 프로토콜의 요구사항을 충족시키려면 프로토콜이 제시하는 청사진의 기능을 모두 구현해야하며,
프로토콜은 정의를 하고 제시를 할 뿐이지 스스로 기능을 구현하지 않는다.

스위프트 표준 라이브러리는 프로토콜에 기반을 둔다.
그렇기 때문에 애플은 개발자에게 프로토콜지향 패러다임을 사용하게 권장할 뿐만 아니라,
애플 스스로 스위프트 표준 라이브러리에서 프로토콜을 사용하고 있다.


프로토콜 채택

프로토콜은 구조체, 클래스, 열거형의 모양과 비슷하게 정의할 수 있으며 protocol 키워드를 사용한다.

protocol Person {
  	'프로토콜 정의'
}

struct Fezz: Person {
 		... 
}

이런식으로 채택해 줄 수 있으며, 만약 클래스가 다른 클래스를 상속받는다면
상속받을 클래스 이름 다음에 채택할 프로토콜을 나열한다.

프로토콜 요구사항

프로토콜은 타입이 특정 기능을 실행하기 위해 필요한 기능을 요구한다.
프로토콜이 자신을 채택한 타입에 요구하는 사항은 프로퍼티나 메서드와 같은 기능이다.

프로퍼티 요구

프로토콜은 자신을 채택한 타입이 어떤 프로퍼티를 구현해야 하는지 요구할 수 있다.
그렇지만 연산 프로퍼티인지, 저장 프로퍼티인지는 따로 신경쓰지 않는다.

  • 프로퍼티를 읽기전용으로 할지 또는 읽기, 쓰기 가 모두 가능하게 할지는 프로토콜이 정해줘야한다.
  • 프로토콜의 프로퍼티 요구사항은 항상 var 키워드를 사용한 변수 프로퍼티로 정의한다.
  • 클래스 타입 프로퍼티에는 상속 가능한 타입 프로퍼티인 class 타입 프로퍼티와
    상속 불가능한 static 타입 프로퍼티가 있지만 이 두 타입 프로퍼티를 따로 구분하지 않고 모두 static 키워드를 사용한다.
  • 프로토콜에서 요구한 프로퍼티는 읽기 전용 프로퍼티였지만,
    실제로 프로토콜을 채택한 클레스에서 구현할 때는 읽고 쓰기 모두 가능한 프로퍼티로 구현해도 문제가 되지 않는다.

메서드 요구

프로토콜은 특정 인스턴스 메서드나 타입 메서드를 요구할 수도 있다.

  • 메서드의 실제 구현부인 중괄호({}) 부분은 제외하고 메서드의 이름, 매개변수, 반환 타입 등만 작성하며 가변 매개변수도 허용한다.
  • 프로토콜의 메서드 요구에서는 매개변수 기본값을 지정할 수 없다.

가변 메서드 요구

가끔 메서드가 인스턴스 내부의 값을 변경할 필요가 있다.

값 타입(구조체, 열거형)의 인스턴스 메서드에서 자신 내부의 값을 변경하고자 할 때는
메서드의 func 키워드 앞에 mutating 키워드를 적어 메서드에서 인스턴스 내부의 값을 변경한다는 것을 확실히 해준다.

프로토콜에 mutating 키워드를 사용한 메서드 요구가 있다고 해도 클래스 구현에서는 제외해도 된다.


이니셜라이저 요구

프로토콜은 프로퍼티, 메서드 등과 마찬가지로 특정한 이니셜라이저를 요구할 수 있다.

protocol Person {
  	var name: String { get }
  	
  	init(name: String)
}

class Fezz: Person {
  	var name: String
  
  	required init(name: String) {
        self.name = name
    }
}

클래스 타입에서 프로토콜의 이니셜라이저 요구에 부합하는 이니셜라이저를 구현할 때는
이니셜라이저가 지정 이니셜라이저인지 편의 이니셜라이저인지는 중요하지 않다.
그러나 이니셜라이저 요구에 부합하는 이니셜라이저를 구현할 때는 required 식별자가 필요하다


프로토콜의 상속과 클래스 전용 프로토콜

프로토콜은 하나 이상의 프로토콜을 상속받아 기존 프로토콜의 요구사항보다 더 많은 요구사항을 추가할 수 있다.
프로토콜의 상속 리스트에 class 키워드를 추가해 프로토콜이 클래스 타입에만 채택될 수 있도록 제한할 수도 있다.

protocol Fezz: class, Person {
  ...
}

프로토콜 조합과 프로토콜 준수 확인

하나의 매개변수가 여러 프로토콜을 모두 준수하는 타입이어야 한다면
하나의 매개변수에 여러 프로토콜을 한 번에 조합하여 요구할 수 있다.

  • & 키워드를 사용한다. (프로토콜A & 프로토콜B)
  • 타입캐스팅에 사용하던 is, as 연산자를 통해 대상이 프로토콜을 준수하는지 확인할 수 있고, 특정 프로토콜로 캐스팅할 수 있다.

프로토콜의 선택적 요구

프로토콜의 요구사항 중 일부를 선택적 요구사항으로 지정할 수 있다.

선택적 요구사항을 정의하고 싶은 프로토콜은 objc 속성이 부연된 프로토콜이여야 한다.

여기서 더 생각해보아야 할 것은 objc 속성이 부여되는 프로토콜은
Objective-C 클래스를 상속받은 클래스(NSObjcet)에서만 채택할 수 있다는 것이다.
즉, 열거형이나 구조체 등에서는 objc 속성이 부여된 프로토콜은 아예 채택이 불가하다.

@objc protocol Person {
  	func move()
  	@objc optional func run()
}

class Fezz: NSObject, Person {
  	func move() {
      	....
    }
}

프로토콜 변수와 상수

프로토콜 이름을 타입으로 갖는 변수 또는 상수에는 그 프로토콜을 준수하는 타입의 어떤 인스턴스라도 할당할 수 있다.

프로토콜은 프로토콜 이름만으로 자기 스스로 인스턴스를 생성하고 초기화하지는 못한다.
하지만 프로토콜 변수나 상수를 생성하여 특정 프로토콜을 준수하는 타입의 인스턴스를 할당할 수는 있다.

var somePerson: Person = Fezz(name: fezravien)

위임(Delegating)을 위한 프로토콜

델리게이션 패턴은 매우 간단하면서도 강력한 패턴으로, 어느 한 타입의 인스턴스가 다른 인스턴스를 대신해서 동작하는 상황에 잘 맞는다.

동작을 위임(delegating)하는 인스턴스는 델리게이트 인스턴스의 참조를 저장하고 있다가
어떠한 동작이 발생하면 델이게이팅 인스턴스는 계획된 함수를 수행하기 위해 델리게이트를 호출한다.

스위프트에서는 델리게이트가 해야 할 일을 정의한 프로토콜을 생성하는 방식으로 델리게이션 디자인 패턴을 구현한다.

델리게이트라 불리는 프로토콜을 따르는 타입은 해당 프로토콜을 채택하며,
프로토콜에서 정의한 기능을 제공하는 것을 보장할 것이다.

말이 좀 어려워 보이긴 하는데...
할 일을 프로토콜이라는 곳에 명시하고 다른 곳에서 해당 일을 처리하게 한다 (?)
"나는 일에 대해서 몰라 내가 적당한 시기에 알려주면 너가 해줘!! 나는 신경쓰지 않을꺼야 !!"


위임(Delegation)

  • 클래스나 구조체가 자신의 책임이나 임무를 다른 타입의 인스턴스에게 위임하는 디자인 패턴이다.
  • 책무를 위임하기 위해 정의한 프로토콜을 준수하는 타입은 자신에게 위임될 일정 책무를 할 수 있는 것을 보장한다.
    -> 다른 인스턴스에게 자신이 해야할 일을 믿고 맡긴다.
  • 위임은 사용자의 특정 행동에 반응하기 위해 사용되기도 하며, 비동기 처리에도 많이 사용된다.
    -> "서버에 데이터를 요청해 놓을게 이따가 서버한태 데이터가 오면 너가 알아서 화면에 띄워줘 !!"

위임 패턴(Delegation Pattern)

protocol DisplayNameDelegate {
  	func displayName(name: String)
}

DisplayNameDelegate 프로토콜에서는 델리게이트에서 반드시 구현해야 하는 메소드를 하나 정의한다.
메소드에서는 델리게이트가 어딘가에서 이름을 보여줄 것이라고 가정하지만, 필수적인 것은 아니다.
요구 사항은 델리게이트가 이 메소드를 구현해야 한다는 것뿐이다.

// 책임을 전가 하는 ..
struct Person {
    var displayNameDelegate: DisplayNameDelegate
    var firstName = "" {
        didSet {
            // firstName이 변화되면 책임을 전가해!! (프로퍼티 옵저버)
            displayNameDelegate.displayName(name: getFullName())
        }
    }
  
    var lastName = "" {
        didSet {
            // lastName 변화되면 책임을 전가해!! (프로퍼티 옵저버)
            displayNameDelegate.displayName(name: getFullName())
        }
    }
  
    init(displayNameDelegate: DisplayNameDelegate) {
        self.displayNameDelegate = displayNameDelegate
    }
  
    func getFullName() -> String {
        return "\(firstName) \(lastName)"
    }
}

// 책임을 전가 받는 !
struct MyDisplayNameDelegate: DisplayNameDelegate {
    func displayName(name: String) {
        print("Name: \(name)")
    }
}

// 델리게이트 사용하기 
var displayDelegate = MyDisPlayNameDelegate()
var person = Person(displayNameDelegate: displayDelegate)
person.firstName = "Fezz"
person.lastName = "Ravien"

코드를 요약해보자면

  1. MyDisplayNameDelegate 타입의 인스턴스 생성 - 책임을 전가받는 친구 ! (프로토콜 채택)
  2. Person 타입의 인스턴스를 생성하는 데 책임을 전가받는 친구 를 사용한다
  3. Person 인스턴스의 프로퍼티에 값을 설정하면 델리게이트가 사용된다.

델리게이션 패턴의 진정한 능력은 애플리케이션이 행위를 변경하고자 할 떄 발휘된다.

위 코드에서 firstName 또는 lastName 프로퍼티가 변경될 때마다
이름을 출력하기 위해 델리게이트를 사용함으로써 코드의 로직 부분과 뷰를 분리했다.

각각의 타입이 매우 구체적인 작업에 대한 책임이 있는 경우 느슨한 결합은 책임의 분리를 촉진한다.
이는 요구 사항이 변경되는 경우 작업을 매우 쉽게 바꿀 수 있게 해준다.
요구 상항이 종종 바뀐다는 것은 모두가 알고 있는 사실이다.


타입으로서의 프로토콜

프로토콜은 요구만 하고 스스로 기능을 구현하지는 않는다.
그렇지만 프로토콜은 코드에서 완전한 하나의 타입으로 사용되기에 여러 위치에서 프로토콜을 타입으로 사용할 수 있다.

  • 함수, 메서드, 이니셜라이저에서 매개변수 타입이나 반환 타입으로 사용될 수 있다.
  • 프로퍼티, 변수, 상수 등의 타입으로 사용될 수 있다.
  • 배열, 딕셔너리 등 컨테이너 요소의 타입으로 사용될 수 있다.

연관 타입과 프로토콜

프로토콜을 정의하는 경우 하나 이상의 연관 타입(associated type)을 정의하는 것이 유용한 경우가 있다.

연관 타입은 프로토콜 내에서 타입을 대신해 사용할 수 있는 플레이스홀더명(placeholder name)을 제공한다.

연관타입에서 사용하는 실제 타입은 프로토콜이 채택되기 전까지는 정의되지 않는다.
"우리는 사용할 타입을 정확히 몰라. 그렇기 때문에 이 프로토콜을 채택하는 타입이 정확한 타입을 정해줄꺼야 !"

protocol Queue {
    associatedtype QueueType
    mutating func addItem(item: QeueuType)
    mutating func getItem() -> QueueType?
    func count() -> Int
}

struct IntQueue: Queue {
    var items: [Int] = []
    
    mutating func addItme(item: Int) {
    	items.append(item)
    }
    
    mutating func getItem() -> Int? {
    	if items.count > 0 {
            return items.remove(at: 0)
        } else {
            return nil
        }
    }
    
    func count() -> Int {
        return items.count
    }
}

프로토콜을 사용해 설계하기

객체지향 프로그래밍 세계에는 서브클래스를 위한 모든 기본적인 요구 사항을 포함하는 슈퍼클래스를 갖는다.
하지만 프로토콜 설계 방식은 이와는 좀 다르다.

프로토콜지향 프로그래밍 세계에서는 슈퍼클래스 대신 프로토콜을 사용하며,
이는 요구 사항을 더 큰 덩어리의 프로토콜이 아닌 작고 매우 구체적인 프로토콜로 나누기에 매우 적절하다.

protocol Sensor {
   var sensorType: String { get }
   var sensorName: String { get set }
   
   init (sensorName: String)
   func pollSensor()
}
protocol EnvironmentSensor: Sensor {
    func currentTemperature() -> Double
    func currentHumidity() -> Double
}

protocol RangeSensor: Sensor {
    func setRangeNotification(rangeCentimeter: Double, rangeNotification: () -> Void)
    func currentRange() -> Double
}

protocol DisplaySensor: Sensor {
    func displayMessage(message: String)
}

protocol WirelessSensor: Sensor {
    func setMessageReceivedNotification(messageNotification: (String) -> Void)
    func messagesend(message: String)
}
protocol Robot {
    var name: String { get set }
    var sensors: [Sensor] { get }
    
    init (name: String)
    func addSensor(sensor: Sensor)
    func pollSensors()
}

프로토콜지향 설계에서 얻을 수 있는 장점

  1. 각 프로토콜은 특정 타입에서 필요한 구체적인 요구사항만 포함할 수 있다.
  2. 프로토콜 컴포지션을 사용해 단일 타입이 다중 프로토콜을 따르게 할 수 있다.


📄 참고 자료

  • [도서] 스위프트 프로그래밍 - 야곰
  • [도서] 프로토콜지향 프로그래밍 - 존 호프만

좋은 웹페이지 즐겨찾기