ProtectedRoute가 뭘까?
이는 스웨거 문서를 한번 보면, 자물쇠 걸린 부분이 authorization(bearer 토큰을 넣어줘야 하는~)
내 정보를 조회했을 때 여기가 만약 토큰 값이 없으면 내가 누군지 알 수 없다. 로그인하면 토큰들이 담겨올 텐데 401 에러가 뜰 것이다. 로그인을 다시 진행하고 토큰은 만료되면 재생성해야 함. 암튼 로그인을 진행하면 새로운 엑세스 토큰이 나올 것이고, 그걸 bearer에 넣어주면 된다.
그럼 Protected Route가 뭐냐??
예를 들면, 마이페이지를 보자. 마이페이지에서 쓰는 Api를 보면 users/me 토큰이 필요한 페이지인데 내가 로그인도 안 됐는데 /my로 바로 들어가서 데이터를 받아보려고 하면 맞는지 의문이 들 것이다.
즉, 토큰이 없는데 토큰이 필요한 페이지에 들어간다면 그냥 이 페이지에 바로 들어가지는 게 아니라 유저를 볼 수 있는 화면이 없기 떄문에 경고문이 뜨거나 경고 문구와 함께 다시 홈으로 돌려보내거나 로그인 페이지로 보내는 등 이런 처리를 해주는 것을 Protected Route라고 한다. (데모데이에서 중요!)
Protected Route 만들기
contextAPI 이용해서 기존 코드의 수정을 진행한다.
contextAPI를 이용하려는 이유는..
여러 컴포넌트가 존재하는 상황에서 protected route 적용이 되려면 결국 이 모든 공간에서 전역적으로 정확히 로그인 여부를 알아야 한다. 다시 말해, isLogin이란 상태를 모든 곳에서 다 공유하고 있어야 할 것이다.
이를 어떤 식으로 구분할까? 기존에 로컬 스토리지에 저장하는 방식을 했기 때문에 엑세스 토큰이 있는지 없는지 방식으로 할 것이다.
contextAPI로 관리하긴 할 건데 추가적으로, lazy initialization(지연 초기화) 방식을 사용해서 어떻게 구현하는지 볼 것이다.
- 첫번째로, src 폴더에 contextAPI 관련 파일을 만들어줄 것이다. (context 폴더 만들고, AuthContext.tsx 만들어줌.)
- 그 다음, context 타입을 만들어준다. 타입은 아래와 같다.
- accessToken을 관리하는 상태를 만들어줄 것이다. 근데 있을 수도 없을 수도 있기 때문에 string 또는 null로 해준다. refreshToken도 마찬가지.
- login같은 경우는 로그인이랑 로그 관련 함수를 이 AuthContext 안에서 관리를 할 것이기 때문에(auth token에 관한 상태를 setToken을 통해서 엑세스 토큰의 초기값을 바꿔 주는 작업을 할 거기 때문에 하나의 컨텍스트로.)
- 로그인을 할 때 인자로 apis/auth.ts를 보면 RequestSignDto를 던져주고 있다. 그럼 로그인하는 순간에는 signInData로 지정하고 RequestDto가 prop으로 들어와야 하고, Promise 타입에 반환하는 것은 일단 void로.
- logout도 인자가 없다. (로그아웃 버튼만 눌러주고 API 호출만 하면 되니까.)
- 타입을 만들어줬으면 컨텍스트를 만들어 주면 된다. (토큰들은 null로 초기화, login, logout은 promise 타입이므로 async()로.)
- 그 다음, AuthProvider도 만들어준다. - 우산 만들어주기
- children 타입은 많이 쓰기 때문에 PropsWithChildren 타입이 만들어져서 그걸 사용한다.
- 이렇게 하고 나면, refreshToken을 key.ts에 추가해준다.
export const LOCAL_STORAGE_KEY = {accessToken: "accessToken",refreshToken: "refreshToken",}
- 이렇게 등록해주면 AuthContext에서 useLocalStorage를 써서 처리를 한다. 여기서 꺼내올 수 있는 것이 뭐가 있냐면.. getItem, setItem, removeItem이 있다.
- 상태에 따라 토큰이 있는지 여부를 알 수 있어야 하므로 그에 맞는 상태를 추가해준다.
const [accessToken, setAccessToken] = useState<string | null>(getAccessTokenFromStorage(),);const [refreshToken, setRefreshToken] = useState<string | null>(getRefreshTokenFromStorage(),);
- 이런 식으로 함수를 열어 주는 방식으로 처리하게 되면 lazy initailzation 즉, 지연 초기화(상태 변화에 따른 렌더링이 발생하는데 초기값은 렌더링을 해야 하지만, 그 이후에 리렌더링 할 때 해당 함수가 다시 호출되지 않게 해서 불필요한 계산이나 로컬 스토리지에 접근하는 것을 줄인다. - 캡슐화 및 재사용성)가 되는 것이다.
- 근데 주의할 점은 강제로 새로고침을 하면 앱을 재실행되는데 페이지가 새로고침 되는 것처럼 느끼지만 싱글페이지 어플리케이션은 이 페이지를 새로 받아오는 개념보다는 컴포넌트를 갈아끼우는 개념이기 때문에 지연 초기화가 제대로 동작한다.
- 그 다음, 로그인, 로그아웃 함수도 만들어준다.
- 상태 관리를 하고 있는데 이 상태도 바꿔줘야 한다. 지연 초기화이기 때문에 강제로 함수를 통한 렌더링을 유발시키지 않게 set 함수를 통해 값을 넣어 주입시켜 줘야 한다.
const { data } = await postSignin(signinData);
if (data) {
const newAccessToken = data.accessToken;
const newRefreshToken = data.refreshToken;
setAccessTokenInStorage(newAccessToken);
setRefreshTokenInStorage(newRefreshToken);
setAccessToken(newAccessToken);
setRefreshToken(newRefreshToken);
}
- 이 코드는 로그인을 성공하는 과정인데 이 부분이 동기니까 try 안에 감싸주면 될 것이다. 에러가 날 수 있으므로 catch해준다.
- toast ui 같은 경우는 알림 문구들~~
const login = async (signinData: RequestSigninDto) => {
try {
const { data } = await postSignin(signinData);
if (data) {
const newAccessToken = data.accessToken;
const newRefreshToken = data.refreshToken;
setAccessTokenInStorage(newAccessToken);
setRefreshTokenInStorage(newRefreshToken);
setAccessToken(newAccessToken);
setRefreshToken(newRefreshToken);
alert("로그인 성공");
}
} catch (error) {
console.error("로그인 오류", error);
alert("로그인 실패");
}
}
- 그럼 이제 로그아웃 함수도 구현한다.
- 로그아웃할 때 localStorage.clear(); 를 하면 될 거라고 생각할 수도 있는데, 이 사이트에서는 다 지워 줘도 상관 없다. 근데 예를 들어, 로컬 스토리지에 다른 사이트나 조금 거대한 사이트 같은 겨우는 되게 많은 정보들을 담아 놓는다. 근데 그것을 전부 다 지워버리면 로그인 특정 정보를 지우는 것이 아닌, 로그인을 하든 안 하든 상관 없이 여러 정보들이 로컬 스토리지에 있다고 가정했을 때 그것들을 다 지운다면 문제가 발생할 우려가 있다. 그렇기에 가능하면 안 쓰는 것이 좋다.
const logout = async () => {
try {
await postLogout(); // 데이터를 받아와도 response로 넘어오는게 딱히 없기 때문에 실행만 시켜주고 response 값을 안 받아 올 것이다.
removeAccessTokenFromStorage(); // 비워주기
removeRefreshTokenFromStorage(); // 비워주기
setAccessToken(null);
setRefreshToken(null);
// localStorage.claer() // 큰 프로젝트에서는 문제 발생 가능.
alert("로그아웃 성공");
} catch (error) {
console.error("로그아웃 오류", error);
alert("로그아웃 실패");
}
}
- 이런 식으로 로그인이랑 로그아웃 다 만들었으니까 이제 provider에 엑세스 토큰, 리프레쉬 토큰, 로그인, 로그아웃을 전달해야 한다. -> return을 해줌!
return (
<AuthContext.Provider value={{ accessToken, refreshToken, login, logout }}>
{children}
</AuthContext.Provider>
);
// const context = useContext(AuthContext); 이런 식으로 많이 활용했지만, 이 과정이 매번 일어나기 때문에 그냥 애초에
// 훅을 만들자! ->
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("AuthContext를 찾을 수 없습니다.");
}
return context;
}
// 이 컨텍스트가 없을 때 (우산을 안 씌웠을 때) context provider를 씌워주는데 그것을 안 씌워줬을 때 에러도
// 알 수 있기 위해서 조건문을 달아준다.
- 이제, App.tsx에서 전체를 감싸줘야 한다. 그냥은 접근이 안 된다. 전역 상태로 관리할 거기 때문에
<RouterProvider router={router} />
- 지금 모든 페이지들이 여기에 있는 것이다. 그래서 모든 페이지에서 이 상태를 공유하고 싶다면 여기에 provider를 이렇게 씌워 주면 된다. 화면에서 새로고침을 해주면 된다.
- 그 다음, 로그인 페이지를 수정해준다. 로그인을 했을 때 이 토큰을 넣는 작업들은 login 함수에서 다 알아서 해준다. 그러니까 value 관련 부분은 필요가 없어진다. 컨텍스트에서 대부분 처리를 해주고 있는 것을 알 수 있다. 그렇기 때문에
const handleSubmit = async () => {
console.log(values);
try {
const response = await postSignin(values);
setItem(response.data.accessToken) // 액세스토큰이라는 키 이름으로 저장을 해주게 됨.
navigate("/");
} catch (error) {
alert(errors?.message);
}
};
이 정보들을 지워주고, 아래처럼 작성을 해주면 로그인 기능이 완료된다.
const handleSubmit = async () => {
await login(values); // 로그인 요청
};
로그인 성공하면, devTools에서의 Application을 누르고 local storage를 확인하면 두 토큰이 다 뜨는 걸 볼 수 있다.
- 추가로, 로그인 완료됐을 때의 페이지 이동을 구현한다. useNavigate 이용.
const handleSubmit = async () => {
try {
await login(values);
navigate("/my");
} catch (error) {
navigate("/");
}
};
- 로그인 페이지에 위와 같이 써도 되는데 AuthContext에서 처리해주는 게 효율적일 것이다.
setAccessToken(newAccessToken);
setRefreshToken(newRefreshToken);
alert("로그인 성공");
window.location.href = "/my";
- context가 최상위이기 때문에 navigate는 인식을 못 할 것이다. 그렇기에 window를 달아서 처리해준다.
- mypage에서 useEffect를 사용하고 있는데 아래가 먼저 렌더링되기 때문에 useEffect가 데이터를 못 받아오기 때문에 작동을 안 할 것이다. 이는 해결하기 위해 optional을 사용해준다. 새로고침 하면 제대로 불러와질 것이다.
return (<div>{data.data.name} {data.data.email}</div>)
- 이제 마이페이지에다가 로그아웃 버튼을 하나 만들어준다.
return (
<div>
<h1>{data.data?.name}님 환영합니다.</h1>
<img src={data.data?.avatar as string} alt="profile" className="w-100 h-100 rounded-full" />
<h1>{data.data?.email}</h1>
<button
className="cursor-pointer bg-blue-300 rounded-sm p-4 hover:scale-90"
onClick={handleLogout} >
로그아웃
</button>
</div>
)
- 이런 식으로 하면, 로그아웃 버튼 눌렀을 때 토큰이 다 빠져나갈 것이다. 근데 사실은 홈으로 이동을 해야 한다. 그치만 새로 고침을 눌렀을 때 기존 mypage에 데이터를 다 못 받아 올 것이다. 그니까 여기 페이지는 우리가 로그인 안한 상태일 때는 접근이 되면 안 되는, 보호받아야 하는 페이지여야 한다.
const handleLogout = async() => {
await logout();
navigate("/");
}
- App.tsx를 수정을 해준다. 여기서 사실 보호 받아야 하는 라우터가 마이 페이지일 것이다. 나머지는 로그인을 안 해도 들어갈 수 있어야 한다. 페이지를 만약 로그인을 해야 들어갈 수 있게 하려면 영원히 이 사이트는 접근할 수 없는 사이트가 된다. 그래서 구분을 해서 나누는 것이 좋다.
- public, protected 각각 만들어줘야 하는데, 트리키한 요소가 있다.
- outlet은 정확히 말하면
- 홈페이지랑 로그인 페이지랑 signup 페이지가 이 HomeLayout의 아울렛에 계속 들어가고 나머지 요소들(태그)은 다 공통으로 보인다. 화면이 공유되고 있다는 것을 알 수 있다.
- 레이아웃을 하나 더 만든다. -> ProtectedLayout
- 이 레이아웃에서는 아까 토큰이 있는지 여부에 따라 토큰이 필요한 페이지에 접근할 때 그냥 그 페이지로 보낸다. 그러니까 토큰이 필요한 페이지는 볼 수 있게 하고, 토큰이 없으면 로그인 페이지로 이동시켜 버린다? 이것에 대해 처리하면 될 것이다.
- 마이 페이지는 앞이나 뒤로 가게 할 필요가 없기 때문에 replace 속성을 사용해준다. (히스토리가 안 남음.)
- 만약에 토큰이 있으면 그냥 렌더링해줘! 라는 의미로 return <Outlet />; 을 해준다.
- 히스토리가 있다는 거는 경로 이동했던 흔적이 남아있다는 것을 의미한다고 보면 된다.
- 로그인을 인증이 필요한 라우트로 옮기면 평생 로그인을 못 한다고 생각할 수 있는데 로그인 페이지 자체에서 처리를 해주면 된다.
- 어떻게?? 엑세스 토큰의 유무로 사람이 로그인을 했는지 안 했는지를 알 수 있었다. 그것을 login 페이지에서 토큰을 받아올 수 있다.
'UMC 8th Web 워크북' 카테고리의 다른 글
🎙️Social Login (0) | 2025.05.02 |
---|---|
💊리프레쉬 토큰 (0) | 2025.05.01 |
🍎 회원가입 관리 (0) | 2025.04.13 |
🍎 로그인 구현해보기 (umc 4주차) (0) | 2025.04.13 |
🍎 로그인/회원가입 페이지 구현을 위한 복습 (0) | 2025.04.13 |