2025. 6. 8. 22:06ㆍApple Framework, Library/Combine
저 몰래 천천히 야금야금 진행하던 <토스터 TOASTER> 코드 리팩토링이 거의 마무리 단계에 진입했습니다!
"중복 코드의 최소화" 그리고 "명확한 데이터 처리 흐름"을 목표로 코드 리팩토링을 계속 진행해왔고,
블로그로도 (언제가 될지는 모르겠지만) 차근차근 한 주제씩 다뤄보겠습니다! 오늘은 이 중 에러 처리 (Error Handling)에 집중해볼거구요.
- Combine 적용한 선언적 데이터 스트림 흐름의 ViewModel 구조 (Input-transform-Output 흐름) 적용하기
- Completion 핸들러 기반 네트워크 비동기 처리 메서드 -> Combine 기반 네트워크 비동기 처리 메서드로 변경 (코드 일관성 증대)
- 네트워크 메서드에서 발생하는 에러 처리 흐름 (Error Handling Stream) 구조화
- 이벤트 브로드캐스팅 (Event Broadcasting, NotificationCenter) 방식으로 토큰 만료시 수행되는 화면 전환 로직 중앙화
- Swift 6 마이그레이션으로 동시성 안전 (Concurrency Safety) 보장
[iOS] TOASTER 토스터 - 링크 아카이빙 & 리마인드
TOASTER 토스터 - 링크 아카이빙 & 리마인드[더 이상 링크를 태우지 마세요. 토스트 먹듯이 간단하게! TOASTER] 그동안 여러 플랫폼 이곳 저곳에 링크를 저장해왔나요? 링크가 필요할 때 바로바로
mini-min-dev.tistory.com
1. ViewModel의 Input으로 이벤트 발생 트리거
☑️ 이번 에러 처리가 필요한 예시 상황으로
"View에서 사용자가 완료 버튼을 클릭해 새로운 타이머를 추가 (postCreateTimer)하는 요청에서 발생하는 에러 로직"을 따라가며 설명해보겠습니다.
<토스터 TOASTER> 앱에서 사용하는 ViewModel은 가본적으로 Combine 기반 Input-Output 패턴이 적용되어 있습니다.
View에서 발생하는 이벤트가 Input Publisher에서 트리거되면,
ViewModel 내부에서 비즈니스 로직을 거쳐 특정 모델로 변환되고 -> Output Publisher로 다시 View에 방출하는 구조죠.
그래서 일단 View에서 유저의 완료 버튼을 탭하는 이벤트에서 시작합니다.
// RemindTimerAddViewModel struct 내부
// MARK: - Input State
struct Input {
let completeAddButtonTapped: Driver<(Int, RemindTimerAddModel)>
...
}
2. .networkFlatMap (Custom Operator)으로 네트워크 메서드 호출
해당 Input 스트림을 바탕으로, 네트워크 메서드 (postCreateTimerAPI)를 호출해줄 차례입니다.
// RemindTimerAddViewModel struct 내부
// func transform(_ input: Input, cancelBag: CancelBag) -> Output 메서드 내용 중 일부
input.completeAddButtonTapped
.networkFlatMap(self, { context, body in
context.postCreateTimerAPI(forClipID: body.0, forModel: body.1)
})
네트워크 메서드를 호출할 때, 저희 팀은 커스텀 .networkFlatMap 연산자 (Operator)를 사용합니다.
.networkFlatMap Operator는 flatMap과 catch 로직, 그리고 메모리 누수 방지 로직을 추상화해 Publisher 익스텐션에 정의해둔 Custom Operator입니다.
✔️ flatMap : Upstream Publisher로부터 전달받은 각 요소를 가지고 새로운 Publisher를 생성하고, 해당 Publisher가 방출하는 값을 Downstream으로 publish하는 Transforming Operator
✔️ catch : 에러가 발생했을 때, 해당 에러를 처리하고 새로운 Publisher로 대체하여 처리하는 Control Operator
먼저, networkFlatMap 연산자의 제네릭과 파라미터가 각각 의미하는 것이 무엇인지 이해하고 넘어가봅시다.
제네릭 이름 | 설명 | 실제 무엇이 해당? |
SelfPublisher | 해당 연산자 (networkFlatMap)를 사용하는 자기 자신 | RemindTimerAddViewModel |
NewPublisher | 내부에서 네트워크 메서드를 호출하고 반환되는 결과 | postCreateTimerAPI의 결과 AnyPublisher<Void, ToasterError> |
파라미터 이름 | 설명 | 실제 무엇이 해당? |
weakSelf | self의 순환 참조 방지를 위한 약한 참조 역할 (Upstream Publisher를 갖고 있고, networkFlatMap을 사용하는 자기 자신) |
네트워크 메서드를 담고있는 RemindTimerAddViewModel |
firstTransform | flatMap을 통해 새로운 Publisher로 변환하는 연산을 수행하는 역할 (Nework Service를 호출하는 ViewModel 내 네트워크 메서드 호출) |
실제 이 Operator가 담당하는 작업을 담은 클로저 |
onError | catch를 통해 firstTransform에서 에러가 발생할 때 side-effect의 동작을 정의하는 클로저 | 이 부분은 밑에 5번 파트에서 더 자세하게 설명해볼게요 ..^^ |
secondTransform | 에러가 발생했을 때 대체되는 Publisher. default값은 Empty가 수행 | Error가 발생하면 아무것도 방출하지 않도록 함. |
아직 조금 감이 안오죠?
그럼 위의 실제 사용 코드와 파라미터가 의미했던 객체를 기준으로 networkFlatMap 메서드 내부로 직접 들어가서, 순서대로 차근차근 어떻게 동작이 되는지 함께 이해해볼게요.
- 위에서 봤던 Upsteram Publisher를 통해 버튼 탭에 따른 데이터 스트림 (Int, RemindTimerAddModel) 값이 output으로 들어갑니다.
- 메모리 누수를 방지하는 목적에서 약한 참조로 자기 자신을 캡처하죠. -> self가 해제되면 바로 Empty()를 방출하고 종료됩니다!
- 그리고 난 다음 firstTransform(SelfPublisher, Output)을 통해 네트워크 메서드를
(드디어)호출하고 -> 그 네트워크의 결과가 NewPublisher로 연결됩니다. - 여기서 firstTransform, 즉 네트워크 요청 메서드의 결과로 에러가 발생하면 catch 연산자에 의해 onError 클로저가 실행됩니다.
이후 fallback Publisher에 해당되는 secondTransform (아무것도 방출하지 않는 Empty())를 반환하고 해당 Publisher는 종료됩니다.
import Combine
public extension Publisher {
func networkFlatMap<SelfPublisher: AnyObject, NewPublisher: Publisher>(
_ weakSelf: SelfPublisher?,
_ firstTransform: @escaping (SelfPublisher, Output) -> NewPublisher,
onError: ((Error) -> Void)? = nil,
_ secondTransform: @escaping (Error) -> AnyPublisher<NewPublisher.Output, Never> = { _ in
Empty().eraseToAnyPublisher()
}
) -> AnyPublisher<NewPublisher.Output, Failure> {
self.flatMap { [weak weakSelf] output -> AnyPublisher<NewPublisher.Output, Never> in
guard let weakSelf else { return Empty().eraseToAnyPublisher() }
return firstTransform(weakSelf, output)
.catch {
onError?($0)
return secondTransform($0)
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
결론!
networkFlatMap은 ViewModel에서 네트워크 메서드를 호출할 때,
Combine의 flatMap 연산을 수행하면서, 메모리 누수 방지 (weak weakSelf)와 에러 처리 (onError)를 도와주는 커스텀 Operator입니다.
3. Service 호출 네트워크 메서드 (postCreateTimerAPI -> AnyPublisher<Output, ToasterError>) 실행
다시 돌아와서!
ViewModel에서는 위에서 정의한 networkFlatMap Custom Operator을 사용해 네트워크 메서드를 호출합니다.
네트워크 메서드의 구현부는 싱글톤으로 구현되어 있는 NetworkService.shared 객체에 접근해 세부 Domain에 해당하는 네트워크 메서드를 호출 (NetworkService.shared.timerService.postCreateTimer)하는 부분.
그리고 호출된 결과값을 View의 사용 양식 (Model)에 맞게 매핑 (map)해주는 부분을 담당하고 있습니다.
// RemindTimerAddViewModel struct 내부
func postCreateTimerAPI(
forClipID: Int,
forModel: RemindTimerAddModel
) -> AnyPublisher<Void, ToasterError> {
return NetworkService.shared.timerService.postCreateTimer(
requestBody: PostCreateTimerRequestDTO(
categoryId: forClipID,
remindTime: forModel.remindTime,
remindDates: forModel.remindDates
)
)
.map { _ in () }
.eraseToAnyPublisher()
}
잠깐 여기서 네트워크를 담당하는 Concrete Service 부분을 또 다시 타고 들어가보면,
구현부 메서드 (postCreateTimer)를 볼 때 / 네트워크 라이브러리 Moya Provider에게 요청하고 그 결과값을 AnyPublisher<Output, ToasterError> 타입으로 받고 있습니다.
어떤 경우에 Output으로 Publish 되는지. 어떤 경우에 ToasterError로 Publish 되는지. 알기 위해서는 requestWithoutDecodeWithCombine 메서드를 타고 넘어가봐야겠군요!
import Combine
import Foundation
import Moya
protocol TimerAPIServiceProtocol {
func postCreateTimer(requestBody: PostCreateTimerRequestDTO) -> AnyPublisher<NoneDataResponseDTO?, ToasterError>
...
}
final class TimerAPIService: BaseAPIService<TimerTargetType>, TimerAPIServiceProtocol {
private let provider = MoyaProvider<TimerTargetType>.init(session: Session(interceptor: APIInterceptor.shared), plugins: [MoyaPlugin()])
func postCreateTimer(requestBody: PostCreateTimerRequestDTO) -> AnyPublisher<NoneDataResponseDTO?, ToasterError> {
return requestWithoutDecodeWithCombine(
provider: provider,
target: .postCreateTimer(requestBody: requestBody)
)
}
...
}
4. BaseAPIService에 정의된 디코딩 여부/statusCode에 따라 분기 처리 시키기
requestWithoutDecodeWithCombine과 requestWithCombine은 Moya와 Combine을 활용해
네트워크 응답값의 디코딩 처리 & 에러 핸들링을 수행한 후 -> Combine의 AnyPublisher<Output, ToasterError> 타입으로 매핑 시키는 부분입니다.
MoyaProvider의 request 응답값에 따라 분기처리 (.success vs .failure)를 나눠주고 있고,
세부적으로는 그 응답값에 담겨있는 상태 코드 (response.statusCode)에 따라 방출하는 에러 타입 (ToasterError)을 결정하고 있습니다.
import Combine
import Foundation
import Moya
class BaseAPIService<Target: TargetType> {
/// 200 받았을 때 decoding 할 데이터가 없는 경우 (대부분의 PATCH, PUT, DELETE)
func requestWithoutDecodeWithCombine(
provider: MoyaProvider<Target>,
target: Target
) -> AnyPublisher<NoneDataResponseDTO?, ToasterError> {
return Future { promise in
provider.request(target) { result in
switch result {
case .success(let response):
switch response.statusCode {
case 200, 201, 204: return promise(.success(nil))
default: return promise(.failure(.networkFail))
}
case .failure(let error):
guard let statusCode = error.response?.statusCode else { return promise(.failure(.networkFail)) }
switch statusCode {
case 400: return promise(.failure(.badRequest))
case 401: return promise(.failure(.unAuthorized))
case 404: return promise(.failure(.notFound))
case 422: return promise(.failure(.unProcessable))
case 500: return promise(.failure(.serverErr))
default: return promise(.failure(.networkFail))
}
}
}
}.eraseToAnyPublisher()
}
/// 200 받았을 때 decoding 할 데이터가 있는 경우 (대부분의 GET)
func requestWithCombine<T: Decodable>(
provider: MoyaProvider<Target>,
target: Target,
responseType: T.Type
) -> AnyPublisher<T, ToasterError> {
// 큰 틀은 위의 메서드와 동일함돠!
...
}
앗 참고로 앱에서 사용하는 커스텀 에러 타입 (ToasterError)은
Error 프로토콜을 채택한 열거형 (enum)으로 networkFail부터 serverErr까지 직접 statusCode에 따라 나누어 만들어서 사용하고 있죠!
import Foundation
enum ToasterError: Error {
case networkFail // 네트워크 연결 실패했을 때
case decodeErr // 데이터는 받아왔으나 DTO 형식으로 decode가 되지 않을 때
case badRequest // BAD REQUEST EXCEPTION (400)
case unAuthorized // UNAUTHORIZED EXCEPTION (401)
case notFound // NOT FOUND (404)
case unProcessable // UNPROCESSABLE_ENTITY (422)
case serverErr // INTERNAL_SERVER_ERROR (500번대)
}
5. onError 클로저 블록 (네트워크 요청이 실패했을 경우 불러지는 부분) 호출
지금까지 어떤 흐름을 타고 왔는지 정리 한번하고 갈게요!
📌 ViewModel의 Input으로 이벤트 발생 트리거 -> 📌 networkFlatMap 커스텀 Operator로 네트워크 요청 메서드 호출 -> 📌 네트워크 요청 메서드에서는 statusCode와 데이터 디코딩 여부에 따라 성공/실패 여부를 방출 -> 📌 실패 시에는
networkFlatMap 내부에 정의된 catch 블럭에 의해 onError 클로저 블럭이 수행되고, 방출 값은 Empty로 종료
뭐 이것저것 많은 것 같은데 정리해보니 깔끔하군요!
결국은 다시 networkFlatMap의 파라미터에 정의되었던 onError 클로저 블록으로 돌아갈 차례입니다.
promise(.failure(ToasterError)) 타입으로 방출되면,
networkFlatMap Operator 내부에서 transform(SelfPublisher, Output) 메서드를 호출하고 이어지는 catch 블록에 에러로 잡히게 될 것이기 때문이죠!
onError 블록에서는 이 catch에서 잡힌 에러를 어떻게 처리할지 정의해주면 됩니다.
// RemindTimerAddViewModel struct 내부
// func transform(_ input: Input, cancelBag: CancelBag) -> Output 메서드 내용 중 일부
input.completeAddButtonTapped
.networkFlatMap(self, { context, body in
context.postCreateTimerAPI(forClipID: body.0, forModel: body.1)
}, onError: { error in
guard let error = error as? ToasterError else { return }
switch error {
case .unProcessable:
output.onError.send(StringLiterals.ToastMessage.noticeSetTimer)
case .badRequest:
output.onError.send(StringLiterals.ToastMessage.noticeMaxTimer)
default:
output.onError.send(StringLiterals.ToastMessage.error)
}
})
6. ViewModel의 Output으로 에러 스트림 방출
<토스터 TOASTER>에서는 에러 또한 ViewModel의 Output 형태로 Publish하도록 구조를 통일했습니다.
위의 상황 같은 경우에는
.unProcessable (처리할 수 없음)과 .badRequest (잘못된 요청), 그리고 그 외 네트워크 에러 상황에 대해 각각 에러를 View로 다시 넘겨주는 로직이입니다.
View에서는 여기서 case 별로 넘겨받은 Message (String Type) 값을 바탕으로 토스트 메시지를 표출해줄 수 있었죠.
*어떤 경우에서는 View에 Alert를 띄우는 방식으로, 혹은 Root 화면으로 넘어가거나, 앱 자체를 즉시 종료시키는 방식으로 로직을 써볼 수도 있을 것 같네요!
// RemindTimerAddViewModel struct 내부
// MARK: - Output State
struct Output {
...
let onError = PassthroughSubject<String, Never>()
// RemindTimerAddViewController class 내부
output.onError
.sink { [weak self] error in
guard let self else { return }
showToastMessage(width: 297, status: .warning, message: error)
}.store(in: cancelBag)
결론!
결국은 네트워크 통신을 도와주는 .networkFlatMap Operator에 담겨있는 onError 클로저에 에러 처리 로직을 담는다.
그리고 그 에러 값 역시 View에서 처리해주기 위해 Output Publisher로 값을 방출해준다.가 이 글의 핵심 내용이었습니다.
또 글에는 담지 못했지만,
MoyaPlugin에서는 네트워크 연결 오류 (데이터 차단) 상황을 먼저 감지하고 Alert를 표출해주는 로직도 담아서 사용하고 있습니다!
아직은 많이 부족하고 부끄러운 코드인 것 같네요.
더 많이 공부하고, 또 다른 개선된 방법이 무엇이 있을지 / 이 방법에서 미처 고려하지 못한 요소들이 무엇이 있을지 봐야할 것 같습니다.
댓글로 질문이나 지적 얼마든지 환영합니다..!
긴 글 읽느라 고생많으셨어요😊 그럼 오늘은 여기까지 !
'Apple Framework, Library > Combine' 카테고리의 다른 글
[Combine] Combine Operator 완전 정복하기 (4) - Timing and Control Operators (0) | 2025.04.06 |
---|---|
[Combine] Combine Operator 완전 정복하기 (3) - Filtering Operators (0) | 2025.03.25 |
[Combine] Combine Operator 완전 정복하기 (2) - Transforming Operators (0) | 2025.03.24 |
[Combine] Combine Operator 완전 정복하기 (1) - Combining Operators (0) | 2024.12.26 |
[Combine] Cancellable, AnyCancellable 개념 뿌시기 (3) | 2024.12.05 |