SwiftUI Study – 고급 DI 패턴: Environment + Container + 프로토콜로 서비스·전역 상태를 유연하게 구성하기
Dev Study/SwiftUI 2025. 12. 12. 10:12반응형
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) 패턴을 다룹니다.
- 프로토콜 기반 서비스 정의
- DI 컨테이너(Dependency Container) 구성
- 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로 분리 이 네 가지다.
- 이 패턴을 적용하면 테스트·프리뷰·모듈 분리가 쉬워지고,
앱이 커져도 “어디서 무엇을 쓰는지” 구조가 명확하게 유지된다.
반응형


