
단순한 응답의 한계: "왜 '네'라고만 하면 안 될까?"
서버가 "로그인 성공!"이라는 단순한 텍스트만 보낸다면, 누군가 그 응답을 조작해서 서버에 보낼 수 있습니다. 서버는 이 요청이 진짜 나로부터 온 것인지, 아니면 조작된 것인지 구별할 방법이 없기 때문이죠. 그래서 "서버가 발행했음을 증명할 수 있는 증거물"이 필요합니다.
세션(Session) vs 토큰(Token)
인증에는 크게 두 가지 대중적인 방법이 있습니다.
서버 측 세션 (Session): 서버가 "누가 로그인했는지"를 메모리에 기억하고 있는 방식입니다. 주로 프론트와 백엔드가 하나로 합쳐진 풀스택 앱에서 쓰입니다. 하지만 리액트처럼 서버와 클라이언트가 분리된 구조에서는 서버가 클라이언트의 상태를 저장하지 않는(Stateless) 방식이 더 효율적입니다.
인증 토큰 (Token - JWT): 서버는 로그인 성공 시 암호화된 문자열(토큰)을 만들어 클라이언트에게 던져줍니다. 서버는 이 정보를 따로 저장하지 않습니다. 나중에 클라이언트가 이 토큰을 다시 들고 오면, 서버는 자기가 가진 비밀키(Private Key)로 "내가 만든 게 맞나?"만 확인합니다.
생성: 백엔드만 아는 비밀키를 사용해 알고리즘으로 생성합니다.
검증: 클라이언트가 요청을 보낼 때 토큰을 함께 보내면, 백엔드의 미들웨어(Middleware)가 비밀키를 대조해 유효성을 검사합니다.
보안: 비밀키가 노출되지 않는 한, 외부에서 토큰을 위조하는 것은 거의 불가능합니다.
searchParams.get('mode'): URL 뒤에 붙는 ?mode=login 같은 쿼리 스트링을 읽어옵니다.
장점: 사용자가 페이지를 새로고침해도 로그인/회원가입 폼 상태가 유지되고, '뒤로 가기'를 눌러 이전 모드로 돌아갈 수도 있습니다.
<Link to={`?mode=${isLogin ? 'signup' : 'login'}`}>
{isLogin ? 'Create new user' : 'Login'}
</Link>
상대 경로: to 속성에 ?mode=...처럼 물음표로 시작하는 값을 주면, 현재 주소는 유지한 채 쿼리 스트링만 싹 바꿔줍니다.
method="post": 인증 정보(이메일, 비밀번호)는 보안이 중요하므로 URL에 노출되지 않도록 post 방식을 사용합니다.
name 속성: input 태그의 name="email", name="password"는 나중에 이 라우트에 연결될 action 함수에서 데이터를 추출할 때 열쇠가 됩니다.

토큰 ui변경
loader
root에 tokenLoader
토큰 만료
1단계: 로그인 (Authentication.js)
사용자가 ID/PW를 입력하고 제출했을 때 시작됩니다.
데이터 전송: action 함수가 실행되어 백엔드로 정보를 쏩니다.
토큰 수령: 서버가 "맞네!" 하고 토큰을 보내줍니다.
저장 (핵심!):
localStorage.setItem('token', token)으로 신분증 보관.
new Date()를 써서 "지금부터 1시간 뒤"라는 만료 시각을 계산해 expiration 이름으로 저장합니다.
이동: redirect('/')를 통해 홈으로 보냅니다.
2단계: 앱 초기화 및 상태 유지 (auth.js & App.js)
페이지를 새로고침하거나 다른 메뉴로 이동할 때 발생합니다.
루트 로더 실행: App.js에 설정된 tokenLoader가 돌아갑니다.
만료 체크 (getAuthToken):
auth.js의 getAuthToken이 호출됩니다.
여기서 getTokenDuration()을 써서 [저장된 만료 시각 - 현재 시각]을 계산합니다.
시간이 남았으면 토큰을 주고, 지났으면 'EXPIRED'를 던집니다.
UI 업데이트: MainNavigation 같은 곳에서 이 토큰 유무에 따라 '로그아웃 버튼'을 보여줄지 결정합니다.
3단계: 자동 로그아웃 감시 (Root.js)
앱이 켜져 있는 동안 RootLayout 컴포넌트가 계속 감시합니다.
Effect 실행: useEffect가 현재 토큰 상태를 봅니다.
타이머 설정:
getTokenDuration()으로 진짜 남은 시간이 얼마인지 계산합니다. (예: 40분 남음)
setTimeout을 그 시간만큼 걸어둡니다.
강제 제출: 시간이 다 되면 useSubmit을 통해 /logout 경로로 '로그아웃 해!'라는 요청을 자동으로 보냅니다.
4단계: 로그아웃 처리 (Logout.js)
사용자가 직접 눌렀든, 자동 타이머가 작동했든 상관없이 마지막은 여기서 끝납니다.
청소: 로컬 스토리지에서 token과 expiration을 싹 지웁니다.
완료: 다시 홈 페이지('/')로 리디렉션하며 끝납니다.

//Root.js
function RootLayout() {
const token = useLoaderData();
const submit = useSubmit();
// const navigation = useNavigation();
useEffect(() => {
if (!token) {
return;
}
if (token === 'EXPIRED') {
submit(null, { action: '/logout', method: 'post' });
return;
}
const tokenDuration = getTokenDuration();
console.log(tokenDuration);
setTimeout(() => {
submit(null, { action: '/logout', method: 'post' });
}, tokenDuration);
}, [token, submit]);
//auth.js
import { redirect } from 'react-router-dom';
export function getTokenDuration() {
const storedExpirationDate = localStorage.getItem('expiration');
const expirationDate = new Date(storedExpirationDate);
const now = new Date();
const duration = expirationDate.getTime() - now.getTime();
return duration;
}
export function getAuthToken() {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
const tokenDuration = getTokenDuration();
if (tokenDuration < 0) {
return 'EXPIRED';
}
return token;
}
export function tokenLoader() {
const token = getAuthToken();
return token;
}
export function checkAuthLoader() {
const token = getAuthToken();
if (!token) {
return redirect('/auth');
}
}
AuthenticationPage.js
import { json, redirect } from 'react-router-dom';
import AuthForm from '../components/AuthForm';
function AuthenticationPage() {
return <AuthForm />;
}
export default AuthenticationPage;
export async function action({ request }) {
const searchParams = new URL(request.url).searchParams;
const mode = searchParams.get('mode') || 'login';
if (mode !== 'login' && mode !== 'signup') {
throw json({ message: 'Unsupported mode.' }, { status: 422 });
}
const data = await request.formData();
const authData = {
email: data.get('email'),
password: data.get('password'),
};
const response = await fetch('http://localhost:8080/' + mode, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(authData),
});
if (response.status === 422 || response.status === 401) {
return response;
}
if (!response.ok) {
throw json({ message: 'Could not authenticate user.' }, { status: 500 });
}
const resData = await response.json();
const token = resData.token;
localStorage.setItem('token', token);
const expiration = new Date();
expiration.setHours(expiration.getHours() + 1);
localStorage.setItem('expiration', expiration.toISOString());
return redirect('/');
}