포스트

Combine Operators

실무에서 숨 쉬듯이 쓰는 Combine 오퍼레이터

Combine에는 수백 개의 오퍼레이터가 있지만, 실제 iOS 현업에서 매일같이 쓰는 핵심 오퍼레이터는 정해져 있다. 개념만 나열하기보다는 “실무에서 이 오퍼레이터를 왜 써야만 하는가?”에 초점을 맞춰 카테고리별로 정리해 본다.

💡 참고: sinkassign은 오퍼레이터인가? 엄밀히 말해 이 둘은 데이터를 변환하는 ‘오퍼레이터(Operator)’가 아니라, 파이프라인의 끝에서 데이터를 소비하는 ‘구독자(Subscriber)’다. 하지만 실무에서는 파이프라인을 조립하는 필수 블록으로 함께 묶어서 이해하는 것이 훨씬 직관적이다.


1. 스트림 전환 및 변환 (Transformation)

데이터의 형태를 바꾸거나, 사용자의 액션을 네트워크 요청으로 전환할 때 사용하는 가장 기본적인 오퍼레이터들이다.

map

실무 목적: 서버에서 내려온 Raw 데이터(JSON 모델)를 화면에 그리기 편한 View 데이터 모델로 껍데기를 바꿀 때 숨 쉬듯이 사용한다.

1
2
3
4
5
6
service.fetchUser(login: "harold")
    .map { user in
        UserViewModel(name: user.login, bio: user.bio ?? "")
    }
    .sink { print($0) }
    .store(in: &cancellables)

compactMap

실무 목적: map과 비슷하지만 nil을 자동으로 걸러준다. 옵셔널 처리할 때 map + filter { $0 != nil } 콤보 대신 이걸 하나로 쓴다.

1
2
3
4
5
6
7
$searchText
    .compactMap { text -> String? in
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        return trimmed.isEmpty ? nil : trimmed
    }
    .sink { print("유효한 검색어: \($0)") }
    .store(in: &cancellables)

flatMap ⭐️

실무 목적: 사용자의 ‘버튼 클릭’이나 ‘검색어 입력’ 같은 단순 이벤트를, 실제 서버 API를 호출하는 ‘새로운 비동기 네트워크 스트림’으로 바꿔치기(전환)할 때 무조건 사용한다.

1
2
3
4
5
6
7
8
$searchText
    .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
    .flatMap { username in
        GithubService.shared.searchUsers(query: username)
            .catch { _ in Just([]) }
    }
    .sink { users in print(users) }
    .store(in: &cancellables)

switchToLatest

실무 목적: flatMap과 비슷하게 스트림을 전환하는데, 새 이벤트가 오면 이전 스트림을 자동으로 취소한다. 검색에서 이전 요청을 확실하게 버리고 싶을 때 flatMap 대신 쓴다.

1
2
3
4
5
6
7
8
$searchText
    .map { username in
        GithubService.shared.searchUsers(query: username)
            .catch { _ in Just([]) }
    }
    .switchToLatest()
    .sink { users in print(users) }
    .store(in: &cancellables)

scan

실무 목적: 누적 계산이 필요할 때 쓴다. 장바구니 합계, 좋아요 카운트, GPS 누적 거리처럼 이전 값과 새 값을 합쳐서 계속 업데이트해야 하는 상황에 적합하다.

1
2
3
4
5
6
7
8
9
10
11
favoriteAction
    .scan([GithubUser]()) { currentList, action in
        switch action {
        case .add(let user):
            return currentList + [user]
        case .remove(let user):
            return currentList.filter { $0.id != user.id }
        }
    }
    .sink { updatedList in print(updatedList) }
    .store(in: &cancellables)

2. 노이즈 캔슬링 (Noise Cancelling)

사용자의 연타나 시스템 노이즈로부터 서버를 보호한다.

debounce

실무 목적: 실시간 검색창에서 유저가 타이핑을 멈출 때까지 기다렸다가 단 한 번만 API를 쏘게 만들어, 무의미한 통신비를 절약하고 서버 과부하를 막는다.

1
2
3
4
$searchText
    .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
    .sink { print("최종 검색어: \($0)") }
    .store(in: &cancellables)

throttle

실무 목적: debounce는 멈출 때까지 기다리고, throttle일정 간격으로 한 번씩만 통과시킨다. 버튼 연타 방지나 스크롤 이벤트 제어에 자주 쓴다.

1
2
3
4
refreshButton
    .throttle(for: .seconds(5), scheduler: RunLoop.main, latest: false)
    .sink { print("새로고침 실행") }
    .store(in: &cancellables)
 debouncethrottle
동작 방식이벤트가 멈추고 N초 뒤 통과N초 간격으로 한 번씩 통과
주요 용도실시간 검색창버튼 연타 방지
결과마지막 값첫 번째 또는 마지막 값

3. 필터링 (Filtering)

조건에 맞지 않는 값을 걸러내는 수문장 역할을 한다.

filter

실무 목적: 검색어가 2글자 이상인지, 값이 nil은 아닌지 등 조건에 맞지 않는 데이터를 하류로 못 내려가게 1차적으로 컷팅한다.

1
2
3
4
$searchText
    .filter { $0.count > 1 }
    .sink { print("2글자 이상: \($0)") }
    .store(in: &cancellables)

removeDuplicates

실무 목적: SwiftUI TextField의 렌더링 버그나 유저의 실수로 완벽히 동일한 검색어가 연달아 들어왔을 때, 억울한 중복 네트워크 요청을 차단한다.

1
2
3
4
$searchText
    .removeDuplicates()
    .sink { print("중복 제거 후: \($0)") }
    .store(in: &cancellables)

4. 스레드 관리 (Scheduling)

UI 업데이트와 백그라운드 작업을 분리하여 앱이 멈추거나 크래시 나는 것을 방지한다.

receive(on:)

실무 목적: 백그라운드 스레드에서 네트워크 통신이 끝난 뒤, 메인 스레드로 넘겨서 UI를 안전하게 업데이트할 때 필수적으로 붙인다. 누락 시 보라색 에러와 크래시가 발생한다.

1
2
3
4
5
6
service.fetchUsers()
    .receive(on: DispatchQueue.main)
    .sink { users in
        self.users = users
    }
    .store(in: &cancellables)

5. 다중 데이터 퓨전 (Combining)

실무의 화면은 API 하나로 끝나지 않는다. 흩어져 있는 여러 데이터를 하나의 화면으로 조립할 때 사용한다.

combineLatest

실무 목적: 프로필 정보, 팔로워 목록, 게시물 목록 등 여러 개의 API 요청이 ‘전부 도착했을 때’ 비로소 화면을 한 번에 그리기 위해 타이밍을 맞춰 묶어주는 역할을 한다.

1
2
3
4
5
6
7
8
9
10
Publishers.CombineLatest3(
    service.fetchProfile(login: username),
    service.fetchRepos(login: username),
    service.fetchFollowers(login: username)
)
.map { profile, repos, followers in
    ProfileViewData(profile: profile, repos: repos, followers: followers)
}
.sink { data in print(data) }
.store(in: &cancellables)

merge

실무 목적: 출처가 다른 여러 에러 스트림을 ‘단일 파이프라인’으로 합쳐서 일관성 있게 처리하고 싶을 때 사용한다.

1
2
3
4
5
6
7
8
Publishers.Merge(
    profileErrorPublisher.map { AppError.profile($0) },
    repoErrorPublisher.map { AppError.repo($0) }
)
.sink { error in
    self.errorMessage = error.localizedDescription
}
.store(in: &cancellables)

share

실무 목적: 같은 Publisher를 여러 곳에서 구독할 때 중복 네트워크 요청을 방지한다.

1
2
3
4
5
6
7
8
9
10
11
let sharedPublisher = service.fetchUsers().share()

sharedPublisher
    .sink { self.users = $0 }
    .store(in: &cancellables)

sharedPublisher
    .map { $0.count }
    .sink { self.count = $0 }
    .store(in: &cancellables)
// 네트워크 요청은 단 한 번만 나감

6. 에러 방어벽 (Error Handling)

Combine 파이프라인은 에러를 만나면 즉시 영구 사망(종료)한다. 이 치명적인 약점을 막아주는 생명줄이다.

catch

실무 목적: 에러가 발생했을 때 파이프라인이 죽는 것을 막고, 빈 배열([])이나 기본값 같은 안전한 데이터(Fallback)로 대체해서 다음 입력을 계속 대기할 수 있게 앱을 살려둔다.

1
2
3
4
5
6
7
$searchText
    .flatMap { username in
        service.searchUsers(query: username)
            .catch { _ in Just([]) }  // flatMap 안쪽 — 스트림 생존
    }
    .sink { users in self.users = users }
    .store(in: &cancellables)

retry

실무 목적: 일시적인 네트워크 오류 발생 시, 유저에게 에러를 바로 보여주기 전에 지정한 횟수만큼 내부적으로 조용히 다시 요청을 시도한다.

1
2
3
4
5
service.searchUsers(query: username)
    .retry(2)
    .catch { _ in Just([]) }
    .sink { users in self.users = users }
    .store(in: &cancellables)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.