SwiftUI 프로젝트에 Swinject 적용하기

DG
15 min readJul 7, 2024

--

DI(Dependency Injection) 개념

의존성 주입(Dependency Injection, DI)은 객체지향 프로그래밍에서 객체 간의 의존성을 효율적으로 관리하는 설계 패턴입니다.
의존성(Dependency)은 객체지향 프로그래밍에서 하나의 객체가 다른 객체에 의존하는 관계를 의미합니다.

/// User 데이터 저장소
class UserRepository {
func getUsers() -> [String] {
return [
"김철수",
"홍길동",
"박나미"
]
}
}

/// 이름으로 유저를 검색하는 로직
class SearchUserByNameUseCase {
let userRepository = UserRepository()

func implement(name: String) -> String? {
let allUsers = userRepository.getUsers()
return allUsers.first(where: { $0 == name })
}
}

let useCase = SearchUserByNameUseCase()
useCase.implment(name: "김철수") // -> "김철수"

UserRepository 가 수정되어지면 SearchUserByNameUseCase 객체의 동작은 영향을 받게 됩니다. UserRepository 는 독립적인 개체이지만, SearchUserByNameUseCase 는 UserRepository 에 의해 영향을 받는 의존성 있는 클래스입니다.

의존성이 강하게 걸린 객체는 테스트하기 어렵습니다. 의존성을 효과적으로 관리할 수 있는 방법중의 하나가 의존성 주입(Dependency Injection) 입니다.

예시

protocol UserRepository {
func getUsers() -> [String]
}

/// User 데이터 저장소
class UserRepositoryImpl: UserRepository {
func getUsers() -> [String] {
return [
"김철수",
"홍길동",
"박나미"
]
}
}

class UserRepositoryEmptyCaseTest: UserRepository {
func getUsers() -> [String] {
[]
}
}

/// 이름으로 유저를 검색하는 로직
class SearchUserByNameUseCase {
let userRepository: UserRepository

init(userRepository: UserRepository) {
self.userRepository = userRepository
}

func implement(name: String) -> String? {
let allUsers = userRepository.getUsers()
return allUsers.first(where: { $0 == name })
}
}

let notEmptyCaseUseCase = SearchUserByNameUseCase(userRepository: UserRepositoryImpl())
let emptyCaseUseCase = SearchUserByNameUseCase(userRepository: UserRepositoryEmptyCaseTest())

notEmptyCaseUseCase.implement(name: "김철수") // -> 김철수
emptyCaseUseCase.implement(name: "김철수") // -> nil

의존성을 추상화하고 실제 객체를 생성/활용 할 때에 주입합니다.
이제 SearchUserByNameUseCase는 목적에 따라 의존성을 주입해주며 의존성을 쉽게 관리 할 수 있는 구조가 되었습니다.

기존 프로젝트의 의존성 주입

기존의 프로젝트도 철저하게 의존성을 주입받는 방식으로 개발되어져 있는 상태입니다. 의존성이 잘 관리되어지게 되면 프로젝트의 모듈화 및 테스트에 용이하고 이는 프로젝트의 안정성과 생산성에 직결되어집니다.

기존 프로젝트의 가장 최상단 View 파일을 보면 다음과 같습니다.

//
// ContentView.swift
// dg-muscle-ios
//
// Created by 신동규 on 4/30/24.
//

import SwiftUI
import DataLayer
import Domain
import Auth
import Presentation

struct ContentView: View {
typealias FoundationData = Data
@Environment(\.window) var window: UIWindow?
@StateObject var viewModel: ContentViewModel
@State var splash: Bool = true

init() {
self._viewModel = .init(wrappedValue: .init(userRepository: UserRepositoryImpl.shared))
}

var body: some View {
ZStack {
if viewModel.isLogin {
Presentation.NavigationView(
today: Date(),
historyRepository: HistoryRepositoryImpl.shared,
exerciseRepository: ExerciseRepositoryImpl.shared,
heatMapRepository: HeatMapRepositoryImpl.shared,
userRepository: UserRepositoryImpl.shared,
friendRepository: FriendRepositoryImpl.shared,
logRepository: LogRepositoryImpl.shared
)
} else {
AuthenticationView(
appleAuthCoordinator: AppleAuthCoordinatorImpl(
window: window,
logRepository: LogRepositoryImpl.shared,
userRepository: UserRepositoryImpl.shared
)
)
}
SplashView().opacity((splash || UserRepositoryImpl.shared.isReady == false) ? 1 : 0)
}
.animation(.default, value: splash)
.animation(.default, value: UserRepositoryImpl.shared.isReady)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
splash.toggle()
}
}
}
}

#Preview {
ContentView()
}

라이브러리나 특별한 설계없이 직접 구현되어진 DI 의 가장 기본적인 형태입니다. 의존성 주입을 직접 구현한 코드는 DI(Dependency Injection) 라이브러리를 사용하지 않아도 문제 없습니다. 다만, DI 라이브러리를 사용하면 보다 구조화되고 일관된 방식으로 의존성 주입을 관리할 수 있는 장점이 있습니다. 이는 특히 대규모 프로젝트에서 유용할 수 있습니다.

프로젝트의 스케일이 점차 커져감에 따라 의존성 주입 라이브러리를 적용 시켜보려 합니다.

Swinject 채택

PureDI 와 Swinject 라이브러리 사이에서 고민을 하다가 Swinject 라이브러리를 사용하기로 결정했습니다.

PureDI 는 코드 기반 설정으로, 프로그래머가 직접 코드를 작성해 의존성을 설정합니다. 설정 과정이 명시적이고, 제어권이 높지만 설정 과정이 복잡해 질 수 있기도 하고 더 이상 관리가 되어지고 있지 않습니다.

지속적인 관리와 사용 편의성, 활발한 커뮤니티 등을 기반으로 PureDI 보다 Swinject 를 사용하는 것이 더 적합하다고 생각했습니다.

작업

Swinject는 Swinject.Container를 사용하여 의존성을 등록(register) 하고, 의존성을 사용(resolve) 하는 방식입니다.

// 등록
container.register(HistoryRepository.self) { _ in
return HistoryRepositoryImpl.shared
}
// 사용
let historyRepository = container.resolve(HistoryRepository.self)

가장 먼저 DataLayer 의존성들을 등록해주고, 그 이후에 도메인별 의존성을 등록해주도록 하였습니다.

/// DataLayer 의존성 등록

import Swinject
import Domain
import DataLayer
import UIKit

public struct DataAssembly: Assembly {
public func assemble(container: Swinject.Container) {
container.register(HistoryRepository.self) { _ in
return HistoryRepositoryImpl.shared
}

container.register(ExerciseRepository.self) { _ in
return ExerciseRepositoryImpl.shared
}

container.register(HeatMapRepository.self) { _ in
return HeatMapRepositoryImpl.shared
}

container.register(UserRepository.self) { _ in
return UserRepositoryImpl.shared
}

container.register(FriendRepository.self) { _ in
return FriendRepositoryImpl.shared
}

container.register(LogRepository.self) { _ in
return LogRepositoryImpl.shared
}

container.register(AppleAuthCoordinator.self) { (resolver, window: UIWindow?) in

let logRepository = resolver.resolve(LogRepository.self)!
let userRepository = resolver.resolve(UserRepository.self)!

return AppleAuthCoordinatorImpl(
window: window,
logRepository: logRepository,
userRepository: userRepository
)
}
}
}
/// Exercise 의존성 등록

import Swinject
import Exercise
import Domain
import Presentation
import Common

public struct ExerciseAssembly: Assembly {
public func assemble(container: Swinject.Container) {
container.register(ExerciseListView.self) { resolver in
let repository = resolver.resolve(ExerciseRepository.self)!
return ExerciseListView(exerciseRepository: repository) { exercise in
coordinator?.addExercise(exercise: exercise)
}
}

container.register(PostExerciseView.self) { (resolver, exercise: Domain.Exercise?) in
let repository = resolver.resolve(ExerciseRepository.self)!
return PostExerciseView(exercise: exercise, exerciseRepository: repository) {
URLManager.shared.open(url: "dgmuscle://pop")
}
}
}
}

의존성 등록 작업이 마무리 된 이후에 의존성 주입을 실제로 수행해주는 DependencyInjector 의 모습을 설계해주었습니다.

/// DI 대상 등록
public protocol DependencyAssemblable {
func assemble(_ assemblyList: [Assembly])
}

/// DI 등록한 서비스 사용
public protocol DependencyResolvable {
func resolve<T>(_ serviceType: T.Type) -> T
func resolve<T, Arg1>(_ serviceType: T.Type, argument: Arg1) -> T
func resolve<T, Arg1, Arg2>(_ serviceType: T.Type, arguments arg1: Arg1, _ arg2: Arg2) -> T
}

public typealias Injector = DependencyAssemblable & DependencyResolvable

의존성 등록

AppDelegate 와 SceneDelegate 둘 중에 어느곳에서 의존성을 등록해주면 좋을지 고민하다가, SwiftUI 에서라면 RootView 에서 등록해주는 방식 또한 적합할 것 같다고 생각하였다.

RootView

@main
struct dg_muscle_iosApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate

let injector: Injector

init() {
injector = DependencyInjector(container: Container())
injector.assemble([
DataAssembly(),
ExerciseAssembly(),
FriendAssembly(),
HistoryAssembly(),
HomeAssembly(),
MyAssembly(),
MainAssembly()
])
}

var body: some Scene {
WindowGroup {
ContentView(injector: injector)
}
}
}

변경된 ContentView.swift

import SwiftUI
import Auth
import Presentation
import Foundation
import Swinject

struct ContentView: View {
typealias FoundationData = Data
let injector: Injector
@Environment(\.window) var window: UIWindow?
@StateObject var viewModel: ContentViewModel

init(injector: Injector) {
self.injector = injector
self._viewModel = .init(wrappedValue: injector.resolve(ContentViewModel.self))
}

var body: some View {
ZStack {
if viewModel.isLogin {
injector.resolve(Presentation.NavigationView.self, argument: Date())
} else {
injector.resolve(AuthenticationView.self, argument: window)
}

SplashView().opacity(viewModel.splash ? 1 : 0)
}
.animation(.default, value: viewModel.splash)
}
}
NavigationView 쪽의 코드도 많이 압축되어졌다

적용 후 느낀 점

만족스러운 점

  1. 의존성 주입을 중앙 집중화하여 코드의 가독성과 유지보수성이 개선되어졌습니다.
  2. 객체의 선언부가 간결해졌습니다.
  3. 라이브러리를 통해 구현하였기 때문에 비교적 정형화되어진 DI 패턴을 학습할 수 있었습니다.

아쉬운 점

  1. 의존성을 관리해주기 위한 방법인 DI 패턴의 단순한 개념에 비해서 DI 라이브러리를 사용하기 위한 정형화된 패턴을 학습한다는 점은 비교적 러닝 커브가 있었습니다.
  2. Swinject 특성상 의존성 등록의 여부와 의존성 등록 순서 및 시점 등의 요인들에 의해서 런타임 에러가 발생할 수 있는 구조가 되어진 점이 아쉽습니다.

--

--

DG

한국의 iOS 개발자이다. 강아지와 운동을 좋아함. github: https://github.com/donggyushin