본문 바로가기

iOS

[iOS] Protocol Composition과 Default Implementation 활용하기

Protocol Composition & Default Implementation

 

안녕하세요 :)

 

오늘은 Protocol Composition과 Default Implementation 에 대해 알아보고 실제 사용 예시를 보여드리고자 합니다!

 

모든 포스트는 편의 말투로 작성합니다 :D

 

 

 

모두가 코드의 역할들이 적절히 분담되어 유지보수하기 편한 깔끔한 코드 작성을 지향합니다.

 

이를 위해서 우리는 현재 코드를 관련된 프로퍼티와 메서드들을 모아놓은 프로토콜 인터페이스들로 나눕니다.

 

문제는 여러 프로토콜 중 일부를 함께 사용하게 된다는 것이죠.

 

예를 들어, 일부 프로토콜을 함께 사용하는 타입의 변수를 선언하는 경우를 살펴볼게요.

 

protocol FileHandlerType {
    func read() -> String
    func write(_ value: String)
}

struct FileHandler: FileHandlerType {
    func read() -> String {
        return ""
    }

    func write(_ value: String) {
        // do something
    }
}

let handler: FileHandlerType = FileHandler()
handler.read()
handler.write("Protocol Composition")

 

프로토콜 FileHandlerType 은 파일을 읽어 결과를 반환하는 메서드와 파일에 데이터를 작성하는 메서드, 총 2가지의 기능을 가지고 있어요.

 

위 프로토콜은 우리가 흔히 들어왔던 SOLID 원칙의 Interface Segregation Principle을 위반하고 있어요!

 

위 프로토콜을 ISP를 준수하는 방식으로 변경한다면 아래와 같이 나눌수 있을것 같아요.

 

protocol FileHandlerReadable {
    func read() -> String
}

protocol FileHandlerWritable {
    func write(_ value: String)
}

 

기존의 프로토콜이 가지는 2가지 기능을 각각의 프로토콜로 분할해봤어요!

 

그런데 문제가 생겼습니다. 기존 프로토콜 타입을 준수하던 변수들을 어떻게 표시해야 할까요?

 

여기서 Protocol은 다중 채택이 가능하다는 장점을 이용한 Protocol Composition의 개념이 사용됩니다.

 

 

 

기본적인 접근

이 방법은 매우 일반적인 방식이고 자바와 같은 언어에서도 많이 사용되는 방식이에요!

 

2개로 나누어진 프로토콜을 준수하는 하나의 프로토콜을 만들어볼게요.

 

protocol FileHandlerType: FileHandlerReadable, FileHandlerWritable { }

struct FileHandler: FileHandlerType {
    func read() -> String {
        return ""
    }

    func write(_ value: String) {
        // do something
    }
}

let handler: FileHandlerType = FileHandler()
handler.read()
handler.write("Protocol Composition")

위와 같이 Readable 타입과 Writable 타입 모두를 준수하는 FileHandlerType 프로토콜을 정의하고 해당 프로토콜을 준수하는 타입을 만들어서 문제를 해결했어요.

 

이로써 ISP를 준수하지 않던 기존 방식에서 벗어나 ISP를 준수하면서 기존에 제공하던 인터페이스를 그대로 제공할 수 있게 되었어요.

 

그런데 프로토콜을 조합하여 사용하는 경우들이 많아지면 매번 위와 같이 프로토콜을 정의해서 사용하는 것도 귀찮을것 같아요ㅠ

 

위에서 봤던 기본적인 접근 방식이 별로 마음에 안든다면 보다 Swifty한 방식으로 접근해볼까요?

 

Swifty 접근

2개 이상의 프로토콜을 함께 조합하여 사용하고 싶다면 & 연산을 이용해요.

 

struct FileHandler: FileHandlerReadable, FileHanderWritable {
    func read() -> String {
        return ""
    }

    func write(_ value: String) {
        // do something
    }
}

let handler: FileHandlerReadable & FileHandlerWritable = FileHandler()

handler.read()
handler.write("Protocol Composition")

& 연산을 이용하면 프로토콜을 매번 별도로 정의해서 사용하지 않아도 되지만 이 방식에도 단점은 있습니다.

 

n개의 프로토콜을 조합하는 타입이 프로젝트 내에서 여러번 중복되면 코드가 지저분해지기 시작해요..

 

let variable: ProtocolA & ProtocolB & ProtocolC & ProtocolD & ProtocolE

 

이러한 단점을 해결해줄수 있는게 typealias 입니다.

 

typealias HandlerType = FileHandlerReadable & FileHandlerWritable

struct FileHandler: HandlerType {
    func read() -> String {
        return ""
    }

    func write(_ value: String) {
        // do something
    }
}

 

실사용 예시

저는 지금 진행중인 사이드 프로젝트에서 위 개념을 UseCase 쪽에 도입해서 사용하고 있어요!

 

실제 예시와 코드를 보며 좀 더 상세히 살펴봐요!

 

구현하고자 하는 View에서 제공하는 도메인 로직이 아래와 같아요.

  1. 비디오 포스트 가져오기
  2. 비디오 포스트 좋아요 요청하기
  3. 비디오 포스트 신고하기

그럼 위 각각의 기능들을 담당하는 UseCase 프로토콜을 정의해볼게요!

 


// FetchVideoPostUseCaseType.swift
public protocol FetchVideoPostUseCaseType {
  var videoPostService: VideoPostServiceType { get }

  func fetchVideoPosts(request: FetchVideoPostRequest) -> Observable<(posts: [VideoPost], isLastPage: Bool)>
}

extension FetchVideoPostUseCaseType {
  func fetchVideoPosts(request: FetchVideoPostRequest) -> Observable<(posts: [VideoPost], isLastPage: Bool)> {
    videoPostService.fetchVideoPosts(request: request)
      .asObservable()
      .catchAndReturn(([], true))
  }
}


//  LikeVideoPostUseCase.swift
public protocol LikeVideoPostUseCaseType {
  var videoPostService: VideoPostServiceType { get }

  func likeVideoPost(postID: Int) -> Observable<Bool>
  func unLikeVideoPost(postID: Int) -> Observable<Bool>
}

extension LikeVideoPostUseCaseType {
  func likeVideoPost(postID: Int) -> Observable<Bool> {
    videoPostService.likeVideoPost(postID: postID)
      .asObservable()
  }

  func unLikeVideoPost(postID: Int) -> Observable<Bool> {
    videoPostService.unlikeVideoPost(postID: postID)
      .asObservable()
  }
}


//  ExclameVideoPostUseCase.swift
public protocol ExclameVideoPostUseCaseType {
  var videoPostService: VideoPostServiceType { get }

  func exclameVideoPost(postID: Int) -> Observable<Bool>
}

extension ExclameVideoPostUseCaseType {
  func exclameVideoPost(postID: Int) -> Observable<Bool> {
    videoPostService.exclameVideoPost(postID: postID)
      .asObservable()
  }
}

 

여기서 이 비즈니스 로직에 대한 동작 변경은 없을 예정이고 각각의 UseCase들이 여러 화면에서 다양한 조합으로 사용되요.

 

해당 UseCase의 기능이 필요한 곳에서 매번 UseCaseType 프로토콜을 채택한다면 동일한 기능을 제공함에도 불구하고 매번 같은 코드를 반복해서 작성해줘야 해요.

 

저는 이러한 번거로움을 줄이고 코드 재활용을 위해 extension 을 통해 기본 구현으로 비즈니스 로직을 제공했어요.

 

이제 각각의 기능들을 제공하는 프로토콜을 정의했으니 실제 이를 조합해서 사용하는 타입 선언부를 살펴볼게요.

 

public typealias VideoPostUseCaseType = FetchVideoPostUseCaseType & LikeVideoPostUseCaseType & ExclameVideoPostUseCaseType

final class VideoPostUseCase: VideoPostUseCaseType {

  // MARK: Properties
  let videoPostService: VideoPostServiceType

  // MARK: Initializer
  init(videoPostService: VideoPostServiceType) {
    self.videoPostService = videoPostService
  }
}

 

비디오 가져오기 기능, 비디오 좋아요 요청 기능, 비디오 신고 기능을 조합한 VideoPostUseCaseType 이라는 typealias 를 정의했어요.

 

그리고 그 타입을 준수하는 VideoPostUseCase 라는 클래스를 정의했어요!

 

클래스에는 해당 비즈니스 로직을 수행함에 필요한 의존성인 Service 프로퍼티 하나만 들고있을 뿐인데 각각의 UseCase의 기능들을 extension 을 통해 기본구현으로 제공했기 떄문에 별도의 코드 작성 없이 바로 도메인 로직을 수행할 수 있게 되었어요.

 

각각의 기능들을 담당하는 프로토콜을 세분화하여 기본 구현과 조합하면 이렇게 입맛에 맞게 필요한 기능들만 가져다가 바로 쓸 수 있으니 너무 좋지않나요?.?

 

 

이상으로 오늘의 포스팅을 마치도록 할게요!

혹시나 궁금하신 내용 혹은 틀린 내용이 있다면 꼭 꼭 반드시 무적권 댓글 부탁드려요 :D