munsik22 2025. 6. 14. 11:01

🔐  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은 보안성과 편의성 측면에서 적절한 위치에 저장하는 것이 중요하다.

  1. Access Token
    • 권장 위치: 메모리 (예: React 상태, Redux store, JS 변수)
    • 특성:
      • 짧은 수명 (예: 5~15분)
      • 자주 갱신되어도 됨
      • XSS 공격에 노출될 위험 있음 → 로컬스토리지나 세션스토리지는 권장되지 않음
    • 왜 메모리에?
      • Access token은 노출되면 곧바로 API 접근이 가능하므로 XSS에 매우 취약함
      • 브라우저가 새로고침되면 사라지지만, 그것이 오히려 보안 측면에서는 장점
  2. 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 기준)
    1. 로그인 시 서버가 Access Token + Refresh Token 발급
    2. Access Token은 클라이언트 메모리에 저장
    3. Refresh Token은 HttpOnly, Secure 쿠키로 저장
    4. Access Token 만료 시 /refresh API 호출하여 새 Access Token 발급
    5. 로그아웃 시 쿠키 삭제 + 서버에 저장된 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)
  • 댓글로 피드백 가능

기능 구현

  1. 회원가입 / 로그인
    • JWT 기반 인증 ✅
    • 닉네임, 프로필 사진(optional)
  2. TIL 게시글 작성
    • 제목, 본문
    • 마크다운 지원 (또는 코드 블록만 우선)
    • 태그 (예: #React, #DB, #회고)
    • 작성 날짜 자동 입력
  3. 게시글 목록 보기
    • 최신순 / 태그별 / 사용자별 필터링
    • 게시글 요약(제목, 날짜, 미리보기 텍스트)
  4. 게시글 상세보기
    • 본문 마크다운 렌더링
    • 댓글 작성 (피드백, 응원)
  5. 댓글 기능
    • 로그인 사용자만 댓글 가능
    • 댓글 수정/삭제 (본인만)
  6. 추가적 구현 아이디어 (optional)
    • 작성한 TIL 히스토리 보기 (마이페이지)
    • 좋아요 기능 또는 ‘공감돼요’ 버튼
    • 주간 TIL 랭킹: 🔥이번 주 많이 본 TIL
    • 글 검색 (제목/태그/내용 포함)