[TIL] 09.09

rbw·2023년 9월 9일
1

TIL

목록 보기
88/97

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

참고

https://academy.realm.io/kr/posts/appbuilders-natasha-muraschev-practical-protocol-oriented-programming/

위 링크를 보며 정리한 글 자세한 내용은 위 링크 참고 바람바람바람~


PM이 버튼을 클릭하면 떨리는 효과를 시작하는 뷰를 만들어달라고 한 상황을 가정해보자 암호에서 흔하게 사용하는 애니메이션을 생각하면 된다.

//  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")
    }
}

class ViewController: UIViewController {

    @IBOutlet weak var foodImageView: FoodImageView!

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

뷰 컨트롤러에 잘 추가가 되었네요 !

하지만 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")
    }
}

class ViewController: UIViewController {

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

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

서브 클래싱을 해서 버튼을 만들고 shake()함수도 추가하였습니다. 이제 버튼과 이미지 뷰를 떨리게 하는 작업이 완료가 되었습니다.

하지만 지금 코드는 중복이 발생하고있습니다. 떨림 효과를 수정하려면 두 군데를 찾아가야 하니 깔끔하지 않습니다.

이런 문제를 파악하고 해결하는 방법은 여러가지가 있습니다. 바로 생각나는건 UIView를 확장시키는 방법입니다.

//  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")
    }
}

하지만 이 방식은 가독성이 떨어집니다. 예를 들어, foodImageView, actionButton에서 떨리는 기능을 파악할 수 없습니다. 클래스 어디에서도 이들이 떨릴거라는것을 알려주지 않습니다. 원래 기능이 아닌 무작위의 떨림 기능이 어딘가에 존재하므로 이 코드는 분명하지 않습니다.

떨림 기능을 넣은 후 또 희미해지는 기능을 추가하라는 요청이 온다면, 이 코드 아래도 또 변종 기능들이 줄줄이 추가될 확률이 높습니다. 이러한 상황을 해결하는 방법으로 프로토콜이 있습니다.

Shakeable프로토콜을 만듭니다.

// Shakeable.swift
import UIKit

protocol Shakeable { }

extension Shakeable where Self: UIView {
    func shake() {
        // implementation code
    }
}

// 프로토콜을 채택해서 떨림 기능이라는 의도를 알 수 있음
class FoodImageView: UIImageView, Shakeable { }
class ActionButton: UIButton, Shakeable { }

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

이제 뷰에 떨림기능과 희미해지는 기능이 필요하다고 하면 Dimmable이라는 프로토콜을 만들어서 채택시키면 됩니다.

테이블/컬렉션 뷰에서의 활용

보통 셀을 등록할때 문자열로 받는 경우가 대부분일것이라고 생각한다. 그러한 부분도 프로토콜로 편하게 해결이 가능함.

import Foundation

extension NSObject {
    static var identifier: String {
        String(describing: self)
    }
}

위 코드를 작성하면, 셀을 등록할때 cell.identifier만 작성하면 문자열을 받을 수 있으므로, 가독성이 더 좋아진다.

다음으로 셀을 재사용하는 함수의 사용도 개선이 가능하다

// 컬렉션뷰 기준 테이블 뷰도 가틈
extension UICollectionView {
    func dequeueReusableCell2<T: UICollectionViewCell>(forIndexPath indexPath: IndexPath) -> T {
        guard let cell = dequeueReusableCell(withReuseIdentifier: T.identifier, for: indexPath) as? T else {
            fatalError("Could not dequeue cell with identifier: \(T.identifier)")
            }
        return cell
    }
}


// 아래와같이 사용하던 부분이,
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FoodTableViewCell", forIndexPath: indexPath)
    as? FoodTableViewCell
    else {
      fatalError("Could not dequeue cell with identifier: FoodTableViewCell")
}

// 요런식으로다가 사용이 가능하다.
let cell = collectionView.dequeueReusableCell2(forIndexPath: indexPath) as FoodTableViewCell

이의 장점은, UIKit로부터 오는 코드는 개발자에게 통제권이 없는데 프로토콜을 사용하여 조금 개선이 가능한 부분이다.

네트워킹

네트워킹을 위해서는 주로 API호출을 해야 합니다. 아래 코드는 일반적으로 API를 호출하는 패턴입니다. 하지만 전체 뷰 컨트롤러가 음식 배열의 로딩에 의존적입니다. 데이터가 없거나 잘못된 경우 실패합니다. 뷰 컨트롤러가 데이터로 해야하는 일을 제대로 하는지 확인하기 위한 최선의 방법은 테스트입니다.

// FoodLaLaViewController
struct FoodService {
    func get(completionHandler: Result<[Food]> -> Void) {
        // make asynchronous API call
        // and return appropriate result
    }
}
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 호출, 컴플리션 블럭과 Result까지 있으니 복잡합니다

따라서 위 코드에 있는 FoodService를 더 컨트롤할 필요가 있습니다. 음식 배열인지 에러인지 선택해서 넣어야 합니다. 하지만 getFood()FoodService()를 초기화할 때 결과를 선택해서 넣을 방법이 없습니다. 따라서 첫 테스트는 의존성을 주입하는 것입니다.

func getFood(fromService service: FoodService) {

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

// 그러면 아래에서 주입이 가능하다 !
// FoodLaLaViewControllerTests
func testFetchFood() {
    viewController.getFood(fromService: FoodService())

    // now what?
}

이제 우리에게 통제권이 늘어났습니다. 하지만 아직 FoodService에 대한 전체 통제권이 없습니다. 이는 어떻게 얻을까요 ?

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

그래서 더 제네릭하게 바꿔봅니다 !

protocol Gettable {
    associatedtype T

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

이런식으로 말이죠, 연관 타입을 사용해서 음식을 넣는다면 음식을, 후식을 넣는다면 후식 서비스가 됩니다.

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

유일한 변화는 Gettable 프로토콜을 채택했다는 점 말고는 없습니다. 뷰 컨트롤러는 예전과 거의 동일합니다.

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

// Gettable을 따르는 서비스이고, 서비스의 연관타입이 [Food]인지 체크 !
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)
        }
    }
}

여기서 해줄 부분은, 다른 이상한 서비스(Gettable을 따르는)를 부르지 않도록 연관 타입이 음식 배열([Food])임을 특정해 주는 것뿐입니다.

이를 참고하여, Fake_FoodService를 생성할 수도 있습니다.

// FoodLaLaViewControllerTests

class Fake_FoodService: Gettable {
    var getWasCalled = false

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

// 함수가 잘 불려서 성공을 확인하거나,
// 실패를 주입해서 컨트롤러가 결과 입력에 따라 의도한대로 동작한지 확인합니다.
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로 부터 주입된 데이터 소스가 뷰컨에 할당된 것과 동일한지 테스트 가능합니다.


이 글을 통해, 프로토콜을 활용해 좀 더 제네릭하고, 확장성이 좋은 프로그래밍 방식을 조금 배웠다.. 앞으로 프로젝트에 많이 활용해야 하지 싶으,, 더 알아봐야하긴 하겠지마는 ~

profile
hi there 👋

0개의 댓글