TCA로 개발하다가 모듈화를 시작했습니다.
근데 바로 막혔어요.
liveValue를 어느 모듈에 두지? App? Domain? Data?
기존에는 App 모듈에 다 몰아넣고, App이 Data 레이어의 구현체까지 전부 알고 있었거든요.
근데 모듈을 나누니까 그게 안 되잖아요.
LiveLoginRepository를 App이 직접 들고 있으면 모듈 분리가 의미가 없고, 그렇다고 Domain에 두자니 Domain이 구현체를 알면 안 되고....😅
흠...
근데..
나 Dependency 뭐 어떻게 동작하는지 100% 남한테 설명할정도로 이해하고 있나?????

-> 그래서 일단 모듈화는 잠깐 미뤄두고, Dependency 자체를 먼저 파보기로 했습니다.
기존 방식들은 어떤 문제가 있었나
TCA가 왜 이런 구조를 선택했는지 이해하려면, 기존 방식들이 어디서 터지는지부터 봐야 해요.
Singleton
final class LoginRepository {
static let shared = LoginRepository()
func login() async throws { ... }
}
접근은 편합니다. LoginRepository.shared.login() 한 줄이면 끝이에요.
근데 문제가 있어요.
테스트에서 Mock으로 바꿀 수가 없어요.
shared는 앱 전체가 공유하는 전역 인스턴스라서, 테스트 A에서 Mock으로 교체하면 테스트 B도 영향을 받습니다.
병렬 테스트라면 더 위험하고요.
모듈화 관점에서도 문제예요. 구체 구현체에 직접 접근하니까 모듈 경계가 의미 없어집니다.
생성자 주입
struct LoginFeature {
let loginUseCase: LoginUseCase
}
// 테스트에서
let feature = LoginFeature(loginUseCase: MockLoginUseCase())
명시적이고 테스트하기도 좋아요. 근데 앱이 커지면 이렇게 됩니다
AppFeature(
authFeature: AuthFeature(
loginFeature: LoginFeature(
loginUseCase: LoginUseCase(
repository: LoginRepository(
network: NetworkClient(...)
)
)
)
)
)
TCA처럼 Reducer가 많아지는 구조에서는 의존성을 계속 위에서 아래로 전달해야 해서 boilerplate가 폭발해요.
Feature가 10개, 20개 되는 순간 감당이 안 됩니다.
그래서 TCA는 다른 방식을 선택했어요
우선 결과물만 보면
// 사용 — Singleton처럼 쉽게
@Dependency(\.loginRepository) var loginRepository
// 테스트 — 생성자 주입처럼 안전하게, scope가 끝나면 원복
withDependencies {
$0.loginRepository = MockLoginRepository()
} operation: {
// 이 scope 안에서만 Mock
}
이게 어떻게 가능한 건지, 지금부터 뜯어볼게요.
내부 구성은 크게 세 가지입니다.

- DependencyKey — 이 dependency의 기본값을 정의
- DependencyValues — 현재 실행 컨텍스트에서 사용할 dependency 저장소
- @Dependency — DependencyValues에서 값을 꺼내오는 property wrapper
DependencyKey
TCA는 Key의 인스턴스를 만들지 않습니다. 타입 자체를 Key로 씁니다.
// TCA가 실제로 하는 것
LoginRepositorySlotKey.self // 타입 자체를 Key로
// TCA가 하지 않는 것
let key = LoginRepositorySlotKey() // 인스턴스 생성 X
enum LoginRepositorySlotKey: DependencyKey { ... }
LoginRepositorySlotKey() // 컴파일 에러!
왜 enum일까?
case 없는 enum은 인스턴스를 만들 수 없어요. 흠 그럼 왜 이렇게 했을까 다시 생각해볼게요
여기서 필요한 건 타입의 identity뿐이에요.
값을 담는 인스턴스가 아니라, "이게 LoginRepository를 위한 Key다"라는 타입 정보 자체만 필요한 거예요.
case 없는 enum은 인스턴스를 만들 수 없습니다.
왜 static liveValue인가??
타입만 넘기기 때문에, 기본값도 인스턴스 없이 접근할 수 있어야 합니다.
// 불가능한 설계 — 값을 읽으려면 인스턴스가 필요함
struct LoginRepositorySlotKey {
let liveValue: LoginRepository
}
let key = LoginRepositorySlotKey(...)
key.liveValue // 인스턴스가 있어야 접근 가능
// TCA는 이렇게 접근하기 때문에
LoginRepositorySlotKey.liveValue // 타입에서 바로 → static이어야 함
DependencyValues
아직 머릿속에서 그림이 잘 안 그려지죠? 실제 TCA 내부 코드를 보면 이렇게 생겼습니다.
public struct DependencyValues: Sendable {
@TaskLocal public static var _current = Self()
#if DEBUG
@TaskLocal static var currentDependency = CurrentDependency()
@TaskLocal static var isSetting = false
#endif
@_spi(Internals)
public var cachedValues = CachedValues()
private var storage: [ObjectIdentifier: any Sendable] = [:]
}
세 가지 핵심 프로퍼티가 있어요.
DependencyValues
├─ _current 현재 Task에서 사용하는 DependencyValues 자체 (@TaskLocal)
├─ storage withDependencies로 직접 override한 값들
└─ cachedValues liveValue / testValue / previewValue 기본값 캐시
하나씩 왜 이렇게 생겼는지 보겠습니다.
① _current — @TaskLocal
@TaskLocal public static var _current = Self()
@Dependency를 쓰면 결국 이 _current에서 값을 꺼내옵니다.
전역처럼 보이는데, 그냥 static var로 두면 안 될까요?????

테스트 A에서 Mock을 넣는 순간 테스트 B도 영향을 받아요. 병렬 테스트라면 더 심각하고요.
반면, @TaskLocal은 현재 Task와 그 자식 Task에만 영향을 줍니다.
Task A → MockLoginRepository
Task B → FailureLoginRepository
Task C → LiveLoginRepository (기본값)
각 Task가 독립된 DependencyValues 스냅샷을 들고 있는 거예요.
그래서 TCA는 전역처럼 쉽게 접근하면서도, 실제 값은 현재 Task 흐름에만 묶어둘 수 있어요.
참고로 #if DEBUG 블록에 currentDependency, isSetting도 있는데, 이건 테스트 컨텍스트에서 "어느 파일 몇 번째 줄에서 접근했는지" 추적하는 용도예요.
런타임에 "이 Dependency가 어디서 호출됐다"는 정보를 붙여서 문제가 생겼을 때 디버깅하기 쉽게 해줍니다.
② storage — 직접 override한 값들
private var storage: [ObjectIdentifier: any Sendable] = [:]
withDependencies로 넣은 값들이 여기 저장돼요.
앱에는 dependency가 많습니다.
loginRepository, networkClient, date, uuid, userDefaults ...
이걸 하나의 저장소에 담으려면 key-value 구조가 필요해요. 실제 저장되는 모양은 이렇습니다.
ObjectIdentifier(LoginRepositorySlotKey.self) → MockLoginRepository
ObjectIdentifier(NetworkKey.self) → LiveNetworkClient
ObjectIdentifier(DateKey.self) → DateGenerator
Key로 문자열 대신 ObjectIdentifier를 쓰는 이유는 타입 안정성 때문이에요.
// 문자열 Key → 오타가 런타임에야 발견됨
"loginRepository"
"loginRepositroy" // 오타! 컴파일러가 못 잡음
// 타입 Key → 오타 시 컴파일 에러
LoginRepositorySlotKey.self // 틀리면 바로 에러
③ cachedValues — 기본값 캐시
storage에 값이 없을 때, 즉 override가 없을 때 내려오는 곳이에요
public final class CachedValues: @unchecked Sendable {
public let lock = NSRecursiveLock()
public var cached = [CacheKey: CachedValue]()
}
liveValue에 접근할 때마다 새 인스턴스를 만들면 낭비니까, 한 번 만든 뒤 재사용합니다. 말 그대로 캐시 그 잡채예요.
class인 이유는 여러 Task가 동시에 접근해도 같은 인스턴스를 공유해야 하기 때문이에요.
struct면 복사가 일어나서 캐시 의미가 없어집니다.
그래서 NSRecursiveLock으로 레이스 컨디션을 보호하고 있어요.
정리하면 이렇게 연결됩니다
@Dependency(\.loginRepository) 호출
↓
DependencyValues._current 조회 ← @TaskLocal, Task별로 독립
↓
storage 확인 ← withDependencies로 넣은 override
값 있으면 → 즉시 반환 (Mock 등)
값 없으면 ↓
cachedValues 확인 ← liveValue / testValue / previewValue
캐시 있으면 → 반환
캐시 없으면 → Key.liveValue 실행 후 캐시 저장, 반환
DependencyValues extension
내부 구조를 알았으니, 이제 "어떻게 \.loginRepository라는 keyPath로 접근하는가"를 봐야 해요.
Key만 정의하면, 아직 이런 코드는 쓸 수 없어요.
@Dependency(\.loginRepository) var loginRepository
// 오류: DependencyValues에 loginRepository 프로퍼티가 없음
extension을 추가해야 합니다.
public extension DependencyValues {
var loginRepository: LoginRepository {
get { self[LoginRepositorySlotKey.self] }
set { self[LoginRepositorySlotKey.self] = newValue }
}
}
이 extension의 역할은 딱 하나예요. 사람이 쓰는 이름과 내부 Key를 연결하는 것.
\.loginRepository
↓
DependencyValues.loginRepository
↓
LoginRepositorySlotKey.self
get은 값을 읽을 때 호출되고, set은 테스트에서 override할 때 호출돼요.
set이 없으면 이런 코드가 동작하지 않습니다.
withDependencies {
$0.loginRepository = MockLoginRepository() // set 호출
}
subscript — 실제로 어떻게 값을 꺼내오나
self[LoginRepositorySlotKey.self]가 실제로 호출하는 subscript입니다. 실제 TCA 코드를 보면 이렇게 생겼어요.
public subscript<Key: TestDependencyKey>(
key: Key.Type,
fileID fileID: StaticString = #fileID,
filePath filePath: StaticString = #filePath,
line line: UInt = #line,
column column: UInt = #column,
function function: StaticString = #function
) -> Key.Value {
get {
guard let base = self.storage[ObjectIdentifier(key)],
let dependency = base as? Key.Value
else {
// storage에 없으면 context 확인
let context = self.storage[ObjectIdentifier(DependencyContextKey.self)]
as? DependencyContext
?? self.cachedValues.value(for: DependencyContextKey.self, ...)
switch context {
case .live, .preview:
return self.cachedValues.value(for: Key.self, context: context, ...)
case .test:
// DEBUG에서는 어느 파일/라인에서 접근했는지 currentDependency에 기록
return self.cachedValues.value(for: Key.self, context: context, ...)
}
}
return dependency
}
set {
storage[ObjectIdentifier(key)] = newValue
}
}
fileID, filePath, line, column, function 파라미터
단순히 값을 꺼내는 것 이상으로, 어디서 접근했는지까지 추적합니다. 테스트에서 "등록되지 않은 Dependency를 사용했다"는 경고가 뜰 때 파일명과 라인 번호가 찍히는 게 이 덕분이에요.
context 분기
같은 Key라도 현재 context에 따라 다른 값을 반환해요.
.live → liveValue 사용
.preview → previewValue 사용 (없으면 liveValue)
.test → testValue 사용 (없으면 경고)
Xcode Preview에서 자동으로 previewValue가 쓰이고, 테스트에서 자동으로 testValue가 쓰이는 게 이 분기 덕분입니다. withDependencies로 직접 넣지 않아도요.
읽을 때 우선순위를 정리하면:
1순위: storage에 직접 override한 값 (withDependencies로 넣은 Mock 등)
2순위: cachedValues → context에 따라 liveValue / testValue / previewValue
@Dependency — property wrapper 내부
subscript가 어떻게 값을 꺼내는지 알았으니, 이제 우리가 실제로 쓰는 @Dependency가 내부에서 어떻게 동작하는지 볼게요.
실제 TCA 코드입니다.
@propertyWrapper
public struct Dependency<Value>: _HasInitialValues {
let initialValues: DependencyValues = DependencyValues._current
private var installValues: DependencyValues?
private let keyPath: SendableKeyPath<DependencyValues, Value>
private let fileID: StaticString
private let filePath: StaticString
private let line: UInt
private let column: UInt
public var wrappedValue: Value {
guard let installValues else {
#if DEBUG
var currentDependency = DependencyValues.currentDependency
currentDependency.fileID = fileID
currentDependency.line = line
return DependencyValues.$currentDependency.withValue(currentDependency) {
let dependencies = initialValues.merging(DependencyValues._current)
return DependencyValues.$_current.withValue(dependencies) {
DependencyValues._current[keyPath: keyPath]
}
}
#else
let dependencies = initialValues.merging(DependencyValues._current)
return DependencyValues.$_current.withValue(dependencies) {
DependencyValues._current[keyPath: keyPath]
}
#endif
}
return initialValues.merging(installValues)[keyPath: keyPath]
}
}
initialValues
let initialValues: DependencyValues = DependencyValues._current
@Dependency가 선언되는 순간의 _current를 스냅샷으로 들고 있어요.
Reducer처럼 특정 시점에 생성되는 객체 안에 @Dependency가 선언되면, 생성 당시의 dependency context를 기억해야 해요.
나중에 wrappedValue에 접근하는 시점이 달라도 원래 context가 유지될 수 있도록요.
merging
let dependencies = initialValues.merging(DependencyValues._current)
선언 시점 값(initialValues)과 실제 접근 시점 값(_current)을 합칩니다.
덕분에 withDependencies scope 안에서 나중에 override된 값도 제대로 인식할 수 있어요.
// withDependencies scope 안에서 선언된 @Dependency
withDependencies {
$0.loginRepository = MockLoginRepository()
} operation: {
// 이 안에서 생성된 객체의 @Dependency는
// initialValues(선언 시점) + _current(Mock이 들어간 시점) 를 merge
// → MockLoginRepository 반환 ✅
}
fileID, line 추적
DEBUG 빌드에서는 wrappedValue에 접근할 때 어느 파일, 몇 번째 라인에서 접근했는지를 currentDependency에 기록해요.
테스트에서 "이 Dependency가 override되지 않았다"는 경고가 뜰 때 정확한 위치가 찍히는 게 이 덕분입니다.
SwiftUI에서는 조금 달라요
#if canImport(SwiftUI)
extension Dependency: DynamicProperty {
public mutating func update() {
install(environmentValues)
}
}
SwiftUI View에서 @Dependency를 쓸 때는 DynamicProperty를 채택해서 View가 업데이트될 때마다 install이 호출돼요.
SwiftUI의 Environment를 통해서도 dependency가 흘러들어올 수 있게 되는 거예요.
// View에서 이렇게 dependency를 주입할 수도 있음
ContentView()
.dependency(\.loginRepository, MockLoginRepository())
TCA Reducer 안에서 쓸 때와 SwiftUI View에서 쓸 때 동작 방식이 조금 다른 이유가 여기 있어요.
withDependencies는 어떻게 scope를 만드는데?

이제 withDependencies가 어떻게 동작하는지 보면 전부 연결돼요.
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: () throws -> R
) rethrows -> R {
var values = DependencyValues._current // 1. 현재 Task의 DependencyValues 복사
updateValuesForOperation(&values) // 2. 복사본에 override 적용
return try DependencyValues.$_current.withValue(values) {
try operation() // 3. 수정된 값으로 TaskLocal 바인딩 후 실행
}
// 4. operation 종료 → TaskLocal 자동 복귀
}
핵심은 원본을 바꾸는 게 아니라 복사본을 만들어 TaskLocal에 잠깐 바인딩한다는 거예요.
@Dependency(\.loginRepository) var repo1 // LiveLoginRepository
withDependencies {
$0.loginRepository = MockLoginRepository()
} operation: {
@Dependency(\.loginRepository) var repo2 // MockLoginRepository ✅
}
@Dependency(\.loginRepository) var repo3 // 다시 LiveLoginRepository ✅
scope가 끝나면 아무 일도 없었던 것처럼 원복됩니다. @TaskLocal의 withValue가 알아서 해줘요.
실제로 어떻게 흘러가나
흐름을 한 번에 정리해볼게요.
@Dependency(\.loginRepository) var loginRepository
- @Dependency가 DependencyValues._current 조회
- \.loginRepository keyPath → get { self[LoginRepositorySlotKey.self] } 호출
- storage에 override 없음
- context가 .live임 확인
- cachedValues에 live 값 없음 → LoginRepositorySlotKey.liveValue 실행
- LiveLoginRepository() 생성 → cachedValues에 저장 후 반환
테스트
withDependencies {
$0.loginRepository = MockLoginRepository()
} operation: {
TestStore(initialState: LoginFeature.State()) {
LoginFeature()
}
}
- 현재 DependencyValues._current 복사
- $0.loginRepository = MockLoginRepository() → setter 호출
- storage[ObjectIdentifier(LoginRepositorySlotKey.self)]에 Mock 저장
- 수정된 DependencyValues를 TaskLocal _current에 바인딩
- Reducer 내부에서 @Dependency(\.loginRepository) 읽음
- storage에 Mock 있음 → liveValue 실행 안 함, MockLoginRepository 반환
- 테스트 종료 → TaskLocal scope 종료, 원래 값으로 복귀
마무리
결국 TCA가 enum, static liveValue, DependencyValues extension, @TaskLocal을 선택한 이유는 이거예요.
전역 접근의 편리함은 유지하면서, Task 단위로 안전하게 격리하기 위해서.
Singleton처럼 편하게 쓰되 전역 오염은 없고, 생성자 주입처럼 교체 가능하되 boilerplate는 없고,
async Task 안에서도 override가 유지
이 세 가지를 동시에 만족하려고 저 구조가 나온 겁니다.
'SWIFT개발일지' 카테고리의 다른 글
| iOS FCM 푸시 알림 — 동작 원리부터 권한 타이밍까지 컨트롤하기 (0) | 2026.05.26 |
|---|---|
| SwiftUI TabView 오버레이가 iOS 26에서 안 맞는 이유 (0) | 2026.04.04 |
| 로컬 알림 다국어 적용기: 언어가 바뀌어도 알림을 다시 등록하지 않아도 되는 이유 (1) | 2026.02.03 |
| 왜 한번 빌드 이후부터는 빌드가 더 빠를까? - 증분 빌드 (0) | 2026.01.25 |
| ProtoBuf - 이게 뭔데 사람들은 환호성을 지를까? (1) | 2025.11.29 |