[React + SpringBoot] JWT 인증 구현 ⑦ - React에서 사용하기

SihoonCho·2023년 4월 25일
0
post-thumbnail
post-custom-banner

※ 읽기에 앞서


본 시리즈는 작성자의 이해와 경험을 바탕으로 실습 위주의 설명을 기반으로 작성되었습니다.
실습 위주의 이해를 목표로 하기 때문에 다소 과장이 많고 생략된 부분이 많을 수 있습니다.
따라서, 이론적으로 미흡한 부분이 있을 수 있는 점에 대해 유의하시기 바랍니다.

또한, 본 시리즈는 ChatGPT의 도움을 받아 작성되었습니다.
수 차례의 질문을 통해 도출된 여러가지 다양한 방식의 코드를 종합하여
작성자의 이해와 경험을 바탕으로 가장 정석으로 생각되는 코드를 재정립하였습니다.


📌 [React] Auth


SpringBoot 에서 구현한 JWT 인증시스템을, React에서 사용하는 방법입니다.
회원가입 및 로그인 처리를 위해 React Custom API를 구현하고 사용하는 방법입니다.


📖 AuthAPI.js


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 인스턴스 headerAuthorization을 추가하는 것이 관건입니다.
일반적으로 'Authorization': 'Bearer {token}'의 형태로 포함시킵니다.

TOKEN_TYPE, ACCESS_TOKEN은 로그인 이후 값이 할당되어 사용할 수 있습니다.
사실, 회원가입 및 로그인 단계에서 Authorization, AccessToken은 필요없습니다.
로그인 이후 header 추가에 거부감을 줄이고 익숙해지고자 편의상 사용하였습니다.

간단한 예시를 위해 username, password 두 개의 파라미터만 사용하였습니다.
요구사항에 따라 email, contact, address 등 필요한 파라미터를 추가하기 바랍니다.


📖 SignInPage.js


간단하게 username, password를 입력으로 받는 form을 작성하였습니다.
handleChange, handleSubmit이 주요 비동기 함수로 사용되고,
AuthAPIlogin 비동기 함수를 가져와 로그인을 수행합니다.

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 화면으로 이동합니다.

📖 SignUpPage.js


간단하게 username, password를 입력으로 받는 form을 작성하였습니다.
handleChange, handleSubmit이 주요 비동기 함수로 사용되고,
AuthAPIsignUp 비동기 함수를 가져와 회원가입을 수행합니다.

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 화면으로 이동합니다.


📌 [React] User


SpringBoot 에서 구현한 JWT 인증시스템을, React에서 사용하는 방법입니다.
유저 정보를 얻기 위해 React Custom API를 구현하고 사용하는 방법입니다.


📖 UserAPI.js


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 Instance UserApi 요청의 응답을 가로채(intercept) 에러코드를 확인합니다.
에러코드가 403인 경우 REFRESH_TOKEN을 통해 ACCESS_TOKEN의 재발급을 수행합니다.

401: 로그인하지 않은 상태
403: 로그인하였으나 AccessToken이 유효하지 않은 상태
-> headerREFRESH_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`);
}

📖 Navigation.js


화면 상단의 네비게이션바에 유저정보를 불러와 로그인 상태인지 확인합니다.
로그인을 하지 않은 경우 "로그인", 로그인한 경우 "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.jsupdateUser(), deleteUser()의 경우도
KEY POINT1과 같이 회원정보수정 페이지나, 회원탈퇴 버튼 등을 만들어
handleSubmit() 등의 비동기 함수안에 사용하는 방식으로 구현하면 됩니다.


📌 결론


이로서 [React + SpringBoot] JWT 인증 구현 시리즈가 모두 끝났습니다.

본 시리즈는 가능한 필요한 최소한의 라이브러리만 사용하여
필요한 최소한의 기능들을 간결하게 구현하는 것을 목적으로
ChatGPT를 활용하여 작성되었습니다.


본 시리즈보다 더 다양한 기능을 구현하거나, 여러가지 옵션을 더 추가하거나,
다양한 라이브러리를 사용하여 더 간결하게 구현하는 방법도 있을지 모르나,
본 시리즈는 기본적으로 실행이되는 것을 목표로 본 시리즈를 작성하였습니다.

실무에서 요구하는 좋은 개발자는, 기능을 구현할 줄 아는 개발자입니다.
아무리 코드가 간결하고 다양한 라이브러리를 썼을지언정,
실행이 되지 않는다면 의미가 없습니다.


실행 가능한 코드를 개발하는 개발자.
리팩토링으로 코드를 간결하게 만드는 개발자.
그런 좋은 개발자가 될 수 있도록 발전하기를 기원합니다.

profile
개발을 즐길 줄 아는 백엔드 개발자
post-custom-banner

0개의 댓글