SLP란 현재 싹에서 5주 동안 진행하였던 Service Level Project의 약자입니다.
본 글에 나오는 SLP라는 명칭은 앱 이름이라고 생각 해주시면 됩니다.
SLP앱은 위치기반 주변에 같은 취미를 가지고 있는 새싹들을 찾아 매칭하여 채팅할 수 있는 앱이며,
현업 서비스 수준의 기술을 담은 규모있는 프로젝트 입니다.
이번 포스팅은 SLP 앱을 개발하며 적용시킨 iOS클릭아키텍처에 대하여 정리한 페이지입니다...!
아래의 이미지들은 직접 구상한 이미지로 무단 도용을 금지합니다
시작하기 전에
SLP는 4명이 한 팀이 되어 같은 프로젝트 각각 구현하며 어떤 기술스택을 적용할지, 또는 이슈는 무엇이였는지 공유하는 형태로 진행되었습니다. 이번
SLP를 하면서 얻은 경험을 말하라고 한다면, 빠질 수 없는 첫번째가 새로운 아키텍처의 적용입니다. 저는 SLP이전 앱에선 주로 MVVM 아키텍처를 이용하여 앱을 구성하고 출시해왔습니다. iOS를 공부하는 입장에서, MVVM보다 조금더 큰 아키텍처를 적용해보면 나중에 어떤 아키텍처를 처음 마주치더라도 적용시키는데 두려움이 없지 않을까? 하는 생각에 팀원들에게 클린 아키텍처를 적용시켜보는 것이 어떻냐고 의논을 하게 되었고, 적용시키게 되었습니다.
SLP 클린 아키텍처 한눈에 보기
클린아키텍처는 Robert C Martin(Uncle Bob)님이 처음으로 제안하셨으며, 3가지 영역으로 구성됩니다. (참고로 엉클밥님은 솔리드 원칙, 또는 소프트웨어 개발 방법론중 에자일 기법을 소개하신 유명한 소프트웨어 전문가입니다.)
SLP는 클린아키텍처를 기반으로 프레젠테이션 계층, 도메인 계층, 데이터 계층으로 나뉘며, 클린 아키텍처는 비지니스 로직의 분리와 의존성을 도메인쪽으로 향하게끔 설계하였습니다. 그리고 SLP에서는 Presentation Layer계층에서 Coodinator를 이용한 MVVM으로 구성하였기 때문에 종합적으로는, MVVM-C에 클린아키텍처를 적용시킨 프로젝트입니다.
원들의 관계
SLP 폴더 모습과 전체 구조를 원으로 재구성한 모습입니다. SLP에 Data계층에 들어갈 레파지토리를 간단하게 설명하자면 이렇습니다.
- Realm, Socket : 유저들 간의 채팅을 저장하고 실시간 통신을 위해 필요합니다.
- API : User, Shop, Chat, Home, MyPage 등에서 Restful API통신이 필요합니다.
- In App: 자신의 새싹 캐릭터, 배경을 구매하기 위해 인앱 결제가 필요합니다.
- Firebase: APNs (채팅 메세지 , 매칭상태 등)과, 전화번호 인증을 통한 로그인, 가입 절차를 파이어베이스 idToken을 이용하기 때문에 필요합니다.
Presentation 계층을 간단하게 설명하자면 이렇습니다.
- Coodinator: 화면 전환코드를 따로 담당해줄 코디네이터 패턴을 이용하였습니다.
- ViewModel: UI의 화면 로직에 관한 처리를 해줄 뷰모델을 구성하였습니다.
소스 코드의 의존성은 어디서든 도메인쪽으로 향하고 있고, 원으로 보았을 땐 점차 안으로 향하게끔 설계되었습니다. 의존성이 안으로 향하는 설계란, 안쪽에서는 바깥쪽에 어떠한 코드도 알지 못하는 상태를 이야기 합니다. 이부분은 코드로 살펴보며 조금 더 자세하게 살펴보겠습니다!
Domain 계층
팀원JD와 저는 처음 클린 아키텍처를 적용시켜보며, 가장 많은 회의를 하였던 부분이 도메인 계층이였습니다. 도메인 계층으로 모든 의존성이 주입되고 있다고 위에서 설명하였습니다. 그렇다면 도메인 계층은 상위 계층을 알지못하는 상태이며, 즉, 도메인 계층에선 상위 계층의 어떠한 코드도 참조하지 않고 있어야합니다. 이를 위해 도메인에서 레포를 선언할 때에는 레파지토리 프로토콜 타입으로 외부에서 객체생성을 주입받게 됩니다. 이를 클린아키텍처에선 인터페이스라고 부릅니다.
의존성이 도메인으로 향하게 구성하기
레파지토리 인터페이스
import Foundation
import Moya
protocol SesacRepositoryType: AnyObject {
func requestUpdateFCMToken( // FCM 토큰 업데이트 API
tokenInfo: UpdateFCMTokenQuery,
completion: @escaping (
Result< Int,
SesacNetworkServiceError>
) -> Void
)
func requestUserInfo( // 유저정보 API
completion: @escaping (
Result< UserInfo,
SesacNetworkServiceError>
) -> Void
)
func requestRegister( // 회원가입 API
userRegisterInfo: UserRegisterQuery,
completion: @escaping (
Result< UserInfo,
SesacNetworkServiceError>
) -> Void
)
...
}
새싹 레파지토리의 프로토콜 모습, 인터페이스로 도메인에 사용될 것.
유즈케이스 예시
import Foundation
import RxSwift
final class CertificationUseCase {
private let userRepository: UserRepositoryType
private let fireBaseRepository: FirbaseRepositoryType
private let sesacRepository: SesacRepositoryType
var failFirebaseFlowSignal = PublishSubject<FirbaseNetworkServiceError>()
var successLogInSignal = PublishSubject<Void>()
var unRegisteredUserSignal = PublishSubject<Void>()
var unKnownErrorSignal = PublishSubject<Void>()
var retransmitSuccessSignal = PublishSubject<String>()
init(
userRepository: UserRepositoryType,
fireBaseRepository: FirbaseRepositoryType,
sesacRepository: SesacRepositoryType
) {
self.userRepository = userRepository
self.fireBaseRepository = fireBaseRepository
self.sesacRepository = sesacRepository
}
}
유즈케이스에서 sesacRepository 레포 생성 또한 타입형이 프로토콜 타입임을 알 수 있습니다.
외부에서 Dependency Injection 모습
final class AuthCoordinator: Coordinator {
weak var delegate: CoordinatorDelegate?
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
var type: CoordinatorStyleCase = .auth
private let userDefaults = UserDefaults.standard
init(_ navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
if userDefaults.bool(forKey: UserDefaultKeyCase.isNotFirstUser) {
showLoginViewController()
} else {
showOnboardingViewController()
}
}
...
func showCertifacationViewController(verifyID: String) {
let viewModel = CertificationViewModel(
verifyID: verifyID,
coordinator: self,
certificationUseCase: CertificationUseCase(
userRepository: UserRepository(),
fireBaseRepository: FirebaseRepository(),
sesacRepository: SesacRepository() //외부 의존성 주입
)
)
let vc = CertificationViewController(viewModel: viewModel)
navigationController.setNavigationBarHidden(false, animated: false)
navigationController.pushViewController(vc, animated: true)
}
...
}
외부에서 객체 생성을 할 때에는 SesacRepositoryType 프로토콜을 따르는 SesacRepository객체를 생성하여 주입함을 볼 수 있습니다. 이는 SesacRepositoryType뿐만 아니라, 모든 레파지토리가 이러한 인터페이스로 접근합니다. 이러한 방식으로 유즈케이스는 상위계층에 관한 코드를 알지 못하는 상태가 됩니다.
Use Case와 Entity, Model에 대한 고찰 ❗️
- Entity , Model : 특정 도메인에서 사용되는 struct모델. Entity대신 Model이라 사용해도 무방하다.
- Use case: Application Business Rules
클린 아키텍처를 구성하면 유즈케이스는 비지니스 로직을 담당하기 때문에, 비유를 들자면 일종의 종합터미널 같다는 느낌을 많이 받았습니다. 유즈케이스에서 여러 비지니스 로직이 알맞은 방향으로 보내주고 응답이 온다면, 프레젠테이션 영역에 옵저버블과 같은 형태로 이벤트를 전해주기 때문입니다. 이 부분에서 팀원JD와 가장 많은 회의를 하였었는데요. 그렇다면 비지니스 로직은 무엇일까?라는 주제로 의견 공유를 많이 하였습니다.
<비지니스 로직>이라고 단어만 본다면 어딘가 명확하지 않고, 추상적인 느낌이 강한 단어입니다. 그래서 어떤 팀원은 Use case에 이러한 코드를 넣게 됩니다. 사용자가 어떤 텍스트를 입력하였을때, Validation검증 또한 비지니스 로직 이라고 생각하여 Usecase에 두어야한다하여 Validation 또한 유즈케이스에 넣게 됩니다. 이에 대해서 저는, Validation은 View Model로 빼야할 것 같다고 생각은 하였지만, 당시 비지니스 로직을 정확히 설명할 수 없었기에 왜그래야하는지 제대로된 설명은 할 수가 없었습니다. 그 밖의 상황에서도 서로가 정의하는 비지니스 로직이 다르다보니유즈케이스가 모호한 형태로 비대해지는 상황이 발생할 수 있었습니다. 그러다가 일관적이지 못한 형태로 코드가 나아갈 수 있음을 인지하였습니다. 그래서 하루는 유즈케이스의 비지니스 로직은 대체 무엇인가? 에 관한 토론만 온종일 했던것 같습니다.
최대한 관련 많은 레퍼런스를 찾아보고, JD와 오랜 토론 끝에 저희는 이런 결론을 내렸습니다.
Use case에서 비지니스 로직이란 Entity(또는 model)를 얻어내고 있는 로직을 의미한다.
'Usecase 원 안에 있는 Entity(model)을 얻어내고 있는 로직이 아니라면, 비지니스 로직으로 여기지 않는다'고 할 수 있겠습니다. 이러한 정의를 명확히 내리고 간다면, 앞서 설명한 Validation 검증은 Usecase의 역할이 아닌 View Model의 역할임을 결론 내릴 수 있고, 이후에 어떠한 상황에서도 일관적인 코드를 작성할 수 있게 되었습니다.
Presentation 계층
- Coordinator: 화면 전환과 관련된 로직
- View: UI
- View Model: View에 관련된 로직들
프레젠테이션은 Entity(또는 Model)을 View에 표현하는데 필요한 계층입니다. 디자인 기획이 변한다면 변할 수 있는 계층이라 볼 수 있습니다. 코디네이터 적용기를 이야기하자면 이야기가 많이 길어지니, 따로 글을 빼서 포스팅하도록 하고, View Model에 관한 이야기를 집중적으로 해보겠습니다.
View Model은 Rxswift를 이용한 Input - Output패턴으로 구성하였습니다.
예시
import Foundation
import RxCocoa
import RxSwift
final class HomeSearchViewModel: ViewModelType {
private weak var coordinator: HomeCoordinator?
private let useCase: HomeSearchUseCase
private let userCoordinate: Coordinate
struct Input {
let viewWillAppearSignal: Signal<Void>
let searhBarTapWithText: Signal<String>
let itemSelectedSignal: Signal<HobbyItem>
let sesacSearchButtonTap: Signal<Void>
}
struct Output {
let hobbyItems: Driver<[HobbySectionModel]>
let removeSearchBarTextAction: Signal<Void>
let showToastAction: Signal<String>
let indicatorAction: Driver<Bool>
}
var disposeBag = DisposeBag()
private let hobbyItems = BehaviorRelay<[HobbySectionModel]>(value: [])
private let removeSearchBarTextAction = PublishRelay<Void>()
private let showToastAction = PublishRelay<String>()
private let indicatorAction = BehaviorRelay<Bool>(value: false)
init(coordinator: HomeCoordinator?, useCase: HomeSearchUseCase, coordinate: Coordinate) {
self.coordinator = coordinator
self.useCase = useCase
self.userCoordinate = coordinate
}
func transform(input: Input) -> Output {
input.viewWillAppearSignal
.emit(onNext: { [weak self] in
guard let self = self else { return }
self.indicatorAction.accept(true)
self.requestOnqueue(coordinate: self.userCoordinate)
})
.disposed(by: disposeBag)
...
}
}
Rxswift를 이용한 Input Output Pattern은 이전 프로젝트에서도 몇번 적용시켜본 경험이 있습니다. Input - Output 패턴을 사용하며 가장 체감하였던 장점은 로직의 분리를 슥- 보아도 한눈에 감이 온다는 장점이 있습니다. 그리고 어떤 인풋이 왔을때 처리해야 할 아웃풋이 2개 이상이더라도 비교적 난잡해지지 않은 코드로 처리가 가능하다는 장점도 있었습니다.
Rxswift를 조금 공부하신 분들이라면 Binder와 Driver의 차이점은 알고 계실거라 생각이 됩니다. 무엇보다 중요한 것이 스트림을 공유한다는 사실인데요. 저는 Input Output Pattern을 View와 연결할 때 저는 주로 릴레이를 이용 (PublishRelay와 BehaviorRelay를 이용)하여 바인딩(Signal 또는 Driver) 하였고, viewDidLoad와 같이 한번만 방출되는 이벤트라면 그냥 Observable을 이용하여 구독하기도 하였습니다.
Signal과 Driver의 차이는 조금 생소하신 분들도 계신것 같습니다. 이것의 차이는 PublishRelay를 바인딩 하느냐 BehaviorRelay를 바인딩 하느냐에 따른 차이에 있습니다. 참고하였던 히오님 블로그 링크를 아래에 첨부하겠습니다.
https://linux-studying.tistory.com/28
링크안에 Rxswift issue 답변 내용을 요약하자면 이러합니다.
BehaviorRelay/BehaviorSubject는 상태를 나타낸다.
PublishRelay/PublishSubject는 이벤트를 나타낸다.
따라서 사실상 PublishRelay/PublishSubject는 Signal을 사용하는 것이 맞는 것이다.
그러면 Publish*를 사용할지 Behavior*를 사용할지 구분하고, 적절한 Signal과 Driver를 사용하면 됩니다. 이것의 구분은 마지막 상태 '값'을 저장해야하는지와 초기값이 필요한지 안한지에 대하여 필요하지 않다면, PublishRelay를 사용하였습니다. BehaviorRelay를 무분별하게 사용한다면 초기 값 이벤트를 방출하기 때문에 원하지 않는 플로우가 진행 될 수 있습니다 !
Data 계층
- Data Mapping(DTO) : JSON응답값을 도메인으로 매핑하기 위한 객체. 따라서 도메인에 의존한다.
- Repository: 외부 DB API로부터 데이터를 처리하는 책임을 가진다.
DTO와 Domain안의 Entity(model)은 무슨 차이일까?
초반에 JD와 의논을 하면서 DTO의 역할에 대해서 생각하게 되었습니다. DTO는 영어 뜻에 있듯이 Data Tranfer Object라는 의미입니다. 도메인에 필요한 Struct형태로 변환하는 역할 또한 가지고 있습니다.
Data Layer - OnqueueResponseDTO 예시
struct OnqueueResponseDTO: Codable {
let fromQueueDB, fromQueueDBRequested: [QueueDB]
let fromRecommend: [String]
}
struct QueueDB: Codable {
private enum CodingKeys: String, CodingKey {
case userID = "uid"
case nick
case latitude = "lat"
case longitude = "long"
case reputation, reviews
case hobbys = "hf"
case gender, type, sesac, background
}
let userID, nick: String
let latitude, longitude: Double
let reputation: [Int]
let hobbys, reviews: [String]
let gender, type, sesac, background: Int
}
extension OnqueueResponseDTO {
func toDomain() -> Onqueue {
return .init(
fromSesacDB: fromQueueDB.map { $0.toDomain() },
fromSesacDBRequested: fromQueueDBRequested.map { $0.toDomain() },
fromRecommend: fromRecommend
)
}
}
Domain Layer - Onqueue Model 예시
struct Onqueue {
let fromSesacDB, fromSesacDBRequested: [SesacDB]
let fromRecommend: [String]
}
struct SesacDB {
let userID, nick: String
let coordinate: Coordinate
let reputation: [Int]
let hobbys, reviews: [String]
let gender: GenderCase
let type: GenderCase
let sesac: SesacImageCase
let background: SesacBackgroundCase
}
DTO는 코더블 프로토콜을 채택하여 JSON Parsing을 통한 Data받아오기가 가능합니다. 그에 비해 도메인 계층에 Model Onqueue는 아무것도 채택하고 있지 않은 심플한 구조체 모습입니다. 또 다른 다른점은 DTO에서는 gender , type, sesac, background들이 Int 타입인데 Mapping후에는 GenderCase, SesacImageCase, SesacBackgroundCase 등등의 타입으로 변환되고 있음을 알 수 있습니다. 이렇게 도메인의 엔티디가 요구하는 형태로 이미 Data계층을 거치며 Mapping하여 가져오기 때문에 Presentation에서 쓰일때 별도의 Mapping작업없이 사용할 수 있었습니다.
SLP 종합 설계도
SLP의 종합 설계도를 그림으로 표현하자면 위와 같습니다. 각각의 로직 분리와 의존성 주입을 통해 테스트에 용이한 아키텍처이기 때문에 JD와 회의 끝에 테스트코드를 적용하기로 하였으며, 관련 포스팅은 추후에 따로 올리도록 하겠습니다.
Rxswift 사용은 어디까지?
SLP에서 Rxswift는 Presentation에서 뷰에 대해 사용하였고, Use case에선 Relay생성뒤 이벤트 방출에 사용하였습니다. 네트워크 통신에서는 Rxswift를 쓰지 않았는데, 이유는 Swift5에서 <Result> 제네릭 열거형이 나온 후로 네트워크의 응답값을 Result 타입을 이용하여 제네릭으로 뽑는다면, 굉장히 짧은 코드로 작성 할 수 있었기 때문이였습니다. Rxswift를 사용하여 제네릭으로 묶었을 때보다 짧고 깔끔한 코드가 가능하였기 때문에, Rxswift를 사용할 필요성을 느끼지 못하였습니다.
Repository 예시
extension SesacRepository {
func requestUpdateShop(updateShop: UpdateShopQuery, completion: @escaping (Result<Int, SesacNetworkServiceError>) -> Void) {
let requestDTO = UpdateShopRequestDTO(updateShop: updateShop)
provider.request(.updateShop(parameters: requestDTO.toDictionary)) { result in
self.process(result: result, completion: completion)
}
}
func requestPurchaseItem(itemQuery: PurchaseItemQuery, completion: @escaping (Result<Int, SesacNetworkServiceError>) -> Void) {
let requestDTO = PurchaseShopItemRequestDTO(itemInfo: itemQuery)
provider.request(.purchaseShopItem(parameters: requestDTO.toDictionary)) { result in
self.process(result: result, completion: completion)
}
}
}
TMI로 응답값에 데이터가 있는 경우라면 응닶값을 DTO변환하는 작업을 레파지토리에서 했어야하기 때문에 제네릭을 활용하진 못했습니다. 이부분은 Rxswift를 사용하여도 동일하였습니다.
나중에 JD와 회의하며 나온 이야기인데 Rxswift로 네트워크 통신을 처리하였을때, 네트워크 통신이 실패처리나 통신에 시간에 대한 제약을 주는 경우에는 Rx가 편할 수 있지 않았을까 정도로 생각이 들긴 하였습니다.
회고
클린 아키텍처를 SLP에 적용시켜보면서 체감했단 장점들을 나열해보자면 <코드 수정을 할 때, 다른 계층에 영향을 주지 않고 수정을 할 수 있다>, <역할이 명확하다보니 테스트코드를 짜기 편리하다> 등이 있었습니다. 그 중에서 가장 크게 만족했던 점은 계층간의 역할을 아키텍처만의 기준으로 구분짓고 나누어 보는 경험을 하였단 사실입니다. 이 말에는 많은 뜻이 담겨있는데요. 우리는 코드를 짜다보면 100줄의 코드를 한 함수에 통째로 짜버릴 수도 있지만, 클린 코드처럼 전체 코드량은 많아질지라도 기능 단위를 기준으로 여러 함수로 쪼개어 합친 형태로 코드를 짤 수도 있습니다. 아키텍처도 이와 마찬가지로 MVC패턴을 쓴다면 View Controller에 대부분의 코드를 짤 수도 있겠지만, 거대한 아키텍처를 사용할수록 그 아키텍처만이 가지고 있는 기준으로 로직의 역할을 더욱더 세밀하게 나누고, 계층을 꾸릴 수 있습니다. 이 과정을 한번 경험해보는 것은 그렇지 않은 것과 차이가 굉장히 크다고 생각을 합니다.
이전의 저는 새롭거나 큰 규모의 아키텍처에 대한 두려움을 가지고 있었습니다. SLP를 하기전에는 아키텍처의 설계도를 보았을 때, Use case 나 Entity, DTO, Repository 이러한 단어들을 보면 '그냥 글자구나..' 하였고, 무슨 역할을 하는지 전혀 감이 오지 않았습니다. 한편으론 '내가 저런걸 혼자 레퍼런스를 찾아가며 이해할 수 있을까..?' 하는 생각에 두려움도 있었습니다. 하지만 SLP 이후에 , 등과 같은 큰 아키텍처의 설계도를 다시 살펴보면, 얘는 이런 역할을 하나보다 생각이 들더군요. 예를들어, Interactor같은 VIPER에 첨보는 계층을 보았을때, '저기서 비지니스 로직을 처리하나보다'라고 얼추 감이 오게 되었습니다.
어떤 아키텍처를 사용할 수 있는지도 중요할 수 있지만, 그보다 중요한 것이 새로운 아키텍처를 마주하였을 때, 레퍼런스를 찾아가며 스스로 고민할 수 있는 능력도 중요하단 생각을 하게 되었습니다. 그리고 그러려면 먼저 아키텍처에 대한 거부감, 두려움을 없애는 마음가짐에서부터 시작하여야한다고 생각합니다. 이번 SLP에 규모가 있는 아키텍처를 적용시켜 봄으로써, 부족할지라도 새로운 아키텍처에 대한 두려움 보단 자신감을 가지게 되었고, 한 층 성장 할 수 있었던 프로젝트 였던 것 같습니다.
참고자료
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
'🍒 iOS 개발 > 기술 포스팅' 카테고리의 다른 글
[SLP] 코디네이터패턴 적용기 MVVM-C 포스팅 (0) | 2022.02.27 |
---|---|
[새싹 커뮤니티 앱] Rxswift + MVVM 기술 포스팅 (6) | 2022.01.07 |