최근 의존성 주입 하는 것에 많은 삽질을 시도중
기능 확장, 기존의 서비스를 다른 서비스로 대체할 때 발생하는 문제들이 너무 많아서 의존성 주입의 필요성을 많이 느낌
그리고 최근 Unit Test 라는 것도 배우면서, 더더욱 그 중요성을 느낌.
이전의 포스트에서도 DI Container를 다룬 적이 있음. 하지만, UIKit 과 SwiftUI 에서의 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() // 최상단뷰에 의존성 주입
}
}
}
이렇게 의존성을 주입할 준비들은 끝났는데, 재료가 없네요?!
의존성 주입할 서비스들을 만들어 볼게요.
간단히, 데이터를 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
}
}
모델도 급하게 만들어 볼게요.
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),
]
}
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 부분을 확장시키면 된다 :)
거의 다 끝났네요. 이제 뷰 부분만 만들면 되겠군요.
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() 등과 같은 방식으로 코드를 작성하면, 당장은 편리하지만, 나중에 리팩토링 할 때 많은 곳을 수정해야 할 수 있음.
왠만하면, 의존성 주입을 하자.
그렇다고 모든 코드를 의존성 주입 하는 건 ㄴㄴ~~~
싱글톤이 마냥 나쁘다는 것은 아니고, 의존성 주입과 사용하는 상황이 다른 것 뿐일 뿐!!
특별한 목적을 가지고, 단일 기능을 하는 객체라면 그때는 싱글톤을 쓰는게 더 효율적