Skip to content

Architecture

JEONG edited this page Aug 24, 2026 · 2 revisions

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가 런타임에 구현체 결정

Feature 폴더 구조

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/

공유 도메인 — CoreDomain

여러 Feature가 같은 도메인 개념(회원 프로필·권한·게시물)을 쓰기 시작하면서, 이를 한 Feature에 두면 Feature ↔ Feature 의존이 생깁니다. 그래서 교차 도메인만 Core/Domain(CoreDomain)으로 올렸습니다.

영역 내용
Member/ Profile, MemberProfileSummary, ChallengerInfo, UserSessionManager, 프로필 조회·동기화 UseCase
Authorization/ ResourcePermission, AuthorizationUseCase — 역할 기반 권한 판정
Post/ CommunityItemModel, CommunityItemCategory 등 게시물 공용 모델

Feature 전용 도메인은 그대로 각 Feature의 Domain/에 둡니다. 두 개 이상의 Feature가 실제로 공유할 때만 CoreDomain으로 승격합니다.

SOLID 적용

원칙 적용
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
    }
}

DIContainer (CoreDI)

// 등록
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로 주입합니다.

앱 셸과 라우팅

AppFlow 상태 머신

앱 진입 흐름은 AppFlowViewModel(@Observable, 절대 규칙 #1의 명시적 예외)이 소유하는 AppFlowState로 표현합니다. AppRootView가 이 상태에 따라 화면 트리를 교체합니다.

bootstrap → login → signUp(...) → pendingApproval → main

원격 점검(킬스위치)·강제 업데이트 오버레이(Maintenance 모듈)는 이 흐름 위에 겹쳐 표시됩니다.

PathStore (CoreRouting)

기존 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.swiftCFBundleURLTypes)
  • AppDeepLink가 URL을 파싱하고 DeepLinkStore가 처리 시점까지 보관 → 셸이 PathStore의 탭·경로로 반영
  • 예: 스레드 공유 umc://thread/{id}, 명함 QR 딥링크

전역 @Observable 관리자

절대 규칙 #1(“@Observable만”)의 예외가 아니라, 앱 생명주기에 묶인 전역 상태라서 Feature ViewModel과 다르게 앱 셸이 소유하고 .environment로 주입하는 타입들입니다.

타입 모듈 역할
AppFlowViewModel 앱 타겟 진입 흐름 상태 머신
PathStore CoreRouting 탭별 네비게이션 경로
ErrorHandler UMCFoundation 전역 Alert 에러
UserSessionManager CoreDomain 로그인 세션·프로필 캐시

이 넷은 액터 격리를 두지 않는 동일한 정책을 씁니다(실제 접근은 모두 메인 액터인 SwiftUI 뷰). 격리 정책을 바꾼다면 넷을 함께 다뤄야 합니다.

Observation 패턴

ViewModel 규칙

@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 관리자” 표의 타입들

View 규칙

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

Clone this wiki locally