Combine framework를 사용하면서 Error는 잠재적으로 발생할 수 있습니다. 프로그램 실행 중 발생한 Error는 throwing 됩니다. 이때 catching. 예외 처리를 할 수 없다면 런타임 오류가 발생하게 됩니다. 앱은 멈추게 됩니다. 따라서 에러를 처리할 수 있는 다양한 방법에 대해서 정리하려고 합니다.
1부는 정말 간단하게 Error의 특징에 대해 짧게 알아보고 2부에선 Combine을 사용할 때 발생한 에러를 처리하는 방법에 대해 탐구할 것입니다.
1. What is Error? Error Handling Deep Dive :)
Swift에서 에러는 Error protocol을 따릅니다. 반드시 Error 프로토콜을 채택한 object를 구현해야 Swift error handling system에 의해 던져집니다. 우리가 작성한 코드는 예기치 못한 에러가 발생할 수 있을 경우가 분명 존재 합니다. 에러가 발생할 경우를 에러 유형을 구분 지어 정의하고 처리하는 것은 중요하다는 생각이 듭니다. 기본적인 에러 처리 형태는 do - try - catch 형태입니다. 공식 문서에서 자세하게 설명합니다!!
한 가지 정말 중요한 사실이 있습니다. Swift는 NSError, Swift.Error, Error 타입을 채택한 object(Typed Error)의 throw를 지원하지 않습니다. 간단한 예를 통해 확인할 수 있습니다.
enum StudyTypeError: Error {
case watchNetflix
case watchYoutube
case unknown
}
extension StudyTypeError: CustomStringConvertible {
var description: String {
switch self {
case .watchYoutube: return "유튜브 봤어..ㅋㅅㅋ"
case .watchNetflix: return "넷플릭스 재밌더라. 시간 순삭!!"
case .unknown: return "나 오늘 뭐 했지?"
}
}
}
간단한 오류를 정의 했습니다.
let todayStudy: Bool? = nil
func didYouStudyToday() throws -> Bool {
guard let study = study else {
throw StudyTypeError.watchYoutube
}
return study
}
오늘 공부했는지 여부를 담는 프로퍼티를 선언 했습니다. didYouStudyToday()를 통해 오늘 공부 했는지 여부를 파악합니다. 아직 오늘 하루가 안 끝났을 수도 있고 공부하려고 했는데 아직 공부를 안 했을 수도 있습니다. 공부를 하지 않을 다양한 이유가 있습니다. 공부를 하지 않는 다양한 상황은 todayStudy를 nil로 표현하려고 합니다!! StudyTypeError에는 다양한 상황을 작성할 수 있습니다.
do {
_ = try didYouStudyToday()
}catch {
switch error as! StudyTypeError {
case .watchYoutube:
print("DEBUG: \(StudyTypeError.watchYoutube)")
case .watchNetfilx:
print("DEBUG: \(StudyTypeError.watchNetflix)")
case .unknown:
print("DEBUG: \(StudyTypeError.unknown)")
}
}
Catch구문에서 반드시 내가 지정한 커스텀 타입으로 캐스팅해야 합니다. didYouStudyToday가 던진 에러는 StudyTypeError의 .watchYoutube이지만 throws를 통해 던져진 에러는 any Error 타입이기 때문입니다. 커스텀으로 정한 Typed Error를 throw 할 수 없습니다.
아! Custom Error Type를 받을 수 있는 방법이 있습니다. Result를 사용하거나 Future를 사용하는 것입니다.
func didYouStudyTodayReally() -> Result<Bool,StudyTypeError> {
guard let study = study else {
return .failure(StudyTypeError.watchYoutube)
}
return .success(study)
}
Result<Success,Failure> 타입을 반환하도록 함수를 선언합니다.
이때 switch case의 .failure은 StudyTypeError입니다. "DEBUG: 유튜브 봤어..ㅋㅅㅋ"가 출력됩니다.
이 두 경우는 throws를 사용하지 않습니다. 실패 타입을 반환하는 경우입니다. 이를 제외하고 throw, throws를 통해 받은 에러는 내가 커스텀한 에러 타입으로 던져지지 않고 Any Error 타입으로 던져집니다. 그래서 직접 커스텀 type으로 캐스팅해서 다뤄야 합니다. 이젠 컴바인에서 발생하는 에러를 처리하는 방법을 정리하려고 합니다.
2. Error Handling with Combine! Let's Deep Dive :]
컴바인은 Publisher가 value를 시간의 흐름에 따라 emit 하면 publihser를 구독한 subscriber에게 값이 전달됩니다. publihser, subscriber. 둘 다 두 개의 generic type를 갖고 있습니다. 원래 Publihser가 subscriber에게 값을 전달할 수 있는 조건은 두 generic type이 일치해야 값을 전달할 수 있습니다.
Failure generic 타입은 pubilsher가 error를 publish 할 때 타입입니다. Never로 지정할 경우 해당 publisher는 절대로 에러를 발생하지 않는다고 보장할 수 있습니다. 그 예로 Just publihser가 있습니다.
Just는 기본적으로 Failure은 Never입니다.
Just("I didn't study anything.. I watched Netflix today")
.sink{print($0)}.store(in: &subscriptions)
출력은 당연히 아래와 같이 나옵니다.
값을 publish 하기 전에 Netfilx라는 단어가 있으면 위에서 정의한 StudyTypeError.watchNetflix에러를 throw 하도록 변경할 수 있습니다. Failure타입을 Never -> StudyTypeError로 지정하는 방법은 setFailureType(to:)를 통해 지정하면 됩니다.
Just("I didn't study anything.. I watched Netflix today")
.setFailureType(to: StudyTypeError.self) //1
.tryMap{string in //2
guard string.split{$0==" "}.filter{$0=="Netflix"}.isEmpty else {
throw StudyTypeError.watchNetfilx
}
return string
}
.sink(receiveCompletion: { completion in //3
switch completion {
case .failure(let error):
print(error)
case .finished:
print("Finished just publish!!")
}
}, receiveValue: { string in
print(string)
})
.store(in: &subscriptions)
1. setFailureType operator를 사용해 Just에 error type을 정합니다.
2. map 대신 tryMap operator를 사용합니다.
map의 경우 (_) -> _의 형식입니다. 하지만 위의 코드 //2의 경우에는 guard를 통해 특정 Error를 throw 합니다. (_) -> _ 의 형식은 사용할 수 없습니다. map의 내부 로직에서 throw를 할 수 없기 때문에 컴파일 에러가 발생됩니다.
(_) throws -> _ 의 형식을 사용해야 합니다. 이는 tryMap operator가 throws 기능을 제공하기 때문에 map operator가 아닌 tryMap operator를 사용해야 합니다.
Combine에서 Operator들은 대부분 try prefix가 달린 Operator가 대부분 있습니다. map , tryMap과 같이 말입니다!! 이들의 특징은 "throws" 기능이 있습니다. 즉 Operator 내부에서 error가 발생하면 던질 수 있다는 것입니다.
실행하면 에러를 print 한 CustomStringConvertible의 description이 출력됩니다. 그리고 cancel()을 호출합니다.
여기서 대박 사건이 있습니다. Just의 Failure type은 Never가 디폴트입니다. 하지만 setFailureType(to:) operator를 통해 직접 Error타입을 직접 명시했고 tryMap을 통 .watchNetflix를 던졌습니다.
하지만 completion은 Error타입으로 되어 있습니다. 위에 1부. 에러에 관한 특징 파트에서 설명했던 바와 같이 Swift는 내가 커스텀 타입을 throw 했어도 Swift.Error로 erase 합니다. typed throws를 지원하지 않기 때문입니다. 물론 에러를 던지지 않아도 Swift.Error 타입으로 변경됩니다. tryMap 뿐 아니라 try* Operator의 특징입니다.
try* operator 덕에 기본 Error 타입으로 변경 됐지만 mapError operator를 chain 하면 됩니다: ] 다시 원하는 Error 타입으로 캐스팅한 Failure type를 가질 수 있습니다. tryMap코드 바로 아래에 아래의 코드를 연결합니다.
.mapError{ $0 as! StudyTypeError }
mapError를 통해 다시 커스텀 타입의 Error를 캐스팅합니다.
에러 타입이 다시 커스텀 타입으로 반환됩니다!! setFailureType를 써서 그런 게 아닌가 하는 의문이 있을 수도 있습니다. PassthroughSubject 등 초기 선언부터 특정 타입으로 명시해도 try* operator를 사용할 경우 Swift.Error 타입으로 에러가 던져지거나 Error 타입이 바뀌게 됩니다.
여기서 주의해야 할 점이 있습니다. Output value를 보냈지만 operator를 통해 subscription의 값을 변경하는 과정에서 에러가 발생한 것입니다. tryMap operator를 통해 던져진 에러 덕분에 value는 subscriber에게 전달되지 않고 subscription으로부터 receive cancel 합니다. 더 이상 publihser는 subscriber에게 publish 하지 못합니다. cancel 됐기 때문입니다. replaceError(with:)를 통해 cancel이 아니라 .finished 이벤트를 발생할 수 있습니다. 여전히 publihser는 종료됩니다.
뒤에서 설명할 추가 상황을 위해 Just publisher가 아니라 CurrentValueSubject로 바꾸겠습니다.
//Just("I didn't study anything.. I watched Netflix today")
let subject = CurrentValueSubject<String,StudyTypeError>("I didn't study anything.. I watched Netflix")
subject
.tryMap{ ... //위에 Just에사용했던 로직과 같습니다
처음의 예를 이어나가자면.. 아침에 일어나서 방금까지 Netfilx를 봤기에 공부를 안 했다고 말할 수 있습니다. 근데 하루는 길고!! 넷플릭스 시청 이후에 공부를 시작했을 수도 있습니다. 그래서 subject를 통해 특정 subscriber(공부 중이라는 화면 UI 변경해주는 subscriber)한테 지금 공부한다고 상태를 변경하는 말을 보내고 싶습니다!!
하지만 subejct 역시 초기에 Netflix라는 단어가 있었기에 tryMap을 통해 error를 던졌고 subject는 cancel 됐습니다. 더 이상 값을 받아도 publish 할 수 없습니다. cancel 됐기 때문입니다. 방법이 없을까요? 이때도 operator를 사용하면 됩니다. 이전 #3 포스트에서 subject에서도 CurrentValueSubejct의 input type는 [1,2,3] 이었는데 각각의 타입을 Int타입으로 변경했던 것처럼 flatMap을 사용하면 됩니다.
.flatMap{ string in
Just(string)
.tryMap{string in //2
guard string.split{$0==" "}.filter{$0=="Netflix"}.isEmpty else {
throw StudyTypeError.watchNetflix
}
return string
}
.mapError{ $0 as! StudyTypeError }
.catch{ error in
Just("에러 발생했는데 :\(error)")
}
}
.sink(...
에러가 발생할 수 있는 로직은 flatMap을 통해 감싸서 특정 로직 처리 후 catch 구문을 통해 publish 하면 됩니다.
flatMap을 사용하지 않은 Just의 경우 초기 tryMap을 통해 error를 발견합니다. throw 하게 되고 결국 subject는 cancel 됩니다. 하지만 위 코드처럼 에러가 발생할 수 있는 로직은 flatMap과 Just()를 사용해 tryMap -> catch로 에러를 잡도록 하면 됩니다.
flatMap을 사용했기에 subject에게 보낸 문장 중 error를 발생할 수 있는 경우는 Just()의 catch가 처리됩니다. 그리고 Just Publisher는 더이상 퍼블리싱을 하지 않지만, flatMap operator에서 error를 throw하지 않은 경우가 됩니다. 따라서 위와 같은 문장을 send하면 subscriber는 받을 수 있습니다. 초기 에러는 flatMap의 Just()와 catch를 통해 잡았기 때문입니다.
에러가 없다면 성공적으로 subscriber에게 값을 전달할 수 있습니다. 에러가 있다면 flatMap안에 있는 새로운 Just() publihser로 부터 에러가 발생된 경우임으로 flatMap과는 상관이 없습니다.(물론 위의 코드에서 Just().tryMap{...}.catch{...}를 통해 에러를 잡아줬기 때문입니다.
Just()는 down stream의 tryMap의 로직 수행 중에 에러가 던져진다면!!! subscription의 receive cancel를 이벤트를 호출하며 에러를 발생합니다. 이 에러는 Just의 catch를 통해 잡을 수 있고 catch는 위에 말한 것처럼 .finished 이벤트를 발생합니다. origin publihser인 subejct에서 수행하는 게 아니라 Just() publihser가 수행하는 것입니다. (결론 : origin publihser에 영향 x)
flatMap은 정말 신기합니다. Subejct 관련 정리 포스트에서도 살짝 사용해 봤지만,, flatMap은 publisher를 반환합니다. 이때 upstream publisher와 타입이 같을 수도 다를 수도 있습니다. origin publisher(flatMap을 사용하는 upstream publisher)에서 flatMap을 사용했을 때 발생된 새로운 여러 개의 publisher는 단일 publisher로 received 됩니다. flatMap을 통한 각각의 퍼블리셔가 send 요청을 받았을 경우 단일 publihser가 publish 하기 때문입니다._ raywenderlich(버퍼 문제 발생가능 -> maxPublishers로 publishersr개수 지정)
flatMap을 통한 새로운 publihser가 버퍼에 의해서 origin publihser! 단일 publisher로 emit이 가능합니다. 이 publihser들은 cancel이나 .finish되지 않는 한 초기 origin publihser를 통해 downstream(subscriber)에게 emit 합니다. (merge느낌,,) 하지만 새로운 publihser를 Just로 사용하면, Just의 특성상 단 한번 publish 한 후 .finished 호출됨으로 위와 같은 상황[에러가 발생해도 cancel 되지 않고 계속해서 publish 할 수 있는 상황]에 효율적으로 사용될 수 있다는 말입니다!!
긴 글 읽어주셔서 감사합니다.
틀린 부분 발견시 댓글로 알려주시면 정말 감사합니다.
참고
https://www.kodeco.com/books/combine-asynchronous-programming-with-swift