-
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 컴파일러의 버그는 아래 이슈를 참고해보시면 도움이 될겁니다!
마무리
TCA에서뿐만 아니라 중요한 종속성을 참고하여 한번 맛깔나게 설계해보면 좋을것 같습니다
레퍼런스
'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