DI Container 와 SwiftUI

준우·2024년 3월 24일
1

SwiftUI 이야기

목록 보기
4/5
post-thumbnail

최근 의존성 주입 하는 것에 많은 삽질을 시도중
기능 확장, 기존의 서비스를 다른 서비스로 대체할 때 발생하는 문제들이 너무 많아서 의존성 주입의 필요성을 많이 느낌
그리고 최근 Unit Test 라는 것도 배우면서, 더더욱 그 중요성을 느낌.

이전의 포스트에서도 DI Container를 다룬 적이 있음. 하지만, UIKit 과 SwiftUI 에서의 DI Container 는 조금 다른 방식으로 작동함.

  • UIKit 은 AppDelegate, SceneDelegate 에서 의존성 등록, 주입
  • SwiftUI 에서는 RootView에 @Environment 객체에 의존성을 등록하여, 하위 SubView 에서 의존성 주입

1. DI Container 객체 생성 & 의존성 등록 및 주입

struct ServicesDIContainerModifier: ViewModifier {
    let dataCRUDService: DataCRUDServiceProtocol

    init() {
        dataCRUDService = DataCRUDService() // 여기서 의존성 등록, DataCRUDService 말고, Mock-Up 용 서비스를 넣어서 사용할 수 있습니다.
    }

    func body(content: Content) -> some View {
        content
            .environmentObject(ViewModel(dataCRUDService: dataCRUDService))
    }
}

extension View {
    func servicesDIContainer() -> some View {
        modifier(ServicesDIContainerModifier())
    }
}

먼저, ServicesDIContainerModifier 구조체를 만들어 주세요.
이 구조체는 의존성을 하위 서브뷰로 의존성들을 주입해주는 역할을 맡습니다.

그럼 내부를 살펴 보겠습니다.
등록할 서비스의 타입을 정의해주세요.

그리고 init() 생성자로 서비스 타입을 채택하고 있는 서비스 객체를 등록해줍니다.
등록된 객체를 @Environment 에 주입해주면 됩니다.

주입까지 다 마쳤으면 최상단 뷰에 의존성을 등록해줘야 합니다. 그러기 위해서 extension을 사용하여 View 를 확장시켜주세요.
servicesDIContainer 이라는 메서드를 만들어 modifier 를 사용해줍니다.
그리고 최상단 뷰에서 servicesDIContainer 메서드를 사용하면 아래 코드와 같이 됩니다.

@main
struct ExDIContainerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .servicesDIContainer() // 최상단뷰에 의존성 주입
        }
    }
}

2. 서비스 프로토콜와 서비스 객체 생성

이렇게 의존성을 주입할 준비들은 끝났는데, 재료가 없네요?!
의존성 주입할 서비스들을 만들어 볼게요.

간단히, 데이터를 CRUD 할 프로토콜을 만들어 볼게요.

protocol DataCRUDServiceProtocol {
    var items: [Item] { get set }

    func addItem(at item: Item) // Item 을 추가하는 메서드
    func deleteItem(at index: IndexSet) // Item 을 삭제하는 메서드, IndexSet 을 사용한 이유는 .onDelegate 에서는 IndexSet 을 사용하기 때문입니다.
}

이제 서비스도 만들어 볼게요. 프로토콜을 채택해주세요.
전 임시로 두 개의 서비스를 만들 거에요.
진짜로 사용할 서비스와 가짜로 사용할 Mock-Up 용 서비스 입니다.
의존성을 주입하는 부분에서 어떤 서비스를 사용할 지 등록만 해주면 바로 사용이 가능해집니다.

// MARK: - DataCRUDService

class DataCRUDService: DataCRUDServiceProtocol {
    var items: [Item] = []

    func addItem(at item: Item) {
        items.append(item)
    }

    func deleteItem(at index: IndexSet) {
        items.remove(atOffsets: index)
    }
}

// MARK: - Mock-Up Data

class MockDataService: DataCRUDServiceProtocol {
    var items: [Item] = Item.mockItems

    func addItem(at item: Item) {
        // To do stuff
    }

    func deleteItem(at index: IndexSet) {
        // To do stuff
    }
}

3. 모델 생성

모델도 급하게 만들어 볼게요.

struct Item {
    var id: UUID
    var title: String
    var count: Int

    init(id: UUID = UUID(), title: String, count: Int) {
        self.id = id
        self.title = title
        self.count = count
    }
}

extension Item {
    static let mockItems: [Item] = [
        Item(title: "1", count: 1),
        Item(title: "2", count: 2),
        Item(title: "3", count: 3),
        Item(title: "4", count: 4),
        Item(title: "5", count: 5),
    ]
}

4. ViewModel 생성

MVVM 패턴이니까, ViewModel도 필요하겠죠?
그럼 ViewModel 도 만들어 볼게요.
전 여기에 이벤트 처리를 맡아줄 Combine도 같이 사용했어요.

import Combine
import SwiftUI

class ViewModel: ObservableObject {
    let dataCRUDService: DataCRUDServiceProtocol
    var itemPublisher = CurrentValueSubject<[Item], Never>([])
    @Published var items: [Item] // View로 변경된 데이터를 알려줌

    init(dataCRUDService: DataCRUDServiceProtocol) {
        self.dataCRUDService = dataCRUDService
        self.items = dataCRUDService.items
    }

    func addItem(at item: Item) {
        dataCRUDService.addItem(at: item)
        itemPublisher.send(dataCRUDService.items)
    }

    func deleteItem(at index: IndexSet) {
        dataCRUDService.deleteItem(at: index)
        itemPublisher.send(dataCRUDService.items)
    }
}

참고!!!

ViewModel 내부 기능의 확장이 필요해지면 ViewModel 확장 대신에, DataCRUDServiceProtocol 부분을 확장시키면 된다 :)

5. View 생성

거의 다 끝났네요. 이제 뷰 부분만 만들면 되겠군요.

struct ContentView: View {
    var body: some View {
        VStack {
            MainScreen()
        }
    }
}

struct MainScreen: View {
	// 등록된 ViewModel을 사용
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        ZStack(alignment: .bottom) {
            VStack {
                List {
                    ForEach(viewModel.items, id: \.id) { item in
                        ItemRow(at: item)
                    }
                    // Item 삭제
                    .onDelete(perform: { indexSet in
                        viewModel.deleteItem(at: indexSet)
                    })
                }

                .listStyle(.insetGrouped)
            }
			// ViewModel의 ItemPublisher 에 이벤트가 발생하면, @Published 가 선언된 Items 에 데이터를 할당
            .onAppear {
                viewModel.itemPublisher.assign(to: &viewModel.$items)
            }

            Button(action: {
            	// Item 추가 메서드
                viewModel.addItem(at: Item(title: "Hello", count: 999))
            }, label: {
                Text("Button")
                    .foregroundStyle(.white)
                    .padding()
                    .background(.red)
                    .padding(.bottom)
            })
        }
    }

	// Row 뷰를 만드는 메서드
    @ViewBuilder
    func ItemRow(at item: Item) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Item 이름: \(item.title)")
                .font(.headline)

            Text("Item의 갯수: \(item.count)")
                .font(.caption2)
                .foregroundStyle(.gray)
        }
    }
}

결론

기존의 싱글톤과 NetworkService() 등과 같은 방식으로 코드를 작성하면, 당장은 편리하지만, 나중에 리팩토링 할 때 많은 곳을 수정해야 할 수 있음.
왠만하면, 의존성 주입을 하자.

그렇다고 모든 코드를 의존성 주입 하는 건 ㄴㄴ~~~
싱글톤이 마냥 나쁘다는 것은 아니고, 의존성 주입과 사용하는 상황이 다른 것 뿐일 뿐!!
특별한 목적을 가지고, 단일 기능을 하는 객체라면 그때는 싱글톤을 쓰는게 더 효율적

참고한 자료

0개의 댓글