Throw Custom Exception to Axios from Spring

4 분 소요


0. 들어가면서

사용자에게 시스템 상황을 쉽게 피드백하는 방법은 화면에서 팝업 창(popup window)을 띄우는 방법입니다. 프론트엔드(frontend)에서 판단할 수 없는 상황은 백엔드(backend)로부터 피드백을 받아야 합니다. 업무적으로 정상적인 경우가 아니라면 에러를 통해 피드백을 받는 것이 if-else 구문의 사용을 줄이므로 코드의 복잡성을 낮출 수 있습니다. 이번 포스트에선 스프링(spring) 어플리케이션에서 axios 모듈로 커스텀 예외 메시지를 전달하는 방법에 대해 정리하였습니다.

1. Simple Page

간단한 API 요청과 에러 응답에 대한 메시지를 출력하는 페이지입니다.

  • 제출(submit) 버튼을 누르면 axios를 통해 백엔드 서비스로 API 요청이 수행됩니다.
  • 서버에 문제가 발생하면 에러 응답을 받고, 메시지를 팝업 창으로 출력합니다.
import "./App.css";
import { Fragment, useState } from "react";
import ReactDOM from "react-dom";
import axios from "axios";

axios.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    const { data } = error.response;
    return Promise.reject(data);
  }
);

const Modal = (props) => {
  return (
    props.open && (
      <Fragment>
        {ReactDOM.createPortal(
          <div className="dim" />,
          document.getElementById("dim")
        )}
        {ReactDOM.createPortal(
          <div className="modal">{props.children}</div>,
          document.getElementById("modal")
        )}
      </Fragment>
    )
  );
};

function App() {
  const [openModal, setOpenModal] = useState(false);
  const [message, setMessage] = useState("");

  const requestHandler = () => {
    axios.get("/some-request").catch((error) => {
      setOpenModal(true);
      setMessage(`[${error.code}] ${error.message}`);
    });
  };

  const closeModal = () => {
    setOpenModal(false);
  };

  return (
    <div className="App">
      <Modal open={openModal}>
        <h2>{message}</h2>
        <button onClick={closeModal}>OK</button>
      </Modal>
      <h2>Welcome To Sample Page</h2>
      <button onClick={requestHandler}>Submit</button>
    </div>
  );
}

export default App;

2. Backend Service

이번엔 스프링 어플리케이션의 코드를 살펴보겠습니다.

2.1. FooController 클래스

  • 해당 컨트롤러는 요청 받는 모든 응답에 대해 의도적으로 예외를 발생시킵니다.
  • “This is intentional exception.” 메시지와 함께 예외를 상위 콜스택으로 전달합니다.
package action.in.blog.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class FooController {

    private boolean isIntentionalException() {
        return true;
    }

    @GetMapping("/some-request")
    public String someRequest() {
        if (isIntentionalException()) {
            throw new RuntimeException("This is intentional exception.");
        }
        return "Hello World";
    }
}

2.2. GlobalExceptionHandler 클래스

  • @ControllerAdvice 애너테이션을 사용해 컨트롤러들의 예외를 처리할 수 있는 빈(bean)을 생성합니다.
  • @ExceptionHandler 애너테이션을 사용해 지정한 예외들을 핸들링 할 수 있습니다.
    • 이번 예제에선 RuntimeException 예외를 명시적으로 핸들링합니다.
  • @ResponseStatus 애너테이션을 사용해 HTTP 응답 상태를 정의합니다.
    • 이번 예제에선 INTERNAL_SERVER_ERROR(500) 상태로 정의합니다.
  • @ResponseBody 애너테이션을 사용해 처리한 예외를 에러의 응답 데이터로 반환합니다.
    • 별도로 정의한 ErrorResponse 클래스를 사용합니다.
    • timestamp - 예외가 발생한 시간
    • message - 에러 메시지
    • code - 비즈니스적으로 정의한 에러 코드
    • status - 서버 에러 상태
package action.in.blog.handler;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.time.LocalDateTime;

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
class ErrorResponse {
    private final LocalDateTime timestamp = LocalDateTime.now();
    private String message;
    private String code;
    private int status;
}

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ResponseBody
    protected ErrorResponse handleRuntimeException(RuntimeException e) {
        return ErrorResponse.builder()
                .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                .code("300010")
                .message(e.getMessage())
                .build();
    }
}

3. Axios Interceptor

3.1. AxiosError 객체

axios 모듈은 백엔드 서비스로부터 전달 받은 에러 응답을 별도의 에러 객체로 감싸고 있습니다.

  • 로그 출력을 통해 에러를 출력하면 다음과 같은 내용을 확인할 수 있습니다.
  • 백엔드 서비스로부터 전달받은 에러는 response 객체의 내부애 data 객체에 저장됩니다.
{
  "stack": "AxiosError@http://localhost:3000/static/js/bundle.js:41223:18\nsettle@http://localhost:3000/static/js/bundle.js:41861:12\nonloadend@http://localhost:3000/static/js/bundle.js:40569:66\nEventHandlerNonNull*dispatchXhrRequest@http://localhost:3000/static/js/bundle.js:40582:7\n./node_modules/axios/lib/adapters/xhr.js/__WEBPACK_DEFAULT_EXPORT__<@http://localhost:3000/static/js/bundle.js:40524:10\ndispatchRequest@http://localhost:3000/static/js/bundle.js:41690:10\nrequest@http://localhost:3000/static/js/bundle.js:41140:77\n./node_modules/axios/lib/core/Axios.js/forEachMethodNoData/Axios.prototype[method]@http://localhost:3000/static/js/bundle.js:41162:17\nwrap@http://localhost:3000/static/js/bundle.js:42279:15\nrequestHandler@http://localhost:3000/static/js/bundle.js:68:51\ncallCallback@http://localhost:3000/static/js/bundle.js:10846:18\ninvokeGuardedCallbackDev@http://localhost:3000/static/js/bundle.js:10890:20\ninvokeGuardedCallback@http://localhost:3000/static/js/bundle.js:10947:35\ninvokeGuardedCallbackAndCatchFirstError@http://localhost:3000/static/js/bundle.js:10961:29\nexecuteDispatch@http://localhost:3000/static/js/bundle.js:15105:46\nprocessDispatchQueueItemsInOrder@http://localhost:3000/static/js/bundle.js:15131:26\nprocessDispatchQueue@http://localhost:3000/static/js/bundle.js:15142:41\ndispatchEventsForPlugins@http://localhost:3000/static/js/bundle.js:15151:27\n./node_modules/react-dom/cjs/react-dom.development.js/dispatchEventForPluginEventSystem/<@http://localhost:3000/static/js/bundle.js:15311:16\nbatchedUpdates$1@http://localhost:3000/static/js/bundle.js:29703:16\nbatchedUpdates@http://localhost:3000/static/js/bundle.js:10694:16\ndispatchEventForPluginEventSystem@http://localhost:3000/static/js/bundle.js:15310:21\ndispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay@http://localhost:3000/static/js/bundle.js:12816:42\ndispatchEvent@http://localhost:3000/static/js/bundle.js:12810:88\ndispatchDiscreteEvent@http://localhost:3000/static/js/bundle.js:12787:22\nEventListener.handleEvent*addEventBubbleListener@http://localhost:3000/static/js/bundle.js:13009:14\naddTrappedEventListener@http://localhost:3000/static/js/bundle.js:15233:33\nlistenToNativeEvent@http://localhost:3000/static/js/bundle.js:15177:30\n./node_modules/react-dom/cjs/react-dom.development.js/listenToAllSupportedEvents/<@http://localhost:3000/static/js/bundle.js:15188:34\nlistenToAllSupportedEvents@http://localhost:3000/static/js/bundle.js:15183:25\ncreateRoot@http://localhost:3000/static/js/bundle.js:32466:33\ncreateRoot$1@http://localhost:3000/static/js/bundle.js:32812:14\n./node_modules/react-dom/client.js/exports.createRoot@http://localhost:3000/static/js/bundle.js:32888:16\n./src/index.js@http://localhost:3000/static/js/bundle.js:183:60\noptions.factory@http://localhost:3000/static/js/bundle.js:44567:31\n__webpack_require__@http://localhost:3000/static/js/bundle.js:43991:33\n@http://localhost:3000/static/js/bundle.js:45213:56\n@http://localhost:3000/static/js/bundle.js:45215:12\n",
  "message": "Request failed with status code 500",
  "name": "AxiosError",
  "code": "ERR_BAD_RESPONSE",
  "config": {
    "transitional": {
      "silentJSONParsing": true,
      "forcedJSONParsing": true,
      "clarifyTimeoutError": false
    },
    "adapter": [
      "xhr",
      "http"
    ],
    "transformRequest": [
      null
    ],
    "transformResponse": [
      null
    ],
    "timeout": 0,
    "xsrfCookieName": "XSRF-TOKEN",
    "xsrfHeaderName": "X-XSRF-TOKEN",
    "maxContentLength": -1,
    "maxBodyLength": -1,
    "env": {},
    "headers": {
      "Accept": "application/json, text/plain, */*",
      "Content-Type": null
    },
    "method": "get",
    "url": "/some-request"
  },
  "request": {},
  "response": {
    "data": {
      "timestamp": "2023-01-12T06:52:47.914317",
      "message": "This is intentional exception.",
      "code": "300010",
      "status": 500
    },
    "status": 500,
    "statusText": "Internal Server Error",
    "headers": {
      "access-control-allow-headers": "*",
      "access-control-allow-methods": "*",
      "access-control-allow-origin": "*",
      "connection": "close",
      "content-encoding": "gzip",
      "content-type": "application/json",
      "date": "Wed, 11 Jan 2023 21:52:47 GMT",
      "transfer-encoding": "chunked",
      "vary": "Accept-Encoding",
      "x-powered-by": "Express"
    },
    "config": {
      "transitional": {
        "silentJSONParsing": true,
        "forcedJSONParsing": true,
        "clarifyTimeoutError": false
      },
      "adapter": [
        "xhr",
        "http"
      ],
      "transformRequest": [
        null
      ],
      "transformResponse": [
        null
      ],
      "timeout": 0,
      "xsrfCookieName": "XSRF-TOKEN",
      "xsrfHeaderName": "X-XSRF-TOKEN",
      "maxContentLength": -1,
      "maxBodyLength": -1,
      "env": {},
      "headers": {
        "Accept": "application/json, text/plain, */*",
        "Content-Type": null
      },
      "method": "get",
      "url": "/some-request"
    },
    "request": {}
  }
}

3.2. Axios Interceptor Error Handling

백엔드 서비스로부터 전달 받은 에러를 AxiosError 객체로부터 구조 분해 할당(destructuring)해야 합니다. axios 모듈의 인터셉터(interceptor)를 사용하면 처리한 내용을 어플리케이션 전역에 쉽게 적용 가능합니다.

axios.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    const { data } = error.response;
    return Promise.reject(data);
  }
);
에러 핸들링 결과

4. Why do we handle exceptions in one point?

코드 곳곳에서 try-catch 블럭을 만드는 것은 가독성을 나쁘게 만듭니다. 예외를 상위 메소드에게 예외 처리를 위임하는 것도 코드를 복잡하게 만듭니다. @ControllerAdvice, @ExceptionHandler 애너테이션을 사용해 예외를 한 곳에서 처리하는 컴포넌트를 만들면 다음과 같은 점이 개선됩니다.

  • 각 컴포넌트들은 자신이 맡은 비즈니스에 집중할 수 있습니다.
  • 코드 흐름에 문제가 발생하는 경우 이를 과감하게 상위로 던질 수 있습니다.
    • 코드 흐름 상 문제가 있는 경우 이를 계속 진행해서는 안 됩니다.
    • 문제가 있는 상태로 프로세스를 계속 진행하면 데이터가 오염되거나 찾기 힘든 버그를 만들 수 있습니다.
    • 코드 흐름을 중지하고, 예외를 던지거나 적절한 값을 반환합니다.
  • 서비스에서 발생하는 예외들을 모아서 확인할 수 있고, 비즈니스에 맞는 적절한 에러 메시지, 코드 등을 응답할 수 있습니다.
  • 에러 메시지 포맷을 일정하게 만들 수 있습니다.
    • 이는 클라이언트(client) 입장에서 에러 처리를 간편하게 할 수 있게 만듭니다.

TEST CODE REPOSITORY

REFERENCE

댓글남기기