-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
Feature 기반 Clean Architecture + Observation. 계층 구조, ViewModel/View 패턴, 앱 셸과 라우팅을 다룹니다. 에러 처리 상세는 Error Handling, 모듈/타겟 구성은 Module Structure 참고.
Feature-Based Modular + Clean Architecture + Observation
View ←→ ViewModel(@Observable) → UseCase(Protocol) → Repository → DataSource
↑
DIContainer가 Protocol 구현체 주입
- Presentation → Domain → Data 단방향 의존
- 상위 계층은 하위 계층의 Protocol에만 의존 (DIP)
- Tab별 독립
NavigationStack으로 상태 보존
| 계층 | 역할 | 의존 방향 |
|---|---|---|
| Presentation | View, ViewModel | → Domain |
| Domain | UseCase, Model, Interface(Protocol) | ← Data가 구현 |
| Data | Repository, Router, DTO | Domain Protocol 구현 |
- Presentation → Domain: View/ViewModel은 UseCase Protocol에만 의존
- Domain → Data: UseCase는 Repository Protocol 사용, 구현체를 모름
- Protocol 기반 주입: DIContainer가 런타임에 구현체 결정
Tuist Feature 모듈은 레이어별로 별도 타겟이므로, 폴더도 타겟 경계와 1:1로 맞춥니다.
Features/{Feature}/
├── Project.swift # featureProject(...) 매니페스트
├── Domain/
│ ├── Sources/
│ │ ├── UseCases/ # Protocol + 구현체
│ │ ├── Models/ # Entity
│ │ └── Interfaces/ # Repository Protocol
│ └── Tests/ # includesDomainTests: true 일 때
├── Data/
│ ├── Sources/
│ │ ├── Repositories/ # Repository 구현체
│ │ ├── Router/ # Moya Router
│ │ └── DTOs/ # Request / Response DTO
│ └── Tests/
└── Presentation/
├── Sources/
│ ├── Views/ # SwiftUI View
│ ├── ViewModels/ # @Observable ViewModel
│ └── Components/ # Feature 전용 컴포넌트
└── Tests/
여러 Feature가 같은 도메인 개념(회원 프로필·권한·게시물)을 쓰기 시작하면서, 이를 한 Feature에 두면
Feature ↔ Feature 의존이 생깁니다. 그래서 교차 도메인만 Core/Domain(CoreDomain)으로 올렸습니다.
| 영역 | 내용 |
|---|---|
Member/ |
Profile, MemberProfileSummary, ChallengerInfo, UserSessionManager, 프로필 조회·동기화 UseCase |
Authorization/ |
ResourcePermission, AuthorizationUseCase — 역할 기반 권한 판정 |
Post/ |
CommunityItemModel, CommunityItemCategory 등 게시물 공용 모델 |
Feature 전용 도메인은 그대로 각 Feature의
Domain/에 둡니다. 두 개 이상의 Feature가 실제로 공유할 때만 CoreDomain으로 승격합니다.
| 원칙 | 적용 |
|---|---|
| SRP | View(렌더링) / ViewModel(상태) / UseCase(로직) / Repository(데이터) 분리 |
| OCP | Protocol 기반 설계로 기존 코드 수정 없이 새 구현체 추가 |
| LSP | Protocol 구현체는 언제든 교체 가능 (Mock / Real / Stub) |
| ISP | 큰 Protocol보다 작고 명확한 Protocol 여러 개로 분리 |
| DIP | 상위 모듈이 하위 모듈 구현체가 아닌 Protocol에 의존 |
// DIP 예시: UseCase는 Protocol에만 의존
protocol UserRepositoryProtocol {
func fetchUser(id: String) async throws -> User
}
final class FetchUserUseCase {
private let repository: UserRepositoryProtocol // 구현체가 아닌 Protocol
init(repository: UserRepositoryProtocol) {
self.repository = repository
}
}// 등록
container.register(UserRepositoryProtocol.self) { UserRepository() }
container.register(LoginUseCaseProtocol.self) {
LoginUseCase(repository: container.resolve(UserRepositoryProtocol.self))
}
// 사용
let useCase = container.resolve(LoginUseCaseProtocol.self)| API | 용도 |
|---|---|
register(_:factory:) |
타입 → 팩토리 등록 |
resolve(_:) |
해석 (미등록 시 크래시 — 배선 누락을 빌드/실행 초기에 드러냄) |
resolveIfRegistered(_:) |
등록돼 있을 때만 해석, 아니면 nil
|
resolveIfCached(_:) |
이미 생성된 인스턴스만 반환 (새로 만들지 않음) |
resetCache() |
전체 캐시 초기화 — 로그아웃 시 |
resetCache(for:) |
특정 타입만 초기화 |
-
resolve()결과는 캐싱되어 싱글톤처럼 동작합니다. - 실제 등록 코드는 앱 타겟에 Feature별 파일로 분리되어 있습니다:
UMCApp/Sources/DIContainer+{Auth,Home,Notice,Activity,Community,MyPage,BusinessCard,Maintenance,…}.swift - SwiftUI에는
DIEnvironmentKey를 통해.environment로 주입합니다.
앱 진입 흐름은 AppFlowViewModel(@Observable, 절대 규칙 #1의 명시적 예외)이 소유하는
AppFlowState로 표현합니다. AppRootView가 이 상태에 따라 화면 트리를 교체합니다.
bootstrap → login → signUp(...) → pendingApproval → main
원격 점검(킬스위치)·강제 업데이트 오버레이(Maintenance 모듈)는 이 흐름 위에 겹쳐 표시됩니다.
기존 AppRouter + Feature Router 조합은 **탭별 NavigationPath를 들고 있는 전역 PathStore**로
대체되었습니다. CoreRouting은 어떤 Feature에도 의존하지 않습니다.
@Observable
public final class PathStore {
public var selectedTab: NavigationTab = .home
public subscript(tab: NavigationTab) -> NavigationPath { get set }
public func push(_ destination: some Hashable, on tab: NavigationTab)
public func depth(of tab: NavigationTab) -> Int
public func isAtRoot(_ tab: NavigationTab) -> Bool
}
public enum NavigationTab: CaseIterable, Identifiable, Hashable, Sendable {
case home, notice, activity, community, mypage
}왜 타입 소거(NavigationPath)인가
경로를 [SomeDestination] 같은 단일 enum 배열로 두면 그 enum이 모든 Feature의 도메인 타입에
의존하게 되고, 곧 Core → Feature 역방향 의존이 생깁니다. NavigationPath는 서로 다른
Hashable 타입을 한 스택에 섞어 담을 수 있으므로:
- 각 Feature가 자기 목적지 타입을 자기 모듈에서 소유하고
- 그 Feature의 루트 화면이
.navigationDestination(for:)으로 자기 탭 스택에 직접 등록합니다. - 목적지 화면을
public으로 열 필요도 없어집니다.
NavigationTab에는 제목·SF Symbol 같은 표시 정보를 두지 않습니다. 그건 앱 셸의 관심사라
앱 타겟의 NavigationTab+Presentation.swift 확장에서 붙입니다.
- URL Scheme
umc://(Project.swift의CFBundleURLTypes) -
AppDeepLink가 URL을 파싱하고DeepLinkStore가 처리 시점까지 보관 → 셸이PathStore의 탭·경로로 반영 - 예: 스레드 공유
umc://thread/{id}, 명함 QR 딥링크
절대 규칙 #1(“@Observable만”)의 예외가 아니라, 앱 생명주기에 묶인 전역 상태라서
Feature ViewModel과 다르게 앱 셸이 소유하고 .environment로 주입하는 타입들입니다.
| 타입 | 모듈 | 역할 |
|---|---|---|
AppFlowViewModel |
앱 타겟 | 진입 흐름 상태 머신 |
PathStore |
CoreRouting |
탭별 네비게이션 경로 |
ErrorHandler |
UMCFoundation |
전역 Alert 에러 |
UserSessionManager |
CoreDomain |
로그인 세션·프로필 캐시 |
이 넷은 액터 격리를 두지 않는 동일한 정책을 씁니다(실제 접근은 모두 메인 액터인 SwiftUI 뷰). 격리 정책을 바꾼다면 넷을 함께 다뤄야 합니다.
@Observable
final class ChallengerAttendanceViewModel {
private var container: DIContainer
private var useCase: ChallengerAttendanceUseCaseProtocol
// Loadable로 비동기 상태 관리
private(set) var attendanceState: Loadable<Attendance> = .idle
@MainActor
func attendanceBtnTapped(userId: String) async {
attendanceState = .loading
do {
let result = try await useCase.requestGPSAttendance(...)
attendanceState = .loaded(result)
} catch let error as DomainError {
attendanceState = .failed(.domain(error)) // 인라인 에러
} catch {
errorHandler.handle(error, context: ...) // Alert 에러
}
}
}-
@Observable매크로 사용 (NOT@StateObject,@ObservedObject,@Published) - 예외: 위 “전역
@Observable관리자” 표의 타입들
struct ChallengerAttendanceView: View {
@State private var viewModel: ChallengerAttendanceViewModel
init(container: DIContainer, ...) {
_viewModel = State(initialValue: ChallengerAttendanceViewModel(
container: container,
...
))
}
var body: some View { ... }
}-
@State private var viewModel패턴으로 소유권 명시 - Action 기반 단방향 데이터 흐름
관련 문서: Coding Conventions · Error Handling · Module Structure