한 줄 요약: 개인 프로젝트부터 팀 프로젝트까지, DiContainer로 확장 가능한 깔끔한 구조를 만들어보세요.
안녕하세요! iOS 개발자 Roy입니다. 프로젝트가 커질수록 코드가 복잡해지고 관리하기 어려워지죠. 오늘은 DiContainer를 활용해서 처음부터 깔끔하게 구조화된 프로젝트를 만드는 방법을 공유해드리겠습니다. 개인 프로젝트든 팀 프로젝트든 상관없이 바로 적용할 수 있는 실용적인 가이드입니다!
// ❌ 전형적인 문제: 모든 것이 하나에 섞여있음
class ViewController: UIViewController {
// 네트워킹
private func fetchUserData() {
URLSession.shared.dataTask(with: URL(string: "https://api.com/user")!) { data, response, error in
// JSON 파싱
let user = try? JSONDecoder().decode(User.self, from: data!)
// 데이터베이스 저장
CoreDataManager.shared.save(user)
// 분석 이벤트 전송
Analytics.track("user_loaded")
// UI 업데이트
DispatchQueue.main.async {
self.nameLabel.text = user?.name
}
}.resume()
}
}
이런 코드의 문제점:
// 프로젝트 구조 예시
MyApp/
├── App/
│ ├── AppDelegate.swift
│ ├── SceneDelegate.swift
│ └── DIContainer+Bootstrap.swift
├── Core/
│ ├── Network/
│ ├── Database/
│ └── Analytics/
├── Features/
│ ├── UserProfile/
│ ├── ProductList/
│ └── Cart/
└── Shared/
├── Models/
├── Extensions/
└── Utils/
// ✅ 계층별 의존성 분리
protocol NetworkServiceProtocol {
func fetchUser(id: String) async throws -> User
}
protocol DatabaseServiceProtocol {
func save<T: Codable>(_ object: T) async throws
func load<T: Codable>(_ type: T.Type, id: String) async throws -> T?
}
protocol AnalyticsServiceProtocol {
func track(_ event: String, properties: [String: Any]?)
}
// Core 모듈: 구체적인 구현
class NetworkService: NetworkServiceProtocol {
func fetchUser(id: String) async throws -> User {
// 실제 네트워크 구현
}
}
class DatabaseService: DatabaseServiceProtocol {
func save<T: Codable>(_ object: T) async throws {
// Core Data 또는 다른 DB 구현
}
func load<T: Codable>(_ type: T.Type, id: String) async throws -> T? {
// 데이터 로드 구현
}
}
class AnalyticsService: AnalyticsServiceProtocol {
func track(_ event: String, properties: [String: Any]? = nil) {
// Firebase, Mixpanel 등 구현
}
}
// ✅ UserProfile 모듈
class UserProfileViewModel: ObservableObject {
@Inject private var networkService: NetworkServiceProtocol
@Inject private var databaseService: DatabaseServiceProtocol
@Inject private var analytics: AnalyticsServiceProtocol
@Published var user: User?
@Published var isLoading = false
func loadUser(id: String) async {
await MainActor.run { isLoading = true }
defer { Task { await MainActor.run { isLoading = false } } }
do {
// 1. 캐시에서 먼저 확인
if let cachedUser = try? await databaseService.load(User.self, id: id) {
await MainActor.run { user = cachedUser }
}
// 2. 네트워크에서 최신 데이터 가져오기
let freshUser = try await networkService.fetchUser(id: id)
// 3. 캐시 업데이트
try await databaseService.save(freshUser)
// 4. UI 업데이트
await MainActor.run { user = freshUser }
// 5. 분석 이벤트
await analytics.track("user_profile_loaded", properties: ["user_id": id])
} catch {
// 에러 처리
await analytics.track("user_profile_load_error", properties: ["error": error.localizedDescription])
}
}
}
struct UserProfileView: View {
@StateObject private var viewModel = UserProfileViewModel()
let userId: String
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView("사용자 정보 로딩 중...")
} else if let user = viewModel.user {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: user.profileImageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
}
.frame(width: 100, height: 100)
.clipShape(Circle())
Text(user.name)
.font(.title)
.fontWeight(.bold)
Text(user.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Text("사용자 정보를 불러올 수 없습니다")
.foregroundColor(.red)
}
}
.task {
await viewModel.loadUser(id: userId)
}
}
}
enum Environment {
case development
case staging
case production
static var current: Environment {
#if DEBUG
return .development
#elseif STAGING
return .staging
#else
return .production
#endif
}
var baseURL: String {
switch self {
case .development: return "https://dev-api.myapp.com"
case .staging: return "https://staging-api.myapp.com"
case .production: return "https://api.myapp.com"
}
}
var enableAnalytics: Bool {
switch self {
case .development: return false
case .staging: return false
case .production: return true
}
}
}
// 환경별 서비스 구현
class DevelopmentAnalytics: AnalyticsServiceProtocol {
func track(_ event: String, properties: [String: Any]? = nil) {
print("🔍 [Analytics] \(event): \(properties ?? [:])")
}
}
class ProductionAnalytics: AnalyticsServiceProtocol {
func track(_ event: String, properties: [String: Any]? = nil) {
// Firebase Analytics 등 실제 구현
Analytics.logEvent(event, parameters: properties)
}
}
// DI 컨테이너 부트스트랩
extension DependencyContainer {
static func bootstrapApp() async {
await DependencyContainer.bootstrap { container in
// Core Services
await setupCoreServices(container)
// Feature Services
await setupFeatureServices(container)
// Development Tools (디버그 빌드에만)
#if DEBUG
await setupDevelopmentTools(container)
#endif
}
}
private static func setupCoreServices(_ container: DependencyContainer) async {
// 네트워크 서비스
container.register(NetworkServiceProtocol.self, scope: .singleton) {
NetworkService(baseURL: Environment.current.baseURL)
}
// 데이터베이스 서비스
container.register(DatabaseServiceProtocol.self, scope: .singleton) {
DatabaseService()
}
// 분석 서비스 (환경별 다른 구현)
container.register(AnalyticsServiceProtocol.self, scope: .singleton) {
if Environment.current.enableAnalytics {
return ProductionAnalytics()
} else {
return DevelopmentAnalytics()
}
}
}
private static func setupFeatureServices(_ container: DependencyContainer) async {
// UserProfile 관련 서비스
container.register(UserServiceProtocol.self) {
UserService()
}
// Product 관련 서비스
container.register(ProductServiceProtocol.self) {
ProductService()
}
// Cart 관련 서비스
container.register(CartServiceProtocol.self, scope: .scoped("user_session")) {
CartService()
}
}
#if DEBUG
private static func setupDevelopmentTools(_ container: DependencyContainer) async {
// 개발용 도구들
container.register(NetworkLoggerProtocol.self) {
ConsoleNetworkLogger()
}
container.register(MemoryMonitorProtocol.self) {
MemoryMonitor()
}
}
#endif
}
// AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// DI 컨테이너 초기화 (비동기)
Task {
await DependencyContainer.bootstrapApp()
print("✅ DI Container 초기화 완료")
}
return true
}
}
// SwiftUI App
@main
struct MyApp: App {
init() {
// DI 컨테이너 초기화
Task {
await DependencyContainer.bootstrapApp()
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// Mock 구현들
class MockNetworkService: NetworkServiceProtocol {
var mockUsers: [String: User] = [:]
var shouldFailWithError: Error?
func fetchUser(id: String) async throws -> User {
if let error = shouldFailWithError {
throw error
}
return mockUsers[id] ?? User(id: id, name: "Mock User", email: "mock@test.com")
}
}
class MockDatabaseService: DatabaseServiceProtocol {
private var storage: [String: Any] = [:]
func save<T: Codable>(_ object: T) async throws {
let key = String(describing: T.self)
storage[key] = object
}
func load<T: Codable>(_ type: T.Type, id: String) async throws -> T? {
let key = String(describing: type)
return storage[key] as? T
}
}
class MockAnalyticsService: AnalyticsServiceProtocol {
var trackedEvents: [(event: String, properties: [String: Any]?)] = []
func track(_ event: String, properties: [String: Any]? = nil) {
trackedEvents.append((event: event, properties: properties))
}
}
extension DependencyContainer {
static func setupForTesting() async {
await DependencyContainer.resetForTesting()
await DependencyContainer.bootstrap { container in
// Mock 서비스들 등록
container.register(NetworkServiceProtocol.self) {
MockNetworkService()
}
container.register(DatabaseServiceProtocol.self) {
MockDatabaseService()
}
container.register(AnalyticsServiceProtocol.self) {
MockAnalyticsService()
}
}
}
}
// 테스트 예시
class UserProfileViewModelTests: XCTestCase {
var viewModel: UserProfileViewModel!
var mockNetworkService: MockNetworkService!
var mockAnalytics: MockAnalyticsService!
@MainActor
override func setUp() async throws {
await DependencyContainer.setupForTesting()
// Mock 서비스 참조 획득
mockNetworkService = UnifiedDI.resolve(NetworkServiceProtocol.self) as? MockNetworkService
mockAnalytics = UnifiedDI.resolve(AnalyticsServiceProtocol.self) as? MockAnalyticsService
viewModel = UserProfileViewModel()
}
@MainActor
func testLoadUserSuccess() async throws {
// Given
let expectedUser = User(id: "123", name: "Test User", email: "test@example.com")
mockNetworkService.mockUsers["123"] = expectedUser
// When
await viewModel.loadUser(id: "123")
// Then
XCTAssertEqual(viewModel.user?.name, "Test User")
XCTAssertFalse(viewModel.isLoading)
// Analytics 호출 확인
let trackedEvents = mockAnalytics.trackedEvents
XCTAssertEqual(trackedEvents.count, 1)
XCTAssertEqual(trackedEvents.first?.event, "user_profile_loaded")
}
@MainActor
func testLoadUserFailure() async throws {
// Given
mockNetworkService.shouldFailWithError = URLError(.notConnectedToInternet)
// When
await viewModel.loadUser(id: "123")
// Then
XCTAssertNil(viewModel.user)
XCTAssertFalse(viewModel.isLoading)
// 에러 분석 이벤트 확인
let errorEvents = mockAnalytics.trackedEvents.filter { $0.event == "user_profile_load_error" }
XCTAssertEqual(errorEvents.count, 1)
}
}
# .github/workflows/ios-ci.yml
name: iOS CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
name: Test
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
- name: Cache Swift Package Manager
uses: actions/cache@v3
with:
path: |
~/Library/Caches/org.swift.swiftpm/
.build
key: ${{ runner.os }}-spm-${{ hashFiles('Package.swift') }}
- name: Build and Test
run: |
# 프로젝트 빌드
xcodebuild -scheme MyApp -configuration Debug build-for-testing
# 테스트 실행 (DI 컨테이너 포함)
xcodebuild -scheme MyApp -configuration Debug test-without-building \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
-resultBundlePath TestResults
- name: Upload Test Results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: TestResults
// Build Configuration별 설정
#if DEBUG
let DIContainer_LogLevel = "debug"
let DIContainer_EnableMetrics = true
#elseif STAGING
let DIContainer_LogLevel = "info"
let DIContainer_EnableMetrics = true
#else
let DIContainer_LogLevel = "error"
let DIContainer_EnableMetrics = false
#endif
// 빌드 타임에 DI 검증
#if DEBUG
extension DependencyContainer {
static func validateConfiguration() {
// 모든 등록된 서비스의 의존성 그래프 검증
Task {
let validator = DependencyValidator()
let results = await validator.validateAll()
for result in results {
if case .failure(let error) = result {
print("⚠️ DI Validation Error: \(error)")
}
}
}
}
}
#endif
// ✅ 좋은 예시
class GoodViewModel: ObservableObject {
// 1. Protocol 타입으로 의존성 주입
@Inject private var userService: UserServiceProtocol
@Inject private var analytics: AnalyticsServiceProtocol
// 2. Published 프로퍼티로 상태 관리
@Published var users: [User] = []
@Published var isLoading = false
// 3. async 메서드로 비즈니스 로직 구현
func loadUsers() async {
await MainActor.run { isLoading = true }
defer { Task { await MainActor.run { isLoading = false } } }
do {
let loadedUsers = try await userService.getAllUsers()
await MainActor.run { users = loadedUsers }
await analytics.track("users_loaded", properties: ["count": loadedUsers.count])
} catch {
await analytics.track("users_load_failed", properties: ["error": error.localizedDescription])
}
}
}
// ❌ 나쁜 예시
class BadViewModel: ObservableObject {
// 1. 구체 클래스에 직접 의존 (테스트 어려움)
private let networkManager = NetworkManager()
private let coreDataManager = CoreDataManager()
// 2. 동기 메서드로 비동기 작업 (블로킹)
func loadUsers() {
// 메인 스레드 블로킹...
let semaphore = DispatchSemaphore(value: 0)
var result: [User] = []
networkManager.fetchUsers { users in
result = users
semaphore.signal()
}
semaphore.wait()
self.users = result
}
}
# DI 관련 코드 리뷰 체크리스트
## 의존성 주입
- [ ] Protocol 타입으로 의존성 주입했는가?
- [ ] @Inject 어노테이션을 적절히 사용했는가?
- [ ] 순환 의존성은 없는가?
## 비동기 처리
- [ ] async/await를 적절히 사용했는가?
- [ ] MainActor.run으로 UI 업데이트를 보호했는가?
- [ ] Task나 TaskGroup을 적절히 활용했는가?
## 테스트 가능성
- [ ] Mock 객체를 만들기 쉬운 구조인가?
- [ ] 비즈니스 로직이 UI로부터 분리되어 있는가?
## 성능
- [ ] 불필요한 싱글톤 패턴 사용은 없는가?
- [ ] 메모리 누수 가능성은 없는가?
- [ ] 적절한 스코프를 사용했는가?
## 컨벤션
- [ ] 네이밍 컨벤션을 따랐는가?
- [ ] 에러 처리가 적절한가?
- [ ] 로깅/분석 이벤트가 적절한가?
// File Template: ViewModel.xctemplate
import SwiftUI
import DiContainer
class ___FILEBASENAME___ViewModel: ObservableObject {
@Inject private var <#service#>: <#ServiceProtocol#>
@Inject private var analytics: AnalyticsServiceProtocol
@Published var <#data#>: [<#Model#>] = []
@Published var isLoading = false
@Published var errorMessage: String?
func <#loadData#>() async {
await MainActor.run {
isLoading = true
errorMessage = nil
}
defer { Task { await MainActor.run { isLoading = false } } }
do {
let result = try await <#service#>.<#fetchData#>()
await MainActor.run { <#data#> = result }
await analytics.track("<#data_loaded#>", properties: ["count": result.count])
} catch {
let message = error.localizedDescription
await MainActor.run { errorMessage = message }
await analytics.track("<#data_load_failed#>", properties: ["error": message])
}
}
}
// Step 1: 새로운 기능부터 적용
class NewFeatureViewModel: ObservableObject {
@Inject private var service: NewFeatureServiceProtocol
// 새로운 기능은 처음부터 DI 적용
}
// Step 2: 기존 코드 리팩토링
class LegacyViewController: UIViewController {
// 기존 코드는 점진적으로 변경
@Inject private var userService: UserServiceProtocol
// 기존 메서드들을 async로 변환하면서 DI 적용
func loadUserProfile() async {
let user = try? await userService.getCurrentUser()
// ...
}
}
#if DEBUG
extension DependencyContainer {
static func enablePerformanceMonitoring() {
// DI 해결 시간 측정
let monitor = DIPerformanceMonitor()
monitor.startMonitoring()
}
}
class DIPerformanceMonitor {
func startMonitoring() {
// 의존성 해결 성능 추적
NotificationCenter.default.addObserver(
forName: .DIResolutionCompleted,
object: nil,
queue: .main
) { notification in
if let duration = notification.userInfo?["duration"] as? TimeInterval {
print("📊 DI Resolution took \(duration)ms")
}
}
}
}
#endif
DiContainer를 활용한 모듈화 전략으로 얻을 수 있는 것들:
작은 프로젝트부터 시작해서 점진적으로 적용해보세요. DiContainer와 함께 더 깔끔하고 유지보수하기 쉬운 앱을 만들어나가실 수 있을 거예요! 🚀
다음 포스트 예고: DiContainer로 구현하는 Clean Architecture - 계층별 의존성 관리
궁금한 점이나 실제 적용 과정에서 문제가 발생하면 언제든 GitHub에 남겨주세요!