애플 디벨로퍼 아카데미에서 마지막으로 진행했던 프로젝트를 혼자 리팩토링했다.
아직 소소한 것들이 남아 있지만,
전체적으로 내가 계획했던 것들은 마무리되어서 회고를 남겨두려고 한다.
레포지토리 주소: https://github.com/minnnidev/Wote
기간: 7월 한달 정도
인원: 나 혼자!
0번째 리팩토링 일지에서 내가 리팩토링하고자 했던 포인트를 정리하면 다음과 같다.
(https://velog.io/@minnnidev/SwiftUI-팀-프로젝트-혼자-리팩토링하기-0)
나는 ViewModel을 View에 보여줄 데이터, 모델을 정의하는 곳으로 정의했지만, 구현하다가도 항상 애매해지는 느낌이 들곤 했다.
→ API 통신을 viewmodel에서 하지 않으면 어디서?
→ 찾아보니까 usecase, repository로 분리할 수 있다고 함
→ 그리고 이건 clean architecture 기반이 될 수 있다고 함.
이러한 흐름으로 clean architecture에 관심을 가지게 되었고, 뭐야 뷰모델을 더 명확하게 정의하는데 클린 아키텍처가 도움이 되겠는데? 해서 공부하게 됨!
클린 아키텍처의 이점들은 검색하면 수두룩하게 나오니 패스하고, 내가 직접 느꼈던 장단점을 적어보자면!
클린 아키텍처는 의존성 역전을 기반으로 설계됨
유지보수나 의존성 측면에서도 많은 이점이 될 수 있음!
나는 이를 SwiftUI의 Preview를 사용하는 데도 활용했음.
- 리팩 전 우리 프로젝트는 의존성 주입 자체는 사용했지만, 의존성 역전이 적용되지는 않았음. (나도 이것에 대한 지식이 부족해서 이게 크게 잘못된 것인진 몰랐었다… 😥)
- 그러다보니 preview에서 viewModel 의존성 주입 시에 여러 에러가 발생했고, preview를 살리기 애매한 상황이면 그냥 주석처리하거나 삭제했었음.
- 지금 생각해 보면 preview가 SwiftUI의 얼마나 강력한 무기인데…! 싶다.
암튼 그래서 viewModel에서 DIP 적용을 위해 UseCase를 주입할 때
// DIP를 위한 인터페이스 프로토콜
protocol MyPageUseCaseType {
func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError>
func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError>
}
// MyPageUseCase
final class MyPageUseCase: MyPageUseCaseType {
private let userRepository: UserRepositoryType
init(userRepository: UserRepositoryType) {
self.userRepository = userRepository
}
func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError> {
userRepository.getMyVotes(page: page, size: size)
}
func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError> {
userRepository.getMyReviews(page: page, size: size, visibilityScope: visibilityScope)
}
}
// MyPageUseCase stub
final class StubMyPageUseCase: MyPageUseCaseType {
func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError> {
Just(MyVotesModel(total: 1, votes: [.myVoteStub]))
.setFailureType(to: WoteError.self)
.eraseToAnyPublisher()
}
func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError> {
Just(MyReviewsModel(total: 1, myReviews: [.reviewStub1]))
.setFailureType(to: WoteError.self)
.eraseToAnyPublisher()
}
}
// View
#Preview {
MyPageView(viewModel: .init(
myPageUseCase: StubMyPageUseCase(),
userUseCase: StubUserUseCase())
)
}
UseCaseType
프로토콜을 만들고, 이를 채택한 StubUseCase
를 만들어 프리뷰에서 주입시켰다. API가 아직 나오지 않았을 때도 쉽게 작업이 가능하고, 프리뷰만 보고도 빠르게 UI 작업을 할 수 있었다.
기존에는 그냥 하나의 enum에 NavigationStack destination을 전부 두고 관리했음.
이는 모든 destination을 한 뷰에서 관리하도록 했고, 가독성도 떨어지고 코드 길이가 엄청나게 길어지게 했음.
이를 해결하기 위해서 feature별로 나눠서 enum으로 만들었다.
그치만 공통된 곳에 사용되는 경우가 많아서 어느 정도 공통적인 destination을 만드는 건 불가피하긴 했다.
enum AllNavigation: Decodable, Hashable {
case considerationView
case writeReiview
case detailView(postId: Int,
dirrectComments: Bool = false,
isShowingItems: Bool = true)
case reviewView
case makeVoteView
case testIntroView
case testView
case settingView
case mypageView
case searchView
case notiView
case reviewDetailView(postId: Int?,
reviewId: Int?,
directComments: Bool = false,
isShowingItems: Bool = true)
case reviewWriteView(post: SummaryPostModel)
case profileSettingView(type: ProfileSettingType)
}
@Observable
final class NavigationManager {
var navigatePath = [AllNavigation]()
func navigate(_ route: AllNavigation) {
guard !navigatePath.contains(route) else {
return
}
navigatePath.append(route)
}
func back() {
navigatePath.removeLast()
}
func countPop(count: Int) {
navigatePath.removeLast(count)
}
func countDeque(count: Int) {
navigatePath.removeFirst(count)
}
func gotoMain() {
navigatePath.removeAll()
}
}
// Destination 설정
.navigationDestination(for: AllNavigation.self) { destination in
switch destination {
case .profileSettingView(let viewType):
ProfileSettingsView(viewType: viewType,
viewModel: ProfileSettingViewModel(appState: loginStateManager))
case .considerationView:
ConsiderationView(visibilityScope: $visibilityScope,
scrollToTop: $tabScrollHandler.scrollToTop,
viewModel: ConsiderationViewModel(appLoginState: loginStateManager))
case .detailView(let postId,
let showDetailComments,
let isShowingItems):
DetailView(viewModel: DetailViewModel(appLoginState: loginStateManager),
isShowingItems: isShowingItems,
postId: postId,
directComments: showDetailComments
)
case .makeVoteView:
VoteWriteView(viewModel: VoteWriteViewModel(visibilityScope: visibilityScope,
apiManager: loginStateManager.serviceRoot.apimanager),
tabselection: $tabScrollHandler.selectedTab)
case .testIntroView:
TypeTestIntroView()
.toolbar(.hidden, for: .navigationBar)
case .testView:
TypeTestView(viewModel: TypeTestViewModel(apiManager: loginStateManager.serviceRoot.apimanager))
case .reviewView:
ReviewView(visibilityScope: $visibilityScope,
viewModel: ReviewTabViewModel(loginState: loginStateManager))
case .writeReiview:
Text("아직")
case .settingView:
SettingView(viewModel: SettingViewModel(loginStateManager: loginStateManager))
case .mypageView:
MyPageView(viewModel: MyPageViewModel(loginState: loginStateManager),
selectedVisibilityScope: $visibilityScope)
case .searchView:
SearchView(viewModel: SearchViewModel(apiManager: loginStateManager.serviceRoot.apimanager,
selectedVisibilityScope: visibilityScope))
case .reviewDetailView(let postId,
let reviewId,
let isShowDetailComments,
let isShowingItems):
ReviewDetailView(viewModel: ReviewDetailViewModel(loginState: loginStateManager),
isShowingItems: isShowingItems,
postId: postId,
reviewId: reviewId,
directComments: isShowDetailComments
)
case .reviewWriteView(let post):
ReviewWriteView(viewModel: ReviewWriteViewModel(post: post,
apiManager: loginStateManager.serviceRoot.apimanager))
case .notiView:
NotificationView( viewModel: notiManager)
}
}
}
enum WoteDestination: Hashable {
case search
case setting
case notification
case voteDetail(postId: Int)
case reviewDetail(postId: Int)
case reviewWrite(postId: Int)
}
enum TypeTestDestination: Hashable {
case testIntro
case test
case testResult(typeResult: ConsumerType)
}
enum VoteDestination: Hashable {
case voteWrite
}
enum MyPageDestination: Hashable {
case modifyProfile
}
// navigation stack destination
// 필요한 곳에서, 각 feature마다
// 밑은 TypeTestDestination ex
.navigationDestination(for: TypeTestDestination.self) { dest in
switch dest {
case .testIntro:
TypeTestIntroView()
.environmentObject(router)
case .test:
TypeTestView(viewModel: appDependency.container.resolve(TypeTestViewModel.self)!)
.environmentObject(router)
case let .testResult(resultType):
TypeTestResultView(spendType: resultType)
.environmentObject(router)
}
}
사실 요 프로젝트에서는 DTO와 Model이 동일한 경우가 더 많았다.
그래도 클린 아키텍처에 정의된 각 레이어의 역할을 더 명확하게 하기 위해서 분리를 시켰고, DTO에는 Model로 매핑하는 함수들도 같이 추가했다.
그리고 model의 stub data를 stub usecase나 viewModel 등 필요한 곳에 호출하여 사용하도록 했다.
struct ProfileModel: Encodable {
let createDate: String
let modifiedDate: String
let lastSchoolRegisterDate: String
let nickname: String
let profileImage: String?
let consumerType: String?
let school: SchoolModel
let canUpdateConsumerType: Bool
}
extension ProfileModel {
static var profileStub: ProfileModel {
.init(
createDate: "2024-07-15T14:44:44.993126",
modifiedDate: "2024-07-17T15:07:21.032732",
lastSchoolRegisterDate: "2024-07-16",
nickname: "히히",
profileImage: "https://www.wote.social/images/posts/78ec4f86-5676-4c70-ae16-0334c452ec72.jpg",
consumerType: "TRENDSETTER",
school: .schoolStub,
canUpdateConsumerType: true
)
}
}
원래는 Authentication 파트만 새로 구현해 보려고 했는데,
위에서 언급했던 clean archiecture, 의존성 주입, combine에 대해서 더 익숙해지기 위해서 현재까지 35개 API를 다시 연결해 보았다.
기간이 생각보다 훨씬 오래 걸리긴 했지만, 서버 통신을 수행하는 클린 아키텍처의 플로우를 알 수 있어서 잘한 행동인 것 같다.
(세세한 에러 핸들링은 다시 도전해 보려 한다!)
학교를 검색하고, 검색 결과를 로드하는 뷰모델로 예시를 하나만 들어보면
final class SchoolSearchViewModel: ObservableObject {
@Published var schools = [SchoolInfoModel]()
@Published var isFetching = false
private let baseURL = "http://www.career.go.kr/cnet/openapi/getOpenApi"
var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "SCHOOL_API_KEY") as? String else {
fatalError("SCHOOL_API_KEY error")
}
return key
}
@MainActor
func setSchoolData(searchWord: String) async throws {
schools.removeAll()
isFetching = true
let highSchoolValues: HighSchoolResponse = try await fetchSchoolData(schoolType: .highSchool, searchWord: searchWord)
let middleSchoolValues: MiddleSchoolResponse = try await fetchSchoolData(schoolType: .middleSchool, searchWord: searchWord)
let highSchoolSchools = highSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }
let middleSchoolSchools = middleSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }
schools.append(contentsOf: highSchoolSchools + middleSchoolSchools)
isFetching = false
}
private func fetchSchoolData<T: Decodable>(schoolType: SchoolDataType, searchWord: String) async throws -> T {
guard let encodedSearchWord = searchWord.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) else {
throw URLError(.badURL)
}
let url = baseURL +
"""
?apiKey=\(apiKey)&svcType=api&svcCode=SCHOOL&contentType=json&gubun=\(schoolType.schoolParam)&searchSchulNm=\(encodedSearchWord)
"""
return try await AF.request(url, method: .get)
.validate()
.serializingDecodable(T.self)
.value
}
}
import Foundation
import Combine
final class SchoolSearchViewModel: ObservableObject {
enum Action {
case submit(_ searchSchoolText: String)
}
@Published var schools: [SchoolInfoModel] = [SchoolInfoModel]()
@Published var isLoading: Bool = false
@Published var searchSchoolText: String = ""
@Published var textFieldState: SearchTextFieldState = .inactive
private let userUseCase: UserUseCaseType
private var cancellables = Set<AnyCancellable>()
init(userUseCase: UserUseCaseType) {
self.userUseCase = userUseCase
}
func send(action: Action) {
switch action {
case let .submit(searchSchoolText):
isLoading = true
userUseCase.searchSchool(searchSchoolText)
.sink { [weak self] _ in
self?.isLoading = false
} receiveValue: { [weak self] schools in
self?.textFieldState = .submitted
self?.isLoading = false
self?.schools = schools
}
.store(in: &cancellables)
}
}
}
→ ViewModel은 화면에 보여줄 데이터에 집중, API 통신을 직접하는 행위는 하지 않도록 함
위와 같은 형식으로 다른 api들도 구현하였음!
내 기준으로 각 레이어들의 역할을 정의한 것을 최대한 지키도록 구현하였다.
를 고민 중이고, 요건 앞으로도 짬짬이 고민해 봐야 한다.
클린 아키텍처를 사용하면서 명확하게 분리를 할 수 있다는 점은 분명 너무 좋았지만
아직 테스트 코드를 작성해 본 경험이 없어 제일 큰 장점인 테스트 용이성을 경험해 보지 못한다는 게 넘 아쉽.
그리구 팀끼리도 써보면 내가 잘못 생각한 것들을 수정할 수 있을 것 같아서 의향을 물어봤는데 진입 장벽이 높고 시간이 오래 걸려서 고민이 필요하다는 답을 들음.
그러다 보니 클린 아키텍처의 단점도 꽤 크게 다가왔고, 꼭 클린 아키텍처를 사용하지 않아도 이런 레이어의 분리나 각 객체 역할만 명확하게 정의하는 코드를 작성하다면 미래의 나와 팀원에게 많이 도움이 되지 않을까라고 느꼈음!