SwiftUI 기반 iOS 애플리케이션
- Clean Architecture + Hexagonal (Ports & Adapters) Architecture
- Anti-corruption Layer (ACL): 컨텍스트 간 경계 보호
- Repository & Data Source 분리: 명확한 Data Layer 책임 분리
┌────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Coordinator │ │ ViewModel │ │ Views │ │
│ │ (Flow) │──│ (State) │──│ (UI) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Entities │ │ UseCases │ │ Errors │ │
│ │ (Models) │ │ (Logic) │ │ (SomniaryError)│ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Repository │ │ Domain Mapping │ │
│ │ (AuthReposable) │◄──────│ Error Mapping │ │
│ └──────────────────────┘ └──────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Network │ │ Storage │ │ External │ │
│ │ (HTTP Client)│ │ (Keychain) │ │ (Apple Auth) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
Somniary/
│
├── 🎯 Modules/ # 기능 모듈 (Feature Modules)
│ ├── Login/
│ │ ├── Coordinator/ # 화면 전환 로직
│ │ ├── ViewModel/ # 상태 관리 & 비즈니스 로직 연결
│ │ ├── Views/ # UI 컴포넌트
│ │ ├── Reducer/ # 상태 변경 순수 함수
│ │ ├── Effect/ # Side Effect 실행
│ │ ├── Domain/ # 모듈별 도메인 엔티티 & 에러
│ │ └── DataSources/ # Repository 구현체
│ │
│ ├── Settings/
│ └── TabBar/
│
├── 🧩 Domain/ # 공통 도메인 레이어
│ ├── Entities/ # 도메인 엔티티
│ ├── UseCases/ # 비즈니스 유스케이스
│ └── Errors/ # 도메인 에러 정의
│ ├── SomniaryError.swift # 범용 에러 구조
│ ├── DomainError.swift # 에러 프로토콜
│ └── ErrorContext.swift # 에러 컨텍스트 정보
│
├── 🌐 Network/ # Infrastructure - 네트워크
│ ├── Interfaces/ # 프로토콜 (Ports)
│ │ ├── SomniaryNetworking.swift
│ │ └── Endpoint.swift
│ ├── Endpoints/ # API 엔드포인트 정의
│ ├── Entities/ # 네트워크 응답 DTO
│ ├── SomniaryHTTPClient.swift # HTTP 클라이언트 구현
│ └── TransportError.swift # 네트워크 에러
│
├── 🧰 Common/ # 공통 유틸리티
│ ├── TokenRepository/ # 토큰 관리
│ ├── KeychainStorage.swift # Keychain 저장소
│ ├── LocalStorage.swift # UserDefaults 저장소
│ └── ViewModelType.swift # ViewModel 프로토콜
│
├── 🧭 Coordinator/ # 화면 전환 패턴
│ ├── Coordinator.swift # 기본 프로토콜
│ ├── FlowCoordinator.swift # Flow 관리
│ └── NavigationFlowView.swift # SwiftUI 통합
│
├── 🎨 SharedUI/ # 공통 UI 컴포넌트
│ ├── BaseButton.swift
│ ├── TextInput.swift
│ └── Modifiers/
│
└── 🔧 Helpers/ # 확장 & 유틸리티
├── Array+Extensions.swift
├── Publisher+Extensions.swift
└── Result+Extensions.swift
┌─────────────────────────────────────────────────────────────┐
│ Dependency Rule │
│ │
│ Presentation ──► Domain ──► Data ──► Infrastructure │
│ │
│ 역방향 의존 금지 (Dependency Inversion Principle) │
└─────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌────────────────┐ ┌───────────────┐ ┌───────────────┐
│ LoginView │─────►│ LoginViewModel │─────►│ AuthReposable │─────►│ SomniaryHTTP │
│ (SwiftUI) │ │ (State) │ │ (Protocol) │ │ Client │
└──────────────┘ └────────────────┘ └───────────────┘ └───────────────┘
│
▼
┌─────────────────┐
│ LoginEffectPlan │ ◄─── Side Effect 분리
│ (Async Tasks) │
└─────────────────┘
프레젠테이션 레이어는 MVI 아키텍처를 기반으로 구현되어 있습니다.
┌───────────────────────────────────────────────────────────────────┐
│ MVI Cycle │
│ │
│ ┌───────────┐ Intent ┌────────────┐ │
│ │ View │────────────►│ Reducer │ │
│ │ (SwiftUI) │ │ (Pure) │ │
│ └───────────┘ └────────────┘ │
│ ▲ │ │ │
│ │ │ │ │
│ render New State│ │ Effect Plan 생성 │
│ │ │ │ │
│ │ ▼ ▼ │
│ ┌──────────┐ ┌───────────────────┐ │
│ │ State │◄────────│ ViewModel │ │
│ └──────────┘ 할당 │ (Effect 분류/처리) │ │
│ └───────────────────┘ │
│ │ │ │
│ Navigation/ │ │
│ UI Effect API Effect │
│ │ │ │
│ ▼ ▼ │
│ Coordinator ┌──────────────┐ │
│ / UIEvent │ Executor │ │
│ │ (API, 로깅 등) │ │
│ └──────────────┘ │
│ │ │
│ │ │
│ 결과를 Intent로 환류 │
│ │ │
│ └──────►Intent (재순환) │
│ │
└───────────────────────────────────────────────────────────────────┘
| 요소 | 역할 | 구현 예시 |
|---|---|---|
| Model | 불변 상태 | LoginState |
| View | UI 렌더링 & Intent 발행 | LoginView |
| Intent | 사용자/시스템 이벤트 | LoginIntent (User, System, Navigation) |
| Reducer | 순수 함수: (State, Intent) → (State, [Effect]) |
combinedReducer |
| Effect | Side Effect 데이터 | LoginEffectPlan |
| Executor | Effect 실행기 | LoginExecutor |
Intent는 시스템에서 발생하는 모든 이벤트를 표현하며, 출처에 따라 분류됩니다.
| 유형 | 역할 | 예시 |
|---|---|---|
| User Intent | 사용자 인터랙션 | .emailChanged(), .submitLogin(), .signUpTapped() |
| Lifecycle Intent | View 생명주기 | .appeared, .disappeared |
| System External | 외부 시스템 이벤트 | .appleLoginRequest, .networkReachable(), .deepLink() |
| System Internal | Effect 실행 결과 | .loginResponse(), .verifyResponse() |
| Navigation Intent | 화면 전환 요청 | .routeToHome, .routeToSignUp |
// LoginIntent.swift - Intent 계층 구조
enum LoginIntent {
case user(UserIntent) // 사용자 액션
case lifecycle(LifecycleIntent) // View 생명주기
case systemExtenral(SystemExtenralIntent) // 외부 시스템
case systemInternal(SystemInternalIntent) // Effect 결과
case navigation(NavigationIntent) // 네비게이션
}
// 각 Intent는 명확한 출처를 가짐
enum UserIntent {
case emailChanged(String)
case submitLogin
// ...
}
enum SystemInternalIntent {
case loginResponse(Result<VoidResponse, AuthError>)
case verifyResponse(Result<TokenEntity, AuthError>)
// ...
}// 1. User가 로그인 버튼 클릭
View: Button("로그인") { viewModel.send(.user(.submitLogin)) }
// 2. Reducer가 순수 함수로 새로운 상태와 Effect 반환
Reducer: (oldState, .user(.submitLogin))
→ (newState.isLoading = true,
[.verify(email, otp, requestId)])
// 3. Executor가 Effect 실행 (API 호출)
Executor: .verify(...)
→ Repository.verify(...)
→ Intent 환류: .systemInternal(.verifyResponse(result))
// 4. 다시 Reducer로 순환
Reducer: (state, .systemInternal(.verifyResponse(.success(token))))
→ (state, [.storeToken(token), .route(.navigateHome)])- 단방향 데이터 흐름: View → Intent → Reducer → State → View
- 순수 함수 Reducer: 테스트 용이, 예측 가능
- Side Effect 격리: Effect로 분리하여 추적 가능
- 시간 여행 디버깅: 모든 Intent와 State 변화 기록 가능
FlowCoordinator
└── 화면 전환 로직을 ViewModel에서 분리
└── 중앙화된 네비게이션 관리Protocol (AuthReposable) # Domain Layer
↓ implements
Repository (RemoteAuthRepository) # Data Layer
↓ uses
Data Source (SomniaryHTTPClient) # Infrastructure LayerACL 패턴을 통해 인프라 계층의 에러를 도메인 에러로 변환하여 비즈니스 로직을 보호합니다.
┌─────────────────────────────────────────────────────────────┐
│ Error Flow & Mapping │
└─────────────────────────────────────────────────────────────┘
Infrastructure Layer
├─ TransportError # 네트워크 에러 (HTTP, 연결 등)
└─ Data Source Error # 디코딩, 변환 에러
│
│ Repository가 ACL 역할 수행
▼
Domain Layer
└─ DomainError # 비즈니스 컨텍스트 에러
(SomniaryError) # - AuthError
# - UserError 등
│
│ ViewModel/View가 사용자 친화적으로 변환
▼
Presentation Layer
└─ User Message # "이메일을 확인해주세요"
// RemoteAuthRepository.swift
func verify(email: String, otpCode: String) async throws -> TokenEntity {
let result = await client.request(.verify(email: email, otpCode: otpCode))
switch result {
case .success(let response):
do {
// Data Source Error 가능 (디코딩 실패 등)
let data = try JSONDecoder().decode(NetAuth.Verify.Response.self,
from: response.body)
return TokenEntity(accessToken: data.accessToken,
refreshToken: data.refreshToken)
} catch {
// 디코딩 에러 → Domain Error 매핑
throw mapToDomainError(error)
}
case .failure(let transportError):
// Transport Error → Domain Error 매핑
throw mapToDomainError(transportError)
}
}
private func mapToDomainError(_ error: Error) -> DomainError {
// Infrastructure 에러를 Domain 에러로 변환
// 외부 시스템 변경이 도메인에 영향을 주지 않도록 보호
}| 계층 | 에러 타입 | 책임 | 예시 |
|---|---|---|---|
| Infrastructure | TransportError |
HTTP/네트워크 에러 | .networkFailure, .unauthorized(401) |
| Data | Decoding/Parsing Error | 데이터 변환 실패 | DecodingError, DateParsingError |
| Domain | DomainError |
비즈니스 에러 | AuthError.invalidCredentials |
| Presentation | User Message | 사용자 친화적 메시지 | "로그인 정보가 올바르지 않습니다" |
- 외부 의존성 격리: 네트워크 라이브러리 변경이 도메인에 영향 없음
- 명확한 에러 컨텍스트: 비즈니스 관점의 에러 표현
- 테스트 용이성: 도메인 레이어는 Infrastructure 에러를 몰라도 됨