Next.js에서 Spring 서버와 통신하기(w. Server Actions)

김 주현·2024년 5월 26일

공시락 Gongsilock

목록 보기
5/6

React에서는 API와 통신하기 위해서는 컴포넌트나 Hook 안에서 요청을 해주면 됐었는데, Next.js는 풀스택 프레임워크라서 그런지 핸들링하는 방법이 여러 개가 있더라구요. Route Handler라든지 Server Action이라든지. 주로 서버와 관련된 내용이라 정리를 해보려고 포스팅을 해보려고 해요.


RSC와 RCC 중 어디에서 통신?

Next.js에서는 컴포넌트가 서버 컴포넌트와 클라이언트 컴포넌트로 나뉘죠. 서버 컴포넌트는 서버에서 렌더링이 되고, 클라이언트 컴포넌트는 브라우저에서 렌더링이 되는 형식입니다.

그렇기 때문에 서버 컴포넌트에서 통신을 요청하면 서버'가' 요청하는 형태가 되고, 클라이언트 컴포넌트에서 요청하면 '브라우저'가 요청하는 형태가 됩니다. 이는 생각보다 큰 차이를 만들어내는데요.

RSC에서 통신

먼저 서버가 요청을 만들어 내면 유저의 브라우저는 해당 요청에 대한 정보를 알 수 없습니다. 유저가 정보를 알 수 없으니 보안이 강해지게 되구요. 따라서 외부에 노출이 되면 안 되는 중요한 정보는 서버에서 주고 받기도 합니다.

이 방식은 BFF의 동작 흐름과 비슷합니다. 유저가 요청을 하면 BFF가 받아서 외부 API 서버에서 받아와 다시 가공을 하고 유저에게 반환하는 흐름 말이죠. 마찬가지로 Next.js도 BFF로서 동작할 수도 있습니다.

RCC에서 통신

기존 React에서 통신한 것과 동일하게 통신합니다. 통신을 하면 네트워크 상에서 해당 요청에 대한 정보를 확인할 수 있습니다. 통신에 대한 부담을 클라이언트가 지게 합니다.

저는 무엇보다도 스프링 서버의 URL을 드러내고 싶지 않았기에 RSC 쪽에서 처리하고자 했습니다.


Route Handler와 Server Actions

RSC에서 처리하고자 하면 또 선택을 해야 하는 게 Route Handler를 통해 API를 호출할 것인지, Server Actions을 통해 API를 호출할 것인지를 결정해야 합니다...만, 스프링 서버와 소통하는 상황이라면 Route Handler는 굳이 선택할 필요가 없었습니다.

만약 직접 DB에 접속하고 처리하는 서버였다면 Route Handler를 작성하는 것이 서비스 로직과 API 로직을 분리하게 되어 더 구조화된 API를 구현할 수 있겠었으나, 이미 스프링 서버가 있는 상황에서 Route Handler를 작성하는 건 경로를 두 번 작성하는 듯한 불편함을 느꼈습니다.

그렇다고 frontend 경로로서 새로운 API Endpoint를 만들어 접속하자니 굳이? 라는 생각이 들더라구요. 예를 들어 /auth/login(Spring) 이라는 경로라면 /login으로 접속하게 만드는 것이 새로운 비용이 들어가기도 하고 나중에 헷갈릴 것 같아 선택하지 않았습니다.

결론으로, 라우팅 경로를 신경쓰지 않는 Server Action이 현재 상황에 더 맞다고 판단했어요.


HTTP Client 고민: fetch와 axios

어떤 방식으로 서버 사이드 처리를 할 것인지는 정했으니, 이제 HTTP 클라이언트를 결정해야 합니다. 저는 처음에는 Next.js에서 제공하는 fetch()를 사용하고자 했습니다.

fetch()는 Web API의 fetch()를 확장하여 제공하며, 각 요청마다 캐싱 정책을 설정할 수 있어서 데이터를 가져올 때 캐싱을 효과적으로 활용할 수 있기 때문입니다.

그러나,, Next.js에서 fetch()를 확장했다고 한들, 저한테는 다음과 같은 문제가 있었습니다.

보일러 플레이트 문제

에러 처리 및 헤더 관리에 많은 보일러 플레이트 코드가 필요했습니다. 네트워크 에러(timeout, cors, disconnect, etc...)만 throw를 던지기 때문에, 성공 여부를 확인하기 위해 별도의 상태 체크가 필요했어요.

상태를 체크하면서 보다 더 편하게 만들기 위해 성공 응답, 실패 응답, 네트워크 에러 응답, 알 수 없는 응답 등으로 구분하다 보니 기본 Config과 Type이 늘어났습니다.

Type을 만드는 것은 어려운 일이 아니지만, 수많은 Type을 보니 이럴 거면 그냥 Axios를 쓰지(..)하는 마음이 들어서😇

axios의 장점

하지만, axios는 200 이외의 상태 코드를 자동으로 throw로 던져주어 관리가 편했습니다. 또한, axios는 다양한 에러 핸들링 기능을 제공하여 개발자의 편의성을 높였어요. 제일 마음에 드는 건 타입과 관련해서 이미 여러 경우에 대한 타입을 지정해두었다는 것이었습니다. 벗어난 Type 설계 지옥 ... !

이런 이유로 axios를 선택했어요.


서버에서 axios 사용 시 고려 사항

HTTP Client으로 axios를 사용하기로 했습니다. 이 axios를 서버에서 사용하기 위해서는 다음과 같은 사실을 알아두어야 합니다: 서버에서는 각 요청이 독립적이며 상태를 유지할 필요가 없습니다.

이건 HTTP는 Stateless하다는 것과 이어지는 맥락인데요, 서버는 특정 한 유저만 사용하는 것이 아니라 여러 사용자가 접속하는 상황입니다. 그렇다면 A사용자와 B사용자가 같은 Axios Instance를 사용한다면 어떻게 될까요? 무서운 경우엔 B사용자가 A사용자의 정보를 얻게 될 수도...

때문에, 저는 요청 때마다 Axios Instance를 생성해주고 cookies와 headers를 통해 각 클라이언트가 가지고 있는 정보를 넣어주는 방식으로 접근했습니다. 이게 맞는 방법인진 모르겠지만 ,, 적어도 위의 상황은 피할 수 있다고 생각합니다.

물론 매 요청마다 인스턴스를 생성하는 방식이 서버 입장으로서 오버헤드가 많이 걸릴 수도 있을 것 같습니다만,, 요건 나중에 테스트 해보고 따로 글을 작성해볼게요. 지금 생각해보니 인스턴스는 그대로 가되 Config만 바꿔서 전송해주면 괜찮을 것 같기도 하고 ...

어쨌든, 포인트는 요청 때마다 클라이언트가 가지고 있는 cookies와 headers를 넣어주는 것입니다. 모든 요청에 같은 로직을 넣기엔 번거로우니, 요청을 담당하는 녀석을 만들어줄게요.

requestAPI 작성과 에러 처리

requestAPI

export const requestAPI = async <T, U = undefined>(
  method: HTTPMethod,
  url: string,
  body?: U,
  config?: AxiosRequestConfig
) => {
  console.log(`[${method}] ${url}`);

  /** Server Axios \*/
  const serverAxios = createServerAxios();

  /** Browser Cookies \*/
  const allCookies = cookies()
    .getAll()
    .map((cookie) => `${cookie.name}=${cookie.value}`)
    .join(';');

  const axiosConfig: AxiosRequestConfig = {
    ...config,
    withCredentials: true,
    headers: {
      ...config?.headers,
      Cookie: allCookies,
    },
  };

  try {
    let response = null;

    if (method === HTTPMethod.GET) {
      response = await serverAxios.get<T, AxiosResponse<T\>, U>(url, axiosConfig);
    } else if (method === HTTPMethod.POST) {
      response = await serverAxios.post<T, AxiosResponse<T\>, U>(url, body, axiosConfig);
    } else if (method === HTTPMethod.PATCH) {
      response = await serverAxios.patch<T, AxiosResponse<T\>, U>(url, body, axiosConfig);
    } else {
      response = await serverAxios.put<T, AxiosResponse<T\>, U>(url, body, axiosConfig);
    }

    /** 성공 응답 시 아래의 로직 진행 \*/

    console.log(`[${method}] ${url} >> ${response.status}`);

    const setCookies = response.headers['set-cookie'];

    /** 응답에 Set-Cookie가 있다면 cookies()에 저장 \*/
    if (setCookies) {
      setCookies.map((cookie) => {
        const [key, ...values] = cookie.split('=');
        cookies().set(key, values.join(' '));
      });
    }

    const suscessResponse: FetchSuccessResponse<T\> = {
      status: FetchStatus.SUCCESS,
      data: response.data,
    };

    return suscessResponse;
  } catch (error) {
    /** 실패 응답 시 아래 로직 진행 \*/

    if (isAxiosError<T, U>(error)) {
      const hasResponse = error.response !== undefined;

      console.log(
        `[${method}] ${url} >> ${error.response?.status} ${error.response?.statusText} ${
          typeof error.response?.data === 'string'
            ? error.response?.data
            : typeof error.response?.data === 'object'
            ? JSON.stringify(error.response?.data)
            : null
        }`
      );

      // API 오류
      if (hasResponse) {
        const errorResponse: FetchFailResponse = {
          status: FetchStatus.FAIL,
          data: error.response!.data as ErrorObject,
        };

        return errorResponse;
      }

      const hasRequest = error.request !== undefined;

      // 네트워크 오류(disconnected, timeout, cors, etc...)
      if (hasRequest) {
        const errorObject = new Error(error.message);
        errorObject.name = error.name;

        const errorResponse: FetchNetworkErrorResponse = {
          status: FetchStatus.NETWORK_ERROR,
          data: errorObject,
        };

        return errorResponse;
      }

      // 있을 수 없는 오류(진)
      const unknownErrorResponse: FetchUnknownErrorResponse = {
        status: FetchStatus.UNKNOWN_ERROR,
        data: String(error),
      };

      return unknownErrorResponse;
    }

    // 있을 수 없는 오류(진)
    const unknownErrorResponse: FetchUnknownErrorResponse = {
      status: FetchStatus.UNKNOWN_ERROR,
      data: String(error),
    };

    return unknownErrorResponse;
  }
};

크게 다음과 같은 로직을 처리하고 있어요.

requestAPI() 흐름

  1. 현재 요청한 유저의 쿠키 획득 후 Config 설정
  2. HTTPMethod에 맞게 Config와 함께 Axios 요청
  3. 성공 응답 시 헤더에 Set-Cookie 헤더 존재 유무 확인
    3-1. 존재 시 cookies()에 저장
    3-2. 마무리 후 성공 응답
  4. 실패 응답 시 실패 타입 확인
    4-1. API 응답(실패) 시 FailResponse 반환
    4-2. 네트워크 오류 시 NetworkErrorResponse 반환
    4-3. 알 수 없는 오류 시 UnknownErrorResponse 반환

모든 에러는 throw가 아닌 safey하게 처리가 되어서 올라가게 되고, requestAPI 호출부에서 해당 내용을 가지고 throw 던져 Error Boundary로 옮기거나, 별도의 에러 핸들링을 하도록 만들었습니다.

중간중간 helper function으로 분리할 수 있는 여지가 보이지만, 언젠가... 나중에... 추후에...


Server Action 작성

loginService Server Action

export const loginService = async (payload: LoginServiceRequest) => {
  return await requestAPI<null, LoginServiceRequest>(HTTPMethod.POST, `${BASE_API_URL}/signin`, payload);
};

간단하게 로그인을 요청하는 Server Action을 예시로 들어볼게요. requestAPI를 호출하고 결과값을 그대로 반환하는 것을 볼 수 있어요. 만약 Server Action에 대해 공통으로 처리해야 할 로직이 생기면 여기에서 전처리를 해줄 수 있을 것 같아요.

호출부에서 Usage

그러면 호출부에서는 다음과 같이 쓸 수 있어요.

로그인 Usage

    const { status, data } = await loginService(loginValues);

    if (status === FetchStatus.SUCCESS) {
      router.push(redirectUrl);
    }

    if (status === FetchStatus.FAIL) {
      setErrorState(data);
      releasePending();
      return;
    }

    if (status === FetchStatus.NETWORK_ERROR) {
      // TODO: Toast 띄우기
      console.error('[NETWORK_ERROR]: requestLogin, ', data);
      releasePending();
      return;
    }

    if (status === FetchStatus.UNKNOWN_ERROR) {
      // TODO: Toast 띄우기
      console.error('[UNKNWON_ERROR]: requestLogin, ', data);
      releasePending();
      return;
    }

반환된 값 status에 따라 보다 자세하게 성공과 에러 핸들링을 할 수 있어요. 여기에서 throw 던질 수도 있고, 아니면 safey하게 처리할 수도 있겠죠?

또, 이 상태에 따라 처리하는 로직이 모든 서버 액션 호출부에서 쓰이기 때문에, 이것을 한 번 더 구조화 하는 것도 좋을 것 같아요. 어떻게 구조화 할지는 에러 핸들링을 어떻게 할 것인가에 따라 많이 달라질 것 같아서, 이것 역시 나중에 ... 지금 생각나는 건 데코레이터로 가져가도 될 것 같기도 하구요.


오늘도 역시 장황하게 시작했지만 결론은 간단하게 끝난 포스팅. 하지만 이걸 생각하기 위해 이틀을 고민하고 삽질한 나에게 박수...

profile
FE개발자 가보자고🥳

0개의 댓글