요약: 이 글은 카카오페이에서 코드 가독성과 효율성을 높이기 위해 MVVM 아키텍처를 적용한 사례를 소개합니다. 기존의 RIBs 및 TCA 아키텍처에서 커스텀 MVVM 방식으로 전환한 과정을 설명하며, 비즈니스 로직 구현 방법을 자세히 다룹니다. 유연한 설계와 높은 생산성을 목표로 유지보수를 간소화하고 개발자 진입 장벽을 낮추기 위해 노력한 점 또한 강조합니다. 아키텍처 솔루션을 탐색 중인 개발자에게 유용한 가이드를 제공합니다.
리뷰어 한줄평
pure.water 아키텍처에 대한 하비의 많은 고민이 보이는 글입니다. 어떠한 고민이 있었고 어떻게 개발 생산성을 향상했는지 글에 잘 녹여주신 것 같습니다! 그 경험과 노하우를 함께 확인해 보아요~
시작하며
안녕하세요. 카카오페이 결제클라이언트파트에서 사장님플러스앱을 개발하고 있는 하비입니다.
저는 이번 if(kakaoAI)2024에서 ‘좌충우돌: 사장님플러스 앱 아키텍처 전환기’에 대해 발표했는데요,
발표에서는 시간관계상 MVVM에 대한 구체적인 내용을 다루지 못했던 점이 아쉬워, 이번 포스팅에서는 사장님플러스 앱에서 MVVM을 적용하며 얻은 경험과 노하우를 공유하고 MVVM을 적용하려는 다른 개발자들에게 도움이 되고자 합니다.
발표에서 말한 것처럼, 아키텍처를 사용하는 방식은 회사 또는 팀에 따라 다르기 때문에 정답이 되기는 어렵겠지만 이런 고민을 하며 사용하고 있다는 참고가 되었으면 합니다. 이 글의 대상 독자는 아키텍처에 관심있는 iOS 개발자이며 발표 영상을 시청하셨다는 것을 전제로 하겠습니다.
사장님플러스의 MVVM
아키텍처의 목표
먼저, RIBs와 TCA를 걷어내고 MVVM을 도입하기로 하면서 다음과 같은 목표를 설정하였습니다.
- 읽기 쉬운 코드를 작성하자
- 사장님플러스에 맞는 클린 아키텍처 구조를 사용하자
위 목표에는 여러 가지 이유가 있었습니다.
첫 번째 목표인 읽기 쉬운 코드
의 목표는 꽤나 명시적인데요, 바로 개발 생산성을 높이기 위함이었습니다.
기존 아키텍처는 과도한 프로토콜의 사용 및 객체 분리로 많은 코드 점핑을 일으키게 되었습니다. 그로 인해 코드 간의 연결 관계를 파악하기가 어려웠습니다.
따라서 소규모 개발자 리소스를 유동적으로 사용하기 위해서는 가독성과 직관성을 우선으로 개발하여 진입장벽을 낮추고자 했습니다.
두 번째 목표는 사장님플러스에 맞는 클린 아키텍처 구조를 사용하자
입니다.
이번 포스팅에서는 이 두 번째 목표를 어떻게 이루고자 하였는지에 더 초점을 두고 이야기를 시작해보고자 합니다.
사장님플러스에 맞는 클린 아키텍처
먼저 사장님플러스에 맞는 클린 아키텍처는 다음 두 가지 원칙을 가지고 있습니다.
- 안드로이드와 구조적으로 유사하게 설계하자
- Domain Layer의 강제성을 낮추자
안드로이드와 구조적으로 유사하게 설계하자
우선 사장님플러스의 안드로이드의 구조와 모듈 구조를 같이 설명드리겠습니다.
사장님플러스의 모듈은 발표에서 설명드린 대로 서비스 모듈, 코어 모듈, 파운데이션 모듈 등이 존재합니다.
하지만 안드로이드의 경우 각 서비스 모듈마다 Presentation Module, Domain Module, Data Module이 나누어져 있습니다. 즉 홈 모듈은 HomePresentation, HomeDomain, HomeData 총 세 개의 모듈로 구성되어 있습니다.
이렇게 피쳐의 레이어를 모듈로 나눌 경우 다음과 같은 장점이 있습니다. 홈 모듈에서 달력의 비즈니스 로직만 필요할 경우, 달력 모듈의 Domain Module만 의존할 수 있습니다. 이렇게 되면 홈에서 사용하지 않는 달력 모듈의 UI(Presentation Module)를 의존하지 않아 불필요한 의존성과 빌드 시간을 줄일 수 있습니다. 따라서 슈퍼앱으로 진화하게 될 경우 서비스 모듈을 안드로이드의 구조처럼 레이어별로 모듈화를 하는 것이 유리할 수 있다고 생각됩니다.
하지만 사장님플러스 iOS 앱의 경우, 빌드 시간의 이득보다는 모듈과 프로젝트의 복잡도를 줄이고 싶었습니다. 이유는 발표에서 설명드린 대로 프로젝트의 복잡도를 낮추어 유지보수성을 향상하고, 진입장벽을 낮추고자 함이었습니다.
따라서 서비스 모듈의 Layer의 경우 모듈 내부에서 폴더로만 구분해 놓고, 앱의 규모가 충분히 커지게 되면 분리하기로 결정하였습니다.
Domain Layer의 강제성을 낮추자
다음 원칙은 ‘Domain Layer의 강제성을 낮추자’ 인데요. 클린 아키텍처에 따르면 Presentation Layer, Data Layer는 Domain Layer에 의존해야 하고, Domain Layer는 대부분 존재합니다.
하지만 대부분의 사장님플러스 서비스에서는 Usecase가 필요할 만큼, 비즈니스 로직이 복잡한 상황이 상대적으로 적었습니다. 따라서 필요에 따라 Usecase를 선택적으로 도입하는 방식을 채택하여 개발 생산성을 높이고 코드를 간결하게 유지하고자 하였습니다.
그렇다면 Presentation Layer와 Data Layer의 의존 관계는 어떻게 되었을까요? 결론적으로 사장님플러스 iOS 앱에서는 Data Layer는 Domain Layer에 의존하지 않고, 반대로 Domain Layer가 Data Layer에 의존하도록 하였습니다.
이렇게 되면 Presentation Layer에서는 Domain Layer가 존재할 경우 Domain Layer에, 존재하지 않을 경우 바로 Data Layer에 의존하게 됩니다.
이를 그림으로 표현하면 다음과 같습니다.
이렇게 수직적 트리 구조를 가져가 Domain Layer의 강제성을 낮춤으로써 더 직관적이고, 높은 생산성을 가지게 되었습니다.
예시 코드
이제부터 화면이 나타났을 때 매장 정보를 서버를 통해 받아오는 예시 코드를 같이 확인하도록 하겠습니다. 예시 코드는 이해를 돕기 위한 코드로 실제 코드와 무관합니다.
View의 예시 코드는 아래와 같습니다.
struct StoreView: View {
@StateObject var viewModel: StoreViewModel
var body: some View {
_body
.navigationTitle("매장 정보")
.onAppear {
/// 화면에서 일어나는 액션을 뷰모델에게 전달합니다.
viewModel.send(action: .onAppear)
}
}
private var _body: some View {
/// 뷰모델의 상태를 옵저빙하여 뷰를 그립니다.
if let store = viewModel.store {
Text(store.name)
} else {
Text("매장정보 없음")
}
}
}
이렇게 View에서 onAppear가 불릴 경우 뷰모델에 action을 전달하고, viewModel의 store값을 옵저빙하여 뷰를 그리게 됩니다. ViewModel에서는 view의 action을 받아 usecase 혹은 repository에 정보를 요청하여 전달받습니다.
먼저 특별한 비즈니스 로직 없이 API를 통해 받아온 정보를 바로 표시한다고 가정해 보겠습니다.
// MARK: Presentation Layer
final class StoreViewModel: ObservableObject {
enum Action {
case onAppear
}
private let storeRepository: any StoreRepository = StoreRepositoryImpl()
@Published private(set) var store: Store?
func send(action: Action) {
switch action {
case .onAppear:
self.onAppear()
}
}
private func onAppear() {
Task {
do {
self.store = try await self.storeRepository.fetchStore()
}
catch {
self.store = .none
}
}
}
}
// MARK: Data Layer
protocol StoreRepository {
func fetchStore() async throws -> Store
}
final class StoreRepositoryImpl: StoreRepository {
func fetchStore() async throws -> Store {
/// fetch API
}
}
이렇게 Presentation Layer의 StoreViewModel은 Domain Layer를 거치지 않고 바로 Data Layer의 StoreRepository에 접근하여 서버로부터 데이터를 전달받을 수 있습니다. 다음으로는 복잡한 비즈니스 로직이 추가되는 경우를 살펴보겠습니다.
아래의 경우, 매장의 상세 토큰 조회 및 검증 로직을 Domain Layer로 추가했습니다.
// MARK: Presentation Layer
/// 뷰에서 버튼이 눌렸을 때 뷰 모델에 액션을 전달합니다.
struct StoreView: View {
/// ...
private var _body: some View {
Button {
viewModel.send(action: .buttonTapped)
} label: {
/// ...
}
}
}
/// 뷰모델에서는 StoreDetailUsecase에 매장 상세 정보를 요청합니다.
final class StoreViewModel: ObservableObject {
/// ...
private func buttonTapped() {
Task {
do {
let storeDetail = try await storeDetailUsecase.fetchStoreDetail(with: store)
/// 매장 상세로 이동
}
catch {
/// 에러 핸들링
}
}
}
}
// MARK: Domain Layer
protocol StoreDetailUsecase {
func fetchStoreDetail(with store: Store) async throws -> StoreDetail
}
struct StoreDetailUsecaseImpl: StoreDetailUsecase {
/// 매장 상세 정보를 얻어오기 위해 비즈니스 로직을 수행합니다.
/// 해당 로직은 임의로 작성되었으며, 실제 로직과 무관합니다.
func fetchStoreDetail(with store: Store) async throws -> StoreDetail {
let detailToken = tokenExtractor.extractToken(from: store)
try await tokenValidator.validate(detailToken)
let storeDetail = try await storeDetailRepository.fetchStoreDetail(with: detailToken)
guard storeDetail.status != .idle else {
throw StoreError.idle
}
return storeDetail
}
}
// MARK: Data Layer
protocol StoreDetailRepository {
/// ...
}
final class StoreDetailRepositoryImpl: StoreDetailRepository {
/// ...
}
여태까지 사장님플러스에 맞는 클린 아키텍처 코드 예시를 알아보았습니다. 복잡하지 않는 단순 서버요청의 경우 Presentation Layer에서 Data Layer에 바로 접근함으로써 생산성을 높이고, 복잡한 로직이 존재하는 경우, Domain Layer를 생성해 뷰모델의 역할을 Usecase로 위임하여 비즈니스 로직에 집중할 수 있게 되었습니다.
마치며
지금까지 사장님플러스에서 어떻게 MVVM을 사용하고 있는지 알아보았습니다. 회사의 상황에 맞게 진입장벽이 높은 써드파티 아키텍처를 제거하고, 누구나 이해할 수 있는 클린 아키텍처 기반의 MVVM을 도입하였습니다. 그 과정에서 클린 아키텍처를 사장님플러스앱에 맞게 해석하여 사용함으로써 유연한 설계와 높은 생산성을 가져갈 수 있었습니다.
덕분에 기존 시스템이 가진 한계를 개선하고, 더 효율적이며 안정적인 환경을 구축할 수 있었습니다. 앞으로도 이를 바탕으로 업무 생산성을 높이고, 사용자가 실질적으로 체감할 수 있는 개선을 이어가고자 합니다. 아키텍처에 고민이 있는 개발자들에게 조금이나마 도움이 되는 글이었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다!