🔐 Access Token과 Refresh Token
단일 토큰을 사용하면 XSS 등의 공격에 취약하기 때문에 탈취를 방지하는 기술이 필요하다. 다중 토큰은 탈취 문제를 해결하기 위해 등장한 개념이다.
- Access Token: 인증이 완료된 사용자임을 증명하는 짧은 수명의 토큰
- 클라이언트가 API 요청 시 인증 수단으로 사용
- JWT 형식으로 발급되는 경우가 많음
- 토큰 안에 사용자 ID, 권한 정보, 만료 시간 등의 정보가 들어 있음
- 일반적으로 짧은 유효기간 (예: 15분)
- Refresh Token: Access Token이 만료됐을 때 새로운 Access Token을 발급받기 위한 장기 유효 토큰
- 길고 민감한 수명의 토큰 (예: 7일, 30일)
- 보통 서버 DB에도 저장됨 (토큰 탈취 감지용)
- 이 토큰을 서버에 보내면 새 Access Token을 받을 수 있음
- 직접 API 인증에는 사용되지 않음
| 구분 | Access Token | Refresh Token |
| 용도 | API 접근 인증 | Access Token 재발급 |
| 수명 | 짧음 (n분) | 김 (n일 ~ n주) |
| 저장 위치 (권장) | 메모리 | HttpOnly 쿠키 |
| 탈취 시 피해 정도 | 비교적 작음 (짧은 수명) | 큼 (계속해서 새 Access Token 발급 가능) |
JWT 기반 인증에서 Access Token과 Refresh Token은 보안성과 편의성 측면에서 적절한 위치에 저장하는 것이 중요하다.
- Access Token
- 권장 위치: 메모리 (예: React 상태, Redux store, JS 변수)
- 특성:
- 짧은 수명 (예: 5~15분)
- 자주 갱신되어도 됨
- XSS 공격에 노출될 위험 있음 → 로컬스토리지나 세션스토리지는 권장되지 않음
- 왜 메모리에?
- Access token은 노출되면 곧바로 API 접근이 가능하므로 XSS에 매우 취약함
- 브라우저가 새로고침되면 사라지지만, 그것이 오히려 보안 측면에서는 장점
- Refresh Token
- 권장 위치: HttpOnly + Secure 설정된 쿠키
- 특성:
- 긴 수명 (예: 7일~30일)
- Access token 재발급용
- 공격자가 탈취하면 무한 재발급 가능 → 반드시 보호 필요
- 왜 쿠키에?
- HttpOnly 쿠키는 자바스크립트로 접근할 수 없어서 XSS에 매우 강함
- Secure 플래그를 설정하면 HTTPS에서만 전송됨 → 중간자 공격(MITM)에 대한 방어
| 토큰 종류 | 저장 위치 | 이유 |
| Access Token | 메모리(React 상태 또는 JS 변수) | XSS에 안전하며 짧은 수명의 토큰은 메모리에 두는 것이 안전함 |
| Refresh Token | HttpOnly Secure 쿠키 | XSS로부터 안전하게 보호 가능, 장기 저장에 적합 |
- 보안 고려사항
- Access Token이 만료되었을 때 Refresh Token을 서버에 전송하여 새로운 Access Token을 받아오는 Token Rotation을 구현해야 함
- Refresh Token 탈취 감지를 위해 서버는 Refresh Token ID, 클라이언트 정보, IP 등을 서버 DB에 저장하고 검증해야 함
- 예시 아키텍처 (SPA 기준)
- 로그인 시 서버가 Access Token + Refresh Token 발급
- Access Token은 클라이언트 메모리에 저장
- Refresh Token은 HttpOnly, Secure 쿠키로 저장
- Access Token 만료 시 /refresh API 호출하여 새 Access Token 발급
- 로그아웃 시 쿠키 삭제 + 서버에 저장된 Refresh Token 무효화
📦 Redux Store
Access Token를 저장하고, 로그인 여부를 전역으로 관리하기 위해 앱 전체에서 공유하는 중앙 데이터 창고가 필요하다. Redux Store는 React 앱의 state를 전역에서 관리하는 저장소 역할을 한다.
- React만 쓸 때의 문제점
- 컴포넌트 내부에서 useState()로 데이터를 관리하면 부모 → 자식으로 props를 계속 전달해야 함
- 컴포넌트가 많아질수록 데이터 전달이 복잡해지고 여러 컴포넌트가 같은 데이터를 공유하려면 구조가 꼬임
- Redux 도입 시
- 전역 상태를 한 곳(Redux store)에 두고, 필요한 컴포넌트는 그 상태를 직접 읽거나 바꿀 수 있음 👍
- 모든 상태 변경은 추적 가능하며 예측 가능
- Redux Store의 구성요소
| 구성 요소 | 설명 |
| Store | 전체 앱의 상태(state)를 저장하는 공간 |
| Action | 상태에 "무슨 일이 일어났는지"를 설명하는 객체 |
| Reducer | 액션을 보고 상태를 "어떻게 바꿀지" 결정하는 함수 |
| Dispatch | 액션을 store에 보내는 함수 (state 변경 트리거) |
컴포넌트 → dispatch(action) → reducer → 새로운 상태 → store 업데이트 → 구독 컴포넌트에 반영
- Redux Store에 무엇을 저장할까?
- 사용자 인증 상태 (Access Token, 사용자 정보 등)
- UI 상태 (모달 열림/닫힘, 테마 등)
- 서버에서 받아온 데이터 캐시 (리스트 등)
- 페이지 간 공유할 값들
- 왜 Redux에 Access Token을 저장할까?
- Redux는 JS 메모리에 있는 상태 → XSS 공격에 비교적 안전하고 새로고침 시 초기화되어 노출 위험 ↓
- 앱 전체에서 인증이 필요한 컴포넌트들이 쉽게 접근 가능
React 프로젝트를 생성하면 기본적으로 제공되는 카운터 기능을 redux를 사용해 관리하는 예제를 구현해보자.
- Redux 모듈을 설치한다:
npm install @reduxjs/toolkit react-redux /app/store.js파일을 생성한다.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../feat/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
main.jsx파일을 수정한다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './css/index.css'
import App from './App.jsx'
import { Provider } from 'react-redux'; // ← 추가
import { store } from './app/store'; // ← 추가
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}> // ← 추가
<App />
</Provider> // ← 추가
</React.StrictMode>
);
feat/counterSlice.js파일을 생성한다.
import { createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
},
});
export const { increment } = counterSlice.actions;
export default counterSlice.reducer;
App.jsx파일을 수정한다.
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; // ← 추가
import { increment } from "./feat/counterSlice"; // ← 추가
function App() {
// const [count, setCount] = useState(0);
const dispatch = useDispatch(); // ← 추가
const count = useSelector((state) => state.counter.value); // ← 추가
const handleClick = () => {
dispatch(increment());
setAnimate(true);
setTimeout(() => setAnimate(false), 400);
};
return(…);
}
- SPA로 구현했다면 다른 uri로 이동해도 count 값이 초기화되지 않게 된다. (새로고침을 하면 다시 0으로 초기화된다.)
🔑 로그인 기능 수정하기 (Dual-Token System)
- JWT와 쿠키 파싱 모듈을 설치한다: npm install jsonwebtoken cookie-parser
- 서버단에서 auth.js 파일을 생성한다.
const jwt = require("jsonwebtoken");
const ACCESS_SECRET = "ACCESS_SECRET";
const REFRESH_SECRET = "REFRESH_SECRET";
function generateAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: "15m" });
}
function generateRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: "7d" });
}
module.exports = {
generateAccessToken,
generateRefreshToken,
ACCESS_SECRET,
REFRESH_SECRET,
};
- index.js (또는 로그인 관련 파일)을 수정한다.
const cookieParser = require('cookie-parser');
const { generateAccessToken, generateRefreshToken } = require("./auth");
app.post("/api/log-in", (req, res) => {
const { username, password } = req.body;
db.query("SELECT * FROM USERINFO WHERE username = ?", [username])
.then(([rows]) => {
/* 유효하지 않은 ID */
if (rows.length === 0) {
return res.status(401).json({ error: "Invalid ID" });
}
const user = rows[0];
const isPwCorrect = bcrypt.compareSync(password, user.password);
/* 비밀번호가 틀린 경우 */
if (!isPwCorrect) {
return res.status(401).json({ error: "Invalid password" });
}
/* JWT 토큰 생성 */
const payload = { id: user.id, username: user.username };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
return res.status(200)
.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: false, // 실제 배포할 때 true로 변경해야 함
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
})
.json({ accessToken, username: user.username });
})
.catch((err) => {
console.error(err);
res.status(500).json({ error: "Server error" });
});
});
- 클라이언트단에서 /feat/authSlice.js 파일을 생성한다.
import { createSlice } from "@reduxjs/toolkit";
const authSlice = createSlice({
name: "auth",
initialState: {
accessToken: null,
username: null,
isLoggedIn: false,
},
reducers: {
setCredentials: (state, action) => {
state.accessToken = action.payload.accessToken;
state.username = action.payload.username;
state.isLoggedIn = true;
},
clearAuth: (state) => {
state.accessToken = null;
state.username = null;
state.isLoggedIn = false;
},
},
});
export const { setCredentials, clearAuth } = authSlice.actions;
export default authSlice.reducer;
- /app/store.js 파일을 수정한다.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../feat/counterSlice";
import authReducer from "../feat/authSlice"; // ← 추가
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer, // ← 추가
},
});
- 로그인 구현 코드 파일을 수정한다.
import { useDispatch } from "react-redux";
import { setCredentials } from "./feat/authSlice";
function LoginForm({ mode }) {
const dispatch = useDispatch();
…
.then(({ status, data }) => {
if (status === 200) {
dispatch(setCredentials({
accessToken: data.accessToken,
username: data.username,
}));
navigate("/");
} else {
setLoginCheck(true);
}
})
…
}
프로젝트 주제 선정: 데일리 로그/회고 게시판 – TIL Board 📅
목표
하루 공부한 내용, 삽질 경험, 배운 내용을 기록하고 공유
특징
- 마크다운 지원 (또는 단순 텍스트도 가능)
- 날짜 자동 태깅
- 태그별 보기 (#오류해결 #SQL #SpringBoot)
- 댓글로 피드백 가능
기능 구현
- 회원가입 / 로그인
- JWT 기반 인증 ✅
- 닉네임, 프로필 사진(optional)
- TIL 게시글 작성
- 제목, 본문
- 마크다운 지원 (또는 코드 블록만 우선)
- 태그 (예: #React, #DB, #회고)
- 작성 날짜 자동 입력
- 게시글 목록 보기
- 최신순 / 태그별 / 사용자별 필터링
- 게시글 요약(제목, 날짜, 미리보기 텍스트)
- 게시글 상세보기
- 본문 마크다운 렌더링
- 댓글 작성 (피드백, 응원)
- 댓글 기능
- 로그인 사용자만 댓글 가능
- 댓글 수정/삭제 (본인만)
- 추가적 구현 아이디어 (optional)
- 작성한 TIL 히스토리 보기 (마이페이지)
- 좋아요 기능 또는 ‘공감돼요’ 버튼
- 주간 TIL 랭킹: 🔥이번 주 많이 본 TIL
- 글 검색 (제목/태그/내용 포함)
'Krafton Jungle > 6. Frameworks' 카테고리의 다른 글
| [WEEK14] Day 6 (0) | 2025.06.17 |
|---|---|
| [WEEK14] Day 5 (0) | 2025.06.16 |
| [WEEK14] Day 2 (0) | 2025.06.13 |
| [WEEK14] Day 1 (0) | 2025.06.12 |