기본적으로 Swift에서 URLSession
을 이용한 네트워크 통신은 비동기적으로 수행된다.
이러한 비동기 작업을 처리하기 위한 도구로 delegates, completion handler, RxSwift 등 여러 기능이 존재하는데 Swift에서 제공하는 순정 Combine framework도 비동기 작업을 처리하기 위한 수단 중 하나이다.
이번 포스트에서는 Combine framework를 활용하여 비동기 작업인 API 호출을 통해 JSON 데이터를 가져오는 방법에 대하여 알아보려고 한다.
API 호출을 테스트 해보기 위해 사용할 데이터는 국토교통부에서 제공하는 버스정류소 정보 open API와 국토교통부에서 제공하는 정류소 별 버스 도착정보 open API를 사용할 것이다.
API에 대한 정보는 공공데이터 포털에서 확인
버스 정류소는 판교역 현대백화점 앞에 있는 버스 정류소를 기준으로 데이터를 가져올 예정이다.
전체적인 흐름은 다음과 같다.
버스 정류소 API를 호출하여 버스 정류소의 nodeid 정보를 가져오고 그 데이터를 다시 버스 도착 정보 API에 전달하여 두개의 API를 연쇄적으로 호출하는 것이다.
먼저 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
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
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 호출 함수를 구현할 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를 반환한다.
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 성공시 데이터를 실패시 error를 subscriber에게 전달한다.
전달할 데이터는 map
method로 가져오며 가져온 데이터는 JSONDecoder
를 사용하여 디코딩한다.
마지막으로 eraseToAnyPublisher
method를 사용하여 반환 타입까지 AnyPublisher
로 바꿔 주는 것도 잊지 않는다.
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()
}
}
지금까지 API를 호출하여 JSON 데이터를 가져오는 pulisher를 만들었고 이 publisher가 publish한 값을 전달 받을 Subscriber를 구현할 순서이다.
Publisher에 대한 subscribe는 ViewModel 구조체를 정의하고 구조체 내부에 구현한다.
//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
변수는 해당 클래스의 인스턴스가 메모리에서 해제 되면 Publisher의 subscribe도 함께 해제하기 위한
즉, 다시 말해 subscribe에 대한 메모리 관리를 위한 집합으로 Set<AnyCancellable> 타입으로 정의한다.
printBusList()
method가 실질적으로 subscribe가 일어나는 method로 APIService
의 fetchBusListAfterBusStop()
method에 의해 반환된 Publisher의 sink
method로 해당 Publisher를 subscribe 하도록 한다.
subscribe의 결과로 receiveValue
parameter에 Publisher로 부터 전달 받은 값을 출력할 수 있도록 하는 클로저를 전달한다.
마지막으로 store
method를 통해 해당 Subscriber를 cancellables
집합에 저장한다.
위에서 정의한 함수와 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 데이터를 잘 출력하는 것을 확인할 수 있다.