[Swift/Combine] 비동기 API 연쇄 호출 (feat. JSON)

이정훈·2023년 3월 17일
2

Combine Framework

목록 보기
4/4
post-thumbnail

개요

기본적으로 Swift에서 URLSession을 이용한 네트워크 통신은 비동기적으로 수행된다.

이러한 비동기 작업을 처리하기 위한 도구로 delegates, completion handler, RxSwift 등 여러 기능이 존재하는데 Swift에서 제공하는 순정 Combine framework도 비동기 작업을 처리하기 위한 수단 중 하나이다.

이번 포스트에서는 Combine framework를 활용하여 비동기 작업인 API 호출을 통해 JSON 데이터를 가져오는 방법에 대하여 알아보려고 한다.

API

API 호출을 테스트 해보기 위해 사용할 데이터는 국토교통부에서 제공하는 버스정류소 정보 open API와 국토교통부에서 제공하는 정류소 별 버스 도착정보 open API를 사용할 것이다.

API에 대한 정보는 공공데이터 포털에서 확인

버스 정류소는 판교역 현대백화점 앞에 있는 버스 정류소를 기준으로 데이터를 가져올 예정이다.

전체적인 흐름은 다음과 같다.

버스 정류소 API를 호출하여 버스 정류소의 nodeid 정보를 가져오고 그 데이터를 다시 버스 도착 정보 API에 전달하여 두개의 API를 연쇄적으로 호출하는 것이다.

parsing을 위한 구조체 정의

먼저 JSON 데이터를 parsing하기 위한 구조체를 정의할 것이다.

공공데이터 포털에서 샘플 데이터를 사용하여 호출된 API의 JSON 데이터를 미리보기가 가능하다.

판교역.낙생육교.현대백화점 버스 정류소에 대한 JSON 데이터의 결과 값은 다음과 같다.

{"response":{"header":{"resultCode":"00","resultMsg":"NORMAL SERVICE."},"body":{"items":{"item":{"gpslati":37.3914833,"gpslong":127.1117,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","nodeno":7492}},"numOfRows":10,"pageNo":1,"totalCount":1}}}

그리고 해당 버스 정류소의 nodeid를 버스 도착 정보 API의 parameter로 전달하여 도착 예정인 버스 목록 JSON 데이터의 호출 결과는 다음과 같다.

{"response":{"header":{"resultCode":"00","resultMsg":"NORMAL SERVICE."},"body":{"items":{"item":[{"arrprevstationcnt":23,"arrtime":1918,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000025","routeno":521,"routetp":"일반버스","vehicletp":"일반차량"},{"arrprevstationcnt":9,"arrtime":973,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000025","routeno":521,"routetp":"일반버스","vehicletp":"일반차량"},{"arrprevstationcnt":7,"arrtime":1052,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000057","routeno":3330,"routetp":"직행좌석버스","vehicletp":"일반차량"},{"arrprevstationcnt":3,"arrtime":500,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000057","routeno":3330,"routetp":"직행좌석버스","vehicletp":"일반차량"},{"arrprevstationcnt":43,"arrtime":3386,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000060","routeno":103,"routetp":"일반버스","vehicletp":"일반차량"},{"arrprevstationcnt":17,"arrtime":1352,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000060","routeno":103,"routetp":"일반버스","vehicletp":"일반차량"},{"arrprevstationcnt":6,"arrtime":537,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000067","routeno":341,"routetp":"일반버스","vehicletp":"일반차량"},{"arrprevstationcnt":2,"arrtime":209,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000081","routeno":1151,"routetp":"직행좌석버스","vehicletp":"일반차량"},{"arrprevstationcnt":7,"arrtime":739,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000081","routeno":1151,"routetp":"직행좌석버스","vehicletp":"일반차량"},{"arrprevstationcnt":11,"arrtime":1254,"nodeid":"GGB206000535","nodenm":"판교역.낙생육교.현대백화점","routeid":"GGB204000082","routeno":"G8110","routetp":"직행좌석버스","vehicletp":"일반차량"}]},"numOfRows":10,"pageNo":1,"totalCount":32}}}

물론 이 데이터를 보고 직접 parsing을 위한 구조체를 정의할 수 있지만 위와 같이 데이터의 수가 많은 경우 매우 복잡하기 때문에 해당 데이터와 일치하는 구조체를 자동으로 생성해주는 Quick_Type이라는 사이트가 있다.

이 사이트에서 위의 JSON 데이터를 넣어주면 아래와 같이 parsing을 위한 구조체가 자동으로 생성된다.

여기서 버스 정류소 JSON 데이터를 parsing할 구조체는 BusStop으로 도착 예정인 버스 목록 정보 JSON 데이터를 parsing할 구조체는 BusList로 정의 하였다.

BusStop.swift

//BusStop.swift
import Foundation

// MARK: - BusStop
struct BusStop: Codable {
    let response: Response
}

// MARK: - Response
struct Response: Codable {
    let header: Header
    let body: Body
}

// MARK: - Body
struct Body: Codable {
    let items: Items
    let numOfRows, pageNo, totalCount: Int
}

// MARK: - Items
struct Items: Codable {
    let item: Item
}

// MARK: - Item
struct Item: Codable {
    let gpslati, gpslong: Double
    let nodeid, nodenm: String
    let nodeno: Int
}

// MARK: - Header
struct Header: Codable {
    let resultCode, resultMsg: String
}

BusList.swift

//BusList.swift
import Foundation

// MARK: - BusList
struct BusList: Codable {
    let response: BusList_Response
}

// MARK: - Response
struct BusList_Response: Codable {
    let header: BusList_Header
    let body: BusList_Body
}

// MARK: - Body
struct BusList_Body: Codable {
    let items: BusList_Items
    let numOfRows, pageNo, totalCount: Int
}

// MARK: - Items
struct BusList_Items: Codable {
    let item: [BusList_Item]
}

// MARK: - Item
struct BusList_Item: Codable {
    let arrprevstationcnt, arrtime: Int
    let nodeid: String
    let nodenm: Nodenm
    let routeid: String
    let routeno: Routeno
    let routetp: Routetp
    let vehicletp: Vehicletp
}

enum Nodeid: String, Codable {
    case ggb206000535 = "GGB206000535"
}

enum Nodenm: String, Codable {
    case 판교역낙생육교현대백화점 = "판교역.낙생육교.현대백화점"
}

enum Routeno: Codable {
    case integer(Int)
    case string(String)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let x = try? container.decode(Int.self) {
            self = .integer(x)
            return
        }
        if let x = try? container.decode(String.self) {
            self = .string(x)
            return
        }
        throw DecodingError.typeMismatch(Routeno.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for Routeno"))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .integer(let x):
            try container.encode(x)
        case .string(let x):
            try container.encode(x)
        }
    }
}

enum Routetp: String, Codable {
    case 일반버스 = "일반버스"
    case 직행좌석버스 = "직행좌석버스"
}

enum Vehicletp: String, Codable {
    case 일반차량 = "일반차량"
    case 저상버스 = "저상버스"
}

// MARK: - Header
struct BusList_Header: Codable {
    let resultCode, resultMsg: String
}

API 호출

이제 API 호출 함수를 구현할 APIService 구조체를 생성하고 버스 정류소 정보 API 호출을 위한 fetchBusStop method와 도착 예정 버스 정보 API 호출을 위한 fetchBusStop 함수를 구현하였다.

API 주소는 공공데이터 포털 API 사용설명서 참고

import Foundation
import Combine

let key = //공공데이터 포털에서 발급한 개인 key

struct APIService {
    //버스 정류장 API 호출
    static func fetchBusStop() -> AnyPublisher<BusStop, Error> {
        let apiUrl: String = "http://apis.data.go.kr/1613000/BusSttnInfoInqireService/getSttnNoList?serviceKey=\(key)&cityCode=31020&nodeNm=판교역.낙생육교.현대백화점&nodeNo=7489&numOfRows=10&pageNo=1&_type=json"
        let encodedURL = apiUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)    //한글 인코딩
        
        return URLSession.shared.dataTaskPublisher(for: URL(string: encodedURL!)!)
            .map { $0.data }
            .decode(type: BusStop.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    
    //버스 도착 예정 목록 API 호출
    static func fetchBusList(nodeId: String) -> AnyPublisher<BusList, Error> {
        let apiUrl: String = "http://apis.data.go.kr/1613000/ArvlInfoInqireService/getSttnAcctoArvlPrearngeInfoList?serviceKey=\(key)&cityCode=31020&nodeId=\(nodeId)&numOfRows=10&pageNo=1&_type=json"
        
        let encodedURL = apiUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
        return URLSession.shared.dataTaskPublisher(for: URL(string: encodedURL)!)
            .map { $0.data }
            .decode(type: BusList.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

두 함수의 API 호출은 API에 전달해주는 parameter만 다를 뿐 로직은 완전히 동일 하다고 봐도 무방하다.

참고로 해당 API의 url에는 한글 문자가 포함 되어 있으므로 호출 전 반드시 유니코드 인코딩 과정을 거친다.
참고

URLSession 클래스의 shared 프로퍼티로 부터 하나의 싱글톤 객체를 반환 받고 dataTaskPublisher method를 통해 publisher를 반환한다.

dataTaskPublisher

Apple 문서에는 다음과 같이 소개 되어 있다.

dataTaskPublisher(for:)

Returns a publisher that wraps a URL session data task for a given URL request.

이 method는 for parameter로 요청할 url를 전달하면 해당 task에 대한 publisher를 반환한다.

따라서 publisher는 task 성공시 데이터를 실패시 errorsubscriber에게 전달한다.

전달할 데이터는 map method로 가져오며 가져온 데이터는 JSONDecoder를 사용하여 디코딩한다.

마지막으로 eraseToAnyPublisher method를 사용하여 반환 타입까지 AnyPublisher로 바꿔 주는 것도 잊지 않는다.

API 연쇄 호출

API 연쇄 호출은 위의 두 함수를 연쇄적으로 호출하면 되는데, 연쇄 호출을 연결하기 위한 flatMap method를 사용한다.

fetchBusStop method에서 가져온 nodeid
fetchBusList method의 parameter로 바로 전달하기 위함이다.

flatMap에 대한 내용은 여기 참고

//APIService.swift
import Foundation
import Combine

struct APIService {
    ...
    
    //두 API 연쇄 호출
    static func fetchBusListAfterBusStop() -> AnyPublisher<BusList, Error> {
        return fetchBusStop().flatMap { (busStop: BusStop) in
            fetchBusList(nodeId: busStop.response.body.items.item.nodeid)
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

Publisher subscribe

지금까지 API를 호출하여 JSON 데이터를 가져오는 pulisher를 만들었고 이 publisher가 publish한 값을 전달 받을 Subscriber를 구현할 순서이다.

Publisher에 대한 subscribeViewModel 구조체를 정의하고 구조체 내부에 구현한다.

//ViewModel.swift
import Foundation
import Combine

class ViewModel: ObservableObject {
    var cancellables = Set<AnyCancellable>()
    
    func printBusList() {
        APIService.fetchBusListAfterBusStop()
            .sink { compeletion in
                switch compeletion {
                case .failure(let error):
                    print(error)
                case .finished:
                    print("finished")
                }
            } receiveValue: {
                print($0.response.body.items.item)
            }
            .store(in: &subscribtions)
    }
}

해당 클래스는 @StateObject 속성으로 객체를 생성하기 위해 ObservableObject protocol을 준수하도록 한다.

cancellables 변수는 해당 클래스의 인스턴스가 메모리에서 해제 되면 Publishersubscribe도 함께 해제하기 위한
즉, 다시 말해 subscribe에 대한 메모리 관리를 위한 집합으로 Set<AnyCancellable> 타입으로 정의한다.

printBusList() method가 실질적으로 subscribe가 일어나는 method로 APIServicefetchBusListAfterBusStop() method에 의해 반환된 Publishersink method로 해당 Publishersubscribe 하도록 한다.

subscribe의 결과로 receiveValue parameter에 Publisher로 부터 전달 받은 값을 출력할 수 있도록 하는 클로저를 전달한다.

마지막으로 store method를 통해 해당 Subscribercancellables 집합에 저장한다.

View 구성

위에서 정의한 함수와 view model를 바탕으로 ContentView를 구성한다.

먼저 ViewModel 클래스의 인스턴스를 @StateObject 속성으로 선언한다.

해당 프로젝트에서는 API 연쇄 호출을 실험하기 위함이므로 View는 Button 하나만을 배치하고 버튼이 클릭 되었을때 API를 호출하여 결과를 출력하는 함수를 실행하도록 하였다.

//ContentView.swift
import SwiftUI

struct ContentView: View {
    @StateObject var busList: ViewModel = ViewModel()
    
    var body: some View {
        Button(action: {
            busList.printBusList()
        }, label: {
            Text("버스 도착 정보")
        })
    }
}

결과

버튼을 클릭했을때 API를 호출하여 판교역 버스 정유소에 도착 예정인 버스 목록에 대한 JSON 데이터를 잘 출력하는 것을 확인할 수 있다.

Reference

https://youtu.be/73es1FDmm2g

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글