백엔드의 이해

JWT 기반 인증 흐름 구현: 로그인, 로그아웃 및 토큰 관리

startfront 2025. 4. 29. 09:17

- JWT 실제 활용하여 로그인 로그아웃 만들기

JWT 기반 인증 흐름 이해

JWT(Json Web Token) 인증 흐름

  1. 사용자가 로그인 요청 (아이디, 비밀번호)
  2. 서버가 아이디/비밀번호 검증 후 Access TokenRefresh Token 발급
  3. 클라이언트는 Access Token을 저장해두고 API 요청 시 Authorization 헤더에 넣어 보냄
  4. Access Token이 만료되면, Refresh Token을 이용해 새로운 Access Token을 발급받음
  5. 로그아웃 시 모든 토큰을 무효화
  • Access Token 빠르게 만료시켜 보안 유지
  • Refresh Token은 서버에서 재검증해서 새로운 Access Token 발급

개발 구조 및 흐름 정리

  1. Types 정의: 로그인 상태를 명확히 표현
  2. Redux Toolkit을 이용한 Slice 생성: 전역 로그인 상태 관리
  3. Service Layer 구축: API 통신 분리
  4. Custom Hook 작성: 로그인 로직 캡슐화
  5. 컴포넌트에서 Hook 사용: UI에서 로그인/로그아웃 처리

1. 타입 정의 (Types)

인증 관련 데이터 구조를 명확히 타입으로 정의

// 사용자의 인증 정보를 표현
export interface Auth {
  accessToken?: string;  // 서버로부터 발급 받은 액세스 토큰
  refreshToken?: string; // 리프레시 토큰
  id: string;            // 사용자 ID
  isLoggedIn: boolean;   // 로그인 여부
}

// Redux Store에 저장될 상태 타입
export interface MemberState {
  memberState: Auth | null;
}

 

=> TypeScript로 데이터 구조를 엄격히 관리

 

2. Redux Toolkit으로 Slice 생성

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Auth, MemberState } from '../types/memberTypes';

const initialState: MemberState = {
  memberState: null, // 초기에는 로그인 상태가 없음
};

const authSlice = createSlice({
  name: "member", // slice 이름
  initialState,
  reducers: {
    // 로그인 시 사용자 정보를 저장
    login: (state, action: PayloadAction<Auth>) => {
      state.memberState = action.payload;
    },
    // 로그아웃 시 인증정보 제거
    logout: (state) => {
      state.memberState = null;
    },
    // Access Token만 별도로 갱신할 때 사용
    setAccessToken: (state, action: PayloadAction<string>) => {
      if (state.memberState) {
        state.memberState.accessToken = action.payload;
      }
    },
  },
});

export const { login, logout, setAccessToken } = authSlice.actions;
export default authSlice.reducer;
  • createSlice는
  • Redux Toolkit에서 상태(state), 리듀서(reducer), 액션(action)을 한 번에 선언할 수 있게 도와주는 함수

 

3. API 요청 함수 작성 (Service Layer)

import axios from 'axios';
import { Member, Auth } from '../types/memberTypes';

// 로그인 API
export const loginMember = async (member: Member): Promise<Auth> => {
  const response = await axios.post("/member/login", { id: member.id, password: member.password });
  const accessToken = response.headers["authorization"];
  const refreshToken = response.headers["refresh-token"];
  return { accessToken, refreshToken, id: member.id, isLoggedIn: true };
};

// Access Token 재발급 API
export const refreshAccessToken = async (id: string): Promise<string> => {
  const response = await axios.post("/member/refresh", { id });
  const accessToken = response.headers["authorization"];
  return accessToken;
};

 

  • Axios에서는 HTTP 응답 헤더를 소문자로 접근 (authorization, refresh-token).

4. Custom Hook으로 인증 로직 캡슐화

import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { login, logout, setAccessToken } from "../slices/memberSlice";
import { loginMember } from "../service/member";

export const useAuth = () => {
  const dispatch = useAppDispatch();
  const router = useRouter();
  const memberState = useAppSelector((state) => state.member.memberState);

  // 로그인 요청
  const loginMutation = useMutation({
    mutationFn: loginMember,
    onSuccess: (data) => {
      dispatch(login(data)); // 로그인 성공 시 Redux 저장
      router.push("/");      // 홈으로 이동
    },
    onError: (error) => {
      console.error("로그인 실패:", error);
      alert("로그인 실패");
    },
  });

  // 로그인 함수
  const signIn = useCallback(
    (member: Member) => {
      loginMutation.mutate(member);
    },
    [loginMutation]
  );

  // Access Token 갱신 함수
  const setToken = useCallback(
    (token: string) => dispatch(setAccessToken(token)),
    [dispatch]
  );

  // 로그아웃 함수
  const signOut = useCallback(() => dispatch(logout()), [dispatch]);

  return { memberState, loginMutation, login: signIn, logout: signOut };
};

=> 인증 로직을 UI와 완전히 분리

 

5. 컴포넌트에서 Hook 사용

import { useAuth } from "@/store/hooks/memberHook";
import { useRouter } from "next/navigation";

export default function Profile() {
  const { memberState, logout } = useAuth();
  const router = useRouter();

  const handleLogin = () => router.push("/member/login");
  const handleLogout = () => {
    logout();
    router.push("/books");
  };

  if (!memberState?.isLoggedIn) {
    return (
      <div>
        <button onClick={handleLogin}>로그인</button>
        <Link href="/member/regist">
          <button>회원가입</button>
        </Link>
      </div>
    );
  }

  return (
    <div>
      <span>{memberState.id}님</span>
      <Link href="/member">
        <button>회원정보</button>
      </Link>
      <button onClick={handleLogout}>로그아웃</button>
    </div>
  );
}

 

6. 실제 APP Member 에서 로그인/ 로그아웃 처리!!

"use client";

import { useCallback, useRef } from "react";
import styles from "./login.module.scss";

import { useAuth } from "@/store/hooks/memberHook";

const MemberLogin = () => {
  // 아이디 입력 필드를 위한 ref 선언
  const idRef = useRef<HTMLInputElement>(null);
  // 비밀번호 입력 필드를 위한 ref 선언
  const passwordRef = useRef<HTMLInputElement>(null);

  // 로그인 기능을 위한 useAuth 훅에서 함수 전달받기
  const { login } = useAuth();

  // 로그인 버튼 클릭 시 호출되는 함수
  const handleLogin = useCallback(() => {
    // 입력된 아이디와 비밀번호 값 가져오기
    const id = idRef.current?.value.trim() || "";
    const password = passwordRef.current?.value.trim() || "";

    // 아이디가 입력되지 않으면 경고창 띄우고 포커스 이동
    if (!id) {
      alert("아이디를 입력하세요");
      idRef.current?.focus();
      return;
    }

    // 비밀번호가 입력되지 않으면 경고창 띄우고 포커스 이동
    if (!password) {
      alert("비밀번호를 입력하세요");
      passwordRef.current?.focus();
      return;
    }

    // 로그인 요청
    login({ id, password });
  }, [login]);

  return (
    <div className={styles.container}>
      {/* 로그인 폼 테이블 */}
      <table className={styles.table}>
        <caption>로그인</caption>
        <tbody>
          <tr>
            <td>아이디</td>
            <td>
              <input
                type="text"
                placeholder="아이디를 입력하세요"
                ref={idRef} // 아이디 입력 필드 ref 연결
              />
            </td>
          </tr>
          <tr>
            <td>비밀번호</td>
            <td>
              <input
                type="password"
                placeholder="비밀번호를 입력하세요"
                ref={passwordRef} // 비밀번호 입력 필드 ref 연결
              />
            </td>
          </tr>
        </tbody>
      </table>

      {/* 로그인 버튼 */}
      <div className={styles.buttonGroup}>
        <button className={styles.registerButton} onClick={handleLogin}>
          로그인
        </button>
      </div>
    </div>
  );
};

export default MemberLogin;