iOS에서 사용하는 의존성 주입 방법은 뭐가 있을까? | 2025-05-06
의존성 주입의 의도는 객체의 생성과 사용의 관심을 분리 하는 것이에요.
protocol ReadableClient {
func read(id: String) async throws -> Data
}
protocol WritableClient: ReadableClient {
func write(id: String, data: Data) async throws
}
final class ReadOnlyController {
private let readableClient: any ReadableClient
}
final class SomeController {
private let writableClient: any WritableClient
func makeReadOnlyController() -> ReadOnlyController {
return ReadOnlyController(readableClient: writableClient)
}
}
struct SomeClient {
var read: (_ id: String) async throws -> Data
var write: (_ id: String, _ data: Data) async throws -> Void
}
// 상세 구현 생략
final class ReadOnlyController {
@Dependency(\.someClient.read) var read
}
final class SomeController {
@Dependency(\.someClient) var someClient
}
protocol 기반은 mock 객체를 만들기 위해 해당 protocol을 준수하는 객체를 만들어야 해요.
struct 기반은 준수하는 과정 없이 객체를 만들기만 하면 mock 객체로 사용할 수 있어요.
/// Protocol 기반
final class MockWritableClient: WritableClient {
func read(id: String) async throws -> Data { /* 생략 */ }
func write(id: String, data: Data) async throws { /* 생략 */ }
}
let mockClient = MockWritableClient()
/// Struct 기반
let client = SomeClient(
read: { /* 생략 */ },
write: { /* 생략 */ }
)
확실히 Struct 기반이 가볍다고 느껴져요.
그러나 이 단점은 Protocol 기반 + mockolo를 활용해서 코드 생성을 하여 해결할 수 있어요.
Protocol 기반은 인터페이스에 의존을 하며 외부에서 해당 의존성을 주입해요.
swift-dependencies도 그런 방식이 가능하지만 가이드 방식은 조금 달라요.
아래와 같은 모듈 구조가 있다고 가정할게요.
먼저 Protocol 기반 의존성은 다음과 같아요.
/// Service Interface Module
public protocol ReadableClient {
func read(id: String) async throws -> Data
}
public protocol WritableClient: ReadableClient {
func write(id: String, data: Data) async throws
}
/// Service Module
import ServiceInterfaceModule
public final class DefaultClient: WritableClient {
public func read(id: String) async throws -> Data { /* 생략 */ }
public func write(id: String, data: Data) async throws { /* 생략 */ }
}
/// Home Module
import ServiceInterfaceModule
public final class HomeViewController: UIViewController {
private let client: WritableClient
public init(client: WritableClient) { /* 생략 */ }
}
/// App Module
import Home
import ServiceModule
import ServiceInterfaceModule
func makeRootViewController() -> UIViewController {
let client = DefaultClient()
let viewController = HomeViewController(client: client)
return viewController
}
확실히 라이브러리를 사용하지 않은 Protocol 기반은 꽤 복잡해요.
swift-dependencies를 사용하면 굉장히 편리해요.
그러나 인터페이스 모듈을 활용하기 어려워요.
static으로 선언된 의존성을 알고 있어야 하기 때문이에요.
/// 인터페이스
public struct SomeClient: Sendable {
public var read: @Sendable (_ id: String) async throws -> Data
public var write: @Sendable (_ id: String, _ data: Data) async throws -> Void
}
/// 구현체
extension SomeClient: DependencyKey {
public static var liveValue: Self {
/* implemented */
}
}
extension DependencyValues {
public var someClient: SomeClient {
get { self[SomeClient.self] }
set { self[SomeClient.self] = newValue }
}
}
Struct로 인터페이스를 정의하고 DependencyKey
, DependencyValues
로 구현체를 정의해요.
해당 객체를 사용하는 곳에서는 @Dependency
라는 property wrapper를 통해 값을 가져오는데 인터페이스만을 통해서는 가져올 수 없어요.
즉, 모듈 구조가 아래와 같은 형태가 되어요.
저는 최근에 UIKit을 사용하면 needle + mockolo를 사용하고 있어요.
needle은 서비스 로케이터 패턴의 편리함과 컴파일 타임에 의존성 주입 체크를 할 수 있는 라이브러리에요.
SwiftUI를 사용하면 swift-dependencies를 활용해요.
TCA를 주로 활용하는데 해당 라이브러리가 강결합이 되어있어 선택지가 없어요 😢
만약 사내에서 의존성 관리를 어떻게 할 것인지 생각한다면 저는 아래와 같은 기준으로 선택할 것 같아요.
사실 위와 같은 얘기를 했지만 Struct가 너무 편리한 것 같아요.
RIBs + Protocol 기반으로 많은 작업을 했지만 많은 코드 점핑으로 인해 구현체를 찾기 불편한 점도 있었고 굳이 그정도로 추상화를 해야 하는 것인가? 하는 생각이 종종 생겼어요.
최근에는 일단 확장성보다는 생산성에 초점을 두고 진행한 후 추후 문제가 생길 것 같으면 바꾸는 것도 하나의 방법이라 생각해요.