-
Chapter 16: Error HandlingRaywenderlich/Combine: Asynchronous Programming 2020. 8. 26. 01:21
Version
Swift 5.3, iOS 14, Xcode 12
값(values)을 내보내(emit)는 Combine 코드를 작성하는 방법에 대해 많은 것을 배웠다. 하지만 지금까지 작성한 대부분의 코드는 오류(errors)를 전혀 처리(deal with)하지 않고, 대부분 "happy path"를 다뤄왔다는 것을 눈치 챘을 것이다.
오류없는(error-free) 앱을 작성하지 않는 한, 이 장(chapter)은 바로 당신을 위한 것이다.
1장(chapter), "Hello, Combine!"에서 배운 것처럼 Combine publisher는 publisher가 내보낸(emits) 값의 유형을 정의(declares)하는
Output
과, 이 publisher가 완료(finish)할 수 있는 실패(failure) 유형을 정의(defines)하는Failure
라는 두 가지 제네릭(generic) 제약 조건(constraints)을 선언(declares)한다.지금까지 publisher의
Output
유형(type)에 집중했지만, publishers에서Failure
의 역할에 대해 자세히 확인하지 못했다. 이 장(chapter)에서 이를 학습하게 될 것이다.Getting started
이 장(chapter)의 시작(starter) playground를 projects/Starter.playground에서 연다(open). 이 playground의 다양한 페이지(pages)를 사용하여, Combine에서 오류(errors)를 처리(handle)하고 조작(manipulate)할 수 있는 다양한 방법을 확인(experiment)할 것이다.
이제 Combine의 오류(errors)에 대해 자세히 살펴볼(deep dive into) 준비가 되었다. 우선 그것에 대해 잠시 생각해본다. 오류(errors)는 매우 광범위(broad)한 주제(topic)이기에 어디서부터 시작해야 할지 혼란스러울 수 있다.
우선, 오류가 없는 것(absence)부터 시작한다.
Never
Failure
유형(type)이Never
인 publisher는 절대 실패할 수 없음(never fail)을 나타낸다.처음에는 조금(tad) 이상하게 보일 수 있지만, 이것은 publishers에 대해 매우 강력한 보증(guarantees)을 제공한다.
Never
failure 유형(type)의 publisher를 사용하면, 게시자가 절대 실패하지 않을(never fail) 것임을 절대적으로(absolutely) 확신(sure)하면서 publisher의 값(values)을 사용(consuming)하는 데 집중(focus on)할 수 있다. 완료(done)된 후에만 성공적(successfully)으로 완료(complete)할 수 있다.Command-1을 눌러 시작(starter) playground에서 프로젝트 탐색기(Project Navigator)를 연 다음 Never playground를 선택한다.
여기에 다음 예제(example)를 추가한다:
example(of: "Never sink") { Just("Hello") }
Hello
라는 문자열(string) 값(value)을 사용하여Just
를 생성한다. 해당Just
는Failure
유형(type)으로Never
를 선언(declares)한다. 이를 확인(confirm)하려면Just
생성자(initializer)를 Command-click하여 정의로 이동(Jump to Definition)을 선택한다:정의(definition)를 살펴보면,
Just
의 failure 유형(type)의 별칭(alias)을 볼 수 있다:public typealias Failure = Never
Never
에 대한 Combine의 실패 없음(no-failure) 보장(guarantee)은 단순히 이론적(theoretical)인 것이 아니라 프레임 워크(framework)와 다양한(various) API에 깊숙이 뿌리 두고 있다.Combine은 publisher가 절대 실패하지 않을 경우(never fail)에만 사용할 수 있는(guaranteed) 여러 연산자(operators)를 제공(offers)한다. 그 첫 번째는 값(values)만 처리(handle)하는
sink
의 변형(variation)이다.Never playground 페이지(page)로 돌아가, 위의 예제(example)를 다음과 같이 업데이트(update)한다:
example(of: "Never sink") { Just("Hello") .sink(receiveValue: { print($0) }) //Never 에서만 사용할 수 있는 sink의 변형. receiveCompletion 없이 값만 처리할 수 있다. .store(in: &subscriptions) }
playground를 실행(run)하면
Just
의 값(value)이 출력(printed out)되는 것을 볼 수 있다:——— Example of: Never sink ———
Hello위의 예에서는
sink(receiveValue:)
를 사용한다. 이러한 특정(specific)sink
오버로드(overload)를 사용하면, publisher의 완료(completion) 이벤트(event)를 무시(ignore)하고 내보낸(emitted) 값(values)만 처리(deal with)할 수 있다.이 오버로드(overload)는 실패하지 않는(infallible) publishers에서만 사용할(available) 수 있다. Combine은 오류(error) 처리(handling)에 있어 영리(smart)하고 안전(safe)하며, 오류(error)가 발생(thrown)할 경우(예 : 실패하지 않은(non-failing) publisher) 완료(completion) 이벤트(event)를 처리(deal with)하도록 강제(forces)한다.
이를 확인하려면, 절대 실패하지 않는(
Never
-failing) publisher를 실패(fail)할 수 있는 publisher로 전환(turn)한다. 이를 수행하는 몇 가지 방법이 있으며, 가장 많이 사용되는setFailureType
연산자(operator)부터 시작한다.setFailureType
실패하지 않는(infallible) publisher를 실패할 수 있는(fallible) publisher로 전환하는 첫 번째 방법은
setFailureType
을 사용하는 것이다. 이는 실패(failure) 유형(type)이Never
인 publishers만 사용할(available) 수 있는 또 다른 연산자(operator)이다.playground 페이지(page)에 다음 코드와 예제(example)를 추가한다:
enum MyError: Error { case ohNo } example(of: "setFailureType") { Just("Hello") }
예제(example) 코드의 범위(scope)를 벗어나(outside)
MyError
오류(error) 유형(type)을 정의(defining)한다. 이 오류(error) 유형(type)은 잠시 후에 다시 사용(reuse)할 것이다. 그런 다음 이전(before)과 비슷하게Just
를 작성하여 예제(example)를 시작한다.이제
setFailureType
을 사용하여 publisher의 실패(failure) 유형(type)을MyError
로 변경(change)할 수 있다.Just
바로(immediately) 뒤에 다음 행(line)을 추가한다:.setFailureType(to: MyError.self)
실제로 publisher의 실패(failure) 유형(type)이 변경(changed)되었는지 확인(confirm)하려면,
.eraseToAnyPublisher()
를 입력한다. 그러면 자동 완성(auto-completion)으로 지워진(erased) publisher 유형(type)이 표시된다:계속하기(proceeding) 전에 입력한(typing)
.erase...
행(line)을 삭제(delete)한다.이제
sink
를 사용하여 publisher를 사용(consume)한다.setFailureType
호출(call) 직후(immediately after)에 다음 코드를 추가한다:.sink(receiveCompletion: { completion in //완료 이벤트 처리 switch completion { case .failure(.ohNo): //failure 유형은 MyError이며, 특정 오류 처리를 위해 .failure(.ohNo)를 사용할 수 있다. print("Finished with Oh No!") case .finished: print("Finished successfully!") } }, receiveValue: { value in print("Got value: \(value)") }) //publisher는 failure로 완료될 수 있으므로 더 이상 sink(receiveValue:)를 사용할 수 없다. .store(in: &subscriptions)
playground를 실행(run)하면, 다음과 같은 출력(output)이 표시된다.
——— Example of: setFailureType ———
Got value: Hello
Finished successfully!물론,
setFailureType
는 유형의 시스템(type-system) 정의(definition)일 뿐이다. 원래(original) publisher가Just
이므로, 실제로 어떤 오류(error)도 발생(thrown)하지 않는다.이 장(chapter)의 뒷부분에서 publishers에서 실제로 오류(errors)를 발생시키는(produce) 방법에 대해 자세히 알아본다. 그러나 먼저, 결코 실패하지 않는(never-failing) publishers에 특화된(specific) 연산자(operators)가 몇 개 더 있다.
assign(to:on:)
2장(chapter), "Publishers & Subscribers"에서 배운
assign
연산자(operator)는setFailureType
과 마찬가지로 실패할 수 없는(cannot fail) publishers에서만 작동한다. 생각해 보면, 이는 완전히 이치에 맞는다(it makes total sense). 제공된 키(key) 경로(path)에 오류(error)를 보내면(sending), 처리되지 않은(unhandled) 오류(error) 또는 정의되지 않은(undefined) 동작(behavior)이 발생한다.이를 확인해 보려면, 다음 예제(example)를 추가한다:
example(of: "assign") { class Person { //Person 클래스 정의 let id = UUID() var name = "Unknown" } let person = Person() //인스턴스 생성 print("1", person.name) //name 출력 Just("Shai") .handleEvents(receiveCompletion: { _ in print("2", person.name) }) //완료 이벤트를 내보낼 때, name을 다시 출력한다. .assign(to: \.name, on: person) //publisher가 내보낸 값을 해당 인스턴스(person)의 속성(name)에 할당한다. .store(in: &subscriptions) //저장 }
playground를 실행하고, 디버그(debug) 콘솔(console)을 확인한다:
——— Example of: assign ———
1 Unknown
2 Shai예상대로
Just
는 실패할 수 없기(cannot fail) 때문에Just
가 값(value)을 내보내(emits)자마자(as soon as),assign
은 name을 업데이트(updates)한다. 반대로(in contrast) publisher가 절대 실패하지 않는(non-Never
failure) 유형(type)이 아닌 경우에는 어떻게 되는지 살펴본다.Just("Shai")
바로 아래에 다음 행(line)을 추가한다:.setFailureType(to: Error.self)
이 코드에서 실패(failure) 유형(type)을 표준(standard) Swift 오류(error)로 설정한다. 즉,
Publisher<String, Never>
가 아니라 이제Publisher<String, Error>
가 된다.playground를 실행(run)한다. Combine은 당면한 오류(issue)에 대해 매우 장황(verbose)한 설명을 한다:
referencing instance method 'assign(to:on:)' on 'Publisher' requires the types 'Error' and 'Never' be equivalent
방금 추가한
setFailureType
호출(call)을 제거하고, playground가 컴파일(compilation) 오류(errors)없이 실행(runs)되는지 확인한다.오류 처리(dealing with)를 시작하기 전에 알아야 할 실패하지 않는(infallible) publishers와 관련된(related) 마지막 연산자(operator)는
assertNoFailure
이다.assertNoFailure
assertNoFailure
연산자(operator)는 개발(development) 중에 자신을 보호(protect)하고, publisher가 실패(failure) 이벤트(event)로 완료할 수 없음을 확인(confirm)하려는 경우에 유용(useful)하다. 이는 업 스트림(upstream)에서 실패(failure) 이벤트(event)가 발생하는 것을 방지(prevent)하지는 못한다. 그러나 오류(error)를 감지(detects)하면fatalError
로 충돌(crash)하므로, 개발(development) 중에 오류를 수정(fix)할 수 있는 좋은 동기(incentive)를 제공한다.playground에 다음 예제(example)를 추가한다:
example(of: "assertNoFailure") { Just("Hello") //Just로 오류 없는 publisher를 만든다. //Publisher<String, Never> .setFailureType(to: MyError.self) //failure 유형을 MyError으로 설정해 준다. //Publisher<String, Error> .assertNoFailure() //failure 이벤트로 완료되면, fatalError로 crash된다. //이를 사용하면, publisher의 failure 유형이 다시 Never로 변경된다. //Publisher<String, Never> .sink(receiveValue: { print("Got value: \($0) ") }) //수신되 값을 출력한다. //assertNoFailure를 사용하여 Publisher<String, Never>으로 다시 변경되었으므로 sink(receiveValue:)를 사용할 수 있다. .store(in: &subscriptions) //저장 }
playground를 실행(run)하면 예상대로(expected) 문제(issues) 없이 작동한다:
——— Example of: assertNoFailure ———
Got value: Hello이제,
setFailureType
뒤에 다음 행(line)을 추가한다:.tryMap { _ in throw MyError.ohNo }
Hello
가 다운 스트림(downstream)으로 전달(pushed)되면tryMap
을 사용하여 오류(error)를 발생(throw)시킨다. 이 장(chapter)의 뒷부분에서try
접두사(try-prefixed) 연산자(operators)에 대해 자세히 알아 본다.playground를 다시 실행(run)하고 콘솔(console)을 살펴본다. 다음과 유사한 출력(output)이 표시된다:
Playground execution failed:
error: Execution was interrupted, reason: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).
...
frame #0: 0x00007fff232fbbf2
Combine`Combine.Publishers.AssertNoFailure...publisher에서 실패(failure)가 발생(occurred)하여 playground가 충돌(crashes)한다. 어떻게 보면
assertFailure()
를 코드에 대한 보호(guarding) 메커니즘(mechanism)으로 생각할 수 있다. 출시 코드(production)에서 사용해야하는 것은 아니지만, 개발(development) 중에 "crash early and crash hard."하는 것이 매우 유용(useful)하다.다음 섹션(section)으로 이동하기 전에
tryMap
호출(call)을 주석 처리(comment out)한다.Dealing with failure
지금까지 오류 처리(error-handling) 장(chapter)에서 실패할 수 없는(can't fail) publishers를 다루는(deal with) 방법에 대해 배웠다. 조금 아이러니(ironic) 하긴 하지만, 실패하지 않는(infallible) publishers의 특성(traits)과 보증(guarantees)을 철저히(thoroughly) 이해하는 것이 얼마나 중요한지 알 수 있었을 것(appreciate)이다.
이를 염두에 두고, 실제로 실패(fail)하는 publishers를 처리하기 위해 Combine이 제공(provides)하는 몇 가지 기술(techniques)과 도구(tools)에 대해 배워볼 시간이다. 여기에는 기본 제공(built-in) publishers와 사용자 생성(own) publishers가 모두 포함된다.
그러나 먼저 실제로 실패(failure) 이벤트(events)를 생성(produce)하는 방법을 알아야 한다. 이전(previous) 섹션(section)에서 언급했듯이 이를 수행하는 방법은 여러 가지(several)가 있다. 방금
tryMap
을 사용했으므로, 이러한try
연산자(operators)의 작동 방식에 대해 자세히 알아본다.try* operators
섹션(Section) II, "Operators" 에서 Combine 연산자(operators)의 대부분과 이를 사용하여, publishers가 내보내는(emit) 값(values)과 이벤트(events)를 조작(manipulate)하는 방법에 대해 배웠다. 또한 원하는 출력(output)을 생성(produce)하기 위해 여러 publishers의 논리적(logical) 체인(chain)을 구성(compose)하는 방법을 배웠다.
해당 장(chapters)에서 대부분의 연산자(operators)에
try
접두사(prefixed)가 붙은 병렬(parallel) 연산자(operators)가 있으며, "이 책의 뒷부분에서 이에 대해 배울 것(learn about them later in this book)."이라 언급했다. 지금이 바로 그때이다(later is now).Combine은 오류(errors)를 발생(throw)시킬 수 있는 연산자(operators)와 그렇지 않을 수도 있는 연산자 사이에 흥미로운(interesting) 구별(distinction)을 제공한다.
Note: Combine의 모든
try
접두사(prefixed) 연산자(operators)는 오류(errors)가 발생할 때 동일한 방식으로 작동(behave)한다. 이 장(chapter) 전체에서는tryMap
연산자(operator)로만 확인(experiment)하게 될 것이다.먼저 프로젝트 탐색기(Project navigator)에서 try operator* playground 페이지(page)를 선택한다. 여기에 다음 코드를 추가한다:
example(of: "tryMap") { enum NameError: Error { //오류 유형을 정의한다. case tooShort(String) case unknown } let names = ["Scott", "Marin", "Shai", "Florent"].publisher //4개의 다른 문자열을 내보내는 publisher를 생성한다. names .map { value in //각 문자열의 길이를 매핑한다. return value.count } .sink(receiveCompletion: { print("Completed with \($0)") }, receiveValue: { print("Got value: \($0)") }) .store(in: &subscriptions) //저장 }
예제(example)를 실행하고 콘솔(console) 출력(output)을 확인한다:
——— Example of: tryMap ———
Got value: 5
Got value: 5
Got value: 4
Got value: 7
Completed with finished예상대로 모든 이름이 문제(issues)없이 매핑(mapped)된다. 그러나 여기서 5자 미만(shorter)의 이름을 사용하면 오류(error)가 발생(throw)하도록 하려 한다.
위의 예제에서
map
을 다음과 같이 바꾼다(replace):.map { value -> Int in let length = value.count guard length >= 5 else { throw NameError.tooShort(value) } return value.count }
위의
map
에서 문자열(string)의 길이(length)가5
보다 크거나(greater) 같은지(equal) 확인한다. 그렇지 않으면 적절한(appropriate) 오류(error)가 발생(throw)한다.그러나 위의 코드를 추가(add)하거나 실행(run)하려고 하면(as soon as), 컴파일러(compiler)에서 오류(error)가 발생(produces)하는 것을 볼 수 있다:
Invalid conversion from throwing function of type '(_) throws -> _' to non-throwing function type '(String) -> _'
map
은 오류를 발생시키지 않는(non-throwing) 메서드(method)이므로, 그 안에서 오류(errors)를 던질(throw) 수 없다. 다행히도try*
연산자(operators)는 그 목적(purpose)으로 만들어졌다.map
을tryMap
으로 교체(replace)하고 playground를 다시 실행(run)한다. 이제 컴파일되고, 다음과 같은 출력(일부 생략(truncated)함)이 표시된다:——— Example of: tryMap ———
Got value: 5
Got value: 5
Completed with failure(...NameError.tooShort("Shai"))Mapping errors
map
과tryMap
의 차이는 후자가 오류 발생(throwing errors)을 허용(allows)한다는 것 뿐이 아니다(beyond).map
은 기존 실패(failure) 유형(type)을 전달하고 publisher의 값(values)만 조작하지만tryMap
은 그렇지 않다. 실제 오류(error) 유형(type)을 일반(plain) SwiftError
로 지운다(erases). 이는 일반 연산자(counterparts)와 비교하여,try
접두사(prefixed)가 붙은 모든 연산자(operators)에 해당된다.Mapping errors playground 페이지(page)로 전환(switch)하고, 다음 코드를 추가한다 :
example(of: "map vs tryMap") { enum NameError: Error { //오류 유형 정의 case tooShort(String) case unknown } Just("Hello") //"Hello" 문자열을 내보내는 Just 생성 .setFailureType(to: NameError.self) //failure 유형 설정 .map { $0 + " World!" } //map을 사용해 문자열 추가 .sink(receiveCompletion: { completion in switch completion { //모든 failure 사례에서 적합한 메시지를 출력한다. case .finished: print("Done!") case .failure(.tooShort(let name)): print("\(name) is too short!") case .failure(.unknown): print("An unknown name error occurred") } }, receiveValue: { print("Got value \($0)") }) .store(in: &subscriptions) //저장 }
playground를 실행(run)하면 다음 출력(output)이 표시된다:
——— Example of: map vs tryMap ———
Got value Hello World!
Done!다음으로,
switch completion
행(line)을 찾고completion
을 Option-click 한다:Completion
의 실패(failure) 유형(type)은 정확히(exactly) 원하는 대로NameError
이다.setFailureType
연산자(operator)를 사용하면failure(.tooShort(let name))
과 같이NameError
실패(failures)를 구체적인(specifically) 대상(target)으로 지정할 수 있다.다음으로,
map
을tryMap
으로 변경한다. playground가 더 이상 컴파일(compiles)되지 않음을 즉시(immediately) 알 수 있다.completion
를 다시 Option-click 해 본다:흥미롭게도
tryMap
은 엄격하게 입력된(strictly-typed) 오류(error)를 지우고(erased), 일반적인Swift.Error
유형(type)으로 대체(replaced)한다.tryMap
내에서 실제로 오류(error)를 발생(throw)시키지 않고, 이를 단순히 사용했음에도 불구하고 이러한 현상이 발생한다.2015년부터 Swift Evolution에서 이 주제(topic)에 대한 논의(discussions)가 진행 되었음에도 불구하고 Swift는 아직 typed throws를 지원(support)하지 않는다는 것을 생각해 보면 그 이유는 매우 간단하다.
try
접두사(prefixed) 연산자(operators)를 사용할 경우, 오류(error) 유형(type)이 항상 가장 일반적인(common) 상위(ancestor) 항목인Swift.Error
로 지워진다(erased).publishers에 대한 엄격한 유형(strictly-typed)
Failure
의 요점(point)은, 이 예제(example)에서 다른 종류의 오류(error)가 아닌NameError
를 구체적으로 처리(deal with)할 수 있도록 하는 것이다.느슨한(naive) 접근 방식(approach)은 일반(generic) 오류(error)를 특정(specific) 오류(error) 유형(type)으로 수동(manually) 변환(cast)하는 것이지만, 이는 후순위로 고려해 봐야할(quite) 차선책(suboptimal)이다. 이는 엄격한 형식(strictly-typed) 오류(errors)의 전체 목적(purpose)을 위반(breaks)한다. 다행히 Combine은 이 문제(problem)에 대한 훌륭한 해결책(solution)으로
mapError
를 제공(provides)한다.tryMap
을 호출(call)한 직후에(Immediately), 다음 행(line)을 추가한다:.mapError { $0 as? NameError ?? .unknown } //tryMap을 사용하면 error를 발생시킬 수 있지만, error 유형이 기본 Error로 지워진다. //mapError을 사용해 다시 error 유형을 매핑해 줄 수 있다. //반드시 fallback error를 지정해 줘야 한다. //Publisher<String, NameError>로 된다.
mapError
는 업 스트림(upstream) publisher로부터 발생한 모든(any) 오류(error)를 수신(receives)하고, 원하는 오류(error)로 매핑(map)할 수 있다. 이를 활용하여 오류(error)를NameError
로 다시 변환(cast)하거나,NameError.unknown
오류(error)로 되돌릴(fall back) 수 있다. 이 경우에는 대체(fallback) 오류(error)를 제공(provide)해야 한다. 여기서는 그렇지 않더라도 변환(cast)이 이론적(theoretically)으로 실패(fail)할 수 있고, 이 연산자(operator)에서NameError
를 반환(return)해야 하기 때문이다.이렇게 하면
Failure
는 원래(original) 유형(type)으로 복원(restores)되고, publisher는 다시Publisher<String, NameError>
로 된다.playground를 빌드(build)하고 실행(run)한다. 마침내 예상대로 컴파일(compile)되고, 작동한다:
——— Example of: map vs tryMap ———
Got value Hello World!
Done!마지막으로
tryMap
에 대한 전체(entire) 호출(call)을 다음으로 바꾼다(replace):.tryMap { throw NameError.tooShort($0) }
이 호출(call)은
tryMap
내에서 즉시 오류(error)를 발생(throw)시킨다. 콘솔(console) 출력(output)을 다시 확인하고, 올바른 유형(properly-typed)의NameError
가 표시되는지 확인한다:——— Example of: map vs tryMap ———
Hello is too short!Designing your fallible APIs
자체 Combine 기반(based) 코드 및 API를 구성(constructing)할 때, 다양한(various) 유형(types)으로 실패(fail)하는 publishers를 반환(return)하는 다른 소스(sources)의 API를 자주 사용한다. 자체(your own) API를 만들 때, 일반적으로(usually) 해당 API에 대한 자체 오류(errors)를 제공(provide)하려 할 것이다. 이론으로(theorizing) 설명하는 것보다, 이를 직접 확인(experiment)해 보는 것이 더 쉬으므로 예제(example)로 확인해 본다.
이 섹션(section)에서는 다소 진부한(somewhat-funny) 아재 개그(dad jokes)를 가져올(fetch) 수 있는 https://icanhazdadjoke.com/api의 icanhazdadjoke API를 사용해 빠르게 API를 구축(build)한다.
Designing your fallible APIs playground 페이지(page)로 전환(switching)하여 다음 코드를 추가한다. 이 코드는 다음 예제(example)의 첫 번째 부분(portion)을 구성하게 된다:
example(of: "Joke API") { class DadJokes { struct Joke: Codable { //Joke 구조체 let id: String let joke: String } //API response는 Joke 인스턴스로 디코딩된다. func getJoke(id: String) -> AnyPublisher<Joke, Error> { //반환하는 Publisher의 타입은 <Joke, Error> let url = URL(string: "https://icanhazdadjoke.com/j/\(id)")! var request = URLRequest(url: url) request.allHTTPHeaderFields = ["Accept": "application/json"] return URLSession.shared .dataTaskPublisher(for: request) //API 호출 .map(\.data) //매핑 .decode(type: Joke.self, decoder: JSONDecoder()) //디코딩 .eraseToAnyPublisher() } } }
마지막으로 새 API를 실제로 사용한다. 예제(example)의 범위(scope)에서
DadJokes
클래스(class) 바로 아래에 다음을 추가한다:let api = DadJokes() //DaddJokes 인스턴스 생성 let jokeID = "9prWnjyImyd" //유효한 농담 id let badJokeID = "123456" //잘못된 농담 id api .getJoke(id: jokeID) //유효한 농담 id로 DadJokes.getJoke(id:)를 호출한다. .sink(receiveCompletion: { print($0) }, receiveValue: { print("Got joke: \($0)") }) .store(in: &subscriptions) //저장
playground를 실행(run)하고 콘솔(console)을 살펴본다:
——— Example of: Joke API ———
Got joke: Joke(id: "9prWnjyImyd", joke: "Why do bears have hairy coats? Fur protection.")
finishedAPI는 현재 경로(path)를 완벽하게(perfectly) 처리(deals with)하지만, 여기는 오류 처리(error-handling) 장(chapter)이다. 다른 publishers를 래핑(wrapping)할 때, "이 특정(specific) publisher에서 어떤 종류의 오류(errors)가 발생할 수 있는가?" 라고 자문(ask yourself) 해보아야 한다.
이 경우에서는:
- 잘못된(bad) 연결(connection) 또는 잘못된(invalid) 요청(request)과 같은 다양한(various) 이유(reasons)로
dataTaskPublisher
호출(calling)이URLError
와 함께 실패(fail)할 수 있다. - 제공된(provided) 농담(joke) ID가 존재(exist)하지 않을 수 있다.
- API 응답(response)이 변경(changes)되거나 구조(structure)가 잘못된(incorrect) 경우 JSON 응답(response) 디코딩(decoding)이 실패(fail)할 수 있다.
- 기타 알 수 없는(unknown) 오류(error). 오류(errors)는 많고(plenty) 무작위(random)이기 때문에, 모든 경우(case)를 생각하는 것은 불가능(impossible)하다. 이러한 이유로, 항상 알 수 없거나(unknown) 처리되지 않은(unhandled) 오류(error)를 다루는 경우(case)가 있어야 한다.
이 목록(list)을 염두에 두고,
DadJokes
클래스(class) 안의Joke
구조체(struct) 바로 아래에 다음 코드를 추가한다:enum Error: Swift.Error, CustomStringConvertible { //DadJokes에서 발생할 수 있는 모든 가능한 오류 case network case jokeDoesntExist(id: String) case parsing case unknown var description: String { //CustomStringConvertible를 구현한다. //각 오류 case에 대한 상세한 설명을 제공한다. switch self { case .network: return "Request to API Server failed" case .parsing: return "Failed parsing response from server" case .jokeDoesntExist(let id): return "Joke with ID \(id) doesn't exist" case .unknown: return "An unknown error occurred" } } }
위의 오류(Error) 유형(type)을 추가하면 playground가 더 이상(anymore) 컴파일(compile)되지 않는다. 이는
getJoke(id:)
가AnyPublisher<Joke, Error>
를 반환(returns)하기 때문이다. 이전에Error
는Swift.Error
를 가리켰(referred)지만, 지금은DadJokes.Error
를 가리키고 있다(refers). 실제로 원하는 것이다.decode
호출(calls)과eraseToAnyPublisher()
사이에 다음을getJoke(id:)
에 추가한다:.mapError { error -> DadJokes.Error in switch error { case is URLError: return .network case is DecodingError: return .parsing default: return .unknown } }
이 간단한
mapError
는switch
문(statement)을 사용하여 publisher가 던질(throw) 수 있는 모든 종류의 오류(error)를DadJokes.Error
로 대체(replace)한다. 왜 이러한 오류를 래핑(wrap)해야 하는지 자문(ask yourself)할 수 있다. 이에 대한 답(answer)은 두 가지이다:- publisher는 이제
DadJokes.Error
로만 실패(fail)하도록 보장(guaranteed)된다. 이는 API를 사용(consuming)하고 가능한(possible) 오류(errors)를 처리(dealing with)할 때 유용하다. 유형(type) 시스템(system)에서 무엇을 얻을 수 있는지 정확히 알고 있다. - API의 구현(implementation)의 세부 사항(details)을 유출(leak)하지 않는다. API 소비자(consumer)는
URLSession
을 사용하여 네트워크(network) 요청(request)을 수행하고,JSONDecoder
를 사용하여 응답(response)을 디코딩(decode)하는 절차를 전혀 신경 쓰지 않는다. 소비자(consumer)는 내부(internal) 종속성(dependencies)이 아니라 API 자체(itself)가 오류(errors)로 정의한(defines) 사항에만 관심을 가진다.
아직 처리하지 않은 오류(error)가 하나 더 있는데, 존재하지 않는(non-existent) 농담(joke) ID이다. 아래 행(line)을
.getJoke(id: jokeID)
다음으로 바꾼다(replacing):
.getJoke(id: badJokeID)
playground를 다시 실행(run)한다. 이번에는 다음과 같은 오류(error)가 발생한다:
failure(Failed parsing response from server)
흥미롭게도 icanhazdadjoke의 API는 대부분의 API에서 예상되는(expected) 것처럼 존재하지 않는(non-existent) ID를 보낼 때 HTTP 코드(code) 404(Not Found)로 실패(fail)하지 않는다. 대신, 다음과 같이 다르지만 유효한(valid) JSON 응답(response)을 다시 보낸다:
{
message = "Joke with id \"123456\" not found";
status = 404;
}이 경우(case)를 처리(dealing with)하려면, 약간의 기술(hackery)이 필요하지만 확실히 처리할(handle) 수 없는 것은 아니다.
getJoke(id:)
로 돌아가map(\.data)
호출(call)을 다음 코드로 바꾼다(replace):.tryMap { data, _ -> Data in //추가 유효성 검사를 시행한다. guard let obj = try? JSONSerialization.jsonObject(with: data), let dict = obj as? [String: Any], dict["status"] as? Int == 404 else { //JSONSerialization을 사용하여, status 필드가 존재하는지 확인하고 이 값이 404인지 확인한다. return data //해당 농담이 존재한다면(404가 아닌 경우), 단순히 decode 연산자에 다운 스트림하도록 반환하기만 하면 된다. } throw DadJokes.Error.jokeDoesntExist(id: id) //404 상태 코드가 있다면, .jokeDesntExist(id:) 오류를 발생시킨다. }
위의 코드에서는 원시(raw) 데이터(data)를
decode
연산자(operator)에 전달하기 전에tryMap
을 사용하여 추가(extra) 유효성(validation) 검사를 수행(perform)한다.playground를 다시 실행(run)하면, 해결해야하는 또 다른 사소한(nitpick) 문제를 보게 될 것이다:
——— Example of: Joke API ———
failure(An unknown error occurred)이 실패(failure)는
mapError
내에서 해당 유형을 처리(deal with)하지 않았기 때문에DadJokes.Error
가 아닌 알 수 없는(unknown) 오류(error)로 취급(treated)된다.mapError
내부에서 아래 행(line)을 찾아:return .unknown
다음으로 변경한다:
return error as? DadJokes.Error ?? .unknown
다른 오류(error) 유형(types)이 일치하지 않으면, 알 수 없는(unknown) 오류(error)로 되돌리기(falling back) 전에
DadJokes.Error
로 변환(cast)하려고 시도한다.playground를 다시 실행하고 콘솔(console)을 살펴본다:
——— Example of: Joke API ———
failure(Joke with ID 123456 doesn't exist)이번에는 올바른(correct) 유형(type)의 올바른(correct) 오류(error)를 수신(receive)한다.
이 예제(example)를 마무리(wrap up)하기 전에,
getJoke(id:)
에서 할 수 있는 마지막 최적화(optimization)가 하나 더 있다.알다시피 농담(joke) ID는 문자(letters)와 숫자(numbers)로 구성(consist)된다. "잘못된 ID(Bad ID)"의 경우에는 숫자만 전송(sent)했다. 네트워크(network) 요청(request)을 수행(performing)하는 대신, 리소스(resources)를 낭비(wasting)하지 않고 ID를 사전에(preemptively) 검증(validate)하고 실패(fail)할 수 있다.
getJoke(id:)
의 시작 부분에 다음 최종 코드를 추가한다:guard id.rangeOfCharacter(from: .letters) != nil else { //모든 joke id는 숫자와 문자로 되어 있다. //따라서 최소한 한 개 이상의 문자가 포함되어야 한다. return Fail<Joke, Error>(error: .jokeDoesntExist(id: id)) .eraseToAnyPublisher() }
이 코드에서는
id
에 문자(letter)가 하나 이상 포함(contains)되어 있는지 확인(making sure)한다. 그렇지 않으면, 즉시(immediately)Fail
을 반환(return)한다.Fail
은 제공된(provided) 오류(error)로 즉시(immediately), 그리고 반드시(imperatively) 실패(fail)하게 하는 특별한(special) 종류의 publisher이다. 특정 조건(condition)에 따라 조기에(early) 실패(fail)하려는 경우(cases)에 적합하다.eraseToAnyPublisher
를 사용하여 예상되는(expected)AnyPublisher<Joke, DadJokes.Error>
유형(type)을 가져 온다.잘못된(invalid) ID로 예제(example)를 다시 실행(run)하면 동일한 오류(error) 메시지(message)가 표시된다. 그러나 이번에는 즉시(immediately) 게시(post)되며, 네트워크(network)를 사용할 필요가 없다. 훌륭하다(Great success).
계속 진행하기 전에,
getJoke(id:)
에서badJokeId
대신jokeID
를 호출(call)하도록 되돌려(revert) 놓는다.이 시점에서 코드를 수동(manually)으로 "변경(breaking)"하여 오류(error) 논리(logic)의 유효성(validate)을 검사할 수 있다. 다음 조치를 각각 수행(performing)한 후, 이후 작업을 시도(try)할 수 있도록 변경 사항(changes)을 취소(undo)한다.
- 위의 URL을 만들 때, 그 안에 임의의(random) 문자(letter)를 추가하여 URL을 변경한다(break). playground를 실행(run)하면
failure(Request to API Server failed)
가 표시된다. request.allHttpHeaderFields
로 시작하는 줄을 주석 처리(comment out)하고 playground를 실행한다. 서버 응답(response)은 더 이상 JSON이 아니라 일반(plain) 텍스트(text)이므로failure(Failed parsing response from server)
출력(output)이 표시된다.- 이전과 마찬가지로
getJoke(id:)
에 임의(random)의 ID를 보낸다. playground를 실행(run)하면failure(Joke with ID {your ID} doesn't exist)
가 표시된다.
자체(own) 오류(errors)가 있는 Combine 기반(based)의 프로덕션 클래스(production-class) API 계층(layer)을 구축(built)했다.
Catching and retrying
Combine 코드의 오류(error) 처리(handling)에 대해 많은 것을 배웠고, 마지막 두 가지 주제(topics)인 오류(errors) 포착(catching) 및 실패한(failed) publishers 재시도(retrying)가 남았다.
작업을 표현(represent)하는 통합(unified)된 방법으로
Publisher
를 사용하는 가장 큰 장점은, 매우 적은 코드만으로 엄청난(incredible) 양(amount)의 작업을 수행할 수 있는 많은 연산자(operators)가 있다는 것이다.계속해서 예제(example)를 살펴본다.
프로젝트 탐색기(Project navigator)에서 Catching and retrying 페이지(page)로 전환(switching)한다. playground의 Sources 폴더(folder)에서 PhotoService.swift를 연다.
여기에는 이 섹션(section)에서 사용할
fetchPhoto(quality:failingTimes:)
메서드(method)가 있는PhotoService
가 포함(includes)되어 있다.PhotoService
는 사용자 정의(custom) publisher를 사용하여 고화질(high) 또는 저화질(low)로 사진을 가져온다(fetches). 이 예제에서는 고화질(high-quality) 이미지 요청(asking)은 항상 실패(fail)하므로, 실패가 발생(occur)할 때 재시도(retry)하고 포착(catch)하기 위한 다양한(various) 기법(techniques)을 확인(experiment)해 볼 수 있다.Catching and retrying playground 페이지(page)로 돌아가, playground에 아래의 베어 본(bare-bones) 예제(example)를 추가한다:
let photoService = PhotoService() example(of: "Catching and retrying") { photoService .fetchPhoto(quality: .low) //항상 성공 .sink(receiveCompletion: { print("\($0)") }, receiveValue: { image in print("Got image: \(image)") } ) .store(in: &subscriptions) //저장 }
이제 위와 같은 코드는 익숙할 것이다.
PhotoService
를 인스턴스화(instantiate)하고,.low
화질(quality)로fetchPhoto
를 호출(call)한다. 그런 다음sink
를 사용하여 완료(completion) 이벤트(event) 또는 가져온(fetched) 이미지(image)를 출력한다.photoService
의 인스턴스화(instantiation)는 예제(example)의 범위(scope)를 벗어나있으므로, 즉시 할당 해제(deallocated)되지 않는다.playground를 실행(run)하고 완료(finish)될 때까지 기다린다. 다음과 같은 출력이 표시되어야 한다:
——— Example of: Catching and retrying ———
Got image: <UIImage:0x600000790750 named(lq.jpg) {300, 300}>
finishedreceiveValue
의 첫 번째 행(line) 옆에 있는 Show Result 버튼(button)을 누르면, 해당 저화질(low-quality) 사진을 볼 수 있다.다음으로 화질(quality)을
.low
에서.high
로 변경(change)하고 playground를 다시 실행(run)한다. 다음과 같은 출력이 표시된다:——— Example of: Catching and retrying ———
failure(Failed fetching image with high quality)앞서 언급(mentioned)했듯이 고화질(high-quality) 이미지 요청(asking)은 실패(fail)한다. 이곳이 출발점(starting point)이다. 여기에 몇 가지 개선(improve) 사항이 있다. 실패(failure)시 재시도(retrying)하는 것부터 시작한다.
리소스(resource)를 요청(request)하거나 일부 계산(computation)을 수행(perform)할 때, 네트워크(network) 연결(connection)이 잘못되거나 사용할 수 없는(unavailable) 다른 리소스(resource)로 인한 단발성(one-off) 오류가 발생(occurrence)하는 경우가 많다.
이러한 경우, 시도(attempts) 횟수를 추적(tracking)하고 모든 시도(attempts)가 실패(fail)할 경우 수행할 작업을 결정(deciding)하면서 다른 작업을 재시도(retry)하는 big ol' 메커니즘(mechanism)을 일반적으로 작성한다. 다행히 Combine은 이것을 훨씬 더 간단하게 만든다.
Combine의 다른 장점들과 마찬가지로 이를 위한 연산자(operator)가 있다.
retry
연산자(operator)는 숫자를 지정(accepts)한다. publisher가 실패(fails)하면 업 스트림(upstream)을 다시 구독(resubscribe)하고, 지정한(specify) 횟수까지 다시 시도(retry)한다. 모든 재시도(retries)가 실패(fail)하면,retry
연산자(operator)가 없는 것처럼 오류(error)를 다운 스트림(downstream)으로 내보낸다(pushes).fetchPhoto(quality: .high) 아래에 다음을 추가한다:
.retry(3)
이게 전부이다. 이 간단한(simple) retry 연산자(operator)를 호출(calling)하는 것으로 publisher에 래핑된 모든 작업(every piece of work wrapped in a publisher)에 대해 재시도(retry) 메커니즘(mechanism)이 제공된다.
playground를 실행(running)하기 전에,
fetchPhoto
호출(calls)과retry
사이에 다음 코드를 추가한다:.handleEvents(receiveSubscription: { _ in print("Trying ...") }, receiveCompletion: { guard case .failure(let error) = $0 else { return } print("Got error: \(error)") })
이 코드는
fetchPhoto
에서 발생하는 구독(subscriptions) 및 실패(failures)를 출력하여, 재시도(retries)가 발생(occur)하는 시기를 확인하는 데 도움이 된다.playground를 실행(run)하고 완료(complete)될 때까지 기다린다(wait for). 다음과 같은 출력(output)이 표시된다:
——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
failure(Failed fetching image with high quality)보다시피 초기(initial) 시도(attempt)와
retry
연산자(operator)로 실행(triggered)된 세 번의 재시도(retries), 총 4번의 시도(attempts)가 있다. 고화질(high-quality) 사진 가져 오기(fetching)가 계속(constantly) 실패(fails)하기 때문에, 연산자(operator)는 모든 재시도(retry) 횟수(attempts)를 소진(exhausts)하고sink
로 오류(error)를 보낸다(pushes).fetchPhoto
에 대한 아래 호출(call)을:.fetchPhoto(quality: .high) //항상 실패
다음으로 변경한다:
.fetchPhoto(quality: .high, failingTimes: 2) //2번 실패 후 성공
faliingTimes
매개 변수(parameter)는 고품질(high-quality) 이미지 가져 오기(fetching)가 실패(fail)하는 횟수를 제한(limit)한다. 이 경우는 처음 두 번 호출(call)은 실패(fail)하고, 그 다음은 성공(succeed)한다.playground를 다시 실행(run)하고 출력(output)을 살펴본다:
——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got image: <UIImage:0x600001268360 named(hq.jpg) {1835, 2446}>
finished보다시피 이번에는 초기(initial) 시도와 두 번의 재시도(retries), 총 세 번의 시도(attempts)가 있다. 이 메서드(method)는 처음 두 번의 시도(attempts)에서 실패(fails)한 다음, 이후에 성공(succeeds)하여 고품질 사진을 반환(returns)한다:
이 서비스 호출(call)에서 개선(improve)해야 할 마지막 기능(feature)이 하나 남아있다. 고품질 이미지(high- quality) 가져 오기(fetching)에 실패(fails)하면, 저품질 이미지(low-quality)를 가져온다(fall back). 저품질 이미지(low-quality) 가져 오기(fetching)도 실패(fails)하면, 하드 코딩된(hard-coded) 이미지로 대체(fall back)한다.
두 작업 중 후자(latter)부터 시작한다. Combine에는 오류(error)가 발생(occurs)하면 publisher 유형(type)의 기본값(default value)으로 대체(fall back)할 수 있는
replaceError(with:)
라는 편리한 연산자(operator)가 포함되어 있다. 또한 가능한(possible) 모든 실패(failure)를 대체 값(fallback value)으로 바꾸므로(replace) publisher의Failure
유형(type)이Never
로 변경된다.먼저
fetchPhoto
에서failingTimes
인수(argument)를 제거(remove)하여, 이전과 마찬가지로 계속(constantly) 실패(fails)하도록 한다.그런 다음 retry 호출(retry) 직후에 다음 행(line)을 추가한다.
.replaceError(with: UIImage(named: "na.jpg")!) //오류 시 기본값 설정
playground를 다시 실행(run)하고, 이번에는 이미지 결과를 살펴본다. 초기(initial) 시도와 3번의 재시도(retries), 총 4번의 시도(attempts) 후에 디스크의 하드 코딩된(hard-coded) 이미지로 돌아간다.
또한 콘솔(console) 출력(output)을 보면, 4번의 실패(failed)한 시도(attempts) 후에 하드 코딩된(hard-coded) 대체(fallback) 이미지가 이어진 것을 확인(reveals)할 수 있다:
——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Got image: <UIImage:0x6000020e9200 named(na.jpg) {200, 200}>
finished이제 이 장(chapter)의 마지막 부분(part)인 고품질(high-quality) 이미지가 실패(fails)하면, 저품질(low-quality) 이미지로 대체(fall back)한다. Combine은 이 작업을 위한 완벽한(perfect) 연산자(operator)인
catch
를 제공(provides)한다. publisher의 실패(failure)를 포착(catch)하고, 다른 publisher로 복구(recover)할 수 있다.이를 확인하려면,
retry
와replaceError(with:)
사이에 다음 코드를 추가한다:.catch { error -> PhotoService.Publisher in //오류 발생 시 수행할 작업 print("Failed fetching high quality, falling back to low quality") return photoService.fetchPhoto(quality: .low) //저품질 이미지를 가져온다. }
마지막으로 playground를 실행(run)하고, 콘솔(console)을 살펴본다:
——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Failed fetching high quality, falling back to low quality
Got image: <UIImage:0x60000205c480 named(lq.jpg) {300, 300}>
finished이전과 마찬가지로, 고품질 이미지(high-quality) 가져 오기(fetch)의 초기(initial) 시도(attempt)와 세 번의 재시도(retries)가 실패(fail)한다. 연산자(operator)가 모든 재시도(retries) 횟수를 소진(exhausted)하면,
catch
가 역할(role)을 수행하여photoService.fetchPhoto
를 구독(subscribes)하고 저품질(low-quality) 이미지를 요청(requesting)한다. 이는 실패한(failed) 고품질(high-quality) 요청(request)에서 성공적인(successful) 저품질(low-quality) 요청(request)으로 대체(fallback)된다.Key points
Failure
유형(type)이Never
인 Publishers는 실패(failure) 완료(completion) 이벤트(event)를 내보내지(emit) 않도록 보장(guaranteed)된다.- 많은 연산자(operators)는 실패할 수 없는(infallible) publishers에서만 사용할 수 있다. 예를 들면
sink(receiveValue:)
,setFailureType
,assertNoFailure
,assign(to:on:)
등이 있다. try
접두사(prefixed)가 붙은 연산자(operators)를 사용하면, 그 안에서 오류(errors)를 발생(throw)시킬 수 있지만try
가 붙지 않은(non-try) 연산자(operators)는 그렇지 않다.- Swift는 typed throws를 지원하지 않기 때문에,
try
접두사(prefixed)가 붙은 연산자(operators)를 호출(calling)하면 publisher의Failure
유형이 지워져(erases) 일반 SwiftError
가 된다. mapError
를 사용하여 publisher의Failure
유형(type)을 매핑(map)하고, publisher의 모든 실패(failure) 유형(types)을 단일(single) 유형(type)으로 통합(unify)한다.- 자체(own)
Failure
유형(types)이 있는 다른 publishers를 기반(based)으로 고유한 API를 만들 때, 가능한(possible) 모든 오류(errors)를 고유한(own)Error
유형(type)으로 래핑(wrap)하여 이를 통합(unify)하고 API의 세부(details) 구현(implementation) 정보를 숨긴다. retry
연산자(operator)를 사용하여, 실패한(failed) publisher를 추가로(additional) 재구독(resubscribe)할 수 있다.replaceError(with:)
는 실패(failure)시 publisher에 대한 기본(default) 대체 값(fallback value)을 제공(provide)하려는 경우 유용하다.- 마지막으로(finally),
catch
를 사용하여 실패한(failed) publisher를 다른 대체(fallback) publisher로 바꿀(replace) 수 있다.
'Raywenderlich > Combine: Asynchronous Programming' 카테고리의 다른 글
Chapter 18: Custom Publishers & Handling Backpressure (0) 2020.09.30 Chapter 17: Schedulers (0) 2020.08.31 Chapter 15: In Practice: SwiftUI & Combine (0) 2020.08.17 Chapter 14: In Practice: Project "News" (0) 2020.08.16 Chapter 13: Resource Management (0) 2020.08.15 - 잘못된(bad) 연결(connection) 또는 잘못된(invalid) 요청(request)과 같은 다양한(various) 이유(reasons)로