[iOS] 첫 MVVM, 클린 아키텍처 리팩토링을 해보자
🚨 주의! 이 글은 MVVM과 클린 아키텍처에 대한 주관적인 이해와 적용이 들어가 있습니다.
클린 아키텍처, MVVM,,, 들어보았다면 개발자가 맞을 것이다. 개발 초기, 초보 개발자는 이 모든 개념을 이해하고, 적용하기에는 어렵다. 최근 이전에 했던 프로젝트 중 하나인 버닝버디를 피봇하고 다시 앱을 개발하면서 이 개념을 떠올릴 수밖에 없었다. 앱 만으로 구동하던 서버리스였지만, 회원 개념이 들어가고 알림 기능이 필요해지면서 기존에 있었던 코드 구조로는 이것을 구현할 수 없었다. SwiftUI로 구현했기 때문에, 뷰 코드 안에 데이터 관련 코드가 들어가 있었어도 이전에는 괜찮았기 때문이다. 뭐 일단 돌아간다면 코드만 써놓고 커맨드 R만 눌러서 잘 돌아가네 했던 이전과는 다른 상황을 맞이했다.
그래서 이 두 개념 "클린 아키텍처와 MVVM"을 떠올렸고, 무엇이 좋을까에 대한 고민을 했고, 현재 상황에 맞추어 필요한 것을 쏙쏙 뽑아서 리팩토링을 하고 개발을 해야겠다는 마음을 먹었다. 이전까지는 클린 아키텍처, MVVM에 대해 단순, 주입식, 암기식의 이해를 하고 있었지만, 실전적인 이해를 바탕으로 필요한 상황에 좋은 개념들을 붙이는 방법을 생각해 보고 리팩토링한 과정을 본 포스팅에서 담고자 한다.
1. 클린 아키텍처와 MVVM 기본 이해
1) 클린 아키텍처
도메인, 유즈케이스, 인터페이스 어댑터... 뭐 이런 개념이 있다. 간단히 알아보자면 아래와 같다.
이런 이미지를 처음에 검색하면 만날 수 있다. 뭔가 안쪽이 중요하고, 바깥쪽으로 갈수록 안 중요한 것 같은 직관적인 느낌이 들기도 하는데, 뭔 말인지는 처음에 봐도 그렇고, 지금 봐도 솔직히 100% 이해는 안 가기는 한다. 하지만, 여기에서 중요한 포인트만 알아보자면..
(1) 바깥쪽 원에 위치할수록 중요하지 않고, 언제든 바뀔 수 있는 세부사항이다.
(2) 내부 데이터들과 로직은 안쪽에 위치해야 한다.
(3) 요청은 원 내부 쪽으로 들어와 바깥쪽으로 나가게 된다.
(4) 의존성은 원 안쪽으로 향한다. 바깥쪽 원에 해당하는 것은 내부 원에는 영향을 주지 않아야 한다.
(5) 바깥쪽 레이어가 정해지지 않아도 서비스를 구축해 나갈 수 있는 것을 목표로 한다.
이 정도인 것 같다. 대표적인 룰들이며, 내부의 요소는 아래와 같다.
a. Entity (엔티티)
-> 비즈니스 논리를 포함하고 있지 않고, 데이터의 상태를 나타내는 객체. 데이터의 구조만 정의되어 있으며, 데이터베이스와 관련된 기술적인 세부 사항을 알지 못해야 한다.
예를 들어 이러한 구조체와 같은 것을 들 수 있다.
struct User {
let id: Int
let name: String
let email: String
}
b. UseCase (유스케이스)
-> 시스템에서 수행되는 작업이나 시나리오를 정의하는 것으로, 비즈니스 규칙을 포함하며 인터페이스와 분리되어 있다.
실제 기능 로그인, 로그아웃, 구매 등의 기능을 담당하는 코드들을 의미한다. Swift에서는 프로토콜을 활용하여 설계도를 만들고, class 구현체를 만들어 구현한다.
protocol LoginUseCase {
func login(email: String, password: String) -> Bool
}
class LoginUseCaseImpl: LoginUseCase {
func login(email: String, password: String) -> Bool {
// 로그인 로직 구현 return true // 또는 false
}
}
c. Controller (컨트롤러)
-> 유스케이스와 사용자 인터페이스 사이의 중재자 역할. 사용자 입력을 처리하고 결과를 사용자에게 표시한다.
뷰에서 로그인 버튼을 눌렀을 때, UseCase의 로그인 기능을 실행시키는 곳이다. 이메일과 패스워드를 넘겨서 기능하도록 하는 예시를 들 수 있겠다.
class LoginViewController: UIViewController {
var loginUseCase: LoginUseCase?
func loginButtonTapped() {
let email = "example@email.com"
let password = "password"
let isLoggedIn = loginUseCase?.login(email: email, password: password) // 로그인 결과 처리
}
}
d. Interface (인터페이스)
-> 외부와의 통신을 담당하는 입구로, 다른 레이어와의 결합을 낮추고 결합도를 관리한다.
이 프로젝트 파일이 아닌 다른 외부의 DB와 상호작용 하기 위한 입구이다. 이 코드가 만약에 controller 코드와 섞여있다고 생각하면 얼마나 복잡할까. 정확히 영역을 나누어, "버튼을 눌렀을 때 로직 / 로그인 로직"을 분리시켜 놓을 수 있는 방법이라는 것을 생각해 보면 이해를 할 수 있다.
protocol UserRepository {
func getUser(id: Int) -> User?
func saveUser(user: User) // 기타 필요한 메서드들
}
class UserRepositoryImpl: UserRepository {
func getUser(id: Int) -> User? { // 유저 조회 로직
return User(id: id, name: "John", email: "john@example.com")
}
func saveUser(user: User) {
// 유저 저장 로직
}
}
e. Gateway (게이트웨이)
-> 외부 시스템과의 통신을 추상화하고, 데이터의 입출력을 담당한다. 구체적인 데이터베이스나 외부 API와의 통신을 처리.
protocol UserGateway {
func fetchUserFromRemote(id: Int, completion: @escaping (User?) -> Void)
func saveUserToRemote(user: User, completion: @escaping (Bool) -> Void)
}
class UserGatewayImpl: UserGateway {
func fetchUserFromRemote(id: Int, completion: @escaping (User?) -> Void) {
// 원격 서버에서 유저 조회 로직
let user = User(id: id, name: "Jane", email: "jane@example.com")
completion(user)
}
func saveUserToRemote(user: User, completion: @escaping (Bool) -> Void) {
// 원격 서버에 유저 저장 로직
completion(true) // 또는 false
}
}
2) Swift Clean Architecture
위의 클린 아키텍처는 앱 개발자로서 보면 조금 어색한 부분들이 있다. 특히 게이트웨이 부분을 보면 생소하기도 하고 어색한 감이 없지 않아 있다. 또한 카메라, GPS 이런 것들은 어느 계층에 해당하는지...? 등을 이해할 수가 없었다. 그래서 조금 더 찾아보니 모바일에 적합한 클린 아키텍처라는 것이 있다는 것을 알았다. 3계층으로 분할하여 두었고 조금 더 명확하고 간단한 순서를 가지고 있는 것 같았다.
a. Data Layer
데이터 레이어는 데이터의 저장 및 검색을 담당한다. 외부 데이터 소스(예: 데이터베이스, 네트워크 서비스)와의 상호작용을 추상화하여 도메인 레이어에서 사용할 수 있도록 제공한다.
데이터 레이어의 핵심 구성 요소는 리포지토리라고 할 수 있다. 도메인 레이어에서 정의한 엔티티를 저장하고 검색하는 인터페이스를 제공한다.
// 예시: 유저 리포지토리 프로토콜
protocol UserRepositoryType {
func getUser(byId id: Int, completion: @escaping (Result<User, Error>) -> Void)
func saveUser(_ user: User, completion: @escaping (Result<Void, Error>) -> Void)
}
b. Domain Layer
도메인 레이어는 비즈니스 도메인과 관련된 모든 규칙과 데이터를 포함한다. 가장 순수하고 독립적인 형태의 코드를 갖추며, 외부 기술에 대한 의존성이 없어야 한다. Entity와 UseCase가 위치한다.
Entity
struct User {
let id: Int
let name: String
let email: String
}
UseCase
// 예시: 주문 관리 서비스
protocol OrderService {
func placeOrder(for items: [OrderItem], completion: @escaping (Result<Order, Error>) -> Void)
func cancelOrder(_ order: Order, completion: @escaping (Result<Void, Error>) -> Void)
}
class OrderServiceImpl: OrderService {
private let orderRepository: OrderRepository
private let paymentService: PaymentService
init(orderRepository: OrderRepository, paymentService: PaymentService) {
self.orderRepository = orderRepository
self.paymentService = paymentService
}
func placeOrder(for items: [OrderItem], completion: @escaping (Result<Order, Error>) -> Void) {
// 주문 처리 로직
let order = Order(id: UUID().uuidString, items: items)
orderRepository.saveOrder(order) { result in
switch result {
case .success:
// 결제 처리
self.paymentService.processPayment(for: order.totalAmount) { paymentResult in
switch paymentResult {
case .success:
completion(.success(order))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
func cancelOrder(_ order: Order, completion: @escaping (Result<Void, Error>) -> Void) {
// 주문 취소 처리 로직
orderRepository.deleteOrder(order) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
}
c. Presentation
View와 ViewModel을 들 수 있다. 사용자의 화면과 가장 가까이하는 코드들이 이에 속한다.
// 예시: 유저 정보 뷰 모델
class HomeViewModel {
private let userRepository: UserRepository
var user: User?
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func fetchUser(withId id: Int) {
userRepository.getUser(byId: id) { [weak self] result in
switch result {
case .success(let user):
self?.user = user
// 데이터가 업데이트되었음을 뷰에 알림
case .failure(let error):
// 에러 처리 로직
print("Failed to fetch user: \(error)")
}
}
}
}
3) MVVM
위의 클린 아키텍처를 모두 살펴보았다면, MVVM이 무엇인가를 감을 잡을 수 있다. View, ViewModel 모두 Presentation 영역에 들어가 있는 것을 알 수 있고, Entity가 곧 Model임을 알 수 있다. 클린 아키텍처에 속하는 개념이 아니라, UI와 관련된 하나의 패턴임을 알 수 있다. (그래서 디자인 패턴인가) 만약, Presentation에서 View와 ViewModel로 분리하지 않고, Controller, View로 분리할 수도 있는 것이다.
여기서는 MVVM 패턴을 왜 선택했는 지를 이야기해보고 싶다. 다른 곳에서 MVVM 패턴에 대해 더 친절하고 자세하게 알 수 있으니...
일단은 이전 프로젝트에서 UIKit을 사용하면서 MVC 디자인 패턴에 대한 경험이 있었다. 그때, 뷰와 비즈니스 로직에 대한 분리 필요성을 여실히 느꼈으며, 코드의 가독성 부분과 재사용성을 위해 코드를 분리해야 한다는 것을 알았다. 버닝버디 프로젝트의 경우, 제대로 된 디자인 패턴을 가지고 있지 않았는데, 이러한 분할을 생각해야 했고 SwiftUI 프로젝트였기 때문에, MVC 패턴은 적절하지 않았다. 상태와 데이터를 중심으로 개발을 진행하기 때문에 MVVM 패턴이 적절하다고 보았기 때문이다.
이렇게 해서, View Struct는 UI만을 표시하고, ViewModel class는 비즈니스 로직과 상태를 관리하며, Model을 사용하여 Firebase 데이터 바인딩 문제를 해결하기 위해 도입을 했다고 할 수 있다.
다만, 최근에는 SwiftUI의 선언적 UI 프로그래밍에 MVVM 패턴이 굳이 필요하지 않다는 의견이 있기도 하다.
https://gist.github.com/unnnyong/439555659aa04bbbf78b2fcae9de7661?permalink_comment_id=4277488
swiftui_mvvm.md
GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
간략하게 이야기하면, SwiftUI에서는 View 자체가 상태를 가지고 자체적으로 업데이트를 수행할 수 있는데, ViewModel을 코드 분할을 위해 분리하고, 상호작용하도록 만드는 것 자체가 복잡성을 높이고, 중간 관리자(ViewModel)가 필요 없으며, SwiftUI 자체가 선언적인 방식으로 UI를 설계하고 상태를 관리하도록 만드는 것을 목표로 하고 있지만, 데이터 흐름이 더욱 복잡해지는 MVVM의 문제로 인해 고민을 해보아야 한다는 것이다.
그렇지만, 개발을 혼자함에 있어서도 View 하나만을 가지고 개발을 진행할 경우, 어쨌든 인간이 코드를 입력하고 관리하는 것이기 때문에 언어의 지향점이 그렇고, 일부 성능을 발휘하지 못할 수도 있는 측면이 있다고 해도, 개발 편의성과 성능적 측면을 놓고 보았을 때 MVVM을 쓰는 것이 더 좋을 것 같다는 나의 생각이 컸다. 또한 MVVM이라는 공통적으로 인정하는 하나의 포인트가 있을 때, 다른 사람들과 코드를 이해하고 설명하는데 더 좋지 않을 까라는 생각이 있었다. 그렇지만 맹신적으로 MVVM을 사용하는 것이 아니라, SwiftUI의 장점들을 흐리게 하는 MVVM의 원칙이라면 깨부숴보면서 프로젝트를 개선시켜 나가 보면 되겠다는 마음가짐을 가지고 MVVM을 도입하게 되었다.
2. 클린 아키텍처 그리고 MVVM을 기준으로 한 리팩토링, 폴더링
자 그럼, 이제 어떻게 리팩토링을 하고, 폴더링을 했는지를 알아보자. 기존에 어떻게 구성이 되어 있었고, 어떤 식으로 변화했는지를 위주로 설명을 해볼 것이다.
1) 기존 1.1 버전의 구조
(1) Presentation 코드의 구성
a. OnboardingView
내가 만들었던 코드 중 일부를 보면 다음과 같다. 온보딩 뷰의 TabView를 이런 방식으로 작성했었다.
struct OnboardingRoot: View {
@State var onboardingPage = 0
@Binding var isFirst: Bool
@ObservedObject var viewModel: OnboardingRootViewModel
var body: some View {
VStack(spacing: 0) {
switch onboardingPage {
case 0:
OnboardingSwipeView(pageNum: $onboardingPage)
case 1:
UserSettingInputView(
viewModel: UserSettingViewModel(inputCase: .nickname),
isBackHidden: false,
pageNum: $onboardingPage,
isOnboarding: true
)
case 2:
UserSettingInputView(
viewModel: UserSettingViewModel(inputCase: .characterName),
isBackHidden: false,
pageNum: $onboardingPage,
isOnboarding: true
)
case 3:
CalorieSettingView(
viewModel: CalorieSettingViewModel(),
isBackHidden: false,
pageNum: $onboardingPage,
isFirst: $isFirst
)
default:
Spacer()
}
}
}
}
final class OnboardingRootViewModel: ObservableObject {
init() {
UserModel.shared.createUserData()
BunnyModel.shared.createBunnyData()
}
}
루트를 하나 묶어놓고, pageNum에 따라서 각 뷰를 보여주는 방식을 택했다.
이 방법은 일단 View와 주요 코드를 분리시키기 위해 시도를 하였다는 점에서는 좋지만,
온보딩 기능은 뷰를 보여주는 기능 밖에 없는데, 일단은 viewModel까지 두었다.
모든 뷰에 viewModel이 있어야 하는 것은 아니다. 변화 없이 고정된 UI를 보여주는 View까지 일괄적으로 MVVM 형식을 적용하여 일어난 것이다.
b. SingleWorkoutView
struct SingleWorkoutView: View {
var delegate: ReceiveUserStateDelegate?
@StateObject var viewModel: SingleWorkoutViewModel
@State private var isNotDoneWorkout = false
@State private var isNextButtonTapped = false
@Binding var mainViewNavLinkActive: Bool
@State private var currentCalorie = 0
@State private var isSuccess = false
// 코드 중략
}
다른 주요한 뷰 같은 경우에는 viewModel이 있음에도 불구하고 상태와 관련된 변수들이 View에 남아있었다. 말 그대로 MVVM에 대한 제대로 된 이해 없이 단순 암기식의 MVVM 적용을 했기 때문에, 적절한 정리가 되지 않은 모습들을 보인다.
이 상황을 보면, ViewModel에 들어있는 코드가 무엇일까?라는 의문이 들지 않을 수가 없는데, 놀랍게도 viewModel에는 단순 계산을 하는 코드들만 잔뜩 들어가 자리를 차지하고 있었다.
final class SingleWorkoutViewModel: ObservableObject {
init() {
WorkoutModel.shared.createWorkoutData()
}
func resetWorkoutState() {
UserDefaults.standard.setValue(false, forKey: "isWorkouting")
UserDefaults.standard.setValue(false, forKey: "isDoneWorkout")
HealthData.shared.stopObservingCalories()
}
func checkWorkoutTime() -> Int {
let standardTime = UserDefaults.standard.object(forKey: "workoutStartTime") as? Date
return Int(Date().timeIntervalSince1970 - (standardTime?.timeIntervalSince1970 ?? Date().timeIntervalSince1970))
}
// 코드 중략
}
일단 기능 자체가 간단해서 이러한 함수들이 여기에 논리상 들어있는 것은 맞다. 하지만, 대부분 보면 프로퍼티를 활용한다던지 하는 부분이 전혀 없다. 왜냐하면 주요 상태 프로퍼티들이 View에 있기 때문이다.
이런 식으로 기존에는 MVVM을 적용한다고 했지만, 피상적인 적용, 코드 분할 정도의 역할 밖에 하지 못했다. 전반적으로 이러한 코드들이 많았기 때문에, 나는 리팩토링과 폴더링을 다시 진행하면서, 이러한 코드들을 거의 대부분 밀어버리고 새로 적용하는 방식으로 진행하는 방법을 택했다.
(2) 전체 폴더링
└── BurningBuddy
├── BurningBuddy
│ ├── App
│ │ ├── Keys
│ │ └── Preview Content
│ ├── Components
│ │ └── ButtonStyle
│ ├── Domain
│ │ ├── CoreData
│ │ │ ├── Bunny
│ │ │ ├── Model
│ │ │ ├── User
│ │ │ ├── UserDataMappingModel.xcmappingmodel
│ │ │ ├── UserDataModel.xcdatamodeld
│ │ │ └── Workout
│ │ └── Update
│ ├── Legacy
│ ├── Model
│ │ └── CoreData
│ │ └── MappingModel.xcmappingmodel
│ ├── Presentation
│ │ ├── LevelUp
│ │ ├── Main
│ │ │ ├── ViewModels
│ │ │ └── Views
│ │ ├── Onboarding
│ │ │ ├── Models
│ │ │ ├── ViewModels
│ │ │ └── Views
│ │ └── SinglePlay
│ │ ├── View
│ │ └── ViewModel
│ ├── Resources
│ │ └── Assets.xcassets
│ └── Utility
│ ├── Enum
│ ├── Extension
│ └── Utils
└── BurningBuddy.xcodeproj
├── project.xcworkspace
├── xcshareddata
└── xcuserdata
기존 버전을 보면, Presentation 코드만 존재한다. 서버가 없었기 때문에, Presentation만 존재해도 무방했기 때문이다.
다만, Presentation 내부에 Models라고 적혀있는 부분들을 보면, Model에 대한 분할이 적절하지 않았다고 볼 수 있다.
나머지는 편의사항, 팀 내부 정책에 따라 어디에 위치하도록 하는 것이기 때문에 별로 볼 것은 없다. Utility, Resource 등 특별히 변경사항이 없는 것도 루트 디렉토리 바로 하위에 위치하게 하여 기능에 비해 상위 폴더가 많아 보이긴 했다.
2) 아키텍처가 적용된 1.2 버전의 구조
1.2 버전에서는 기존의 코드들이 대부분 필요하지 않았다. 그래서 일단은 다시 밀고 쌓아 올린다는 생각으로 거의 대부분을 밀어버렸다. 서버와의 상호작용도 필요하기 때문에, 이를 고려하여 코드를 만들었다.
(1) 비즈니스 로직 흐름
Firebase Firestore, 그리고 Storage에 접근하는 것이 일단은 이번 버전에서의 전부였다.
Presentation -> 비즈니스 로직 -> Firebase로 전달 -> 정보 가져오기 -> Presentation에서 표시
다만, 추후 추가될 기능들을 고려하여 확장성을 생각하여야 했다.
(2) 서비스, 리포지토리 코드 분할
1-2)에서 이야기한 Swift Clean Architecture를 적용하고자 했다. 그래서 Presentation과 Service, Repository 영역으로 나누어 코드를 구현하고자 했다.
a. Repository
protocol UserDataDBRepositoryType {
func addUser(_ userDataObject: UserDataObject) async throws
func getUser(userId: String) async throws -> UserDataObject
func getPodiumUser() -> AnyPublisher<[UserDataObject], DBError>
...
}
class UserDataDBRepository: UserDataDBRepositoryType {
// Firestore Reference
let db: Firestore
init() {
db = Firestore.firestore()
}
func addUser(_ userDataObject: UserDataObject) async throws {
guard let userDataDict = userDataObject.toDictionary() else { throw DBError.dataConversionError }
// "users" 컬렉션의 uid에 해당하는 이름의 도큐먼트에 유저 정보 저장
try await db.collection(DBKey.users).document(userDataObject.id).setData(userDataDict)
}
/// userId에 해당하는 유저 데이터를 불러오는 메서드
func getUser(userId: String) async throws -> UserDataObject {
guard let value = try await self.db.collection(DBKey.users).document(userId).getDocument().data() else {
throw DBError.emptyValue
}
let data = try JSONSerialization.data(withJSONObject: value)
let userObject = try JSONDecoder().decode(UserDataObject.self, from: data)
return userObject
}
}
Repo 같은 경우, protocol로 메서드를 선언하고, 구현해 주는 방식으로 작성했다.
db에 직접 접근하는 코드이기 때문에, db 처리와 관련된 것들이 주로 들어간다.
모델 같은 경우, 여기에서 Object라는 타입을 볼 수 있는데, UserData라는 타입을 DTO로 만들어 DB 전송 간에 캐스팅을 하기 위한 용도로 사용하고 있다. JSON 형식으로 입출력을 하는 Firebase의 특성에 맞추어 encode와 decode를 편리하게 하기 위함이며, Model과 DTO 간에 toObject, toModel이라는 함수를 두어 변환하기 편리하도록 만들었다.
DB에 접근하는 것이기 때문에, 코드 양이 많고, 세세하게 설정해주어야 할 것들을 이 부분에서 진행하게 되는 것이다.
b. Service
Service의 경우, 여러 가지 유즈케이스에 맞추어 여러 서비스 타입을 선언하고, 구현하였다. 예를 들어 회원관리에 필요한 정보를 관리하는 AuthenticationService, 유저 정보와 관련된 UserService, 알림 관련된 정보를 관리하는 NotificationService 등을 만들고, Service를 하나로 묶어서 하나의 container 안에서 관리하는 방식을 택했다. 일단은 이렇게 되면, 추후 결합성이 높아지는 문제가 발생할 수 있지만, 현재 단계에서는 묶어서 하나로 관리하는 것이 일단은 개발 편의에 도움이 되고, 클린 아키텍처가 익숙하지 않은 단계에서 container 하나에 접근하는 방식으로 모든 서비스 코드에 접근할 수 있다는 점이 좋아서 이렇게 했다.
protocol ServiceType {
var authService: AuthenticationServiceType { get set }
var userService: UserServiceType { get set }
var imageCacheService: ImageCacheServiceType { get set }
}
class Services: ServiceType {
var authService: AuthenticationServiceType
var userService: UserServiceType
var imageCacheService: ImageCacheServiceType
init() {
self.authService = AuthenticationService()
self.userService = UserService(dbRepository: UserDataDBRepository())
self.imageCacheService = ImageCacheService(memoryStorage: MemoryStorage(), diskStorage: DiskStorage())
}
}
class DIContainer: ObservableObject {
// TODO: - 서비스
var services: ServiceType
init(services: ServiceType) {
self.services = services
}
}
이런 방식들로 Service를 만들었으며, 하나의 Services를 가지고 auth, user 등의 서비스 코드에 접근할 수 있도록 하였다.
각각의 유즈케이스인 서비스들은 다음과 같은 코드를 가지고 있다.
protocol AuthenticationServiceType {
func checkAuthentictionState() async -> String?
...
}
class AuthenticationService: AuthenticationServiceType {
/**
[ 인증 상태 체크 ]
*/
func checkAuthentictionState() async -> String? {
return await checkAuthState()
}
}
이런 식으로, authService 코드에 접근하면, 함수들을 사용할 수 있도록 만들어 두었다.
c. ViewModel
이러한 서비스 코드들은 ViewModel에서 이러한 방식으로 사용된다.
enum AuthenticationState {
case loading
case unauthenticated
case authenticated
case onBoarding
}
class AuthenticatedViewModel: ObservableObject {
private var container: DIContainer
init(container: DIContainer) {
self.container = container
....
}
private func checkAuthState() {
Task {
if let userID = await container.services.authService.checkAuthentictionState() {
getMyUserData(userID)
} else {
DispatchQueue.main.async { [weak self] in
self?.authenticationState = .unauthenticated
}
}
print("authenticationState: \(authenticationState)")
}
}
}
이러한 방식으로 DIContainer를 지속적으로 주입을 받아서, 하나의 컨테이너 안에 담긴 코드들에 접근을 하게 된다.
이 메서드들은 View의 생애 주기에 맞추어 적절한 시기에 호출이 되면서 제시간에 기능을 할 수 있도록 만들어 주는 방식으로 구조를 만들어 보았다.
(3) View, ViewModel 분할
이전 1.1에서 있었던 것과는 달리, 확실하게 코드를 분할하고, ViewModel에서 상태 관리와 비즈니스 로직 호출을 하는 것에 초점을 맞추었다.
struct AuthenticatedView: View {
@EnvironmentObject var container: DIContainer
@StateObject var authViewModel: AuthenticatedViewModel
@StateObject var mainRootViewModel: MainRootViewModel
@EnvironmentObject var navigationRouter: NavigationRouter
var body: some View {
NavigationStack(path: $navigationRouter.destination) {
VStack {
// 로그인 상태에 따른 화면 처리
switch authViewModel.authenticationState {
case .loading:
AdvertiseView()
case .unauthenticated:
LoginView()
case .authenticated:
MainRootView()
case .onBoarding:
OnBoardingView()
}
}
.onAppear {
// onAppear에서 인증상태 체크
authViewModel.send(action: .checkAuthenticationState)
}
...
}
}
상위 뷰에서 EnvironmentObject로 주입을 해준 것과, StateObject로 여기에서 선언한 것들이 섞여있으나, 필요한 위치에 맞추어 선언을 해두었다. mainRootViewModel의 선언 위치가 어색하긴 하지만, MVVM을 최대한 준수하면서 불필요한 데이터 흐름이 없도록 최대한 맞추어 정리해 보았다.
(4) 전체 폴더링
└── BurningBuddy
├── BurningBuddy
│ ├── App
│ │ ├── Keys
│ │ └── Preview Content
│ ├── Domain
│ │ ├── Provider
│ │ ├── Repositories
│ │ ├── Services
│ │ └── Update
│ ├── Extensions
│ ├── General
│ │ ├── Components
│ │ │ └── ButtonStyle
│ │ ├── Modifier
│ │ ├── Navigation
│ │ ├── URLImage
│ │ ├── Utility
│ │ └── View
│ ├── Model
│ ├── Resources
│ └── View
│ ├── Authentication
│ ├── Camera
│ ├── Login
│ ├── Main
│ ├── MyInfo
│ ├── Nickname
│ ├── Notification
│ ├── Onboarding
│ ├── Profile
│ ├── Ranking
│ ├── SearchPlace
│ └── TotalRank
├── BurningBuddy.xcodeproj
크게 변동된 것은 없다. 상위 디렉토리를 Presentation / Service / Repository / General / Entity 개념으로 보고, 분할을 했다. Resource의 경우, 어딘가에 들어가기는 층위 상 어색한 부분이 있어서 상위 디렉터리에 두는 방식으로 폴더링을 진행했다.
Presentation이라고 하지 않고 View라고 한 이유는 그냥 View라는 단어가 UI 층을 정확하게 표현할 수 있고, 정감이 가서... 랄까...? 여하튼 필요한 코드를 이합집산하여 새로운 폴더 기준을 만들게 되었다. 이 폴더들은 주요 기능 구현 전에 모두 폴더를 만들어 놓은 것은 아니다. 상위 디렉토리인 Presentation 등만 먼저 만들어놓고, 개발 시 팀원들과 협의하여 더 편한 곳에 코드를 배치하면서 최종적으로 만들어진 폴더 트리이다.
3. 마치며
이렇게 해서 v1.2.0. 기능 개발을 들어가기 전, 클린 아키텍처를 도입하고, 새롭게 코드를 짜는 시간을 가질 수 있었다. 아직은 부족한 점이 많을 것이다. 현재는 알 수 없지만, 더 알고 나면 부족한 점들이 보일 것 같다.
말로만 리팩토링을 실제로 일단 첫 번째로 진행해 보면서, 많은 새로운 개념들을 알게 되었고, 이 부분들을 어떻게 활용해 나가야 할지를 고민하는 시간이 되었다. 추후 개발을 진행하면서도 예상되었던 한계점들을 직접 마주하면서 더 좋은 코드로 튜닝해 나가는 과정을 밟아야 더 좋은 프로덕트가 되지 않을까 싶다.
이번 리팩토링은 여기까지!
'Project' 카테고리의 다른 글
새싹톤 1차, 2차 통과와 예선, 본선 그리고 후기 (0) | 2023.07.04 |
---|---|
[retrospec] EatTwoGetter 프로젝트 종료...? (0) | 2022.10.10 |
개발 이력 노트를 꼼꼼하게 기록해두자. (0) | 2022.09.18 |
[Project 1] 첫 프로젝트 EatTwoGetter 초기 구현 (0) | 2022.08.15 |
로또 번호 추출기 3탄(자바) (0) | 2022.07.03 |
댓글