들어가며
최근 프로젝트를 진행하면서 네트워크 코드를 한 번 수정해야겠다는 생각이 들었습니다.
수정이 왜 필요한가요?
수정을 해야겠다는 생각은 URL Path와 Version를 따로 받을 수 있도록 개선하는 도중 떠올랐습니다. Path, Version 말고도 여러 부분들이 엉켜 있다는 생각이 들더라구요. 분리할 필요가 있어보였습니다.
1.
URL 내부에 있는 Host, Path, Query Parameter
저희 프로젝트에서는 getURL 메서드에 String 형식으로 URL를 적습니다. baseURL은 매개 변수로 받아오지만 그 외의 Path, Query는 String으로 작성해야하죠.
func getURL(baseURL: String) -> String {
switch self {
case .mixRandomManitto(let roomId):
return "\(baseURL)/api/rooms/\(roomId)/relations"
case .openManitto:
return "\(baseURL)/api/relations/my-manitto"
}
}
Swift
복사
2.
EndPointable 프로토콜에서 HTTPHeader 영역
EndPointable은 URLRequest를 만들기 위한 정보들을 관리합니다. EndPointable를 준수하는 타입은 요구 사항에 적절한 값을 넣어주어야 합니다.
protocol EndPointable {
var requestTimeOut: Float { get }
var httpMethod: HTTPMethod { get }
var requestBody: Data? { get }
func getURL(baseURL: String) -> String
}
Swift
복사
하지만, 요청 메시지 전송 시 필요한 Header 영역이 요구 사항으로 포함되어 있지 않습니다. URLRequest를 생성하는 createRequest 메서드에서 Header 값을 만들어줍니다.
func createRequest() -> NetworkRequest {
var headers: [String: String] = [:]
headers["Content-Type"] = "application/json"
return NetworkRequest(url: getURL(baseURL: APIEnvironment.baseUrl),
headers: headers,
reqBody: requestBody,
reqTimeout: requestTimeOut,
httpMethod: httpMethod
)
}
Swift
복사
3.
URLSessionConfiguration 을 안쓸거면 왜 저기 놔뒀는가
2번째 줄을 보면 default 타입으로 Configuration를 생성합니다. 그리고 EndPoint에서 넣어뒀던 requestTimeOut 도 지정해줬습니다.
하지만, 정작 네트워크 통신을 한 건 shared Session 입니다. ( ¯⌓¯)
private func requestDataToUrl(_ request: NetworkRequest) async throws -> UrlResponse {
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = TimeInterval(request.requestTimeOut ?? requestTimeOut)
guard let encodedUrl = request.url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encodedUrl) else {
throw NetworkError.encodingError
}
let (data, response) = try await URLSession.shared.data(for: request.buildURLRequest(with: url))
guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.serverError }
return (data, httpResponse)
}
Swift
복사
4.
request 를 통해서 반환되는 값
request 메서드를 통해서 네트워크 통신이 완료되면 Type Parameter로 들어온 타입으로 값이 반환됩니다. 하지만, 디코딩을 통해서 가져올 값이 없고 상태 코드만 필요한 상황이라면 어떻게 할까요? Response 헤더 필드에 있는 값이 필요한 거라면 어떻게 할까요?
func request<T: Decodable>(_ request: NetworkRequest) async throws -> T? {
let (data, httpResponse) = try await requestDataToUrl(request)
switch httpResponse.statusCode {
case (200..<300):
let decoder = JSONDecoder()
let baseModelData: T? = try decoder.decode(T.self, from: data)
return baseModelData
case (300..<500):
throw NetworkError.clientError(message: httpResponse.statusCode.description)
default:
throw NetworkError.serverError
}
}
Swift
복사
위의 있는 request 메서드를 사용하면 아마 두 상황에서 원하는 값을 얻지 못 할 겁니다. request 메서드를 수정해줘야 할 겁니다. 하지만 request 메서드가 수정되면 이제 Type Parameter로 들어온 타입을 써야 하는 상황에서 문제가 생길겁니다.
protocol Requestable {
var requestTimeOut: Float { get }
func request<T: Decodable>(_ request: NetworkRequest) async throws -> T?
func request(_ request: NetworkRequest) async throws -> Int
func requestCreateRoom(_ request: NetworkRequest) async throws -> Int?
}
Swift
복사
이런 저런 상황을 다 고려하다보니 request 메서드가 3개나 생겼습니다. 줄일 필요가 있어보이네요.
5.
한정된 NetworkError
Network 에러에 있는 케이스가 3가지 밖에 없습니다. 3가지 밖에 없는 케이스에는 Response와 관련된 에러 밖에 없구요. Response 관련 에러들도 모든 케이스를 담지 못합니다.
enum NetworkError: Error {
case encodingError
case clientError(message: String?)
case serverError
}
Swift
복사
6.
multipart/form-data 데이터 디코딩
프로젝트에서 multipart/form-data 타입을 사용하는 경우가 있어서 해당 데이터를 위한 createDataBody 메서드를 만들었습니다. 메서드에서는 멀티 파트 폼 데이터를 위한 요청 바디 형식을 만들어 줍니다.
하지만, 프로젝트에서 사용할 데이터 형식에 맞춰서 만들어져 있기 때문에 다른 곳에서 사용하려면 코드를 수정해야 합니다. 안에 하드코딩 되어 있는 데이터도 있고 Data 형식의 값은 매개변수로 하나 밖에 못 오니깐요.
func createDataBody(withParameters params: [String: String?],
media: Data?,
boundary: String) -> Data {
let lineBreak = "\r\n"
var body = Data()
for (key, value) in params {
guard let value = value else { continue }
body.append("--\(boundary + lineBreak)")
body.append("Content-Disposition: form-data; name=\"\(key)\"\(lineBreak + lineBreak)")
body.append("\(value + lineBreak)")
}
if let media = media {
let mediaKey = "image"
body.append("--\(boundary + lineBreak)")
body.append("Content-Disposition: form-data; name=\"\(mediaKey)\"; filename=\"\(arc4random()).jpeg\"\(lineBreak)")
body.append("Content-Type: image/jpeg\(lineBreak + lineBreak)")
body.append(media)
body.append(lineBreak)
}
body.append("--\(boundary)--\(lineBreak)")
return body
}
Swift
복사
7.
Logger의 부재
현재 프로젝트에서 Request, Response 값이 오고 가는 것을 콘솔 창에 출력해주는 것이 아무것도 없습니다. 시뮬레이터나 실기기 화면을 보면서 값이 오는지 확인할 수 있습니다. 그렇기 때문에 디버깅 시에 print, dump 코드를 이곳 저곳에 달고 브레이크 포인트를 이곳 저곳에 찍어보면서 확인해야 합니다.
위에서 말한 NetworkError 케이스가 한정되어 있는 문제로 인해서 Error가 어디서 발생했는지도 바로 알아내기 어렵습니다. 에러가 일어나도 콘솔에 찍히는 것이 아무것도 없습니다.
일반적으로 나타나는 문제를 모두 ServerError 케이스로 찍어내다보니 이 문제가 과연 서버 문제인지, 클라에서 발생했지만 서버 에러로 찍혔는지 고민해야하는거죠.
이러한 문제들로 인해서 네트워크 코드를 대대적으로 수정해야겠다 생각을 했고, 어떤 식으로 수정을 진행할까 고민하던 차에 Package 로 만들어보면 어떻겠냐는 의견을 듣게 되었습니다.
Thanks to. snoopy
Package로 만드는 건 너무 투머치가 아닐까 생각하던 차에 네트워크 코드를 여러 프로젝트에서 동일하게 사용하고 있다는 생각이 들었습니다. 그렇다면 매번 프로젝트마다 코드를 복붙해서 사용하기 보다는 Package로 가져다가 사용하는 것이 훨씬 좋겠다 라는 생각이 들었습니다.
따라서, Request 요청을 만들고 Request를 보내고 Response를 받는 일련의 과정을 도와주는 MTNetwork 라이브러리를 만들었습니다.
Network 개선
라이브러리를 만들기 위해서 기본적인 네트워크 공부도 했고(Network), URLSession이 어떻게 작동하는지도 살펴봤습니다([정리] URLSession 뜯어보기 - Shared Session 사용해서 데이터 보내고 받아보기).
라이브러리 내에 모든 네트워크 통신 케이스를 추가하고 싶었지만, 당장 사용할 기본 케이스부터 차차 만드는 것이 맞다고 생각해서 후에 다른 케이스들이 들어왔을 때 유연하게 케이스를 추가할 수 있는 환경을 만들어야 했습니다. 그런 환경을 만들기 위해 프로토콜, 제너릭 개념을 추가로 공부했습니다.
라이브러리를 만들기까지 밑 작업이 꽤나 오래 걸렸네요. (ノ´ー`)ノ
이전에 Moya 라이브러리를 잘 사용했기 때문에 Moya의 형식을 많이 따랐고, 네트워크 통신과 관련된 코드는 Alamofire에 있었기 때문에 Alamofire도 많이 참고했습니다.
두 라이브러리 안에서 제가 사용할 부분들만 쏙쏙 빼와서 만들었다고 보시면 됩니다.
프로젝트에서 사용했던 네트워크 관련 타입들은 크게 보면 아래 그림과 같은 플로우로 연결되어 있습니다.
1.
000EndPoint는 EndPointable 를 준수합니다. EndPointable에서 요구하는 여러 가지 값들을 EndPoint 내부에 설정합니다. 그리고 createRequest 메서드를 사용해서 NetworkRequest 타입으로 만듭니다.
2.
NetworkRequest는 EndPoint와 비슷하지만 buildURLRequest 메서드를 사용해서 URLRequest를 만드는 역할을 해줍니다.
3.
APIService는 Requestable 프로토콜을 준수하고 있어서 APIService에 request 메서드를 만들 수 있도록 도와줍니다. request 내부에서는 요청이 보내지고 응답을 받는 과정들이 일어나는데, 이 때 NetworkRequest에서 만든 URLRequest 타입을 사용합니다.
그리고 응답 상태에 따라서 NetworkError나 디코딩된 데이터가 000API로 보내집니다.
4.
000API에서는 받은 데이터나 에러를 000API 내부 메서드를 호출해준 곳으로 반환해줍니다.
전반적인 흐름은 비슷하지만 조금 다르게 수정해보았습니다.
다음 섹션에서 더 자세하게 설명할테지만 이전 플로우와 연결해서 설명을 잠깐하자면,
1.
이전에 사용하던 EndPoint를 Request로 변경했습니다. Request 요청 관련한 내용들을 담기 때문에 000Request라고 네이밍하는 것이 더 직관적이고 이해하기 좋을 것 같아서 이름을 변경했습니다. Request 관련 내용에 대한 틀이기 때문에 EndPointable를 Requestable로 이름을 수정했습니다.
2.
이전에는 NetworkRequest를 사용해서 URLRequest 타입으로 만들어 줬습니다. 수정된 네트워크 코드에서는 EndPoint를 사용해서 URLRequest 타입으로 변경해줍니다. 이번엔 중간에 거쳐서 가지 않고 request 메서드에서 Request 타입을 EndPoint 타입으로 변환하고 EndPoint 내부에 있는 urlRequest 메서드를 거쳐서 URLRequest 타입이 만들어 지도록 했습니다. 그 안에서 HTTPTask에 따라서 다르게 인코딩이 되도록 했는데, 더 자세한 내용은 다음 섹션에 적어두겠습니다.
3.
Provider는 APIService가 하던 일을 합니다. request 메서드를 사용해서 요청을 보내고 응답을 받습니다. 이번에는 응답 시에 Response 타입을 000Service로 보내줍니다. Response 타입은 Data, HTTPURLResponse, StatusCode를 가지기 때문에 이전처럼 반환 타입을 달리한 request 메서드를 여러개 만들지 않아도 됩니다.
4.
Response 내부에는 decode 메서드가 있기 때문에 Service 내부에서 데이터를 디코딩해서 사용하면 됩니다.
MTNetwork
라이브러리 내부에 있는 파일들에 대한 자세한 설명을 담고 있는 섹션입니다.
Requestable
Requestable은 URLRequest에 담을 요청에 필요한 내용들을 가지고 있습니다. Requestable를 준수하는 타입은 요구 사항에 있는 내용들을 채워줘야 합니다.
(작성 예시)
import Foundation
import MTNetwork
enum PhotoRequest {
case fetchImages(query: [String: Any])
}
extension PhotoRequest: Requestable {
var baseURL: URL {
return APIEnvironment.baseURL
}
var path: String {
switch self {
case .fetchImages:
return "/photos"
}
}
var method: MTNetwork.HTTPMethod {
switch self {
case .fetchImages:
return .get
}
}
var task: MTNetwork.HTTPTask {
switch self {
case let .fetchImages(query):
return .requestParameters(query)
}
}
var headers: MTNetwork.HTTPHeaders {
switch self {
case .fetchImages:
let token = "\(KeyProvider.appKey(of: .clientId))"
let authorization = HTTPHeader.authorization(token)
return HTTPHeaders([authorization])
}
}
var requestTimeout: Float {
return 20
}
var sampleData: Data? {
return Data()
}
}
Swift
복사
Requestable에 있는 프로퍼티 중 3가지는 MTNetwork 패키지 내부에 있는 커스텀 타입을 가집니다.
HTTPMethod, HTTPHeaders, HTTPTask 입니다. HTTPHeaders는 Alamofire에 있는 HTTPHeaders 타입을 참고했습니다. HTTPTask는 Moya에 있는 Task 타입을 참고했습니다.
HTTPMethod
기본적인 HTTPMethod 값을 가지고 있습니다.
public enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
Swift
복사
HTTPHeaders
HTTPHeader 타입을 배열로 가지고 있습니다. HTTPHeader는 헤더 필드에 들어가는 값을 name, value로 받습니다. dictionary 메서드를 사용해서 URLRequest의 allHTTPHeaderFields 프로퍼티에 Dictionary 형식으로 넣어줍니다.
public struct HTTPHeader {
/// Name of the header.
let name: String
/// Value of the header.
let value: String
public init(name: String, value: String) {
self.name = name
self.value = value
}
}
Swift
복사
HTTPTask
HTTPTask에는 6가지 케이스가 존재합니다.(케이스 추가 예정)
Type | Description |
requestPlain | 추가 데이터가 없는 상황에서 사용합니다.
예시) Query Parameter가 없는 GET 요청 |
requestJSONEncodable(Encodable) | Encodable를 준수하는 데이터를 Request body로 보내야 하는 상황에서 사용합니다.
예시) Encodable를 따르는 User 구조체를 요청 바디로 넣어서 POST 요청 |
requestParameters(_ parameters: [String: Any]) | Dictionary 형식의 Query Parameter를 URL QueryItem으로 추가해야 하는 상황에서 사용합니다.
예시) 정렬 형식을 [”order_by”: “popular”] 쿼리 파라미터로 설정해서 GET 요청 |
requestCompositeParameters(body: Encodable, query: [String: Any]) | request body와 URL QueryItem를 모두 사용해야 하는 상황에서 사용합니다.
예시) [”user_id”: “mtnetwork123”] 쿼리 파라미터를 가진 URL로 User 구조체를 요청 바디로 넣어서 PATCH 요청 |
uploadMultipart([MultipartFormData]) | multipart/form-data 타입일 때, MultipartFormData를 Request body로 보내야 하는 상황에서 사용합니다.
예시) 이미지 데이터를 요청 바디로 넣어서 POST 요청 |
uploadCompositeMultipart([MultipartFormData], query: [String: Any]) | multipart/form-data 타입일 때, MultipartFormData를 Request body로 보내면서 쿼리 파라미터를 보내야 하는 상황에서 사용합니다.
예시) [”user_id”: “mtnetwork123”] 쿼리 파라미터를 가진 URL로 이미지 데이터를 요청 바디로 넣어서 PUT 요청 |
EndPoint
EndPoint는 Requestable를 준수하는 타입을 URLRequest 타입으로 만들기 전에 한 번 더 감싸주는 역할 합니다. EndPoint 내부에 있는 urlRequest 메서드를 사용해서 URLRequest 타입으로 EndPoint를 변환합니다.
urlRequest 메서드 내부에서는 URLRequest 인스턴스 내부에 있는 프로퍼티에 적절한 값을 설정해줍니다. httpBody, queryItems 값은 HTTPTask 별로 나누어서 설정해줍니다.
URLRequest+MTNetwork
URLRequest를 확장하여 encode 메서드를 총 3개 추가했습니다.
•
Encodable를 준수하는 타입을 받아서 Data 타입으로 변환하여 httpBody에 넣어주는 encode 메서드
•
Parameter를 받아서 URLComponent의 queryItems에 넣어주는 encode 메서드
•
MultipartFormData를 받아서 Data 타입으로 변환하여 httpBody에 넣어주는 encode 메서드
Providable
Providable 프로토콜을 준수하는 Provider는 request 메서드를 가집니다. 해당 메서드는 Request 요청을 생성하여 URLSession에 요청을 태워보내고 Response 데이터를 받습니다. Response 데이터는 200대 상태 코드가 들어오면 반환됩니다. 그 외의 코드가 들어오면 MTError가 반환됩니다.
request 메서드 내부에서 EndPoint와 URLSession를 생성합니다. URLSession은 URLSessionConfiguration.default를 URLSessionConfiguration으로 가집니다. 다른 타입의 URLSessionConfiguration를 추가하게 되면 Session 타입을 선택할 수 있도록 개발해보겠습니다.
Response
Response 타입은 네트워크 응답 관련 내용들을 담고 있습니다.
statusCode, HTTPURLResponse, Data 정보를 가지고 있기 때문에 사용자가 원하는 값을 가져다가 사용할 수 있습니다.
public struct Response {
/// The status code of the response.
public let statusCode: Int
/// The HTTPURLResponse object.
public let response: HTTPURLResponse?
/// The response data.
public let data: Data
/// A text description of the `Response`.
public var description: String {
return "Status Code: \(self.statusCode), Data Length: \(self.data.count)"
}
}
Swift
복사
내부에 있는 decode 메서드를 사용해서 Response data를 Decodable를 준수하는 타입으로 디코딩할 수 있습니다.
마치며
케이스가 위에 있는 것들이 다가 아니기에 계속 업데이트 하겠습니다. ヽ(•̀ω•́ )ゝ
참고 자료
•