SwiftUI로 Film-in 프로젝트를 의존성 주입(DI, Dependency Injection)을 어떻게 구현할 지 고민하게 되었다.
특히, UIKit과는 달리 SwiftUI에서는 @StateObject, @ObservedObject, @EnvironmentObject 등을 활용해
상태관리를 해야 했기에의존성 주입을 어떻게 구현할 지에 대해 고려가 필요했다.
처음에는 SOLID 원칙을 준수하면서 유지보수성을 높이는 방향으로 설계를 진행했지만, SwiftUI의 제약으로 인해
설계의 변경이 필요했다.
이 글에서는 DIContainer 설계 과정에서 고려했던 사항과 결론을 공유하고자 한다.
1️⃣ UIKit에서 적용했던 의존성 주입 적용 방식
최근 Clean Architecture를 적용했던 UIKit 프로젝트 LookForRealBurger의 경우
팩토리 메서드 패턴(Factory Method Pattern)을 활용하여 ViewController 생성과 동시에 의존성을 외부에서 주입하는
방식으로 구현했다.
🚀 UIKit에서 DI 적용 방식 (팩토리 메서드 패턴 활용)
enum LoginScene {
static func makeView() -> LoginViewController {
let authRepository = DefualtAuthRepository.shared
let loginUseCase = DefaultLoginUseCase(authRepository: authRepository)
let accessStorage = UserDefaultsAccessStorage.shared
let viewModel = DefaultLoginViewModel(
loginUseCase: loginUseCase,
accessStorage: accessStorage
)
let view = LoginViewController.create(with: viewModel)
return view
}
}
📌 ViewController가 직접 의존성을 생성하는 것이 아니라, 외부에서 주입하여 결합도를 낮춤.
📌 전역적으로 관리해야 하는 객체(NetworkManager, Repository 등)는 싱글톤 패턴을 활용하여 공유.
2️⃣ SwiftUI에서 DIContainer 설계
- 팩토리 메서드 패턴 대신 DIContainer 도입의 필요성
- UIKit에서는 단순히 ViewController init(viewModel: )을 통해 의존성을 주입
- SwiftUI에서는 @@StateObject, @ObservedObject, @EnvironmentObject 등을 활용하여 상태를 관리
- 따라서,팩토리 메서드 패턴 대신 DIContainer에서 ViewModel을 생성하고, View 생성 시 주입하는 방식으로 변경
- 물론, 겉으로 보기에는 동일한 방식이지만 UIKit과는 다르게 싱글톤 객체를 사용하지 않는 방식
- 전역에서 사용되는 객체의 관리
- UIKit에서는 NetworkManager, Repository 등 전역에서 사용되는 객체를 싱글톤 패턴으로 관리했지만, SwiftUI에서는 @EnvironmentObject를 활용하면 싱글톤을 사용할 필요 없이 전역 객체를 관리할 수 있음
- 하지만 SwiftUI의 @EnvironmentObject는 Generic을 활용하고 있는 구조체의 형태로 구현되어 있어, Concrete Type만을 지원하고 있어 Protocol을 타입으로서 활용하지 못하는 문제가 있음
📝 NOTE: 여기서 Concrete Type이란?
Concrete란, 명확한, 구체적인 등의 뜻을 갖고있는 형용사이며
추상적인 이라는 뜻의 Abstract와 대조적인 단어로 이해하면 된다.
즉, Concrete Type은 추상화된 타입이 아닌 모든 메서드가 구현되어 있는 타입을 말한다.
3️⃣ SOLID 원칙 준수 고민
@EnvironmentObject가 Protocol을 지원하지 않지만
DIContainer를 설계하는 과정에 있어 SOLID 원칙을 고려하는 방향으로 진행했다.
🚀 초기설계
- 추상화된 Protocol(DIContainerProtocol)을 정의 (인터페이스 분리 원칙)
- 해당 Protocol을 준수하는 구현체를 구조체(DIContainerManager)로 구성 (개방-폐쇄 원칙)
- 따로 클래스(DIContainer)를 구성하여 Protocol 타입으로 구현체인 구조체를 사용 (의존성 역전 원칙)
- 구조체 내부에서 클래스의 인스턴스 프로퍼티(Repository, Manager 등)를 클로저를 통해 사용
- 클래스 내부에서 클로저를 구현하여 인스턴스 프로퍼티를 약한참조(weak)로 활용사용
🚀 예시코드
protocol DIContainerManagable {
func makeViewModel(closure: () -> ViewModel) -> ViewModel
}
struct DIContainerManager: DIContainerManagable {
func makeViewModel(closure: () -> ViewModel) -> ViewModel {
return closure()
}
}
protocol DIContainer: AnyObject {
func makeViewModel() -> ViewModel
}
final class DefaultDIContainer: ObservableObject {
let networkManager: NetworkManager
let repository: Repository
let diContainerManager: DIContainerManagable
init(
networkManager: NetworkManager,
repository: Repository,
diContainerManager: DIContainerManagable
) {
self.networkManager = networkManager
self.repository = repository
self.diContainerManager = diContainerManager
}
}
extension DefaultDIContainer: DIContainer {
func makeViewModel() -> ViewModel {
let viewModel = diContainerManager.makeViewModel {
return ViewModel(networkManager: networkManager, repository: repository)
}
return viewModel
}
}
📌 이 방식은 SOLID 원칙을 지킬 수 있지만, 코드의 Depth가 깊어지고 가독성이 떨어지는 문제가 발생
📌 결국, 코드의 유지보수성과 가독성을 위해 더 단순한 방식으로 변경하기로 결정
4️⃣ 최종적으로 설계한 방식
- '요구사항을 정의하는 용도'로 Protocol(DIContainer)을 활용
- Protocol은 활용하되, @EnvironmentObject에는 사용하지 않음
- 구현체(DefaultDIContainer)를 @EnvironmentObject로 활용
- Protocol이 아닌 구현체를 @EnvironmentObject로 전달
- 제네릭한 ViewModel 생성 메서드대신, 각가의 ViewModel 생성 메서드 명확히 선언
- 필요한 외부 데이터를 매개변수로 받아야 하는 경우, 제네릭한 방식이 불편해져
ViewModel별 메서드를 명확하게 선언.
- 필요한 외부 데이터를 매개변수로 받아야 하는 경우, 제네릭한 방식이 불편해져
🚀 예시코드
protocol DIContainer: AnyObject {
func makeViewModel(data: Data) -> ViewModel
}
final class DefaultDIContainer: ObservableObject {
let networkManager: NetworkManager
let repository: Repository
init(
networkManager: NetworkManager,
repository: Repository
) {
self.networkManager = networkManager
self.repository = repository
}
}
extension DefaultDIContainer: DIContainer {
func makeViewModel(data: Data) -> ViewModel {
let viewModel = ViewModel(networkManager: networkManager, repository: repository)
return viewModel
}
}
📌 이제 @EnvironmentObject를 사용할 수 있도록 ObservableObject를 채택한 DefaultDIContainer를 직접 사용
📌 ViewModel을 개별 메서드로 제공하여, 가독성을 높이고 유지보수를 쉽게 함
5️⃣ 결론
SwiftUI에서 DIContainer를 설계하면서 SOLID 원칙을 지키는 것과 유지보수성을 고려하는 것 사이에서 균형을 잡아야 했다.
SwiftUI의 @EnvironmentObject가 프로토콜을 지원하지 않는 제약이 있었고, 이를 우회하려다 보니 코드의 Depth가 깊어지고
가독성이 떨어지는 문제가 발생했다.
결국, 핵심적인 객체만 프로토콜로 추상화하고, DIContainer는 클래스 형태로 유지하는 방식을 선택했다.
또한, 제네릭한 ViewModel 생성 방식 대신, 명확한 개별 메서드를 선언하여 코드의 직관성을 높였다.
SwiftUI에서 DI 패턴을 적용하면서, UIKit과의 차이점과 SwiftUI의 상태 관리 기법을 고려해야 한다는 점을 깊이 이해할 수 있었다.
또한, SOLID 원칙을 유지하면서도 SwiftUI의 제약을 고려한 현실적인 설계를 적용하는 것이 중요함을 경험했다.
이 과정을 통해 DIContainer를 활용하여 의존성을 효율적으로 관리하고, 유지보수성과 확장성을 높이는 방법을 고민하며 적용할 수
있었다. 앞으로도 프로젝트의 요구사항에 맞춰 설계를 유연하게 적용하는 것의 중요성을 계속해서 고민하며 발전해 나가고자 한다.
'프로젝트 > Film-in' 카테고리의 다른 글
CarouselView - DragGesture -> ScrollView -> UICollectionView (0) | 2024.12.24 |
---|---|
메모리 사용량을 줄이기 위한 노력 - 이미지 다운샘플링 (0) | 2024.12.23 |
Memory Leak - Combine Sink, Swift Concurrency (1) | 2024.12.23 |
메모리 사용량을 줄이기 위한 노력 - Kingfisher 사용 중 일어난 일 (2) | 2024.12.23 |