Hits

라우팅 기능이 필요한 클라이언트 애플리케이션을 개발하다보면, 때때로 QueryString을 검증해야 하는 경우가 생깁니다

제가 만든 블로그에서는 특정 주제에 맞는 게시글을 보여주는 페이지가 위 경우에 해당 되었습니다.

해당 페이지는 다음과 같은 url을 가지게 됩니다.

/posts?category=JavaScript → 카테고리가 JavaScript인 게시글 목록

/posts?tag=NextJS → NextJS 태그가 걸려있는 게시글 목록

/posts?search=React → React가 제목에 들어간 게시글 목록

따라서, 해당 게시글 목록 페이지(/posts)가 category/tag/search 라는 key가 QueryString에 없다면 이에 대한 핸들링을 해주어야 합니다.


🛠 HOC Pattern

HOC(High Order Component) Pattern은 인자로 받은 컴포넌트에 공통된 추가 로직을 수행하여 필요한 결과물을 표현할 수 있도록 도와주는 패턴입니다. → React 공식문서 High Order Component

요즘에는 보통 Custom Hook 패턴을 사용하지만, 개인적으로 컴포넌트의 결과물(return)이 결정되는 로직은 HOC Pattern을 사용하는 것이 가독성이 좋다고 생각해 활용하고 있습니다.

HOC Pattern을 사용해 QueryString을 검증한다면 다음과 같은 형태가 될 것입니다.

WithPostsQueryValidation.tsx
function withPostsQueryValidation<P extends object>(
  Component: ComponentType<P>
) {
  return function WihLoadingComponent({ ...props }: P) {
    const [loading, setLoading] = useState(true);
    const { query } = useRouter();
 
    const isQueryInValid = !(query.search || query.tag || query.category);
 
    const queryInvalidCallBack = useCallback(() => {
      alert(MESSAGE.INVALIDE_ACCESS);
      window.location.replace("/");
    }, []);
 
    useEffect(() => {
      if (isQueryInValid) queryInvalidCallBack();
      else setLoading(false);
    }, [query, isQueryInValid]);
 
    if (loading) return <Loading />;
 
    return <Component {...props} />;
  };
}
 
export default withPostsQueryValidation;
WithPostsQueryValidation.tsx
function withPostsQueryValidation<P extends object>(
  Component: ComponentType<P>
) {
  return function WihLoadingComponent({ ...props }: P) {
    const [loading, setLoading] = useState(true);
    const { query } = useRouter();
 
    const isQueryInValid = !(query.search || query.tag || query.category);
 
    const queryInvalidCallBack = useCallback(() => {
      alert(MESSAGE.INVALIDE_ACCESS);
      window.location.replace("/");
    }, []);
 
    useEffect(() => {
      if (isQueryInValid) queryInvalidCallBack();
      else setLoading(false);
    }, [query, isQueryInValid]);
 
    if (loading) return <Loading />;
 
    return <Component {...props} />;
  };
}
 
export default withPostsQueryValidation;

사용처
export default withPostsQueryValidation(Posts);
사용처
export default withPostsQueryValidation(Posts);

검증 로직을 HOC 내부에 두고, useEffect가 실행되어 검증이 되기전까지는 LoadingComponent를 보여주는 형태입니다.


🛠 Custom Hook Pattern

Custom Hook 패턴을 사용하게 된다면, Hook 내부에서 검증로직을 수행하는 HOC를 반환하는 형태로 구현할 수 있습니다.

앞서, 보여드린 HOC 패턴은 invalidate시에 실행하는 콜백을 매게변수로 주입할 경우, Hook과 관련된 로직을 넣을 수 없었습니다.

하지만, Custom Hook패턴으로 변경할 경우, Hook을 이용한 콜백을 주입할 수 있습니다.

useSearchParamsValidation.ts
interface Params {
  onInvalidate: () => void;
}
 
export default function useSearchParamsValidation({ onInvalidate }: Params) {
  const { query } = useRouter();
 
  const isQueryInValid = !(query.search || query.tag || query.category);
 
  const SearchParamsValidationWrap = ({
    children,
  }: {
    children: JSX.Element;
  }) => {
    const [loading, setLoading] = useState(false);
 
    useEffect(() => {
      if (isQueryInValid) onInvalidate();
      else setLoading(false);
    }, [isQueryInValid, onInvalidate]);
 
    if (loading) return null;
    return <>{children}</>;
  };
 
  return SearchParamsValidationWrap;
}
useSearchParamsValidation.ts
interface Params {
  onInvalidate: () => void;
}
 
export default function useSearchParamsValidation({ onInvalidate }: Params) {
  const { query } = useRouter();
 
  const isQueryInValid = !(query.search || query.tag || query.category);
 
  const SearchParamsValidationWrap = ({
    children,
  }: {
    children: JSX.Element;
  }) => {
    const [loading, setLoading] = useState(false);
 
    useEffect(() => {
      if (isQueryInValid) onInvalidate();
      else setLoading(false);
    }, [isQueryInValid, onInvalidate]);
 
    if (loading) return null;
    return <>{children}</>;
  };
 
  return SearchParamsValidationWrap;
}
사용처
const Posts = () => {
  const router = useRouter();
 
  const onInvalidate = useCallback(() => {
    alert(MESSAGE.INVALIDE_ACCESS);
    router.push("/");
  }, []);
 
  const SearchParamsValidationWrap = useSearchParamsValidation({
    onInvalidate,
  });
 
  return (
    <SearchParamsValidationWrap>
      <>...</>
    </SearchParamsValidationWrap>
  );
};
사용처
const Posts = () => {
  const router = useRouter();
 
  const onInvalidate = useCallback(() => {
    alert(MESSAGE.INVALIDE_ACCESS);
    router.push("/");
  }, []);
 
  const SearchParamsValidationWrap = useSearchParamsValidation({
    onInvalidate,
  });
 
  return (
    <SearchParamsValidationWrap>
      <>...</>
    </SearchParamsValidationWrap>
  );
};

검증역할을 수행하는 SerachParamsValidationWrap HOC를 훅을 통해 반환하고, 해당 컴포넌트가 반환 JSX를 감싼 후(children), validation이 되었을 때만 children을 반환하는 형태입니다.


🖥 getServerSideProps

앞서 보여드린 HOC패턴과 Custom Hook 패턴을 사용하는 경우 한 가지 문제가 있습니다.

바로 두 패턴 모두 useEffect를 사용해 검증을 진행한다는 점입니다.

useEffect의 내부 로직은 Server-Side에서 실행되지 않고, Browser에서 실행됩니다.

따라서, 서버에서 클라이언트에게 전송하는 HTML파일에는 어떤한 경우에도 Posts 컴포넌트가 아닌 Loading 컴포넌트가 포함되게 됩니다.

결과적으로, useEffect 내부에서 검증 로직이 실행되는 것이 아니라, 서버에서 라우팅 단계에 검증 로직이 실행되도록 변경해야 합니다.

(서버에서 렌더링할 필요가 없을 경우, 변경하지 않아도 상관 없습니다 → SSR x CSR o)

Browser가 아닌 Server-Side에서 요청(Request)를 검증하기 위해서는 getServerSideProps를 이용해야 합니다. (when using NextJS)

공식문서에는 getServerSideProps는 오직 Server-Side에서만 실행되고, Browser에서 실행되지 않는다고 나와있습니다.

getServerSideProps를 사용하는 페이지는 요청을 받았을 때, getServerSideProps 로직이 실행되고, 반환하는 prop과 함께 페이지를 pre-rendering합니다. (공식문서 설명)

추가로, getServerSideProps는 페이지를 Redirect시키는 기능을 제공하기 때문에, 이를 이용해 QueryString을 검증하고 이에 따른 Redirect처리를 구현 할 수 있습니다.

export const getServerSideProps: GetServerSideProps = async ({
  req,
  res,
  query,
}) => {
  const isNumberOfKeyValid = verifyNumberOfKeys(Object.keys(query), 1);
  const isValueValid = verifyValue(query);
 
  if (!isNumberOfKeyValid || !isValueValid) {
    return {
      redirect: {
        permanent: false,
        destination: "/",
      },
      props: {},
    };
  }
 
  return {
    props: {},
  };
};
 
function verifyNumberOfKeys(keys: string[], validNumber: number) {
  const verifiedKeys = ["category", "search", "tag"];
  const includedKeys = keys.filter((key) => verifiedKeys.includes(key));
  return includedKeys.length === validNumber;
}
 
function verifyValue(query: ParsedUrlQuery) {
  for (const value of Object.values(query)) {
    if (!value) {
      return false;
    }
  }
  return true;
}
export const getServerSideProps: GetServerSideProps = async ({
  req,
  res,
  query,
}) => {
  const isNumberOfKeyValid = verifyNumberOfKeys(Object.keys(query), 1);
  const isValueValid = verifyValue(query);
 
  if (!isNumberOfKeyValid || !isValueValid) {
    return {
      redirect: {
        permanent: false,
        destination: "/",
      },
      props: {},
    };
  }
 
  return {
    props: {},
  };
};
 
function verifyNumberOfKeys(keys: string[], validNumber: number) {
  const verifiedKeys = ["category", "search", "tag"];
  const includedKeys = keys.filter((key) => verifiedKeys.includes(key));
  return includedKeys.length === validNumber;
}
 
function verifyValue(query: ParsedUrlQuery) {
  for (const value of Object.values(query)) {
    if (!value) {
      return false;
    }
  }
  return true;
}

이번 글에서는 QueryString 검증 관련 로직만을 이야기 했지만, HOC패턴과 getServerSideProps를 이용해 관리자 검증, 로그인 여부 검증, 토큰 검증 등등 여러 부분에서도 똑같은 방식으로 검증 로직을 적용할 수 있을것입니다.


🍪 with Cookie

다만, 검증과정에서 Client에서 온 Request에 포함된 Cookie가 필요하다면, 이에 대한 세팅이 따로 필요합니다.

getServerSideProps에서 인자로 받는 context.req.header.cookie를 통해 Client의 Request에 포함된 Cookie를 가져올 수 있습니다. (문자열 형태이기 때문에, 파싱이 필요할 경우 파싱 로직 적용)

export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  const cookies = req.headers.cookie;
};
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  const cookies = req.headers.cookie;
};

요 Cookie를 AJAX통신에 사용해, Token 혹은 Session 기반으로 로그인 한 사용자를 확인할 수 있습니다.