JWT를 이용한 인증-I(feat. react)
이번 시간에는 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> |
<Link to="/modify" >MODIFY</Link> |
<Link to="/" onClick={signoutBtnClickHandler}>SIGN-OUT</Link>
</div>
</>
:
<>
<div className="warp">
<Link to="/" >Home</Link> |
<Link to="/signin" >SIGN-IN</Link> |
<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 소스 첨부합니다.