Swinject이요? (1/2)

요즘 회사일,, 사이드 프로젝트,, 공모전까지.. 정말 너무 바쁜 한달을 보내다보니 블로그 글을 너무 오래 못쓴것같다.. 오늘은 프로젝트를 진행하면서 Swinject을 활용한 DI를 구성하는데 궁금한 내용이 있어 이 부분에 대해 공부를 좀 해보려고 한다.

 

먼저 왜 DI (Dependency Injection) 이 필요한 걸까?

 

문제 상황 예시를 한번 보자

// ❌ 강한 결합 - 수정하기 어려운 코드
class UserService {
    private let networkManager = NetworkManager()
    private let coreDataManager = CoreDataManager()
    
    func fetchUser(id: String) -> User? {
        // NetworkManager와 CoreDataManager에 직접 의존
        let data = networkManager.request("/users/\\(id)")
        return coreDataManager.save(data)
    }
}

예를 들어 User Service 클래스에 각 매니저들을 위와 같은 방식으로 직접 강한 결합을 통해 구현하게 된다면, 높은 결합도가 생기게 되고 유지 보수 뿐 만 아니라 추후 테스트까지 불가한 형태가 된다. 또한, UserService에서 Network, Coredata 등 다양한 책임까지 가지게 되어 단일 책임 원칙에 위반된다고 할 수 있다.

// ✅ 느슨한 결합 - 유연하고 테스트 가능한 코드
protocol NetworkManagerProtocol {
    func request(_ endpoint: String) -> Data?
}

protocol DataManagerProtocol {
    func save(_ data: Data?) -> User?
}

class UserService {
    private let networkManager: NetworkManagerProtocol
    private let dataManager: DataManagerProtocol
    
    // 의존성을 외부에서 주입받음
    init(networkManager: NetworkManagerProtocol, 
         dataManager: DataManagerProtocol) {
        self.networkManager = networkManager
        self.dataManager = dataManager
    }
    
    func fetchUser(id: String) -> User? {
        let data = networkManager.request("/users/\\(id)")
        return dataManager.save(data)
    }
}

이처럼 강한 결합을 피해 느슨한 결합으로, 수정이 용이하고 Mock 객체를 주입할 수 있는 형태의 확장, 모듈화된 구조로 만들어 주는 것이 DI , 의존성 주입의 개념이라고 생각한다.

 

이러한 의존성 주입이 진행되어야 하는데 이번 프로젝트에서는 Swinject를 활용해 DI 를 구축하기로 결정했다. 이 스윈젝은 의존성 그래프를 관리하고 객체의 생명주기를 제어할 수 있도록 도와주는 의존성 주입 컨테이너 프레임 워크이다.

 

간단한 예시로 살펴보자면

import Swinject

// 1. Container 생성
let container = Container()

// 2. 의존성 등록
container.register(NetworkManagerProtocol.self) { _ in
    NetworkManager()
}

container.register(DataManagerProtocol.self) { _ in
    CoreDataManager()
}

container.register(UserService.self) { resolver in
    UserService(
        networkManager: resolver.resolve(NetworkManagerProtocol.self)!,
        dataManager: resolver.resolve(DataManagerProtocol.self)!
    )
}

// 3. 의존성 해결
let userService = container.resolve(UserService.self)!

여기서 Container는 의존성 주입을 관리하는 중앙 저장소라고 보면된다. 쉽게 말해 객체 Factory의 역할을 하는 클래스 라고 보면된다.

이 컨테이너는 서비스 등록을 담당하고 등록된 서비스의 창고처럼 사용이 되는데 예시코드를 보면 이렇다

// 1. 서비스 등록부 역할
container.register(NetworkManager.self) { _ in NetworkManager() }
container.register(UserService.self) { resolver in 
    UserService(networkManager: resolver.resolve(NetworkManager.self)!)
}

// 2. 인스턴스 창고 역할 (.container 스코프)
let service1 = container.resolve(UserService.self)  // 새로 생성
let service2 = container.resolve(UserService.self)  // 저장된 것 재사용

내부적으로 살짝 들여다 보면 이 Container는

class Container {
    // 📋 등록부: "어떤 타입을 어떻게 만들지" 저장
    private var services: [ServiceKey: ServiceEntry] = [:]
    
    // 🏪 창고: ".container 스코프" 인스턴스들 저장
    private var instances: [ServiceKey: Any] = [:]
    
    // 🔄 임시 작업장: ".graph 스코프" 해결 과정 중 사용
    private var resolutionPool: ResolutionPool = ResolutionPool()
}

크게 3가지 역할을 수행한다!

 

1. 먼저 첫번째로 서비스 등록! 무엇을 어떻게 만들까를 정의한다.

container.register(NetworkManager.self) { _ in NetworkManager() }

container.register(UserService.self) { resolver in 
    UserService(networkManager: resolver.resolve(NetworkManager.self)!)
}

등록하려는 서비스의 타입과, 해당 타입을 생성하는 팩토리 클로저를 등록한다. 일종의 어떤 서비스를 등록을 해두는 곳이라고 보면된다.

 

2. 두번째로는 인스턴스의 창고 역할

let service1 = container.resolve(UserService.self)  // 생성
let service2 = container.resolve(UserService.self)  // 저장된 인스턴스 재사용

container 스코프에서는, 한 번 생성된 인스턴스를 내부 캐시에 보관하고 이후 같은 타입을 요청하면 기존 인스턴스를 꺼내 반환한다.

이러한 방식으로 싱글톤 처럼 동작하는 이유!!!!

 

3. 마지막 3번째로 임시 작업장 역할을 한다.

서비스를 생성하는 도중 의존성이 서로 꼬여있을 수 있는데 이럴 때 .graph 스코프에서 내부적으로 사용하는 임시 저장 공간이 바로 resolutionPool이다. 순환 참조 방지나, 여러 의존성을 한 번에 해결할 때 유용하게 사용한다.

 

그렇다면 container 내부에서 보이는 ServiceKey는 어떻게 구성되어있을까

 

바로 이 컨테이너 내부에서 서로 다른 서비스의 등록 정보를 구분하기 위해 이 serviceKey가 사용된다.

// 서비스를 고유하게 식별하는 키
internal struct ServiceKey: Hashable {
    let serviceType: Any.Type     // 무엇을
    let argumentsType: Any.Type   // 어떤 인자로
    let name: String?             // 어떤 이름으로
    
    // 해시 계산으로 빠른 검색 지원
    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(serviceType))
        hasher.combine(ObjectIdentifier(argumentsType))
        hasher.combine(name)
    }
}

이렇게 되어있는데 ServiceKey는

container.register(NetworkManager.self) { _ in 
    ProductionNetworkManager() 
}

container.register(NetworkManager.self) { _ in 
    MockNetworkManager()  // 🚨 덮어써짐!
}

이렇게 덮어써지는 문제를 해결하기 위해 argumentsType이나 name을 추가해 정확히 어떤 상황에서 어떤 인스턴스를 써야하는지 구분하는 고유 식별자 역할을 담당한다!!

// 인자 없는 UserService 등록
container.register(UserService.self) { _ in 
    UserService() 
}
// ServiceKey: (UserService.self, Void.self, nil)

// 인자 있는 UserService 등록
container.register(UserService.self) { _, userID in 
    UserService(userID: userID) 
}
// ServiceKey: (UserService.self, String.self, nil)

이 내용은 argumentsType을 통해 구분하는 방식의 예제 코드이다. 둘 다 UserService를 리턴하지만, 생성 시점에 받는 인자가 다르므로, 서로 다른 ServiceKey로 취급된다!!

 

이렇게 오늘은 일단 Swinject의 첫번째 자료로 어떤 방식으로 구성되어있는지만 먼저 알아봤다 ㅎㅎ 이 프레임워크를 숙지하는 방향이라기 보다는 숙지는 사용해보면서 점점 더 될거라고 생각하고, 어떤 원리로 DI가 진행되는지 이해를 도울 수 있는 방향으로 학습을 해 나가려고 한다.

 

정말 오랜만에 다시 썼다.. 이제 바쁜건 많이 지났으니 다시 내실 채우기 들어가야지!!