이재용의 iOS

iQA 개발 후기

2022년 5월 16일 • ☕️ 7 min read

screenshot3

제목 내용
🕖 개발 기간 2022.04.25 ~ 2022.05.06
🐦 서비스 이름 iOS Question App
📌 서비스 한줄 소개 흔들면 나오는 iOS 랜덤 질문 뽑기, iQA
✏️ 사용한 언어와 구조 Swift, Clean Architecture + MVVM
📇 주요 라이브러리와 프레임워크 SQLite, Combine

서론

로버트 C. 마틴이 쓴 클린 아키텍처 책을 읽는 스터디를 진행하면서 직접 적용해본 작은 프로젝트를 진행했습니다.

기획부터 디자인, 개발까지 혼자 진행하며 2주동안 열심히 만들었습니다. 기획과 디자인은 4일 정도 소요하였고 나머지 4일간 클린 아키텍처를 적용하기 위해서 설계 및 비즈니스 로직을 개발하였으며 마지막 2일동안 UI를 개발했습니다.

개발한 기능 소개

기능은 일반적인 다이어리 앱처럼 단순합니다.

사용자는 iOS 질문에 대한 답변을 작성할 수 있고, 작성한 답변들을 한번에 볼 수 있습니다. 질문마다 iOS, Architecture, Swift, SwiftUI, Advanced, Programming 총 6가지 카테고리가 존재하는데 카테고리마다 답변한 질문 개수를 통계적으로 보여줍니다. 사용자는 하나의 질문에 대해 여러번 답변을 할 수 있고, 여러 개의 답변을 한번에 볼 수 있습니다.

위 기능들을 정리하면 아래와 같고 하나하나가 비즈니스 로직이 됩니다.

  1. 랜덤 질문 조회
  2. 답변한 질문들에 대한 카테고리 통계 조회
  3. 답변한 질문들에 대한 정보 조회
  4. 랜덤 질문에 대한 답변 작성
  5. 하나의 질문에 대한 여러가지 답변 조회
  6. 답변한 모든 질문 개수 조회

클린 아키텍처 설계

클린 아키텍처의 목표는 관심사의 분리입니다. 관심사의 분리를 통해 업무 규칙을 위한 층과 시스템, 사용자 인터페이스를 위한 층을 완전히 독립시켜야 합니다.

클린 아키텍처가 추구하는 독립성을 위하여 이 프로젝트는 Domain Layer, Presentation Layer, Data Layer 3가지 계층으로 이루어져 있습니다.

screenshot3

의존 관계

  • Presentation -> Domain <- Data <- DB

  • Presentation Layer = View + ViewModel

  • Domain Layer = Use Case + Entity + Repository Entity

  • Data Layer = Repository (UserDefaults+SQLiteDataSource와 소통하는)

도메인 레이어

Domain Layer는 비즈니스 로직을 담당합니다. 유즈 케이스와 엔티티 폴더를 소유하고 있습니다. 유즈 케이스는 레포지토리를 호출해야 하기 때문에 의존 규칙을 위배하지 않도록 인터페이스를 작성하였으며 Data Layer에서 구현하도록 만들었습니다.

위 기능들 중 답변한 모든 질문들의 개수 조희 유즈케이스를 예시로 설명하겠습니다.

protocol QuestionRepositoryProtocol {
    func getQuestionsAnswered() -> [Question]
}

가장 먼저, 유즈케이스에서 사용할 수 있도록 인터페이스를 만듭니다.

final class AnswerNumberUseCase {
    private let questionRepository: QuestionRepositoryProtocol
    
    let answerNumber = CurrentValueSubject<Int, Never>(0)
    
    init(_ questionRepository: QuestionRepositoryProtocol) {
        self.questionRepository = questionRepository
    }
}

extension AnswerNumberUseCase {
    func requestAnswerNumber() {
        let questions = questionRepository.getQuestionsAnswered()
        answerNumber.send(questions.count)
    }
}

그리고 답변한 모든 질문들의 개수를 구하는 비즈니스 로직을 구현합니다. 로직이 너무나 간단하기 때문에 레포지토리는 하나만 필요하고 단지 전달해주는 유즈케이스 예제이지만 만약 로직이 복잡하다면 여러 인터페이스를 주입하여 데이터를 이리저리 볶아서 사용합니다.

레포지토리는 프로토콜로 선언하여 레포지토리의 의존성을 유즈케이스에서 제거하였습니다.

데이터 레이어

Data Layer는 외부 프레임워크와의 통신을 담당합니다.

로컬 DB인 SQLiteDataSource와 비즈니스 로직을 담당하는 유즈케이스 사이를 이어주는 것은 Repository입니다. 이는 SQLiteDataSource에서 데이터가 넘어오면 유즈 케이스에서 사용하기 편하도록 데이터 타입을 변환해주는 인터페이스 어댑터입니다.

SQLiteDataSourc뿐만 아니라 Repository는 UserDefaults와도 소통을 합니다.

또한, 이 프로젝트의 Repository는 SQL문으로 가능한 작업 일부를 대신해줍니다. (SELECT -> filter 메소드)

final class QuestionRepository {
    let questionTable: QuestionTableProtocol
    
    init() {
        self.questionTable = QuestionTable()
        questionTable.createTable()
    }
}

extension QuestionRepository: QuestionRepositoryProtocol {
    func getQuestionsAnswered() -> [Question] {
        if let questions = questionTable.getRow() {
            return questions.filter { $0.isAnswered == true }
        }
        return []
    }
}

프레젠테이션 레이어

Presentation Layer는 UI를 담당합니다.

필자는 MVC 대신 MVVM 패턴을 사용하였습니다. 왜냐하면 뷰에 뿌려져야할 데이터 타입으로 변환해주는 인터페이스 어댑터가 필요하기 때문입니다. 뷰모델은 Entity를 뷰에서 필요한 데이터 타입으로 변환하는 역할뿐만 아니라 화면에서 필요한 유즈 케이스를 호출하는 역할도 합니다. 여러가지 유즈케이스가 필요하다면 이를 비동기적으로 호출할 지 동기적으로 호출할 지 결정합니다.

final class HomeViewModel: BaseViewModel {
    private let answerNumberUseCase: AnswerNumberUseCase
    
    // ✅ input
    let viewWillAppear = PassthroughSubject<Void, Never>()
    // ✅ output
    @Published var answerNumber: Int = 0
    
    init(_ answerNumberUseCase: AnswerNumberUseCase) {
        self.answerNumberUseCase = answerNumberUseCase
        super.init()
        transform()
    }
    
    private func transform() {
        viewWillAppear.sink { [weak self] _ in
            self?.answerNumberUseCase.requestAnswerNumber()
        }
        .store(in: &cancelBag)
        

        answerNumberUseCase.answerNumber
            .assign(to: &$answerNumber)
    }
}

뷰모델에서는 유즈케이스를 호출하여 비즈니스 로직을 처리하고 응답이 오면 @Published 어노테이션을 이용하여 View와 바인딩된 값을 변화시켜줍니다.

// ✅ VC
override func bind() {
        super.bind()
        
        viewModel.$answerNumber
            .map { String($0) }
            .assign(to: \.text!, on: questionCountLabel)
            .store(in: &cancelBag)
    }
}

스크린샷

Github으로 보러가기

고민했던 부분들 🤯

UseCase와 ViewModel의 역할 분리

유즈케이스는 명확히 한가지 일을 합니다. 아래 화면처럼 여러 유즈케이스를 한번에 호출해야하는 상황에 이 호출은 뷰모델에서 하는 것이 맞을까? 라는 의문이 생겼습니다. 여러개의 유즈케이스를 비동기적으로 호출할 지 동기적으로 호출할 지에 대한 것 또한 비즈니스 로직이라고 생각했기 때문입니다.

screenshot1

빨간 네모가 있는 정보 하나하나를 유즈케이스로 분리하였습니다.

호출을 뷰모델에서 담당하지 않게 하려면, 세개의 유즈케이스를 하나로 합쳐서 뷰모델에서 한번에 호출해야합니다. 하지만 한 개의 유즈케이스에 여러 로직들을 포함하게 된다면 유즈케이스의 재사용성을 떨어뜨리고 코드의 양도 많아진다고 판단하여 뷰모델에서 비동기적으로 호출하도록 구현했습니다. 해보지 않아 모르지만, 유즈케이스 하나에 담는다면 엔티티도 더러워졌을 듯 합니다.

UIKit + Combine 바인딩 처리

UIKit과 Combine을 사용하면서 가장 불편한 점은 바인딩이었습니다. assgin(to:on:)을 이용하면 되지만 UIControl의 경우 이벤트가 발생할 경우 Publisher 프로퍼티로 바로 보낼 수 없습니다.

@IBAction func pressCompleteButton(_ sender: Any) {
    viewModel.completButtonPressed.send(textView.text)
}

필자 같은 경우, 위와 같이 이벤트를 처리했습니다.

// ✅ VM
@Published private(set) var questionContent: String = ""
let textCount = PassthroughSubject<Int, Never>()
 // ✅ VC
override func bind() {
    viewModel.$questionContent
        .assign(to: \.text!, on: questionLabel)
        .store(in: &cancelBag)
        
    viewModel.textCount
        .sink { [weak self] in
            self?.placeholderLabel.isHidden = !($0 == 0)
            self?.textCountLabel.text = "\($0) / 600"
        }
        .store(in: &cancelBag)
}

UI 컴포넌트들과 데이터 모델의 바인딩은 여러 다른 방법이 존재하겠지만 필자는 @Published 프로퍼티 래퍼와 PassthroughSubject<>()로 대부분 처리했습니다.

tableView.reloadData()는 꼭 쓸 수 밖에 없을까? 불가피한 것일까?

Combine + Datasource를 더 찾아보기, UITableViewDataSource에서 DiffableDataSource로 바꾸어보기.

마치며

2주 간 진행한 프로젝트로, 빠르다면 빠르게 개발했던 프로젝트였습니다. UI를 먼저 개발하고 비즈니스 로직을 이후에 구현하던 이전의 프로젝트와는 개발 프로세스가 완전히 반대였기 때문에 클린 아키텍처의 장점 중 하나를 경험할 수 있었던 소중한 기회였습니다.

프로젝트에서 클린 아키텍처와 MVVM을 함께 사용했지만 이 둘은 당연히 따로 사용할 수 있는 개념입니다. 하지만 MVVM 패턴은 오직 Presentation Layer에서 View의 분리를 위해서 사용되었다면, 클린 아키텍처는 코드를 테스트 가능한 재사용성 있고 이해하기 쉬운 층으로 분리해줍니다. 그리고 뷰모델이 유즈 케이스와 뷰의 포맷을 이어주는 역할로 사용되므로 굳이 하나만 적용해서 사용할 필요는 없다고 생각합니다.

클린 아키텍처에서 가장 중요한 점은 유즈 케이스를 작성한다는 점이라고 생각합니다. 비즈니스 로직이 너무 간단해서 레포지토리를 부르고 전달하는 역할만 할 지라도 꼭 작성해야 합니다. 왜냐하면 어느 로직인 지 명세가 잘 되어 있기만 해도 시간이 지나도 이해하기 쉽고 새로운 개발자가 합류하기 쉬운 코드를 짤 수 있기 때문입니다.

하지만 무엇보다도 아키텍처를 고르기 전에 자신의 프로젝트 사이즈에 적당한 지, 요구사항에 만족하는 지를 먼저 판단하는 것이 가장 중요합니다.

다음 스텝으로는 TDD를 공부해볼까 합니다. 클린 아키텍처를 적용하며 테스트 가능한 객체들을 많이 만들었지만 테스트 코드를 작성해본 적이 없기에 이 장점을 직접 경험해볼 수 없었기 때문입니다.

또한 클린 아키텍처에서 파생되어 나온 RIBs, VIP 등 새로운 아키텍처들도 관심 있게 알아보아야겠습니다.

읽어주셔서 감사합니다. 궁금한 점은 메일로 연락해주세요.

Reference