Junhyunny's Devlogs

글 검색

기술 글 제목, 본문, 카테고리에서 검색합니다.

    이전 화면으로

    스프링(Spring)에서 Axios 클라이언트로 커스텀 예외 전달하기

    • spring-boot
    • axios
    이 글의 목차

    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) 입장에서 에러 처리를 간편하게 할 수 있게 만든다.

    KEEP READING

    DISCUSSION

    댓글