Practical protocol oriented programming

실무에서 활용하는 프로토콜 중심 프로그래밍

“프로토콜 중심 프로그래밍을 일상에서의 코딩에 사용할 수 있을까요?” Natasha는 이 질문의 답을 위해 뷰, 뷰 컨트롤러와 네트워킹 예제를 통해 프로토콜 중심 프로그래밍을 위한 실제 애플리케이션 예제를 설명합니다. App Builders CH에서의 이 강연을 통해, Swift 사용시의 여러분의 사고 방식을 객체 중심에서, 보다 간결하고 가독성이 높은 코드를 작성할 수 있는 프로토콜 중심으로 바꿔 보세요.


현실로 돌아가 보죠 – Swift가 가장 훌륭한 프로그래밍 언어라고 가정할 겁니다.

실용적인 측면에 초점을 맞춰 Swift에서의 프로토콜 중심 프로그래밍에 대해 말해볼까 합니다.

Swift에서의 프로토콜 중심 프로그래밍 (00:37)

Swift가 처음 등장했을 때, 새로운 것을 배운다는 것은 정말 즐거운 일이었죠. 처음 1년간은 배움의 즐거움을 느끼면서 제 기본 Objective-C 코드를 Swift로 작성했습니다. 가끔은 밸류 타입 등 멋진 기능도 활용했고요. 하지만 작년 WWDC에서 프로토콜 익스텐션이 소개됐습니다.

특히 Dave Abrahams이 프로토콜 중심 프로그래밍에 대해 다룬 강연, “Swift에서의 프로토콜 중심 프로그래밍”이 인상적이었습니다. 그는 “Swift는 프로토콜 중심 프로그래밍 언어”라고 주장했죠. Swift 표준 라이브러리를 살펴보면 50개가 넘는 프로토콜을 발견할 수 있습니다. 언어 자체가 만들어질 때부터 많은 프로토콜이 사용되므로 우리 개발자들도 프로토콜을 사용해야 합니다. Dave는 또한 프로토콜이 코드를 어떻게 바꾸는지에 대한 예시도 보여줬습니다. drawable에 대한 예제로, 사각형, 삼각형과 원을 예로 들어 프로토콜로 이들 도형을 보다 멋지게 만들었죠. 강연을 보는 내내 정말 놀라웠지만 저는 사실 drawable을 자주 사용하지 않아서 일상적인 코드와 연관짓기가 좀 어려웠습니다.

그래서 어떻게 제가 프로토콜 중심 프로그래밍을 일상 코드에서 사용할 수 있을지에 대한 예제를 생각해 봤습니다. Objective-C과 다른 프로그래밍 경험을 통해 자주 사용하는 패턴이 있지만, 객체 지향에서 프로토콜 지향으로 사고 방식을 바꾸기는 어렵습니다.

실무에서 활용하는 프로토콜 중심 프로그래밍 (03:05)

최근 몇년 간 저는 프로토콜 사용법을 연구해 왔고, 더 나은 코드를 위한 예제들을 공유해볼까 합니다. 실무에서 활용하는 프로토콜 지향 프로그래밍이 주제이니 View, (UITable)ViewController, Networking을 다루겠습니다. 이 예제들이 여러분이 어디에 프로토콜을 활용할지 도움이 됐으면 좋겠습니다.

(03:24)

PM이 버튼을 클릭하면 떨리는 효과를 시작하는 뷰를 만들어달라고 한 상황을 가정해 봅시다. 암호 텍스트필드에서 흔하게 사용하는 애니메이션이죠. 사용자가 잘못된 암호를 넣으면 떨리는 효과가 날 겁니다.

저는 Stack Overflow를 참조하면서 업무를 시작하곤 합니다. 누군가 이미 객체를 떨리게 하는 Swift 기본 코드를 제공해주니 살짝 떨림 효과를 변경하지 않는 정도의 고민 정도만으로도 그 코드를 사용할 수 있습니다. 그러나 어려운 부분은 아키텍처입니다. 제 코드 중 어느 부분에 이 코드를 넣어야 할까요?

//  FoodImageView.swift

import UIKit

class FoodImageView: UIImageView {
    func shake() {
        let animation = CABasicAnimation(keyPath: "position")
        animation.duration = 0.05
        animation.repeatCount = 5
        animation.autoreverses = true
        animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
        animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
        layer.addAnimation(animation, forKey: "position")
    }
}

UIImageView의 서브클래스인 FoodImageView를 만들어서 떨림 애니메이션을 넣겠습니다.

//  ViewController.swift

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var foodImageView: FoodImageView!

    @IBAction func onShakeButtonTap(sender: AnyObject) {
        foodImageView.shake()
    }
}

제 뷰 컨트롤러에 인터페이스 빌더로부터 뷰를 연결하고 FoodImageView로 서브 클래싱한 후 떨림 기능을 넣어서 완성했습니다! 전체 과정은 10분 안에 끝났고, 저도 만족스럽고, 코드도 잘 동작하죠.

잠시 후, PM이 다시 뷰가 떨릴 때 버튼도 떨려야한다고 지시합니다. 다시 코드로 돌아가서 버튼에 같은 작업을 합니다.

//  ShakeableButton.swift

import UIKit

class ActionButton: UIButton {

    func shake() {
        let animation = CABasicAnimation(keyPath: "position")
        animation.duration = 0.05
        animation.repeatCount = 5
        animation.autoreverses = true
        animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
        animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
        layer.addAnimation(animation, forKey: "position")
    }
}

이런 개발 뉴스를 더 만나보세요

서브클래싱을 해서 버튼을 만들고 shake() 함수를 추가한 것이 아래 ViewController 입니다. 이제 버튼과 음식 이미지 뷰를 떨리게 하는 작업이 완료됐습니다.

//  ViewController.swift

class ViewController: UIViewController {

    @IBOutlet weak var foodImageView: FoodImageView!
    @IBOutlet weak var actionButton: ActionButton!

    @IBAction func onShakeButtonTap(sender: AnyObject) {
      foodImageView.shake()
      actionButton.shake()
    }
}

아마 뭔가 잘못되어감을 느낄 겁니다. 떨림 코드를 두 군데에 중복했습니다. 떨림 효과를 수정하려면 두 군데를 찾아가야 하니 깔끔하지 않습니다.

//  UIViewExtension.swift

import UIKit

extension UIView {

    func shake() {
        let animation = CABasicAnimation(keyPath: "position")
        animation.duration = 0.05
        animation.repeatCount = 5
        animation.autoreverses = true
        animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
        animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
        layer.addAnimation(animation, forKey: "position")
    }
}

좋은 프로그래머가 되려면 즉각 이런 사실을 인지하고 리팩터링을 시도해야 합니다. Objective-C라면 UIView 카테고리를 생성할테고, Swift라면 익스텐션을 사용하겠죠.

UIButtonUIImageView 모두 UI View이므로, UI view를 확장해서 떨림 기능을 넣을 수 있습니다. 버튼과 이미지 뷰를 위한 로직이지만 다른 곳에서도 떨림 기능을 사용할 수 있습니다.

class FoodImageView: UIImageView {
    // other customization here
}

class ActionButton: UIButton {
    // other customization here
}

class ViewController: UIViewController {
    @IBOutlet weak var foodImageView: FoodImageView!
    @IBOutlet weak var actionButton: ActionButton!

    @IBAction func onShakeButtonTap(sender: AnyObject) {
        foodImageView.shake()
        actionButton.shake()
    }
}

이 코드의 가독성이 떨어진다는 것을 느낄 겁니다. 예를 들어 foodImageViewactionButton에서 떨리는 기능을 파악할 수 없습니다. 클래스 어디에서도 이들이 떨릴 것임을 알려주지 않습니다. 원래 기능이 아닌 무작위의 떨림 기능이 어딘가 존재하므로 이 코드는 불분명합니다. 떨림 기능을 넣은 후 또 희미해지는 기능을 추가하라는 요청이 온다면, 이 코드 아래도 또 변종 기능들이 줄줄이 추가될 겁니다. 결국에는 UI View가 수행하는 무작위 기능들을 모두 담아서 길고 가독성이 낮으며 찾기 어려운 더러운 파일로 변하겠지만, 이 기능을 사용하는 것은 소수에 불가하겠죠.

의도가 불분명한 이 상황을 어떻게 해결할 수 있을까요?

프로토콜 중심 프로그래밍에 대해 말하고 있으니 프로토콜을 사용해 봅시다. Shakeable 프로토콜을 만듭니다.

//  Shakeable.swift

import UIKit

protocol Shakeable { }

extension Shakeable where Self: UIView {

    func shake() {
        // implementation code
    }
}

프로토콜 익스텐션으로 클래스가 이를 구현하도록 강제합니다. 이 경우 떨림 기능을 밖으로 꺼내고, 카테고리를 통해 이 프로토콜을 사용하는 것은 UI View만임을 명시할 수 있습니다.

원래 계획했던대로 익스텐션의 강력한 기능성을 유지하면서 프로토콜로 만들 수 있습니다. 뷰가 아닌 클래스는 이 프로토콜을 따르더라도 동작하지 않습니다. 떨림을 기본으로 구현해야 하는 것은 오직 뷰뿐이죠.

class FoodImageView: UIImageView, Shakeable {

}

class ActionButton: UIButton, Shakeable {

}

FoodImageViewActionButtonShakeable 프로토콜을 따르는 것을 볼 수 있습니다. 떨림 기능이라는 의도를 읽을 수 있도록 바뀌었습니다. 이 뷰를 다른 곳에서 사용한다면 거기서도 떨림 기능이 필요한지 한 번 더 생각해보겠죠. 가독성을 높이면서 코드의 독립성과 재사용성이 유지됩니다.

뷰에 떨림 기능과 희미해지는 기능이 필요하다고 가정해 볼까요? Dimmable이라는 희미해지는 기능을 위한 프로토콜 익스텐션을 만들어서 추가할 수 있습니다. 이 경우에도 클래스 정의를 보는 것만으로 뷰의 동작에 대한 의도를 파악할 수 있습니다.

class FoodImageView: UIImageView, Shakeable, Dimmable {

}

리팩터링 측면에서도, 만약 더 이상 떨림 기능이 필요하지 않은 경우 Shakeable 프로토콜을 따르던 규약을 삭제하면 그만입니다.

class FoodImageView: UIImageView, Dimmable {

}

자, 이제 dimmable만 남았습니다. 프로토콜을 사용하면 플러그 방식으로 마치 레고처럼 아키텍처를 만들어서 쉽게 조립할 수 있습니다. 트랜지션과 함께 희미해지는 뷰를 만드는 것처럼 프로토콜로 뷰에 여러 기능을 활용하는 것에 관심이 있다면 이 곳을 참고하세요.

자, 이제 프로토콜 중심 프로그래밍을 활용해 봅시다.

(UITable)ViewControllers (10:09)

여러 다른 장소의 음식 사진을 보여주는 음식을 위한 Instagram 앱을 보여 드리겠습니다.

// FoodLaLaViewController

override func viewDidLoad() {
    super.viewDidLoad()

    let foodCellNib = UINib(NibName: "FoodTableViewCell", bundle: nil)
    tableView.registerNib(foodCellNib,
                          forCellReuseIdentifier: "FoodTableViewCell")
}

위 코드는 우리가 자주 사용하는 기본 코드인 tableView입니다. 뷰가 로드될 때 Nib에서 셀을 로드하죠. NibName을 특정하고 ReuseIdentifier로 Nib을 등록합니다.

let foodCellNib = UINib(NibName: String(FoodTableViewCell), bundle: nil)
tableView.registerNib(foodCellNib,
                      forCellReuseIdentifier: String(FoodTableViewCell))

안타깝게도 UIKit의 특성 상 스트링을 사용해야만 합니다. 저는 제 셀의 이름으로 동일한 identifier를 사용하곤 합니다.

비효율적인 작업임을 느낄 겁니다. Objective-C라면 클래스로부터 NSString를 사용하고, Siwft라면 살짝 나은 String를 사용하는데, iOS를 처음하는 인턴에게 보여주기 좋은 직관적인 메서드는 아니죠. 인턴은 무작위로 어떤 이름을 string화하는데 왜 그래야 하나요? 하고 묻겠죠. 또한 스토리보드에서 identifier를 특정하지 않으면 크래시가 나는데 왜 그러는지 도무지 파악할 방법이 없을 겁니다. 이 상황을 어떻게 개선할 수 있을까요?

protocol ReusableView: class {}

extension ReusableView where Self: UIView {

    static var reuseIdentifier: String {
        return String(self)
    }
}
extension UITableViewCell: ReusableView { }

FoodTableViewCell.reuseIdentifier
// FoodTableViewCell

더 이상 Objective-C를 사용하지 않으므로 재사용 가능한 뷰 프로토콜을 셀을 위해 사용할 수 있습니다.

let foodCellNib = UINib(NibName: "FoodTableViewCell",
                        bundle: nil)
tableView.registerNib(foodCellNib,
                      forCellReuseIdentifier: FoodTableViewCell.reuseIdentifier)
protocol NibLoadableView: class { }

extension NibLoadableView where Self: UIView {

    static var NibName: String {
        return String(self)
    }
}

또다시, 테이블 뷰의 모든 재사용 identifier는 클래스의 스트링 버전이 됩니다. 모든 뷰를 위해 프로토콜 익스텐션을 사용할 수 있습니다. 이는 UICollectionView, UITableView, Cell에 적용될 수 있습니다. 이게 우리가 사용할 재사용 identifier입니다. UIKit 때문에 사용해야 했던 성가신 로직에서 해방됐죠. 이제 모든 UITableViewCell를 확장할 수 있습니다.

extension FoodTableViewCell: NibLoadableView { }

FoodTableViewCell.NibName
// "FoodTableViewCell"

UICollectionViewCell에도 동일하게 이 재사용 뷰 프로토콜을 확장할 수 있습니다. 모든 셀은 기본 reuseIdentifier를 가지고 있어서 다시 타이핑하거나 신경쓰지 않아도 됩니다. FoodTableViewCell, reuseIdentifier 등등으로 설정하면 우리 대신 클래스를 스트링화해줍니다.

아직 길긴 하지만 가독성이 보다 높아졌습니다.

let foodCellNib = UINib(NibName: FoodTableViewCell.NibName, bundle: nil)
tableView.registerNib(foodCellNib,
                      forCellReuseIdentifier: FoodTableViewCell.reuseIdentifier)
extension UITableView {

    func register<T: UITableViewCell where T: ReusableView, T: NibLoadableView>(_: T.Type) {

        let Nib = UINib(NibName: T.NibName, bundle: nil)
        registerNib(Nib, forCellReuseIdentifier: T.reuseIdentifier)
    }
}

불편하게 스트링을 다루는 대신 NibName에도 똑같이 적용할 수 있습니다. Nib에서부터 로드되는 뷰인 NibLoadableView를 생성합니다. NibName에는 스트링화된 클래스 이름이 들어갑니다.

let foodCellNib = UINib(NibName: "FoodTableViewCell", bundle: nil)
tableView.registerNib(foodCellNib,
                      forCellReuseIdentifier: "FoodTableViewCell")

우리의 TableViewCell 처럼 Nib로부터 로드되는 뷰는 Nib loadable view 프로토콜을 따르게 되며, 클래스 이름을 스트링화하는 NibName 프로퍼티를 자동으로 가지게 됩니다. 인턴이 보더라도 클래스를 등록할 때마다 TableViewCell의 셀 NibName과 셀 reuseIdentifier을 가지고 옴을 알 수 있겠죠.

더 나아가서 아래 단 두 줄의 코드만으로 우리가 만든 셀을 등록할 제네릭을 사용할 수 있습니다.

tableView.register(FoodTableViewCell)

tableView를 확장해서 이들 두 프로토콜을 따르는 타입을 포함하는 등록 클래스를 생성할 수 있습니다. 이들 프로토콜로부터 재사용 identifier와 Nib 이름을 받죠. Nib 이름이 NibLoadableView를 따르는 두 줄의 코드를 위한 로직을 완벽히 분리했습니다. NibName 프로퍼티가 있어서 셀이 재사용 뷰 프로토콜을 따르며, 이 프로토콜은 reusable identifier 프로퍼티를 갖죠. 즉, 모든 테이블 뷰 안에 넣어야 했던 이 두 줄의 코드가 이제 분리됐습니다. 한 줄의 코드로 셀을 등록하므로 훨씬 간결합니다. 스트링과 씨름할 필요도 없고요.

아직 셀을 등록하고 셀을 큐에서 꺼내야 하는 작업이 남아 있는데, 이 부분도 제네릭과 프로토콜을 사용해서 개선할 수 있습니다. 셀을 큐에서 꺼낼 때 reuseIdentifier이 무엇인지 알려주면 됩니다. Swift에서는 옵셔널이 있으므로 아래 코드와 같이 사용합니다.

extension UITableView {

    func dequeueReusableCell<T: UITableViewCell where T: ReusableView>(forIndexPath indexPath: NSIndexPath) -> T {
        guard let cell = dequeueReusableCellWithIdentifier(T.reuseIdentifier, forIndexPath: indexPath) as? T else {
            fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)")
        }
        return cell
      }
}

셀을 큐에서 꺼낼 때, 아래처럼 보호 코드를 사용해서 셀이 큐에 없는 경우 치명적인 에러를 던지거나 명시적으로 언래핑 하도록 처리해야 합니다.

guard let cell = tableView.dequeueReusableCellWithIdentifier(FoodTableViewCell", forIndexPath: indexPath)
    as? FoodTableViewCell
    else {
      fatalError("Could not dequeue cell with identifier: FoodTableViewCell")
}

Objective-C에서 유래된 이런 코드는 깔끔하지 않죠. UIKit로부터 오는 코드로 개발자에게 통제권이 없습니다. 그러나 프로토콜을 사용한다면 모든 테이블 뷰 셀에 reuseIdentifier가 있으므로 개선이 가능합니다.

let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as FoodTableViewCell

위 코드로부터 아래 코드를 만들 수 있습니다.

if indexPath.row == 0 {
    return tableView.dequeueReusableCell(forIndexPath: indexPath) as DesertTableViewCell
}
return tableView.dequeueReusableCell(forIndexPath: indexPath) as FoodTableViewCell

셀을 큐에서 꺼낼 때마다 필요한 만큼 이 코드를 실행하면 됩니다. forIndexPath로 셀을 꺼내고 어떤 셀인지 알려줍니다. 셀이 여러 개 있다면 등록할 셀로 캐스팅하면 어떤 타입인지 즉시 알 수 있습니다.

마법같지 않나요! Swift와 옵셔널을 혼합하고 프로토콜과 제너릭을 사용해서 Objective-C 시절부터 오래도록 사용하던 방법에서 벗어나 전체 프로젝트에서 사용할 수 있는 보다 훌륭한 코드로 만들었습니다.

iOS 셀 등록과 프로토콜 익스텐션과 제네릭으로 재사용하기 (17:28)

Guille Gonzalez는 이를 collection view에 적용했는데, Protocol-Oriented Segue Identifiers in Swift 처럼 우리가 씨름해야 했던 UIKit의 다른 부분에도 적용할 수 있습니다. 이런 방법은 작년 WWDC의 예제에서도 사용됐습니다. 프로토콜 중심 프로그래밍은 정말 훌륭합니다!

네트워킹 (18:25)

네트워킹을 위해서는 주로 API 호출을 해야 합니다. 제 경우에는 서버에서 음식들을 가져오는 등의 서비스가 있다면, API를 사용해서 결과를 가져오는 get 함수를 사용했습니다. Swift의 에러 핸들링을 사용하곤 했는데 비동기식이라서 에러를 던질 수 없었죠.

struct FoodService {

    func get(completionHandler: Result<[Food]> -> Void) {
        // make asynchronous API call
        // and return appropriate result
    }
}

Haskel에서 유래돼 Swift에서 자주 사용되는 패턴인 resulting enum을 사용해 보겠습니다.

resulting enum은 단순합니다. 서버가 결과를 반환하면 파싱해서 성공인지 파악해서 성공이면 음식 아이템의 배열로 반환합니다. 실패인 경우 에러를 반환하고 컴플리션 핸들러로부터 뷰 컨트롤러가 실패의 경우에 할 일을 파악합니다.

enum Result<T> {
    case Success(T)
    case Failure(ErrorType)
}

서버가 응답을 비동기식으로 받을 경우의 컴플리션 핸들러로, 이를 통해 음식 아이템 결과값을 전달합니다.

struct FoodService {

    func get(completionHandler: Result<[Food]> -> Void) {
        // make asynchronous API call
        // and return appropriate result
    }
}

이제 뷰 컨트롤러가 아이템을 파싱합니다. 뷰 컨트롤러에 dataSource가 있는데 이 것이 바로 음식 배열입니다. 뷰가 로드될 때 이 비동기식 API 호출을 하고 컴플리션 핸들러에서 결과를 받습니다. 결과값이 음식 배열이면 성공입니다. 데이터를 리셋하고 테이블 뷰를 리로드합니다. 만약 결과값이 에러면 사용자에게 에러를 보여주고 처리합니다.

// FoodLaLaViewController

var dataSource = [Food]() {
    didSet {
        tableView.reloadData()
    }
}

override func viewDidLoad() {
    super.viewDidLoad()
    getFood()
}

private func getFood() {
    FoodService().getFood() { [weak self] result in
        switch result {
        case .Success(let food):
            self?.dataSource = food
        case .Failure(let error):
            self?.showError(error)
        }
    }
}

API를 호출할 때 흔히 사용하는 패턴이죠. 하지만 전체 뷰 컨트롤러가 음식 배열의 로딩에 의존적입니다. 데이터가 없거나 잘못된 경우 실패하죠. 뷰 컨트롤러가 데이터로 해야하는 일을 제대로 하는지 확인하기 위한 최선의 방법은, 테스트입니다.

뷰 컨트롤러 테스트?!!! (20:54)

뷰 컨트롤러를 테스트하는 것은 참 고통스럽습니다. 우리 예제의 경우 서비스에 비동기 API 호출, 컴플리션 블럭과 resulting enum까지 있으니 더 복잡해지죠. 따라서 뷰 컨트롤러가 원래 해야하는 일을 잘 하는지 테스트하기가 더 어려워집니다.

// FoodLaLaViewController

var dataSource = [Food]() {
    didSet {
        tableView.reloadData()
    }
}

override func viewDidLoad() {
    super.viewDidLoad()
    getFood()
}

private func getFood() {
    FoodService().getFood() { [weak self] result in
        switch result {
        case .Success(let food):
            self?.dataSource = food
        case .Failure(let error):
            self?.showError(error)
        }
    }
}

음식 서비스를 더 통제해야 합니다. 음식 배열인지 에러인지 선택해서 넣을 수 있어야 합니다. 하지만 벌써 문제가 있습니다. getFood()가 음식 서비스를 초기화할 때 테스트에서 우리 결과를 선택해서 넣을 방법이 없습니다. 첫 테스트는 의존성 주입을 추가하는 것입니다.

// FoodLaLaViewController

func getFood(fromService service: FoodService) {

    service.getFood() { [weak self] result in
        // handle result
    }
}
// FoodLaLaViewControllerTests

func testFetchFood() {
    viewController.getFood(fromService: FoodService())

    // now what?
}

이제 getFood() 함수가 FoodService를 받고, 우리 통제권이 좀 늘어났으니 더 테스트를 진행해 보겠습니다. 컨트롤러가 있고 getFood 함수를 호출하고 FoodService를 넘깁니다. 하지만 아직 우리는 FoodService에 대한 전체 통제권이 없죠. 어떻게 통제권을 얻을까요?

struct FoodService {
    func get(completionHandler: Result<[Food]> -> Void) {
        // make asynchronous API call
        // and return appropriate result
    }
}

밸류 타입이어서 서브클래싱을 할 수 없으므로 프로토콜을 사용해야 합니다. FoodService에는 get 함수와, 결과를 주는 completionHandler가 있습니다. 앱 내의 다른 서비스, get가 필요한 다른 API 호출도 비슷할 겁니다. 결과를 포함하는 컴플리션 핸들러가 있고 이를 파싱하겠죠.

보다 제네릭하게 바꿔볼까요?

protocol Gettable {
    associatedtype T

    func get(completionHandler: Result<T> -> Void)
}

연관 타입과도 프로토콜을 사용할 수 있는데 이것이 Swift에서 제네릭을 만드는 방법입니다. Gettable 프로토콜을 따르는 모든 것은 get 함수를 가지고 타입의 결과를 지니는 컴플리션 핸들러를 포함한다고 할 수 있습니다. 우리 예제의 경우 음식이 되겠죠. 호환성이 있으므로 후식 서비스의 경우 후식이 됩니다.

struct FoodService: Gettable {

    func get(completionHandler: Result<[Food]> -> Void) {
        // make asynchronous API call
        // and return appropriate result
    }
}

음식 서비스로 돌아가보면 유일한 변화는 Gettable 프로토콜을 따른다는 점 뿐입니다. get 함수는 이미 구현돼 있습니다. 또한 연관 타입과의 프로토콜이 똑똑하게 작동하므로 아이템 결과값을 포함할 completionHandler를 가지게 됩니다. 결과값은 음식 배열이고 연관 타입도 음식 배열이므로 특정할 필요가 없습니다.

뷰 컨트롤러는 예전과 동일합니다.

// FoodLaLaViewController

override func viewDidLoad() {
    super.viewDidLoad()
    getFood(fromService: FoodService())
}

func getFood<S: Gettable where S.T == [Food]>(fromService service: S) {
    service.get() { [weak self] result in

        switch result {
        case .Success(let food):
            self?.dataSource = food
        case .Failure(let error):
            self?.showError(error)
        }
    }
}

유일한 차이점은 음식 뷰 컨트롤러가 후식 서비스를 부르지 않도록 연관 타입이 음식 배열임을 특정해주는 것 뿐입니다. 이를 강제해서 이 getFood() 함수가 음식 아이템 결과만을 받도록 해야 합니다. 그렇지 않으면 Gettable 프로토콜을 따르는 어느 것이든 올 수 있으니까요. 이런 방법을 통해 FoodService로 무엇을 전달하는지 잘 통제할 수 있습니다. 실제로 FoodService일 필요는 없고 다른 것을 주입할 수도 있습니다.

테스트에서 Fake_FoodService를 생성할 수도 있습니다. 단, 1) Gettable프로토콜을 따르고, 2) 연관 타입이 음식 배열이어야 합니다.

// FoodLaLaViewControllerTests

class Fake_FoodService: Gettable {

    var getWasCalled = false

    func get(completionHandler: Result<[Food]> -> Void) {
        getWasCalled = true
        completionHandler(Result.Success(food))
    }
}

Gettable을 따르고 음식 결과값을 포함하며, 음식 배열을 반환합니다. 테스트의 용도이므로 Gettable로부터 get 함수가 불리는지 확인하고 싶을 겁니다. 함수가 잘 불려서 성공임을 확인하거나, 실패를 주입해서 뷰 컨트롤러가 결과 입력에 따라 의도한대로 잘 동작하는지 확인해야 합니다. 아래 테스트 코드를 확인하세요.

// FoodLaLaViewControllerTests

func testFetchFood() {
    let fakeFoodService = Fake_FoodService()
    viewController.getFood(fromService: fakeFoodService)

    XCTAssertTrue(fakeFoodService.getWasCalled)
    XCTAssertEqual(viewController.dataSource.count, food.count)
    XCTAssertEqual(viewController.dataSource, food)
}

우리가 통제할 수 있는 fakeFoodService를 주입해서 get 함수가 호출되고 FoodService로부터 주입된 데이터 소스가 뷰 컨트롤러에 할당된 것과 동일한지 테스트할 수 있습니다. 이제 뷰 컨트롤러를 테스트할 강력한 도구가 생긴 셈입니다. 동시에 Gettable 프로토콜을 추가해서 모든 다른 서비스를 위한 프레임워크도 갖게 됐습니다. 삭제와 갱신이 가능한 프로토콜을 생성해서 서비스를 확인하고 어떤 함수가 구현됐는지 확인해서 주입하고 테스트할 수 있습니다.

스토리보드 주입과 프로토콜 사용하기를 다룬 제 예제를 참고하고, Alexis Gallagher의 protocols with associated types 강연도 시청해 보세요. 제가 간단하게 다루긴 했지만 연관 타입과 프로토콜을 사용하는 것은 종종 기대했던 대로 작동하지 않습니다. 실제 사용하기에 혼란스러울 수도 있고 한계도 존재합니다.

실용적인 프로토콜 중심 프로그래밍: 결론 (27:40)

뷰 컨트롤러, 뷰, 네트워킹 등 일상적으로 작성하는 코드에 프로토콜을 어떻게 적용할 수 있는지 말해봤습니다. 보다 안전하고 유지 가능하며 보다 균일하면서 모듈화되고 테스트가 가능한 코드를 작성하는데 도움이 될 겁니다. 서브클래싱에 비교하면 프로토콜은 경이로울 따름입니다.

하지만 프로토콜에서도 주의할 점이 있습니다. 제 경우 너무 많은 프로토콜을 사용하고 있을지도 모릅니다. 새로 배운 멋진 기능이니 모든 곳에 적용하고 싶었죠. 하지만 필요하지 않은 경우도 있습니다. 떨림 기능이 있는 뷰를 다룬 제 첫 예제는 처음에는 괜찮았지만 두 개의 뷰가 되자 리팩토링을 해서 프로토콜을 만들었습니다. 이처럼 과하게 프로토콜에 집착하지는 마세요.

두 가지 흥미로운 사이트를 소개하면서 마치겠습니다.

  1. Rob Napier의 Beyond Crusty: Real-World Protocols에서는 실생활 프로토콜에 대해 다루는데, 나쁜 코드로부터 시작해서 이를 리팩토링해서 프로토콜로 만듭니다. 마지막에는 20줄의 코드에 10가지 프로토콜이 담기며, 문제를 단 4줄의 구조체와 프로토콜을 통해 풀어나갑니다. Swift에는 강력한 enum, 구조체, 프로토콜 등 새롭게 달라진 것이 많으므로 문제 해결을 위한 해결책도 달라지겠죠. 프로토콜을 잘 활용하는 것도 중요하지만 구조체나 조합으로도 충분히 해결할 수 있는지 항상 염두에 두는 것이 좋습니다.

  2. Daniel Steinberg의 Blending Cultures: The Best of Functional, Protocol-Oriented, and Object-Oriented Programming에서는 프로토콜 사용이 강제적이지 않고 객체 지향적, 혹은 기능 중심적 아이디어에 익숙하기 때문에 이 모든 것들을 사용할 수 있는지에 대해 말합니다. 코드에서 바뀌지 않는 것을 추출하기 위해 여러 방법들을 사용하는 법을 보여줍니다.

실무에서 코딩할 때 프로토콜도 함께 염두에 두세요!

다음: iOS 프로그래밍 아키텍처와 패러다임 #4: 프로토콜 지향 MVVM을 소개합니다.

General link arrow white

컨텐츠에 대하여

2016년 4월에 진행한 App Builders 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Natasha Murashev

Natasha is secretly a robot that loves to learn about Swift and iOS development. She previously worked as a senior iOS Engineer at Capital One in San Francisco, but now she travels around, writing about her experiences with new technologies. In her free time, she works on personal projects, speaks at meetups and conferences, contributes to open source, and likes to cross things off her bucket list.

4 design patterns for a RESTless mobile integration »

close