YunDaeHyeon
← Lab

DIContainer와 Property Wrapper

스터디2025.07.27
  • Swift
  • MVVM

DIContainer란?

Dependency Injection Container는 말 그대로 의존성 역전을 관리하는 컨테이너이다!

특히 MVVM과 클린 아키텍처가 적용된 프로젝트에서 상당한 이점을 얻을 수 있다.

🤔 DIContainer를 왜 쓰는걸까?

DI컨테이너는 뜻 그대로 객체들의 의존성을 한 곳에서 관리하고 주입해주는 창고의 역할을 수행한다.

만약, A에서 B를 직접적으로 생성한다면 A와 B는 강하게 결합이 되어버린다.

DI 컨테이너를 사용하게 되면 A는 나 지금 B 필요해!라는 요청만 던지고

실제 B는 컨테이너가 넣어주는 흐름이 된다.

위 흐름으로 인해 코드의 결합도가 매우 낮아진다!

그러다보니 Mock 객체의 구현을 쉽게 갈아끼울 수 있으며 어떤 객체가 어떤 의존성을 가질지 한 곳에서 정의할 수 있으므로 유지보수와 변경 측면에서 정말 편해진다.


💡 기본 개념

DIContainer를 통해 의존성 주입을 한 곳에서 관리한다는 것을 배웠다.

아래 링크를 토대로 기본적인 코드와 흐름을 익혀보자.

tanaschita.com/dependency-injection-building-lightweight-container

DIContainer의 핵심은 Register(등록) 과 Resolve(해결) 의 단계로 구성된다.

Register는 컨테이너에 앞으로 사용할 모든 인스턴스를 등록시키는 동작이다.

Resolve 는 컨테이너에게 특정 타입의 인스턴스를 요구하면 코디네이터가 이를 반환하는 동작이다.


직접 만들어보기

DIContainer 정의하기

// 의존성을 관리할 DIContainer
final class DIContainer {
    static let shared = DIContainer()
    private var dependencies: [String: Any] = [:]
    
    private init() {}
    
    // 등록하기
    func register<T>(type: T.Type, service: Any) {
        let key = "\(type)"
        dependencies[key] = service
    }
    
    // 꺼내쓰기
    func resolve<T>(type: T.Type) -> T {
        let key = "\(type)"
        guard let service = dependencies[key] as? T else {
            fatalError("❌ \(key) is not registered")
        }
        return service
    }
}

이때 register와 resolve의 매개변수를 T.Type 으로 둬서 register로 넘어온 타입의 이름이 Key로 저장된다.

그러면 resolve에선 해당 타입의 이름을 문자열로 접근해서 꺼내올 수 있다.

다만, 타입의 이름이 중복되면 충돌이 될 수 있으므로 주의하자!

또 정의 부분을 보면 싱글턴으로 구현된 것을 볼 수 있다.

DI 컨테이너는 객체들의 의존성을 한 곳에서 관리하고 주입해주는 창고 의 역할을 수행한다는 걸 상기해보자.

당연히 전역적으로 1개만 존재해야한다.

만약, 컨테이너를 여러 개 생성할 수 있다면? (구조체라던지) DI 컨테이너를 사용하는 이유 자체가 사라질 것이다.

한 곳에서 의존성들을 관리해주기 위해 하나의 인스턴스만 생성하도록 하는 것이다.

적용하기 (클린 아키텍처 기반)

위와 같이 정의한 DI 컨테이너를 클린 아키텍처 기반 코드에서 간단하게 적용해보자.

아래와 같이 프로토콜과 프로토콜 구현체가 있다 가정해보자.

protocol UserService {
    func fetchUserName() -> String
}

class UserServiceImpl: UserService {
    func fetchUserName() -> String {
        return "홍길동"
    }
}

위 인스턴스를 컨테이너에 등록하려면 아래와 같이 하면 된다.

let container = DIContainer.shared
container.register(type: UserService.self, service: UserServiceImpl())

의존성이 등록(register)되었으니 원하는 곳에서 언제든지 꺼내쓰면 된다!

class ProfileViewModel {
    private let userService: UserService
    
    init(userService: UserService = DIContainer.shared.resolve(type: UserService.self)) {
        self.userService = userService
    }
    
    func printUserName() {
        print(userService.fetchUserName())
    }
}

let vm = ProfileViewModel()
vm.printUserName() // 출력 결과 : 홍길동

하지만 이때 매번 사용하고자 하는 곳에서 의존성을 꺼내오기 위해 DIContainer.shared.resolve를 하는 건 상당히 코드의 분량이 길어질 것이다.

이때 Swift의 Property Wrapper를 사용한다면 훨씬 깔끔하게 사용할 수 있다!


Property Wrapper를 활용해 DIContainer 사용하기

아까 정의한 DIContainer를 다시 가져와보자.

// 의존성을 관리할 DIContainer
final class DIContainer {
    static let shared = DIContainer()
    private var dependencies: [String: Any] = [:]
    
    private init() {}
    
    // 등록하기
    func register<T>(type: T.Type, service: Any) {
        let key = "\(type)"
        dependencies[key] = service
    }
    
    // 꺼내쓰기
    func resolve<T>(type: T.Type) -> T {
        let key = "\(type)"
        guard let service = dependencies[key] as? T else {
            fatalError("❌ \(key) is not registered")
        }
        return service
    }
}

위 컨테이너를 호출하는 부분을 따로 Property Wrapper로 정의한다면 다음과 같다.

@propertyWrapper
struct Inject<T> {
		// 실제 주입될 객체
		// T 타입을 가진 인스턴스를 저장
    private var service: T
    
    // 초기화 시점(프로퍼티 래퍼가 사용되는 지점)에서 resolve 호출
    // T.self를 통해 등록된 객체 꺼내오기
    init() {
        self.service = DIContainer.shared.resolve(T.self)
    }
    
    // 프로퍼티 래퍼를 적용한 곳에서 실제 접근이 이뤄지는 값
    // private var service를 외부로 노출 시키기
    var wrappedValue: T {
        service
    }
}

그 후 아래와 같이 프로토콜과 구현체를 만들어보자.

protocol NetworkService {
    func fetch()
}

class DefaultNetworkService: NetworkService {
    func fetch() {
        print("데이터를 불러옵니다...")
    }
}

DIContainer에 의존성을 등록해보자.

DIContainer.shared.register(NetworkService.self, service: DefaultNetworkService())

그 후 실제로 다음과 같이 사용할 수 있다!

class ViewModel {
    @Inject var networkService: NetworkService
    
    func load() {
        networkService.fetch()
    }
}

코드가 정말 획기적으로 단축된 것을 볼 수 있다.

@Inject가 붙으면 ViewModel이 생성될 때 자동으로 DIContainer의 resolve가 호출된다.

그 후 networkService.fetch() 는 resolve()의 결과물인 DefaultNetworkService 인스턴스를 사용할 수 있게 되는거다!

이때 프로퍼티 래퍼에서 _lazy_를 넣어 실제 접근할 때까지 _resolve_가 지연되도록 할 수 있다.

상황에 맞게 유연하게 사용해보자!


🤔 DIContainer의 문제점들

DIContainer는 의존성 관리 측면에서 효율적임은 분명하지만 그에 따른 단점들이 명확하다.

그래서 상황에 맞게 근거를 가지고 활용하는 것이 중요하다고 느껴진다.

1. 런타임 에러

DIContainer의 가장 큰 문제는 컴파일 시점이 아닌 런타임 시점에서 크래시가 발생한다.

class ViewModel {
		// 실제 register를 통해 등록하지 않아도 컴파일 시점에서 안보인다!
    @Inject var networkService: NetworkService
    
    func load() {
        networkService.fetch() // register 안하고 접근하면 바로 💣
    }
}

만약 위처럼 프로퍼티 래퍼를 사용하여 직접적으로 인스턴스에 접근하려 할 때 실수로 의존성을 등록하지 않았다면 앱이 동작하다가 죽어버린다!

물론.. 이걸 잡기 위해 옵셔널 처리를 할 수 있지만 코드가 상당히 복잡해질 것이다.

@propertyWrapper
struct SafeInjected<T> {
    var wrappedValue: T?
    
    init() {
        self.wrappedValue = DIContainer.shared.resolve(T.self)
    }
}

class ViewModel {
    @SafeInjected var networkService: NetworkService?
    
    func loadData() {
        guard let service = networkService else {
            print("NetworkService가 등록되지 않았습니다.")
            return
        }
        service.fetch()
    }
}

2. 코드 가독성 저하

의존성 관리 측면에선 충분히 매력적인 방식이지만.. 코드의 가독성이 상당히 떨어져 팀원이나 리뷰어가 어떤 의존성이 필요한지 명시적으로 알기 힘들어진다.

class UserViewModel {
    private let networkService: NetworkServiceProtocol
    private let userRepository: UserRepositoryProtocol
    
    init(networkService: NetworkServiceProtocol, 
         userRepository: UserRepositoryProtocol) {
        self.networkService = networkService
        self.userRepository = userRepository
    }
}

실제 위 코드는 의존성 주입 방식이 명시적이므로 해당 레이어에서 어떤 의존성이 필요한지 명확하게 알 수 있다.

하지만.. DIContainer를 적용한다면 아래처럼 암시적으로 되어버려서 내부 코드를 봐야 의존성을 확인할 수 있게 된다.

class UserViewModel {
    @Injected var networkService: NetworkServiceProtocol
    @Injected var userRepository: UserRepositoryProtocol
    
    // 어떤 의존성이 필요한지 클래스 내부를 봐야만 알 수 있다.. 🥲
}

3. 과한 추상화

DIContainer 자체를 정의하고, 프로토콜을 대입하며 Register 코드를 해결하고.. 프로퍼티 래퍼까지 끼워버리는 순간 굳이 왜 이렇게 했지? 라는 의문이 들 정도로 추상화가 과하게 이뤄진다.

그러다보니 작은 프로젝트에선 확실히 적합하지 않을 수 있다.

만약에 실무에서 사용한다면 꼭 DIContainer를 사용한 근거를 제시하여 팀원을 설득할 필요가 있어 보인다.


참고 자료

tanaschita.com/dependency-injection-building-lightweight-container

[[Swift] Dependency Container(feat. Property Wrapper) — 정주는 개발 중 :]](https://jeong9216.tistory.com/615#google_vignette)

www.swiftanytime.com/blog/dependency-injection-in-swift

medium.com/swift2go/dependency-injection-with-property-wrappers-1a8a07a4124c

//
//  ContentView.swift
//  IssueTracker
//
//  Created by 박현수 on 10/13/25.
//

import SwiftUI

struct ContentView: View {
    @State private var userId: String = ""
    @State private var password: String = ""
    var body: some View {
        VStack {
            Spacer()
            
            // MARK: - 로고
            Text("Issue Tracker")
                .italic()
            
            // MARK: - 아이디, 비밀번호
            HStack {
                Text("아이디")
                TextField("아이디를 입력하세요", text: $userId)
            }
            
            HStack{
                Text("비밀번호")
                SecureField("비밀번호를 입력하세요", text: $password)
            }
            
            // MARK: - 로그인
            Button(action: {
                // TODO: 로직 추가 예정
            }) {
                Text("로그인")
            }
            
            // MARK: - 회원가입
            Button(action: {
                // TODO: 로직 추가 예정
            }) {
                Text("회원가입")
            }
            
            VStack {
                Text("SNS 로그인")
                HStack {
                    Button(action: {
                        // TODO: Apple login
                    }) {
                        Image(systemName: "apple.logo")
                    }
                    
                    Button(action: {
                        // TODO: Github login
                    }) {
                        Image(systemName: "logo.playstation") // FIXME: github logo
                    }
                }
            }
            
            Spacer()
        }
        .frame(maxWidth: .infinity)
        .background(Color(UIColor.systemGray5))
    }
}

#Preview {
    ContentView()
}