프로토콜 지향 프로그래밍

박형석·2021년 11월 29일
1

CS

목록 보기
6/10
post-thumbnail

Swift의 핵심 강력한 축

드디어 프로그래밍의 여러 축을 넘어 프로토콜 지향 프로그래밍으로 넘어왔다. 개인적으로 다음 글인 반응형 프로그래밍과 함께 가장 눈여겨보고 중점적으로 고려해야 겠다(사실은 배워가고 적용해야 겠다) 생각한 패러다임이다. 이 패러다임의 이해를 위해 프로토콜, 익스텐션, 제네릭에 대한 글을 Swift에 올려놓았으니 간단히 확인하면 좋을 것 같다.

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

객체 지향 프로그래밍을 기반으로 둔 언어는 대부분 클래스의 상속을 사용해서 타입의 공통된 기능을 구현한다. 그런데 스위프트의 표준 라이브러리에서 타입과 관련된 것을 살펴보면 대부분이 구조체로 구현되어 있고 동시에 공통된 기능을 가지고 있다. 왜 굳이 이렇게 했을까?

객체 지향 프로그래밍은 기능을 사용하는데 있어서 몇 가지 단점을 가지고 있다.

  • 첫 번째는 SuperClass에 종속적이다. 이건 익스텐션 글에서도 다루었지만 클래스의 경우 수직적인 확장이 이루어진다. SubClass가 기능을 상속하기 위해서는 SuperClass를 아주 잘 알아야 하고, SubClass는 불필요한 변수나 상수, 함수를 가지고 있을 수 밖에 없다. SuperClass를 잘 알아야 한다는 건 개발자의 입장은 물론 불필요한 상속들이 많이 일어날 수밖에 없다.

  • 두 번째는 Value type을 사용할 수 없다는 점이다. 참조 타입은 추적에 많은 비용이 발생한다. 하지만 기능 추가를 위해서는 구조체나 열거형으로 구현해도 상관없는 타입을 굳이 클래스로 구현해야 한다. 불필요하게 참조 타입 추적이 늘어나게 되는 것이다.

프로토콜 지향 프로그래밍은 이런 기능적 단점을 만회하는 장점이 있다.

  • 값 타입 사용 가능
  • 참조 타입 사용 시, SuperClass에 독립적으로 기능 추가 구현 가능
  • 값 타입 사용 시, 상속을 할 수 없으므로 매번 기능을 다시 구현해야 하는 한계를 극복
  • 기능의 모듈화 가능
  • 불필요한 API를 제외하고 정의한 API만 가져올 수 있음

Swift는 어떻게 이 패러다임을 구현하도록 도울까? 그 핵심에 프로토콜, 익스텐션, 제네릭이 있다.

프로토콜 초기구현

왜 3가지 문법이 프로토콜 지향 프로그래밍의 핵심이 될까? 이미 세 요소를 공부했다면, 대략 감이 올 것 같다. 제네릭은 모든 타입에 유동적이고, 익스텐션은 그 타입의 기능을 확장할 수 있고, 프로토콜은 그 기능을 강제로 구현하게 한다. 이미 답이 나온 느낌이다. 이 세 문법적 요소를 가지고 POP를 구현할 수 있는데, 첫 번째 요소인 프로토콜 초기구현부터 살펴보자.

프로토콜 초기구현?

위에서 POP의 장점 중에 "값 타입 사용 시, 상속을 할 수 없으므로 매번 기능을 다시 구현해야 하는 한계를 극복"한다고 이야기했다. 하지만 프로토콜만 준수할 시에 매번 기능을 다시 구현해야 하는건 똑같다. 이 한계를 어떻게 극복할까? 바로 프로토콜과 익스텐션을 결합으로 가능하다. 아래 코드를 참고하자.

protocol Receivable {
 func receive(data: Any?, from: Sendable)
}

extension Receivable {
 func receive(data: Any?, from: Sendable) {
  print("receive!!")
 }
}

class Message: Receivable {
 var to: Receivable?
}

let message = Message()
message.receive() // "receive!"

프로토콜은 요구하는 기능을 실제로 구현할 수는 없다. 하지만 익스텐션과 결합하면 요구사항을 실제로 구현할 수 있다. 그리고 해당 프로토콜에 원하는 기능만을 구현 및 구분해준다면, POP의 장점의 "불필요한 API를 제외하고 정의한 API만 가져올 수 있음, 기능의 모듈화 가능"을 둘 다 충족시킬 수 있다.

저장 프로퍼티는 알다시피 익스텐션에서 구현할 수 없다. 각각의 타입에서 직접 구현해야 한다.

만약 프로토콜 익스텐션에서 구현한 기능을 사용하지 않고 타입의 특정에 따라 변경해서 사용하려면? 재정의

class Message: Receivable {
 var to: Receivable?
 func receive(data: Any?, from: Sendable) {
  print("change Receive!!")
 }
}

상속처럼 override가 필요가 없다. 해당 클래스에 재정의해주면 된다. 이렇게 하면

  • 특정 프로토콜을 준수하는 타입에 프로토콜의 요구사항을 찾아보고 이미 구현되어 있다면 그 기능을 사용
  • 없다면 프로토콜 초기구현을 사용한다.

얼마나 좋은가? 그런데 여기에 제네릭까지 더한다면? 재사용성이 훨씬 올라갈 것이다.

+ 제네릭

protocol SelfPrintable {
 func printSelf()
}

extension SelfPrintable where Self: Container {
 func printSelf() {
  print(items)
 }
}

protocol Container: SelfPrintable {
 associatedtype: ItemType
 var items: [ItemType] { get set }
 var count: Int { get }
 mutating func append(item: ItemType)
 subscript(i: Int) -> ItemType { get }
}

extension Container {
 mutation func append(_ item: ItemType) {
  items.append(item)
 }
  var count: Int {
   return items.count
  }
  subscript(i: Int) -> ItemType {
   return items[i]
  }
}

Container 프로토콜은 연관 타입(제네릭)을 활용해서 타입에 더욱 유연하게 대응할 수 있도록 정의했다. 또 프로토콜 초기구현을 통해 미리 공통 기능을 구현했기에 실제 프로토콜을 따르는 타입은 추가 구현이 필요없다.

이렇게 POP를 통해 클래스의 상속보다도 훨씬 강력하게 기능의 단위를 공유할 수 있다. 스위프트 클래스는 다중상속을 지원하지 않기 때문에 부모 클래스의 기능으로 부족하다면 자식클래스에서 다시 구현해야 하지만 프로토콜 초기구현을 한 프로토콜을 채택했다면 상속도 추가 구현도 필요없다. 그냥 프로토콜을 채택만 하면 된다.

기본 타입 확장

이런 프로토콜 초기구현을 통해 스위프트의 기본 타입을 확장할 수도 있지 않을까? 기본 타입을 확장해서 내가 원하는 기능을 공통적으로 추가해볼 수 있다.

protocol SelfPrintable {
 func printSelf()
}

extension SelfPrintable {
 func printSelf() {
  print(self)
 }
}

extension Int: SelfPrintable {
 // 초기구현 사용
}

위와 같이 코드를 수정할 수 없는 기본 타입인 Int에 프로토콜을 채택하는 것만으로도 초기구현으로 인한 공통 기능을 정의할 수 있다. 레알 강력

정리

사실 객체 지향 패러다임과 프로토콜을 비교한다는 건 말이 안된다. 프로토콜도 지향 프로그래밍도 객체를 다루고 있고 그 객체 간의 상호작용 역시 존재한다. 다만 "객체의 상호작용, 각 객체의 기능을 구현할 때 좀 더 좋은 패러다임이 있지 않을까?"하는 고민 속에서 나온 패러다임인 만큼 기능, 확장성, 효율성 면에서 집중해서 고민하면 좋을 것 같다는 생각이다. 앞으로 프로그래밍을 할 때 적극 활용해보자.

profile
IOS Developer

0개의 댓글