자바스크립트 에러 핸들링 : 신뢰 할 만한 가이드

이 문서는 https://levelup.gitconnected.com/the-definite-guide-to-handling-errors-gracefully-in-javascript-58424d9c60e6 를 번역한 글입니다.

자바스크립트 에러 핸들링 : 신뢰 할 만한 가이드

마지막 글을 보고, 난 에러에 대해 이야기하고 싶었다. 에러는 굿이다. 전엔 그렇게 들었을 것이다. 에러를 처음 봤을때 두려워 한다. 왜냐면, 상처를 입거나, 공개적으로 굴욕감을 느끼기 때문이다. 에러를 당하면, 우리는 실질적으로 어떤것을 하지 않아야하고, 다음에 나은 방법을 배우게 된다.

이 글은 3가지 부분으로 나누어진다. 처음엔 일반적인 에러를 살펴 볼 것이다. 그 후에, backen(node.js+express.js) 에 초첨을 맞추고, 마지막으론 React.js 에서 에러를 다루는 법을 볼 것이다. 나는 지금까지 가장 인기 있기 때문에 프레임워크를 선택했지만, 당신은 새로 알게된 지식을 다른 프레임워크에도 쉽게 적용 할 수 있어야 한다.

전체 샘플 프로젝트는 여기 github에 있습니다.

1. 자바스크립트 에러 와 일반 핸들링

에러가 있는 경우를 제외하고, 스크립트 실행을 멈추고, 자바스크립트에서 throw new Error('something went wrong') 인스턴스를 만든다. 자바스크립트 개발자로 경력을 시작하면, 자기 자신은 그렇게 할 필요느 없지만, 다른 라이브러리에서 보았을 것이다. ReferenceError: js is not defined 또는 유사하게.

The Error Object

Error객체는 우리가 사용 할 수 있는 두가지 속성이 있다. 첫번째 메세지는 Error 생성자(new Error('This is ther message'))에 아규먼트로 메세지를 전달 한다. 당신은 message 속성을 통해 메세지에 접근 할 수 있다.

const myError = new Error('please improve your code')

console.log(myError.message) // please improve your code

두번째로, 중요한 것은 에러 스택 추적 이다. stack 속성을 통해 접근 할 수 있다. 에러 스택은 에러를 발생 시킨 ‘책임’이 어떤 파일인지 기록(콜스택)을 제공한다. 스택은 맨위에 메세지를 포함하고, 가장 최근/독립된 에러 포인트와 가장 바깥 ‘책임있는’ 파일로 이동하는 실제 스택을 따른다.

Error: please improve your code
 at Object.<anonymous> (/Users/gisderdube/Documents/_projects/hacking.nosync/error-handling/src/general.js:1:79)
 at Module._compile (internal/modules/cjs/loader.js:689:30)
 at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
 at Module.load (internal/modules/cjs/loader.js:599:32)
 at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
 at Function.Module._load (internal/modules/cjs/loader.js:530:3)
 at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)
 at startup (internal/bootstrap/node.js:266:19)
 at bootstrapNodeJSCore (internal/bootstrap/node.js:596:3)

Throwing and Handling Errors

이제 에러 인스턴스 혼자로는 아무것도 발생 하지 않는다. (new Error('....')) 는 아무것도 안한다. 에러가 throw 되면, 조금 더 흥미로워진다. 그러면 이전에 말했듯이, 당신이 어떤 프로세스를 처리 하지않는 한 스크립트는 멈추게 된다. 수동으로 에러를 던진다 거나, 라이브러리 또는 런타임(node, browser)에 던져지는 건 중요하지 않습니다. 다양한 시나리오에서 에러를 어떻게 다루는 지 살펴 보자.

try … catch

이것은 단순하지만, 종종 에러를 처리하는데 잊어버리곤 한다. 요증엔 감사하게도 async/await 때문에 다시 많이 사용되고 있다. 아래 참조

const a = 5

try {
    console.log(b) // b is not defined, so throws an error
} catch (err) {
    console.error(err) // will log the error with the error stack
}

console.log(a) // still gets executed

만약 console.log(b) 를 try … catch 블록으로 감싸지 않았다면, 스크립트 실행은 멈추었을 것이다.

…finally

때때로 에러이든 말든 코드를 실행해야 할 필요가 있다. 마지막 세번째에서 옵션으로 finally 를 사용 할 수 있다. 종종 try...catch문 뒤에 줄이 있는거 같지만, 때때로 유용하게 사용 할 수 있다.

const a = 5

try {
    console.log(b) // b is not defined, so throws an error
} catch (err) {
    console.error(err) // will log the error with the error stack
} finally {
    console.log(a) // will always get executed
}

Enter asynchronity - Callbacks

비동기는 자바스크립트 작업 할 때 항상 고려해야 할 하나의 토픽입니다. 비동기 함수를 가지고 있고, 그 함수 내에 에러가 발생 할 경우, 스크립트는 계속 실행이 되고, 즉시 오류가 발생하지 않는다. 콜백함수로 비동기 함수를 핸들링 할 때(이 방법을 추천하지 않는다.), 콜백 함수에 두 파라미터를 다음 과 같이 받는다.

myAsyncFunc(someInput, (err, result) => {
    if(err) return console.error(err) // we will see later what to do with the error object.
    console.log(result)
})

위에서 만약 에러 라면, err 파라미터는 에러와 같을 것이고, 아니라면 undefined 또는 null 일 것이다. if 블록문에서 어떤 것을 반환 하거나, else 블록에서 다른 명령문을 랩핑하는 것이 중요하다. 그렇지 않으면, 다른 에러가 발생 할 수 있다. 결과는 정의되지 않을 것이고, result.data 또는 유사한 것에 접근을 시도 할 것이다.

Asynchronity — Promises

비동기를 다루는 가장 좋은 방법은 promises 를 사용하는 거다. 게다가 읽기 좋은 코드와 에러 처리를 개선 할 수 있다. catch 블록을 가지고 있는 한 우리는 에러 캐치를 더이상 신경 쓸 필요가 없어진다. promises가 체이닝 될 때, 마지막 catch 블록 또는 promises 실행 이후에 모든 에러를 캐치 한다. catch 블록이 없는 promises는 스크립트를 끝내지 않지만, 다음과 같은 읽기 어려운 메세지를 제공한다.

(node:7741) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: something went wrong
(node:7741) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. */

그러므로, promises에 catch 블록을 항상 추가하자. 다음과 보자.

Promise.resolve(1)
    .then(res => {
        console.log(res) // 1

        throw new Error('something went wrong')

        return Promise.resolve(2)
    })
    .then(res => {
        console.log(res) // will not get executed
    })
    .catch(err => {
        console.error(err) // we will see what to do with it later
        return Promise.resolve(3)
    })
    .then(res => {
        console.log(res) // 3
    })
    .catch(err => {
        // in case in the previous block occurs another error
        console.error(err)
    })

try …catch - again

자바스크립트 async/await 를 소개 했을때, try ..catch ..finally 로 에러를 처리하는 원래 방법으로 돌아 가자.

;(async function() {
    try {
        await someFuncThatThrowsAnError()
    } catch (err) {
        console.error(err) // we will make sense of that later
    }

    console.log('Easy!') // will get executed
})()

일반적인 동기 오류를 처리 하는 방식과 같아서, 넓은 범위의 에러를 캐치 하는 것이 쉽다.

2. 서버 에러 생성 및 처리

이제 에러를 다룰 수 있는 도구를 가지고 있으니, 실제 상황에서 실제 어떻게 처리하느지 봐보자. 백엔드에서 에러를 생성하고, 처리하는 것은 어플리케이션에서 중요한 부분이다. 에러를 처리하는 방법은 다양하다. 프론트엔드나 API 사용자에게 쉽게 전달 할 수 있는 커스텀 에러 생성자와 에러 코드에 접근하는 법을 보여주겠다. 백엔드를 디테일하게 구조화 하는 방법은 중요하지 않다. 아이디어는 동일하게 유지된다.

우리는 라우팅 프레임워크로 Express.js 를 사용한다. 효과적으로 에러 처리 하는 법에 대해서 생각해보자. 우리가 원하는 것:

  1. 일반적인 에러 처리와 몇몇 종류의 fallback은 기본적으로 다음과 같이 말한다.: ‘Something went wrong, please try again or contact us’. 이는 특별히 영리하지 못하지만, 적어도 무한로딩이나 비슷한 어떤 잘못에 대해 사용자에게 알려준다.
  2. 사용자에게 어떤것을 고쳐야하고, 무엇이 잘못됬는지 관한 디테일한 정보를 주기 위한 특별한 에러처리 예: “there is some information missing, the entry already exists in the database” 등등

커스텀 에러 생성자 만들기

우린 기존 에러 생성자를 사용하여 확장 할 것이다. 자바스크립트 상속은 위험한 것이지만, 이 경우, 매우 유용한 경험이였다. 왜 필요할까? 우린 좋은 디버깅 환경을 가지기 위해서 stack trace 가 여전히 필요하다. 네이티브 자바스크립트 에러 생성자를 확장하면 자유롭게 stack trace 를 얻을 수 있다. 오로지 add 를 추가하고, err.code 를 통해서 나중에 접근 할 수 있고, 상태(http status code)를 프론트엔드에 전달 할 수 있다.

class CustomError extends Error {
    constructor(code = 'GENERIC', status = 500, ...params) {
        super(...params)

        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, CustomError)
        }

        this.code = code
        this.status = status
    }
}

module.exports = CustomError

라우팅 처리 방법

우리의 커스텀 에러 를 사용할 준비가 됬다면, 라우팅 구조를 세팅 해야 한다. 내가 얘기했듯이, 모든 라우팅의 에러 처리를 위한 단일 포인트가 필요하다. 행동에 대한 같은 에러 처리가 필요하다. 기본적으로, express 는 라우팅을 모두 캡슐화 하기 때문에 이것을 실제로 지원 하지 않는다.

이 이슈를 해결 하기 위해, 라우터 핸들러와 일반 함수로 실제 라우트 로직을 정의하여 구현 할 수 있다. 이렇게 되면, 라우팅 함수(또는 함수 내부)가 에러를 던지면 라우트 핸들러로 반환되어 프론트엔드로 전달 할 수 있다. 백엔드 어디서든 에러가 발생하면, 우린 프론트엔드로 다음과 같은 포맷으로 에러를 전달할 것이다.

{
    error: 'SOME_ERROR_CODE',
    description: 'Something bad happened. Please try again or     contact support.'
}

압도할 자신을 준비해라. 나의 학생들은 항상 내가 이런 말 할때 화를 냈었다.

처음 봤을때 이해하지 못한다고 해도 괜찮다. 일단 사용하고 후에 어떤 의미가 있는지 찾아 보면 될것이다.

이것은 또한 탑-다운 학습방식으로 난 굉장히 좋아 한다.

라우터 핸들러는 다음과 같다:

const express = require('express')
const router = express.Router()
const CustomError = require('../CustomError')

router.use(async (req, res) => {
    try {
        const route = require(`.${req.path}`)[req.method]

        try {
            const result = route(req) // We pass the request to the route function
            res.send(result) // We just send to the client what we get returned from the route function
        } catch (err) {
            /*
            This will be entered, if an error occurs inside the route function.
            */
            if (err instanceof CustomError) {
                /* 
                In case the error has already been handled, we just transform the error 
                to our return object.
                */

                return res.status(err.status).send({
                    error: err.code,
                    description: err.message,
                })
            } else {
                console.error(err) // For debugging reasons

                // It would be an unhandled error, here we can just return our generic error object.
                return res.status(500).send({
                    error: 'GENERIC',
                    description: 'Something went wrong. Please try again or contact support.',
                })
            }
        }
    } catch (err) {
        /* 
        This will be entered, if the require fails, meaning there is either 
        no file with the name of the request path or no exported function 
        with the given request method.
        */
        res.status(404).send({
            error: 'NOT_FOUND',
            description: 'The resource you tried to access does not exist.',
        })
    }
})

module.exports = router

난 코드의 주석을 봤으면 좋겠다. 여기서 설명하는것보다 코드 주석 보는 방법이 더 합리적이다. 이제 실제 라우트 파일이 어떤지 봐봅시다.

const CustomError = require('../CustomError')

const GET = req => {
    // example for success
    return { name: 'Rio de Janeiro' }
}

const POST = req => {
    // example for unhandled error
    throw new Error('Some unexpected error, may also be thrown by a library or the runtime.')
}

const DELETE = req => {
    // example for handled error
    throw new CustomError('CITY_NOT_FOUND', 404, 'The city you are trying to delete could not be found.')
}

const PATCH = req => {
    // example for catching errors and using a CustomError
    try {
        // something bad happens here
        throw new Error('Some internal error')
    } catch (err) {
        console.error(err) // decide what you want to do here

        throw new CustomError(
            'CITY_NOT_EDITABLE',
            400,
            'The city you are trying to edit is not editable.'
        )
    }
}

module.exports = {
    GET,
    POST,
    DELETE,
    PATCH,
}

이 예제에서 난 실제 요청을 하지 않고, 다른 가짜 에러 시나리오를 했다.

그래서 예를 들어, GET /city 는 3째줄, POST /city는 8번째 줄에서 끝난다. 또한 GET /city?startsWith=R과 같은 매개변수도 함께 작동한다. 본질적으로, 다음과 같이 프론트엔드가 받은대로 처리되지 않은 에러를 가지게 되고,

{
    error: 'GENERIC',
    description: 'Something went wrong. Please try again or contact support.'
}

또느 메뉴얼한 CustomError 던질 것이다.

throw new CustomError('MY_CODE', 400, 'Error description')

그렇게 되면,

{
    error: 'MY_CODE',
    description: 'Error description'
}

이제 아름다운 백엔드 설정을 했으므로, 우린 더이상 프론트엔드로 에러 로그가 누출 되지않으며, 유용한 정보가 항상 반환 될 것이다.

github에서 전체 레파지토리를 확인 할 수 있다. 당신의 프로젝트에 자유롭게 사용하고, 필요하게 수정 하여라.

3. 사용자에게 에러 표시

다음 마지막 단계로 프론트엔드에서 에러를 관리하는 것이다. 첫번째 단계에서 설명한 도구로 프론트엔드 로직으로 발생한 에러를 처리하고 싶을 거다. 그러나 백엔드에서 발생한 에러도 표시해야한다. 먼저 어떻게 에러를 표시 하는지 보자. 앞에서 언급했듯이, 우린 React를 사용할 것이다.

Saving Errors in React state

다른 데이터 같이, 에러와 에러 메세지는 변경 될 수 있다. 그러므로, 컴포넌트 state 에 넣어야 한다. 기본적으로 마운트시에, 에러를 리셋 하여, 사용자가 첫 페이지를 봤을 때, 에러가 보이지 않도록 한다.

다음으로, 시각 표현과 일치하는 여러 유형의 에러를 명확하게 해야 한다. 백엔드와 같이 3가지 타입이 있다:

  1. 전역 에러 : 일반적인 에러 중 하나는 백엔드에서 다시 발생하거나 사용자가 로그인 하지 않은 상태 이다.
  2. 백엔드에서 발생하는 특정 에러 : 사용자는 로그인 정보를 백엔드로 보낸다. 백엔드는 암호가 일치 하지 않는다고 응답한다. 이것은 프론트엔드에서 확인 할 수 없어서, 백엔드에서 가져와야 한다.
  3. 프론트엔드 자체에서 특별한 에러: 메일 입력값 유효성 검사

2와 3은 매우 유사하다. 같은 state로 처리 할 수 있다(원한다면). 그러나, 다른 출처를 가지고 있다. 우린 어떻게 작용하는지 코드를 볼 것이다.

난 React’s 네이티브 상태 구현을 사용 할 것이다, 하지만 당신은 MobX 또는 Redux 상태 관리 시스템을 사용 할 수 있다.

Global Errors

보통 이러한 에러는 바깥 쪽 stateful 컴퍼넌트에 저장하고 정적 UI요소를 렌더링 한다. 이는 화면 상단에 빨간 배너이거나 모달, 또는 다른것일 수도 있다. 디자인 구현은 당신에 달렸다.

다음 코드를 보자.

import React, { Component } from 'react'

import GlobalError from './GlobalError'

class Application extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._resetError = this._resetError.bind(this)
        this._setError = this._setError.bind(this)
    }

    render() {
        return (
            <div className="container">
                <GlobalError error={this.state.error} resetError={this._resetError} />
                <h1>Handling Errors</h1>
            </div>
        )
    }

    _resetError() {
        this.setState({ error: '' })
    }

    _setError(newError) {
        this.setState({ error: newError })
    }
}

export default Application

당신이 봤다시피, 우린 Application.js state 에 에러를 가지고 있다. 우린 또한 에러 값을 리셋하고 변경 할 수 있는 메서드를 가지고 있다. 값과 reset 메서드를 GlobalError 컴포넌트로 전달 한다. 그리고 x를 클릭 할 때 재설정하고 컴포넌트에 표시 한다. 자 GlobalError 컴포넌트를 봅시다.

import React, { Component } from 'react'

class GlobalError extends Component {
    render() {
        if (!this.props.error) return null

        return (
            <div
                style=
            >
                {this.props.error}
                &nbsp;
                <i
                    className="material-icons"
                    style=
                    onClick={this.props.resetError}
                >
                    close
                </i>
            </div>
        )
    }
}

export default GlobalError

5번째 줄 보면 알 수 있듯이, 에러가 없으면 어느것도 렌러딩 하지 않는다. 이러면 항상 페이지에 비어있는 빨간 박스가 보이지 않을 것이다. 물론 이 컴포넌트의 동작과 모양을 변경 할 수 있다. 예를 들어 x 를 2초 후에 에러 상태로 설정하는 Timeout 으로 교체 할 수 있다.

이제 당신이 원하는 곳 어디서든 전역 에러 state 를 사용할 준비가 되었다. Application.js 에서 _setsError 를 전달하여 전역 에러를 설정 할 수 있다. 백엔드에서 필드 에러 인 GENERIC 와 함께 되돌아 왔을때, 예를 들어:

import React, { Component } from 'react'
import axios from 'axios'

class GenericErrorReq extends Component {
    constructor(props) {
        super(props)

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            <div>
                <button onClick={this._callBackend}>Click me to call the backend</button>
            </div>
        )
    }

    _callBackend() {
        axios
            .post('/api/city')
            .then(result => {
                // do something with it, if the request is successful
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                }
            })
    }
}

export default GenericErrorReq

만약 당신이 게으르다면, 여기서 멈춰도 된다. 심지어 특별한 에러가 있으면, 당신은 전역 에러 상태를 변경하고 페이지 상단에 에러 박스를 표시 할 수 있다. 그러나 난 특별한 에러를 보여주고 어떻게 처리할지 보여줄 것이다. 왜? 첫번째로 에러 처리를 위한 가이드 이기 때문에, 여기서 멈출 수 없다. 두번째로, UX 사용자는 모든 에러를 전역적으로 표시하면 놀랄 것이다.

Handling specific request Errors

전역 에러와 비슷하게, 다른 컴포넌트에 지역 에러 state 도 있을 수 있다. 순서는 다음과 같다.

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            <div>
                <button onClick={this._callBackend}>Delete your city</button>
                <InlineError error={this.state.error} />
            </div>
        )
    }

    _callBackend() {
        this.setState({
            error: '',
        })

        axios
            .delete('/api/city')
            .then(result => {
                // do something with it, if the request is successful
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                } else {
                    this.setState({
                        error: err.response.data.description,
                    })
                }
            })
    }
}

export default SpecificErrorRequest

한가지 기억할 것은 에러를 지우는데 일반적인 다른 트리거가 있다. 에러를 제거 하는데 x를 가지고 있는 건 말이 안된다. 새로운 요청이 생길 때, 에러를 삭제하는게 더 좋다. 또한 입력값이 변경 하는 것 같이 사용자가 변경을 했을 때, 에러를 삭제해야 한다.

Frontend origin errors

앞에서 언급했듯이, 이런 에러는 특정 에러와 같은 방법으로 처리 할 수 있다. 자 입력 필드와 함께 예제를 사용해보자. 그리고 실제로 입력이 될 때, city 를 삭제 할 수 있게 해보자.

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
            city: '',
        }

        this._callBackend = this._callBackend.bind(this)
        this._changeCity = this._changeCity.bind(this)
    }

    render() {
        return (
            <div>
                <input
                    type="text"
                    value={this.state.city}
                    style=
                    onChange={this._changeCity}
                />
                <button onClick={this._callBackend}>Delete your city</button>
                <InlineError error={this.state.error} />
            </div>
        )
    }

    _changeCity(e) {
        this.setState({
            error: '',
            city: e.target.value,
        })
    }

    _validate() {
        if (!this.state.city.length) throw new Error('Please provide a city name.')
    }

    _callBackend() {
        this.setState({
            error: '',
        })

        try {
            this._validate()
        } catch (err) {
            return this.setState({ error: err.message })
        }

        axios
            .delete('/api/city')
            .then(result => {
                // do something with it, if the request is successful
            })
            .catch(err => {
                if (err.response.data.error === 'GENERIC') {
                    this.props.setError(err.response.data.description)
                } else {
                    this.setState({
                        error: err.response.data.description,
                    })
                }
            })
    }
}

export default SpecificErrorRequest

Error internationalisation using the Error code

아마도 당신은 왜 에러 코드를 가지고 있는지 궁금해 할 것이다. 예를 들어 GENERIC 같이, 백엔드에서 전달 된 에러 설명을 표시하고 있었다. 이제 앱이 성장하고, 새로운 시장을 정복하고, 다양한 언어를 제공해야 하는 문제에 직면하게 된다. 이 시점에서 사용자 언에 맞는 올바른 캡션 에러코드를 사용 할 수 있다.

나는 당신이 에러를 처리하는 방법에 대한 인사이트를 얻길 소망한다. 빠르게 입력하고, 빠르게 잊어버려야 한다. 디버깅용으로 필수이지만, 프로덕션 빌드에서는 끝내지 않아야 한다. 이를 막기 위해, 로깅 라이브러리를 추천한다. 과거에 loglevel 을 사용했었으며, 매우 만족했다.

Written on January 1, 2018