반응형

SwiftUI Study – 고급 DI 패턴: Environment + Container + 프로토콜로 서비스·전역 상태를 유연하게 구성하기

 

1. 왜 중요한가 (문제 배경)

규모 있는 SwiftUI 앱에서는 거의 항상 다음과 같은 요구가 생깁니다.

  • 공통으로 사용하는 서비스 객체
    • AuthService, UserService, ProductService, Analytics, Logger, FeatureFlagService 
  • 앱 전역에서 공유되는 설정/환경 값
    • API Base URL, 빌드 타겟(staging/production), 실험 플래그 등
  • 여러 화면이 공유하는 전역 상태
    • 로그인 세션, 알림 뱃지 카운트, 사용자 환경설정 등

이를 대충 구현하면:

  • 서비스 싱글톤(AuthService.shared)이 코드에 퍼져 테스트·교체가 어려워지고
  • AppGlobalState 같은 God Object EnvironmentObject가 생기며
  • 의존성 관계가 얽히고설켜 “어디서 무엇을 쓰는지” 추적하기 힘들어집니다.

이 팁에서는 다음 세 가지를 조합한 고급 DI(Dependency Injection) 패턴을 다룹니다.

  1. 프로토콜 기반 서비스 정의
  2. DI 컨테이너(Dependency Container) 구성
  3. Environment / EnvironmentObject를 이용한 안전한 주입

이전 팁들(상태 관리, AppStorage, ScenePhase, PreferenceKey 등)은
“상태와 UI”에 집중했다면, 42번은 “서비스와 의존성 구조” 자체를 다룹니다.

 

2. 안티 패턴 정리 (이렇게 되면 이미 냄새 난다)

❌ 패턴 1: “무엇이든 GlobalState에 넣는” God Object

final class AppGlobalState: ObservableObject {
    @Published var user: User?
    @Published var theme: Theme = .light
    @Published var unreadCount: Int = 0
    @Published var isPremiumUser: Bool = false
    @Published var lastSyncAt: Date?
    // 여기에 점점 더 붙기 시작…
}

문제

  • “변수 하나 추가”가 쉬워서 모든 상태가 한 곳으로 몰린다.
  • 어떤 View가 무엇에 의존하는지 추적이 힘들고,
    store 변경 한 번으로 엄청난 범위의 뷰가 리렌더링될 수 있다.
  • 테스트에서 특정 기능만 떼어 검증하기가 어렵다.

❌ 패턴 2: 전역 싱글톤 직접 접근

struct LoginView: View {
    var body: some View {
        Button("로그인") {
            AuthService.shared.login()    // ❌ 강결합
        }
    }
}

문제

  • 테스트/프리뷰에서 다른 구현체로 바꾸기 어렵다.
  • “언제 어떤 인스턴스가 생성되고 파괴되는지” 추적이 어렵다.
  • 나중에 AuthService가 네트워크 모킹/로깅/리트라이 등을 위해 바뀌면
    사용하는 모든 곳에서 수정해야 한다.

❌ 패턴 3: 깊은 자식에서 갑자기 EnvironmentObject 요구

struct DeepChildView: View {
    @EnvironmentObject var session: SessionStore   // ❌ 상위에서 안 넣으면 바로 크래시

    var body: some View {
        Text(session.user?.name ?? "Guest")
    }
}

문제

  • 어떤 계층에서 SessionStore가 제공되는지 명확하지 않다.
  • 이 뷰를 다른 프로젝트/화면에서 재사용하려면,
    반드시 동일한 EnvironmentObject를 맞춰줘야 한다.
  • 재사용 가능한 뷰와 특정 앱 구조가 강하게 결합된다.

 

3. 고급 DI의 기본 축 – “프로토콜 + 컨테이너 + Environment”

3-1. 서비스는 프로토콜로 정의

// 1) 서비스 역할 정의
protocol AuthService {
    func login(id: String, password: String) async throws -> User
    func logout() async throws
    var currentUser: User? { get }
}

// 2) 실제 구현
final class RealAuthService: AuthService {
    private(set) var currentUser: User?

    func login(id: String, password: String) async throws -> User {
        // 네트워크 요청…
        let user = User(id: id, name: "실사용자")
        currentUser = user
        return user
    }

    func logout() async throws {
        currentUser = nil
    }
}

포인트

  • View는 RealAuthService를 알 필요 없이 AuthService 프로토콜만 알면 된다.
  • 나중에 MockAuthService, PreviewAuthService, StubAuthService 등으로 대체 가능.

3-2. DI 컨테이너(Dependency Container) 설계

/// 앱에서 사용하는 모든 서비스 집합
struct AppEnvironment {
    let authService: AuthService
    let userService: UserService
    let logger: Logger
    let featureFlags: FeatureFlagService
    // …
}

앱 시작 시점(App @main)에서 하나의 컨테이너를 조립:

@main
struct MyApp: App {
    @State private var appEnvironment = AppEnvironment(
        authService: RealAuthService(),
        userService: RealUserService(),
        logger: OSLogger(),
        featureFlags: RemoteFeatureFlagService()
    )

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.appEnvironment, appEnvironment)
        }
    }
}

여기서 .environment(\.appEnvironment, appEnvironment)를 쓰려면,
AppEnvironment를 위한 EnvironmentKey를 정의해야 한다.

3-3. AppEnvironment를 Environment에 통째로 넣기

private struct AppEnvironmentKey: EnvironmentKey {
    static let defaultValue = AppEnvironment(
        authService: RealAuthService(),
        userService: RealUserService(),
        logger: OSLogger(),
        featureFlags: RemoteFeatureFlagService()
    )
}

extension EnvironmentValues {
    var appEnvironment: AppEnvironment {
        get { self[AppEnvironmentKey.self] }
        set { self[AppEnvironmentKey.self] = newValue }
    }
}

이제 어디에서든:

struct ProfileView: View {
    @Environment(\.appEnvironment) private var env

    var body: some View {
        Button("로그아웃") {
            Task {
                try? await env.authService.logout()
            }
        }
    }
}

포인트

  • 싱글톤을 직접 접근하는 대신,
    AppEnvironment를 통해서만 서비스에 접근하게 강제할 수 있다.
  • 테스트/프리뷰에서 다른 AppEnvironment를 제공하면 손쉽게 전체 서비스 구조를 바꿀 수 있다.

 

4. 기능 단위로 “좁은” DI 인터페이스 만들기

AppEnvironment 전체를 View 깊은 곳까지 넘기는 것도
결국 “큰 의존성”이 움직이는 것이기 때문에, 기능 단위로 좁힌 인터페이스를 만드는 게 좋다.

4-1. 기능별 Facade/UseCase 정의

protocol ProfileFeatureEnvironment {
    var authService: AuthService { get }
    var userService: UserService { get }
}

extension AppEnvironment: ProfileFeatureEnvironment {}

Profile 화면 루트:

struct ProfileRootView: View {
    private let env: ProfileFeatureEnvironment

    init(env: ProfileFeatureEnvironment) {
        self.env = env
    }

    var body: some View {
        ProfileScreen(
            viewModel: ProfileViewModel(
                authService: env.authService,
                userService: env.userService
            )
        )
    }
}

앱 진입점에서 연결:

RootView()
    .environment(\.appEnvironment, appEnvironment)

RootView 내부:

struct RootView: View {
    @Environment(\.appEnvironment) private var appEnv

    var body: some View {
        TabView {
            ProfileRootView(env: appEnv)
                .tabItem { Label("프로필", systemImage: "person") }

            // 다른 탭들…
        }
    }
}

포인트

  • Profile 관련 뷰는 전체 AppEnvironment 대신,
    ProfileFeatureEnvironment 프로토콜에만 의존한다.
  • 나중에 Profile 기능을 다른 앱으로 옮기거나
    독립 모듈로 분리할 때 훨씬 수월해진다.

 

5. 테스트·프리뷰에서의 Mock DI 패턴

5-1. Mock 환경 정의

struct MockAuthService: AuthService {
    var currentUser: User? = User(id: "test", name: "테스트 유저")

    func login(id: String, password: String) async throws -> User {
        User(id: id, name: "Mock User")
    }

    func logout() async throws {}
}

struct MockEnvironment {
    static let preview = AppEnvironment(
        authService: MockAuthService(),
        userService: MockUserService(),
        logger: ConsoleLogger(),
        featureFlags: LocalFeatureFlagService()
    )
}

5-2. Preview에 주입

struct ProfileView_Previews: PreviewProvider {
    static var previews: some View {
        RootView()
            .environment(\.appEnvironment, MockEnvironment.preview)
    }
}

포인트

  • 실제 API 없이도 “로그인된 상태”, “게스트 상태”, “특정 플래그 ON/OFF 상태” 등
    다양한 조합을 프리뷰 레벨에서 빠르게 돌려볼 수 있다.
  • 이 구조는 후에 UI 테스트, 유닛 테스트에서도 그대로 활용 가능하다.

 

6. EnvironmentObject와 DI 컨테이너를 함께 쓰는 패턴

전역 “상태”는 ObservableObject + EnvironmentObject,
전역 “서비스/설정”은 AppEnvironment + EnvironmentKey 로 나누는 패턴이 실전에서 가장 안정적이다.

@MainActor
final class SessionStore: ObservableObject {
    @Published var user: User?
}

@main
struct MyApp: App {
    @State private var env = AppEnvironment(
        authService: RealAuthService(),
        userService: RealUserService(),
        logger: OSLogger(),
        featureFlags: RemoteFeatureFlagService()
    )

    @StateObject private var session = SessionStore()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.appEnvironment, env)
                .environmentObject(session)
        }
    }
}

View에서는:

struct HomeHeaderView: View {
    @EnvironmentObject private var session: SessionStore
    @Environment(\.appEnvironment) private var env

    var body: some View {
        HStack {
            Text(session.user?.name ?? "게스트")
            Spacer()
            Button("로그아웃") {
                Task {
                    try? await env.authService.logout()
                }
            }
        }
    }
}

역할 분리

  • SessionStore: 상태 (현재 사용자)
  • AppEnvironment: 행동/서비스 (로그인/로그아웃)

 

7. 실전 적용 팁

✔ 팁 1 – “상태”와 “서비스”는 구분해서 설계

  • 변하는 값(로그인 상태, 알림 카운트 등) → ObservableObject + EnvironmentObject
  • 거의 고정되거나 서비스 역할(API 클라이언트 등) → EnvironmentKey 기반 AppEnvironment

✔ 팁 2 – 전역 싱글톤은 최후의 수단

  • 대다수 경우, 싱글톤 대신 AppEnvironment에 넣으면 된다.
  • 특히 네트워크 계층/로그/Analytics는 테스트 시 모킹해야 하므로
    싱글톤 직사용은 장기적으로 발목을 잡는다.

✔ 팁 3 – 모듈/기능 단위로 “좁은 인터페이스”를 정의

  • AppEnvironment 전체를 깊은 곳까지 끌고 가지 말고,
    ProfileFeatureEnvironment, SettingsFeatureEnvironment같은
    작은 프로토콜로 잘라서 전달하면 모듈성을 확보할 수 있다.

✔ 팁 4 – Preview에서 Mock Environment를 적극 활용

  • “프리뷰를 만들기 힘들다” = “DI/아키텍처가 꼬여 있다”는 신호인 경우가 많다.
  • Preview가 쉽게 만들어지도록 설계하면
    자연스럽게 테스트 가능한 구조가 따라온다.

✔ 팁 5 – DI는 한 번에 완벽하게 설계하려고 하지 말고, 점진적으로 개선

  • 처음에는 간단히 싱글톤/EnvironmentObject로 시작해도 된다.
  • 기능이 늘어날수록, 테스트/프리뷰가 힘들어지는 지점에서
    AppEnvironment + 프로토콜 + EnvironmentKey 패턴으로 단계적으로 옮겨가면 된다.

 

8. 정리

  • SwiftUI에서도 “좋은 DI”는 충분히 구현할 수 있고,
    오히려 Environment/EnvironmentObject 덕분에 UIKit보다 깔끔해질 수 있다.
  • 핵심은
    • 서비스는 프로토콜 기반
    • AppEnvironment 컨테이너를 한 번 조립
    • Environment를 통해 하위 뷰에 주입
    • 전역 상태는 역할별 Store로 분리 이 네 가지다.
  • 이 패턴을 적용하면 테스트·프리뷰·모듈 분리가 쉬워지고,
    앱이 커져도 “어디서 무엇을 쓰는지” 구조가 명확하게 유지된다.
반응형
Posted by 까칠코더
,