ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Chapter 14: In Practice: Project "News"
    Raywenderlich/Combine: Asynchronous Programming 2020. 8. 16. 05:47

    Version

    Swift 5.3, iOS 14, Xcode 12

     

    지난 몇 장(chapters)에서 Foundation 유형(types)의 Combine 통합(integration)에 대한 몇 가지 실용적인(practical) 적용 방법(applications)에 대해 배웠다. URLSession의 data task publisher를 사용하여, 네트워크(network) 호출(calls)을 수행하는 방법을 배웠고 Combine 등으로 KVO를 구현한(compatible) 객체(objects)를 관찰(observe)하는 방법을 살펴 보았다.

    이 장(chapter)에서는 운영자(operators)에 대한 탄탄한(solid) 지식을 사용해 Foundation 통합(integrations)을 이전 "In Practice" 장(chapter)에서와 같이 일련(series)의 작업으로 처리한다. 이번에는 Hacker News API 클라이언트(client)를 구축(building)하는 작업을 수행한다.

    이 장(chapter)에서 API로 사용하는 "Hacker News"는 컴퓨터와 기업가 정신(entrepreneurship)을 집중 조명(focused)하는 소셜 뉴스 웹 사이트(social news website)이다. https://news.ycombinator.com에서 확인할 수 있다.

     

    이 장(chapter)에서는 API 클라이언트(client) 자체에만 초점을 맞춘 Xcode playground에서 작업한다.

    15장(chapter), "In Practice: Combine & SwiftUI"에서 완성된 API를 사용하여, 네트워크(network) 계층(layer)을 SwiftUI 기반의 사용자 인터페이스(interface)에 연결하여 실제 Hacker News reader 앱(app)을 구축(build)한다. 그 과정에서 SwiftUI의 기본 사항과 놀라운 반응 형(reactive) 앱 UI를 구축하기 위한 새로운 선언적(declarative) Apple 프레임 워크(framework)를 사용하여 Combine 코드를 작동시키는 방법을 배우게 된다.

     

    Getting started with the Hacker News API

    projects/starter에 포함된 API.playground를 열고 살펴본다. Combine 코드에만 집중할 수 있도록 몇 가지 간단한 시작(starter) 코드가 포함되어 있다:

     

    API 유형(type) 내에는 두 개의 중첩된(nested) 도우미(helper) 유형(types)이 있다:

    • 서버에 연결(reach)할 수 없거나 서버 응답(response)을 디코딩(decode)할 수 없는 경우, API에서 발생하는 두 가지 사용자 정의(custom) 오류(errors)를 나타내는 Error 열거형(enum).
    • 유형(type)에 따라 연결(connecting)될 두 API 엔드 포인트(endpoints)의 URL을 포함하는 두 번째 열거형(enum) EndPoint.

    더 아래로 내려가면 maxStories 속성(property)을 찾을 수 있다. 이는 API 클라이언트(client)가 가져올(fetch) 최신 stories 수를 제한(limit)하여 Hacker News 서버의 부하(load)를 줄이고(reduce), JSON 데이터를 디코딩(decode)하는 데 사용할 decoder를 제한할 수 있다.

    또한, playground의 Sources 폴더(folder)에는 story 데이터를 디코딩(decode)할 Story라는 간단한 구조체(struct)가 포함되어 있다.

    Hacker News API는 무료로 사용할 수 있으며(free to use), 개발자 계정 등록(registration)이 필요하지 않다. 다른 공용(public) API처럼 긴(lengthy) 등록(registration)과정을 먼저 완료(complete)해야할 필요 없이, 바로 코드 작업을 시작할 수 있기 때문에 매우 좋다.

     

    Getting a single story

    첫 번째 작업은 EndPoint 유형(type)을 사용해 서버에 연결(contact)하고 올바른 엔드 포인트(endpoint) URL을 얻어 단일(single) story에 대한 데이터를 가져오는(fetch) 메서드(method)를 API에 추가하는 것이다. 이 새로운 메서드(method)는 API 소비자(consumers)가 구독(subscribe)할 publisher를 반환(return)하고, 유효하고 구문 분석(parsed)된 Story나 실패(failure) 중 하나를 받게 된다.

    playground 소스(source) 코드를 아래로 스크롤하여(scroll down) // Add your API code here. 이라는 주석(comment)을 찾는다. 해당 행(line) 바로 아래에 새 메서드(method) 선언(declaration)을 삽입(insert)한다:

    func story(id: Int) -> AnyPublisher<Story, Error> {
        return Empty().eraseToAnyPublisher()
        //Empty를 사용하면 즉시 완료된다.
    }

    playground에서 컴파일(compilation) 오류(errors)를 방지하려면, 즉시(immediately) 완료(completes)되는 Empty publisher를 반환(return)한다. 메서드(method) 본문 작성(building)을 마치면(finish), 표현식(expression)을 제거(remove)하고 대신 새 구독(subscription)을 반환(return)한다.

    앞서 언급했듯이, 이 publisher의 출력(output)은 Story이고 오류(failure)는 사용자 정의(custom) API.Error 유형(type)이다. 나중에 보게 되겠지만, 네트워크(network) 오류(errors) 또는 기타 다른 오류(mishaps)가 발생할 경우 예상되는 반환(return) 유형(type)과 일치하도록 이를 API.Error 사례(cases) 중 하나로 변환(convert)해야 한다.

    Hacker News API의 단일 story 엔드 포인트(endpoint)에 대한 네트워크(network) 요청(request)을 생성하여 구독(subscription) 모델링(modeling)을 시작한다. 새 메서드(method)의 return 문(statement) 위에 다음을 삽입(insert)한다:

    URLSession.shared
        .dataTaskPublisher(for: EndPoint.story(id).url)

    Endpoint.story(id).url에 요청(request)을 생성하는 것으로 시작한다. 엔드 포인트(endpoint)의 url 속성(property)은 요청(request)할 전체 HTTP URL을 포함한다. 단일 story의 URL은 다음과 같다(일치하는 ID 포함) : https://hacker-news.firebaseio.com/v0/item/12345.json (API 응답(response)을 미리 보려(preview)면 https://bit.ly/2nL2ojS 를 참조한다.)

    다음으로 백그라운드(background) 스레드(thread)에서 JSON 구문 분석(parse)을 하여 나머지 앱(app)의 응답성(responsive)을 유지(keep)하기 위해 새로운 사용자 정의(custom) dispatch queue을 만든다. 다음과 같이 story(id:) 메서드(method) 위에 API의 새로운 속성(property)을 추가한다:

    private let apiQueue = DispatchQueue(label: "API", qos: .default, attributes: .concurrent)
    //background queue

    이 대기열(queue)을 사용하여 JSON 응답(responses)을 처리하므로, 네트워크(network) 구독(subscription)을 해당 대기열(queue)로 전환(switch)해야 한다. story(id:)로 돌아가서 dataTaskPublisher(for:)를 호출(calling)하는 아래(below)에 다음을 추가한다.

    .receive(on: apiQueue) //대기열 전환

    백그라운드(background) 대기열(queue)로 전환(switched)한 후, 응답(response)에서 JSON 데이터를 가져와야(fetch) 한다. dataTaskPublisher(for:)의 publisher는 (Data, URLResponse) 유형(type)의 출력(output)을 튜플(tuple)로 반환(returns)하지만, 구독(subscription)의 경우에는 데이터만 있으면 된다.

    메서드에 다음 행(line)을 추가하여, 현재 출력(output)을 결과 튜플(tuple)의 데이터에만 매핑(map)한다:

    .map(\.data) //매핑

    이 연산자(operator)의 출력(output) 유형(type)은 Data이며, decode 연산자(operator)에 전달(feed)해 응답(response)을 Story로 변환(converting)할 수 있다.

    구독(subscription)에 추가한다:

    .decode(type: Story.self, decoder: decoder) //해당 데이터를 디코딩한다.

    유효한 story JSON 이외의 것이 반환(returned)된 경우, decode(...)는 오류(error)를 발생(throw)시키고 publisher는 실패(failure)로 완료(complete)된다.

    16장(chapter) "Error Handling"에서 오류(error) 처리(handling)에 대해 자세히 배우게 된다. 이 장(chapter)에서는 많지 않은 연산자(operators)를 사용하고 오류(errors)를 처리(handle)하는 몇 가지 다른 방법(taste)을 경험하지만, 작동 방식의 핵심(nitty-gritty)에 대해서는 자세히 설명하지 않을 것이다.

    현재 story(id:) 메서드(method)는 어떤 이유로 오류가 발생할 경우를 대비해 빈(empty) publisher를 반환(return)한다. catch 연산자(operator)를 사용하면 이를 쉽게 수행(achieved)할 수 있다. 구독(subscription)에 다음을 추가한다:

    .catch { _ in Empty<Story, Error>() } //오류 처리

    던져진(thrown) 오류(error)를 무시(ignore)하고 Empty()를 반환(return)한다. 기억하고 있는 것처럼, 이는 출력(output) 값(values)을 내 보내지(emitting) 않고 즉시(immediately) 완료(completes)되는 publisher이다:

     

    이러한 방식으로 catch(_)를 사용해 업 스트림(upstream) 오류(errors)를 처리(handling)하면, 다음과 같은 작업을 수행할 수 있다:

    • Story를 받으면, 값(value)을 내보내고(emit) 완료(complete)한다.
    • 실패(failure)시 값(values)을 내보내지(emitting) 않고, 성공적으로 완료되는 Empty publisher를 반환(return)한다.

    다음으로 메서드(method) 코드를 마무리(wrap up)하고 깔끔하게(neatly) 설계된(designed) publisher를 반환(return)하려면, 마지막에 현재 구독(subscription)을 교체(replace)해야 한다. 다음을 추가한다:

    .eraseToAnyPublisher()

    이제 이전에 추가한 임시(temporary) Empty를 제거(remove)할 수 있다. 다음 줄(line)을 찾아 제거(remove)한다:

    return Empty().eraseToAnyPublisher() //제거

    이제 코드가 문제(issues)없이 컴파일(compile)되어야 한다. 이 모든 과정에서 빠진(missed) 단계가 없는지 확인하기 위해 지금까지의 진행 상황(progress)을 검토(review)하고, 완성된 코드가 다음과 같은지 확인한다:

    func story(id: Int) -> AnyPublisher<Story, Error> {
        URLSession.shared
            .dataTaskPublisher(for: EndPoint.story(id).url)
            .receive(on: apiQueue) //큐 전환
            .map(\.data) //dataTaskPublisher는 (Data, URLResponse)를 반환한다. data만 필요하므로 매핑한다.
            .decode(type: Story.self, decoder: decoder) //디코딩
            .catch { _ in Empty<Story, Error>() } //오류 처리
            .eraseToAnyPublisher()
    }

    코드가 컴파일(compiles)되더라도 이 메서드(method)는 아직 출력(output)을 생성(produce)하지 않는다. 다음으로 이것을 처리해야 한다(take care of that next).

    이제 API를 인스턴스화(instantiate)하고, Hacker News 서버를 호출(calling)해 본다.

    아래로 스크롤(scroll down)하여, // Call the API here. 주석(comment)을 찾는다. 테스트 API 호출(call) 하기에 좋은 지점이다. 다음 코드를 삽입(Insert)한다:

    let api = API()
    var subscriptions = [AnyCancellable]()

    다음을 추가하여 테스트할 임의(random)의 ID를 제공하여(providing) story를 가져온다(fetch):

    api.story(id: 1000)
        .sink(receiveCompletion: { print($0) },
              receiveValue: { print($0) })
        .store(in: &subscriptions) //저장

    api.story(id : 1000)를 호출(calling)하여 새 publisher를 만들고, 출력(output) 값(values) 또는 완료(completion) 이벤트(event)를 출력하는 sink(...)를 사용해 구독(subscribe)한다. 작업이 완료(done)될 때까지 구독(subscription)을 유지하려면 subscriptions에 저장(store)한다.

    playground가 다시 실행(runs)되는 즉시, hacker-news.firebaseio.com에 네트워크(network) 호출(call)을 하고 콘솔(console)에 결과를 출력한다:

     

    서버에서 반환(returned)된 JSON 데이터는 다음과 같이 다소 간단한 구조(structure)이다:

    {
        "by":"python_kiss",
        "descendants":0,
        "id":1000,
        "score":4, "
        time":1172394646,
        "title":"How Important is the .com TLD?",
        "type":"story",
        "url":"http://www.netbusinessblog.com/2007/02/19/how- important-is-the-dot-com/"
    }

    StoryCodable를 준수하므로, by, id, time, title, url 속성(properties)의 값(values)을 구문 분석(parses)하고 저장(stores)한다.

    요청(request)이 성공적으로(successfully) 완료(completes)되면, 다음 출력(output)(혹은 요청(request)에서 1000값(value)을 변경한 경우에는 유사한 출력)이 콘솔(console)에 표시된다:

    How Important is the .com TLD?
    by python_kiss
    http://www.netbusinessblog.com/2007/02/19/how-important-is-the- dot-com/
    -----
    finished

    Story 유형(type)은 CustomDebugStringConvertible을 준수(conforms)하며, 위와 같이 깔끔하게(neatly) 정렬된(ordered) title, author name, story URL을 반환(returns)하는 사용자 정의(custom) debugDescription이 있다.

    출력(output)은 완료(completion) 이벤트(event)로 finished된다. 오류(error) 발생시 어떤 일이 발생하는지 확인하려면, id 1000-5로 바꾸고(replace) 콘솔(console)의 출력을 확인한다. 오류(error)를 발견(caught)하고, Empty()를 반환(returned)하기 때문에 finished만 표시된다.

    API 유형(type)의 첫 번째 메서드(method)가 완성되었고, 네트워크(network) 호출(calling) 및 JSON 디코딩(decoding)과 같이 이전(previous) 장(chapters)에서 다룬 일부 개념(concepts)을 연습했다. 또한 기본(basic) dispatch queue 전환(switching)과 간단한 오류(error) 처리(handling)에 대한 간략한 구현도 했다. 이에 대해서는 향후(future) 장(chapters)에서 더 자세히 다룰(covered) 것이다.

    다음 섹션(section)에서는 더 깊이 파고들어(dig deeper), 더 상세한(serious) 코드를 작성한다.

     

    Multiple stories via merging publishers

    API 서버에서 단일 story를 가져오는(getting) 것은 비교적(relatively) 간단한 작업이었다. 다음으로, 동시에 여러(multiple) stories를 가져 오는 사용자 정의(custom) publisher를 만들면서 지금까지 학습했던 몇 가지 개념(concepts)을 더 다룰 것이다.

    새로운 메소드(method) mergedStories(ids:)는 주어진(given) story ids 각각에 대한 story publisher를 가져와 이를 모두 병합(merge)한다. API 유형(type)에서 이전에(earlier) 구현한(implemented) story(id:) 메서드(method) 뒤에, 이 새 메서드(method) 선언(declaration)을 추가한다:

    func mergedStories(ids storyIDs: [Int]) -> AnyPublisher<Story, Error> {
            
    }

    이 메서드(method)가 기본적으로(essentially) 수행하는 작업은 주어진(given) 각 ids에 대해 story(id:)를 호출(call)한 다음, 결과를 단일(single) 출력(output) 값(values) 스트림(stream)으로 평탄화(flatten)하는 것이다.

    우선 네트워크(network) 호출(calls)을 줄이려면(reduce), 제공된(provided) 목록(list)에서 maxStories 만큼만 나눠 가져온다(fetch). 다음 코드를 삽입(inserting)하여 새 메서드(method)를 시작한다:

    let storyIDs = Array(storyIDs.prefix(maxStories)) //maxStories 만큼만 id를 가져온다.

    시작하려면 첫 번째 publisher를 만든다:

    precondition(!storyIDs.isEmpty) //해당 조건을 만족하지 않으면 오류를 발생 시킨다.
            
    let initialPublisher = story(id: storyIDs[0])
    //첫 번째 id로 해당 story 를 가져와 publisher를 만든다.
    let remainder = Array(storyIDs.dropFirst())
    //첫 번째 story를 제외하고 남은 story

    story(id:)를 사용하여 목록(list)의 첫 번째 id로 story를 가져오는(fetches) initialPublisher 를 만든다.

    다음으로 나머지 story ID에 Swift 표준(standard) 라이브러리(library)의 reduce(_:_:)를 사용하여, 다음과 같이 각 다음 story publisher를 initial publisher에 병합(merge)한다:

     

    나머지 stories를 initial publisher로 reduce하려면 다음을 추가한다:

    return remainder.reduce(initialPublisher) { combined, id in
                
    }

    reduce(_:_:)는 initial publisher에서 시작하여, remainder 배열(array)의 각 ids를 처리(process)할 클로저(closure)에 제공(provide)한다. 이 코드를 삽입하여 빈(empty) 클로저(closure)에 지정된 story id에 대한 새 publisher를 만들고, 현재 결합된(combined) 결과에 병합(merge)한다:

    return combined
        .merge(with: story(id: id))
        .eraseToAnyPublisher()

    최종 결과는 성공적으로(successfully) 가져온(fetched) 각 story를 내보내고(emits), 각 단일(single) story publisher가 발생할(encounter) 수 있는 오류(errors)를 무시(ignores)하는 publisher이다.

    Note: 방금 MergeMany publisher의 사용자 정의(custom) 구현(implementation)을 만들었다(이와 똑같은 작업을 하는 MergeMany 연산자가 있다). 하지만, 코드를 직접 작성하는 것은 의미없는(vain) 일은 아니다. 실제 사용 사례(real-world use case)에서 연산자(operator) 구성(composition)과 mergereduce와 같은 연산자(operators)를 적용하는 방법에 대해 배웠다.

    새 API 메서드(method)가 완료(completed)되면, 아래로 스크롤(scroll down)하여 이전에 구현한 아래의 코드에 주석(comment)을 달거나 삭제(delete)하여 최신 코드를 테스트(testing)하는 동안 playground의 실행(execution) 속도를 높일 수 있다(speed up):

    api.story(id: -5)
        .sink(receiveCompletion: { print($0) },
            receiveValue: { print($0) })
        .store(in: &subscriptions)

    방금 삭제한(deleted) 코드 대신 다음을 삽입(insert)한다:

    api.mergedStories(ids: [1000, 1001, 1002])
        .sink(receiveCompletion: { print($0) },
              receiveValue: { print($0) })
        .store(in: &subscriptions) //저장

    최신 코드로 playground를 다시 실행(run)한다. 이번에는 콘솔(console)에 다음 세 가지 story의 요약(summaries)이 표시된다:

    How Important is the .com TLD?
    by python_kiss
    http://www.netbusinessblog.com/2007/02/19/how-important-is-the-dot-com/
    -----
    
    Wireless: India's Hot, China's Not
    by python_kiss
    http://www.redherring.com/Article.aspx?a=21355
    -----
    
    The Battle for Mobile Search
    by python_kiss
    http://www.businessweek.com/technology/content/feb2007/tc20070220_828216.htm?campaign_id=rss_daily
    -----
    finished

    이 섹션(section)에서는 여러 publishers를 결합(combines)하여 단일(single) publisher로 축소(reduces)하는 메서드(method)를 작성했다. 내장된(built-in) merge 연산자(operator)는 최대 8 개의 publishers만 병합(merge)할 수 있기 때문에 이 코드는 매우 유용하다. 그러나 때로는 사전에(advance) 얼마나 많은 publishers가 필요한지 모르는 경우도 있을 것이다.

     

    Getting the latest stories

    이 장(chapter)의 섹션(section)에서는 최신 Hacker News 기사 목록(list)을 가져 오는 API 메서드(method)를 만든다.

    이 장(chapter)은 어떤 패턴(pattern)에 따라 진행되고 있다. 먼저 단일(single) story를 가져오는(fetch) 메서드(method)을 재사용(reused)하여 여러(multiple) stories를 가져왔다. 이제 여러 stories를 가져오는(fetch) 메서드(method)를 재사용(reused)하여 최신(latest) 스토stories리 목록(list)을 가져 온다.

    다음과 같이 새로운 메서드(method) 선언(declaration)을 API 유형(type)에 추가한다:

    func stories() -> AnyPublisher<[Story], Error> {
        return Empty().eraseToAnyPublisher()
    }

    이전(before)과 마찬가지로, 메서드(method) 본문(body)과 publisher를 구성(construct)하는 동안 발생하는 컴파일(compilation) 오류(errors)를 방지(prevent)하기 위해 Empty 객체(object)를 반환(return)한다.

    하지만 이전(before)과 달리, 이번에는 반환된(returned) publisher의 출력(output)이 stories 목록(list)이다. 서버에서 응답(responses)이 돌아올 때 각 중간(intermediary) 상태(state)를 내보내(emitting)도록, publisher는 여러(multiple) stories를 가져와서(fetch) 배열(array)에 축적(accumulate)하도록 설계(design)한다.

    이 동작(behavior)은 다음(next) 장(chapter)에서 이 새로운 publisher를 서버에서 들어오는 stories를 화면(on-screen)에 자동으로(automatically) 애니메이션(animate)하는 List UI 컨트롤(control)에 직접(directly) 바인딩(bind)할 수 있도록 해 준다.

    이전(previously)과 같이 Hacker News API에 대한 네트워크(network) 요청(request)을 실행하여 시작(iring off)한다. return 문(statement) 위에 다음을 새 메서드(method)에 삽입(insert)한다:

    URLSession.shared
        .dataTaskPublisher(for: EndPoint.stories.url)

    stories의 엔드 포인트(endpoint)를 사용해 https://hacker-news.firebaseio.com/v0/newstories.json URL을 방문하여 최신(latest) story ids를 가져올(get) 수 있다.

    다시 내보낸(emitted) 결과의 데이터 구성 요소(component)를 가져와야(grab) 한다. 다음을 추가하여 출력(output)을 매핑(map)한다:

    .map(\.data) //dataTaskPublisher는 (Data, URLResponse)를 반환한다. data만 필요하므로 매핑한다.

    서버에서 받을 JSON 응답(response)은 다음과 같은 일반(plain) 목록(list)이다:

    [1000, 1001, 1002, 1003]

    목록(list)을 정수 배열(array of integer)로 구문 분석(parse)해야 하며, 성공(succeeds)하면 ids를 사용하여 일치하는(matching) stories를 가져올(fetch) 수 있다.

    구독(subscription)에 다음을 추가(append)한다:

    .decode(type: [Int].self, decoder: decoder)

    이는 현재 구독(subscription) 출력(output)을 [Int]에 매핑(map)하고, 서버에서 해당(corresponding) stories를 하나씩(one-by-one) 가져오는 데 사용한다.

    그러나 지금은 잠시 오류(error) 처리(handling)로 돌아가야 한다. 단일(single) story를 가져올(fetching) 때, 간단하게 모든 오류(errors)를 무시(ignore)하도록 구현했다. 하지만 stories()에서는 그보다 더 많은 것을 구현한다.

    API.Errorstories()에서 발생한(thrown) 오류(errors)를 제한(constrain)하는 오류(error) 유형(type)입니다. 열거형(enumeration)으로 정의(defined)된 두 가지 오류(errors)가 있다:

    • invalidResponse : 서버 응답(response)을 예상된(expected) 유형(type)으로 디코딩(decode)할 수 없는 경우.
    • addressUnreachable(URL) : 엔드 포인트(endpoint) URL에 연결(reach)할 수 없는 경우.

    현재 stories()의 구독(subscription) 코드에서 두 가지 유형(types)의 오류(errors)가 발생(throw)할 수 있다:

    • dataTaskPublisher(for:)는 네트워크(network) 문제(problem)가 발생(occurs)할 때, 다양한(variations) URLError를 발생시킬(throw) 수 있다.
    • decode(type:decoder:)는 JSON이 예상(expected) 유형(type)과 일치(match)하지 않을 때, 디코딩(decoding) 오류(error)를 발생시킬(throw) 수 있다.

    다음 작업은 반환된(returned) publisher가 실패(failure)할 때, 단일(single) API.Error 유형(type)에 매핑(map)하는 방식으로 다양한(various) 오류(errors)를 처리(handle)하는 것이다.

    다음 번에 오류(error) 처리(handling) 연산자(operator)에 대한 자세한 학습을 할 것이다. decode 후 현재 구독(subscription)에 다음 코드를 추가한다:

    .mapError { error -> API.Error in
        switch error {
        case is URLError:
            return Error.addressUnreachable(EndPoint.stories.url)
        default:
            return Error.invalidResponse
        }
    }

    mapError는 업 스트림(upstream)에서 발생하는(occurring) 모든 오류(errors)를 처리(handles)하고, 이를 단일 오류(error) 유형(type)으로 매핑(map)할 수 있도록 한다. 이는 map을 사용하여 출력(output) 유형(type)을 변경(change)하는 것과 유사하다.

    위의 코드에서 오류(errors)를 분류(switch)하고 다음을 수행한다:

    • errorURLError 유형(type)인 경우, stories 서버의 엔드 포인트(endpoint)에 연결(reach)하는 동안 발생한 오류로, .addressUnreachable(_)을 반환(return)한다.
    • 그렇지 않으면, 오류(error)가 발생(occur)하는 유일한 다른 유형인 .invalidResponse를 반환(return)한다. 성공적으로 값을 가져오면(fetched) 네트워크(network) 응답(response)은 JSON 데이터를 디코딩(decoding)한다.

    이를 사용해, stories()에서 예상되는(expected) 실패(failure) 유형(type)을 일치시키고(matched), API 소비자(consumers)에게 다운 스트림(downstream) 오류(errors)를 처리(handle)하도록 맡길(leave) 수 있다. 다음 장(chapter)에서도 stories()를 사용할 것이다. 따라서 16장(chapter), "Error Handling" 전에 오류(error) 처리(handling)에 대해 좀 더 많은 작업을 수행할 것이다.

    지금까지 현재 구독(subscription)은 JSON API에서 ids 목록(list)을 가져 오지만(fetches), 그 외에 많은 작업을 수행하지는 않는다. 다음으로 몇 가지 연산자(operators)를 사용하여 원하지 않는 내용(content)을 필터링(filter)하고, id 목록(list)을 실제 stories에 매핑(map)한다.

    먼저 빈(empty) 결과를 필터링(filter)한다. API가 실패(bonkers)하여, 최신(latest) stories에 빈(empty) 목록(list)을 반환(returns)하는 경우를 대비한다. 다음을 추가(append)한다:

    .filter { !$0.isEmpty }

    이렇게 하면 다운 스트림(downstream) 연산자(operators)가 적어도 하나 이상의 요소(element)가 있는 스토리 ids 목록(list)을 제공받도록(receive) 보장(guarantee)할 수 있다. 기억 하겠지만 mergedStories(ids:)에는 입력 매개 변수(parameter)가 비어 있지 않은지 확인(ensuring)하는 precondition이 있기 때문에 매우 편리(handy)하다.

    mergedStories(ids:)를 사용하여 story의 세부 정보(details)를 가져 오려면 flatMap 연산자(operator)를 추가하여, 모든 story publishers를 평탄화(flatten)한다:

    .flatMap { storyIDs in
        return self.mergedStories(ids: storyIDs)
    }

    모든 publishers를 단일 다운 스트림(downstream)으로 병합(merging)하면, Story 값의 연속(continuous) 스트림(stream)이 생성된다. 이는 네트워크(network)에서 가져오는(fetched) 즉시(as soon as) 방출(emitted)된다:

     

    현재 구독(subscription)을 그대로 둘 수 있지만, 목록(list) UI 컨트롤(control)에 쉽게 바인딩할(bindable) 수 있도록 API를 설계(design)한다. 이를 사용해 소비자(consumers)는 단순히 stories()를 구독(subscribe)하고, 그 결과를 view controller 또는 SwiftUI view의 [Story] 속성(property)에 할당할 수 있다.

    이를 위해, 방출된(emitted) stories를 집계(aggregate)하고 구독(subscription)을 단일(single) Story 값(values) 대신 계속 축적(ever-growing)되는 배열(array)을 반환(return)하도록 매핑(map)해야 한다.

    여기에 3장(chapter), "Transforming Operators"의 scan 연산자(operator)를 사용한다. 오래 전(time ago)에 학습한 내용이지만, 현재 작업을 처리(achieve)에 도움이 되는 연산자(operator)이다. 필요한 경우 해당 장(chapter)으로 돌아가서 scan을 복습하고 돌아온다.

    현재 구독(subscription)에 다음을 추가(append)한다:

    .scan([]) { stories, story -> [Story] in
        return stories + [story] //값을 누적시킨다
    }

    scan(...)이 빈(empty) 배열(array)로 방출(emitting)을 시작하도록 한다. 새 story가 방출(emitted)될 때마다, stories + [story] 하여 현재 집계된(aggregated) 결과에 추가(append)한다.

    이렇게 구독(subscription) 코드에 추가(addition)하면, 작업중인 배치(batch)에서 새 story를 가져올(fetched) 때마다 버퍼링(buffered)된 내용(contents)을 얻을 수 있도록 동작(behavior)이 변경된다:

     

    마지막으로, 출력(output)을 내보내기(emitting) 전에 stories를 정렬(sort)하는 것이 좋다. StoryComparable을 준수(conforms)하므로, 사용자 정의(custom) 정렬(sorting)을 구현(implement)할 필요가 없다. 결과에 대해 단순히 sorted()를 호출하면 된다. 다음을 추가(append)한다:

    .map { $0.sorted() }

    반환된(returned) publisher의 유형(type)을 지우는(erasing) 것으로 다소 길었던 현재 구독(subscription)을 마무리(wrap up)한다. 마지막으로 연산자(operator)를 추가(append)한다:

    .eraseToAnyPublisher()

    이 시점에서 다음 임시(temporary) return 문(statement)을 찾아 제거(remove)할 수 있다:

    return Empty().eraseToAnyPublisher()

    이제 playground가 오류(errors)없이 컴파일(compile)된다. 그러나 여전히 이전(previous) 장(chapter) 섹션(section)의 테스트 데이터를 보여준다. 다음을 찾아서 주석 처리(comment out)한다:

    api.mergedStories(ids: [1000, 1001, 1002])
        .sink(receiveCompletion: { print($0) },
              receiveValue: { print($0) })
        .store(in: &subscriptions)

    그 자리에 다음을 삽입(insert)한다:

    api.stories()
        .sink(receiveCompletion: { print($0) },
              receiveValue: { print($0) })
        .store(in: &subscriptions)

    이 코드는 api.stories()를 구독(subscribes)하고 반환된(returned) 출력(output) 및 완료(completion) 이벤트(events)를 출력한다.

    playground를 다시 실행(run)하면, 콘솔(console)에서 최신 Hacker News 기사 더미(dump)를 볼 수 있다. 목록(list)은 반복적(iteratively)으로 쌓이게(dumped) 된다. 처음에는 자체적으로 먼저 가져온(fetched) story를 볼 수 있다:

    [
    More than 70% of America’s packaged food supply is ultra- processed
    by xbeta
    https://news.northwestern.edu/stories/2019/07/us-packaged-food-supply-is-ultra-processed/
    -----]

    그런 다음, 두 번째 story를 동반(accompanied)한다:

    [
    More than 70% of America’s packaged food supply is ultra- processed
    by xbeta
    https://news.northwestern.edu/stories/2019/07/us-packaged-food-supply-is-ultra-processed/
    -----,
    New AI project expects to map all the word’s reefs by end of next year
    by Biba89  
    https://www.independent.co.uk/news/science/coral-bleaching-ai-reef-paul-allen-climate-a9022876.html
    -----]

    그런 다음, 동일한 stories와 세 번째 story가 목록(list)에 추가된다:

    [
    More than 70% of America’s packaged food supply is ultra- processed
    by xbeta 
    https://news.northwestern.edu/stories/2019/07/us-packaged-food-supply-is-ultra-processed/
    -----,
    New AI project expects to map all the word’s reefs by end of next year
    by Biba89  
    https://www.independent.co.uk/news/science/coral-bleaching-ai-reef-paul-allen-climate-a9022876.html
    -----,  
    People forged judges’ signatures to trick Google into changing results
    by lnguyen
    https://arstechnica.com/tech-policy/2019/07/people-forged-judges-signatures-to-trick-google-into-changing-results/ -----]

    Hacker News 웹 사이트(website)에서 stories를 실시간으로 가져오므로(fetching), 몇 분마다 더 많은 stories가 추가되어 콘솔(console)에 표시되는 내용이 달라질 수 있다(will be different).

    실제로 라이브(live) 데이터를 가져오고(fetching) 있는지 확인하려면, 몇 분 정도 기다렸다가 playground를 다시 실행(re-run)한다. 이미 확인한 stories와 함께 새로운 stories가 나타나는 것을 볼 수 있다.

    이 장(chapter)의 다소 긴 섹션(section)을 ​​마무리했다. Hacker News API 클라이언트(client) 개발을 완료(completed)했으며 다음 장(chapter)으로 넘어갈 준비가 되었다. 다음 장(chapter)에서는 SwiftUI를 사용하여 적절한(proper) Hacker News 리더(reader) 앱을 구축(build)한다.

     

    Challenges

    API 클라이언트(client)에 추가(add)할 사항은 없지만, 이 장(chapter)의 프로젝트(project)에서 작업을 더 해볼 수 있다.

     

    Challenge 1: Integrating the API client with UIKit

    이미 언급했듯이 다음 장(chapter)에서는 SwiftUI와 이를 Combine 코드와 통합(integrate)하는 방법을 배운다.

    이 과제(challenge)에서는 완성된(completed) API 클라이언트(client)를 사용하여 최신(latest) stories를 table view에 표시하는 iOS 앱을 구축(build)해 본다. 원하는 만큼 세부 사항(details)을 개발(develop)하고 스타일링(styling) 또는 재미있는 기능(features)을 추가할 수 있지만, 이 과제(challenge)에서 가장 중요한 것은 API.stories()를 구독하고 결과를 table view에 바인딩(binding)하는 것이다. 8장(chapter), "In Practice: Project ‘Collage’"에서 작업한 바인딩(bindings)과 매우 유사하다.

    과제(challenge)를 성공적으로(successfully) 완료 한 경우, 시뮬레이터(simulator) 또는 기기(device)에서 앱을 실행(launch)할 때 최신 stories가 "pour in" 하는 것을 볼 수 있다:

     

    Key points

    • Foundation에는 Swift 표준(standard) 라이브러리(library)에 대응(counterpart)하는 메서드(methods)를 가진 여러 publishers가 포함되어 있으며, 이 장(chapter)의 reduce에서 사용했던 것처럼 서로 바꿔서(interchangeably) 사용할 수도 있다.
    • Decodable과 같은 많은 기존 API도 Combine 지원(support)을 통합(integrated)했다. 이는 모든 코드에서 하나의 표준(standard) 접근 방식(approach)을 사용할 수 있게 해 준다.
    • Combine 이전의 API와 비교할 때, Combine 연산자(operators)의 체인(chain)을 구성(composing)해서 능률적(streamlined)이고 따라하기 쉬운 방식(easy-to-follow way)으로 상당히(fairly) 복잡한(complex) 작업(operations)을 수행(perform)할 수 있다.
Designed by Tistory.