iOS

Completion(escaping) 와 Combine

햄지이 2025. 1. 26. 15:52

현재 진행 중인 프로젝트는 SwiftUI를 사용하여 전체 앱을 리뉴얼하는 상황입니다.
서버 통신 역시 기존 Completion Handler(escaping closure)가 아닌 Combine을 통해 구현하고 있습니다. 여기서 의문점을 가지게 되었습니다. 단순히 “SwiftUI니까 Combine을 써야지!”라는 생각이 아니라, 두 방식을 비교해보고 Combine을 사용하면 어떤 점에서 이점이 있는지 이해하고자 합니다.

왜? 그냥 SwiftUI를 쓰느니깐 거기에 맞는 Combine을 쓰는거 아닐까? 라는 생각을할 수 도있지만 그래도 이유를 알고 쓰는점 또한 어떤한 문제점에서는 Combine이 아닌 CompletionHandler로 통신을할 수 도있기에 차이점에 대해서 알아보고자 합니다.

이스케이핑 클로저(Completion Handler)방식

  • 순수그자체?인점, Swift 초창기부터 사용된 전통적인 방법이므로, 대부분의 라이브러리나 예제 코드가 Completion Handler 기반입니다.
  • 네트워크 요청을 하고, 완료되는 시점에 completion(결과)을 호출합니다.
  • 단일 요청을 처리할 경우, 구현이 간단하고 직관적입니다.
  • 단일 호출만 할 때는 간단해 보이지만, 연속적 또는 병렬적인 여러 네트워크 요청이 필요할 때마다 콜백(Completion Handler)이 중첩되고, 로직이 복잡해질 수 있습니다.
  • 로직이 조금만 복잡해져도 “콜백 지옥(Callback Hell)”처럼 가독성이 떨어지는 문제를 일으킬 수 있습니다.
  • 복잡한 시나리오(연속적인 여러 API 호출, 분기 처리, 예외 처리)가 필요한 경우, 콜백이 중첩되어 코드의 구조가 깊어집니다.
  • 중첩된 Switch나 If 구문이 점점 늘어나 가독성이 떨어지고, 에러 처리가 어려워질 수 있습니다.

그렇다면 여기서 escaping에서 콜백 지옥? 음 아직 나는 겪어본적이 없는거같은데?(그렇다 한번에 많은 API를...) 라는 의문을 가지게 되었습니다.

콜백 지옥(Callback Hell)이란?
콜백 지옥이란, 여러 비동기 작업을 순차적으로 처리할 때 각 단계마다 콜백(Completion Handler)을 중첩해 호출해야 하면서, 코드가 지나치게 복잡해지고 가독성이 떨어지는 상황을 말합니다.
예를 들어, API1API2API3처럼 연속적으로 네트워크 요청을 보내야 할 때, 이전 요청이 성공해야 다음 요청을 보낼 수 있는 구조를 계속 콜백을 써서 이어가게 되면, 코드가 점점 깊어져서 “피라미드” 구조를 띠게 됩니다.

 fetchUserProfile(userId: userId) { result1 in 가장먼저 첫 API를 통해서 통신을 진행
        switch result1 {
        case .success(let user):
            // 첫 번째 콜백 진입
            self.fetchUserPosts(userId: user.id) { result2 in 첫 API가 통신에 성공했을경우 빠져나가는것이 아니라 다음 API 통신진행의 상태를 초례할 수 있다는점입니다.
                switch result2 {
                case .success(let posts):
                    // 두 번째 콜백 진입
                    guard let firstPost = posts.first else {
  • 콜백 지옥의 문제점
    가독성 저하: 한눈에 로직을 파악하기 힘들고, 코드가 수직으로 계속 늘어집니다. 아도겐코드??
    확장성 문제: 추가 기능(예: 네 번째, 다섯 번째 API 호출)을 붙일 때마다 더 깊은 중첩 구조가 생깁니다.
    에러 처리 어려움: 콜백 각각에서 발생하는 오류를 처리하고 그 흐름을 전체적으로 관리하기가 복잡해집니다.

이런 문제점들 너무 명확하게 나타나고 있습니다.
그래서 이러한 부분들을 해결하기 위해 여러가지 방법들을 생각하게 되는거같습니다..

콜백 지옥을 해결하는 방법
먼저, 구조적으로 변경하는방법?이 있지않을까 생각됩니다.

  • 단계별로 메서드 분리: 각 콜백 함수를 작게 나누고, 결과 처리 로직을 공통화하는 식으로 가독성을 높일 수 있습니다.
  • 프로젝트가 커지고 API 체인이 많아질수록 근본적으로는 콜백 자체가 제한점이 되기 때문에, 더 나은 비동기 프로그래밍 기법을 고려하게 됩니다.
  • Combine(또는 RxSwift)처럼 반응형을 도입하거나 iOS15부터 나타난 async/await(Swift Concurrency)를 사용하는 방법이존재할꺼같습니다.

여러가지의 API호출이 존재할 수 있기에.. 이스케이핑이 아닌 Combine으로 구현을 진행했다고 볼수 있을꺼 같습니다.
적다보니 왜? 부정적인 글이 너무 길게 되어서 당황스럽긴하네요... 하지만 이부분에 대해서 알면 알수록 서버통신에 있어서 잘할 수 있지않을까 생각됩니다.


Combine 방식

  • Publisher와 Subscriber의 개념을 통해 비동기 흐름을 “연쇄적으로” 또는 “반복적으로” 구성할 수 있습니다.
  • map, flatMap, filter 등 다양한 오퍼레이터로 데이터 변환이나 에러 처리를 일관성 있게 적용할 수 있어 로직이 한눈에 보이는 구조를 만들기 쉽습니다.
  • 여러 비동기 작업을 체이닝(chaining)하거나, 병렬적으로 수행하고 결과를 합치거나 하는 등의 작업을 선언적으로 표현할 수 있습니다.

체이닝(Chaining): 연속적인 네트워크 요청을 연쇄적으로 연결하기 쉬워집니다.
가독성 향상: 여러 API가 연결되어 있어도 중첩 클로저 대신 오퍼레이터 체인을 사용해 한눈에 로직이 드러납니다.
에러 처리 일관성: 스트림 도중 어느 단계에서 에러가 발생해도, 체인의 마지막에서 통합적으로 처리할 수 있습니다.
SwiftUI 친화적: SwiftUI 역시 Combine과 밀접하게 연동되도록 설계되어 있어, @Published나 ObservableObject 등을 활용하면 UI 업데이트와 비동기 작업을 일관성 있게 처리할 수 있습니다.

func fetchUserProfile(userId: String) -> AnyPublisher<User, Error>
func fetchUserPosts(userId: String) -> AnyPublisher<[Post], Error>
func fetchComments(postId: String) -> AnyPublisher<[Comment], Error>

func loadUserData(userId: String) -> AnyPublisher<[Comment], Error> {
    return fetchUserProfile(userId: userId)
        .flatMap { user in
            self.fetchUserPosts(userId: user.id)
        }
        .tryMap { posts in
            guard let firstPost = posts.first else {
                throw CustomError.noPosts
            }
            return firstPost
        }
        .flatMap { firstPost in
            self.fetchComments(postId: firstPost.id)
        }
        .eraseToAnyPublisher()
}

 


콜백 지옥(Callback Hell)

  • 여러 비동기 작업을 이스케이핑 클로저(Completion Handler)로 순차 연결할 때, 콜백이 깊게 중첩되어 가독성과 유지보수성이 떨어지는 문제.

 

Combine 사용 이유

  • SwiftUI와의 시너지: SwiftUI의 데이터 흐름과 Combine의 퍼블리셔-구독자 구조가 잘 어울립니다.
  • 체이닝이 쉽다: 여러 API를 연쇄적으로 호출할 때, 중첩 클로저 없이 연산자 체인으로 명확하게 표현할 수 있습니다.
  • 에러 처리 간편: .catch나 .tryMap 등 오퍼레이터를 통해 중간 단계 에러를 쉽게 처리 가능합니다.
    정도로 생각을 할 수 있을꺼 같습니다.

결론적으로, “왜 Combine을 선택했나?" 라면....
SwiftUI와 호환성, 비동기 처리 체이닝과 에러 처리의 가독성
콜백 중첩으로 인한 유지보수라는 점을 말할 수 있을꺼 같습니다.