2024-06-11 JWT 회원가입 인증/인가 실습하기

목차
토큰 기반 인증 원리 이해하기 (Thunder Client)
아래는 Thunder Client라는 VSCode extension과 미리 준비된 회원 관리 백엔드 API로 토큰 기반 인증의 원리를 실습해보며 이해하는 과정이다.
굳이 이해할 필요가 없다면 생략하고 다음 과정으로 넘어가도 좋다.
1. Thunder Client 설치
VSCode extension에서 Thunder Client를 설치한다.

2. Thunder Client 실행
좌측 하단에 생긴 Thunder Client 메뉴에서 New Request를 누른다.

아래와 같은 화면이 보인다.

3. POST 요청 보내기 (회원가입)
아래는 회원가입 API 명세서이다.
- API URL : https://moneyfulpublicpolicy.co.kr
- Method : POST
- URL PATH : /register
- Body
{
"id": "유저 아이디",
"password": "유저 비밀번호",
"nickname": "유저 닉네임"
}
- Response
{
"message": "회원가입 완료",
"success": true
}

과제 요구사항에 맞게 입력한다.
위 사진은 임의로 입력한 것이고, 2번째 라인에 더블쿼트를 중복으로 오타가 난 것 등이 있으니 그대로 치지 말자.
Send 버튼을 누르면 아래 Response에 응답이 표기된다.

위 백엔드 서버는 나만 사용하는 것이 아니기 때문에 이미 있을 법한 계정을 입력하면 존재하다고 응답이 올 수도 있다.
4. POST 요청 (로그인)

방금 만든 계정으로, URL PATH를 /login으로 변경한 후 로그인에 맞는 Body를 작성해서 Send로 POST 요청을 보내보면
아래와 같이 토근이 배달 되며 로그인 성공을 알려주게 된다.
5. GET 요청 (회원정보 확인)
API Method는 GET, URL Path는 /user이며, Body는 없으니 지금까지 입력했던 내용을 전부 지우고 Headers에 Authorization 속성을 추가한 후 아래와 같은 형태로 입력해준다.
{
"Authorization": "Bearer AccessToken"
}

그리고 Send를 보내보면 응답이 아래와 같이 온다.

여기서 우리가 알 수 있는 건 Body에 user 정보를 실어서 보내지 않았음에도 토큰만으로 유저를 검증하고 정보를 반환해주었다는 점이다.
만약 토큰이 한 글자라도 다르면 GET 요청을 보냈을 때 401 클라이언트 오류를 반환하며 토큰 검증에 실패하였다고 보낼 것이다.
6. PATCH 요청 (회원정보 수정)
API Method는 PATCH, URL PATH는 /profile이며, 이 유저가 해당 유저 정보를 바꿀 자격이 있는 사람인지 검증해야 하니, Headers에 Authorization을 그대로 추가하고, Barer 토큰 은 그대로 유지한 채로 Body를 아래처럼 바꾸고자 하는 정보를 객체로 작성한다.

원래는 admin911로 설정했었는데, 한글로 바꿔달라고 PATCH 요청을 보내보았다. 그랬더니 아래처럼 응답이 왔다.

정말 바뀌었는지, user 정보를 확인하는 GET 요청을 보내보니 아래처럼 잘 바뀌었다고 응답이 왔다.

로그인, 로그아웃, 인증 상태 로직 작성하기 (Context API, 로컬 스토리지)
로그인 페이지와 마이페이지가 미리 제작 되어있다고 가정한 후 진행하는 순서이다.
지금 할 것은 유저가 로그인 한 상태인지, 로그인하지 않은 상태인지 판가름 해줄 상태를 Context API를 통해 만들고, 로그인, 로그아웃 함수를 만들 것이다.
이 방식은 로컬 스토리지를 활용하여 유저의 토큰을 저장하는 방식을 활용한다. 유저에게 이 토큰이 있다면 인증이 된 상태이고 없으면 되지 않은 상태이다.
로그인을 할 때는 서버에서 전달받은 토큰을 로컬 스토리지에 저장하고, 로그아웃을 할 때는 로컬 스토리지에서 토큰을 삭제하는 아주 직관적인 방법이다.
유저의 토큰을 로컬 스토리지를 저장하는 이 방식에는 뚜렷한 장단점이 있다. 하지만 이는 실습용 과제이므로, 실제 서비스에서는 이렇게 사용하지 않는 것이 좋겠다.
장점 :
1. 직관적이다.
useState, useEffect 훅만 사용해도 되기 때문에 코드가 직관적이고 사용이 편리하다.
2. 새로고침을 해도 인증 상태가 풀리지 않는다.
사용자의 웹 브라우저 로컬 스토리지에 토큰을 저장하기 때문에 새로고침을 해도 인증 상태가 풀리지 않는다. 이를 방지하는 다른 방식이 많이 있지만 이 방식은 가장 기초적인 방식이고 쉽게 사용할 수 있다.
3. 로그인, 로그아웃 함수를 간단히 구현 가능하다.
로컬 스토리지에서 사용자의 토큰을 저장하거나 삭제하기만 하면 인증이 기록되거나 풀리기 때문에 매우 간단하게 로그인, 로그아웃 로직을 작성할 수 있다.
단점 :
1. 보안 취약성
사용자의 로컬 스토리지는 언제나 탈취 당할 우려가 있다.
2. 토큰 만료 처리 로직 부재
일반적인 서비스의 경우, 사용자가 토큰을 발급받았을 때 영구적으로 사용하는 것이 아니라 토큰의 만료일자가 존재한다. 그런데 이 방식에서는 토큰 만료에 대한 로직이 없기 때문에 실제로 토큰이 만료되었다 하더라도 토큰이 만료된 사용자에 대해서 처리하는 로직이 없어 계속 인증된 상태로 받아들일 수 있다. 추후 실제 서비스에서는 토큰이 만료되었을 때의 처리 로직도 고민을 해야 겠다.
코드 예제 :
// src/context/AuthContext.jsx
import React, { createContext, useState, useEffect } from "react";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const token = localStorage.getItem("accessToken");
if (token) {
setIsAuthenticated(true);
}
}, []);
const login = (token) => {
localStorage.setItem("accessToken", token);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem("accessToken");
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
위 코드 예제를 하나씩 살펴보겠다.
인증 상태 만들기
import React, { createContext, useState, useEffect } from "react";
// AuthConetext를 생성한다.
export const AuthContext = createContext();
// 프로바이더를 생성한다.
// children을 props로 받아 이 프로바이더로 감싸는 모든 자식 컴포넌트들이
// 인증 상태를 사용할 수 있도록 한다.
export const AuthProvider = ({ children }) => {
// 인증 상태는 최초에는 인증받지 않았을 것이기에 false로 둔다.
const [isAuthenticated, setIsAuthenticated] = useState(false);
// 컴포넌트들이 최초 마운트 되었을 때 로컬 스토리지에서 accessToken이라는 key의 값을 불러온다.
// 당연히 최초에는 있을 리가 없다. 다만 처음 인증 이후 다음에 접속할 때를 생각해서 이 로직이 먼저 실행되어야 한다.
useEffect(() => {
const token = localStorage.getItem("accessToken");
// 로컬 스토리지에서 꺼내온 값을 할당한 token이라는 변수의 값이 존재한다면, (truthy 하다면 / 토큰이 존재한다면)
// 인증 상태의 값을 true로 바꾼다.
if (token) {
setIsAuthenticated(true);
}
}, []);
// 방금 만든 프로바이더로 자식 컴포넌트들을 감싼다.
// 실제로 감싸는 것은 프로바이더를 호출하는, 페이지 라우팅을 하는 컴포넌트에서 지정할 것이다.
// 그리고 login, logout 함수도 같이 내려주는데, 이는 바로 이어서 작성할 것이다.
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
로그인, 로그아웃 함수 작성하기
생각보다 간단한 로직이다. login 함수로 토큰을 매개 변수로 받고, 매개 변수로 전달받은 토큰을 로컬 스토리지에 저장해주는 것이 로그인 함수 로직의 전부이다.
그리고 인증 상태를 true로 바꾸어 주면 된다.
반대로 로그아웃 함수는 로컬 스토리지에서 로그인 함수로 만든 accessToken라는 key를 삭제해버리고 인증 상태의 값을 false로 바꾸어 주면 된다.
const login = (token) => {
localStorage.setItem("accessToken", token);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem("accessToken");
setIsAuthenticated(false);
};
이렇게 작성된 컨텍스트를 자식 컴포넌트에서도 공유받을 수 있도록 바로 이어서 페이지 라우팅을 해보겠다. 이 인증상태 프로바이더는 모든 컴포넌트에서 사용할 것이기에 가장 최상단에서 감싸주면 된다.
로그인 상태에 따라 페이지 라우팅하기
로그인 컴포넌트가 이미 작성되어 있다고 전제한다.
지금 하고자 하는 것은 로그인이 되어 있을 때 렌더링할 컴포넌트와 로그인이 되어 있지 않을 때 렌더링 할 컴포넌트를 구분해서 페이지 라우팅 하는 것이다.
조건부 렌더링 함수 생성 :
const PrivateRoute = ({ element, ...rest }) => {
const { isAuthenticated } = useContext(AuthContext);
return isAuthenticated ? element : <Navigate to="/login" />;
};
const PublicRoute = ({ element, ...rest }) => {
const { isAuthenticated } = useContext(AuthContext);
return !isAuthenticated ? element : <Navigate to="/mypage" />;
};
위의 조건부 렌더링 함수는 일종의 패턴이다. 심오하게 이해할 필요는 없어 보인다. 하지만 해석을 해보자면 아래와 같다.
앞서 Context에서 isAuthenticated라는 상태를 만들었다. 이 상태는 최초 false 상태이다. 페이지에 처음 접속해서 로그인을 하지 않았으니 토큰도 없고, 인증되지 않았다는 의미이다. 이 상태가 true이면 지금 보고자 하는 페이지를 렌더링 해주는데 만약 false라면 로그인 컴포넌트를 렌더링 하라는 의미이고,
아래 PublicRoute 컴포넌트는 그 반대이다. 인증 상태가 false이면, 즉 로그인 되어 있지 않다면 보려는 걸 렌더링 해주고, 로그인 되어 있으면 MyPage 컴포넌트를 렌더링해주라는 의미이다.
조금 해석이 헷갈릴 수 있다. 프라이빗 라우트는 로그인이 되어 있지 않을 때 잠궈 버릴 컴포넌트에다가 호출에 주면 되고, 퍼블릭 라우트는 로그인 되어있지 않을 때도 볼 수 있는 페이지에다가 걸어주면 된다.
나의 실제 코드 예제를 보면 이해가 빠를 듯하다.
const PrivateRoute = ({ element, ...rest }) => {
const { isAuthenticated } = useContext(AuthContext);
return isAuthenticated ? element : <Navigate to="/login" />;
};
const PublicRoute = ({ element, ...rest }) => {
const { isAuthenticated } = useContext(AuthContext);
return !isAuthenticated ? element : <Navigate to="/mypage" />;
};
return (
<>
<AuthProvider>
<BrowserRouter>
<Layout>
<Routes>
<Route
path="/"
element={<PrivateRoute element={<Home expenses={expenses} setExpenses={setExpenses} />} />}
/>
<Route
path="/detail/:id"
element={<PrivateRoute element={<Detail expenses={expenses} setExpensese={setExpenses} />} />}
/>
<Route path="/login"
element={<PublicRoute element={<Login />} />} />
<Route path="/mypage"
element={<PrivateRoute element={<MyPage />} />} />
</Routes>
</Layout>
</BrowserRouter>
</AuthProvider>
</>
);
위 코드를 보면 홈 컴포넌트는 프라이빗 컴포넌트로 잠갔다. 이렇게 되면 페이지에 접속하자마자 로그인 컴포넌트가 렌더링 되면서 인증 토큰이 없으면 홈 컴포넌트로 접근이 불가능하다.
그리고 디테일 컴포넌트로 마찬가지로 잠갔다. 또 마이페이지도 로그인을 하지 않았으면 볼 개인정보도 없기 때문에 잠그고 로그인 컴포넌트가 렌더링되게 하였다.
그리고 퍼블릭 라우트를 호출한 곳은 오히려 로그인 컴포넌트인데 로그인을 한 사용자가 로그인 페이지를 보고 있다는 것은 말이 안 되므로, 로그인 컴포넌트는 로그인을 하지 않더라도 볼 수 있는 퍼블릭 라우트로 작성하였다. 다만 퍼블릭 라우트 설정에 의해서 로그인을 한 사람이
로그인 페이지에 접속하려고 한다면 마이페이지로 navigate to 하여 넘겨버린다.
로그인 페이지 작성하기
미리 준비된 백엔드 API 서버에 POST 요청을 보내어 로그인을 하는 로직을 작성할 것이다.
여기에서는 자바스크립트의 fetch 함수를 이용하지 않고 axios 라이브러리를 사용하도록 한다.
따라서 이에 대한 지식이 선행되어 있어야 하고, 이는 별도 게시글로 상세히 다루겠다.
예제 코드에서의 핵심은, id, password를 상태로 관리한다는 것과, 컨텍스트에서 정의한 login 함수를 불러와 별도의 submit 함수에서 사용하고, 컨텍스트에서 login 함수를 작성할 때 매개 변수로 서버에서 유저에게 전달하는 토큰을 받아서 로컬 스토리지에 저장하는 것이 login 함수의 핵심 로직이었다.
따라서 매개 변수로는 로그인 결과로 reponse 받은 객체에서 토큰 부분만 마침표 접근법으로 추출하여 login 함수의 매개 변수로 전달해주는 것이 핵심 로직이다.
로그인 결과로 받은 response 객체가 어떻게 구성되어 있는지 궁금하면 포스트 위에서 다룬 썬더 클라이언트를 참고해보면 좋다. 이 경우 POST 요청(로그인 PATH)을 보냈을 때 서버가 유저에게 response 객체에 어떤 정보를 담아서 주는지 확인해보면 좋다.
아래처럼 날아 온다. 여기에서 accessToken만 빼내어 login 함수의 매개 변수로 전달해주면 된다.
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFkbWluODI5MiIsImlhdCI6MTcxODEwNjcxNSwiZXhwIjoxNzE4MTEwMzE1fQ.9Olr-2LcHUZvDAKk4yYzbx02ITFJ8UnXr8ts5zrv2ao",
"userId": "admin8292",
"success": true,
"avatar": null,
"nickname": "테스트계정"
}
예제 코드 :
import React, { useState, useContext } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import { AuthContext } from "../context/AuthContext";
const Login = () => {
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const { login } = useContext(AuthContext);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await axios.post(
"https://moneyfulpublicpolicy.co.kr/login",
{
id,
password,
}
);
const data = response.data;
if (data.success) {
login(data.accessToken);
navigate("/mypage");
} else {
alert("Login failed");
}
} catch (error) {
console.error("Login error:", error);
alert("Login failed");
}
};
return (
<div>
<h2>Login Page</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="ID"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
</div>
);
};
export default Login;
예제 코드 해석 :
import React, { useState, useContext } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
// 이번 예제에서는 fetch 함수 대신 axios 라이브러리를 사용한다.
import { AuthContext } from "../context/AuthContext";
const Login = () => {
const [id, setId] = useState("");
// id 상태와 상태를 변경할 setId 함수를 초기화
const [password, setPassword] = useState("");
// password 상태와 상태를 변경할 setPassword 함수를 초기화
const { login } = useContext(AuthContext);
// AuthContext에서 login 함수를 가져옴
const navigate = useNavigate();
// useNavigate 훅을 사용하여 navigate 함수를 가져옴
const handleSubmit = async (e) => {
e.preventDefault();
// 폼 제출 시 페이지가 새로고침되는 기본 동작을 막음
try {
const response = await axios.post(
"https://moneyfulpublicpolicy.co.kr/login",
{
id,
password,
}
);
// axios를 사용하여 서버에 로그인 요청을 보냄 (POST)
const data = response.data;
// 서버로부터 받은 응답 데이터를 가져와 data라는 상수에 할당함.
if (data.success) {
login(data.accessToken);
// 로그인 성공 시, AuthContext의 login 함수를 호출하여 인증 토큰을 저장함.
navigate("/mypage");
// 로그인 후 마이페이지로 이동함.
} else {
alert("Login failed");
// 로그인 실패 시 경고 메시지를 표시
}
} catch (error) {
console.error("Login error:", error);
// 로그인 중 에러가 발생하면 콘솔에 에러를 출력합니다.
alert("Login failed");
// 로그인 실패 시 경고 메시지를 표시
}
};
return (
<div>
<h2>Login Page</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="ID"
/>
{/* 사용자 ID 입력 필드. id라는 상태와 연결되어 있음. */}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{/* 사용자 비밀번호 입력 필드. id라는 상태와 연결되어 있음. */}
<button type="submit">Login</button>
{/* 로그인 버튼. 클릭 시 handleSubmit 함수 호출 */}
</form>
</div>
);
};
export default Login;
Header 완성 시키기
Header 로그아웃 버튼에 logout 함수 연결하기

로그인은 위와 같이 별도의 로그인 컴포넌트가 있었기 때문에 미리 제작한 로그인 컴포넌트에 하나 하나 연결해주었다.
그런데 로그인 컴포넌트에는 로그아웃 버튼이 없다.

로그아웃 버튼은 위 사진처럼 헤더에 있다. 따라서 지금 상태로는 로그아웃을 하려면 아래처럼 로컬 스토리지에서 직접 토큰을 삭제시켜야 한다.

다시 Header 컴포넌트로 와서 AuthContext에서 작성했던 로그아웃 함수를 연결해주겠다.
먼저 복기하자면 Logout 함수는 아래와 같다.
// src/context/AuthContext.jsx
const logout = () => {
localStorage.removeItem("accessToken");
setIsAuthenticated(false);
};
Header 컴포넌트에서 이 컨텍스트를 사용하기 위해 useContext 훅과 함께 authContext 컴포넌트를 import 해준다.
// components/Header.jsx
import { useContext } from 'react';
import { AuthContext } from '../../context/AuthContext';
Header 함수 컴포넌트 내에서 logout 함수 사용 선언한다.
// components/Header.jsx
const Header = () => {
const { logout } = useContext(AuthContext);
...
}
로그아웃 버튼에 이벤트 핸들러로 logout 함수를 연결해준다.
// Header.jsx
<LogoutButton onClick={logout}>로그아웃</LogoutButton>
여기까지 하면 로그아웃 함수가 잘 작동한다.
Header Username 영역에 실제 유저 닉네임 렌더링하기
위 로그아웃 버튼 예제에서 test344라고 렌더링 되어 있는 것은 아래와 같이 코드를 작성하며 샘플로 작성한 것이다.
이 부분에 실제 유저의 닉네임을 렌더링하고자 한다.
<Username>test344</Username>
여러 방법이 있겠으나, ContextAPI를 통해 모든 컴포넌트를 감싸서 인증 상태를 공유하고 있으니, 이번에도 ContextAPI를 통해 유저 정보를 상태에 담아 모든 컴포넌트에서 접근이 가능하도록 하겠다.
먼저 user라는 상태를 만들고 초기값으로는 null을 할당한다.
// context/AuthContext.jsx
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
...
컴포넌트가 최초 마운트 될 때 토큰을 로컬 스토리지에 저장하는 로직에서 user 상태도 로컬 스토리지에 담도록 로직을 추가한다.
그리고 유저 정보가 존재할 때(token이 존재할 때) 인증 상태를 true로 바꾸는 로직에 마찬가지로 로컬 스토리지에서 user 값을 꺼내와 user 상태에 담는 로직을 추가 작성한다.
useEffect(() => {
const token = localStorage.getItem("accessToken");
const storedUser = localStorage.getItem("user"); // 추가
if (token) {
setIsAuthenticated(true);
setUser(JSON.parse(storedUser)); // 추가
}
}, []);
마찬가지로, 로그인, 로그아웃 함수에서 로컬 스토리지에 저장된 유저 토큰을 어떻게 처리할 지 정했던 로직에 로컬 스토리지의 user 정보 또한 어떻게 할 지 동일한 로직으로 처리해준다.
로그인 했을 때 로컬 스토리지에 담고, user 상태를 바꿔주며, 로그아웃 했을 때 로컬 스토리지에서 삭제하고 상태도 다시 null로 초기화 해주는 로직을 추가한다.
const login = (token, user) => { // 매개 변수 추가
localStorage.setItem("accessToken", token);
localStorage.setItem("user", JSON.stringify(user)); // 추가
setIsAuthenticated(true);
setUser(user); // 추가
};
const logout = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("user"); // 추가
setIsAuthenticated(false);
setUser(null); // 추가
};
로그인 함수를 실행할 때 유저 토큰을 로컬 스토리지에 저장해서 유저 인증 상태를 컴포넌트 간에 공유하는 것처럼,
로그인 함수를 실행할 때 받아온 유저의 정보를 담을 것이므로, login 함수의 매개 변수로 user 정보를 담았다.
따라서 login 함수가 호출되는 곳에서도 매개 변수를 수정해주어야 한다.
그 전에 먼저 AuthContext 컴포넌트에서 프로바이더에 props로 user를 추가해준다.
// AuthProvider.jsx
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout, user }}> {/* user 추가 */}
{children}
</AuthContext.Provider>
);
그럼 다시 돌아와 login 함수가 호출되는 곳에서도 매개 변수를 수정해준다.
const handleLoginSubmit = async (e) => {
e.preventDefault();
try {
const response = await axios.post(
"https://moneyfulpublicpolicy.co.kr/login",
{
id,
password,
}
);
const data = response.data;
if (data.success) {
login(data.accessToken, data.nickname); // 추가
navigate('/');
} else {
alert('로그인에 실패했습니다. 계정을 확인해주세요.')
}
} catch (error) {
console.error('Login error:', error);
alert('로그인에 실패했습니다.')
}
};
자, 그럼 유저의 정보를 담았으니 이를 Header 컴포넌트에서 데이터 바인딩 해주어 사용하면 된다.
참고로 로그인을 성공했을 때 날아오는 유저의 데이터는 아래와 같이 생겼다.
유저 정보를 확인하는 /user PATH로 GET 요청을 보냈을 때 응답되는 데이터와 유사하다.
그렇다면 우리는 data 객체에서 nickname에 접근하면 되는 것을 알았다.

하지만 data 객체로 접근하는 것이 아니라 다른 컴포넌트에서 공유하기 위해 user라는 상태에 담았으니 Header 컴포넌트에서 이를 사용하겠다고 선언하고,
로그인이 되어 있지 않은 경우에는 어떤 텍스트를 렌더링 할 지 고민하며 조건부 렌더링까지 추가해준다.
const Header = () => {
const { logout, user } = useContext(AuthContext); // user 상태 props에 추가
return (
<HeaderContainer>
<NavLinks>
<NavLink to="/">Home</NavLink>
<NavLink to="/mypage">내 프로필</NavLink>
</NavLinks>
<ProfileContainer>
<ProfileImage src={user && user.avatar} alt="Profile" />
<Username>{user ? user.nickname : '로그인 필요'}</Username>
<LogoutButton onClick={logout}>로그아웃</LogoutButton>
</ProfileContainer>
</HeaderContainer>
);
};
자, 여기까지 해서 모두 완료되면 아름답겠지만 한 가지 문제가 있다.
AuthContext 컴포넌트의 로직을 가만히 생각해보면, 최초에 로컬 스토리지에서 먼저 인증 정보와 user 정보를 꺼내 오는 것부터 시작한다.
당연히 최초 접속 시에는 인증받은 내역이 없기 때문에 로컬 스토리지에 아무 것도 없는데, 토큰의 경우 단순하게 localStorage.getItem ~ localStorage.setItem ~ 메서드만 사용했기 때문에 값이 없어도 에러는 없으나,
user 객체를 로컬 스토리지에서 꺼내오는 경우에는 문제가 된다. JSON.parse 메서드는 undefined인 경우 에러를 뿜어 낸다.
따라서 이 경우에는 로컬 스토리지에서 호출하려는 값이 undefined인 경우, 어떻게 처리할 지 try...catch문으로 작성하면 조금 더 안전하게 에러 없이 코드를 실행시킬 수 있다.
// context/AuthContext.jsx
// 로직 변경 전
useEffect(() => {
const token = localStorage.getItem("accessToken");
const storedUser = localStorage.getItem("user");
if (token) {
setIsAuthenticated(true);
setUser(JSON.parse(storedUser));
}
}, []);
// 로직 변경 후
useEffect(() => {
const token = localStorage.getItem("accessToken");
const storedUser = localStorage.getItem("user");
if (token) {
setIsAuthenticated(true);
try {
const userObject = storedUser ? JSON.parse(storedUser) : null;
setUser(userObject);
} catch (error) {
console.error("로컬 스토리지에서 유저 정보를 불러오지 못했습니다요 왜냐면 값이 =>", error);
}
}
}, []);
그러면 catch 문에 걸려서 최초 접속하여 user 정보가 undefined인 경우 콘솔 에러를 출력한다. 이것은 코드 에러가 아니기 때문에 코드는 정상적으로 실행된다.

MyPage 완성 시키기
토근 기반 회원 인증 중에서 지금까지는 POST에 관련된 내용만 다룬 것이다.
POST(회원가입), POST(회원정보 받아오기) 였다.
이번에는 MyPage를 만들어 유저의 닉네임을 수정할 수 있는 로직을 작성해보고자 한다.
먼저 썬더 클라이언트에서 실습했던 내용 중 유저 정보를 수정하는 요청에 대해서 다시 한 번 복습해보자.
API Method는 PATCH, URL PATH는 /profile이며, 이 유저가 해당 유저 정보를 바꿀 자격이 있는 사람인지 검증해야 하니, Headers에 Authorization을 그대로 추가하고, Barer 토큰 은 그대로 유지한 채로 Body를 아래처럼 바꾸고자 하는 정보를 객체로 작성한다.
Headers에는 Barer 토큰... 형태로 작성해주면 된다.
자, 그럼 시작해보자.
필요한 훅과 컴포넌트 import
먼저 필요한 훅과 AuthContext를 import 해준다.
// MyPage.jsx
import styled from 'styled-components';
import { useState, useEffect, useContext } from 'react';
import { AuthContext } from '../../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import axios from "axios";
다음으로 변경 할 정보, 즉 유저에게 입력 필드를 통해 수정 받을 데이터를 state로 만든다.
이와 더불어 유저 인증 상태도 AuthContext에서 사용을 선언하고, useNavigate훅도 사용 선언한다.
필요한 상태 정의하기
// MyPage.jsx
const MyPage = () => {
const [userInfo, setUserInfo] = useState(null);
const [newNickname, setNewNickname] = useState("");
const [selectedFile, setSelectedFile] = useState(null);
const [fileName, setFileName] = useState("");
const { isAuthenticated } = useContext(AuthContext);
const navigate = useNavigate();
각 상태들이 어떻게 사용되는지 살펴보겠다.
- userInfo : 유저의 정보를 서버에서부터 받아와 저장하는 용도로 사용한다. 왜 저장하냐면 현재 유저의 닉네임이 무엇인지 렌더링 해주기 위함이다.
- newNickName : 바꿀 닉네임을 받는 입력 필드가 있기 때문에 이것을 상태로 만들어 준다.
- selectedFile : 유저에게 변경할 프로필 사진을 받을 것이기 때문에 이도 역시 상태로 만들어 준다.
- fileName : 유저가 선택한 프로필 사진의 이름이 무엇인지 렌더링 해주기 위해서 상태로 만들어 준다.
- isAuthenticated : AuthContext 에서 상태로 만든 유저 인증 상태이다. 이 인증 상태가 true여야만 마이페이지를 렌더링 해줄 것이기 때문에 인증 상태가 필요하다.
- useNavigate : 이 훅은 인증되지 않은 사용자를 login 컴포넌트로 리다이렉션 시키기 위해 사용을 선언해주는 것이다.
현재 로그인한 유저 정보 받아오는 GET 요청 보내기
다음으로 useEffect 훅을 사용하여 MyPage 컴포넌트가 마운트 되었을 때, 즉 최초 렌더링 되었을 때
사용자 인증 상태에 따라 login 컴포넌트를 렌더링 할 지 말 지에 대한 로직을 작성한다.
그런데 사실 이것은 라우팅을 설정하는 부분에서 퍼블릭 라우트와 프라이빗 라우트로 이미 작성한 로직이지만,
그럼에도 어떠한 버그로 마이페이지에 접근할 수 있게 됐을 때 보조 장치로서 역할을 해주게 된다.
그리고 현재 로그인 한 유저 정보를 받아 오기 위해 API에 GET 요청을 보낸다. GET 요청을 보낼 때는 API 명세서에 근거해서 headers에 실어 보내야 하는 정보가 있었다. Bearer 토큰... 이다.
마이페이지에 접근했다는 것은 로그인을 했다는 것이고, 로그인을 했다는 것은 토큰이 로컬 스토리지에 저장되어 있다는 뜻이니 로컬 스토리지에서 getItem을 해서 token이라는 변수에 할당해준다.
그리고 API 요청을 보내는 함수는 시간이 소요되는 함수이니, async...awiat를 통해 비동기로 실행되도록 설정해준다.
그리고 어떤 이유로든지 서버로부터 응답을 받지 못할 수 있으니 try...catch문으로 응답 실패 시에도 안전하게 코드가 실행될 수 있도록 에러 처리를 해준다.
useEffect(() => {
if (!isAuthenticated) {
alert("로그인이 필요합니다.");
navigate("/login");
} else {
const fetchUserInfo = async () => {
try {
const token = localStorage.getItem("accessToken");
const response = await axios.get(
"https://moneyfulpublicpolicy.co.kr/user",
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
setUserInfo(response.data);
console.log('GET 요청에 대한 응답입니다요(userInfo) =>', userInfo);
} catch (error) {
console.error("서버에서 유저 정보를 못 받아왔습니다요 이유는 =>", error);
}
};
fetchUserInfo();
}
}, [isAuthenticated, navigate]);
다시 한 줄 씩 자세히 살펴보겠다.
- useEffect( , [isAuthenticated, navigate]) : 컴포넌트가 최초 마운트 되었을 때, 그리고 사용자의 인증 상태에 변화가 생겼을 때 유저 정보를 받아오는 이 GET 요청을 다시 실행한다. navigate를 넣은 이유는 ESLint의 규칙 상 넣은 것이고 큰 의미는 없다.
- if(!isAuthenticated) { alert... navigate... } : 유저의 인증 상태가 없다면, 로그인이 필요하다고 alert창을 띄우고 로그인 페이지로 리다이렉션 시킨다.
- else { const fetchUserInfo = async () => {} } : 그게 아니라 만약 유저의 인증 상태가 존재한다면 fetchUserInfo라는 함수를 실행하는데 이는 async 키워드를 통해 비동기 함수로 실행한다.
- try { const token = localStorage.getItem("accessToken") } : 유저 정보가 있다면 try...catch 문을 통해 try문을 항상 실행하고 여기서 에러가 발생하면 catch문으로 코드를 넘기게 된다. 그런데 try문을 보니, 로컬 스토리지에서 유저의 토큰을 추출하여 token이라는 변수에 할당부터 해주고 있다. 이 토큰을 활용해서 GET 요청을 보내야 하기 때문이다.
- try { const response = await axios.get(API URLl PATH) { headers: { Authorication: `Bearer 토큰` } } } : 그렇게 받아온 토큰으로 현재 유저의 정보를 받아오는 GET 요청을 API 서버에 보낸다.
- setUserInfo(response.data) : API 서버에서 유저 데이터를 response 받게 되면 그 데이터를 userInfo 상태에 담는다.
- catch (error) { console.errror("에러 알리는 내용", error) } : 만약 try문에서 실행한 코드가 실패하면 뱉어내는 에러를 담아서 예외 처리 해주는 함수이다.
- fetchUserInfo() : 여기까지 완료되면 fetch 함수를 실제로 날려 본다.
이미지 업로드 함수 작성
// MyPage.jsx
const handleFileChange = (e) => {
const file = e.target.files[0];
setSelectedFile(file);
setFileName(file.name);
};
유저에게 프로필 사진을 받을 것이기 때문에 이미지 업로드 함수를 작성한다.
- const file = e.target.files[0] : input type = "file"에서는 사용자가 파일을 여러 개 선택할 수 있기 때문에 그 중에서 가장 첫번째 사진을 보여주기 위해 files 배열에서 0번 인덱스를 선택하여 file이라는 변수에 할당한 것이다.
- 만약 다른 의도로 응용해서 사용하고자 하여 사용자가 선택한 파일을 모두 렌더링 하고자 한다면, 배열 인덱스를 없애고, 렌더링 하는 부분에서 map문을 통해서 아래와 같이 렌더링 해주면 된다.
const [selectedFiles, setSelectedFiles] = useState([]);
const [fileNames, setFileNames] = useState([]);
const handleFileChange = (e) => {
const files = Array.from(e.target.files);
setSelectedFiles(files);
setFileNames(files.map(file => file.name));
};
// JSX
return (
<div>
<input type="file" multiple onChange={handleFileChange} />
<ul>
{fileNames.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
</div>
);
- setSelectedFile(file) : 선택된 파일의 배열을 selectedFile 상태에 할당한다. 이렇게 하는 이유는 서버에 파일 리스트를 보내기 위해서다. 그리고 다른 프로젝트에서는 파일 미리보기를 만들어야 할 때도 이 과정이 필요할 수 있다.
- setFileName(file.name) : 파일의 이름을 상태에 담는다. 단순히 유저에게 렌더링 해주기 위해 사용한다.
<span>{fileName}</span>
이런 식으로 바인딩하여 렌더링 하면 아래와 같이 렌더링 된다.

이 함수는 꼭 필요한가?
handleFileChange라고 명명한 이 함수는 유저가 이미지를 선택하는 input type = "file"에 onChange 이벤트 핸들러로 아래와 같이 연결해 줄 것이다.
<input
id="file-upload"
type="file"
onChange={handleFileChange}
/>
여기서 이벤트 핸들러로 굳이 이 함수를 달아주지 않아도 작동은 잘 한다.
다만 유저가 본인이 어떤 파일을 선택했는지 렌더링 되지 않아 직관적이지 않다. 아래의 사진에서 이 이벤트 핸들러를 제외시키면 유저가 선택한 파일명이 아래 사진처럼 렌더링 되지 않아 유저가 파일을 선택했는지 아닌지 헷갈릴 수 있다. 그래서 작성하는 코드다.
프로필 업데이트 요청하는 PATCH 함수 작성
const handleProfileUpdate = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem("accessToken");
const formData = new FormData();
formData.append("nickname", newNickname);
if (selectedFile) {
formData.append("avatar", selectedFile);
}
const response = await axios.patch(
"https://moneyfulpublicpolicy.co.kr/profile",
formData,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
}
);
if (response.data.success) {
setUserInfo((prevState) => ({
...prevState,
nickname: response.data.nickname,
avatar: response.data.avatar,
}));
alert("프로필이 업데이트되었습니다.");
setNewNickname("");
setSelectedFile(null);
setFileName("");
} else {
alert("프로필 업데이트에 실패했습니다.");
}
} catch (error) {
console.error("Failed to update profile:", error);
alert("프로필 업데이트에 실패했습니다.");
}
};
이 함수는 유저에게 정보를 받는 입력 폼에 아래 처럼 연결 시켜줄 함수이다.
return (
<Container>
<form onSubmit={handleProfileUpdate}>
...
이 로직은 다소 길어서 복잡해보인다. 하나씩 뜯어 보겠다.
- const handleProfileUpdate = async (e) => {} : async 키워드를 사용하여 비동기적으로 실행할 함수임을 명시한다. 이유는 API 서버로부터의 응답은 시간이 걸리는 로직이기 때문이다.
- e.preventDefault() : form 태그의 기본 동작을 막는다. form 태그에 이벤트 핸들러로 연결되는 함수에는 필수적으로 가장 먼저 사용되는 메서드이다. 이유는 form 태그의 기본 동작은 사용자가 입력했든 입력하지 않았든 입력 필드를 서버로 전송하는 것이 기본 동작이기 때문이다. 즉 사용자가 변경할 정보를 입력하지 않았는데도 제출 버튼을 누르면 서버로 전송이 될 수도 있고, 또한 그런 동작을 유효성 검사로 막는다고 하더라도 페이지가 깜빡이는 것은 막을 수가 없다. 따라서 이 메서드로 그런 기본 동작을 막아준다.
- try { const token = localStorage.getItem("accessToken") } : try문을 사용하여 이 코드는 무조건 실행된다. 로컬 스토리지에서 토큰을 가져온다.
- const formData = new FormData() : 새 폼 데이터를 생성한다. 새 폼 데이터란 서버로 전송할 데이터를 말한다.
- formData.append("nickname", newNickname) : 그렇게 만든 폼 데이터에서 nickname이라는 필드에 newNickname이라는 상태를 실어 보낸다. newNickname 상태는 입력 필드에서 사용자에게 직접 받을 것이다.
- if (selectedFile) { formData.append("avatar", selectedFile) } : 사용자가 프로필 사진을 선택 했을 수도 있고 아닐 수도 있는데, 선택한 경우에는 formData에서 avater라는 필드에 값을 실어 보내준다. 위에서 "nickname"이라는 key나 이 "avatar"라는 키는 API마다 다를 수 있다. 썬더 클라이언트로 GET 요청을 보내서 확인해보면 좋다.
- const response = await axios.patch( ... ) : API 서버로 patch 요청을 보낸다. ... 안에는 patch 요청을 보낼 때 Headers에 유저의 토큰을 실어서 보내라는 API 명세에 따라 위에서 추출한 토큰 정보를 담아서 보낸다.
- "Content-Type": "multipart/form-data" : Content-Type의 의미는 HTTP 요청을 보낼 때 필요한 서식인데, 지금 사용자가 서버에 보내는 데이터가 이러한 종류입니다라고 명시해주는 것이고, multipart/form-data는 파일과 텍스트가 혼합되어 있다는 의미이다.
- alert("프로필이 업데이트되었습니다.") : 여기까지 완료되면 사용자에게 alert을 띄워주고, 입력 필드를 모두 지워주는 로직을 추가한다. 그리고 else문으로 실패했을 때의 처리 방법도 정한다.
- catch... : 그리고 서버에서 어떠한 이유로든 API 요청에 대한 응답을 받지 못했을 때의 예외 처리 방법도 catch문으로 작성한다.
여기까지 완료되었다면 회원 정보 변경이 잘 되어야 한다. 그런데 잘 안 되는 경우가 있다. 콘솔에 보면 401 에러가 뜨면서 API에서 응답을 못 받는 에러가 뜨기도 하는데, 이 에러는 여러 가능성이 있지만 지금 이 코드는 만료된 토큰에 대해서 처리하는 로직이 없기 때문에 사이트를 오랫동안 띄워놔 토큰이 만료된 상태에서 변경을 시도하면 에러를 뱉는 것이다.
내용이 너무 길어지므로 토큰 만료 시 강제 로그아웃을 시킨다든지의 로직은 추후 구성해보도록 하겠다.
마이페이지 최종 렌더링
마지막으로 MyPage를 어떻게 return하고 있는지 살펴보겠다. MyPage에서 userInfo 상태에 받아온 유저 닉네임과 프로필 사진 URL을 추출하여 바인딩 후 렌더링 하는 것이 포인트이다.
return (
<Container>
<form onSubmit={handleProfileUpdate}>
<InputFieldWithLabel>
<label>현재 닉네임 : {userInfo.nickname}</label>
<label>변경할 닉네임</label>
<input
type="text"
value={newNickname}
onChange={(e) => setNewNickname(e.target.value)}
/>
</InputFieldWithLabel>
<InputFieldWithLabel>
<label>현재 프로필 사진</label>
<img src={userInfo.avatar} width={250}/>
<label>변경할 프로필 사진 선택</label>
<InputFieldWithButton>
<label htmlFor="file-upload">파일 선택</label>
<span>{fileName}</span>
<input
id="file-upload"
type="file"
onChange={handleFileChange}
/>
</InputFieldWithButton>
</InputFieldWithLabel>
<Button type="submit">프로필 업데이트 하기</Button>
</form>
</Container>
);
'Programing > TIL' 카테고리의 다른 글
2024-06-13 axios, json-server, TanStack Query - RESTful (1) | 2024.06.13 |
---|---|
2024-06-12 프론트 엔드 학습 가이드(로드맵) (1) | 2024.06.12 |
2024-06-10 헤더 만들기 (styled-components) (0) | 2024.06.11 |
2024-06-07 팀 프로젝트 회고 (0) | 2024.06.07 |
2024-06-06 게시글을 업데이트 했음에도 페이지가 리렌더링 되지 않는 이유 (0) | 2024.06.06 |
댓글