nextjs ssr에 aop를 적용해보기

pds·2023년 3월 23일
0

TIL

목록 보기
36/60

nextjs는 정말 어려운 것 같다..

nextjs에서 서버사이드렌더링 적용에서 불필요한 중복 코드 작성 방지를 위해 삽질했던 경험을 기록했다.


현재 상황

추후 SSG와 ISR을 적용해야되는 페이지를 제외하고 모든 페이지에 대해서 jwt토큰을 통해 회원 정보를 조회하는 api를 서버사이드 렌더링으로 가져오려고 의도했다.

import { GetServerSideProps } from 'next';
import { dehydrate } from '@tanstack/react-query';
import nookies from 'nookies';

import { generateQueryClient } from '@/query/queryClient';
import queryKey from '@/query/queryKey';
import userApi from '@/apis/userApi';
import { setContext } from '@/apis/config/instance';

export const getServerSideProps: GetServerSideProps = async (context) => {
  setContext(context);
  const { accessToken } = nookies.get(context);
  if (!accessToken) {
    return {
      props: {
        hasAuth: false,
      },
    };
  }
  const queryClient = generateQueryClient();
  try {
    const member = await queryClient.fetchQuery([queryKey.me], userApi.getMe);
    return {
      props: {
        hasAuth: true,
        accessToken,
        memberId: member.memberId,
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch {
    return {
      props: {
        hasAuth: false,
      },
    };
  }
};

로그인 성공 시 서버사이드에서 생성한 쿠키를 통해 토큰을 가져와 존재한다다면 회원정보를 조회해와 react query queryClient로 fetch하고 클라이언트에서 사용할 수 있도록 dehydrate해서 넘겨주는 형태이다.

어차피 페이지에 처음 접근했을 때 토큰이 있다면 클라이언트 사이드에서 결국 회원정보를 조회해야 되기 때문에 서버사이드에서 가져오게끔 의도했다.

물론 클라이언트 로컬 스토리지에 로그인했던 사용자 이미지나 닉네임 같은 것을 넣어서 회원정보 조회 이전에 useQuery의 initialData에 넣어서 사용하게 해서 깜빡거림을 없앨 수 있겠지만

어차피 개인페이지의 데이터를 서버사이드에서 가져와야 할 일이 있을 것 같아 서버사이드에서 조회해오는 것이 낫지 않나 생각했다.

아무튼 위에서 만든 ssr함수를 페이지에 export하여 필요한 페이지마다 적용하려고 의도했다.

export { getServerSideProps } from '@/libs/getAuthenticatedUserServerSideProps';

이렇게 페이지에 넣어주면 페이지마다 해당 ssr 메소드를 만들 필요 없이 적용되기 때문이다.


발생한 문제

위에서 만든 회원정보 조회 뿐 아니라 다른 개인의 데이터를 서버에서 패치해와야 할 때가 문제였다.

물론 스택오버플로우를 뒤져보니 페이지에 getServerSideProps 메소드를 만든 뒤 내부에서 공용 함수를 패치해오고 부가적인 처리를 하면 된다고 하여 크게 문제될 것은 없어보였기에 위의 공용 서버사이드렌더링 함수를 만들었던 것 같았다. 코드 출처

import serverProps from "../lib/serverProps";

// other stuff...

export async function getServerSideProps(ctx) {
  // do custom page stuff...
  return {
    ...await serverProps(ctx),
    ...{
      // pretend this is what you put inside
      // the return block regularly, e.g.
      props: { junk: 347 }
    }
  };
}

문제는 react-query

또 패치해서 pageProps만 잘 넘기면 되겠거니 했는데 잘 생각해보니 react-query를 사용하기 때문에 발생할 문제가 있었다.

serverSide에서 prefetch 또는 fetch를 통해 쿼리 데이터를 만들고 dehydrate를 통해 이를 클라이언트에 전달해서 사용할 수 있게 한다.

유저정보를 조회해올 때 쓰는 queryClient 와 그 이후 사용자 데이터를 패치할 때 쓰는 queryClient 의 인스턴스가 같아야 dehydrate로 정상적으로 패치한 모든 데이터를 넘길 수 있는 것이다.

dehydrate로 서버에서 얻은 데이터를 클라이언트에 넘기지 못하면 서버사이드렌더링을 하는 의미가 없다.

클라이언트 측에서는 동기화될 초기데이터를 인식하지 못해 결국 처음으로 패치해오는 것으로 되기 때문에 깜빡임이 발생한다.

이미 react query를 쓰는데 그렇다고 pageProps에 직접 조회해온 모든 데이터를 넘겨서 사용하게 하는 것은 비효율적일 것 같았고 어떻게 처리해야하나 고민을 많이 한 것 같았다.


AOP

Aspect-Oriented-Programming
프로그램의 여러 부분에서 반복적으로 발생하는 관심사항을 분리하고 모듈화하여 관리하는 기법

Aspect: 특정한 부가 동작을 추출할 모듈
Pointcut: Aspect를 적용할 대상
Advice: Aspect에 대한 동작

스프링에서 로깅이나 보안처리, 예외처리 때문에 사용해본적 있었고

핵심 비즈니스 로직이 아니면서도 여러 비즈니스 로직에 적용되어야 하는 개념이 있을 때 Aspect를 만들어 비즈니스로직 동작 전후에 처리했었던 기억이 있다.

    @Pointcut("@annotation(com.xxx.aop.WithEmailVerification)")
    public void withEmailVerification() {
    }
    
    @Around("withEmailVerification() && " + "execution(* *(@org.springframework.web.bind.annotation.RequestBody (*), ..)) && " + "args(body, ..)"
    )
    public Object processWithBody(ProceedingJoinPoint joinPoint, Object body) throws Throwable {
        // Advice
        Cookie cookie = cookieUtil.getCookieByCookieName(httpServletRequest, VERIFICATION_COOKIE_NAME);
        checkCookieExists(cookie);
        checkRequestEmailEqualsWithCookie(cookie, body);
        //
        return joinPoint.proceed();
    }    

예를 들어 위의 메소드는 @WithEmailVerfication 어노테이션이 붙은 API에 대해 동작하는 AOP다.

해당 API가 호출되기 전 어떤 부가적인 로직을 수행하는 것이다.


저 방식대로 해보기

부가기능으로 정의해야 될 Advice를 먼저 생각해보았다.

위의 발퀄 그림을 통해 대충 요약해보았다.

어플리케이션의 모든 getServerSideProps는 인증된 회원의 데이터를 사전에 가져오는 것으로 의도했기 때문에

서버 쿠키에서 토큰 존재여부를 검사하고 회원정보 조회까지 해오는 것을 Pointcut의 사전 Advice 동작이라고 보았다.

그 이후 에러 상황에서는 해당 AOP가 동작하는 Pointcut인 getServerSideProps의 응답 형태에 따라 redirect되던가 에러가 리턴되던가 정상적으로 pageProps를 리턴하게 구성했다.

해당 Aspect를 사용하는 클라이언트? 코드인 특정 페이지의 getServerSideProps에서의 토큰 검증이나 예외처리에 대한 책임을 모두 없앴고

해당 동작에서는 무엇을 패치해 무엇을 리턴하고 어떤 상황에 throw할 것이며 어떤 상황에 redirect할 것인지만 결정하여 리턴하면 된다.

function serverSideRenderAuthorizedAspect(
  proceed?: (
    context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>,
    queryClient: QueryClient,
    memberId?: string,
  ) => Promise<object | void>,
): GetServerSideProps {
  return async (context) => {
    setRequest(context.req);
    const { accessToken } = nookies.get(context);
    if (!accessToken) {
      return {
        props: {
          hasAuth: false,
        },
      };
    }
    try {
      const queryClient = generateQueryClient();
      const user = await queryClient.fetchQuery([queryKey.me], userApi.getMe);
      const pointcutProps = proceed && (await proceed(context, queryClient, user.memberId));
      return {
        props: {
          ...pointcutProps,
          hasAuth: true,
          dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
        },
      };
    } catch (error) {
      if (hasErrorRedirection(error)) {
        return {
          redirect: {
            destination: error.url,
            permanent: false,
          },
        };
      }
      return Promise.reject(error);
    }
  };
}

export { serverSideRenderAuthorizedAspect as ssrAspect };

좀 지저분하긴하고 사실 만들고보니 AOP보다는 함수로 한번 감싼 느낌이긴 하지만 그래도 의도된 대로 동작했다.

고백산 형님도 저 함수 보고 AOP개념이 적용된 것 같다고 했으니 암튼 AOP다.

해당 aspect함수에서 serverSideProps를 위한 queryClient 인스턴스를 생성하는 책임까지 들고 있고 이를 pointcut에 넘겨주고 있고 해당되는 곳에서는 사용만 하면 된다.

동작이 모두 끝나면 dehydrate하여 한번에 클라이언트로 넘겨주게 되기 때문에

서버에서 얻은 모든 API 데이터들을 클라이언트에서 동기화하여 사용할 수 있다!

export const getServerSideProps: GetServerSideProps = ssrAspect(async (context, queryClient, memberId) => {
  const { categoryId } = context.query;
  if (!categoryId || typeof categoryId !== 'string') {
    throw { url: '/' };
  }
  await queryClient.prefetchInfiniteQuery([queryKey.cards, memberId], () => cardApi.getMyCardList({ categoryId }), {
    getNextPageParam: (lastPage) => lastPage.hasNext,
  });
  return { memberId, categoryId };
});

적절한 정보를 조회해와 필요한 정보를 포함시켜 throw 하거나 pageProps를 리턴하면 된다.

다만 복잡한 처리가 추가되어야할 때 구성한 AOP함수에 여러 조건이나 동작이 더 추가될 여지는 있는 것 같아 주의해야할 것 같다

Reference

profile
강해지고 싶은 주니어 프론트엔드 개발자

0개의 댓글