상세 컨텐츠

본문 제목

React 로그인 구현 (JWT)

Front/React

by Chan.94 2025. 2. 9. 08:00

본문

반응형

토근 저장 방법 (HTTPOnly 쿠키 / localStorage)

HTTPOnly 쿠키

장점

  • 보안 클라이언트 측 스크립트(JavaScript)에서 접근할 수 없기 때문에 XSS(Cross-Site Scripting) 공격으로부터 보호된다
  • 자동 전송 요청을 보낼 때 브라우저가 자동으로 쿠키를 포함시켜 주기 때문에 개발자가 직접 토큰을 헤더에 추가할 필요가 없다
  • SameSite 속성 쿠키의 SameSite 속성을 통해 CSRF(Cross-Site Request Forgery) 공격을 방지할 수 있다

단점

  • 특정 도메인 제한 특정 도메인에만 저장되므로, 도메인 간에 토큰을 공유하기 어려울 수 있다

localStorage

장점

  • 간편함 데이터를 저장하고 불러오는 방법이 간단하다
  • 브라우저 전역 접근 동일한 도메인 내의 모든 탭과 창에서 접근할 수 있다
  • 클라이언트 측 컨트롤 개발자가 자유롭게 데이터를 관리할 수 있다

단점

  • 보안 취약성 localStorage에 저장된 데이터는 클라이언트 측 스크립트에서 접근 가능하기 때문에 XSS 공격에 취약하다
  • 토큰 관리 API 요청 시마다 토큰을 헤더에 수동으로 추가해야 한다

로그인 구현(JWT 토큰, 재발급 로직 포함)

axios.js 전체소스

더보기
import axios from 'axios';
import { getGlobalModal } from '../context/ModalContext';
import { getGlobalNavigate } from '../service/NavigationService';

// Axios 인스턴스 생성
const axiosInstance = axios.create({
    baseURL: '/main-dev',   // 프록시 경로
    //timeout: 5000,          // 요청 타임아웃 설정 (ms)
    headers: {
      'Content-Type': 'application/json',
    },
});

// 토큰 갱신
const refreshAccessToken = async() => {

    const response = await axiosInstance.post('/auth/refresh'
                                            , { token : localStorage.getItem('refreshToken')}
                                            );
    
    // refresh token expire 403 Error 
    if(response.data.status === 403){
        return false;
    }
    const responseHeader = response.headers;
    const responseData = response.data.data;

    localStorage.clear();
    localStorage.setItem('accessToken', responseHeader.authorization);
    localStorage.setItem('refreshToken', responseData.token);

    axiosInstance.defaults.headers.common['Authorization'] = `${responseHeader.authorization}`;
    return true;
}

// 요청 인터셉터
axiosInstance.interceptors.request.use(
    (config) => {
      // 요청 헤더 설정
      const accessToken = localStorage.getItem('accessToken');
      //console.log(accessToken);
      if (accessToken) {
        config.headers.Authorization = `${accessToken}`;
      }
      return config;
    },
    (error) => {
      return Promise.reject(error);
    }
);

// 공통 요청 함수
const axiosRequest = async (method, url, data = null, fn_callback, bResponseOnlyData = true) => {

    const openModal = getGlobalModal();

    try {
        const response = await axiosInstance({
            method,
            url,
            data,
        });
        
        if(response == null){
            return ;
        }

        var res = response;
        console.log(res);

        if(bResponseOnlyData){
            res = res.data.data;
        }

        if(response.data.status !== 200){
            openModal("E", `[${response.data.status}] ${response.data.message}`);
            return;
        }

        if (fn_callback && typeof fn_callback === "function") {
            fn_callback(res);
        }
    } catch (error) {
        fn_axiosError(error, openModal);
    }
};

const fn_axiosError = async(error, openModal) => {

    const navigate = getGlobalNavigate();
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true; // 중복 호출 방지

        const tokenRefreshed = await refreshAccessToken();
        if (tokenRefreshed) {
            return axiosInstance(originalRequest); // 새 토큰으로 재시도
        }

        // 토큰 갱신 실패 시 로그인 페이지로 이동
        openModal("A", '로그인 토큰이 만료되었습니다');
        localStorage.clear();
        navigate('/Login');
        return;
    }

    // 기타 에러 처리
    console.log(error);
    openModal("E", `Transaction failed: ${error.message}`);
}

export default axiosRequest; // 기본 익스포트 사용

요청 인터셉터

  • 모든 Request에 대해 공통적인 로직을 추가한다.
  • api전송 시 axios 인터셉터를 이용하여 accessToken 설정
  • 로그인 시 accessToken을 localStorage에 저장한다.
  • accessToken이 필요 없는 api들은 backend에서 springsecurity에 별도 로직을 통해 구현하여 구분한다.

401 Error

  • 로그인 시 AccessToken과 RefreshToken을 서버로부터 발급받는다. AccessToken이 만료된 경우 RefreshToken을 이용하여 AccessToken을 재발급받고 다시 요청을 진행한다. (RefreshToken이 AccessToken보다 만료시간이 길다)
  • RefreshToken도 만료된 경우 로그인화면으로 이동한다.

Access Token 재발급

  • 403 Error Refresh Token이 만료된 경우 403 Error를 반환하도록 서버에 설정함.
  • 재발급받은 토큰을 localStorage에 저장하고 새로운 AccessToken을 header정보에 저장한다.

App.js 전체소스

import React, { useEffect } from 'react';
import { BrowserRouter, Route, Routes, Navigate, useNavigate } from 'react-router-dom';
import CombinedProvider from './context/CombinedProvider';
import { setGlobalNavigate } from './service/NavigationService';
import LoginPage from './pages/LoginPage';
import MainPage from './pages/MainPage';

const App = () => {
  const NavigateInitializer = () => {
    const navigate = useNavigate();
    useEffect(() => {
      setGlobalNavigate(navigate);
    }, [navigate]);
    return null; // 이 컴포넌트는 UI를 렌더링하지 않음
  };

  return (
    <CombinedProvider>
      <BrowserRouter>
        <NavigateInitializer />
        <Routes>
          <Route path="/" element={<LoginPage />} />
          <Route path="/login" element={<LoginPage />} />
          <Route path="/main" element={<MainPage />} />
          <Route path="*" element={<Navigate to="/login" />} />
        </Routes>
      </BrowserRouter>
    </CombinedProvider>
  );
}

export default App;

NavigationService.js

let globalNavigate = null;

export const setGlobalNavigate = (navigate) => {
  globalNavigate = navigate;
};

export const getGlobalNavigate = () => {
  if (!globalNavigate) {
    throw new Error("Navigate function is not set. Make sure to initialize it.");
  }
  return globalNavigate;
};

axios.js에서 navigate를 사용하기 위해 전역으로 관리


LoginPage.js 전체소스

더보기
import React, { useEffect, useState } from 'react';
import "./LoginPage.css";
import axiosRequest from '../api/axios';
import { getGlobalNavigate } from '../service/NavigationService';
import { useModal } from '../context/ModalContext';
import { useAuth } from '../context/AuthContext';

function LoginPage() {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState('');
    const navigate = getGlobalNavigate();
    const { openModal } = useModal();
    const { login, logout } = useAuth();

    useEffect(() => {
        localStorage.clear();
        logout();
    }, []);

    const handleEmailChange = (e) => {
        setEmail(e.target.value);
    };
    
    const handlePasswordChange = (e) => {
        setPassword(e.target.value);
    };

    const inputValidation = () => {
        if(!email){
            openModal("A", "이메일을 입력해주세요.");
            return false;
        }
        if(!password){
            openModal("A", "비밀번호 입력해주세요.");
            return false;
        }

        return true;
    }

    const handleLogin = (e) => {
        //<form> 태그의 기본 동작은 페이지 새로고침(reload)을 발생시키므로, onSubmit에서 기본 동작을 막아야함
        e.preventDefault();

        if(!inputValidation()){
            return;
        }

        axiosRequest("post"                     //method
                    , "/auth/login"             //url
                    , { email, password }       //data
                    , fn_loginCallback          //callback
                    , false                     //true : OnlyData, false : Original Response
        );
    };

    const fn_loginCallback = (response) => {

        const responseHeader = response.headers;
        const responseData = response.data.data;

        // localStorage에 토큰정보 저장
        localStorage.clear();
        localStorage.setItem('accessToken', responseHeader.authorization);
        localStorage.setItem('refreshToken', responseData.refreshToken);
        
        responseData.refreshToken = "";
        login(responseData);

        //메인화면 이동
        navigate('/main');
    }

    return (
        <div className="login-container">
            <h2>로그인</h2>
            <form onSubmit={handleLogin} className="login-form">
                <div className="input-group">
                    <label htmlFor="email">이메일</label>
                    <input
                        type="email"
                        id="email"
                        value={email}
                        onChange={handleEmailChange}
                        className="input-field"
                    />
                </div>
                <div className="input-group">
                    <label htmlFor="password">비밀번호</label>
                    <input
                        type="password"
                        id="password"
                        value={password}
                        onChange={handlePasswordChange}
                        className="input-field"
                    />
                </div>
                <button type="submit" className="login-btn" id="default-login">
                로그인
                </button>
            </form>
            <div>
                <button type="submit" className="login-btn" id="kakao-login">
                카카오 로그인
                </button>
                <button type="submit" className="login-btn" id="naver-login">
                네이버버 로그인
                </button>
            </div>
        </div>
    );
}

export default LoginPage;

로그인

  • e.preventDefault();
    <form> 태그의 기본 동작은 페이지 새로고침(reload)을 발생시키므로, onSubmit에서 기본 동작을 막는다.
  • 토큰정보 localStorage에 저장
  • navigate를 사용하여 화면 이동

LoginPage.css

더보기
.login-container {
    max-width: 400px;
    margin: 0 auto;
    padding: 20px;
    border: 1px solid #ccc;
    border-radius: 8px;
    background-color: #f9f9f9;
  }
  
  h2 {
    text-align: center;
    margin-bottom: 20px;
  }
  
  /* 폼 스타일 */
  .login-form {
    display: flex;
    flex-direction: column;
  }
  
  .input-group {
    margin-bottom: 15px;
    margin-right: 15px;
  }
  
  label {
    font-weight: bold;
    margin-bottom: 5px;
    display: block;
  }
  
  .input-field {
    width: 100%;
    padding: 8px;
    margin-top: 5px;
    border-radius: 4px;
    border: 1px solid #ccc;
  }
  
  .error-message {
    color: red;
    margin-bottom: 15px;
    font-size: 14px;
  }
  
  .login-btn {
    width: 100%;
    padding: 10px;
    margin: 4px 4px 4px 4px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  #default-login {
    background-color: #AAAAAA;
    color: white;
  }
  #default-login:hover {
    background-color: black;
    color: yellow;
  }
  #kakao-login {
    background-color: yellow;
    color: black;
  }
  #kakao-login:hover {
    background-color: black;
    color: yellow;
  }
  #naver-login {
    background-color: #4CAF50;
    color: white;
  }
  #naver-login:hover {
    background-color: black;
    color: yellow;
  }

MainPage.js 전체소스

더보기
import React, { useEffect, useState, lazy } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import './MainPage.css';

function MainPage() {
    const navigate = useNavigate();
    const { user, logout } = useAuth();

    const [isMenuOpen, setIsMenuOpen] = useState(true); // 메뉴 열림/닫힘 상태
    const [menuItems, setMenuItems] = useState([]); // 메뉴 데이터
    const [selectedMenu, setSelectedMenu] = useState(null); // 선택된 메뉴
    const [selectedComponent, setSelectedComponent] = useState(null);   // 동적 컴포넌트

    useEffect(() => {
        if(user == null){
            fn_logout();
        }else{
            fn_init();
        }
    }, []);

    useEffect(() => {
        if (selectedMenu) {
            // 선택된 메뉴의 컴포넌트 로드
            const menu = menuItems.find(item => item.id === selectedMenu.id);
            if (menu) {
                const Component = lazy(() => import(`../components/${menu.componentPath}`));
                setSelectedComponent(<Component />);
            }
        }
    }, [selectedMenu, menuItems]);

    const fn_toggleMenu = () => {
        setIsMenuOpen(!isMenuOpen);
    };

    const fn_init = () => {
        fn_searchMenuItems();
    };
    const fn_searchMenuItems = async () => {
        // DB에서 메뉴 데이터 가져오기
        var db = [
                    { "id": "1", "menuName": "Auth Test", "componentPath": "AuthTest.js" },
                    { "id": "2", "menuName": "Props and State Example", "componentPath": "ParentComp.js" }
                ];
        setMenuItems(db);
    };

    const fn_logout = () => {
        localStorage.clear();
        logout();
        navigate('/Login');
    }

    const handleLogout = (e) => {
        fn_logout();
    };

    return (
        <div className="main-page">
            <header className="header">
                <div className='userInfo'>
                    {user && (
                        <>
                            <div>{user.userId} / {user.userName}</div>
                            <button className="logout-btn" onClick={handleLogout}>
                                Logout
                            </button>
                        </>
                   )}
                </div>
            </header>
            <div className="content">
                <button className="toggle-btn" onClick={fn_toggleMenu}>
                    {isMenuOpen ? '◀' : '▶'}
                </button>
                <aside className={`left ${isMenuOpen ? 'open' : 'closed'}`}>
                    <ul className="menu-item">
                        {menuItems.map((item, index) => (
                            <li key={index} onClick={() => setSelectedMenu(item)}>
                                {item.menuName}
                            </li>
                        ))}
                    </ul>
                </aside>
                <main className="main">
                    {selectedMenu && (<h1>{selectedMenu.menuName}</h1>)}
                    {selectedComponent}
                </main>
            </div>
        <footer className="footer">Footer</footer>
        </div>
    );
}

export default MainPage;

React lazy

동적으로 컴포넌트를 불러올 수 있게 해주는 함수
이 기능을 사용하면 컴포넌트의 코드를 처음 렌더링될 때까지 로딩을 지연시킬 수 있음

 

MainPage.css

더보기
.main-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

/*Header Area Start*/
.header {
  display: flex; 
  justify-content: space-between; 
  align-items: center; 
  padding: 10px; 
  background-color: #4CAF50; 
  border-bottom: 1px solid #ddd;
  
}
.userInfo {
  display: flex;
  align-items: center;
  justify-content: flex-end; /* 요소를 오른쪽으로 정렬 */ 
  flex-grow: 1; /* 요소가 남는 공간을 차지하게 함 */
}
.logout-btn { 
  margin-left: 20px; 
  padding: 5px 10px; 
  background-color: #ff4b4b; 
  color: white; 
  border: none; 
  border-radius: 5px; 
  cursor: pointer;
} 
.logout-btn:hover { 
  background-color: #e04343;
}
/*Header Area End*/

/*content Area Start*/
.content {
  display: flex;
  flex: 1;
  position: relative;
}

.toggle-btn {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  z-index: 10;
  background: #f0f0f0;
  border: none;
  padding: 0;
  cursor: pointer;
}

.left {
  width: 200px;
  background-color: #f4f4f4;
  padding: 10px;
  transition: all 0.3s ease;
}
.left.closed {
    width: 0;
    overflow: hidden;
}

.menu-item {
  cursor: pointer;
}
/*content Area End*/

.main {
  flex: 1;
  padding: 10px;
}

.footer {
  background-color: #ddd;
  color: black;
  padding: 10px;
  text-align: center;
}

마무리

기본 ID/PW를 사용한 로그인은 구현되었다. 이후 OAuth를 사용한 로그인을 구현해 보자.

 

이전글

다음 글

  •  
반응형

'Front > React' 카테고리의 다른 글

React Modal 구현하기 (Alter, Loading)  (1) 2025.02.08
React axios 모듈화  (0) 2025.02.07
React-Router-Dom 정리  (0) 2025.02.06
React Hook 기본 정리 / Custom Hook  (1) 2025.02.05
React CORS 설정 - 프록시 (http-proxy-middleware)  (7) 2025.01.31

관련글 더보기

댓글 영역

>