Skip to content

Via-Amor/Amor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Amor

소모임을 만들고 다양한 사람들과 교류하는 앱

스크린샷

라운지 채널 채팅
채널 설정 DM 목록 DM 채팅

프로젝트 환경

  • 인원: iOS 2명, 서버 1명
  • 기간: 2024.11.25 - 2024.12.19(약 3주)
  • 버전: iOS 15+

협업관리

담당 역할

김상규 홍정민
@skkim125 @jmzzang
홈, 라운지, DM 목록, DM 채팅 채널 채팅, 채널 설정, 안읽은 메시지 카운팅

브랜치 전략

Github Flow를 기반으로 한 커스텀 브랜치 전략

branch
  • 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을 사용한 다중 섹션 컬렉션뷰 구현

기술선택에 있어 고려한 지점들

Clean Architecture

CleanArchitecture

ViewModel이 비대해지는 문제점

  • 네트워크, 데이터베이스, 소켓통신과 관련된 작업들이 ViewModel에 존재하며 너무 많은 일을 수행하게 됨
  • ViewModel이 비대해지며 프로젝트의 유지보수성이 저하

Clean Architecture로 각 계층의 역할 분리

  • Presentation, Domain, Data 계층으로 나누어 역할 분리
  • 비즈니스 로직이 UseCase를 통해 이루어짐으로써 비대한 ViewModel 문제 해결
  • UseCase가 Repository Protocol을 소유하도록 하였으며 이를 통해 데이터소스 변경사항에 유연하게 대응하도록 함

Coordinator

Coordinator

화면 전환로직이 ViewController에 혼재되어있는 문제점

  • 화면이 많아질수록 ViewController에 화면전환 로직이 혼재되어 추적이 어렵고 관리가 어려워짐
  • 화면전환 플로우를 Coordinator가 관리함으로써 ViewController 간 결합도 감소

Coordinator의 역할에 대한 기준 설정

  • 화면전환 시에 값을 전달하는 경우에는 Coordinator가 해당 인자를 받아서 화면전환 되도록 구성
  • 화면전환과 관계없는 데이터나 로직은 Coordinator가 가지고 있지 않도록 함

Swinject

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
    }
}

홈 화면에서 일어나는 네트워크 통신 줄이기

homeUpdate
  • 홈 화면에서는 네트워크 통신이 여러번 일어나기 때문에 viewWillAppear시점마다 서버통신을 하는 것은 콜 수 낭비로 이어질 수 있다고 판단
  • 활용할 수 있는 서버응답값이 있는 경우 해당 값을 활용해서 데이터를 갱신하도록 구성
  • 홈 화면으로 화면 전환된 후 데이터 갱신이 일어나야 하는 경우 PublishRelay를 활용하여 이벤트를 전달
  • 채팅 화면과 같이 여러 화면에서 진입이 가능한 화면의 경우 viewWillDisappear 시점에 Notification을 통해 이벤트를 방출하고 홈 화면의 데이터를 갱신하도록 구성

회고

  1. UseCase에서 비즈니스 로직을 처리하면서 도메인 모델에서 처리할 수 있는 로직도 함께 가지고 있는 경우가 있었음. 도메인 모델에서 처리할 수 있는 작업을 분리한다면 더 명확한 역할 분리를 할 수 있을 것 같다는 생각을 하게 되었음

  2. Coordinator패턴에서 ViewController가 Coordinator를 소유함으로써 생기는 문제들이 있었음

  • 화면전환만을 위한 이벤트를 발생시켜 ViewModel에 접근하고 다시 돌아와 화면전환을 하는 번거로움 발생
  • ViewModel만 독립적으로 테스트할 경우를 가정했을 때 화면전환에 대한 테스트가 불가능해질 것으로 판단
  • 위와 같은 문제들 때문에 ViewModel이 Coordinator를 소유하는 것이 규모가 있는 앱에서는 더 합리적인 선택이 될 것 같다는 생각을 하게 되었음

About

소모임을 만들고 다양한 사람들과 교류하는 앱

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages