Alamofire를 활용하여 iOS HTTP 통신을 위한 레퍼런스를 찾던 중 다음 문서를 (opens new window) 발견하게 되어 정리합니다. 문서에 나오지 않은 부분을 추가로 정리하였습니다.

문서를 읽고 jsonplaceholder (opens new window)라는 사이트를 참고하여 직접 코드를 작성 및 테스트 해보면 좋습니다.

시작에 앞서 Alamofire5 버전을 설치해줍니다.

# Constant 설정

본격적인 네트워크 통신 이전에 설정해줄 상수값들이 여러개가 있다. baseURL, HTTP헤더 등이 이에 해당한다.

import Foundation
struct K {
    struct ProductionServer {
        static let baseURL = "https://baseurl.com"
    }

    struct APIParameterKey {
        // MARK: 유저 모델 관련 API 파라미터 키값 리스트
        static let id = "id"
        static let password = "password"

        static let arrayOfPos = "arrayOfPos"
        static let routeName = "routeName"
        static let runningTime = "runningTime"
    }
}

enum HTTPHeaderField: String {
    case authentication = "Authorization"
    case contentType = "Content-Type"
    case acceptType = "Accept"
    case acceptEncoding = "Accept-Encoding"
    case multipartFormdata = "multipart/form-data"
}

enum ContentType: String {
    case json = "application/json"
}

ProductionServer nested 타입 설정을 통해 개발서버 배포서버를 분리하여 URL 설정도 가능하다. 중간에 있는 APIParameterKey 열거형 타입은 엔드포인트 설정 과정에서 다시 살펴본다.

APIParameterKey 중첩 구조체 타입 내에 정의하는 문자열들은 각 API 문서에 맞게 본인이 커스텀 하면 된다.

# API 엔드포인트 설정

서버에서 API를 제공할때 일반적으로 기능별로 묶어 제공하게 된다. 예를 들어 유저와 관련된 기능을 User, 산책코스 공유 관련 기능을 Route 등으로 묶어 제공할때 클라이언트 측에서 이를 한 엔드포인트로 묶어 관리하게 되면 해당 타입이 너무 방대해지고 유지보수가 어렵다는 단점이 있다.

본인의 경우 러닝코스 공유 앱 제작을 위한 엔드포인트 설정을 진행하였다. API문서가 아래와 같이 작성되어 있다고 가정해보자.

## User

post(`/auto/login`) // 자체 회원 로그인
get(`/auth/kakao`) // 카카오 로그인

## Route

post(`/running-route`) // POST 기능 1
get(`/running-route/main/${id}`) // GET 기능 2

이때 API 기본 설정을 위한 프로토콜을 먼저 정의한다. URLRequestConvertible은 Alamofire에서 제공하는 타입이며 새로 정의할 APIConfiguration 프로토콜에서 이를 상속한다.

이후 해당 프로토콜에 통신 메서드, URL Path, 쿼리 파라미터 및 body 파라미터들을 정의한다. 계산속성 타입으로 정의한다.

// APIRouter.swift
import Alamofire

protocol APIConfiguration: URLRequestConvertible{
    var method: HTTPMethod{ get }
    var path: String { get }
    var parameters: Parameters? { get }
}

위의 예시 API 문서를 기반으로 각각에 대해 엔드포인트 열겨헝 타입을 정의한다. 각 타입은 위에 정의한 APIConfiguration 프로토콜을 채택한다. 프로토콜을 채택했으므로 계산속성 세가지를 구현해줘야 한다.

HTTPMethod나 Parameters타입은 Alamofire의 타입이므로 꼭 import를 해주자.

  1. 먼저 API 각 요청 대상을 열거형 case로 정의한다.
  2. 이후 계산속성은 switch ~ case로 하며 self를 switch 대상 변수로 지정한 뒤 각 API 요청 대상에 대해 메서드, URLPath, 파라미터를 정의한다.
  3. 열거형은 모든 케이스에 대해 switch~case 고려를 마치면 굳이 default까지 삽입할 필요는 없다.
  4. request body에 들어갈 데이터나 URL 쿼리 파라미터에 들어갈 데이터는 enum case에 함께 전달하게 된다.
    • 파라미터 목록은 K열거형에 정의했으며 딕셔너리 형태로 참조한다.
enum UserEndPoint: APIConfiguration{
    case login(id: String, password: String)
    case kakaoLogin

    // 메서드 정의
    var method: HTTPMethod{
        switch self{
        case .login:
            return .post
        case .kakaoLogin:
            return .post
        }
    }

    // URL path 정의
    var path: String{
        switch self{
        case .login:
            return "/auth/login"
        case .kakaoLogin:
            return "/auth/kakaoLogin"
        }
    }

    // let 바인딩으로 쿼리 파라미터 전달받음.
    // 전달할 파라미터가 없으면 nil을 리턴하면 된다.
    var parameters: Alamofire.Parameters?{
        switch self{
        case .login(let id, let password):
            return [
                K.APIParameterKey.id: id,
                K.APIParameterKey.password: password
            ]
        case .kakaoLogin:
            return nil
        }
    }

    // 아래 함수는 엔드포인트마다 동일하게 정의된다.
    func asURLRequest() throws -> URLRequest {
        let url = try K.ProductionServer.baseURL.asURL()

        var urlRequest = URLRequest(url: url.appendingPathComponent(path))

        // HTTP Method
        urlRequest.httpMethod = method.rawValue

        // Common Headers
        urlRequest.setValue(ContentType.json.rawValue, forHTTPHeaderField: HTTPHeaderField.acceptType.rawValue)
        urlRequest.setValue(ContentType.json.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue)

        // Parameters
        if let parameters = parameters {
            do {
                urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
            } catch {
                throw AFError.parameterEncodingFailed(reason: .jsonEncodingFailed(error: error))
            }
        }

        return urlRequest
    }
}

위와 같이 유저 엔드포인트에 대해 열거형 정의를 해보았다. 나머지 Route라는 기능에 대해서도 정의를 해보자.

case 정의는 API문서를 보고 직접 네이밍하면 된다. 본인의 경우 경로를 저장하는 postRoute, 경로를 불러오는 getRoute라고 네이밍하였다.

enum RouteEndPoint: APIConfiguration{
    case postRoute(route: RouteForServer) // 서버에 저장할 데이터
    case getRoute(id: Int) // id: 저장된 경로의 Id값

    var method: HTTPMethod{
        switch self{
        case .postRoute:
            return .post
        case .getRouet:
            return .get
        }
    }

    // URL 쿼리 파라미터에 들어갈 변수가 사용된다.
    // 열거형 인스턴스 생성시 전달된 파라미터를 switch - self 바인딩으로 불러온 뒤 문자열 보간법으로 URL에 삽입한다.
    var path: String{
        switch self{
        case .postRoute:
            return "/running-route"
        case .getRoute(let id):
            return "/running-route/\(id)"
        }
    }

    // request body를 정의한다. 이후 다시 살펴봄.
    var parameters: Alamofire.Parameters? {
        switch self{
        case .postRoute(let route):
            return [
                K.APIParameterKey.arrayOfPos: route.arrayOfPos,
                K.APIParameterKey.routeName: route.routeName,
                K.APIParameterKey.runningTime: route.runningTime
            ]
        // 전달할 body가 없으면 빈 딕셔너리 리턴
        case .getRoute:
            return [:]
        }
    }

    // asURLRequest 함수는 동일
}

# 데이터 모델링과 Codable

웹 기반 데이터통신에서는 JSON 자체를 주고받을 수 있었다. 이는 자바스크립트로 해당 데이터 구조로 파싱하기가 쉬웠기 때문인데, 스위프트에서는 구조체 기반의 인스턴스를 JSON으로 인코딩, 디코딩 하는 과정을 직접 코드로 작성해야 한다.

또한 스위프트도 타입에 엄격하기 때문에 response에 대한 타입 명시가 다르게 되면 데이터 디코딩 과정에서 에러가 심심치 않게 발생하게 된다.

데이터 모델링 코드는 Route를 예시로 작성해본다.

struct Route: Codable{
    let arrayOfPos: [Coordinate]
    let routeName: String
    let runningTime: String
}

struct Coordinate: Codable{
    let latitude: Double
    let longitude: Double
}

스위프트에서는 Codable프로토콜을 제공한다. 이를 채택한 구조체 타입 인스턴스는 JSON 인코딩 / 디코딩이 가능해진다.

위의 예시처럼 저장속성 arrayOfPos가 스위프트 원시타입인 String, Int 등이 아닌 커스텀 타입 Coordinate인 경우 해당 타입도 Codable프로토콜을 채택해야 한다.

각 저장속성의 타입은 반드시 API문서에 작성된 데이터 타입과 동일한 타입으로 지정해야한다. 예컨대 Coordinate 타입의 latitude가 문서에는 Double인데 타입을 String으로 지정하는 등의 실수를 조심해야 한다.

# 데이터 요청

이제 본격적으로 버튼 등의 이벤트 핸들러를 통해 API 요청을 시도한다. 요청에 앞서 정의해둔 엔드포인트로 연결을 해줄 새로운 타입을 다시 정의해줘야 한다. 엔드포인트를 모아두는 파일명을 APIRouter.swift로 했다면 새로 작성할 파일은 APIClient.swift로 한다.

import Alamofire
class APIClient {
    @discardableResult
    private static func performRequest<T:Decodable>(route:RouteEndPoint, decoder: JSONDecoder = JSONDecoder(), completion:@escaping (Result<T, AFError>)->Void) -> DataRequest {
        return AF.request(route)
            .responseDecodable (decoder: decoder){ (response) in
                            completion(response.result)
        }
    }

    static func postRoute(routeData: Route, completion: @escaping (Result<RouteId, AFError>) -> Void){
        performRequest(route: .postRoute(route: routeData), completion: completion)
    }
}

@discardableResult

리턴타입이 Void가 아닌 함수에 대해 리턴된 데이터를 사용하지 않는 경우 경고문구를 xcode에서 띄우지 않도록 해준다.

POST요청을 보내는 함수만 예시로 정의했다. performRequest함수는 APIClient 내의 함수들이 공통적으로 사용하는 함수이다.

  1. 파라미터로 decoder를 받을 수 있다.
  2. Decodable을 채택한 타입에 대해 제네릭을 받아 함수를 정의하게 된다. Codable을 채택한 타입이라면 자동으로 EncodableDecodable을 채택한다.
  3. completion handler 클로저를 파라미터로 받으며, 해당 함수는 Result타입을 파라미터로 다시 받는다. Result의 Success 케이스에 대한 타입은 제네릭 선언된 T 타입이며, failure case에 해당하는 에러 타입은 Alamofire에서 제공하는 AFError를 사용한다.

아래는 버튼 이벤트 정의 후 데이터를 전달하는 과정이다. 뷰컨트롤러 내의 이벤트들을 커스텀 델리게이트 프로토콜 내에 정의한 형태이다. APIClient 타입 내에 정의한 요청함수를 호출하면서 파라미터에 데이터를 전달한다.

extension PostDetailViewController: PostDetailViewEventDelegate{
    func registerButtonTapped(route: Route) {
        APIClient.postRoute(routeData: route) { result in
            switch result{
            case .success(let data):
                print(data)
            case .failure(let error):
                print(error)
            }
        }
    }
}

트레일링 클로저 형태로 completion handler도 함께 전달한다. switch-case로 success 케이스와 failure케이스에 대해 데이터를 바인딩받아 프린트하고 있다.

위와 같이 코드를 모두 작성 후에 요청을 보내면 __SwiftValue와 관련된 fatal error가 출력되며 앱이 종료되는 것을 볼 수 있을 것이다.

# request body 작성시 주의점

명확한 이유를 현재로서는 알 수 없는 상황이지만, Coordinate타입에 대해 Codable프로토콜을 채택했음에도 JSON Serialize, 즉 JSON으로 데이터 인코딩 과정에서 에러가 발생한 상황이었다.

본래 인코딩/디코딩 가능 타입은 옵셔널을 포함하여 스위프트 내의 Int, Double, Double등의 원시타입들만 가능한데, 커스텀 타입 Coordinate은 커스텀 구조체 타입이기 때문이 인코딩을 위해서는 Codable채택이 필요한 상황이었다.

애플 공식문서에도 동일한 예시가 있지만 에러가 발생하여 결국 해당 인스턴스는 각 배열을 순회하며 딕셔너리로 직접 파싱하도록 코드를 작성해야 했다.

var parameters: Alamofire.Parameters? {
    switch self{
    case .postRoute(_, let route):
        let array = route.arrayOfPos.map({ coor in
            ["latitude": coor.latitude, "longitude": coor.longitude]})
        return [
            K.APIParameterKey.arrayOfPos: array,
            K.APIParameterKey.routeName: route.routeName ,
            K.APIParameterKey.runningTime: route.runningTime
        ]
    }
}

위에서 작성했던 RouteEndPoint의 parameters 계산속성 내의 내용인데, 인스턴스 map 메서드를 통해 배열 원소를 순회하며 새로운 딕셔너리를 반환받고 있다.

딕셔너리를 파라미터에 전달하니 해당 에러는 더 이상 발생하지 않았다.

디버깅 방법으로는 String(data: serialize된 데이터, encoding: .utf8)을 print한 뒤 브라우저에서 JSON.parse메서드로 JSON 데이터로 잘 파싱 되는지 확인하는 방법이 있다.

# 에러 핸들링

읽기 전 주의

아래 작성한 내용은 매우 주관적인 해결방법이므로 개선의 여지가 많습니다. 주의!

에러가 발생하고 나면 서버에서 에러 메세지를 반환한다. 이 또한 JSON 형태로 주고받게 되는데, 다음 예시를 보자.

{
	"statusCode": 403,
	"message": [
		"Already Existed routeName"
	],
	"error": "Forbidden"
}

statusCode, message, error 속성을 갖는 JSON 데이터가 있는데, Alamofire에서는 서버 응답코드가 어떤것이 되었던간에 데이터 반환이 되면 이를 .success 케이스로 분류해버린다.

따라서 success케이스 기준으로 전달된 제네릭 타입을 기준으로 타입캐스팅을 진행하는데, 위의 예시에서는 Route모델을 기준으로 타입캐스팅을 진행하는 과정에서 에러 데이터 내의 속성과 Route모델 속성 키값이 맞지 않아 디코딩 에러를 발생하는 것이다.

본인의 경우 retryAPI함수를 APIClient타입에 새로 추가하여 디코딩에러가 발생한 경우 API 재요청을 보내도록 에러 처리를 하였으며 이때에 제네릭 타입은 새롭게 정의한 NetworkError로 전달하였다.

import Foundation

struct NetworkError: Codable, Error{
    let statusCode: Int
    let message: [String]
    let error: String

    enum CodingKeys: String, CodingKey{
        case statusCode = "statusCode"
        case message = "message"
        case error = "error"
    }
}

해당 에러 열거형 타입도 역시 Codable을 채택해야 한다. 백엔드 개발자와 합의 후 결정된 에러 타입을 기준으로 데이터 모델링을 진행하고, API 재요청을 위한 함수를 새로 추가한다.

import Alamofire
class APIClient {
    // 기존 함수
    @discardableResult
    private static func performRequest<T:Decodable>(route:RouteEndPoint, decoder: JSONDecoder = JSONDecoder(), completion:@escaping (Result<T, AFError>)->Void) -> DataRequest {
        return AF.request(route)
            .responseDecodable (decoder: decoder){ (response) in
                            completion(response.result)
        }
    }

    // 기존 함수
    static func postRoute(routeData: RouteForServer, completion: @escaping (Result<RouteId, AFError>) -> Void){
        performRequest(route: .postRoute(accessToken: "", route: routeData), completion: completion)
    }

    // 새로 추가한 함수
    static func retryAPIRequest(routeData: RouteForServer,retryEndPoint: RouteEndPoint, completion: @escaping (Result<NetworkError, AFError>) -> Void){
        performRequest(route: retryEndPoint, completion: completion)
    }
}

위와 같이 API 재요청 함수를 정의해둔 뒤 다시 버튼 이벤트 핸들러 함수로 넘어가보자.

extension PostDetailViewController: PostDetailViewEventDelegate{
    func registerButtonTapped(route: Route) {
        APIClient.postRoute(routeData: route) { result in
            switch result{
            case .success(let data):
                print(data)
            case .failure(let error):
                print(error)
            }
        }
    }
}

failure 케이스로 분류된 이유가 typemismatch나 decoding key error와 관련되었을 것이다. API 재요청을 통해 넘어온 네트워크 에러 JSON을 새롭게 정의한 커스텀 에러 타입으로 디코딩 해야하기 때문에, failure케이스에서 API를 재요청하되, 해당 함수의 completion handler로 전달하는 클로저 Result파라미터의 success 타입은 NetworkError가 된다. (retryAPI 함수 클로저 파라미터를 살펴보자.)

재요청 함수를 작성하면 아래와 같다.

APIClient.postRoute(routeData: routeForServer) { result in
    switch result{
    case .success(let data):
        print(data)
    case .failure:
        APIClient.retryAPIRequest(routeData: routeForServer, retryEndPoint: .postRoute(accessToken: "", route: routeForServer)) { result in
            switch result{
            case .success(let error):
                print(error)
            case .failure(let fatalError):
                print(fatalError)
            }
        }
    }
}

routeData를 받는 이유는 동일한 API에 재요청을 보내야 하기 때문에 postRoute에 필요한 파라미터를 전달한 것이다.

요청에 앞서 고려한 에러인 경우 Success 케이스에서 NetworkError로 타입캐스팅이 정상적으로 이루어졌을 것이며 프린트가 잘 이루어진다.

반면 여전히 고려하지 못한 서버상의 오류가 분명 존재할 것이다. 해당 에러의 경우 다시 한번 failure케이스로 분류되며, 이는 fatalError로 네이밍하여 출력하도록 코드를 작성하였다.

# Reference

  1. Medium - Write a Networking Layer in Swift 4 using Alamofire 5 and Codable Part 1: API Router (opens new window)
  2. Medium - Write a Networking Layer in Swift 4 using Alamofire 5 and Codable Part 2: Perform request and parse using Codable (opens new window)
  3. ZeddiOS - DecodingError (opens new window)
  4. Write a Networking Layer in Swift 4 using Alamofire 5 and Codable Part 3: Using Futures/Promises (opens new window)