API / HTTP Error

iOS 앱개발 공부

목록 보기
2/30

오늘의 목표

기상청 API를 호출하여 XCTest로 호출 결과를 확인해보자

1. 기상청 API

이번에 새로 진행하는 프로젝트로, 끌밋(kkeulmit)이라는 프로젝트를 진행 중이다.
이는 사용자가 설정한 시간과 요일마다 날씨를 점검하고 사용자에게 옷차림을 추천해주는 앱이다.

이 앱의 핵심 중 하나는 날씨를 점검하는 것이기 때문에 날씨를 확인할 수 있는 API의 사용이 필수이다. 때문에 구글링을 통해 어떤 API가 좋을지 조사를 해보았는데, 무료로 사용할 수 있는 API 중에 OpenWeather한국 기상청 API가 있었다.

OpenWeather은 예전에 과제로 사용해본 적이 있어서 익숙하긴 했지만, 국내 사용자가 타겟팅인 끌밋 앱에서는 한국 기상청의 날씨 정보가 더 정확하고 신뢰성을 줄 것이라고 판단했다.

때문에 한국 기상청에서 제공하는 API 중, 단기예보 API를 활용하기로 했다.
단기예보 API에서 얻을 수 있는 데이터 목록은 아래와 같다.

POP = 강수확률
PTY = 강수 형태
PCP = 1시간 강수량
REH = 습도
SNO = 1시간 신적설
SKY = 하늘 상태
TMP = 1시간 기온
TMN = 일 최저기온
TMX = 일 최고기온
UUU = 풍속(동서성분)
VVV = 풍속(남북성분)
WAV = 파고

2. API Manager 구현

1) JSON 모델 파일 구현

기상청에서는 API를 호출했을 때 데이터 형식을 미리보기로 볼 수 있기 때문에, 이를 참고하여 JSON 데이터 모델 파일을 구현해준다.

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

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

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

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

// MARK: - Item
struct Item: Codable {
    let baseDate, baseTime: String
    let category: Category
    let fcstDate, fcstTime, fcstValue: String
    let nx, ny: Int
}

enum Category: String, Codable {
    case pcp = "PCP"
    case pop = "POP"
    case pty = "PTY"
    case reh = "REH"
    case sky = "SKY"
    case sno = "SNO"
    case tmn = "TMN"
    case tmp = "TMP"
    case tmx = "TMX"
    case uuu = "UUU"
    case vec = "VEC"
    case vvv = "VVV"
    case wav = "WAV"
    case wsd = "WSD"
}

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

이제 위 모델을 이용해서 기상청으로부터 받은 데이터를 변환해 줄 예정이다.

2) API Managable 구현

API를 확보했으니 이제 프로젝트에서 API를 호출하고 관리하는 객체를 구현해 줄 차례이다.
이번에는 평소 잘 사용하지 않았던 Swift Concurrency인 async/await을 사용해서 구현해 줄 예정이다.

APIManager는 싱글톤 패턴으로 만들어도 되지만, 테스트 용이성을 높이기 위해 의존성 주입 방식을 사용하려고 한다.
이를 위해 APIMaanagabel 이라는 프로토콜을 만들고, 이 프로토콜을 채택하는 형식으로 APIManager를 구현해준다.

protocol APIManagable: AnyObject {
    func fetch() async throws -> Items
}
final class APIManager: APIManagable {
	func fetch() async throws -> Items { ... }
}

3) fetch 메소드 구현

이제 프로토콜에서 정의한 fetch 메소드 내부 코드를 구현해 줄 차례이다.
이번 프로젝트에서는 URLSession 대신 Alamofire를 사용하여 API 통신을 진행 할 예정이다.

func fetch() async throws -> Items {
	let url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"
        
	guard let apiKey = Bundle.main.infoDictionary?["APIKey"] as? String else {
		throw NSError(domain: "APIKeyError", code: 0, userInfo: [NSLocalizedDescriptionKey: "APIKey가 없습니다."])
	}
        
	let date = Date().formattedDateToString()
	let parameters: Parameters = [
		"ServiceKey": apiKey,
		"pageNo": "1",
		"numOfRows": "1000",
		"dataType": "JSON",
		"base_date": "20250410",
		"base_time": "0500",
		"nx": "55",
		"ny": "127"
	]
	let request = AF.request(url, method: .get, parameters: parameters)
	let response = await request.serializingDecodable(WeatherModel.self).response
        
	switch response.result {
	case .success(let weather):
		debugPrint("✅ API 호출 성공, 코드:", weather.response.header.resultCode)
		return weather.response.body.items
            
	case .failure(let error):
		debugPrint("🚨 API 호출 실패", error.localizedDescription)
		throw error
	}
}

위 코드에서 apiKey는 미리 .xcconfig파일로 암호화 해둔 키로, APIKey의 보안을 위해 구현하였다.
또, async/await 형태의 코드를 사용하기 위해, Alamofire의 serializingDecodable을 활용하였다.

이는 Alamofire에서 제공하는 응답 처리 메소드 중 하나로, 네트워크 응답을 디코딩 가능한 타입(Decodable)으로 변환 해주는 기능이다.
보통은 잘 사용하지 않지만, async/await을 사용할 때 유용하게 사용할 수 있다.

4) XCTest

API 호출러를 구현했으니, 이제 실제로 API 호출이 잘 되는지 테스트를 해볼 차례이다.
아직은 UI의 구현이 되지 않았기 때문에 XCTest를 활용하여 테스트를 진행하기로 했다.

먼저, API 테스트를 위한 XCTest 파일을 하나 만들어주고, 프로젝트를 import한다.

import XCTest
@testable import kkeulmit

final class APITest: XCTestCase { ... }

그리고 APIManager 객체를 생성하여 시작 옵션과 종료 옵션을 설정해준다.

private var sut: APIManagable!

override func setUpWithError() throws {
	sut = APIManager()
	try super.setUpWithError()
}

override func tearDownWithError() throws {
	sut = nil
	try super.tearDownWithError()
}

이제 테스트 메소드를 만들어 API 테스트를 해 볼 차례이다.

func testGetMethod() async throws {
// given
var data: Items? = nil
        
// when
do {
	data = try await sut.fetch()
	debugPrint("✅ 날씨 데이터 변환 성공", data?.item.count ?? 0)
} catch {
	debugPrint("🚨 날씨 데이터 변환 실패", error.localizedDescription)
}
        
// then
XCTAssertNotNil(data)
}

위 코드는 API를 호출하여 data라는 변수에 삽입하는데, 만약 호출에 실패하면 data는 nil이기 때문에 테스트에 실패하게 된다.
그리고 테스트를 실행해 보았는데...

이렇게 실패하는 문제가 발생했다.
에러 내용은 아래와 같았다.

URLSessionTask에 오류가 발생하여 실패했습니다.: App Transport Security 정책에 보안 연결을 사용해야 하므로 리소스를 로드할 수 없습니다.

즉, HTTP(비보안) 주소로 요청을 보내고 있는데, iOS의 보안 정책(ATS: App Transport Security) 때문에 HTTPS가 아닌 요청은 자동으로 차단되는 것이다.


3. 문제 해결

1) https 변경

결국 원인은 기상청 API의 주소가 http이기 때문인데, 그럼 이걸 https로 바꾸면 해결될까?
바로 시도를 해보았다.

let url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"

이번에는 다른 오류이지만 결국 에러가 발생했다.
SSL 오류가 무엇일까?

이 에러는 Swift나 Alamofire에서 발생한 문제가 아니라, 기상청 서버가 SSL 인증서를 제대로 설정하지 않았거나, iOS에서 신뢰할 수 없는 인증서를 사용하고 있다는 뜻이다.
즉, 기상청 API는 https를 제공하긴 하지만, iOS 기준에서는 '안전하지 않다'라고 판단하는 인증서를 사용하고 있는 것이다.

iOS.. 정말 어렵다.
이 부분은 기상청에 문의를 하면 해결될 수도 있지만, 시간이 오래 걸리기도 하고 해결이 될지 안될지 알 수 없기 때문에 보류하기로 했다.

2) ATS 예외 설정

다음 방법은 url은 그대로 http로 사용하되, ATS 보안 설정을 수정하는 방법이다.
이는 프로젝트 info.plist에서 설정할 수 있고 굉장히 간단하다.

프로젝트의 info.plist로 이동한 후에 아래 사진처럼 새로운 데이터를 추가해주면 끝이다.

이 내용은 iOS 앱에서 모든 도메인에 대해 보안 없이 통신하는 것을 허용하는 설정이다.
즉, 앱 전체적으로 HTTP 사용을 허용해준다는 뜻이다.

이렇게 설정하고 테스트를 해보면...

이렇게 테스트가 성공하는 것을 확인할 수 있었다.
다만, 이 방법은 테스트를 할 때는 유용할 수 있어도, 실제 앱을 출시하려는 목적이라면 적합하지 않다.

왜냐하면 info.plist에 추가한 내용은 결국 프로젝트에서 전역적으로 ATS 설정을 비활성화 하겠다는 뜻이므로 보안에 매우 취약해지기 때문이다. 단순히 보안 문제 때문만이 아니라 애초에 애플에서는 이를 사유로 앱스토어 출시를 거부할 가능성이 크기 때문에 앱을 출시하는 것이 목적이라면 다른 API를 사용하는 것이 좋다.


4. 결론

ATS 설정을 해제하는 방법으로 API의 호출이 정상적으로 이루어지고 있다는 것은 확인했지만, 결국 이 방법으로는 앱스토어에 출시할 수 없기 때문에 다른 방법을 선택해야 한다.
한국 기상청은 왜 https를 제대로 지원하지 않는걸까...

결국은 OpenWeather같은 다른 API를 사용하는 방식으로 바꿔야 할 것 같다.


API 통신을 하는 것은 오랜만인데, 처음 보는 에러를 마주하고 당황했다.
한국 기상청... https를 제대로 지원해주길...
profile
이유있는 코드를 쓰자!!

0개의 댓글