** 해당 글은 라이빗 팀블로그에 게재된 글을 개인 블로그로 아카이빙해 재게시한 글입니다.
라이빗에서는 서버 통신을 위한 Network 모듈을 개발하는 과정에서 비동기 처리를 구현하기 위해 Combine 프레임워크를 사용했는데요.
이번에 Swift 6이 발표되며 async/await에도 많은 변화가 생겼고, Network 모듈 구조를 async/await으로 리팩토링하게 되었습니다.
오늘은 왜 Combine이 아닌 async/await을 선택했는지, 어떻게 전환했는지에 대한 과정을 설명드리고자 합니다.
![]()
iOS 개발에서 서버와의 네트워크 통신을 위한 비동기 처리는 선택이 아닌 필수입니다.
이 비동기 처리를 위해 가장 기본적인 Completion Handler나 서드파티 라이브러리인 RxSwift 등 여러 방법을 사용하곤 하죠!
그 중에서도 라이빗에서 처음 네트워크 모듈을 구현할 때는 Combine을 채택해서 구현했는데요.
Combine을 채택한 가장 큰 이유는 Combine이 애플의 퍼스트파티 프레임워크이며 SwiftUI와의 호환성이 좋다는 이점 때문이었습니다.
Combine과 SwiftUI가 모두 선언형 패러다임을 따르고 있기 때문에 이 부분을 통일시킨다면 구현과 유지보수가 더 수월할 것이라고 판단했습니다.
프로젝트가 작은 규모는 아니다 보니 RxSwift와 같이 별도의 서드파티 라이브러리를 사용하기가 부담스럽기도 했는데요.
Alamofire, Kingfisher와 같이 구현에 필요한 최소한의 라이브러리를 제외하고는 최대한 내장 프레임워크를 이용해 구현해보자는 생각으로 선택했던 것도 있었던 것 같습니다 ㅎㅎ 외부 의존성을 떨어트릴 수 있다는 장점도 있었구요!
또한 중요하게 고려했던 점은 타입 안정성이었습니다.
Combine은 Swift의 강력한 타입 안정성을 그대로 활용해 컴파일 타임에 타입 불일치 오류를 잡아낼 수 있는데요!
AnyPublisher<DTO.Response.FetchUserInfo, NetworkError>
위와 같이 Publisher의 Output과 Failure 타입이 명확하게 정의되어 있어 네트워크 응답을 처리할 때 정확히 어떤 데이터가 오고, 어떤 에러가 발생할 수 있는지 타입 레벨에서 보장받을 수 있습니다. 타입 안정성 덕분에 런타임 에러를 크게 줄일 수 있고, 코드 리뷰나 리팩토링 과정에서도 더 안전하게 작업할 수 있다는 장점이 있죠!
기존 Combine에서는 여러 API 호출 간 데이터를 공유하거나 이전 호출의 결과를 다음 호출에 사용하려면 복잡한 오퍼레이터 체이닝이 필요했습니다. 특히 네트워크 통신에서는 한 API를 호출하기 위해 기존 API의 호출 결과를 활용해야 할 때가 있는데요, Combine에서는 Publisher를 이용해 결과 값을 감싸 저장하기 때문에 값을 활용하기 위한 체이닝 과정이 길고 복잡했습니다.
// Combine - 유저 정보와 콘서트 정보를 함께 필요로 하는 경우
let userPublisher = networkService.fetchUserInfo().share()
let concertPublisher = networkService.fetchConcertList().share()
Publishers.CombineLatest(userPublisher, concertPublisher)
.flatMap { userInfo, concerts in
// userInfo.id와 concerts[0].id를 모두 사용하는 API 호출
networkService.fetchCommentList(userId: userInfo.id, concertId: concerts[0].id)
}
.sink { ... }
.store(in: cancelBag)
특히 라이빗에서는 아티스트-콘서트-셋리스트-노래 로 이어지는 데이터의 연관 관계가 깊고 복잡하기 때문에 이런 코드에 대한 간소화 과정이 반드시 필요했습니다.
🙋 헉 근데 상태 관리나 실시간으로 변하는 데이터는 어떡하죠?
물론 데이터의 연속성을 관리하기 위해서는 여전히 Combine이 필요합니다. 저희도 UI의 상태 관리나 실시간으로 변하는 데이터의 경우 Combine을 사용해 구현하고 있는데요! 이 글에서 설명하는 async/await은 네트워크 모듈에 대한 내용이니 읽으실 때 참고 부탁드립니다 🙇♀️
네트워크 모듈은 대부분의 코드가 API 호출, 즉 단발성 네트워크 요청/응답으로 이루어져 있고, 이를 구현하기 위해서는 async/await이 더 간단하고 적합하다고 판단했습니다. 위의 복잡한 코드를 async/await으로 표현한다면 어떨까요?
// async/await - 동일한 작업
let userInfo = try await networkService.fetchUserInfo()
let concerts = try await networkService.fetchConcertList()
let comments = try await networkService.fetchCommentList(
userId: userInfo.id,
concertId: concerts[0].id
)
훨씬 간단하고 가독성도 높은 코드가 된 것을 확인할 수 있습니다!
위에서 언급했던 것처럼 Combine은 Swift의 강력한 타입 안정성을 활용해 컴파일 타임에 타입 불일치 오류를 잡아낼 수 있습니다.Combine의 최대 장점 중 하나라고 생각했는데... 이번에 Swift 6가 발표되면서 async/await에서도 에러 타입을 특정해 throw할 수 있도록 기능이 확장되었죠! 코드로 보시면 아래와 같습니다.
// Swift 5.x - 에러 타입을 특정할 수 없었음
func fetchUserInfo() async throws -> DTO.Response.FetchUserInfo {
// Error 프로토콜을 따르는 모든 타입을 throw 가능
// 호출하는 쪽에서 정확히 어떤 에러가 올지 알 수 없음
}
// Swift 6 - typed throws로 에러 타입 특정 가능
func fetchUserInfo() async throws(NetworkError) -> DTO.Response.FetchUserInfo {
// NetworkError 타입만 throw 가능
// 컴파일 타임에 타입 체크
}
기존 Swift 5 버전에서는 타입이 명확하지 않아 따로 타입 캐스팅이 필요했는데요, 이번 Swift 6에서는 에러 타입을 지정해줄 수 있게 되면서 아래와 같이 더 간편하게 사용할 수 있게 되었습니다.
// Swift 5.x
do {
let userInfo = try await fetchUserInfo()
} catch {
// error의 타입이 명확하지 않아 타입 캐스팅 필요
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
// 인증 에러 처리
default:
// 기타 네트워크 에러
}
}
}
// Swift 6
do {
let userInfo = try await fetchUserInfo()
} catch {
// error가 NetworkError 타입임이 보장됨
switch error {
case .unauthorized(let message):
// 인증 에러 처리
case .serverError(let message):
// 서버 에러 처리
case .decodingFailed:
// 디코딩 에러 처리
default:
// 기타 에러
}
}
Combine의 복잡한 데이터 체이닝 과정은 줄이고, 타입 안정성은 동일하게 가져갈 수 있으니 async/await을 선택하지 않을 이유가 없었던 겁니다!
Network 모듈의 핵심인 handleResponse 메서드를 예시로 보여드리겠습니다.
handleResponse에서는 서버에서 받아온 응답의 에러 처리를 담당하고, 최종적으로는 응답 데이터를 반환하는데요. 먼저 Combine 기반 구현 코드를 보여드리겠습니다.
private extension NetworkService {
func handleResponse<T: Decodable>(_ dataRequest: DataRequest) -> AnyPublisher<T, NetworkError> {
return dataRequest
.publishDecodable(type: T.self)
.tryMap { [responseHandler] response in
try responseHandler.handle(response)
}
.mapError { [errorMapper] error in
errorMapper.map(error)
}
.eraseToAnyPublisher()
}
}
Combine 기반 코드는 Publisher 체인을 통해 응답을 처리하고 있습니다. publishDecodable, tryMap, mapError, eraseToAnyPublisher 등 여러 오퍼레이터를 거쳐야 응답 데이터를 받아볼 수 있는데요. 이 응답 데이터마저 AnyPublisher로 감싸져 있어 값을 사용하기 위해서는 한번 더 오퍼레이터를 거쳐 값을 언래핑하는 과정이 필요했습니다.
그렇다면 async/await은 어떨까요?
private extension NetworkService {
func handleResponse<T: Decodable>(data: Data, response: HTTPURLResponse) async throws(NetworkError) -> T {
return try await responseHandler.handle(data: data, response: response)
}
}
놀랍게도 위와 같은 코드입니다. 너무 간단해서 저도 구현해놓고 잘못 구현했나... 싶었는데요.
Publisher 체인이 사라지고 일반적인 함수 호출처럼 변경되었다는 점, async throws(NetworkError)로 정확한 에러 타입을 명시했다는 점, 복잡한 오퍼레이터 없이 직관적인 코드 흐름을 가진다는 점 등 Combine 기반 코드보다 명확한 장점을 가진 코드가 되었습니다.
위에서 말씀드렸던 것처럼 이 아티클은 Combine보다 async/await이 훨씬 낫다! 고 말하는 글이 아닙니다!
상태를 변경해야 할 때, 단발적으로 데이터를 받아와야 할 때, 등등 여러 상황마다 알맞은 구현 방법을 선택해 구현하신다면 더 나은 코드를 만들 수 있다는 점을 저도 이번 기회를 통해 배우게 된 것 같습니다. ㅎㅎ
다양한 상황에서 유연하게 대처할 수 있는 iOS 개발자가 되기 위해 열심히 공부해야겠다고 다짐하면서 글을 마쳐보겠습니다.
긴 글 읽어주셔서 감사합니다. 라이빗 iOS 깃허브도 한번 보고 가세요! (앱스토어 출시도 조만간... 합니다)
© 2025 Youjin Lee. All rights reserved.