소모임을 만들고 다양한 사람들과 교류하는 앱
| 라운지 | 홈 | 채널 채팅 |
|---|---|---|
![]() |
![]() |
![]() |
| 채널 설정 | DM 목록 | DM 채팅 |
|---|---|---|
![]() |
![]() |
![]() |
- 인원: iOS 2명, 서버 1명
- 기간: 2024.11.25 - 2024.12.19(약 3주)
- 버전: iOS 15+
| 김상규 | 홍정민 |
|---|---|
| @skkim125 | @jmzzang |
| 홈, 라운지, DM 목록, DM 채팅 | 채널 채팅, 채널 설정, 안읽은 메시지 카운팅 |
Github Flow를 기반으로 한 커스텀 브랜치 전략
- Github 브랜치의 Main 브랜치를 Develop 브랜치로 변경하여 사용
- 각 기능별 Feat 브랜치로 구분하여 개발 및 PR을 통한 분기 병합
- 그 외 Add, Refactor, Fix 등의 브랜치를 용도별로 사용
- 라운지: 라운지 생성 및 참여
- 홈: 내가 속한 채널 및 DM목록 표시
- DM: 최신 주고받은 DM 목록 확인 및 라운지 멤버 탐색
- 채팅: 실시간 채널, DM 채팅 전송
- iOS: Swift, UIKit
- Architecture: Clean Architecture, MVVM + Coordinator, Singleton
- Socket: SocketIO
- DB & Network: Realm, Moya
- Dependency Injection: Swinject
- Reactive: RxSwift, RxDataSources, RxGesture
- UI: UIKeyboardLayoutGuide, SnapKit, Kingfisher, Toast
- Clean Architecture를 채택하여 각 계층간의 역할 분리를 통해 의존성 감소 및 유지보수성 증가
- Coordinator 패턴을 통한 화면 전환 로직 분리
- Swinject과 Assembly를 활용한 DI Container를 구현하여 의존성 관리
- NotificationCenter를 사용하여 Background, Foreground 시점의 소켓 단절 및 재연결 처리
- Moya를 활용하여 TargetType 구현 및 네트워크 요청 로직 추상화
- Alamofire RequestInterceptor와 NotificationCenter를 활용한 리프레시 토큰 만료 시점 대응
- Single과 ResultType을 활용한 네트워크 통신 실패 대응
- RxDataSource와 CompositionalLayout을 사용한 다중 섹션 컬렉션뷰 구현
ViewModel이 비대해지는 문제점
- 네트워크, 데이터베이스, 소켓통신과 관련된 작업들이 ViewModel에 존재하며 너무 많은 일을 수행하게 됨
- ViewModel이 비대해지며 프로젝트의 유지보수성이 저하
Clean Architecture로 각 계층의 역할 분리
- Presentation, Domain, Data 계층으로 나누어 역할 분리
- 비즈니스 로직이 UseCase를 통해 이루어짐으로써 비대한 ViewModel 문제 해결
- UseCase가 Repository Protocol을 소유하도록 하였으며 이를 통해 데이터소스 변경사항에 유연하게 대응하도록 함
화면 전환로직이 ViewController에 혼재되어있는 문제점
- 화면이 많아질수록 ViewController에 화면전환 로직이 혼재되어 추적이 어렵고 관리가 어려워짐
- 화면전환 플로우를 Coordinator가 관리함으로써 ViewController 간 결합도 감소
Coordinator의 역할에 대한 기준 설정
- 화면전환 시에 값을 전달하는 경우에는 Coordinator가 해당 인자를 받아서 화면전환 되도록 구성
- 화면전환과 관계없는 데이터나 로직은 Coordinator가 가지고 있지 않도록 함
DI를 사용하면서 객체 생성 및 주입과정의 불편함 증가
- 객체를 등록하고 필요시에 사용할 수 있도록 DI Container 구성
Swinject 라이브러리를 사용한 이유
- Object Scope를 사용해 인스턴스의 LifeCycle 관리의 용이성
- 싱글턴 객체는 Container 옵션으로 구성
- 인스턴스 생성이 매번 이루어져야할 경우 Graph 옵션으로 구성
- Assembly를 통해 객체를 그룹화하여 등록 및 관리 가능
- Presentation, Domain, Data 계층별 Assembly를 구성
- PublishRelay를 선언 및 소켓이벤트 발생 시 Relay에 이벤트를 보낸 후 Observable형태로 변경하여 리턴
- UseCase에서 해당 이벤트를 구독하고 받아온 실시간 채팅 데이터를 가공하여 ViewModel에 전달
func receive(chatType: ChatType) -> Observable<ChatResponseDTO> {
let receiver = PublishRelay<ChatResponseDTO>()
let socketType = chatType.event
socket.on(socketType) { dataArray, ack in
do {
let data = dataArray[0] as! NSDictionary
let jsonData = try JSONSerialization.data(withJSONObject: data)
// 이벤트 타입에 따라 데이터 디코딩(중략)
let decodedData = try JSONDecoder().decode(ChannelChatResponseDTO.self, from: jsonData).toDTO()
receiver.accept(decodedData)
} catch {
print("RESPONSE DECODE FAILED")
}
}
return receiver.asObservable()
}- 사용자가 앱을 백그라운드 상태로 보냈을 때는 리소스 낭비를 피하기 위해 소켓연결을 끊는 작업이 필요
- NotificationCenter를 사용해서 백그라운드 진입시 소켓 연결을 끊고, 다시 돌아왔을 때 재연결되도록 구성
private func addSceneObserver() {
NotificationCenter.default.rx.notification(
UIApplication.didEnterBackgroundNotification
)
.asDriver(onErrorRecover: { _ in .never() })
.drive(with: self) { owner, _ in
owner.closeConnection()
}
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(
UIApplication.didBecomeActiveNotification
)
.asDriver(onErrorRecover: { _ in .never() })
.drive(with: self) { owner, _ in
owner.openConnection()
}
.disposed(by: disposeBag)
}- 초기 UseCase는 단순히 네트워크, DB 등의 Repository에서 이루어지는 작업을 데이터 변환만 해서 내보내는 형태로 구성되어 있었음
- ViewModel은 UseCase의 여러 함수를 호출하고 비즈니스 로직을 구성함으로써 많은 부담을 가지게 됨
final class DefaultChatUseCase: ChatUseCase {
func fetchServerChannelChatList(request: ChatRequest)
-> Single<Result<[Chat], NetworkError>> {
// 채팅내역 조회(서버)
}
func fetchDBChannelChatList(channelID: String)
-> Single<[Chat]>> {
// 채팅내역 조회(DB)
}
- UseCase는 사용자 행위에 따른 데이터 응답을 줄 수 있어야 한다는 전제 기반으로 재구성
- ViewModel은 단순히 UseCase를 호출하고 이를 ViewController에 응답으로 제공하도록 함
final class DefaultChatUseCase: ChatUseCase {
// UseCase: 사용자가 채팅화면 진입 시 채팅 리스트를 보여준다
func fetchChannelChatList(channelID: String)
-> Single<[ChatListContent]> {
// 채팅내역 조회(DB)
// 채팅내역 조회(서버)
// TableView에 보여지는 형태로 가공
return channelChatList
}
}
- 홈 화면에서는 네트워크 통신이 여러번 일어나기 때문에 viewWillAppear시점마다 서버통신을 하는 것은 콜 수 낭비로 이어질 수 있다고 판단
- 활용할 수 있는 서버응답값이 있는 경우 해당 값을 활용해서 데이터를 갱신하도록 구성
- 홈 화면으로 화면 전환된 후 데이터 갱신이 일어나야 하는 경우 PublishRelay를 활용하여 이벤트를 전달
- 채팅 화면과 같이 여러 화면에서 진입이 가능한 화면의 경우 viewWillDisappear 시점에 Notification을 통해 이벤트를 방출하고 홈 화면의 데이터를 갱신하도록 구성
-
UseCase에서 비즈니스 로직을 처리하면서 도메인 모델에서 처리할 수 있는 로직도 함께 가지고 있는 경우가 있었음. 도메인 모델에서 처리할 수 있는 작업을 분리한다면 더 명확한 역할 분리를 할 수 있을 것 같다는 생각을 하게 되었음
-
Coordinator패턴에서 ViewController가 Coordinator를 소유함으로써 생기는 문제들이 있었음
- 화면전환만을 위한 이벤트를 발생시켜 ViewModel에 접근하고 다시 돌아와 화면전환을 하는 번거로움 발생
- ViewModel만 독립적으로 테스트할 경우를 가정했을 때 화면전환에 대한 테스트가 불가능해질 것으로 판단
- 위와 같은 문제들 때문에 ViewModel이 Coordinator를 소유하는 것이 규모가 있는 앱에서는 더 합리적인 선택이 될 것 같다는 생각을 하게 되었음





