iOS Study 8주차 이론 정리 - Combine (SwiftUI)

농담고미고미·2025년 6월 18일
0

프론트엔드

목록 보기
10/12
post-thumbnail

combine은 데이터 흐름과 비동기 이벤트를 일관된 방식으로 처리하는 프레임워크다. 반응형 프로그래밍을 따른다.

Combine은 이벤트를 시간에 따라 흐르는 스트림으로 보고, 이 스트림을 Publisher -> Operator -> Subscriber의 흐름으로 처리한다.
프로그래밍 언어론에서 배운 producer-consumer와 비슷하다.

publisher의 구성 요소는 값, 완료(completion), 에러가 있다.

enum Subscribers.Completion<Failure: Error> {
    case finished       // 정상 완료
    case failure(Failure) // 오류 발생
}

subscriber는 데이터가 도착할 때마다 반응하는 구조이다. Operator를 통해 흐름을 가공하여, 원하는 최종결과만 subscriber에게 전달할 수 있다.

let numbers = [1, 2, 3].publisher

numbers
    .map { $0 * 10 }      // [10, 20, 30]
    .sink { value in
        print("값 수신: \(value)")
    }

Sink : 데이터를 수신하고, 클로저 안에서 원하는 처리를 직접 수행한다.

viewModel.$name
    .sink { newName in
        print("새 이름: \(newName)")
    }

receiveCompletion 클로저를 활용해 퍼블리셔의 완료 이벤트 즉, 정상적으로 완료 및 오류를 별도로 처리할 수 있다.

Assign : 퍼블리셔가 새 값을 방출할 때마다 지정된 객체의 특정 프로포타에 그 값을 직접 할당한다.
컴파일 시점에 퍼블리셔의 출력 타입과 할당될 프로퍼티의 타입이 일치하는지 확인하기 때문에 타입 불일치로 인한 런타임 오류를 사전에 방지할 수 있다. 또, 직접 프로퍼티에 값을 할당하기 때문에 클로저를 통해 할당하는 것보다 조금 더 효율적이다.

viewModel.$name
    .assign(to: \.text, on: nameLabel)
    .store(in: &cancellables)

Publisher는 구독자가 없으면 아무 일도 하지 않기 때문에, sink, assign 등을 통해 구독을 명시적으로 등록해야한다.

위의 경우에는 현재 강하게 참조되어 있기 때문에 .store을 통해 cancellables를 선언하여 메모리 누수를 막을 수 있다.
또한, Publisher가 에러를 내면 구독이 즉시 취소되고 이를 별도로 처리할 수 있는 방법이 없다!! 즉, 에러핸들링이 불가능하다.

참고링크

@Observable 매크로는 Combine 기반 로직이 지원되지 않기 때문에 ObservableObject과 @Published를 사용해야한다.
오ㅐ... 그런걸까? 레딧에도 같이 이슈가 있더라!
레딧 사진
궁금해져서 검색해봤다.

@Observable 매크로는 Combine 퍼블리셔($foo) 자동 생성 X

기존의 ObservableObject에서는 @Published 프로퍼티에 foo를붙이면Publisher를자동으로생성해주어,Combine파이프라인에바로연결할수있었다.그러나@Observable을사용하면이런자동퍼블리싱기능이제공되지않아foo를 붙이면 Publisher를 자동으로 생성해 주어, Combine 파이프라인에 바로 연결할 수 있었다. 그러나 @Observable을 사용하면 이런 자동 퍼블리싱 기능이 제공되지 않아 incomingText 등 $ 프리픽스로 만든 퍼블리셔에 접근할 수 없으며 즉시 Combine 구문으로 debounce, sink 등을 사용하는 것이 불가능하다.

아하!
Observable에 대해 좀 더 고찰하고 싶으면 아래의 링크로...
레딧의문 해소 링크

Combine의 ObservableObject는 willSet/didSet 이벤트의 선행 단계(leading edge) 에서 변경 사항을 감지하고, 값이 실제로 설정되기 전에 모든 변경 사항을 전달합니다. 이러한 방식은 SwiftUI에는 적합하지만, SwiftUI 외부에서는 제한적으로 작용하며 처음 사용하는 개발자에게는 다소 놀랍게 느껴질 수 있습니다.

또한 ObservableObject는 모든 관찰 대상 속성에 @Published를 붙여야만 변경 이벤트에 반응합니다. 대부분의 경우 이 어노테이션은 모든 속성에 반복적으로 붙여야 하며, 이는 개발자에게 의미 없는 반복이 되고, "무엇이 관찰 대상인지 아닌지"에 대한 명확성조차 흐려집니다. 결국 ObservableObject를 사용하는 클래스는 모든 속성에 일일이 @Published를 붙이게 되고, 이는 코드의 명료성을 오히려 해칩니다.

이런 내용들이 담겨있다. 크크 프로그래밍 언어의 맥락을 따라가는 것도 재밌당

@Publishable도 있다는데, 이건 애플에서 기본 제공해주는게 아니라 개발자들이 ObservationRegister를 커스텀 구현한 채로 오버라이드한거다. 나는 기본에 충실하자는 주의라... @Publishable 사용 안할 듯 ㅇㅅㅇ

다시 본론으로 돌아가, Combine은 ObservableObject와 @Published를 사용해야 한다.

import Combine

class UserViewModel: ObservableObject {
    @Published var name: String = "Jacob"
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $name
            .sink { newName in
                print("새 이름: \(newName)")
            }
            .store(in: &cancellables)
    }
}

Combine에서 .sink와 같은 구독은 구독 객체(cancellable)를 반환한다. 이 구독 객체는 메모리에서 해제될 때 자동으로 구독을 끊어준다. 그런데 이 객체를 어딘가에 저장하지 않으면 즉시 사라져서 구독이 바로 해제되어버린다. 따라서 구독을 유지하기 위해 store를 사용하는 것이다. 이때 저장할 타입이 AnyCancellable이고, 그걸 여러 개 저장하기 위해 Set을 사용한다.

private var cancellables = Set<AnyCancellable>()

즉, 모든 구독을 여기에 모아두겠다는 의미다.

import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("이름: \(viewModel.name)")
                .font(.title)
            
            TextField("이름 입력", text: $viewModel.name)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
        }
        .padding()
    }
}

여기서는 @StateObject로 뷰모델을 받았다. @StateObject는 SwiftUI에서 View가 소유하고 직접 생성하는 ViewModel을 위한 속성 래퍼다. 위와 같은 경우에서 View가 다시 그려져도 매번 뷰모델을 새로 생성하는게 아니라 뷰모델은 한번 생성된 후 상태를 유지하는게 더 편리하기 때문에 @ObservedObject가 아닌 @StateObject를 사용한다.

복잡한 상태 감지 및 로직처리는 Combine(sink)가 적절하지만 단순한 UI 반응이나 뷰 내부 처리에는 onChange(of:)가 간편하고 효과적이다.

MoyaCombine은 아래와 같은 코드가 가능하게 한다.

provider.requestPublisher(.getUser)
    .map(\.data)
    .decode(type: User.self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { ... }, receiveValue: { user in
        self.user = user
    })
    .store(in: &cancellables)
  1. Provider.requestPublisher(.getUser)
    Moya의 requestPublisher는 Combine용 확장 메서드다.
    Publisher<Response,MoyaError>를 생성한다.
  2. map(.data)
    응답객체에서 데이터 부분만 추출한다.

요청이 시작되었을 때 로딩 표시하기, 요청이 성공하면 결과를 보여주고 실패하면 에러 메시지 출력, 중복된 요청 방지, 네트우ㅝ크 에러에 따른 재시도를 우리가 핸들링 해줘야 한다.

Combine의 handleEvents, sink, catch, 그리고 상태 변수를 함께 사용하는 방식이 매우 효과적이다.

provider.requestPublisher(.getData)
    .handleEvents(
        receiveSubscription: { _ in self.isLoading = true },
        receiveCompletion: { _ in self.isLoading = false }
    )
    .map(\.data)
    .decode(type: DataModel.self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            self.errorMessage = error.localizedDescription
        case .finished:
            break
        }
    }, receiveValue: { model in
				 /* 정상적으로 파싱된 DataModel 객체 저장하기 */
    })
    .store(in: &cancellables)

한번에 에러핸들링까지 가능하다니 ㅇㅁㅇ!!

실시간 검색 API 호출 예시

@Published var keyword: String = ""
@Published var searchResults: [Book] = []

init() {
    $keyword
        .debounce(for: .milliseconds(300), scheduler: RunLoop.main) // debounce로 타이핑 멈춘 후에만 요청이 가능해요!
        .removeDuplicates() // 중복 검색을 방지하기 위함이에요!
        .filter { !$0.isEmpty }
        .flatMap { keyword in
            self.provider.requestPublisher(.searchBook(query: keyword)) // Moya + Combine으로 API 요청하는 데 사용합니다!
                .map(\.data)
                .decode(type: [Book].self, decoder: JSONDecoder())
                .catch { _ in Just([]) }
        }
        .receive(on: DispatchQueue.main)
        .assign(to: &$searchResults)
}

debounce는 지정된 시간동안 값 변화가 없을 때만 다음 단계로 이벤트를 전달한다. scheduler: RunLoop.main은 이 디바운스 로직이 메인 쓰레드의 RunLoop에서 실행됨을 의미한다. RunLoop.main은 iOS에서 메인 쓰레드는 RunLoop라는 이벤트 루프 위에서 동작한다. UI 업데이트, 이벤트 처리 등은 메인쓰레드에서 돌아가야하니깐~

.catch { _ in Just([]) }
에러가 나면 요청이 실패하니깐 빈 배열을 대신 내보낸다. Just([])는 Combine에서 하나의 값을 바로 발행하는 Publisher다. 따라서 에러가 나더라도 앱이 죽지 않고 빈 검색 결과로 마무리된다.

8주차 실습

import Foundation
import CombineMoya
import Combine

class CombineViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()
}
import Foundation
import CombineMoya
import Combine
import Moya

class CombineViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()
    
    private let provider: MoyaProvider<UserRotuer>
    
    @Published var userName: String = ""
    @Published var isLoading: Bool = false
    @Published var userData: UserData? = nil
    
    init(provider: MoyaProvider<UserRotuer> = APIManager.shared.createProvider(for: UserRotuer.self)) {
        
        self.provider = provider
        
        $userName
            .debounce(for: .milliseconds(400), scheduler: RunLoop.main)
            .removeDuplicates()
            .filter { !$0.isEmpty }
            .flatMap { name in
                self.getUser(name: name)
            }
            .receive(on: DispatchQueue.main)
            .assign(to: &$userData)
    }
    
    private func getUser(name: String) -> AnyPublisher<UserData?, Never> {
           provider.requestPublisher(.getPerson(name: name))
               .handleEvents(
                   receiveSubscription: { _ in
                       DispatchQueue.main.async {
                           self.isLoading = true
                       }
                   },
                   receiveCompletion: { _ in
                       DispatchQueue.main.async {
                           self.isLoading = false
                       }
                   }
               )
               .map(\.data)
               .decode(type: UserData.self, decoder: JSONDecoder())
               .map { Optional($0) }
               .catch { error -> Just<UserData?> in
                   DispatchQueue.main.async {
                       print("에러: \(error.localizedDescription)")
                   }
                   return Just(nil)
               }
               .eraseToAnyPublisher()
       }
}

flatMap은 이름이 변경될 때마다 getUser(name:) API 요청을 실행한다.
.assgin(to:) 응답 받은 값을 userData에 자동 저장한다.

private func getUser(name: String) -> AnyPublisher<UserData?, Never>

name을 받아서, UserData? 를 비동기적으로 Combine으로 리턴하는 함수다.

provider.requestPublisher(.getPerson(name: name)) 는 MoyaProvider가 Combine을 기반을로 API 요청을 보낸다. receiveSubscription와 recieveCompletion을 이용해 API 요청이 끝나면 isLoading을 false로 바꾼다.
eraseToAnyPublisher()는 Combine에서 타입을 감추는 용도다. Publisher는 .map, .decode, .catch 등으로 매우 복잡한 타입이 되는데, 외부에서 이걸 다 알 필요는 없으니 AnyPublisher<UserData?, Never>로 모양만 남기고 내부 로직을 숨긴다.

profile
농담곰을 좋아해요 말랑곰탱이

0개의 댓글