react로 진행하는 팀 프로젝트에서 로그인을 구현하면서 마주했던 고민들을 정리해본다.
가장 먼저 마주한 선택지는 로그인 인증 방식이었다.
JWT(JSON Web Token)을 이용한 토큰기반 방식과 Session 방식이 있었다.
각각의 특징과 인증 절차는 많은 자료가 있어서 생략하고, 결론부터 말하면 팀원들간의 논의 끝에 JWT를 사용하기로 했다.
여기에는 몇가지 이유가 있다.
Session 방식은 백엔드의 부담이 커진다.
유저의 로그인 상태를 DB에서 모두 다루어야하기 때문에 부담이 크고 확장도 쉽지 않다.
또한 보안 처리를 위해서 백엔드에서 해주어야 할 설정들도 많아서 기간이 정해진 프로젝트에서 적합하지 않다고 판단했다.
JWT는 SPA의 비동기적 특성에 부합한다.
react 프로젝트에서 JWT를 사용한 사람들이 많아보여서 찾아본 결과, SPA의 비동기적 특성에 부합한다는 것을 알게 되었다.
SPA는 필요한 데이터만 서버에 비동기 요청을 해서 화면에 반영하는데, JWT는 이런 요청에 인증 토큰을 쉽게 첨부할 수 있어서 페이지 리로드 없이 토큰을 갱신하거나 검증할 수 있는 점이 서로 잘 어울린다.
JWT 방식은 토큰이 탈취 당할 수 있다는 단점이 있는데 이를 보완하기 위해 두 종류의 토큰을 사용한다.
accessToken이 만료된 것을 확인하고 재발급하는 방식도 두가지가 있다는 것을 알게되었다.
서버에 accessToken을 포함한 요청을 했을 때 토큰 만료로 실패했다는 응답을 받으면
refreshToken으로 토큰 재발급을 하고 새로운 토큰으로 다시 요청을 보내는 방식이다.
토큰을 발급 받을 때마다 클라이언트에 발급 시간을 기록해두고 요청을 보내기 전에 토큰이 만료 되었는지 확인 후 요청을 보내는 방식이다.
이렇게 하면 불필요한 요청을 줄일 수 있다.
일단은 서버에서 만료를 확인하는 방식으로 코드를 작성했다.
어떤 방식이 좋은지 조금 더 알아본 뒤에 다시 수정할 예정!
이제 서버에서 발급받은 토큰을 어디다가 저장할지에 고민이 생겼다.
전역 상태에 저장해서 쓸 수 있을 것 같은데, 이렇게 하면 새로고침 했을 때 토큰이 사라지므로 결국 Web Storage를 이용한 redux-persist, recoil-persist등을 사용해야해서 좋은 방법이 아니다.
Web Storage의 경우 script를 삽입하는 XSS 공격을 받으면 token을 열람할 수 있다.
Cookie는 httpOnly 옵션을 통해 xss 공격을 방어할 수 있기 때문에 webStorage보다 안전하다.
하지만 xss는 필수적으로 방어해야 하는 공격이므로 딱히 cookie의 장점이라 보긴 어렵다.
또한 React는 자체적으로 사용자 입력에 대해 escape처리를 하고 있다.
만약 XSS가 뚫린다면??
js로 위조된 request를 보낼 수 있으므로 자동으로 request에 실리는 쿠키의 특성 상 안전하지 못한 건 똑같다.
위에서 본 이유들을 바탕으로 우리는 localStorage에 저장하는 방식을 택했다.
Cookie를 지지하는 사람들도 있지만 XSS 취약점만 빼면 가장 적절한 방식이라고 판단했기 때문이다.
최대한 선언적인 코드스타일을 지향하는 react의 특성을 살리기 위해 localStorage에서 토큰을 다루는 util 함수를 만들었다.
이런 helper function을 쓰면 컴포넌트 안에서 재사용 할 때 선언적으로 코드를 짤 수 있어서 좋다고 생각한다.
export function getAccessToken() {
return localStorage.getItem("accessToken");
}
export function getRefreshToken() {
return localStorage.getItem("refreshToken");
}
export function setToken(key, token) {
localStorage.setItem(key, token);
}
로그인 요청을 보내고 성공 여부와 응답을 반환하는 비동기 함수를 따로 분리해서 로그인 컴포넌트안의 로직을 단순화 했다.
// api.js
export async function login({ id, password }) {
try {
const response = await fetch("http://localhost:8080/user/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ id, password })
});
if (response.ok) {
const data = await response.json();
return {
success: true,
data
};
} else {
// 서버 에러코드에 따라 에러처리
return { success: false, message: "Login failed" };
}
} catch (error) {
console.error("Login error:", error);
return { success: false, message: error.toString() };
}
}
로그인 요청을 통해 응답을 받으면 토큰을 저장하고 리다이렉트 한다.
이렇게 하면 로그인이 완료된다.
// login component
...
const handleLogin = async (e) => {
e.preventDefault();
const { success, data, message } = await login({
id: userId,
password
});
if (success) {
setToken("accessToken", data.accessToken);
setToken("refreshToken", data.refreshToken);
navigate("/");
} else {
console.error(message);
}
};
...
react-router v6 data-router의 loader 사용하면 페이지에 접근하기 전에 함수를 실행할 수 있다.
따로 Wrapper 컴포넌트를 만들어서 인증 처리를 해야하나 하다가 알게 되었는데 아주 유용한 것 같다.
우리 프로젝트에서는 매 요청마다 토큰 만료를 확인하긴 하지만, 사용자가 권한이 있어야 접근할 수 있는 페이지로 들어왔을 때 최소한의 페이지 보호는 필요하다고 생각했다.
export function checkAuthLoader() {
const token = getAccessToken();
if (!token) {
return redirect("/login");
}
}
// app.jsx 로그인 필요한 페이지 예시
<Route path="subscription/form" loader={checkAuthLoader} element={<Subscribe />} />
권한이 필요한 페이지의 경우 페이지 접근시에 토큰이 없다면 login 페이지로 redirect 시키도록 했다.
인증이 필요한 요청을 보낼 때 마다 Bearer 토큰 타입으로 accessToken을 같이 보내는데 이 부분을 재사용할 수 있는 함수로 만들었다.
async function sendAuthRequest(url, options = {}) {
const accessToken = getAccessToken();
if (accessToken) {
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`
};
}
let response = await fetch(url, options);
// 액세스 토큰이 만료되었을 경우 로직, status 코드에 따라 변경 필요
if (response.status === 401) {
try {
const newAccessToken = await refreshAccessToken();
options.headers.Authorization = `Bearer ${newAccessToken}`;
response = await fetch(url, options);
} catch (error) {
console.error("Session expired. User needs to login again.");
logout();
}
}
return response;
}
accessToken이 만료되었을 때 재발급을 받는 코드
async function refreshAccessToken(refreshToken) {
const response = await fetch("/api/refresh_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
return data.accessToken;
} else {
throw new Error("Refresh token is invalid or expired.");
}
}
async function submitReview(reviewData) {
try {
const response = await sendAuthRequest("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(reviewData)
});
if (response.ok) {
console.log("Review submitted successfully.");
} else {
console.error("Failed to submit review.");
}
} catch (error) {
console.error("Error submitting review:", error);
}
}
프로젝트 데모에서는 메인 비즈니스 로직을 보여주는 것이 더 중요하다고 생각해서 최대한 로그인을 피해왔었다.
하지만 이번 프로젝트에서는 유저를 구분하는 것이 중요했기 때문에 로그인을 구현해봤는데 생각보다 고민거리가 더 많았다. 그래도 여러 고민들을 하면서 로그인 구현에 대한 두려움은 확실히 사라졌기 때문에 필요한 시간이었다고 생각한다.
https://blog.naver.com/h9911120/222310637750
https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies
https://medium.com/swlh/whats-the-secure-way-to-store-jwt-dd362f5b7914
https://reactrouter.com/en/main/route/loader