
본 시리즈는 작성자의 이해와 경험을 바탕으로 실습 위주의 설명을 기반으로 작성되었습니다.
실습 위주의 이해를 목표로 하기 때문에 다소 과장이 많고 생략된 부분이 많을 수 있습니다.
따라서, 이론적으로 미흡한 부분이 있을 수 있는 점에 대해 유의하시기 바랍니다.
또한, 본 시리즈는 ChatGPT의 도움을 받아 작성되었습니다.
수 차례의 질문을 통해 도출된 여러가지 다양한 방식의 코드를 종합하여
작성자의 이해와 경험을 바탕으로 가장 정석으로 생각되는 코드를 재정립하였습니다.
SpringBoot에서 구현한JWT인증시스템을,React에서 사용하는 방법입니다.
회원가입 및 로그인 처리를 위해React Custom API를 구현하고 사용하는 방법입니다.
AuthAPI.js
import axios from "axios";
const TOKEN_TYPE = localStorage.getItem("tokenType");
let ACCESS_TOKEN = localStorage.getItem("accessToken");
/** CREATE CUSTOM AXIOS INSTANCE */
export const AuthApi = axios.create({
baseURL: 'http://localhost:8080',
headers: {
'Content-Type': 'application/json',
'Authorization': `${TOKEN_TYPE} ${ACCESS_TOKEN}`,
},
});
/** LOGIN API */
export const login = async ({ username, password }) => {
const data = { username, password };
const response = await AuthApi.post(`/api/v1/auth/login`, data);
return response.data;
}
/** SIGNUP API */
export const signUp = async ({ username, password }) => {
const data = { username, password };
const response = await AuthApi.post(`/api/v1/auth/signup`, data);
return response.data;
}
Axios인스턴스header에Authorization을 추가하는 것이 관건입니다.
일반적으로'Authorization': 'Bearer {token}'의 형태로 포함시킵니다.
TOKEN_TYPE,ACCESS_TOKEN은 로그인 이후 값이 할당되어 사용할 수 있습니다.
사실, 회원가입 및 로그인 단계에서Authorization,AccessToken은 필요없습니다.
로그인 이후header추가에 거부감을 줄이고 익숙해지고자 편의상 사용하였습니다.
간단한 예시를 위해
username,password두 개의 파라미터만 사용하였습니다.
요구사항에 따라contact,address등 필요한 파라미터를 추가하기 바랍니다.
간단하게
username,password를 입력으로 받는form을 작성하였습니다.
handleChange,handleSubmit이 주요 비동기 함수로 사용되고,
AuthAPI의login비동기 함수를 가져와 로그인을 수행합니다.
SignInPage.js
import { useState } from "react";
import { login } from "../api/AuthAPI";
export default function SignInPage() {
const [values, setValues] = useState({
username: "",
password: "",
});
const handleChange = async (e) => {
setValues({...values,
[e.target.id]: e.target.value,
});
}
const handleSubmit = async (e) => {
login(values)
.then((response) => {
localStorage.clear();
localStorage.setItem('tokenType', response.tokenType);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('refreshToken', response.refreshToken);
window.location.href = `/home`;
}).catch((error) => {
console.log(error);
});
}
return (
<div className="d-flex justify-content-center" style={{ minHeight: "100vh" }}>
<div className="align-self-center">
<form onSubmit={handleSubmit}>
<div className="form-group" style={{ minWidth: "25vw" }}>
<label htmlFor="username">아이디</label>
<input type="text" className="form-control" id="username" onChange={handleChange} value={values.username} />
</div>
<div className="form-group" style={{ minWidth: "25vw" }}>
<label htmlFor="password">비밀번호</label>
<input type="password" className="form-control" id="password" onChange={handleChange} value={values.password} />
</div>
<div className="form-group" style={{ minWidth: "25vw" }}>
<button type="submit" style={{ width: "100%"}}>로그인</button>
</div>
</form>
</div>
</div>
);
}
tokenType,accessToken,refreshToken을 응답으로 돌려받습니다.
localStorage에 각각key-value의 형태로 저장합니다.
저장한 이후에는/home화면으로 이동합니다.
간단하게
username,password를 입력으로 받는form을 작성하였습니다.
handleChange,handleSubmit이 주요 비동기 함수로 사용되고,
AuthAPI의signUp비동기 함수를 가져와 회원가입을 수행합니다.
SignUpPage.js
import { useState } from "react";
import { signUp } from "../api/AuthAPI";
export default function SignUpPage() {
const [values, setValues] = useState({
username: "",
password: "",
});
const handleChange = async (e) => {
setValues({...values,
[e.target.id]: e.target.value,
});
}
const handleSubmit = async (e) => {
signUp(values)
.then((response) => {
window.location.href = `/login`;
}).catch((error) => {
console.log(error);
});
}
return (
<div className="d-flex justify-content-center" style={{ minHeight: "100vh" }}>
<div className="align-self-center">
<form onSubmit={handleSubmit}>
<div className="form-group" style={{ minWidth: "25vw" }}>
<label htmlFor="username">아이디</label>
<input type="text" className="form-control" id="username" onChange={handleChange} value={values.username} />
</div>
<div className="form-group" style={{ minWidth: "25vw" }}>
<label htmlFor="password">비밀번호</label>
<input type="password" className="form-control" id="password" onChange={handleChange} value={values.password} />
</div>
<div className="form-group" style={{ minWidth: "25vw" }}>
<button type="submit" style={{ width: "100%"}}>회원가입</button>
</div>
</form>
</div>
</div>
);
}
회원가입은 응답으로 돌려받는 값이 없습니다.
따라서 정상작동 확인 이후/login화면으로 이동합니다.
SpringBoot에서 구현한JWT인증시스템을,React에서 사용하는 방법입니다.
유저 정보를 얻기 위해React Custom API를 구현하고 사용하는 방법입니다.
UserAPI.js
import axios from "axios";
const TOKEN_TYPE = localStorage.getItem("tokenType");
let ACCESS_TOKEN = localStorage.getItem("accessToken");
let REFRESH_TOKEN = localStorage.getItem("refreshToken");
/** CREATE CUSTOM AXIOS INSTANCE */
export const UserApi = axios.create({
baseURL: 'http://localhost:8080',
headers: {
'Content-Type': 'application/json',
'Authorization': `${TOKEN_TYPE} ${ACCESS_TOKEN}`,
'REFRESH_TOKEN': REFRESH_TOKEN,
},
});
// 토큰 갱신
const refreshAccessToken = async () => {
const response = await UserApi.get(`/api/v1/auth/refresh`);
ACCESS_TOKEN = response.data;
localStorage.setItem('accessToken', ACCESS_TOKEN);
UserApi.defaults.headers.common['Authorization'] = `${TOKEN_TYPE} ${ACCESS_TOKEN}`;
}
// 토큰 유효성 검사
UserApi.interceptors.response.use((response) => {
return response;
}, async (error) => {
const originalRequest = error.config;
if (error.response.status === 403 && !originalRequest._retry) {
await refreshAccessToken();
return UserApi(originalRequest);
}
return Promise.reject(error);
});
Response Status를 통해ACCESS_TOKEN의 유효성을 검사하고 재발급하는 과정입니다.
Axios InstanceUserApi요청의 응답을 가로채(intercept) 에러코드를 확인합니다.
에러코드가403인 경우REFRESH_TOKEN을 통해ACCESS_TOKEN의 재발급을 수행합니다.
401: 로그인하지 않은 상태
403: 로그인하였으나AccessToken이 유효하지 않은 상태
->header에REFRESH_TOKEN이 추가되었음에 유의합니다.
->REFRESH_TOKEN에는TOKEN_TYPE이 없음에 유의합니다.
/** 회원조회 API */
export const fetchUser = async () => {
const response = await UserApi.get(`/api/v1/user`);
return response.data;
}
/** 회원수정 API */
export const updateUser = async (data) => {
const response = await UserApi.put(`/api/v1/user`, data);
return response.data;
}
/** 회원탈퇴 API */
export const deleteUser = async () => {
await UserApi.delete(`/api/v1/user`);
}
화면 상단의 네비게이션바에 유저정보를 불러와 로그인 상태인지 확인합니다.
로그인을 하지 않은 경우"로그인", 로그인한 경우"username님 환영합니다."
라는 메세지가 뜨도록 구현합니다.
Navigation.js
import logo from '../logo.svg';
import { Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import { useEffect, useState } from 'react';
import { fetchUser } from '../api/UserAPI';
export default function TestPage() {
const [user, setUser] = useState({});
const ACCESS_TOKEN = localStorage.getItem('accessToken');
useEffect(() => {
if (ACCESS_TOKEN) {
fetchUser()
.then((response) => {
setUser(response);
}).catch((error) => {
console.log(error);
});
}
}, [ACCESS_TOKEN]);
const handleLogout = async () => {
localStorage.clear();
}
return (
<Navbar collapseOnSelect expand="lg" bg="dark" variant="dark">
<Container>
<Navbar.Brand href="/home">
<img src={logo} width="40" height="35" alt="" />
Home
</Navbar.Brand>
<Navbar.Toggle aria-controls="responsive-navbar-nav" />
<Navbar.Collapse id="responsive-navbar-nav">
<Nav className="me-auto" alt="Nav Empty Space">
</Nav>
<Nav>
<Nav.Link href="/home">Home</Nav.Link>
<NavDropdown title="DropDown1" id="collasible-nav-dropdown">
<NavDropdown.Item href="/dropdown1/menu1">Menu1</NavDropdown.Item>
<NavDropdown.Item href="/dropdown1/menu2">Menu2</NavDropdown.Item>
<NavDropdown.Item href="/dropdown1/menu3">Menu3</NavDropdown.Item>
</NavDropdown>
<NavDropdown title="DropDown2" id="collasible-nav-dropdown">
<NavDropdown.Item href="/dropdown2/menu1">Menu1</NavDropdown.Item>
<NavDropdown.Item href="/dropdown2/menu2">Menu2</NavDropdown.Item>
<NavDropdown.Item href="/dropdown2/menu3">Menu3</NavDropdown.Item>
</NavDropdown>
{ACCESS_TOKEN
?
<NavDropdown title={user.username + "님 환영합니다"} id="collasible-nav-dropdown">
<NavDropdown.Item href="/my-page">MyPage</NavDropdown.Item>
<NavDropdown.Item href="/" onClick={handleLogout}>로그아웃</NavDropdown.Item>
</NavDropdown>
:
<NavDropdown title="Login/SignUp" id="collasible-nav-dropdown">
<NavDropdown.Item href="/login">Login</NavDropdown.Item>
<NavDropdown.Item href="/signup">SignUp</NavDropdown.Item>
</NavDropdown>
}
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
);
}
KEY POINT1
import { useEffect, useState } from 'react';
import { fetchUser } from '../api/UserAPI';
export default function TestPage() {
const [user, setUser] = useState({});
const ACCESS_TOKEN = localStorage.getItem('accessToken');
useEffect(() => {
if (ACCESS_TOKEN) {
fetchUser()
.then((response) => {
setUser(response);
}).catch((error) => {
console.log(error);
});
}
}, [ACCESS_TOKEN]);
const handleLogout = async () => {
localStorage.clear();
}
...
ACCESS_TOKEN의 여부가 곧 로그인 여부이므로localStorage에서 읽어옵니다.
존재한다면 로그인상태이므로, 유저정보를GET요청하여setUser로 저장합니다.로그아웃의 경우
localStorage에서ACCESS_TOKEN을 삭제하는 것으로 처리합니다.
KEY POINT2
import logo from '../logo.svg';
import { Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
export default function TestPage() {
return (
...
{ACCESS_TOKEN
?
<NavDropdown title={user.username + "님 환영합니다"} id="collasible-nav-dropdown">
<NavDropdown.Item href="/my-page">MyPage</NavDropdown.Item>
<NavDropdown.Item href="/" onClick={handleLogout}>로그아웃</NavDropdown.Item>
</NavDropdown>
:
<NavDropdown title="Login/SignUp" id="collasible-nav-dropdown">
<NavDropdown.Item href="/login">Login</NavDropdown.Item>
<NavDropdown.Item href="/signup">SignUp</NavDropdown.Item>
</NavDropdown>
}
...
);
}
Front화면의 로그인 상태에 따른 표시 여부도ACCESS_TOKEN을 이용합니다.
삼항연산자를 사용해ACCESS_TOKEN이 있다면 "환영합니다"를 표시하고,
ACCESS_TOKEN이 없다면 "Login/SignUp"을 표시합니다.
UserAPI.js의updateUser(),deleteUser()의 경우도
KEY POINT1과 같이회원정보수정페이지나,회원탈퇴버튼 등을 만들어
handleSubmit()등의 비동기 함수안에 사용하는 방식으로 구현하면 됩니다.
이로서
[React + SpringBoot] JWT 인증 구현시리즈가 모두 끝났습니다.
본 시리즈는 가능한 필요한 최소한의 라이브러리만 사용하여
필요한 최소한의 기능들을 간결하게 구현하는 것을 목적으로
ChatGPT를 활용하여 작성되었습니다.
본 시리즈보다 더 다양한 기능을 구현하거나, 여러가지 옵션을 더 추가하거나,
다양한 라이브러리를 사용하여 더 간결하게 구현하는 방법도 있을지 모르나,
본 시리즈는 기본적으로 실행이되는 것을 목표로 본 시리즈를 작성하였습니다.
실무에서 요구하는 좋은 개발자는, 기능을 구현할 줄 아는 개발자입니다.
아무리 코드가 간결하고 다양한 라이브러리를 썼을지언정,
실행이 되지 않는다면 의미가 없습니다.
실행 가능한 코드를 개발하는 개발자.
리팩토링으로 코드를 간결하게 만드는 개발자.
그런 좋은 개발자가 될 수 있도록 발전하기를 기원합니다.