안녕하세요!
The Month (iOS) 서비스의 기획자 및 iOS 개발자를 담당하고 있는 파이집🥧 입니다! 혹시 궁금하시다면 다운 받아보시면 좋겠습니다 ㅎㅎ
앱스토어 링크
The Month - 나만의 월간 꼴라쥬 캔버스
SwiftUI + Coordinator + Clean Architecture + Factory + MVVM + 모듈화
제 글 중에서도 Clean Architecture 를 다룬 글이 있습니다.
로버트 마틴 행님이 쓰신 Clean Architecture 도 1회 완독하고,
Swift 에 CleanArchitecture 를 적용시킨 TCA 를 공부하면서 익혔었죠.
하지만 TCA 를 쓰면서, 여러 불편함을 느꼈었습니다.
TCA 레포
1. TheComposableArchitecture 라는 Third Party 라이브러리를 필수로 써야한다. (그것이 TCA 니깐. 끄덕)
2. 계속 업데이트가 되는 라이브러리다. 즉, 안정된 버전이 없다.
3. 실제로 TCA 개발 당시에, 버전의 Major 업데이트가 있던 직후 라서 제대로된 레퍼런스가 없어서 TCA 개발 Slack 에 들어가서 Slack 개발진에게 직접 이런 상황에서 어떻게 사용해야하냐 라는 질문을 남기고 답을 들었었습니다. 장점이자 단점이겠죠..?
이런 이유들 때문에 TCA 는 충분히 한번 익혀봤으니,
"SwiftUI 에서 클린아키텍쳐를 사용해봐야겠다." 라고 생각이 들었습니다.
아직은 SwiftUI 가 프로덕트 라인에 적합한가? 라는 의견을 묻는다면, 어떤 기능을 가진 앱이냐에 따라 답이 갈리겠지만, 저는 UIKit 과 RxSwift 를 일정 수준 이상 사용해봤다고 생각했습니다. 프로덕트를 2개 이상 만들어서 유지보수도 해봤습니다.
그래서 이번엔 Combine 과 SwiftUI 를 사용해서 한번 서비스를 만들어보고 이때, Clean Architecture 를 제대로 활용해보자 라는 도전 정신이 들었습니다.
그래서 한참 레퍼런스와 Swift 에서는 어떻게 사용되는지 여러 레포를 돌아보며 배워나갔습니다... 그리고는 정착했죠!
서두가 좀 길었죠? 이제 그 정착한 Clean Architecture + MVVM + SwiftUI 이야기에 대해서 해보겠습니다!
설명을 위해, 다음 그림을 보시죠.
클린 아키텍쳐라면 누구나 다 접하시는 다이어그램이죠? (근데 Mobile Application 을 곁들인)

출처: https://medium.com/@it.hhkn/clean-architecture-with-mvvm-5c1410e548c4
그렇기 때문에, 개발의 순서는 반대로 안에서 바깥으로 나가야 한다는 것을 알 수 있죠.
Domain -> Data -> Presenters 순으로 개발을 진행했습니다.
프로젝트를 조금 진행해 보시거나 하신 분들은 체감을 하실 겁니다.
맨 처음에 UI 를 먼저 뚝딱 거리고, 이후에 Mock 데이터로 그 빈 데이터 자리를 채우고 나서, 나중에 네트워크나 모델을 만들면?
먼저 만든 UI 코드를 나중에 생긴 Repository 나 Service 의 코드에 맞게 수정을 하게 된다는 부정할 수 없는 사실을요. 그리고 가끔 내가 만든 코드가 결국 다른 사람의 UI 코드까지 바꾸게 한다는 그 상황을요!
4, 5개의 iOS 프로젝트를 진행하면서 뼈저리게 느꼈습니다.
"아 절대 UI 코드부터 짜면 좋지 않겠구나. 빠르게 코드 진행이 되는 것 같아서 기분은 좋을지 모르겠는데 나중에 가면 갈수록 힘들어지는구나."
-> 이 단락에서 말한 것이 사실 클린아키텍쳐 탄생의 배경이라고 할 수 있을 겁니다?
그래서 위의 순으로 개발을 진행했습니다.
그럼 이제 각각의 Layer 에서 어떤 것들이 쓰였는지 봐야겠죠?
당연히 Swift 를 통해서요!

위의 그림은 제가 Domain 모듈에서 어떤 레이어를 만들었는지를 보여주는 그림입니다.
Domain 에는 기본적으로 UseCase 와 Repository 를 가지고 있는데, 여기서 중요한 점을 짚고 넘어가야합니다.
바로 Domain 은 개발 Framework 인 SwiftUI 와 UIKit 을 몰라야 한다는 점입니다. 즉, Foundation 이라는 기본 프레임워크만 사용해서 가져가야 한다는 점입니다.
이게 우리 로버트 마틴 행님의 본론이죠?
저는 Clean Architecture 가 따르는 그 정수를 그대로 따라가보자 라는 식에서 Domain 에는 SwiftUI 와 UIKit 을 사용하지 않았습니다. 즉, 의존하지 않았습니다.
하지만 모바일 앱 이라는 특수성을 생각하면 Domain 에서 UI 관련 Framework 를 안쓰는게 더 이상해보이기는 합니다... 사실 저는 썼으면 했습니다.
나중에 Presenters 에서 Mapping 을 필수로 해야하기 때문에 매우 좀 골치가 아프기 때문입니다.
하지만 그럼에도 저는 Mapping 을 안고 갔고, 여러분들은 편하신대로 하시면 될 것 같습니다.
아무튼 다시 돌아와서...
가장 변동성이 적고, 핵심이 되는 데이터 모델의 인터페이스를 이 Entities 에 저장합니다.
다음 그림은 제가 어떻게 Entities 를 구성했는지를 보여줍니다.

import Foundation
public protocol TemporayDraft: DiaryDraftType {
var id: String { get }
var createdDate: Date { get set }
var lastModifiedDate: Date { get set }
var title: String { get set }
var body: String { get set }
var font: String { get set }
var fontSize: Double { get set }
var fontColor: String { get set }
var backgroundColor: String { get set }
var backgroundImage: String? { get set }
}
The Month 에서 글을 기록하는 파트에서 쓰이는 모델의 인터페이스 코드입니다.
Entities 에서는 모델의 인터페이스를 만들고, 구현체는 만들지 않습니다.
그 이유는, DI 와 모듈 간 결합도 감소를 위해서 입니다.
다른 저수준의 모듈에서 Entities 만을 의존해서 조금 더 여유롭게 모델을 쓰기 위해서 입니다.
간단하게 말하면, SwiftData 의 Persistent Model 과 UI 에 사용되는 모델을 따로 만들고 이를 구분하지 않고 사용하기 위함입니다. 결합도가 너무 높다면 유연하게 대처할 수 없기 때문에 그렇습니다.
이 모델의 인터페이스는 나중에 Data 나 Presenter 라든가 상대적으로 저수준의 모듈에서 사용됩니다.
UseCase 가 정확히 어떤 역할을 하는지(비즈니스 로직)를 정해주는 인터페이스 입니다.
즉, UseCase 가 어떤 일을 하는지에 대한 역할을 정해줍니다.

import Foundation
import Entities
public protocol DeleteTemporaryDiaryDraftUseCaseProtocol {
func execute(id: String) async throws -> Bool
}
왜 이렇게 귀찮게 인터페이스를 만들고, 구현체를 만드냐 하시면 결국 DI 때문이라고 생각하시면 됩니다.
저는 특정 기능에 따라 하나하나 모두 UseCase 를 만들었습니다. 그렇기 때문에 당연히 그 UseCase 의 Protocol 이 필요합니다.
코드를 보시면,
이 UseCase 가 하는 일은, 어떤 id 를 가지고 무슨 일을 하는데, 그게 async 하고 throws 하고 Bool 을 리턴하는 역할을 한다. 그걸 정해주겠다.
라는 것을 알려주는게 바로 이 UseCaseProtocol 인터페이스라고 보시면 됩니다.
여기서는 TemporaryDiaryDraft 를 지우는 기능을 하는 UseCase 의 인터페이스인 것을 알 수 있고, id 를 가지고 작업하고 Bool 을 내뱉는구나 라는 것을 알 수 있죠.
Data 모듈에서 만들어질 Data 와 관련된 로직의 인터페이스 입니다.
Data 모듈에서 만든 Repositry 에 어떤 비즈니스 로직이 있는지 정해줍니다.

import Foundation
import Entities
public protocol DiaryRepositoryProtocol {
...
func deleteTemporaryDraft(id: String) async throws -> Bool
func deleteTemporaryDraftIfExpired() async throws
func deleteDiary(id: String) async throws -> Bool
func fetchTotalDiaryCount() async throws -> Int
...
}
데이터의 추상화를 이 RepositoryProtocol 에서 정합니다.
그러면 Data 모듈에서의 Repository 는 해당 Protocol 을 따르는 구현체가 됩니다.
UseCase 는 특정 기능에 대한 비즈니스 로직을 가지고 있습니다.
이 로직에는 2개가 필요합니다.
바로 UseCaseProtocol 과 RepositoryProtocol 이 필요하죠.
UseCase 는
1. UseCaseProtocol 을 준수하므로, 어떤 로직을 실행할 것인지에 대한 protocol 에서 만든 함수를 가지게 됩니다.
2. 그 함수의 구현부를 RepositoryProtocol 을 통해 가져옵니다.
import Foundation
import Entities
import RepositoryProtocol
import UseCaseProtocol
public final class DeleteTemporaryDiaryDraftUseCase: DeleteTemporaryDiaryDraftUseCaseProtocol {
private let repository: DiaryRepositoryProtocol
public init(repository: DiaryRepositoryProtocol) {
self.repository = repository
}
public func execute(id: String) async throws -> Bool {
try await repository.deleteTemporaryDraft(id: id)
}
}
이해가 가시나요?
이 모든게 결국 DI 를 위해서 설계를 하고 있는겁니다!!
특히 프로젝트가 커지고, 모듈화를 진행하며 이 Layer 가 도드라지게 나뉠 경우, 더더욱 필수 요소가 됩니다.
네!! 맞습니다! 왜냐하면 Domain 은 이 서비스(앱)를 작동하게 하는 모델과 로직에 대한 틀을 잡는 역할을 합니다.
아파트를 지을 때(앱을 만들 때)를 예시로 들어보죠.
즉, 이 기반(Domain)이 제대로 잡혀 있지 않으면,
나중에 저수준(Data, Presenters)에서 코드를 변경할 때
이 Domain 을 변경해야하는 상황이 생깁니다.
-> 😬 가구를 여기서 저기로 옮기는데, 지하 3층에 있는 콘크리트 지반을 깨부숴야한다고..?
라는 극단적인 상황이 나올 수 있다는 것이죠.
뭐 간단한 예시이기 때문에, 비약이 있을 수 있지만 그만큼 이 Domain 에서 미리 잘 설계를 해놔야하고, 잘 정해야 한다는 말과 같죠.
Clean Architecture 를 아예 모르는 사람이 봤을 때는, 조금 이해가 가지 않을 수 있습니다. 하지만 이 Clean Architecture 를 공부를 했지만, 어떻게 이 Swift 와 이어야 하는지에 대한 고민이 있으신 분들에게는 조금이나마 도움이 됐을거라 생각합니다.
왜냐면 아키텍쳐 수준과 소스 코드 수준은 완전히 다르기 때문에 아키텍쳐를 아는 것과 그걸 소스 코드에서 활용하여 디자인 패턴을 만드는 것은 다른 이야기이기 때문이죠...
아머턴! 이 글이 조금이나마 도움이 되길 바라며...
여기서 이번 글을 마칩니다! 파이집이었습니다!