spring boot

JWT를 이용한 인증-I(feat. react)

hoazzinews 2024. 12. 25. 08:45

이번 시간에는 JWT를 이용해서 인증(로그인)하는 방법에 대해서 살펴보겠습니다.

 

1. JWT란

JWT(Json Web Token)는 JSON 객체를 기반으로 하여 정보를 안전하게 전달하기 위한 토큰입니다. 주로 로그인과 같은 이증에 사용되면 정보 교환등에도 사용됩니다. JWT는 세 가지 주요 구성 요소로 이루어져 있으며, 각각은 점(.)으로 구분됩니다.

- Header: JWT의 유형(typ)과 사용된 서명 알고리즘(alg) 정보가 있습니다.

- Payload: 토큰에 포함된 데이터를 담고 있습니다.
  - 일반적으로 클레임(Claim)이라고 불리는 사용자나 토큰에 대한 정보를 포함합니다.
  - 등록된 클레임 (Registered claims): iss (발행자), exp (만료 시간), sub (주제) 등.
  - 공개 클레임 (Public claims): 사용자 정의 데이터.
  - 비공개 클레임 (Private claims): 특정 애플리케이션 간에 합의된 데이터.

- Signature: Header와 Payload를 합친 후 비밀키를 사용해 서명한 값으로, 데이터의 무결성과 인증을 보장합니다.

 

예) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

2. 프로젝트 구성

----

 

3. DB(MySQL) 구축

- DB_JWT 데이터베이스를 생성합니다.

-- DB_JWT database -------------------------
CREATE DATABASE DB_JWT;
USE DB_JWT;
-- -----------------------------------------

 

 

- MEMBER 테이블을 생성합니다.

-- MEMBER table ----------------------------
CREATE TABLE MEMBER (
    NO		INT AUTO_INCREMENT, 
    ID		VARCHAR(20) UNIQUE, 
    PW		VARCHAR(100) NOT NULL, 
    MAIL	VARCHAR(50) NOT NULL, 
    REG_DATE	DATETIME DEFAULT NOW(),
    MOD_DATE	DATETIME DEFAULT NOW(), 
    PRIMARY KEY(NO)
);
SELECT * FROM MEMBER;
DELETE FROM MEMBER;
DROP TABLE MEMBER;
-- -------------------------------------------

 

 

- REFRESH_TOKEN 테이블을 생성합니다.

-- REFRESH_TOKEN table -----------------------
CREATE TABLE REFRESH_TOKEN (
    NO			INT AUTO_INCREMENT, 
    OWNER_NO		INT NOT NULL, 
    REFRESH_TOKEN	TEXT NOT NULL, 
    REG_DATE		DATETIME DEFAULT NOW(), 
    MOD_DATE		DATETIME DEFAULT NOW(),
    PRIMARY KEY(NO)
);

SELECT * FROM REFRESH_TOKEN;
DELETE FROM REFRESH_TOKEN;
DROP TABLE REFRESH_TOKEN;
-- -------------------------------------------

 

4. react 프로젝트 생성

- CRA로 jwtex 프로젝트를 생성합니다.

> npx create-react-app jwtex

 

※ 참고. 프로젝트 생성 중 다음과 같은 에러가 발생할 수 있습니다.

 

에러는 react 버전 불일치로 현재 리액트의 안정화 버전은 18.x 인데 19.x가 설치되어서 일부 라이브러리에서 18.x 버전을 못찾아 발생한 오류입니다. 다음 과정을 따라합니다.

 

- 생성된 프로젝트 폴더로 진입 후 목록을 조회 합니다.

> cd myapp 

> dir 

 

- 목록에서 'node_modules' 폴더와 'package-lock.json' 파일을 삭제합니다.

 

- react와 react-dom의 18.x 버전을 설치합니다.

> npm install react@18 react-dom@18 

 

- 의존 모듈을 재 설치합니다.

> npm install 

 

- React 프로젝트에서 성능 측정에 사용되는 web-vitals는 패키지를 설치합니다.

> npm install web-vitals 

 

- 생성된 프로젝트를 실행 합니다.

> npm start 

 

 

5. index.js 수정

- index.js에서 불필요한 코드를 주석 또는 삭제해서 다음과 같이 수정합니다.

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  // <React.StrictMode>
    <App />
  // </React.StrictMode>
);

reportWebVitals();

 

6. css 파일 만들기

src 폴더에에 css 폴더를 만들고 home.css, signin.css, signup.css 파일을 만듭니다.

 

home.css

article .wrap {
    width: 700px;
    margin: 0 auto;
    text-align: center;
}

 

signin.css

article .wrap {
    width: 700px;
    margin: 0 auto;
    text-align: center;
}

 

signup.css

article .wrap {
    width: 700px;
    margin: 0 auto;
    text-align: center;
}

article .wrap input {
    width: 300px;
    padding: 10px;
    margin: 2px;
}

article .wrap button {
    width: 150px;
    padding: 10px;
}

 

7. 필요 모듈 설치

spring boot와 비동기 통신하기 위해서 axios와 http-proxy-middleware 모듈을 설치합니다.

> npm install axios 

> npm install http-proxy-middleware 

 

jwt를 디코딩하기 위해서 jwt-decode 모듈을 설치합니다.

> npm install jwt-decode 

 

 

8. Home 컴포넌트 만들기

comp 디렉터리에 Home.jsx파일을 만듭니다.

 

Home.jsx

import React, { useEffect } from "react";
import './css/home.css';

function Home({ setIsLoggedIn }) {

    // hooks
    useEffect(() => {
        console.log('[Home] useEffect()');


    }, []);

    return (
        <article>
            <div className="wrap">
                HOME
            </div>
        </article>
    );
}

export default Home;

 

9. SignUp 컴포넌트 만들기

comp 디렉터리에 SignUp.jsx파일을 만듭니다.

 

SignUp.jsx

import React, { useState } from "react";
import axios from "axios";
import './css/signup.css';
import { useNavigate } from "react-router-dom";

function SignUp() {

    // hooks
    const [id, setId] = useState('');
    const [pw, setPw] = useState('');
    const [mail, setMail] = useState('');

    const navigate = useNavigate();

    // handlers
    const signupBtnClickHandler = async () => {
        console.log('signupBtnClickHandler()');

        const requestBody = {
            id: id,
            pw: pw,
            mail: mail,
        };

        try {
            const response = await axios.post("http://localhost:8090/member/signup", requestBody, {
                headers: {
                    "Content-Type": "application/json",
                },
            });

            if (response.status === 200) {
                alert("회원가입 성공: " + JSON.stringify(response.data.message));
                navigate("/");

            } else {
                alert("회원가입 실패: " + response.data.message);

            }
        } catch (error) {
            if (error.response) {
                // 서버 응답이 있는 경우
                alert("회원가입 실패: " + error.response.data.message);

            } else {
                // 요청 실패 또는 기타 에러
                console.error("Error during sign-up:", error);
                alert("회원가입 중 오류가 발생했습니다.");
                
            }
        }
    };

    return (
        <article>
            <div className="wrap">
                <input 
                    type="text" 
                    value={id} 
                    placeholder="INPUT ID" 
                    onChange={(e) => {
                        setId(e.target.value);
                    }}/><br />
                <input 
                    type="password" 
                    value={pw} 
                    placeholder="INPUT PW" 
                    onChange={(e) => {
                        setPw(e.target.value);
                    }}/><br />
                <input 
                    type="text" 
                    value={mail} 
                    placeholder="INPUT MAIL" 
                    onChange={(e) => {
                        setMail(e.target.value);
                    }}/><br />
                <button onClick={signupBtnClickHandler}>SIGN-UP</button>
            </div>
        </article>
    );
}

export default SignUp;

 

10. SignIn 컴포넌트 만들기

comp 디렉터리에 SignIn.jsx파일을 만듭니다.

 

SignIn.jsx

import React, { useState } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";
import './css/signin.css';

function SignIN({ setIsLoggedIn }) {

    // hooks
    const [id, setId] = useState('');
    const [pw, setPw] = useState('');

    const navigate = useNavigate();

    const signinBtnClickHandler = async () => {
        console.log('signinBtnClickHandler()');
    
        const requestBody = {
            id: id,
            pw: pw
        };
    
        try {
            const response = await axios.post(
                "http://localhost:8090/member/signin", 
                requestBody, 
                {
                    headers: {
                        "Content-Type": "application/json",
                    },
                    withCredentials: true, // 쿠키 포함
                }
            );
    
            console.log('response.status: ', response.status);
    
            if (response.status === 200) {
                const result = response.data;
                alert("로그인 성공: " + result.signInResponse.message);
    
                // 액세스 토큰을 로컬 스토리지에 저장
                localStorage.setItem("accessToken", result.tokenResponse.accessToken);
    
                setIsLoggedIn(true);
                navigate('/');
            } else {
                alert("로그인 실패: " + response.data.signInResponse.message);
            }
    
        } catch (error) {
            if (error.response) {
                // 서버 응답 에러
                alert("로그인 실패: " + error.response.data.signInResponse.message);
            } else {
                // 네트워크 오류 또는 기타 에러
                console.error("Error during sign-in:", error);
                alert("로그인 중 오류가 발생했습니다.");
            }
        }
    }

    return (
        <article>
            <div className="wrap">
                <input 
                    type="text" 
                    value={id} 
                    placeholder="INPUT ID" 
                    onChange={(e) => {
                        setId(e.target.value);
                    }}/><br />
                <input 
                    type="password" 
                    value={pw} 
                    placeholder="INPUT PW" 
                    onChange={(e) => {
                        setPw(e.target.value);
                    }}/><br />
                <button onClick={signinBtnClickHandler}>SIGN-IN</button>
            </div>
        </article>
    );
}

export default SignIN;

 

 

11. Modify 컴포넌트 만들기

comp 디렉터리에 Modify.jsx파일을 만듭니다.

 

Modify.jsx

import { jwtDecode } from "jwt-decode";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";

function Modify({ setIsLoggedIn }) {

    // hooks
    const [id, setId] = useState('');
    const [pw, setPw] = useState('');
    const [mail, setMail] = useState('');

    useEffect(() => {
        console.log("[Modify] useEffect()");

        const accessToken = localStorage.getItem("accessToken"); // 로컬 스토리지에서 액세스 토큰 가져오기
        
        if (accessToken) {
            try {
                const decodedToken = jwtDecode(accessToken); // JWT 디코딩
                console.log("디코딩된 토큰: ", decodedToken);
        
                // ID(username) 추출
                const username = decodedToken.username;
                console.log("사용자 ID(username): ", username);
                
                // 시간 추출
                const exp = decodedToken.exp;
                const iat = decodedToken.iat;
                console.log("발급 시간 (KST): ", new Date(iat * 1000).toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }));
                console.log("만료 시간 (KST): ", new Date(exp * 1000).toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }));

                // 사용자 세부 정보 가져오기
                fetchUserInfo(username);

            } catch (error) {
                console.error("토큰 디코딩 실패: ", error);
            }

        } else {
            console.log("액세스 토큰이 없습니다.");

        }

    }, []);

    const navigate = useNavigate();

    // handlers
    const modifyBtnClickHandler = async () => {
        console.log("[Modify] modifyBtnClickHandler()");
    
        const requestBody = {
            id: id,
            pw: pw,
            mail: mail,
        };
    
        try {
            const response = await axios.post(
                "http://localhost:8090/member/modify",
                requestBody,
                {
                    headers: {
                        Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
                        "Content-Type": "application/json",
                    },
                    withCredentials: true, // 쿠키 포함
                }
            );
    
            if (response.status === 200) {
                const result = response.data;
                alert("회원수정 성공: " + JSON.stringify(result.modifyResponse.message));
                navigate('/');
            } else {
                alert("회원수정 실패: " + response.data.message);
            }
        } catch (error) {
            if (error.response) {
                // 서버 응답 에러
                alert("회원수정 실패: " + error.response.data.message);
            } else {
                // 네트워크 오류 또는 기타 에러
                console.error("Error during modify:", error);
                alert("회원수정 중 오류가 발생했습니다.");
            }
        }
    };

    // etc
    const fetchUserInfo = async (username) => {
        const params = {
            id: username,
        };
    
        try {
            const response = await axios.get("http://localhost:8090/member/getuserinfo", {
                params: params,
                headers: {
                    Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
                    "Content-Type": "application/json",
                },
                withCredentials: true, // 쿠키 포함
            });
    
            const data = response.data;
            console.log("가져온 정보: ", data);
    
            setId(data.member.id);        // 상태에 사용자 ID 저장
            setMail(data.member.mail);    // 상태에 사용자 메일 저장
    
            // 액세스 토큰을 로컬 스토리지에 저장
            localStorage.setItem("accessToken", data.userInfoResponse.accessToken);

        } catch (error) {
            console.error("사용자 정보 가져오기 실패: ", error);
    
            try {
                await axios.post("http://localhost:8090/member/signout", {}, {
                    withCredentials: true, // 쿠키 포함
                });
    
                // 성공적으로 로그아웃되었을 때 추가 작업 필요 시 작성
            } catch (logoutError) {
                console.error("로그아웃 실패: ", logoutError);
            } finally {
                localStorage.removeItem("accessToken"); // 로컬 스토리지 토큰 삭제
                setIsLoggedIn(false); // 로그인 상태 업데이트
                navigate("/"); // 홈 화면으로 이동
            }
        }
    };

    return (
        <article>
            <div className="wrap">
                <input 
                    type="text" 
                    value={id} 
                    placeholder="INPUT ID" 
                    onChange={(e) => {
                        setId(e.target.value);
                    }}/><br />
                <input 
                    type="password" 
                    value={pw} 
                    placeholder="INPUT PW" 
                    onChange={(e) => {
                        setPw(e.target.value);
                    }}/><br />
                <input 
                    type="text" 
                    value={mail} 
                    placeholder="INPUT MAIL" 
                    onChange={(e) => {
                        setMail(e.target.value);
                    }}/><br />
                <button onClick={modifyBtnClickHandler}>MODIFY</button>
            </div>
        </article>
    )
}

export default Modify;

 

 

12. react-router-dom 모듈 설치

라우터르 사용하기 위해서 react-router-dom을 설치합니다.

 

> npm install react-router-dom 

 

 

13. App.js 수정하기

App.js를 다음과 같이 수정합니다.

import React, { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import './App.css';
import Home from './comp/Home';
import axios from "axios";
import SignIn from './comp/SignIn';
import SignUp from './comp/SignUp';
import Modify from "./comp/Modify";

function App() {

    // hooks
    const [isLoggedIn, setIsLoggedIn] = useState(false);

    useEffect(() => {
        console.log('[App] useEffect(): ');

        const token = localStorage.getItem("accessToken");
        setIsLoggedIn(!!token); // 토큰이 있으면 로그인 상태로 설정

    }, [isLoggedIn]);

    // handlers
    const signoutBtnClickHandler = async () => {
        console.log('signoutBtnClickHandler()');

        try {
            const response = await axios.post(
                "http://localhost:8090/member/signout",
                {},
                {
                    withCredentials: true, // 쿠키 포함
                }
            );

            if (response.status === 200) {
                // 성공적으로 로그아웃되었을 때
                localStorage.removeItem("accessToken"); // 로컬 스토리지 토큰 삭제
                setIsLoggedIn(false); // 로그인 상태 업데이트

                alert(response.data.message);
            } else {
                // 오류 처리
                console.error("SIGNOUT FAIL!!");
            }
        } catch (error) {
            if (error.response) {
                // 서버 응답 에러 처리
                console.error("로그아웃 실패: ", error.response.data);
            } else {
                // 네트워크 오류 또는 기타 에러 처리
                console.error("Error: ", error);
            }
        }
    };

    return (
        <BrowserRouter>
            <header>
                JWTEX
            </header>
            <nav>
                {isLoggedIn 
                ?
                <>
                    <div className="warp">
                        <Link to="/" >Home</Link> &nbsp;&nbsp; | &nbsp;&nbsp;
                        <Link to="/modify" >MODIFY</Link> &nbsp;&nbsp; | &nbsp;&nbsp;
                        <Link to="/" onClick={signoutBtnClickHandler}>SIGN-OUT</Link> 
                    </div>
                </>
                :
                <>
                    <div className="warp">
                        <Link to="/" >Home</Link> &nbsp;&nbsp; | &nbsp;&nbsp;
                        <Link to="/signin" >SIGN-IN</Link> &nbsp;&nbsp; | &nbsp;&nbsp;
                        <Link to="/signup" >SIGN-UP</Link> 
                    </div>
                </>}
                
            </nav>
            <div>
                <Routes>
                    <Route path="/" element={<Home setIsLoggedIn={setIsLoggedIn} />} />
                    <Route path="/signin" element={<SignIn setIsLoggedIn={setIsLoggedIn} />} />
                    <Route path="/signup" element={<SignUp />} />
                    <Route path="/modify" element={<Modify setIsLoggedIn={setIsLoggedIn} />} />
                </Routes>
            </div>
        </BrowserRouter>
    );
}

export default App;

 

14. App.css 수정

App.css 파일을 다음과 같이 수정합니다.

header {
  height: 50px;
  line-height: 50px;
  text-align: center;
  border-bottom: 1px solid #ccc;
  font-weight: bold;
}

nav {
  border-bottom: 1px solid #ccc;
}

nav .warp {
  width: 700px;
  margin: 0 auto;
  height: 50px;
  line-height: 50px;
  text-align: right;
}

 

15. 프록시 설정

프로젝트 루트에 setupProxy.js 파일을 만듭니다.

const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function (app) {
    app.use(
        '/',                             	 // 앤드포인트 '/' 경로에 대해 프록시 적용
        createProxyMiddleware({
            target: 'http://localhost:8090',    // Spring Boot 서버 주소
            changeOrigin: true,
        })
    );
};

 

 

16. 프로젝트 실행

프로젝트를 실행하고 브라우저에서 확인합니다.

 

react 소스 첨부합니다.

jwtex.zip
0.18MB