안녕하세요 !
오늘은 Combine의 마지막 편인 (4) 편입니다 ~
앞서 설명했던 것 처럼 저의 Combine 포스팅의 종착역은 MVVM + UIKit + Combine 입니다.
일단 제 목적과 비슷한 예제 프로젝트가 있는지 찾아보기로 하였고,
저는 youtube를 통해 제가 찾던 demo project를 찾아볼 수 있었습니다.
Link:
https://www.youtube.com/watch?v=KK6ryBmTKHg
해당 개발자 분이 공유해주신 프로젝트 코드를 clone해서 구조를 공부하였고, 제 나름대로 조금씩 내용과 구현 방식을 바꿔가며 공부 및 정리를 해보았습니다.
그럼 시작합니다 ~ !
Input & Output Pattern
iOS 에서 MVVM 구조로 개발을 하는 방식에는 정말 많은 방식이 존재합니다.
애초에 Code Design Architecture 라는 것이, 유지 보수 및 코드 유닛 테스트에 유용하도록 코드를 작성하기 위해 만들어진 것이기 때문에 정형화된 객체 간의 통신 방법은 없습니다.
저는 그 중 많은 분들이 프로젝트에 차용하고 계신 Input & Output Pattern을 사용해서 프로젝트를 구성해보기로 했고, 위의 Youtube 예제 코드 또한 Input & Output 구조로 ViewController와 ViewModel 간의 Transfer binding이 이뤄지고 있습니다.
해당 구조를 아주 간단하게 설명해보자면,
Input
ViewController(View) 에서 ViewModel로 보내는 신호를 처리하는 객체
(View에서 Publish, ViewModel에서 Subscribe)
Output
ViewController(View) 에서 ViewModel로 보낸 신호에 대한 결과를 처리하는 객체
(ViewModel에서 Publish, View에서 Subscribe)
입니다.
여기서 Input, Output은 Combine 프레임워크의 Publisher 그 중에서도 PassthroughSubject 객체로 구성합니다. (예제 기준)
또한, 해당 객체들은 ViewModel 의 변수로 생성하고, ViewController 쪽에서 ViewModel 객체를 참조하여 사용하는 방식을 체택합니다.
일단 해당 구조에 대해 처음 접하는 것이기 때문에 이정도로만 이해하고 다음으로 넘어가보겠습니다.
Foldering
해당 예제는 Foldering을 진행하지 않고, In/Output pattern으로 transfer binding을 통해 mvvm 구조를 어떻게 구현하는지에 주안을 맞춰 프로젝트를 작성했습니다.
저는 Code Design Architecture에서 가장 중요한 것 중 하나는 Foldering 이라고 생각하기 때문에 아주 기본적인 골자를 만들어 진행했습니다.
구성 폴더링 구조
1. Models
- 해당 서비스에서 사용되는 데이터 모델 혹은 공유 데이터를 저장하는 폴더
2. Services (MVVM - Model)
- API 혹은 서버를 통해 통신 및 데이터 처리를 위한 코드들을 모아두는 폴더
3. ViewModels (MVVM - ViewModel)
- UI의 비동기 동작에 대한 요청 응답 처리 및 UI 구성에 필요한 여러 동작들을 정의한 ViewModel 들을 저장한 폴더
4. Views (MVVM - View)
- 페이지 별 View 관련 코드들을 저장한 폴더
규모가 더 큰 프로젝트에서의 폴더링은 더 복잡하고 구체적이겠지만 해당 예제에서는 MVVM 구조에서 데이터의 처리 방식이나 비동기 데이터 흐름에 따른 View의 변화 등을 연습해보기 위한 프로젝트이기 때문에 이정도로 구성해보겠습니다.
View
자 그럼 페이지 디자인 부터 같이 살펴보도록 하겠습니다.
해당 프로젝트는 StoryBoard 기반의 UI code가 작성되어있기 때문에 초반 디자인을 볼 수 있기 때문에 같이 보도록 하겠습니다.
페이지 디자인
아마 해당 유튜브에서 깃허브 링크를 타고 가셔서 처음 받으시면 author label은 없을 거에요 !
너무 심심해보이기도 하고, 변형을 주어 코드를 작성해보고 싶어서 제가 넣은 부분이라 이 글을 보시는 분들도 저렇게 변형을 줘보시면 더 재밌게 공부해보실 수 있답니다.
아무튼 저렇게 뷰가 구성되어있죠?
그럼 해당 뷰를 구성하는 코드를 같이 살펴보도록 하겠습니다.
코드
import UIKit
import Combine
class QuoteViewController: UIViewController {
// MARK: - Properties
// View 관련 변수
@IBOutlet weak var quoteLabel: UILabel!
@IBOutlet weak var authorLabel: UILabel!
@IBOutlet weak var refreshButton: UIButton!
// ViewModel 관련 변수들
private let vm = QuoteViewModel()
private let input: PassthroughSubject<QuoteViewModel.Input, Never> = .init()
private var cancellables = Set<AnyCancellable>()
// MARK: - View Life Cycle
/// viewDidLoad - bind 처리를 해줌
override func viewDidLoad() {
super.viewDidLoad()
bind()
print("viewDidLoad")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
input.send(.viewDidAppear)
print("viewDidAppear")
}
// MARK: - Methods
/// view와 viewModel을 연결해주는 함수
private func bind() {
// .eraseToAnyPublisher : 데이터 타입을 AnyPublisher로 타입을 바꿔주는 역할. 구체적인 publisher 타입을 감추어 인터페이스를 간호화하는게 목적.
let output = vm.transform(input: input.eraseToAnyPublisher())
// output 과의 연결은 약한 참조 형태로 써야 viewController가 메모리에서 해제될 때, viewModel도 안전하게 메모리에서 해제된다.
output
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
switch event {
case .fetchQuoteDidSucceed(let quote):
self?.authorLabel.text = quote.author
self?.quoteLabel.text = quote.content
case .fetchQuoteDidFail(let error):
self?.quoteLabel.text = error.localizedDescription
case .toggleButton(let isEnabled):
self?.refreshButton.isEnabled = isEnabled
}
}.store(in: &cancellables)
}
/// 새로고침 버튼을 누른 경우 새로 고침 되는 함수
@IBAction func refreshButtonTapped(_ sender: Any) {
input.send(.refreshButtonDidTap)
}
}
저는 보통 ViewController 코드를 작성할 때 Apple Docs 에 있는 코드들과 같이
Properties, View Life cycle, Methods 등으로 섹션을 구분하기 때문에 MARK로 구분된 섹션 별로 정리했습니다.
Properties
Storyboard 연결 변수
- 내용, 작가 이름 Label
- 새로고침 button
ViewModel 관련 변수
- ViewModel을 강한 참조로 변수 생성 (ViewController가 메모리에서 삭제될 시, 같이 삭제되도록 하기 위해)
- ViewModel와 binding 하기 위한 input Subject 객체 변수
- binding 정보를 삭제할 cancellables 객체 변수
View Life Cycle
- 해당 부분에서 유의 깊게 봐야할 부분은 ViewDidLoad 입니다.
- 해당 부분에서 bind() method 가 호출되는데 해당 메서드가 호출된 이후 어떤 flow로 binding이 이뤄지는지 이해해야 합니다.
- ViewDidAppear는 ViewDidLoad 이후 시점이기 때문에 bind가 이뤄져있는 상태이고, input.send() 메서드로 Input.viewDidAppear 값을 발행 (ViewModel, bind 내용을 봐야 이해 가능 - 일단 그렇구나 하고 넘어가보아요)
Methods
- bind() ⭐️⭐️⭐️
1) input 변수를 vm 객체의 transform methods의 인자로 전달하여 vm(viewmodel) 쪽에서 해당 input passthorughSubject를 sink.
(ViewController의 input에서 publisher 역할을 하고, ViewModel 쪽에서 subscriber 역할을 함)
-> View에서 ViewModel로 신호를 보내는 역할을 하는 객체가 input 이기 때문에 정말 당연한 내용 !
2) vm.transform() 메서드의 반환 값으로 ViewModel의 output passthroughSubject를 반환받아와서 sink.
(ViewModel의 output에서 publisher 역할을 하고, ViewController 쪽에서 subscriber 역할을 함)
-> ViewModel에서 View로 응답 결과를 보내는 역할을 하는 객체가 output 이기 때문에 정말 당연한 내용!
Flow 및 유의할 점
1. viewmodel에서 output을 반환받은 후 sink 하여 결과 값을 receive하는 경우, weak self 형식으로 클로저를 약한 참조로 설정하여야 한다. (combine(3) 편 내용에 이유가 정리 되어있습니다.)
2. 결과 값으로 받은 내용으로 case를 나눠 UI를 업데이트 하도록 코드를 작성
- refreshButtonTapped()
: @IBAction 함수로 Storyboard와 연결된 버튼의 동작을 정의하는 함수입니다. 이 버튼 함수도 input에 Input.refreshButtonDidTap 값을 발행하는 동작으로 정의 되어있습니다.
Input, Output case가 ViewModel에 정의되어있어서 ViewModel을 보시면 더 이해가 쉽게 될 것 같습니다 !
바로 ViewModel로 가보시죠 ~
View Model
View를 천천히 읽고 이해하셨다면, 아마 View Model 코드는 어렵지 않게 이해가 되실거에요 :)
자 그럼 코드 부터 같이 살펴보도록 하죠
코드
import Foundation
import Combine
class QuoteViewModel {
/*
QuoteViewController에서 전달받는 신호
*/
enum Input {
case viewDidAppear
case refreshButtonDidTap
}
/*
QuoteViewController로 전달하는 신호
*/
enum Output {
case fetchQuoteDidFail(error: Error)
case fetchQuoteDidSucceed(quote: Quote)
case toggleButton(isEnabled: Bool)
}
private let quoteServiceType: QuoteServiceType
private let output: PassthroughSubject<Output, Never> = .init()
private var cancellables = Set<AnyCancellable>()
// 서비스 객체를 연결
init(quoteServiceType: QuoteServiceType = QuoteService()) {
self.quoteServiceType = quoteServiceType
}
// viewModel과 view를 bind하는 함수
func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
// 약한 참조를 하여 ViewController 쪽에서 연결을 해제하면 자연스럽게 cancle 되는 구조
input.sink { [weak self] event in
switch event {
case .viewDidAppear, .refreshButtonDidTap:
self?.handleGetRandomQuote()
}
}.store(in: &cancellables)
// .eraseToAnyPublisher : 데이터 타입을 AnyPublisher로 타입을 바꿔주는 역할. 구체적인 publisher 타입을 감추어 인터페이스를 간호화하는게 목적.
return output.eraseToAnyPublisher()
}
// input sink event를 처리하는 함수 중 하나
private func handleGetRandomQuote() {
output.send(.toggleButton(isEnabled: false))
quoteServiceType.getRandomQuote().sink { [weak self] completion in
self?.output.send(.toggleButton(isEnabled: true))
if case .failure(let error) = completion {
self?.output.send(.fetchQuoteDidFail(error: error))
}
} receiveValue: { [weak self] quote in
self?.output.send(.fetchQuoteDidSucceed(quote: quote))
}.store(in: &cancellables)
}
}
1) Input / Output enum structure
해당 Enum case는 꼭 ViewModel 안에 Nested 된 구조로 작성하지 않아도 되고, 파일을 분리하여 작성해도 무관합니다.
꼭 구조를 해당 방식으로 작성 해야하는 것은 아니지만, 이렇게 case를 구분하여 처리한다면 유지 보수 및 에러 케이스 관리에 굉장히 용이할 것 같다는 생각이 들었습니다.
예를 들어, 기존 페이지에서 버튼이 하나 더 추가된다고 하면, In/Output case에 각각 case를 추가하고 양쪽의 Sink에 case 별로 처리를 달아주면 되니깐요! (삭제나 수정도 동일)
2) init
해당 view model 객체를 만들면 기본적으로 필요한 service 객체를 생성하여 강한 참조를 하도록 구현되어있습니다.
강한 참조로 구현하는 이유는 이제 다들 이해되시죠?
View 메모리 해제 -> 연결된 View Model 메모리 해제 -> 연결된 Services 메모리 해제!
해당 예제에서는 특정 서버에서 랜덤 명언을 불러오는 기능이 있기 때문에 관련 Service class를 만들었습니다.
3) transform() method
View Model 에 있어 가장 핵심이 되는 method 라고 볼 수 있습니다.
View에서 bind() 메서드를 호출해서 서로의 in/output passthroughSubject를 연결할 때,
View의 input을 sink하고 output을 반환 값으로 전달해주는 함수입니다.
View의 output.sink 과정과 마찬가지로 Input을 sink 하는 과정에서의 유의점을 기록해보겠습니다.
Flow 및 유의점
1. view에서 input을 인자로 받은 후 sink 하여 결과 값을 receive하는 경우, weak self 형식으로 클로저를 약한 참조로 설정하여야 한다. (combine(3) 편 내용에 이유가 정리 되어있습니다.)
2. 요청 값을 case로 나누어 받아서, case에 맞게 service 처리가 필요한 경우는 service 함수로 handling 하고 데이터의 조작이 필요한 경우 함수를 따로 만들어 조작 후 값을 발행하도록 한다.
여기까지 살펴보면 flow가 어느정도 잡히시나요?
정리를 해보자면,
1. View에서 버튼을 누르는 등의 동작이 발생
2. Input 에서 특정 case 값을 발행
3. Input을 sink 한 view model 에서 해당 내용을 처리 (Service or Bussiness 로직 처리)
4. 처리된 결과 값을 output의 case 값을 발행
5. Output을 sink 한 view에서 해당 내용을 처리 (UI 업데이트)
이런 식으로 비동기 이벤트 흐름을 처리하게 되는 겁니다 !
4) handleGetRandomQuote()
해당 함수는 위의 Input.sink 클로저의 내용으로 입력 신호를 처리하는 과정에서 사용된 비즈니스 로직 함수 입니다.
QuoteService에서 getRandomQuote() 메서드를 호출하여 반환 값으로 랜덤 명언 Model인 Quote를 발행하는 Publisher를 반환 받아 구독하는 식으로 구성되어있습니다.
여기서 getRandomQuote()는 반환 값으로 한번만 실행되는 발행자를 발행하는데 이와 관련된 내용은 뒤의 Services를 통해 살펴보도록 합시다 !
Services
앞서 View와 View Model 간의 동작을 살펴 보며 대략적인 transfer binding 구조에 대해 알아봤습니다.
마지막으로 Combine을 통해 어떤 식 Service 코드를 작성하는지 살펴보도록 하겠습니다.
코드
import Foundation
import Combine
protocol QuoteServiceType {
func getRandomQuote() -> AnyPublisher<Quote, Error>
}
class QuoteService: QuoteServiceType {
// func getRandomQuote() -> AnyPublisher<Quote, Error> {
// let url = URL(string: "https://api.quotable.io/random")!
// return URLSession.shared.dataTaskPublisher(for: url)
// .catch { error in
// return Fail(error: error).eraseToAnyPublisher()
// }.map({ $0.data })
// .decode(type: Quote.self, decoder: JSONDecoder())
// .eraseToAnyPublisher()
// }
/*
문제 발생 : 서버가 해외에 있어 우회하거나 인증서 발급을 받아야함.
해결 : 서버에서 데이터를 불러오는 비동기 동작과 비슷하게 구성하여 Quote를 전달함.
*/
// 임의의 Quote 배열
private let quotes = [
Quote(content: "The only way to do great work is to love what you do.", author: "Steve Jobs"),
Quote(content: "Success is not final; failure is not fatal: It is the courage to continue that counts.", author: "Winston Churchill"),
Quote(content: "Believe you can and you're halfway there.", author: "Theodore Roosevelt"),
Quote(content: "Don't watch the clock; do what it does. Keep going.", author: "Sam Levenson"),
Quote(content: "You only live once, but if you do it right, once is enough.", author: "Mae West"),
Quote(content: "Life is what happens when you're busy making other plans.", author: "John Lennon"),
Quote(content: "The purpose of our lives is to be happy.", author: "Dalai Lama"),
Quote(content: "Get busy living or get busy dying.", author: "Stephen King"),
Quote(content: "You have within you right now, everything you need to deal with whatever the world can throw at you.", author: "Brian Tracy"),
Quote(content: "Believe in yourself and all that you are. Know that there is something inside you that is greater than any obstacle.", author: "Christian D. Larson")
]
// 랜덤으로 Quote 반환
func getRandomQuote() -> AnyPublisher<Quote, Error> {
// 0.5초, 1초, 1.2초, 1.4초 중 하나를 랜덤으로 선택
let randomDelay = [0.5, 1, 1.2, 1.4].randomElement()!
return Just(quotes.randomElement()!)
.delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) // 랜덤 딜레이 추가
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
일단 해당 Service 코드에 대해 설명을 작성하기 이전에 예제 코드 원본이 아닌 변경된 코드로 진행하는 이유를 먼저 설명드려야 할 것 같습니다.
해당 예제에서 사용한 서버의 url 이 우리나라가 아닌 해외에 서버를 두고 있어, 인증서에 대한 정보를 따로 작성해서 info.plist 를 수정해야 하는 등의 추가 작업이 필요해 해당 로직을 변경하게 되었습니다.
변경한 구조는 Random Delay + Random Element 입니다.
Random Delay는 실제로 서버에서 정보를 불러올 때 무작위로 걸리는 시간을 표현한 것이고,
Random Element는 Random한 값을 가져다 주는 서버 코드를 client 코드 쪽에서 구현한 것 입니다.
서버와 최대한 비슷한 환경에서 MVVM을 테스트 해보고자 해당 환경을 구성하게 되었습니다.
Protocol
해당 코드에서 주목해야 할 2가지 항목 중 첫 번째 항목입니다.
Protocol을 분리하여 코드를 작성하므로써 Service 객체 코드의 책임 분리를 시도한 형태입니다.
이런 식으로 하나의 Service 안에서도 책임을 Protocol 별로 구분하면 해당 서비스를 사용하는 코드 쪽에서 프로토콜 별로 의존성 주입을 하여 테스트 및 유지 보수에 매우 유용합니다.
해당 Service를 사용하는건 ViewModel 쪽이니 해당 코드를 살펴보도록 합시다.
private let quoteServiceType: QuoteServiceType
// 서비스 객체를 연결
init(quoteServiceType: QuoteServiceType = QuoteService()) {
self.quoteServiceType = quoteServiceType
}
해당 부분만 발췌한 코드 입니다.
이렇게 프로토콜을 지정하여 의존성을 주입하면 만약 MockTestService 객체를 만들어서 테스트를 진행하려고 할 때,
해당 객체에 Test Service를 바로 주입해서 테스트가 가능하겠죠?
AnyPublisher 반환
해당 코드에서 주목해야할 2가지 항목 중 두 번째 항목 입니다.
getRandomQuote() 함수를 보면 반환 값으로 AnyPublisher를 반환하는 것을 볼 수 있습니다.
Combine의 존재 이유 자체가 비동기 이벤트 혹은 데이터 처리를 위한 것이기 때문에
비동기적으로 데이터를 받아오는 경우, Publisher를 전달하여 정보를 받아오면 값을 발행하도록 하여 subscribe 한 부분에서
이벤트를 처리할 수 있게 하기 위함 입니다.
주석 처리된 본래의 코드 중,
URLSession.shared.dataTaskPublisher(for:)는 비동기 네트워크 요청을 위한 Combine의 Publisher입니다.
이 Publisher는 네트워크 요청이 완료된 후 데이터를 방출하며, 요청이 끝날 때까지 기다리면서 실행 흐름을 멈추지 않습니다.
저는 저의 프로젝트를 firebase cloud service를 사용해서 만들었기 때문에 Promise 혹은 Future 자료형을 활용하여 Publisher를 만들어 반환하는 방식으로 구현해봐야 할 것 같습니다. (정확한 내용은 직접 구현해봐야 알 것 같습니다.)
- Promise: 비동기 작업의 결과를 나중에 제공할 것을 약속하는 객체. 주로 비동기 작업이 끝난 후, 결과를 success 또는 failure로 전달합니다.
- Future: Publisher를 구현한 객체로, promise로 전달된 값을 구독할 수 있게 해주는 객체. 비동기 작업이 완료되면 success나 failure로 결과를 방출합니다.
해당 부분은 실제 프로젝트에서 리팩토링 하는 과정에서 더 상세하게 공부해보고 포스팅 하겠습니다 :)
Model
여기서 Model은 MVVM의 Model 이 아닌 데이터 구조 모델에 더 가까운 Model 입니다.
(MVVM에서 Model 에 가까운 코드는 Service 파트의 코드 입니다)
코드
import Foundation
struct Quote: Decodable {
let content: String
let author: String
}
넵. 엄청 간단하죠 !
서비스에서 사용하는 데이터의 단순한 데이터 Struct 입니다.
Model에는 이러한 단순 Data struct 뿐만 아니라 shared data(singleTon 등), 캐시 데이터 구조 등 많은 데이터가 포함됩니다.
지금은 간단한 예제를 살펴보는 중이기 때문에 해당 내용은 실제 프로젝트를 Refectoring 하는 과정에서 살펴보도록 하겠습니다.
마무리
네 이렇게 해서 장장 1주일에 걸친 combine 공부가 드디어 마무리 되었습니다 !!
학기 중이라 이것 저것 할게 많은 와중에 목표하던 Combine 공부도 끝마칠 수 있어서 뿌듯하네요 :)
Combine 기초 공부는 끝났지만, Combine을 위한 Combine에 의한 .. 공부가 아닌
제가 출시한 어플리케이션인 Ro_ad의 리펙토링을 위한 공부였기 때문에 !
본래의 목적을 달성하기 위해 앞으로는 본 프로젝트 리팩토링 관련 글을 포스팅 하게 될 것 같습니다.
Combine 공부를 위해 제 게시글을 끝까지 보신 분이 계시다면 정말 고생 많으셨고, 혹시 중간에 틀린 내용이 있다면 댓글로 알려주세요 !
그럼 다음 포스팅에서 뵙겠습니다.
Mins 주인장 올림
'iOS > iOS' 카테고리의 다른 글
[iOS] Combine (3) (8) | 2024.11.08 |
---|---|
[iOS] Combine (2) (9) | 2024.11.07 |
[iOS] Combine (1) (6) | 2024.11.05 |
[iOS] Frame & Bounds (2) - Bounds (0) | 2023.02.13 |
[iOS] Frame & Bounds (1) - Frame (4) | 2023.02.13 |