SwiftUI 프리뷰가 작동하지 않을 때 ~ 의존성 주입(DI)

JaeEun Lee·2024년 10월 27일

SwiftUI & Jetpack compose

목록 보기
2/10

얼마전 swiftUI에게 아래와 같은 두가지 질문을 받았다.
1. 프리뷰에서 빌드에러가 나서 표시가 안되요.
2. 특정라이브러리를 쓰기때문에 빌드&실행해서 확인하는게 불편해요.
위의 이유로 스토리보드로 돌아가고싶어요..라는 내용이었다.

위 상담에 대한 실제 코드를 보지 않았기에 정확히 알수는 없지만 내 경험에 비추어 보면 뷰와 비스니스로직이 강하게 결합되어 있는 상태가 아닐까 생각되어 위 상황을 가정하여 해결방법에 대해 설명해 보려고 한다.

1. 프리뷰 문제 해결

먼저 SwiftUI에서 ObservableObject를 사용하기 위해 어떠한 클래스를 선언하고 사용해야 할것이다.(ViewModel) 그런데 이걸 클래스로 선언해 버리면 결과적으로 View와 강하게 결합되는 결과를 초래한다.
반드시 View와 ViewModel은 프로토콜을 선언하여 사용해야 한다. ~ 객체지향 프로그래밍(OOP)의 다형성(Polymorphism)

다만, SwiftUI에서 ObservableObject를 사용하여 ViewModel을 만들 때 프로토콜 기반의 DI를 적용하기 어려운 점이 있다. (ObservableObject는 클래스 타입에 한정되며 프로토콜로 사용하기 어렵기 때문이다.)

프로토콜을 통해 DI를 적용하고 실제 구현체에서 ObservableObject를 구현하는 방법이다. 인터페이스로 사용할 프로토콜에는 ObservableObject를 포함하지 않고 실제 ViewModel 구현체에만 ObservableObject를 적용는 예시코드이다.

import SwiftUI

// ViewModel 프로토콜 및 구현체
protocol MyViewModelProtocol: ObservableObject {
    var data: String { get }
    func fetchData()
}

class MyViewModel: MyViewModelProtocol {
    @Published var data: String = ""

    func fetchData() {
        // 실제 데이터 로직 구현 예시
        self.data = "Real Data from Fetch"
    }
}

// SwiftUI View
// 참고: 여기서 제네릭을 쓰는 이유는 컴파일단계에서 viewModel형을 확정하기 위해서임. 
// 여기서 제네릭을 선안하지 않고 프로토콜형으라고 명시해도 됨. 
// 그러면 아래와 같이 선언하루 수 있음
// struct MyView: View {
//  @ObservedObject var viewModel: any MyViewModelProtocol

struct MyView<ViewModel: MyViewModelProtocol>: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text(viewModel.data)
                .padding()
            Button("Fetch Data") {
                viewModel.fetchData()
            }
        }
    }
}

// 프리뷰 코드: 초기 data 값을 설정하여 화면상태 확인
struct MyView_Previews: PreviewProvider {
    static var previews: some View {
    	// 프리뷰를 위한 Mock
        let viewModel = MyViewModel()
        viewModel.data = "Preview Initial Data" // 초기 데이터 설정
        
        // Mock를 DI
        return MyView(viewModel: viewModel)
    }
}

위와같은 코드의 또하나의 장점은 프로토콜에서 ObservableObject 의존성을 분리함으로써 ViewModel의 테스트 가능성을 높이고 테스트 중에도 불필요한 SwiftUI 종속성을 줄일 수 있다.
Mock ViewModel를 사용하여 테스트용 Mock ViewModel을 구현하도록 하면 ObservableObject에 종속되지 않으므로 SwiftUI 종속 없이 테스트 환경에서 가짜 데이터를 반환하도록 할 수 있다.

class MockViewModel: MyViewModelProtocol {
    var data: String = "Test Data"
    func fetchData() {
        // 가짜 데이터 로직
    }
}

MyViewModel에 대해 ObservableObject를 사용하지 않으므로 SwiftUI 환경과 분리된 유닛 테스트가 가능하다. XCTest를 사용할 때, @Published를 신경 쓰지 않고도 상태 변화를 조작할 수 있으므로 테스트를 쉽게 작성할 수 있다.
또 인터페이스만 구현하면 되므로 다양한 테스트 더블(stub, spy, mock...)을 통해 의도한 동작을 쉽게 시뮬레이션할 수 있다. 이는 ViewModel이 여러 의존성을 가지는 경우 더 유용하다.

import XCTest
@testable import YourApp

final class MyViewModelTests: XCTestCase {
   func testFetchData() {
       let viewModel = MockViewModel()
       viewModel.fetchData()
       
       XCTAssertEqual(viewModel.data, "Test Data")
   }
}

2.특정라이브러리부터 의존성해결

특정 라이브러리의 의존성 때문에 SwiftUI 프리뷰가 작동하지 않는 문제를 해결하려면 의존성 주입(DI) 및 조건부 코드를 사용해 프리뷰와 실제 앱 로직을 분리하는 방법을 생각할 수 있다. 이를 통해 프리뷰에서는 특정 라이브러리를 사용하지 않고 대체 객체를 사용할 수 있다.

프로토콜과 Mock 객체 활용하기

라이브러리와 의존 관계가 있는 부분을 인터페이스(프로토콜)로 추상화하고 프리뷰에서는 Mock 객체를 사용하도록 설정한다.

protocol SomeServiceProtocol {
    func fetchData() -> String
}

class RealService: SomeServiceProtocol {
    func fetchData() -> String {
        // 실제 라이브러리의 기능 사용
        return "Real Data from Commercial Library"
    }
}

class MockService: SomeServiceProtocol {
    func fetchData() -> String {
        return "Mock Data for Preview"
    }
}

ViewModel 또는 View에서 프로토콜을 통해 서비스 객체를 주입받도록 구성하면 프리뷰에서만 Mock 객체를 사용할 수 있다.
실제 클래스의 구성을 해 보았다.

실제 예시

서비스 정의
라이브러리를 사용할 서비스를 정의한다.

import Foundation

// 서비스 프로토콜 정의
protocol SomeServiceProtocol {
    func fetchData() -> String
}

// 상용라이브러리를 사용하는 실제 서비스
class RealService: SomeServiceProtocol {
    func fetchData() -> String {
        // 실제라이브러리의 실제 데이터 로직
        return "Real Data from Commercial Library"
    }
}

// 빌드 환경에 따라 사용할 Stub 서비스
class StubService: SomeServiceProtocol {
    func fetchData() -> String {
    	// 여기선 라이브러리를 사용하지 않고 적절히 반환해주게 구성
        return "Stub Data for Testing/Preview"
    }
}

ViewModel 정의
ViewModel에서 SomeServiceProtocol을 의존성 주입(DI) 방식으로 받아 서비스 구현체가 변경되더라도 동일한 인터페이스를 사용할 수 있도록 설정한다.

import SwiftUI
import Combine

class MyViewModel: ObservableObject {
    @Published var data: String = ""
    private var service: SomeServiceProtocol

    init(service: SomeServiceProtocol) {
        self.service = service
        self.data = service.fetchData()
    }
    
    func fetchData() {
        self.data = service.fetchData()
    }
}

SwiftUI View 정의
MyView는 MyViewModel을 주입받아 해당 데이터를 화면에 표시하고 버튼을 통해 데이터를 다시 불러올 수 있도록 구성한다.

import SwiftUI

struct MyView: View {
    @ObservedObject var viewModel: MyViewModel

    var body: some View {
        VStack {
            Text(viewModel.data)
                .padding()
            Button("Fetch Data") {
                viewModel.fetchData()
            }
        }
    }
}

빌드 환경에 따른 Stub 주입 (프리뷰 및 실제 앱 코드 분리)
Swift에서는 #if DEBUG와 같은 조건부 컴파일 지시자를 사용해 빌드 환경에 따라 특정 코드를 실행할 수 있다. 이를 통해 프리뷰나 테스트 환경에서는 StubService를 사용하고 실제 앱에서는 RealService를 사용할 수 있다.

// 실제 앱 코드에서 사용될 환경
#if DEBUG
let service: SomeServiceProtocol = StubService()
#else
let service: SomeServiceProtocol = RealService()
#endif

// 실제 앱에서 주입된 service를 사용하여 ViewModel 생성
let viewModel = MyViewModel(service: service)
let contentView = MyView(viewModel: viewModel)

SwiftUI Preview 설정
프리뷰에서는 StubService를 사용하여 라이브러리와 상관없이 프리뷰를 확인할 수 있도록 설정한다.

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        // 프리뷰에서 StubService를 주입하여 실제라이브러리 사용 방지
        let viewModel = MyViewModel(service: StubService())
        return MyView(viewModel: viewModel)
    }
}

참고로 위의 코드는 설명을 간략히 하기 위해 ViewModel은 프로토콜을 선언하지 않았다.

끝으로

의외로 객체지향프로그래밍에서 상속을 이용하는 문제는 잘 하지만 다형성을 활용해야하는 문제에 대해서는 익숙하지 않은듯하다. 위 예제로 조금이나마 도움이 되었다면 좋겠다.

profile
공업철학프로그래머

0개의 댓글