티스토리 뷰
저번편 Moya 에러 정리에 이어서 이번엔 진행중인 프로젝트 내부 에러 핸들링을 리팩토링을 해보고자 합니다.
혹시 저번글을 안보셨다면,, 한번 보셔도 좋을듯합니다!
[iOS] Moya 에러 정리
현재 프로젝트에서 Moya 를 사용하고 네트워크 에러 또한 Moya 에서 정의한대로 사용하고 있는데요. 프로젝트를 진행하다 돌아보니 Moya 에서 정의한 에러들을 정확하게 알지 못한다고 느꼈습니다.
gobans.tistory.com
그럼 리팩토링 고고!
에러 핸들링 구조
현재 프로젝트의 에러 핸들링 구조는 다음과 같습니다.
클린 아키텍처의 이점을 살리기 위하여 레이어를 분리하고 ErrorVO와 ErrorDTO 로 에러 객체를 나눴는데요, 이렇게 하면 다음과 같은 장점이 있습니다.
- Presentation, Domain 레이어와 Data 레이어가 사용하는 에러를 분리하였기 때문에 각각 레이어의 관심사에 맞게 에러 케이스를 쉽게 확장, 변경, 대응 가능.
- 서버의 잘못된 response 에 대한 대응을 깔끔하게 처리 가능.
NetworkErrorDTO 와 OAuthErrorDTO 의 구분은 데이터 발행자입니다! (프로젝트 서버, 외부 OAuth 서버)
그런데 여기서 이상한점.... 그림을 딱 보시면 MoyaError 가 뜬금없이 NetworkErrorDTO 로 맵핑되고 있죠?
왜 이렇게 했을까요?? (진짜 모름)
아마 이때 MoyaError 에 대해서 잘 몰라서 그냥 커스텀 ErrorDTO 를 만들어서 쓰자고 생각했던 것 같습니다.
이로인해 불필요하고 부정확하게 Error 케이스들이 맵핑되었습니다.
때문에 MoyaError -> NetworkErrorDTO 의 맵핑을 없애고, MoyaError 를 그대로 사용하는 방향으로 수정해보고자 합니다.
NetworkErrorDTO
NetworkErrorDTO 는 다음과 같이 구성되어 있습니다.
public enum NetworkErrorDTO: Error {
case requestError(String)
case clientError(Error)
case serverError(Response)
case underlyingError(Error, Response?)
case tokenExpired
public var debugString: String {
switch self {
case .clientError(let error):
return "⛑️ Client Error: \(error.localizedDescription) "
case .requestError(let description):
return "⛑️ Request Error \(description)"
case .serverError(let response):
let serverErrorMessage = convertServerErrorMessage(response: response)
return "⛑️ Server Error \(response.description)\n" + (serverErrorMessage?.description ?? "")
case .underlyingError(let error, let response):
if let response = response {
let serverErrorMessage = convertServerErrorMessage(response: response)
return "⛑️ UnderlyingError \(error.localizedDescription)\n" + (serverErrorMessage?.description ?? "")
}
return "⛑️ UnderlyingError \(error.localizedDescription)"
case .tokenExpired:
return "⛑️ Token Expired"
}
}
public func toVO() -> ErrorVO {
switch self {
case .clientError(_):
return .fatalError
case .requestError(_):
return .fatalError
case .serverError(let response):
if 500...599 ~= response.statusCode || 429 == response.statusCode {
let serverErrorMessage = convertServerErrorMessage(response: response)
return .retryableError(serverErrorMessage?.message)
} else {
return .fatalError
}
case .underlyingError(_, let response):
if let response = response {
let serverErrorMessage = convertServerErrorMessage(response: response)
return .retryableError(serverErrorMessage?.message)
} else {
return .fatalError
}
case .tokenExpired:
return .tokenExpired
}
}
private struct ServerErrorMessage: Decodable {
let time: String
let status: Int
let message: String
let code: String
let errors: [DetailedErrors]
public var description: String {
var serverErrorResponse = """
🔊 Server Error Response
time: \(time)
status code: \(String(status)) - \(httpStatusDescription)
code: \(code) - \(message)
🧐errors:\n
"""
for error in errors {
serverErrorResponse += """
field: \(error.field)
value: \(error.value ?? "nil")
reason: \(error.reason)
----------------------
"""
}
return serverErrorResponse
}
struct DetailedErrors: Decodable {
let field: String
let value: String?
let reason: String
}
public var httpStatusDescription: String {
switch self.status {
case 200:
return "성공"
case 400:
return "잘못된 요청"
case 401:
return "비인증 상태"
case 403:
return "권한 거부"
case 404:
return "존재하지 않는 요청 리소스"
case 405:
return "API 는 존재하나 Method가 존재하지 않는 경우"
case 500:
return "서버 에러"
default:
return "정의되지 않은 에러"
}
}
}
private func convertServerErrorMessage(response: Response) -> ServerErrorMessage? {
do {
let serverErrorMessage = try JSONDecoder().decode(ServerErrorMessage.self, from: response.data)
return serverErrorMessage
} catch {
print("🫠Failed to decode serverErrorMessage\n")
print("⭐️plain response is below")
print(String(data: response.data, encoding: .utf8) ?? "no response")
return nil
}
}
}
보시는 것 처럼 서버와 협의해서 정의한 에러 메시지를 파싱해서 쓰고있습니다.
그런데 에러 케이스 중에서
case requestError(String)
case clientError(Error)
case serverError(Response)
case underlyingError(Error, Response?)
case tokenExpired
requestError, clientError, underlyingError 를 보시면 아주 general 하게 에러 debugString 을 구성되어있습니다.
그 이유는 MoyaError -> NetworkErrorDTO 에 맵핑할때 parameter 를 묶어서 맵핑을 했기 때문입니다..
MoyaError 를 한번 보시죠!
MoyaError
import Moya
extension MoyaError {
public func toNetworkError() -> NetworkErrorDTO {
switch self {
// Client Error
case .encodableMapping(let error):
return NetworkErrorDTO.clientError(error)
case .parameterEncoding(let error):
return NetworkErrorDTO.clientError(error)
// Request Error
case .requestMapping(let string):
return NetworkErrorDTO.requestError(string)
// Server Error
case .imageMapping(let response):
return NetworkErrorDTO.serverError(response)
case .jsonMapping(let response):
return NetworkErrorDTO.serverError(response)
case .objectMapping(_, let response):
return NetworkErrorDTO.serverError(response)
case .stringMapping(let response):
return NetworkErrorDTO.serverError(response)
case .statusCode(let response):
return NetworkErrorDTO.serverError(response)
// Underlying Error
case .underlying(let error, let response):
// 토큰 재발급 실패
if response?.statusCode == 401, let httpUrlResponse = response?.response {
if httpUrlResponse.url?.lastPathComponent == "reissue" {
return NetworkErrorDTO.tokenExpired
}
}
// 토큰 재발급 실패 -> retry error
if let afError = error.asAFError, afError.isRequestRetryError {
if case .requestRetryFailed = afError {
if response?.statusCode == 401 {
return NetworkErrorDTO.tokenExpired
}
}
}
return NetworkErrorDTO.underlyingError(error, response)
}
}
}
지금와서 보니 serverError 라고 할 수 없는 에러들도 다 포괄적으로 묶어서 serverError 로 퉁치고 있네요^^...
애초에 client, server 에러 네이밍 자체도 어색하고 이들을 나누는 기준도 애매한 것 같습니다.
정리하자면, 현재 에러 핸들링 구조에서 파악된 문제점은 다음과 같습니다.
- MoyaError -> serverErrorDTO 로 한번 더 맵핑하는 과정이 있다.
- 맵핑이 general 하게 이루어져 있기 때문에 각각의 에러에 대해 정확한 대응을 하지 못하고 있다.
이 문제점을 해결하기 위해 NetworkErrorDTO를 제거하고, MoyaError 에서 에러를 대응하기로 결정했습니다.
MoyaError 개선
extension MoyaError {
/*
/// Response의 data를 이미지로 맵핑하는 과정에서 발생할 수 있는 에러
case imageMapping(Response)
/// Response의 data를 json 으로 맵핑하는 과정에서 발생할 수 있는 에러
case jsonMapping(Response)
/// Response의 data를 string 으로 맵핑하는 과정에서 발생할 수 있는 에러
case stringMapping(Response)
/// Response의 data를 특정 타입으로 파싱하는 과정에서 발생할 수 있는 에러
case objectMapping(Swift.Error, Response)
/// URLRequest를 만들때 httpBody 를 인코딩하는 과정에서 발생할 수 있는 에러
case encodableMapping(Swift.Error)
/// Response 의 statusCode가 설정한 statusCode에 포함되지 않는다면 발생하는 에러
case statusCode(Response)
/// URLRequest 요청, 수행과정에서 발생할 수 있는 에러
case underlying(Swift.Error, Response?)
/// URLRequest를 만들때 String 을 URL로 변환하는 과정에서 발생할 수 있는 에러
case requestMapping(String)
/// URLRequest를 만들때 parameter 를 인코딩하는 과정에서 발생할 수 있는 에러
case parameterEncoding(Swift.Error)
*/
public func toVO() -> ErrorVO {
switch self {
case .underlying(let error, let response):
if let response = response, response.statusCode == 500 {
return .retryableError("일시적인 서버 에러입니다. 잠시 후 다시 시도해주세요.")
}
// 토큰 재발급 실패
if let afError = error.asAFError, afError.isRequestRetryError {
if case .requestRetryFailed = afError {
if response?.statusCode == 401 {
return .tokenExpired
}
}
}
return .fatalError
default:
return .fatalError
}
}
}
우선 MoyaError -> ErrorVO 로 변환하는 부분은 underlying 만 케이스를 따로 정하여 처리하였습니다.
statusCode가 500 일 경우, 일시적인 서버 에러일 수 있으니 VO의 retryableError 를 던져줍니다.
혹은 토큰 재발급에 실패한 경우, 유저를 로그아웃 시키고 초기 화면으로 이동시켜야 하기 떄문에 VO의 tokenExpired 를 던져줍니다.
그 외의 에러들은 사실상 유저입장에서 해결할 수 없는 에러들이라고 판단하여 모두 fatalError를 던져줬습니다.
개발자 입장에서 디버깅을 위한 debugString 은 다음과 같이 만들었습니다.
public var debugString: String {
switch self {
case .imageMapping(let response):
return makeDebugString(title: "imageMapping", response: response, dataParse: false)
case .jsonMapping(let response):
return makeDebugString(title: "jsonMapping", response: response, dataParse: true)
case .stringMapping(let response):
return makeDebugString(title: "stringMapping", response: response)
case .objectMapping(let error, let response):
return makeDebugString(title: "objectMapping", response: response, error: error)
case .encodableMapping(let error):
return makeDebugString(title: "encodableMapping", error: error)
case .statusCode(let response):
return makeDebugString(title: "statusCode", response: response)
case .underlying(let error, let response):
return makeDebugString(title: "underlying", response: response, error: error)
case .requestMapping(let urlString):
return makeDebugString(title: "requestMapping") + "urlString: \(urlString)"
case .parameterEncoding(let error):
return makeDebugString(title: "parameterEncoding", error: error)
}
}
private func makeDebugString(title: String, response: Response? = nil, error: Error? = nil, dataParse: Bool = true) -> String {
var debugDescription: String = "Empty"
var errorLocalizedDescription: String = "Empty"
var parsedData = "Empty"
if let response = response {
// response 를 우리측 서버의 ErrorMessage 로 변환 시도
do {
let serverErrorMessage = try convertServerErrorMessage(response: response)
return """
⛑️ Moya \(title) Error
\(serverErrorMessage.description)
"""
} catch {
#if DEBUG
print("Failed to Parse ServerErrorMessage \n \(error)")
#endif
debugDescription = response.debugDescription
if dataParse {
parsedData = String(data: response.data, encoding: .utf8) ?? "Empty"
}
}
}
if let error = error {
errorLocalizedDescription = error.localizedDescription
}
return """
⛑️ Moya \(title) Error
statusCode: \(response?.statusCode ?? 0)
debugDescription: \(debugDescription)
errorDescription: \(errorLocalizedDescription)
parsedData: \(parsedData)
"""
}
만약 우리측 서버의 에러 메시지로 파싱되는 에러라면 그대로 파싱해서 출력하도록 하였습니다.
그런데 파싱되지 않는 경우, 최대한 정보를 수집하기 위하여 data 를 string 으로 파싱해서 출력하도록 만들었습니다.
다만 imageMapping 에러의 경우, data 를 string 으로 그대로 파싱한다면, 너무 많은 출력을 할 것 같아서 일단 파싱하지 않도록 했습니다.
참고로 서버의 에러 메시지 구조는 다음과 같습니다.
private struct ServerErrorMessage: Decodable {
let time: String
let status: Int
let message: String
let code: String
let errors: [DetailedErrors]
public var description: String {
var serverErrorResponse = """
🔊 Server Error Response
time: \(time)
status code: \(String(status)) - \(httpStatusDescription)
code: \(code) - \(message)
🧐errors:\n
"""
for error in errors {
serverErrorResponse += """
field: \(error.field)
value: \(error.value ?? "nil")
reason: \(error.reason)
----------------------
"""
}
return serverErrorResponse
}
struct DetailedErrors: Decodable {
let field: String
let value: String?
let reason: String
}
public var httpStatusDescription: String {
switch self.status {
case 200:
return "성공"
case 400:
return "잘못된 요청"
case 401:
return "비인증 상태"
case 403:
return "권한 거부"
case 404:
return "존재하지 않는 요청 리소스"
case 405:
return "API 는 존재하나 Method가 존재하지 않는 경우"
case 500:
return "서버 에러"
default:
return "정의되지 않은 에러"
}
}
}
private func convertServerErrorMessage(response: Response) throws -> ServerErrorMessage {
do {
let serverErrorMessage = try JSONDecoder().decode(ServerErrorMessage.self, from: response.data)
return serverErrorMessage
}
}
실제로 통신을 하는 부분에는 다음과 같이 사용합니다.
func call<Value>(target: Provider) -> AnyPublisher<Value, Error> where Value: Decodable {
return self.requestPublisher(target)
.map(Value.self)
.catch({ moyaError -> Fail in
#if DEBUG
print(moyaError.debugString)
#endif
return Fail(error: moyaError.toVO())
})
.eraseToAnyPublisher()
}
마치며
이렇게 리팩토링을 마치고 다음과 같은 구조를 갖게 되었습니다.
기존 구조의 문제점
- MoyaError -> serverErrorDTO 로 한번 더 맵핑하는 과정이 있다.
- 맵핑이 general 하게 이루어져 있기 때문에 각각의 에러에 대해 정확한 대응을 하지 못하고 있다.
이중에 첫번쨰는 확실하게 없앴고, 두번쨰 문제점은 사실 크게 바뀐 부분은 없었네요.
하지만 추후에 로깅 시스템을 구축할때, 각각의 에러 케이스가 발생하는 상황을 명확히 알고있고 나눠져있기 떄문에 용이하게 활용할 수 있을듯 합니다.
규모로보면 작은 리팩토링이였지만, 많은 고민을 하며 리팩토링을 진행했습니다.
뿌듯하네요~~
끝~
'iOS-Development' 카테고리의 다른 글
클린 아키텍처 - 아키텍처 적용 (0) | 2023.10.01 |
---|---|
Moya 에러 정리 (0) | 2023.09.26 |
Lottie (0) | 2022.05.08 |
CoreData (0) | 2022.04.24 |
UserDefaults (0) | 2022.04.19 |