본문 바로가기
Dev/고민과 삽질의 기록들🤔

[삽질] 클래스의 확장성 개선(protocol 의존성 주입: any)

by Mintta 2023. 6. 1.

Trouble Shooting (Action나누면서)

기존 상황

import Foundation

public typealias CompleteBottomSheetAction = ((DidBottomSheetActionType) -> Void)

protocol BottomSheetAction {
  func sendAction(_ action: DidBottomSheetActionType)
  func cancel()
  var completeAction: CompleteBottomSheetAction? { get }
}

위와 같이 Action 프로토콜을 정의하고 해당 프로토콜을 정의하는 아래와 같은 클래스가 있다.

 

import Foundation

final class RuleBottomSheetAction: BottomSheetAction {

    let view: RuleBottomSheetView

    var completeAction: CompleteBottomSheetAction?

    init(view: RuleBottomSheetView) { self.view = view }

    func sendAction(_ action: DidBottomSheetActionType) {
        completeAction?(action)
    }

    func cancel() {
        completeAction?(.cancel)
    }

}

해당 Action클래스는 ViewController에 의존성 주입을 통해서 넣어준다.

 

final class RuleBottomSheetViewController: UIViewController {

    private let bottomSheetView: RuleBottomSheetView
    private let action: RuleBottomSheetAction

    init(action: RuleBottomSheetAction) {
        self.action = action
        self.bottomSheetView = action.view
        super.init(nibName: nil, bundle: nil)
    }
        ...
}

여기서 한번 개선점을 느꼈다. BottomSheetAction의 프로토콜을 채택하고 있는 객체 그 자체를 넘길 것이 아니라 프로토콜을 넘기면 확장성을 더 높이 가져갈 수 있지 않을까? 하고 생각했다.

그렇다면 현재의 프로토콜에는 view가 없다. view를 추가하면 되지!

 

개선 과정

import Foundation

public typealias CompleteBottomSheetAction = ((DidBottomSheetActionType) -> Void)

protocol BottomSheetAction {
  func sendAction(_ action: DidBottomSheetActionType)
  func cancel()
  var completeAction: CompleteBottomSheetAction? { get }
}

다시 프로토콜로 돌아와서 이 곳에 이제 view를 추가해보자 !

하지만 여기서 드는 첫번째 의문. View는 모두 제각각의 View를 가지고 있기 때문에 Type을 하나로 정해줄 수가 없다. 나는 여기서 associatedtype을 떠올렸다. 딱 이런 상황을 위해 내게 필요한 도구 였다.

그럼 한번 추가해보자

 

import UIKit

public typealias CompleteBottomSheetAction = ((DidBottomSheetActionType) -> Void)

protocol BottomSheetAction {
    associatedtype T: UIView
    func sendAction(_ action: DidBottomSheetActionType)
    func cancel()
    var completeAction: CompleteBottomSheetAction? { get }
    var view: T { get set }
}

view는 UIView일 것이기 때문에 associatedtype에 UIView의 제약을 걸어주었다.

 

import Foundation

final class RuleBottomSheetAction: BottomSheetAction {

    var view: RuleBottomSheetView

    var completeAction: CompleteBottomSheetAction?

    init(view: RuleBottomSheetView) { self.view = view }

    func sendAction(_ action: DidBottomSheetActionType) {
        completeAction?(action)
    }

    func cancel() {
        completeAction?(.cancel)
    }

}

그리고 해당 BottomSheetAction 프로토콜을 채택하는 곳에서 view: T의 T자리에 RuleBottomSheetView의 타입이 들어가기 때문에 타입추론이 가능해지는 점을 이용해서 위와 같이 코드를 작성할 수 있다.

처음과 바뀐점은 let viewvar view가 되었다는점?

이제 그러면 ViewController에서 구체적인 객체의 타입을 받는 것이 아닌 프로토콜 타입으로 받도록 해보자.

 

final class RuleBottomSheetViewController: UIViewController {

    private let backgroundView: UIView = {
      let view = UIView()
      view.backgroundColor = .black.withAlphaComponent(0.5)
      return view
    }()

    private let bottomSheetView: RuleBottomSheetView

    private let action: any BottomSheetAction

    private let disposeBag = DisposeBag()

    init?(action: any BottomSheetAction) {
        self.action = action
        guard let view = action.view as? RuleBottomSheetView else { return nil }
        self.bottomSheetView = view
        super.init(nibName: nil, bundle: nil)

    }
        ...
}

이 곳은 변경사항이 꽤 있다.

우선 프로토콜을 통한 접근을 하기 때문에 구체적인 타입을 알 수 없다. 따라서 init에서 해당 action의 view에 대한 downcasting이 필요했다. (이 방법은 어떻게 개선할 수 있지 않을까 싶은데 지금까지는 이게 내 최선이다…)

그 후 downcasting이 실패했을 시에 대한 처리를 해주기 위해 initfailable initializer로 바꿔주었다.

또 다른 점은 BottomSheetAction 타입을 가진 곳에 모두 any가 들어갔다는 것이다.

Swift 5.7부터 existential type에는 any를 붙여야만 하기 때문이다.

 

existential type이 뭔데 그럼? 왜 붙이는데?

existential은 프로토콜을 준수하는 한 동적으로 할당할 수 있는 값이다.

위의 코드에서 Swift 5.7 전으로

private let action: BottomSheetAction

 

이 코드는 컴파일이 되었었다.

여기서 컴파일러가 뒤에서 하고있는 일은, 프로토콜을 위한 wrapper type을 만드는 것이다. (이 부분은 더 공부해봐야겠다)

메모리에 얼마나 많은 공간을 할당할지 알 수 없기 때문에 Swift는 할당할 수 있는 모든 것에 대해 일정량의 메모리를 할당한다.

변수에 할당되는 값이 더 적은 공간을 필요로 한다면 메모리에 잘 저장될 것이다. 변수에 할당된 값이 할당된 공간보다 더 많은 공간을 필요로 하는 경우, 값을 다른 곳에 저장해야 하며 해당 값에 대한 포인터가 대신 저장된다. 따라서 동적 할당이 가능하다. 즉, dynamic dispatch로 적용된다는 이야기이다. dynamic dispatch에 대한 얘기는 여기서 다루지 않는다.

이제는 5.7 이 후 부터는 위와 같은 코드에서 any를 꼭 명시해서 써주어야한다. any를 명시적으로 써줌으로써 개발자가 뭘 할려고 하는지 더 인식할 수 있게 만든다.

(클로저 안에서 self.를 쓰라고 에러 메시지를 띄우는 이유와 비슷하다고 느꼈다.)

결과

  • 구체적 타입이 아닌 프로토콜을 타입으로 의존성 주입이 가능해졌고, 확장성의 개선을 가져갈 수 있었다.

생각해야할 점

  • any 대신 다른 방법
  • downcasting 대신 다른 방법

 

++ 20230601

 

같이 프로젝트를 하는 리드분이 개선의 방향과 이유는 이해했는데 현상황!, 즉 view와 action이 모두 각각 있는 상황에서의 이러한 변경점이 가지는 의미에 대해서 여쭤보셨다.! 그래서 이렇게 '결과'에서 조금 더 정리해보고자 한다.

아래는 PR에 남긴 것을 일부 가져와봤습니다.


위처럼 코드를 바꿨을 때의 이점과 왜 그렇게 생각했는지를 다시 정리해서 생각해보았습니다.

  1. 우선 protocol로 타입을 넘기게 되면 해당 객체의 구체화된 타입에 의존하지 않아도 되니까 나중에 객체가 추가되거나 다른 객체로 변경된다하더라도 해당 프로토콜을 준수하고 있는 한 변경할 필요가 없다고 생각해서 결합도를 낮추고 확장성이 좋아진다가 장점이라고 생각했고, 더욱 유연해질 수 있다고 생각했습니다
  2. ViewController가 상위 객체이고 Action이 거기에 소속되는 하위 객체인데 하위 객체의 변경이 상위 객체의 변경을 야기시키면 안된다는 DIP의 원칙으로 봤을 때도 프로토콜로 바꾸는게 더 나을 거라고 생각했습니다
  3. 프로토콜을 통해서 접근하기 때문에 보다 프로토콜에 정의된 메서드와 프로퍼티만 사용할 수 있어 안전한 코드를 작성할 수 있다. 구현부와 호출부를 나누어서 ViewController쪽에서는 구현이 어떻게 되어 있는지 전혀 알 필요도 없고 알 수도 없는 장점이 있을 것 같습니다
  4. 모든 action이 view를 가지기에 프로토콜도 view를 가지는게 자연스럽다고 생각했습니다

그렇다면 지금 현상황(view, action 모두 각각 있는 상황)에서는 큰 의미가 있는지를 고민해보면 호세형의 말대로 큰 의미가 없을 수 있겠다고 생각합니다. 하지만 위의 장점들을 기반으로 객체지향적인 설계를 했을 때의 장점은 있을거라고 판단했었습니다! 코드가 늘어나는 거에 대한 건 어느정도 트레이드오프라고 생각하긴하는데 아마 guard let으로 downcasting하는 부분 때문에 생기는 코드들이 거의 대부분인 것 같아서 여기서 타입을 확실하게 알 수 있는 방법이 있을지는 고민중입니다만 아직 확실하게는 잘모르겠네요ㅜㅜ

 

Ref

https://medium.com/@paulwall_21/the-any-keyword-in-swift-7c127fad30f0

https://zeddios.tistory.com/1348

댓글