
의존성 주입(Dependency Injection, DI)은 객체 간의 결합도를 낮추고, 테스트 용이성과 코드의 확장성을 높이기 위해 널리 사용되는 설계 패턴입니다. 특히 규모가 커지는 애플리케이션에서는 객체 생성과 의존성 관리가 복잡해지기 때문에 이를 체계적으로 관리할 수 있는 도구나 라이브러리의 도움을 받는 경우가 많습니다.
→ 여기까지 Chat GPT 가 생성해준 말인데, 물론 너무나 맞는 말이지만 저도 처음 실무에 적용할 때 테스트 용이성? 확장성 높이기 위한 설계 패턴? 이런 것들이 다소 와닿지 않더라구요. “의존성 주입을 왜 해야해?” 라는 질문에 대한 제 생각을 먼저 말씀드리자면 “생성하고자 하는 인스턴스의 의존성을 몰라도 됨”이 가장 큰 것 같습니다. 일반적으로 객체가 필요한 의존성을 직접 생성한다면, 해당 객체는 의존 객체의 생성 방식이나 구체적인 구현에 대해 알고 있어야 합니다. 하지만 DI를 사용하면 객체는 단순히 필요한 의존성을 외부로부터 전달받기만 하면 됩니다. 실제로 어떤 객체가 생성되고 어떻게 구성되는지는 외부(예: DI 컨테이너)가 담당하게 됩니다.
‘의존성을 몰라도 된다’ 를 다시 풀어서 설명하자면
(1) DI 를 통해 외부(컨테이너)에서 의존성 주입
(2) 해당 인스턴스가 변경이 되더라도 영향을 받지 않음
그러니 객체 생성 책임이 외부로 분리되었으니 결합도가 낮아지고, 변경에 유연하며 확장에도 용이한 구조인 것이죠. 테스트 용이성은 객체를 직접 생성하는 구조에서는 테스트 시에도 실제 의존 객체가 함께 생성되기 때문에 테스트 환경을 제어하기 어렵습니다. 반면 DI 구조에서는 테스트 시점에 원하는 의존 객체를 직접 주입할 수 있습니다. 예를 들어 실제 네트워크 요청을 수행하는 객체 대신에 미리 정의된 결과를 반환하는 Mock 객체로 주입한다면 실제 요청보내지 않더라도 테스트 수행이 가능한 것이니 테스트 용이성을 확보할 수 있는 것입니다.
Swift 생태계에서도 DI를 지원하기 위한 여러 라이브러리가 존재하며, 그중 대표적으로 Swinject와 Pure를 들 수 있는데요, 두 라이브러리는 모두 의존성 주입을 보다 구조적으로 관리할 수 있도록 도와주지만, 접근 방식과 설계 철학에는 차이가 있습니다.
이 글에서는 제 사이드 프로젝트에 적용했던 사례를 기반으로 Swinject와 Pure 에 대한 특징과 구조, 그리고 이 둘을 복합적으로 어떻게 활용했는지에 대해 알아보겠습니다.
Swinject 에는 보통 register() 과 resolve() 의 개념이 있습니다. 쉽게 말해
여기서 조금 헷갈리는 것은 register 는 인스턴스 만드는 방법을 등록하는거지, 생성하는 것이 아닙니다. 그럼 여기서 왜 인스턴스 만드는 방법을 등록하는지? 등록하면 장점이 뭔지? 의문이 들 수 있는데요,
이렇게 생성 방법을 등록해 두는 이유는 객체 생성 책임을 한 곳으로 모으기 위해서입니다. 만약 각 객체가 필요한 의존성을 직접 생성한다면, 애플리케이션 곳곳에서 객체 생성 코드가 반복적으로 등장하게 됩니다. 예를 들어
ViewModel→ UseCase 생성UseCase→ Repository 생성Repository→ DataSource 생성이런식으로 의존성이 코드 전반에 흩어지게 됩니다. 이런 구조에서는 특정 의존 객체의 생성 방식이 변경될 때 영향을 받는 코드 범위가 넓어질 수 있습니다. 반면 register()를 통해 객체 생성 규칙을 DI 컨테이너에 미리 정의해 두면, 실제 객체를 사용하는 쪽에서는 단순히 resolve()로 필요한 의존성을 요청하기만 하면 됩니다. 객체를 어떻게 생성해야 하는지에 대한 로직은 모두 컨테이너 내부에 모이게 되기 때문에, 생성 방식이 변경되더라도 해당 규칙만 수정하면 됩니다.
그래서 다시 register() 를 통해 인스턴스 생성 방법을 미리 등록하는 것의 장점을 요약하자면,
앞서 설명하면서 “외부”, “등록하는 곳”, “중앙에서 관리한다”와 같은 표현을 사용했는데, 이러한 역할을 담당하는 것이 바로 의존성 컨테이너(DI Container) 입니다. DI Container는 객체를 어떻게 생성할지에 대한 규칙을 보관하고, 필요한 시점에 해당 규칙을 이용해 인스턴스를 생성하고 주입하는 역할을 합니다. 즉 애플리케이션에서 필요한 객체들의 생성 방법을 한 곳에서 관리하고, 실제로 객체가 필요할 때 이를 생성해 전달하는 중앙 관리 지점이라고 볼 수 있습니다. Swinject 에서는 Container 타입을 제공하고 있으며 해당 컨테이너를 통해 register() 와 resolve() 를 이용할 수 있습니다.
앞서 Container를 통해 객체의 생성 규칙을 register()로 등록할 수 있다고 설명했습니다. 하지만 애플리케이션의 규모가 커질수록 등록해야 하는 의존성의 수 역시 빠르게 늘어나게 됩니다. 예를 들어 클린 아키텍처 구조에서 하나의 Container 만 사용한다고 가정해보겠습니다. 그러면 이 때 Usecase, Repostiory, DataSource 등 다양한 객체들의 생성 규칙을 하나의 Container 에 등록하게 될텐데 이러면 해당 Container 가 매우 방대해지고 추후에는 관리가 더 어렵겠죠. 따라서 이를 해결하기 위해 Swinject 에서는 Assembly 라는 것을 사용합니다.
Assembly는 간단히 말해 의존성 등록 코드를 기능 단위로 분리하기 위한 구조입니다. 각 모듈이나 레이어 단위로 의존성 등록을 나누어 작성하고, 이를 Container에 조립(assemble)하는 방식으로 사용할 수 있습니다.
현재 진행중인 사이드 프로젝트에서는 클린 아키텍처 + Modular Architecture 를 따르고 있는데, 이 구조에서 레이어 모듈을 예시로 들어보겠습니다.
public final class DataAssembly: Assembly {
public init() {}
public func assemble(container: Container) {
// MARK: - KeychainClient
container.register(KeychainClient.self) { _ in
KeychainClientImpl()
}.inObjectScope(.container)
// MARK: - Auth
container.register(AuthRemoteDataSource.self) { _ in
...
return AuthRemoteDataSourceImpl(...)
}.inObjectScope(.container)
container.register(AuthLocalDataSource.self) { r in
AuthLocalDataSourceImpl(keychainClient: r.resolve())
}.inObjectScope(.container)
container.register(AuthRepository.self) { r in
AuthRepositoryImpl(
remoteDataSource: r.resolve(),
localDataSource: r.resolve()
)
}.inObjectScope(.container)
...
public final class DomainAssembly: Assembly {
public init() {}
public func assemble(container: Container) {
// MARK: - UserStore
container.register(UserStore.self) { _ in
UserStore()
}.inObjectScope(.container)
// MARK: - Auth
container.register(SignInWithIdTokenUseCase.self) { r in
SignInWithIdTokenUseCaseImpl(
authRepository: r.resolve(),
userRepository: r.resolve(),
userStore: r.resolve()
)
}.inObjectScope(.container)
container.register(SignOutUseCase.self) { r in
SignOutUseCaseImpl(
authRepository: r.resolve(),
userStore: r.resolve()
)
}.inObjectScope(.container)
...
이런식으로 각 레이어 모듈별로 Assembly 를 통해 각 모듈별로 필요한 의존성들을 조립했고, 해당 Assembly 들은 최상위 모듈에서 Assembler 를 통해 관리하게 됩니다.
final class AppDIContainer {
static let shared = AppDIContainer()
private let assembler: Assembler
var resolver: Resolver {
return self.assembler.resolver
}
private init() {
self.assembler = Assembler([
DataAssembly(),
DomainAssembly(),
...
])
}
}
위와 같은 구조를 사용하면 의존성 등록 코드가 하나의 Container에 모두 모이지 않고, 레이어 또는 모듈 단위로 자연스럽게 분리됩니다. 그 결과 각 모듈은 자신이 필요로 하는 의존성만 정의하게 되고, 전체 애플리케이션의 의존성 구조 역시 보다 명확하게 드러나게 됩니다.
특히 클린 아키텍처와 모듈화된 구조에서는 이러한 방식이 더욱 유용합니다. Data, Domain, Presentation과 같은 레이어별로 Assembly를 구성하면, 각 레이어가 어떤 의존성을 가지고 있는지 한눈에 파악할 수 있고, 의존성 변경이 발생하더라도 해당 모듈의 Assembly만 수정하면 되기 때문에 유지보수 역시 수월해집니다.
또한 최상위 모듈에서는 Assembler를 통해 각 Assembly를 한 번에 조립(assemble)함으로써, 애플리케이션 전체의 의존성 구성을 중앙에서 관리할 수 있습니다. 이후 실제 객체가 필요한 시점에는 Resolver를 통해 필요한 의존성을 요청하기만 하면 되고, 인스턴스 생성과 의존성 주입은 컨테이너가 담당하게 됩니다.
정리하자면 Assembly를 활용한 구조는 다음과 같은 장점을 가집니다.
이처럼 Assembly는 단순히 DI 설정 코드를 나누기 위한 도구라기보다, 애플리케이션의 의존성 구조를 모듈 단위로 구성하고 관리할 수 있도록 도와주는 중요한 역할을 합니다.
약간의 디테일을 추가하자면 resolve() extension 을 사용하면 코드의 가독성을 올릴 수 있습니다. 단, resolve() 에서 Forced Unwrapping 을 사용해 리턴하고 있기 때문에 등록 누락 시 런타임 크래시가 발생할 수 있으므로 주의가 필요합니다.
import Swinject
extension Resolver {
public func resolve<Service>() -> Service! {
return self.resolve(Service.self)
}
public func resolve<Service, Arg>(argument: Arg) -> Service! {
return self.resolve(Service.self, argument: argument)
}
}
container.register(SignInWithIdTokenUseCase.self) { r in
SignInWithIdTokenUseCaseImpl(
authRepository: r.resolve(AuthRepository.self)!,
userRepository: r.resolve(UserRepository.self)!,
userStore: r.resolve(UserStore.self)!
)
}.inObjectScope(.container)
container.register(SignInWithIdTokenUseCase.self) { r in
SignInWithIdTokenUseCaseImpl(
authRepository: r.resolve(),
userRepository: r.resolve(),
userStore: r.resolve()
)
}.inObjectScope(.container)
즉 extension을 사용하면
Pure 도 Swinject 처럼 의존성 주입 라이브러리입니다. Pure 를 채택한 프로젝트에서는 보통 CompositionRoot 패턴을 적용해 상위 계층에서 의존성을 조립하고, 하위 모듈에서는 필요한 의존성을 주입받아 사용하는 구조로 구성하는데요,
이 두 라이브러리의 가장 큰 차이점은 다음과 같습니다.
container.register(UserRemoteDataSource.self) { r in
UserRemoteDataSourceImpl(apiClient: r.resolve())
}.inObjectScope(.container)
final class UserRemoteDataSourceImpl: FactoryModule {
struct Dependency {
let apiClient: APIClient
}
struct Payload {
...
}
private let dependency: Dependency
required init(dependency: Dependency) {
self.dependency = dependency
}
...
}
let dataSource = UserRemoteDataSourceImpl(dependency: .init(
apiClient: apiClient
))
위 코드를 보면 Swinject 에서는 DIContainer , resolve() , inObjectScope() 개념을 활용하지만 Pure 의 경우 우선 Container 를 제공하지 않습니다. 대신 FactoryModule 프로토콜을 채택하여 각 클래스가 자신이 필요로 하는 의존성을 Dependency 구조체로 명시적으로 선언하고, 생성 시점에 이니셜라이저를 통해 직접 주입받는 방식으로 의존성을 관리합니다. 또한 Payload 라는 필요 시 명시해야 하는데, 이는 “런타임” 단계에서 생성되는 데이터들을 의미합니다. 예를 들어 어떤 객체에서 userId 가 필요하다면 이는 런타임 단계에서 전달되는 값이기 때문에 이는 Dependency 가 아닌 Payload 에 실어야 하는 것이죠.
Pure 에서는 그럼 왜 FactoryModule 을 채택하여 Dependency 와 Payload 를 사용하는지 이 철학에 대해서 알아야 하는데요, 이는 Swinject 와 다르게 “컴파일 단계에서 의존성 누락을 발견하기 위함” 입니다. 예를 들어 설명하겠습니다.
// ✅ UserRemoteDataSource는 등록
container.register(UserRemoteDataSource.self) { r in
UserRemoteDataSourceImpl(apiClient: r.resolve()!)
}
// ❌ APIClient는 등록을 깜빡함
// container.register(APIClient.self) { ... } ← 이게 빠진 상태
// 사용 시점
let dataSource = container.resolve(UserRemoteDataSource.self)!
// → r.resolve()!에서 APIClient를 찾지 못해 런타임 크래시
r.resolve()는 결국 컨테이너에 APIClient.self로 등록된 게 있는지 런타임에 딕셔너리 조회를 하는 것이기 때문에, 등록이 빠져 있어도 컴파일러는 이를 알 수 없고 실행해봐야 발견됩니다. 따라서 개발자의 실수로 컨테이너에 의존성 등록을 깜빡하고 생략한 뒤 넘어갔다면, forced Unwrapping 으로 인해 의존성 부재로 앱이 크래시가 발생하는 것입니다.
따라서 위의 문제를 보완하기 위해 Pure에서는 Dependency를 명시함으로써 의존성 누락을 컴파일 타임에 잡아냅니다.
final class UserRemoteDataSourceImpl: FactoryModule {
struct Dependency {
let apiClient: APIClient // 필수 의존성으로 선언
}
required init(dependency: Dependency, payload: Payload) { ... }
}
// apiClient를 빠뜨리면?
let dataSource = UserRemoteDataSourceImpl(
dependency: .init(), // ❌ 컴파일 에러: 'apiClient' 인자 누락
payload: .init(userId: "123")
)
Swinject 의 장점으로는 Container 를 통해 의존성들을 중앙에서 관리할 수 있고, 스코프 관리를 통해 생명주기 관리를 개발자가 신경쓰지 않아도 된다는 점, 그리고 의존성 등록 시 프로토콜 기반으로 등록하고 resolve() 하기 때문에 사용하는 쪽에서는 구현체를 전혀 몰라도 된다는 장점이 있습니다.
다시 정리하면, 컴파일 타임 안정성을 우선시하면 Pure가, 런타임 유연성과 의존성 중앙 집중 관리를 우선시하면 Swinject 가 유리한데요,
이 둘을 혼합해서 의존성을 중앙에 집중하여 관리하되, 컴파일 타임 안정성을 유지할 수 있다면 더 좋겠죠. 그래서 저는 해당 사이드 프로젝트를 진행하면서 다음과 같이 혼합하여 사용했습니다.
Swinject
├── DIContainer
│ ├── DataAssembly
│ ├── DomainAssembly
│ └── 각 Feature 모듈들의 Assembly
Pure
├── ~Reactor
└── ~ViewController
DIContainer를 통해 의존성을 중앙에서 집중 관리하되, 해당 프로젝트는 Clean Architecture를 준수하여 모듈을 구성했기 때문에 Data, Domain 모듈 그리고 각 Feature 모듈의 Assembly에는 Swinject를 적용했습니다.
반면 각 Feature 모듈 내부의 Reactor와 ViewController에는 Pure를 적용했습니다. 화면 간 데이터 전달이나 화면 구성에 필요한 런타임 파라미터가 많고, 의존성 역시 해당 화면을 띄우기 위해 반드시 필요한 경우가 대부분이기 때문에, 이를 누락할 경우 곧바로 크래시로 이어집니다. 따라서 런타임이 아닌 컴파일 단계에서 누락을 잡아낼 수 있는 Pure가 더 적합하다고 판단했습니다.
final class HomeReactor: Reactor, FactoryModule {
struct Dependency {
let fetchPostsListUseCase: FetchPostsListUseCase
let fetchTop10PostsUseCase: FetchTop10PostsUseCase
...
}
...
required init(dependency: Dependency, payload: Void) {
self.dependency = dependency
self.payload = payload
}
final class HomeViewController: UIViewController, FactoryModule {
// MARK: - Init
struct Dependency {
let reactor: HomeReactor
}
required init(dependency: Dependency, payload: Void) {
super.init(nibName: nil, bundle: nil)
defer { self.reactor = dependency.reactor }
}
...
위 코드처럼 HomeReactor는 UseCase들을, HomeViewController는 Reactor를 Dependency로 선언하고 있어, 이 중 하나라도 빠뜨리면 컴파일 단계에서 즉시 에러가 발생합니다. 이를 통해 화면을 띄우는 데 필요한 의존성이 런타임 크래시 없이 안전하게 보장됩니다.
위 Reactor 와 ViewController 의 의존성을 조립하는 주체는 Assembly 입니다. 위에서 언급한대로 각 Feature 모듈 별 Assembly 는 Container 에서 관리하기 때문에 Assembly 내부에서 r.resolve()로 필요한 의존성을 가져온 뒤 Pure의 Dependency 구조체에 담아 Factory를 생성하는 방식으로 Swinject와 Pure를 함께 활용하고 있습니다.
public final class HomeAssembly: Assembly {
public init() {}
public func assemble(container: Container) {
container.register(HomeReactor.Factory.self) { r in
HomeReactor.Factory(dependency: .init(
fetchPostsListUseCase: r.resolve(),
fetchTop10PostsUseCase: r.resolve(),
likePostUseCase: r.resolve(),
cancelLikePostUseCase: r.resolve(),
userStore: r.resolve()
))
}
.inObjectScope(.graph)
container.register(HomeViewController.Factory.self) { r in
HomeViewController.Factory(dependency: .init(
reactor: r.resolve(HomeReactor.Factory.self)!.create()
))
}
.inObjectScope(.graph)
container.register(HomeCoordinator.self) { (r, navigationController: UINavigationController) in
HomeCoordinatorImpl(
navigationController: navigationController,
viewController: r.resolve(HomeViewController.Factory.self)!.create()
)
}
.inObjectScope(.graph)
}
}
위 코드에서 볼 수 있듯이, Assembly 내부에서 r.resolve()로 가져온 의존성들을 Pure의 Dependency 구조체에 담아 Factory를 생성하고 있습니다. 이를 통해 모듈 간 의존성 관리는 Swinject의 Container가, 화면 단위의 의존성 안전성은 Pure의 컴파일 타임 검증이 각각 담당하는 구조가 됩니다.
Data/Domain 레이어의 Assembly와 달리 각 Feature 모듈의 Assembly에서는 ObjectScope를 .graph로 설정했습니다. .graph 스코프는 하나의 resolve 체인 내에서만 동일 인스턴스를 공유하고, 이후에는 새로운 인스턴스를 생성합니다. 따라서 Coordinator가 removeChild()를 통해 해제되면, 해당 resolve 체인에서 생성된 Reactor, ViewController 등의 객체들도 강한 참조가 사라지며 자연스럽게 메모리에서 해제됩니다. 또한 .container로 설정할 경우 화면을 다시 로드하더라도 기존 상태값이 그대로 유지되는 문제가 있는데, .graph를 사용하면 화면 진입 시마다 의존성이 새로 생성되므로 이전 상태가 남아있는 문제를 방지할 수 있습니다.
이처럼 Swinject 와 Pure 를 같이 활용함으로써 모듈 간 의존성처럼 유연한 중앙 관리가 필요한 레이어에 대해서는 Swinject 를, 화면을 구성할 때 의존성 누락처럼 휴먼 에러가 발생하기 쉬운 곳에서는 Pure 를 적용함으로써 런타임 유연성과 컴파일 타임 안정성을 동시에 확보할 수 있습니다. 물론 해당 구조가 모든 프로젝트의 정답으로 사용할 수 있는 Silver Bullet 은 아니겠지만, DI 전략을 고민하고 계신 분들께 하나의 선택지로 참고가 되었으면 합니다.
해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.
https://github.com/sangjin-hash/09Market