[Vapor/Swift] 에러 커스터마이징하기

Ryan (Geonhee) Son·2021년 7월 7일
0

이전 포스팅의 말미에 잠시 언급 드렸듯이 검증 이외에도 클라이언트 요청에 따라 서버에서 작업을 수행하다 에러가 일어나는 경우가 있습니다. 이런 경우에 서버에서 기본적으로 지정된 HTTP status code와 에러 메시지를 response로 반환하게 되지만 이해하기 어려운 경우가 많습니다. 예를 들어 "Something went wrong!", "Bad Request"와 같은 에러 메시지를 받으면 도대체 무엇이 잘못 되었는지 알 수 없는 경우가 많습니다. 그래서 이번 시간에도 이전 포스팅의 순서와 같이 Vapor의 에러와 관련한 내용을 먼저 알아보고 적용하는 모습을 살펴보도록 하겠습니다.

에러

Vapor는 에러 처리를 위해 Swift의 Error 프로토콜을 사용합니다. 라우트(루트) 핸들러는 에러를 throw 하거나 실패한 EventLoopFuture를 반환할 수 있습니다. Swift의 Error를 던지거나 반환하면 500 상태 코드 응답과 함께 에러가 기록되는 결과를 낳습니다. AbortErrorDebuggableError를 통해 응답과 기록을 각각 변경할 수 있습니다. 에러 처리는 ErrorMiddleware에 의해 수행되는데, 이 middleware는 애플리케이션에 기본적으로 추가되어 있고 원할 경우 사용자 지정 로직으로 교체할 수 있습니다.

Abort

Vapor는 Abort라는 이름의 구조체로 기본 에러를 제공합니다. 이 구조체는 AbortErrorDebuggableError 프로토콜을 모두 준수하고 있고, 필요에 따라 HTTP 상태와 failure에 대한 원인을 이니셜라이즈 할 수 있습니다.

// 404 error, default "Not Found" reason used.
throw Abort(.notFound)

// 401 error, custom reason used.
throw Abort(.unauthorized, reason: "Invalid Credentials")

flatMap 클로저와 같이 throwing이 지원되지 않는 비동기 상황에서는 failed future를 반환할 수 있습니다.

guard let user = user else {
    req.eventLoop.makeFailedFuture(Abort(.notFound))    
}
return user.save()

Vapor는 옵셔널 값을 가진 future를 언래핑하기 위한 도우미 익스텐션으로 unwrap(or:) 메서드를 가지고 있습니다.

User.find(id, on: db)
    .unwrap(or: Abort(.notFound))
    .flatMap 
{ user in
    // Non-optional User supplied to closure.
}

만약 User.findnil을 반환한다면, future는 실패하며 지정된 에러의 이유를 보여줄 것입니다. 그렇지 않은 경우에는 flatMap에게 옵셔널이 아닌 값을 제공해줄 것입니다.

Abort Error

기본적으로 라우트(루트) 클로저가 던지거나 반환한 모든 Swift Error500 Internal Server Error 응답으로 처리됩니다. 디버그 모드로 빌드된 경우에는 ErrorMiddleware가 에러 설명을 포함해줍니다. 이는 프로젝트가 release mode로 빌드되면 보안 상의 이유로 완전히 제거됩니다.

특정 에러에 대해 HTTP 응답 상태 또는 원인을 구성하고자 한다면 AbortError를 준수하면 됩니다.

import Vapor

enum MyError {
    case userNotLoggedIn
    case invalidEmail(String)
}

extension MyError: AbortError {
    var reason: String {
        switch self {
        case .userNotLoggedIn:
            return "User is not logged in."
        case .invalidEmail(let email):
            return "Email address is not valid: \(email)."
        }
    }

    var status: HTTPStatus {
        switch self {
        case .userNotLoggedIn:
            return .unauthorized
        case .invalidEmail:
            return .badRequest
        }
    }
}

Skip 하셔도 되는 구간입니다.

Debuggable Error

ErrorMiddleware은 루트에서 던져진 에러를 기록하기 위해 Logger.report(error:) 메서드를 사용합니다. 이 메서드는 읽을 수 있는 메시지를 기록하기 위해 CustomStringConvertibleLocalizedError의 채택 여부를 체크할 것입니다.

에러 기록 작업을 커스터마이징 하기를 원한다면 에러 타입이 DebuggableError을 준수하게 만들면 됩니다. 이 프로토콜은 고유 식별자, 소스 위치, 스택 추적과 같은 유용한 프로퍼티들을 포함하고 있습니다. 대부분의 프로퍼티들은 준수를 용이하게 만들기 위해 선택적으로 사용할 수 있도록 설정되어 있습니다.

DebuggableError를 잘 준수하면

DebuggableError를 가장 잘 준수하려면 에러 타입이 구조체여야 하며 필요한 경우에는 소스 및 스택 추적 정보를 저장할 수 있어야 합니다. 아래는 구조체 및 에러 소스 정보를 캡처하고 사용하기 위해 업데이트된 MyError 열거 타입의 예시입니다.

import Vapor

struct MyError: DebuggableError {
    enum Value {
        case userNotLoggedIn
        case invalidEmail(String)
    }

    var identifier: String {
        switch self.value {
        case .userNotLoggedIn:
            return "userNotLoggedIn"
        case .invalidEmail:
            return "invalidEmail"
        }
    }

    var reason: String {
        switch self.value {
        case .userNotLoggedIn:
            return "User is not logged in."
        case .invalidEmail(let email):
            return "Email address is not valid: \(email)."
        }
    }

    var value: Value
    var source: ErrorSource?

    init(
        _ value: Value,
        file: String = #file,
        function: String = #function,
        line: UInt = #line,
        column: UInt = #column
    ) {
        self.value = value
        self.source = .init(
            file: file,
            function: function,
            line: line,
            column: column
        )
    }
}

DebuggableError는 발생한 에러의 디버그 용이성을 향상시켜줄 수 있는 possibleCausessuggestedFixes와 같은 몇 가지 다른 프로퍼티들을 가지고 있습니다. 더 많은 정보는 프로토콜 자체에 대해 살펴보시면 됩니다.

스택 추적

Vapor에는 정상적인 Swift 오류와 충돌에 대한 스택 추적를 볼 수 있는 기능이 포함되어 있습니다.

Swift Backtrace

Vapor는 SwiftBacktrace 라이브러리를 사용하여 Linux에서 치명적인 에러 또는 assertion이 발생한 후 스택 추적을 제공합니다. 이 기능이 작동하려면 아래와 같이 컴파일 중에 앱에 디버그 기호가 포함되어 있어야 합니다.

swift build -c release -Xswiftc -g

에러 추적

기본적으로 Abort는 이니셜라이즈 되었을 때 현재 스택 추적을 캡처합니다. 사용자 지정 에러 타입들은 DebuggableError을 채택하고 StackTrace.capture()를 가지게 함으로써 이 작업을 수행할 수 있습니다.

import Vapor

struct MyError: DebuggableError {
    var identifier: String
    var reason: String
    var stackTrace: StackTrace?

    init(
        identifier: String,
        reason: String,
        stackTrace: StackTrace? = .capture()
    ) {
        self.identifier = identifier
        self.reason = reason
        self.stackTrace = stackTrace
    }
}

애플리케이션의 log level.debug 이하로 설정되어 잇는 경우 에러 스택 추적은 로그 결과에 포함될 것입니다.

스택 추적은 log level이 .debug 이상이라면 캡처되지 않습니다. 이 행동을 재정의하려면 configure에서 StackTrace.isCaptureEnabled를 수동으로 설정해주세요.

// Always capture stack traces, regardless of log level.
StackTrace.isCaptureEnabled = true

Error Middleware

ErrorMiddleware는 기본적으로 애플리케이션에 추가된 유일한 미들웨어입니다. 이 미들웨어는 루트 핸들러가 던지거나 반환한 Swift 에러들을 HTTP 응답으로 변환해줍니다. 이 미들웨어가 없으면 에러가 발생하여도 응답 없이 연결이 끊어질 것입니다.

AbortErrorDebuggableError가 제공하는 에러처리를 커스터마이징하고 싶다면 ErrorMiddleware를 자체적인 에러 처리 로직으로 교체하면 됩니다. 이렇게 하려면 먼저 app.middleware를 빈 구성으로 설정함으로써 기본 에러 미들웨어를 제거하고, 자체 에러 처리 미들웨어를 애플리케이션의 첫 번째 미들웨어로 추가하면 됩니다.

// Remove all existing middleware.
app.middleware = .init()
// Add custom error handling middleware first.
app.middleware.use(MyErrorMiddleware())

스킵 구간 끝입니다.


적용하기

저는 아래와 같이 크게 헤더의 Content-Type 검사와 검증 실패 시 에러 unwrap(or:) 메서드에서 nil을 발견하였을 때 에러를 정의하여 적용하였습니다.

import Vapor

enum HTTPError {
    case progressNotFoundInURL
    case invalidProgressInURL
    case invalidContentType
    case validationFailedWhileCreating
    case validationFailedWhileUpdating
    case invalidID
}

extension HTTPError: AbortError {
    
    var reason: String {
        switch self {
        case .progressNotFoundInURL:
            return "This path requires progress as path parameter. Please enter \"/todo\", \"/doing\" or \"/done\" additively."
        case .invalidProgressInURL:
            return "Progresses are classified as todo, doing and done. Please check entered URL."
        case .invalidContentType:
            return "The Content-Type of the request is not application/json."
        case .validationFailedWhileCreating:
            return "Validations are failed while creating data. Please check if the content is not exceed length of 1000, progress is in todo, doing and done."
        case .validationFailedWhileUpdating:
            return "Validations are failed while updating data. Please check if the content has id, not to exceed length of 1000, and the progress is in todo, doing and done."
        case .invalidID:
            return "You have entered invalid projectItem ID. The database does not have such item."
        }
    }
    
    var status: HTTPResponseStatus {
        switch self {
        case .progressNotFoundInURL:
            return .notFound
        case .invalidProgressInURL:
            return .notFound
        case .invalidContentType:
            return .badRequest
        case .validationFailedWhileCreating:
            return .badRequest
        case .validationFailedWhileUpdating:
            return .badRequest
        case .invalidID:
            return .notFound
        }
    }
}
func create(req: Request) throws -> EventLoopFuture<ProjectItem> {
    // ⭐️ Header의 Content-Type이 application/json일 것
    guard req.headers.contentType == .json else {
        throw HTTPError.invalidContentType
    }
    
    do {
        try PostProjectItem.validate(content: req)
    } catch {
        // 검증에 대한 커스텀 에러 
        throw HTTPError.validationFailedWhileCreating
    }
    
    let exist = try req.content.decode(PostProjectItem.self)
    let newProjectItem = ProjectItem(exist)
    return newProjectItem.save(on: req.db).map{ (result) -> ProjectItem in
        return newProjectItem
    }
}

func update(req: Request) throws -> EventLoopFuture<ProjectItem> {
    guard req.headers.contentType == .json else {
        throw HTTPError.invalidContentType
    }
    
    do {
        try PatchProjectItem.validate(content: req)
    } catch {
        throw HTTPError.validationFailedWhileUpdating
    }
    
    let exist = try req.content.decode(PatchProjectItem.self)
    
    return ProjectItem.find(exist.id, on: req.db)
        .unwrap(or: HTTPError.invalidID)
        .flatMap { item in
            if let title = exist.title { item.title = title }
            if let content = exist.content { item.content = content }
            if let progress = exist.progress { item.progress = progress }
            if let deadlineDate = exist.deadlineDate { item.deadlineDate = deadlineDate }
            if let index = exist.index { item.index = index }
            
            return item.update(on: req.db).map { return item }
        }
}

func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
    guard req.headers.contentType == .json else {
        throw HTTPError.invalidContentType
    }
    
    let exist = try req.content.decode(DeleteProjectItem.self)
    return ProjectItem.find(exist.id, on: req.db)
        .unwrap(or: HTTPError.invalidID)
        .flatMap { $0.delete(on: req.db) }
        .transform(to: .ok)
}

참고자료

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글