
TaskLocal로 스레드 안전한 의존성 주입하기
주입하기 애매한 Date, UUID, Locale을 컨텍스트로 다루는 법
2026-01-30 16:30
·- Swift
- iOS
- Dependency Injection
- TaskLocal
- Testing
들어가며
의존성 주입이라고 하면 보통 생성자나 함수 인자로 넘기는 방식을 먼저 떠올리잖아요.
그런데 Date, UUID, Locale 같은 값은 "현재 시각", "새 ID", "현재 로케일"처럼
전역이나 환경에 가깝기 때문에 매 레이어마다 인자로 넘기기 애매해요.
swift-dependencies처럼 "컨텍스트 기반 DI"를 쓰면
이런 의존성도 테스트 가능하고 스레드 안전하게 다룰 수 있다는 걸
TaskLocal로 직접 구현해보면서 정리해봤어요.
왜 "주입하기 애매한" 의존성이 있을까
Date, UUID, Locale은 앱 전역에서 쓰이지만,
매번 init(date: Date, uuid: UUID, locale: Locale)처럼 넘기자니 시그니처가 부풀어 오르고,
실제로는 대부분 "지금 시각", "새 UUID", "현재 로케일" 한 가지 구현만 써요.
이미지 디스크 캐시처럼 앱 전역에서 하나만 쓰는 객체도 비슷해요.
프로덕션에서는 한 인스턴스를 쓰고, 테스트에서만 mock/stub으로 바꾸고 싶은데
생성자로만 주입하려면 그 객체를 쓰는 모든 경로에 인자가 붙어야 해요.
정리하면, "싱글톤처럼 한 번 정해진 값을 어디서든 쓰고 싶지만,
테스트와 스레드 안전하게 바꿀 수 있는" 수단이 필요해요.
TaskLocal이 주는 것: 스레드 안전 + 태스크 스코프
TaskLocal은 Swift가 제공하는 메커니즘으로,
스레드 로컬처럼 "현재 실행 컨텍스트(태스크)"에 값을 묶어 둬요.
싱글톤처럼 "한 번 정해진 값을 어디서든 쓰는" 느낌을 주면서,
태스크 단위로 다른 값으로 덮어쓸 수 있어요.
그래서 테스트에서만 withValue로 고정된 Date, UUID, Locale을 넣어 주면 되고,
프로덕션 코드는 생성자 인자를 늘리지 않아도 돼요.
1. 가장 단순한 형태: SimpleDependency
구현 난이도가 가장 낮아요.
타입당 TaskLocal 하나만 두는 방식이에요.
@TaskLocal public static var current에 클로저를 넣어 두고, 호출부에서는 current()처럼 써요.
public enum SimpleDateGenerator {
@TaskLocal public static var current: @Sendable () -> Date = { Date() }
}
public enum SimpleUUIDGenerator {
@TaskLocal public static var current: @Sendable () -> UUID = { UUID() }
}
public enum SimpleLocaleGenerator {
@TaskLocal public static var current: @Sendable () -> Locale = { .current }
}
View에서는 생성자로 Date/UUID/Locale을 받지 않고,
SimpleDateGenerator.current(), SimpleUUIDGenerator.current()처럼 현재 컨텍스트에서 꺼내 써요.
테스트에서는 $current.withValue로 해당 태스크 안에서만 값을 덮어써요.
@Test @MainActor func testDate() async throws {
let now = Date(timeIntervalSince1970: 0)
SimpleDateGenerator.$current.withValue({ now }) {
let view = SimpleDependencyView()
#expect(view.date == now)
}
}
"타입당 TaskLocal 하나"만 있어도,
생성자 인자 없이 View나 비즈니스 로직에서 값을 쓰고, 테스트에서만 덮어쓸 수 있어요.
2. 컨테이너 하나로 모으기: ContainerDependency
난이도는 Simple보다 한 단계 올라가요.
TaskLocal에는 "값 하나"만 둘 수 있으므로,
date, uuid, locale을 담은 구조체 하나를 통째로 넣는 방식이에요.
public enum ContainerDependencyValues {
@TaskLocal public static var current = ContainerDependencyContainer()
}
public struct ContainerDependencyContainer: Sendable {
public var date: @Sendable () -> Date
public var uuid: @Sendable () -> UUID
public var locale: @Sendable () -> Locale
public init(
date: @Sendable @escaping () -> Date = { Date() },
uuid: @Sendable @escaping () -> UUID = { UUID() },
locale: @Sendable @escaping () -> Locale = { Locale.current }
) {
self.date = date
self.uuid = uuid
self.locale = locale
}
}
@propertyWrapper
public struct ContainerDependency<Value> {
private let keyPath: KeyPath<ContainerDependencyContainer, Value>
public var wrappedValue: Value {
ContainerDependencyValues.current[keyPath: keyPath]
}
public init(_ keyPath: KeyPath<ContainerDependencyContainer, Value>) {
self.keyPath = keyPath
}
}
withDependencies는 컨테이너를 하나 만들고, 필요한 필드만 바꾼 뒤 TaskLocal에 넣어 operation을 실행해요.
public func withDependencies<T>(
_ configure: (inout ContainerDependencyContainer) -> Void,
operation: () async throws -> T
) async rethrows -> T {
var deps = ContainerDependencyContainer()
configure(&deps)
return try await ContainerDependencyValues.$current.withValue(deps, operation: operation)
}
View 사용법은 Simple보다 나아져요. @ContainerDependency(\.date) 등으로 접근할 수 있어요.
다만 단점이 있어요.
연관 없는 의존성을 하나의 구조체에 몰아넣다 보니 응집도가 떨어져요.
Date/UUID/Locale은 서로 역할이 다른데, 같은 컨테이너에 묶여 있거든요.
그리고 확장성이 없어요. 새 의존성(예: 이미지 캐시)을 넣으려면 ContainerDependencyContainer 정의를 매번 수정해야 해요.
3. swift-dependencies 스타일: ProtocolDependency
구현 난이도는 세 가지 중 가장 높지만, 사용성은 가장 좋아요.
Container의 단점(응집도와 확장성)을 Key 기반 설계로 해결한 형태예요.
의존성을 한 컨테이너에 모으되, Key로 접근해요.
각 Key가 자기 타입만 책임지므로 연관 없는 것끼리 한 덩어리로 묶이지 않고,
새 의존성은 Key + defaultValue만 추가하면 되므로 확장이 자유로워요.
DependencyKey와 DependencyValues(TaskLocal)로 "현재 컨텍스트의 의존성 모음"을 표현해요.
public protocol DependencyKey {
associatedtype Value: Sendable
static var defaultValue: Value { get }
}
public struct DependencyValues: Sendable {
@TaskLocal public static var current = DependencyValues()
private var storage: [ObjectIdentifier: any Sendable] = [:]
public subscript<Key: DependencyKey>(key: Key.Type) -> Key.Value {
get {
guard let value = storage[ObjectIdentifier(key)] as? Key.Value else {
return Key.defaultValue
}
return value
}
set {
storage[ObjectIdentifier(key)] = newValue
}
}
}
@propertyWrapper
public struct Dependency<Value> {
private let keyPath: KeyPath<DependencyValues, Value>
public init(_ keyPath: KeyPath<DependencyValues, Value>) {
self.keyPath = keyPath
}
public var wrappedValue: Value {
DependencyValues.current[keyPath: keyPath]
}
}
Date, UUID, Locale은 Key + DependencyValues 확장으로 등록해요.
public struct DateGeneratorKey: DependencyKey {
public static let defaultValue: @Sendable () -> Date = { Date() }
}
extension DependencyValues {
public var date: @Sendable () -> Date {
get { self[DateGeneratorKey.self] }
set { self[DateGeneratorKey.self] = newValue }
}
}
withDependencies로 "이 operation 안에서만" 의존성을 바꿔서 실행해요.
public func withDependencies<T>(
_ configure: (inout DependencyValues) -> Void,
operation: () async throws -> T
) async rethrows -> T {
var deps = DependencyValues.current
configure(&deps)
return try await DependencyValues.$current
.withValue(deps, operation: operation)
}
View에서는 생성자 주입 없이 @Dependency(\.date) 등으로 접근해요.
struct ProtocolDependencyView: View {
@Dependency(\.date) var date
@Dependency(\.uuid) var uuid
@Dependency(\.locale) var locale
var body: some View {
// ...
DateView(date: date())
UUIDView(uuid: uuid())
LocaleView(locale: locale)
}
}
테스트에서는 withDependencies 안에서만 mock으로 교체해요.
await withDependencies {
$0.date = { now }
} operation: {
let view = ProtocolDependencyView()
#expect(view.date() == now)
}
새 의존성(예: 이미지 캐시)은 Key + defaultValue만 추가하고,
DependencyValues에 프로퍼티만 붙이면 호출부는 그대로 @Dependency(\.imageCache)로 사용할 수 있어요.
세 가지를 한 줄로 정리하면:
- SimpleDependency: 구현이 가장 쉽고 단순해요. 타입별 TaskLocal 하나씩.
- ContainerDependency: 한 단계 복잡해요. 단일 구조체 컨테이너. 다만 응집도와 확장성은 아쉬워요.
- ProtocolDependency: 구현은 가장 어렵지만 사용성은 가장 좋아요. Key 기반으로 응집도와 확장성을 모두 잡아요.
정리 및 활용 포인트
Date, UUID, Locale, 이미지 디스크 캐시처럼 "주입받기 애매한" 의존성은
TaskLocal + (필요하면) Key/Container 패턴으로 깔끔하게 다룰 수 있어요.
- 테스트: 생성자/함수 시그니처를 바꾸지 않고,
withValue/withDependenciesscope 안에서만 mock으로 교체하면 돼요. - 스레드 안전: TaskLocal은 태스크 단위로 격리되므로, 동시에 여러 태스크가 있어도 서로 덮어쓰지 않아요.
- swift-dependencies 라이브러리를 쓰지 않아도,
같은 아이디어를 프로젝트 규모에 맞게 Simple -> Container -> Protocol 중에서 선택해서 구현할 수 있어요.
난이도와 사용성을 고려하면 1->2->3 순서로 갈수록 구현은 복잡해지고, 대신 사용성과 확장성이 좋아져요.
마치며
매번 인자로 넘기지 않아도, TaskLocal로 스레드 안전하고 테스트 가능한 의존성 주입을 할 수 있다는 걸
Simple -> Container -> Protocol 순으로, 난이도가 올라가면서 사용성도 함께 올라가는 세 가지 형태로 구현해봤어요.
주입하기 애매한 의존성이 생길 때마다 "생성자에 또 넣을까" 말고,
"TaskLocal로 컨텍스트에 묶어 두고 테스트에서만 바꿀까"를 한 번 떠올려 보면 좋겠어요.
참고: