Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e3356d7
[#57] Cloth 관련 이미지 API 수정
Hrepay Jan 31, 2026
b802ab2
[#57] 내정보 Email 필드 추가
Hrepay Jan 31, 2026
b9a544d
[#57] 프로젝트 아키텍처 md 파일 생성
Hrepay Jan 31, 2026
9b70ed6
[#57] 로그아웃 API 연결 및 화면 전환 완료
Hrepay Jan 31, 2026
9c410ac
[#57] CommentView UI 및 API 연결 수정
Hrepay Jan 31, 2026
23b70c6
[#57] isMine이 true일 때만 더보기 버튼 활성화
Hrepay Jan 31, 2026
cca4af3
[#57] 일별 기록 조회 isMine 연결 완료
Hrepay Jan 31, 2026
68c2dfe
[#57] 마이페이지 캘린더에 월별 기록 API 연결
Hrepay Jan 31, 2026
b8cb620
[#57] 마이페이지 달력조회 API 연결
Hrepay Jan 31, 2026
825543c
[#57] 달력의 날짜셀 UI 수정
Hrepay Jan 31, 2026
4d8426a
[#57] 설정 아이콘 수정
Hrepay Feb 1, 2026
9a40650
[#57] 설정 리스트 화면 연결
Hrepay Feb 1, 2026
e702047
[#57] 좋아요 한 기록 API 연결
Hrepay Feb 1, 2026
95ba15e
[#57] 좋아요 한 기록에서 FeedDetailView로 연결 완료
Hrepay Feb 1, 2026
f295d24
[#57] 내가 남긴 댓글 API 연결완료
Hrepay Feb 1, 2026
c116545
[#57] 내가 남긴 댓글에서 기록으로 바로 연결 완료
Hrepay Feb 1, 2026
62ad572
[#57] 아키텍처 구조에 맞게 수정
Hrepay Feb 1, 2026
99b2474
[#57] 차단한 계정 UI 및 API 연결
Hrepay Feb 1, 2026
0f9c714
[#57] 경고 1차 수정
Hrepay Feb 1, 2026
fc082f9
[#57] 회원 탈퇴 API 연결
Hrepay Feb 1, 2026
59c6894
[#57] 회원 탈퇴 구조 개선
Hrepay Feb 3, 2026
73d45fe
[#57] 탈퇴 플로우 UX 개선
Hrepay Feb 3, 2026
76ad5a6
[#57] 설정 리스트 여백도 터치 반응하게 수정
Hrepay Feb 3, 2026
67d56f6
[#57] 좋아요한 한 기록 좋아요 버튼 활성화
Hrepay Feb 3, 2026
fed1687
[#57] 설정에서 피드로 이동하는 플로우 추가
Hrepay Feb 3, 2026
d7604d0
[#57] 코드 리뷰 피드백 반영: DI 싱글톤화, 중복 코드 제거, 디버깅 로그 정리
Hrepay Feb 3, 2026
4663217
[#57] authDIContainer 호출 프로퍼티 변경
Hrepay Feb 3, 2026
f9f5d4c
[#57] 내 댓글 조회 API 수정
Hrepay Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Architecture Documentation

이 문서는 **Codive-iOS** 프로젝트의 아키텍처 및 디자인 패턴에 대한 개요를 제공합니다.
이 프로젝트는 **SwiftUI**를 기반으로 하며, **MVVM (Model-View-ViewModel)** 패턴과 **Clean Architecture** 원칙을 따르고 있습니다. 또한 **DI Container**를 통한 의존성 주입과 **Router** 패턴을 통한 화면 전환 처리를 적용하여 모듈 간의 결합도를 낮추고 유지보수성을 높였습니다.

---

## 🏗 Architectural Overview

전체적인 구조는 **Clean Architecture**의 계층화된 접근 방식을 따르며, 데이터의 흐름은 단방향으로 관리됩니다.

### Core Principles
1. **관심사의 분리 (Separation of Concerns):** 각 레이어는 명확한 역할을 가지며 서로 독립적으로 동작합니다.
2. **의존성 규칙 (Dependency Rule):** 의존성은 항상 **안쪽(Domain Layer)**을 향해야 합니다. Presentation이나 Data 레이어는 Domain 레이어를 알지만, Domain 레이어는 외부 레이어를 알지 못합니다.
3. **테스트 용이성 (Testability):** 비즈니스 로직(Domain)은 UI나 프레임워크와 분리되어 있어 독립적으로 테스트가 가능합니다.

---

## 📂 Layer Structure (계층 구조)

각 Feature(기능)는 다음과 같은 3개의 주요 레이어로 구성됩니다.

### 1. Domain Layer (Inner Circle)
가장 안쪽에 위치하며, 비즈니스 로직을 담당합니다. 외부 라이브러리나 UI 프레임워크(SwiftUI, UIKit 등)에 의존하지 않는 순수 Swift 코드로 작성됩니다.
* **Entities:** 앱의 핵심 데이터 모델.
* **UseCases:** 비즈니스 로직을 실행하는 단위. Repository Interface를 사용하여 데이터를 요청합니다.
* **Interfaces (Repository Protocols):** Data Layer에서 구현해야 할 Repository의 추상화된 정의.

### 2. Data Layer (Outer Circle)
실제 데이터 처리를 담당합니다. API 통신, 로컬 DB 접근 등을 수행하며 Domain Layer의 Repository Interface를 구현합니다.
* **Repositories (Implementation):** Domain Layer의 Repository Interface를 실제로 구현한 클래스.
* **DataSources:** Remote(API) 또는 Local(DB, UserDefaults) 데이터 소스.
* **DTOs (Data Transfer Objects):** API 응답 모델 (Domain Entity로 매핑되어 사용됨).

### 3. Presentation Layer (Outer Circle)
사용자에게 데이터를 보여주고 입력을 받는 UI 계층입니다.
* **Views (SwiftUI):** UI를 구성하고 사용자의 입력을 받습니다. 비즈니스 로직을 직접 처리하지 않고 ViewModel에 위임합니다.
* **ViewModels:** View의 상태(State)를 관리하고, UseCase를 실행하여 데이터를 처리합니다. `@Published` 속성을 통해 View와 바인딩됩니다.

---

## 🧩 Modularization (Folder Structure)

프로젝트는 기능(Feature) 단위로 그룹화되어 있으며, 각 기능 내부는 Clean Architecture 레이어로 나뉩니다.

```
Codive/
├── Application/ # 앱 진입점 및 초기 설정 (AppConfigurator, AppRootView)
├── Features/ # 기능별 모듈
│ ├── Auth/
│ │ ├── Domain/ # Entity, UseCase, Repository Interface
│ │ ├── Data/ # Repository Impl, DTO, API Service
│ │ └── Presentation/ # View, ViewModel
│ ├── Feed/
│ ├── Home/
│ └── ...
├── Shared/ # 공통 사용 모듈 (DesignSystem, Extensions, Network, Storage)
├── DIContainer/ # 의존성 주입 컨테이너
└── Router/ # 화면 전환 및 내비게이션 로직
```

---

## 🔌 Dependency Injection (DI)

의존성 주입은 **DIContainer** 패턴을 사용하여 중앙에서 관리합니다.
* **AppDIContainer:** 앱 전체의 최상위 컨테이너로, 각 Feature의 DIContainer를 생성하고 관리합니다.
* **FeatureDIContainer (e.g., AuthDIContainer):** 각 기능 모듈에 필요한 UseCase, Repository, ViewModel 등의 인스턴스를 생성하고 주입합니다.
* 이를 통해 객체 간의 결합도를 낮추고 테스트 시 Mock 객체 주입을 용이하게 합니다.

---

## 🚦 Navigation (Router Pattern)

화면 전환 로직은 View에서 분리되어 **Router**와 **ViewFactory**가 담당합니다.
* **Router:** 내비게이션 스택을 관리하고 화면 전환을 수행합니다 (`AppRouter`, `NavigationRouter`).
* **ViewFactory:** 특정 화면(View)을 생성할 때 필요한 의존성(ViewModel 등)을 조립하여 View를 반환합니다.
* View는 `Router`를 통해 "어디로 갈지"만 요청하며, 실제 "어떻게 화면을 띄울지"는 Router가 처리합니다.

---

## 🛠 Tech Stack & Tools

* **Language:** Swift 6.0+
* **UI Framework:** SwiftUI
* **Architecture:** MVVM + Clean Architecture
* **Build Tool:** Tuist
* **Networking:** CodiveAPI (swift-openapi-generator)
* **Reactive Programming:** Combine / Swift Concurrency (async/await)
14 changes: 6 additions & 8 deletions Codive/Application/AppRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct AppRootView: View {

init(appDIContainer: AppDIContainer) {
self._appRouter = StateObject(wrappedValue: appDIContainer.appRouter)
self.authDIContainer = appDIContainer.makeAuthDIContainer()
self.authDIContainer = appDIContainer.authDIContainer
self.appDIContainer = appDIContainer
self._authRepository = State(wrappedValue: self.authDIContainer.authRepository)
}
Expand All @@ -35,11 +35,9 @@ struct AppRootView: View {
authDIContainer.makeAuthFlowView()

case .termsAgreement:
TermsAgreementView(
onComplete: {
appRouter.navigateToMain()
}
)
TermsAgreementView {
appRouter.navigateToMain()
}

case .main:
MainTabView(appDIContainer: appDIContainer)
Expand Down Expand Up @@ -75,8 +73,8 @@ struct AppRootView: View {
return
}

let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value
let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value
let accessToken = queryItems.first { $0.name == "accessToken" }?.value
let refreshToken = queryItems.first { $0.name == "refreshToken" }?.value

guard let unwrappedAccessToken = accessToken,
let unwrappedRefreshToken = refreshToken else {
Expand Down
3 changes: 3 additions & 0 deletions Codive/Core/Resources/TextLiteral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ enum TextLiteral {
static let withdrawTitle = "계정 탈퇴"
static let withdrawNotice = "탈퇴 전 아래 내용을 확인해주세요"
static let withdrawButton = "계정 탈퇴하기"
static let withdrawButtonLoading = "탈퇴 중..."
static let withdrawConfirmTitle = "정말 탈퇴하시겠습니까?"
static let withdrawConfirmMessage = "한 번 탈퇴하면 계정과 모든 데이터는 복구할 수 없습니다."

// Liked Records
static let likedRecordsEmpty = "좋아요 한 기록이 없어요!"
Expand Down
17 changes: 9 additions & 8 deletions Codive/DIContainer/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ final class AppDIContainer {
lazy var appRouter = AppRouter()
lazy var navigationRouter = NavigationRouter()

init() {
// Router 간 의존성 설정
appRouter.setNavigationRouter(navigationRouter)
}

// MARK: - Domain DIContainers
lazy var sharedDIContainer = SharedDIContainer()
lazy var closetDIContainer = ClosetDIContainer(navigationRouter: navigationRouter)

lazy var profileDIContainer = ProfileDIContainer(navigationRouter: navigationRouter)
lazy var authDIContainer = AuthDIContainer(appRouter: appRouter, navigationRouter: navigationRouter)

// MARK: - Feature DIContainers
func makeAuthDIContainer() -> AuthDIContainer {
return AuthDIContainer(
appRouter: appRouter,
navigationRouter: navigationRouter
)
}

func makeAddDIContainer() -> AddDIContainer {
return AddDIContainer(
Expand All @@ -36,7 +37,7 @@ final class AppDIContainer {
}

func makeSettingDIContainer() -> SettingDIContainer {
return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter)
return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer, authDIContainer: authDIContainer)
}

func makeReportDIContainer() -> ReportDIContainer {
Expand Down
23 changes: 20 additions & 3 deletions Codive/DIContainer/ProfileDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ final class ProfileDIContainer {
self.navigationRouter = navigationRouter
}

// MARK: - API Service
// MARK: - API Services
private lazy var profileAPIService: ProfileAPIServiceProtocol = {
return ProfileAPIService()
}()

private lazy var historyAPIService: HistoryAPIServiceProtocol = {
return HistoryAPIService()
}()

// MARK: - Data Sources
private lazy var profileDataSource: ProfileDataSourceProtocol = {
return ProfileDataSource(apiService: profileAPIService)
Expand All @@ -33,6 +37,10 @@ final class ProfileDIContainer {
return ProfileRepositoryImpl(dataSource: profileDataSource)
}()

private lazy var historyRepository: HistoryRepository = {
return HistoryRepositoryImpl(historyAPIService: historyAPIService)
}()

// MARK: - UseCases
func makeFetchMyProfileUseCase() -> FetchMyProfileUseCase {
return DefaultFetchMyProfileUseCase(repository: profileRepository)
Expand All @@ -46,12 +54,21 @@ final class ProfileDIContainer {
return DefaultUpdateProfileUseCase(repository: profileRepository)
}

func makeFetchMonthlyHistoryUseCase() -> FetchMonthlyHistoryUseCase {
return FetchMonthlyHistoryUseCase(historyRepository: historyRepository)
}

// MARK: - ViewModels
func makeProfileViewModel() -> ProfileViewModel {
private lazy var profileViewModel: ProfileViewModel = {
return ProfileViewModel(
navigationRouter: navigationRouter,
fetchMyProfileUseCase: makeFetchMyProfileUseCase()
fetchMyProfileUseCase: makeFetchMyProfileUseCase(),
fetchMonthlyHistoryUseCase: makeFetchMonthlyHistoryUseCase()
)
}()

func makeProfileViewModel() -> ProfileViewModel {
return profileViewModel
}

func makeFollowListViewModel(mode: FollowListMode, memberId: Int) -> FollowListViewModel {
Expand Down
41 changes: 37 additions & 4 deletions Codive/DIContainer/SettingDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,41 @@

import Foundation
import SwiftUI
import CodiveAPI

@MainActor
final class SettingDIContainer {

// MARK: - Dependencies
private let appRouter: AppRouter
private let navigationRouter: NavigationRouter
private let profileDIContainer: ProfileDIContainer
private let authDIContainer: AuthDIContainer
private let apiClient: Client

// ViewFactory
lazy var settingViewFactory = SettingViewFactory(settingDIContainer: self)

// Data / Repository
private let repository: SettingRepository

// MARK: - Init
init(appRouter: AppRouter, navigationRouter: NavigationRouter) {
init(
appRouter: AppRouter,
navigationRouter: NavigationRouter,
profileDIContainer: ProfileDIContainer,
authDIContainer: AuthDIContainer,
apiClient: Client = CodiveAPIProvider.createClient(
middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())]
)
) {
self.appRouter = appRouter
self.navigationRouter = navigationRouter
self.profileDIContainer = profileDIContainer
self.authDIContainer = authDIContainer
self.apiClient = apiClient

let dataSource = SettingsDataSource()
let dataSource = SettingsDataSource(apiClient: apiClient)
let repo = SettingsRepositoryImpl(dataSource: dataSource)
self.repository = repo
}
Expand Down Expand Up @@ -60,13 +75,19 @@ final class SettingDIContainer {
GetWithdrawNoticesUseCase(repository: repository)
}

func makeWithdrawAccountUseCase() -> WithdrawAccountUseCase {
WithdrawAccountUseCase(repository: repository)
}

// MARK: - ViewModels
func makeSettingViewModel() -> SettingViewModel {
SettingViewModel(
appRouter: appRouter,
navigationRouter: navigationRouter,
getPrefsUC: makeGetNotificationPrefsUseCase(),
updatePrefsUC: makeUpdateNotificationPrefsUseCase()
updatePrefsUC: makeUpdateNotificationPrefsUseCase(),
authRepository: authDIContainer.authRepository,
profileViewModel: profileDIContainer.makeProfileViewModel()
)
}

Expand All @@ -92,6 +113,14 @@ final class SettingDIContainer {
)
}

func makeWithdrawViewModel() -> WithdrawViewModel {
WithdrawViewModel(
navigationRouter: navigationRouter,
appRouter: appRouter,
withdrawUC: makeWithdrawAccountUseCase()
)
}

// MARK: - Views
func makeSettingView() -> SettingView {
SettingView(viewModel: self.makeSettingViewModel())
Expand All @@ -108,4 +137,8 @@ final class SettingDIContainer {
func makeSettingBlockedView() -> SettingBlockedView {
SettingBlockedView(vm: self.makeBlockedUsersViewModel())
}

func makeWithdrawView() -> WithdrawView {
WithdrawView(vm: makeWithdrawViewModel())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class AuthRepositoryImpl: AuthRepository {

func logout() async {
await socialAuthService.logout()
try? KeychainManager.shared.clearAllTokens()
}

func saveTokens(accessToken: String, refreshToken: String) async throws {
Expand Down
4 changes: 2 additions & 2 deletions Codive/Features/Auth/Data/SocialAuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol {
return
}

let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value
let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value
let accessToken = queryItems.first { $0.name == "accessToken" }?.value
let refreshToken = queryItems.first { $0.name == "refreshToken" }?.value

guard let accessToken = accessToken,
let refreshToken = refreshToken else {
Expand Down
2 changes: 1 addition & 1 deletion Codive/Features/Auth/Data/TokenService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ final class TokenService: TokenServiceProtocol {

/// 키체인에 유효한 토큰이 있는지 확인
func hasValidTokens() -> Bool {
guard let _ = getAccessToken(), let _ = getRefreshToken() else {
guard getAccessToken() != nil, getRefreshToken() != nil else {
return false
}
return true
Expand Down
6 changes: 3 additions & 3 deletions Codive/Features/Auth/Presentation/View/AuthFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import SwiftUI

struct AuthFlowView: View {

@StateObject var navigationRouter: NavigationRouter
private let authViewFactory: AuthViewFactory
private let authDIContainer: AuthDIContainer

init(
authDIContainer: AuthDIContainer,
authViewFactory: AuthViewFactory
Expand All @@ -21,7 +21,7 @@ struct AuthFlowView: View {
self.authViewFactory = authViewFactory
self.authDIContainer = authDIContainer
}

var body: some View {
NavigationStack(path: $navigationRouter.path) {
authDIContainer.makeOnboardingView()
Expand Down
7 changes: 3 additions & 4 deletions Codive/Features/Auth/Presentation/View/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ struct OnboardingView: View {
VStack {
Spacer()

VStack{
VStack {
// 온보딩 멘트 슬라이드
TabView(selection: $currentPage) {
ForEach(0..<Self.pages.count, id: \.self) { index in
Expand Down Expand Up @@ -202,7 +202,6 @@ struct OnboardingContainerView: View {
onKakaoLogin: { },
onAppleLogin: { },
isLoading: false,
errorMessage: nil,
onErrorDismiss: { }
)
errorMessage: nil
) { }
}
Loading