ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TCA - Dependency 설계
    TCA 2024. 6. 29. 15:53

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 TCA에서 Dependencies 설계에 대해 알아보겠습니다 🙋🏻


    Designing dependencies

    종속성을 기능에 주입하고 테스트를 위해서 재정의하는데 도움이 되는 설계가 필요합니다.

    즉, 핵심적으로 가져가야할것은 종속성을 제어할 수 있게 만들기 위해서 격리 및 테스트가 가능하게 만드는것이죠!

     

    한단계씩 알아볼까요?

     


    Protocol-based dependencies

    Swift에서 종속성을 설계하는 방법 중 가장 널리 사용되는것이 프로토콜을 이용하는것입니다.

    예시로, 아래와 같이 오디오 플레이에 대한 프로토콜을 설계해본다고 가정해봅시다.

     

    protocol AudioPlayer {
      func loop(url: URL) async throws
      func play(url: URL) async throws
      func setVolume(_ volume: Float) async
      func stop() async
    }

     

    이렇게 재생부터 정지까지 기능을 담은 프로토콜을 설계해봤어요.

     

    해당 프로토콜은 이제 실제로 사용될 수 있도록 채택하여 각 역할을 구현해볼 수 있습니다.

     

    struct LiveAudioPlayer: AudioPlayer {
      let audioEngine: AVAudioEngine
      // ...
    }
    struct MockAudioPlayer: AudioPlayer {
      // ...
    }
    struct UnimplementedAudioPlayer: AudioPlayer {
      func loop(url: URL) async throws {
        XCTFail("AudioPlayer.loop is unimplemented")
      }
      // ...
    }

     

    이렇게 실제 라이브하게 실 기능을 해줄 수 있게 구현하는 타입과 테스트를 위한 Mock, Unimplemented 타입으로 채택하여 실제 구현을 해줄 수 있습니다.

     

    그럼 이제 이 객체들을 적절히 DependencyKey를 통해 liveValue부터 testValue까지 구현할 수 있습니다.

     

    private enum AudioPlayerKey: DependencyKey {
      static let liveValue: any AudioPlayer = LiveAudioPlayer()
      static let previewValue: any AudioPlayer = MockAudioPlayer()
      static let testValue: any AudioPlayer = UnimplementedAudioPlayer()
    }

     

    기본적으로 저 세개에요.

    실제 상용되는 liveValue, 프리뷰에서 보여줄 previewValue, 테스트를 위한 testValue로 아까 만든 객체를 넣어줘서 설정합니다.

     

    이게 기본적으로 프로토콜 기반으로 종속성을 설계하는 방식이죠.

     

    두번째로 구조체 기반 종속성에 대해 보겠습니다.

     


    Struct-based dependencies

    프로토콜 기반에서 더 많은 사용을 위해 변경해볼 수 가 있습니다.

    즉, 오디오 플레이어를 프로토콜이 아닌 클로저 프로퍼티를 가진 구조체로 만들어 인터페이스를 구현할 수 있죠.

     

    struct AudioPlayerClient {
      var loop: (_ url: URL) async throws -> Void
      var play: (_ url: URL) async throws -> Void
      var setVolume: (_ volume: Float) async -> Void
      var stop: () async -> Void
    }

     

    해당 구조체를 이제는 확장시켜 live부터 mock, unimplemented 유형 값을 정의할 수 있습니다.

     

    extension AudioPlayerClient: DependencyKey {
      static var liveValue: Self {
        let audioEngine: AVAudioEngine
        return Self(/* ... */)
      }
    
    
      static let previewValue = Self(/* ... */)
    
    
      static let testValue = Self(
        loop: unimplemented("AudioPlayerClient.loop"),
        play: unimplemented("AudioPlayerClient.play"),
        setVolume: unimplemented("AudioPlayerClient.setVolume"),
        stop: unimplemented("AudioPlayerClient.stop")
      )

     

    이제 DependencyValues를 확장하여 마찬가지로 해당 객체에 대해 get set 프로퍼티로 구현해줍니다.

     

    extension DependencyValues {
      var audioPlayer: AudioPlayerClient {
        get { self[AudioPlayerClient.self] }
        set { self[AudioPlayerClient.self] = newValue }
      }
    }

     

    여기서 우리는 XCTestDynamicOverlay의 unimplemented 메서드를 사용해 호출 시 XCTest 실패를 유발할 수 있는 클로저를 제공됩니다.

     

    이에 대해서는 밑에서 어떻게 처리하는지 알아볼께요!

     

    이러한 방식으로 종속성을 설계한다면 기능에 필요한 종속성 엔드 포인트를 선택할 수 있죠.

    예시로, play에 대한것만 필요하다 싶으면 아래와 같이 사용할 수 있습니다.

     

    final class FeatureModel: ObservableObject {
      @Dependency(\.audioPlayer.play) var play
      // ...
    }

     

    테스트를 고려할때 테스트에선 종속성의 최소값을 재정의할 수도 있습니다.

    예시로, 아래와 같이 사용할 수도 있죠.

     

    func testFeature() {
      let isPlaying = ActorIsolated(false)
    
    
      let model = withDependencies {
        $0.audioPlayer.play = { _ in await isPlaying.setValue(true) }
      } operation: {
        FeatureModel()
      }
    
    
      await model.play()
      XCTAssertEqual(isPlaying.value, true)
    }

     

    즉 해당 테스트를 통과한다면 테스트하는 사용자 흐름에서는 종속성의 다른 엔드 포인트가 사용되지 않는다는것을 보장할 수 있죠.

     

    다음으로 @DependencyClient 매크로에 대해 알아볼께요.

     


    @DependencyClient macro

    해당 TCA에서는 구조체 기반 종속성 인터페이스의 더욱 개선된 매크로가 함께 제공됩니다.

    @DependencyClient죠.

     

    다만, 매크로는 SwiftSyntax에 의존해 빌드 시간이 약 20초 정도 증가하기에 해당 패키지 내에서 별도의 라이브러로 제공합니다.

    즉, 우린 디펜던시 매크로 라이브러리를 사용하여 명시적으로 아래와 같이 사용해야 합니다.

     

    import DependenciesMacros
    
    @DependencyClient
    struct AudioPlayerClient {
      var loop: (_ url: URL) async throws -> Void
      var play: (_ url: URL) async throws -> Void
      var setVolume: (_ volume: Float) async -> Void
      var stop: () async -> Void
    }

     

    또한 테스트를 위해선 이젠 unimplemented 대신 아래와 같이 사용할 수 있습니다.

     

    extension AudioPlayerClient: TestDependencyKey {
      static let testValue = Self()
    }

     

    즉, TestDependencyKey를 채택하여 Self로 사용할 수 있죠.

     

    더이상 아래와 같이 하지 않아도 되는것이죠.

     

    extension AudioPlayerClient: TestDependencyKey {
      static let testValue = Self(
        loop: unimplemented("AudioPlayerClient.loop"),
        play: unimplemented("AudioPlayerClient.play"),
        setVolume: unimplemented("AudioPlayerClient.setVolume"),
        stop: unimplemented("AudioPlayerClient.stop")
      )
    }

     

    더 편해지고 간결해졌죠?

     

    이젠 그럼 아래와 같이 리듀서와 같은 필요한 곳에서 사용할 수 있습니다.

     

    try await player.play(url: URL(filePath: "..."))

     

    간단하죠?

     

    해당 매크로는 모든 클라이언트의 엔드 포인트와 함께 이니셜라이저를 생성합니다.

    즉, 여기서의 팁이 있습니다!

     

    var loop: (_ url: URL) async throws -> Void

     

    이렇게 해당 클로저 프로퍼티가 에러를 던지는 타입인 throws를 지니고 있으면 초기화를 시켜주지 않아도 됩니다.

     

    그러나, 그렇지 않은 아래와 같은 타입이 있다면

     

    var get: () async -> Int

     

    Swift 컴파일러의 버그때문에 throw 하지 않는 엔드 포인트는 호출 시 테스트 실패를 보내지 않습니다.

    즉, 이런 프로퍼티는 꼭 초기화를 명시적으로 종속성에 적용해야 합니다.

     

    @DependencyClient
    struct NumberFetcher {
      var get: () async -> Int = { 42 }
    }

     

    이런식으로 말이죠.

     

    아니면 요렇게요.

     

    @DependencyClient
    struct NumberFetcher {
      var get: () async -> Int = { XCTFail("\(Self.self).get"); return 42 }
    }

     

    이 부분은 항상 주의해야 합니다!

     

    위에서 말한 Swift 컴파일러의 버그는 아래 이슈를 참고해보시면 도움이 될겁니다!

     

     

    Accessor macro executing wrong code · Issue #71070 · swiftlang/swift

    Description We've implemented a macro similar to @ObservationTracked that swaps out a stored property for accessors that call down to some private storage. Much to my surprise, though, that private...

    github.com

     


    마무리

    TCA에서뿐만 아니라 중요한 종속성을 참고하여 한번 맛깔나게 설계해보면 좋을것 같습니다 

     


    레퍼런스

     

    swift-dependencies Documentation – Swift Package Index

     

    swiftpackageindex.com

    'TCA' 카테고리의 다른 글

    TCA - Shared State  (78) 2024.08.05
    TCA 1.0 - Testable Code (ch.09)  (85) 2024.03.02
    TCA 1.0 - Navigation (ch.08)  (84) 2024.02.27
    TCA 1.0 - MultiStore (ch.07)  (85) 2024.02.22
    TCA 1.0 - Swift의 비동기 처리와 TCA에서의 응용 (ch.06)  (81) 2024.02.20
Designed by Tistory.