TCA Dependency는 어떻게 동작하는지 정확하게 설명할수 있는 사람

2026. 5. 24. 01:49·SWIFT개발일지

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
  1. @Dependency가 DependencyValues._current 조회
  2. \.loginRepository keyPath → get { self[LoginRepositorySlotKey.self] } 호출
  3. storage에 override 없음
  4. context가 .live임 확인
  5. cachedValues에 live 값 없음 → LoginRepositorySlotKey.liveValue 실행
  6. LiveLoginRepository() 생성 → cachedValues에 저장 후 반환

테스트

withDependencies {
    $0.loginRepository = MockLoginRepository()
} operation: {
    TestStore(initialState: LoginFeature.State()) {
        LoginFeature()
    }
}
  1. 현재 DependencyValues._current 복사
  2. $0.loginRepository = MockLoginRepository() → setter 호출
  3. storage[ObjectIdentifier(LoginRepositorySlotKey.self)]에 Mock 저장
  4. 수정된 DependencyValues를 TaskLocal _current에 바인딩
  5. Reducer 내부에서 @Dependency(\.loginRepository) 읽음
  6. storage에 Mock 있음 → liveValue 실행 안 함, MockLoginRepository 반환
  7. 테스트 종료 → 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
'SWIFT개발일지' 카테고리의 다른 글
  • iOS FCM 푸시 알림 — 동작 원리부터 권한 타이밍까지 컨트롤하기
  • SwiftUI TabView 오버레이가 iOS 26에서 안 맞는 이유
  • 로컬 알림 다국어 적용기: 언어가 바뀌어도 알림을 다시 등록하지 않아도 되는 이유
  • 왜 한번 빌드 이후부터는 빌드가 더 빠를까? - 증분 빌드
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (148)
      • SWIFT개발일지 (35)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (46)
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (3)
      • 인생회고 (1)
      • AI 라이브러리 (1)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
TCA Dependency는 어떻게 동작하는지 정확하게 설명할수 있는 사람
상단으로

티스토리툴바