[NextJs] URL 직접 접근 막기

해달·2022년 5월 11일
1

프로젝트를 진행하면서 어드민페이지에 url로 접속했을 경우에
/ 으로 리다이렉트 시켜주는 방식을 구현했던 방법들을 기록해놓으려고한다

우선 Next 는 기본적으로 페이지들이 pre-render로 동작하기때문에 ssr로 동작한다
데이터는 ssr 설정을 해주지않게되면 CSR적으로 동적으로 동작하게 된다

알고있던 지식을 토대로 위 문제를 해결하는 과정속에서 Recoil도 사용해보았지만 실패하였고🥲,
해결하기위한 방법을 찾으면서 공부하게 된 내용이 많은거같아 정리해보려고한다!

결론적으로는 ﹗
서버쪽에 어드민을 체크해주는 api를 추가로 작성하여
페이지를 렌더링하기전에 어드민을 먼저 체크해주는방식으로 구현하였다


❎ 1차

  1. 유저정보를 받아오는 쿼리를 실행시켜 isAdmin 값이 false 경우
    / 이동시켜주고,
    유저가 로그인되어있지 않을경우에도 / 으로 이동시켜 주었다
//query
  useUserQuery(userId as number, {
    onSuccess: data => {
      if (!data.isAdmin) {
        router.replace('/');
        return;
      }
    },
  });

...,

  
useEffect(() => {
  if (!userId) router.replace('/');
}, []);

❎ 2차

페이지마다 작성되어있는 위에 1차방식을
Route 파일을 생성해서 페이지마다 적용시켜 주었다!

이 방식은 같이 프로젝트를 하고있는 비비🦄 가 리팩토링 해주었다👏🏻

route 생성

export interface RouteProps {
  children: React.ReactNode;
}

const AdminRoute = ({ children }: RouteProps) => {
  const router = useRouter();
  const userId = useRecoilValue(userIdState);

  useUserQuery(userId as number, {
    onSuccess: data => {
      if (!data.isAdmin) {
        router.replace('/');
        return;
      }
    },
  });

  useEffect(() => {
    if (!userId) router.replace('/');
  }, [userId]);

  return <>{children}</>;
};

export default AdminRoute;

page마다 Route 감싸주기

Admin.getLayout = function getLayout(page: React.ReactElement) {
  return (
    <HomeLayout>
      <AdminRoute>{page} </AdminRoute>
    </HomeLayout>
  );
};

코드는 간결해졌지만 이 방법에는 문제점이 있었다

  1. 리스트를 불러오는 query가 실행이되어서 콘솔창에 403에러가 발생

❎ 3차

위에 문제점을 해결하고자 isAdmin 값을 얻어오기위해
useIsValidAdmin 훅을 만들었다

admin 페이지에서 값을 얻어서 query 옵션으로 enabled로 값을 전달해서 어드민이 아닐경우에는 요청이 가지 않도록 처리하였다

useIsValidAdmin

export function useIsValidAdmin() {
  const router = useRouter();

  const [isAdmin, setIsAdmin] = useState(false);

  const userId = useRecoilValue(userIdState);
  const isLogin = useRecoilValue(loginState);

  useUserQuery(userId as number, {
    onSuccess: data => {
      if (!data.isAdmin) {
        router.replace('/');
      } else if (!!isLogin && !!data.isAdmin) {
        setIsAdmin(true);
      }
    },
  });

  useEffect(() => {
    if (!isLogin) router.replace('/');
  }, []);

  return isAdmin;
}

admin/index

const isAdmin = useIsValidAdmin();

  const queryClient = useQueryClient();
  const { data: commentList } = useCommentListQuery({
    per_page: 10,
    page,
    options: {
      onSuccess: data => {
        setTotalPage(Math.ceil(data.count / 10));
      },
      enabled: isAdmin,
    },
  });

쿼리요청은 가지 않도록 처리되었지만 !

다른 문제점으로는
화면이 랜더링 된 후에 replace 처리가되어서 페이지가 보여지고나서 / 로 이동되었다🥲


❎ 4차

위 문제를 해결하기위해 이전에 작성하였던 hook 을 없애고

useRecoilValueLoadable 을 사용해서 비동기처리를 해주고 화면이동을 해주기 위해
아래와 같이 코드를 작성했지만 원하는대로 구현이 되지 않아 포기한 방법이다 ^_ㅠ..

이 방법은 아래와 같이 코드를 작성하기 위해 공부를 한 부분이 많아 따로 적어놔야겠다


recoil

selector는 getset 을 갖고있고 직접 정의해서 쓸수 있는 순수함수이다

get필수값이고 set선택적으로 사용할 수 있다
이를 활용하면 데이터를 가공하거나, 유효성검사 등을 할 수 있다
리코일에서는 셀렉터 내부에서 다른 셀렉터나 다른 atom을 참조할 수 있다
-> 구독한다고 리코일에서 표현

구독을 하게되면 다른데이터들에 대해 의존성을 가지게 되어
구독하고 있는 상태가 변경이 되면 본 셀렉터는 감지되어 컴포넌트를 다시 랜더링 해준다

읽기전용 셀렉터 (get) Read-only
읽기쓰기 셀렉터 (get,set) Writable

recoil/selector

유저아이디로 isAdmin 값을 가지고옴

import { selector } from 'recoil';
import { getUserInfo } from '../../api/fetcher/users';
import { userIdState } from '../atoms/users';

export const adminSelector = selector({
  key: 'adminSelector',
  get: async ({ get }) => {
    const userId = get(userIdState); // 

    if (!userId) return false; // 유저아이디가 없을경우 바로 false처리

    try {
      const { isAdmin } = await getUserInfo(userId as number);

      return !!isAdmin;
      //
    } catch (err) {
      throw err;
    }
  },
});

useIsAdmin (useRecoilValueLoadable)

비동기 처리를 위해서는
1. Suspense
2. useRecoilValueLoadable

두가지 방식으로 처리를 해주어야하는데 useRecoilValueLoadable 이용해서
만들어놓은 훅이 있길래 참고하여서 훅을 작성하였다

벨류로드에이블은 statecontents 를 반환하고
처음에는 비동기처리를 위해 loading state와 Promise contents가 콘솔에 찍힌다

import { useCallback, useEffect, useState } from 'react';
import { useRecoilValueLoadable } from 'recoil';

import { adminSelector } from '../recoils/selector/admin';

export function useIsAdmin() {
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [isError, setIsError] = useState<boolean>(false);
  const [isAdmin, setIsAdmin] = useState<boolean>(false);

  const isAdminState = useRecoilValueLoadable(adminSelector);

  console.log(isAdminState); 
  // ValueLoadable {state : 'loading', contents : Promise}
  // ValueLoadable {state : 'hasValue', contents : false}
  

  const isAdminCheck = useCallback(() => {
    switch (isAdminState.state) {
      case 'loading':
        setIsLoading(true);
        break;

      case 'hasValue':
        setIsLoading(false);
        setIsAdmin(isAdminState.contents);
        break;

      case 'hasError':
        setIsError(true);
        setIsLoading(false);
        break;

      default:
        return;
    }
  }, [setIsAdmin, isAdminState]);

  useEffect(() => {
    isAdminCheck();
  }, [isAdminCheck]);

  return {
    isLoading,
    isError,
    isAdmin,
  };
}

pages/admin

위에 훅과 같이 각 상태에따라서 컴포넌트 화면을 랜더링시켜주려 하였으나

router.replace 에서 에러가나서 보니
랜더링이전에는 라우터를 쓸 수 없다는 것이였다 ^-^..
당연한 내용인데 코드를 이리저리 바꾸면서 엉뚱한곳에 코드를 작성하고 있었다

useEffect를 이용해서 페이지 랜더링을 시켜주게되면 결국 똑같은 문제점이
발생하기때문에 이번에 이렇게 작성해본 코드는 경험이라고 생각하고 삭제🗑 해버렸다 !! 🥲

//적용한 부분
    const { isLoading, isError, isAdmin } = useIsAdmin();

	...,

    if (isLoading) {
      return (
        <LoadingContainer>
          <Loading src="/loading.gif" alt="이미지 로딩" />
        </LoadingContainer>
      );
    }

    if (isError || !isAdmin) {
      router.replace('/'); // <- 에러발생  
      return <></>;
    }

	...,

✅ 5차

Nextjs 기능인 SSR 사용해서 접근되지 않도록 설정
처음에 언급하였던 해결방안!

코드를 작성하다보니 admin 값을 판별해주는 서버 api가 필요하다는 생각이 들었는데,
백엔드담당인 팀원이 현재 사정으로 바로 코드를 수정해줄 수가 없는 상태여서

서버도 클라이언트와 같이 실행해 테스트해나가며 서버코드도 작성하였다 🙂 ~!

admin 값을 판별해주는 서버 api 작성 후 ! 받아온 response 값으로
로그인 하지않은 유저만 mypage, admin 접속 시 아예 이동하지 않도록 설정해주었다 !

토큰이 없을 경우 로그인 한 유저가 아니기 때문에 / 으로 이동 =>
토큰은 있지만 admin이 아닐 때 / 으로 이동

export const getServerSideProps: GetServerSideProps = async ctx => {
  const { req } = ctx;

  const token = req.cookies['accessToken'];

  if (!token) {
    return {
      redirect: {
        permanent: false,
        destination: '/',
      },
    };
  } else {
    
    const isAdmin = await axios.get(`${API_END_POINT}/adminCheck`, {
      params: {
        query: token,
      },
    });
    
    if (!isAdmin) {
      return {
        redirect: {
          permanent: false,
          destination: '/',
        },
      };
    }
  }

  return {
    props: {},
  };
};

ServerSodeProps

params: 다이나믹 라우트 페이지라면, params를 라우트 파라미터 정보를 가지고 있다.
req: HTTP request object
res: HTTP response object
query: 쿼리스트링
preview: preview 모드 여부 >공식문서
previewData: setPreviewData로 설정된 데이터


return

props : 해당 컴포넌트로 리턴할 값 (선택적)
redirect : 값 내부와 외부 리소스 리디렉션 허용한다 (선택적) 무조건 { destination: string, permanent: boolean } 의 꼴이어야 한다. 몇몇 드문 케이스에서 오래된 HTTP클라이언트를 적절히 리디렉션하기 위해 커스텀 status코드가 필요할 수 있는데, 그땐 permanent property 대신에 statusCode property를 이용한다.

  • getServerSideProps는 페이지를 렌더링하기전에 반드시 fetch해야할 데이터가 있을 때 사용합니다.

출처 블로그


server코드

api/middlewares/isAdmin.js

const decodeAccessToken = require("../utils/decodeAccessToken");

const isAmdin = async (req, res, next) => {
  try {
    const { query: accessToken } = req.query;

    if (accessToken) {
      const decoded = await decodeAccessToken(accessToken);

      res.locals.isAdmin = decoded.isAdmin;
      const userData = res.locals.isAdmin

      return res.json(userData)

    } else {
      return res.status(401).json({
        message: "accessToken 없음",
      });
    }
  } catch (err) {
    throw err
  }
};

module.exports = isAmdin;

서버코드까지 작성하고나서 테스트해본결과 데이터베이스에 있는 값으로
유저의 어드민 값이 잘 찍히는것을 확인할 수 있었다!


ETC

자주 사용한 mysql update 구문
update Users set isAdmin = 1 where id = 116;


한가지 로직을 구현하기위해 고민에도 시간을 많이 쏟았고
코드작성에도 많은 시간이 들었다
아직까지도 조금 더 나은코드를 작성했었으면 좋았을거라는 아쉬움도 많지만

여러방식으로 코드를 작성해본것도 좋은 경험이라고 생각한다
아직 어느방법이 정답인지는 모르겠지만 추후에 더 좋은방식을 찾게된다면 다시 수정해놓을것이다


reference

1) https://www.youtube.com/watch?v=0-UaleJZOw8

2) https://velog.io/@yiyb0603/Recoil-Selector-useRecoilValueLoadable%EC%9D%84-%ED%99%9C%EC%9A%A9

3) https://velog.io/@juno7803/Recoil-Recoil-200-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0#%E2%99%80%EF%B8%8F-selector%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0---%EC%BA%90%EC%8B%B1

5개의 댓글

comment-user-thumbnail
2022년 10월 5일

혹시 5차에 코드 파일명은 무엇인가요 ?

1개의 답글