background
aurora4분

로그인 회원가입

2025년 8월 17일

서버에서 로그인, 회원가입 로직이 끝났다고 해서 바로 연동 후 기능 추가를 진행하였다. API 통신에는 이전에 만들었던 라이브러리를 활용했고, 서버 데이터 캐싱은 TanStack-Query를 활용해서 진행하였다.

useAuth

typescript
import { useApi } from "react-easy-api";
import axiosClient from "@/app/lib/axiosClient";
import {
  setTokens,
  getRefreshToken,
  updateAccessToken,
  clearTokens,
} from "@/app/lib/tokenStorage";
import { SignUpRequest } from "../types/SignUp";
import { LoginRequest, LoginResponse } from "../types/Login";

/**
 * 인증 관련 API 호출을 처리하는 커스텀 훅
 * react-easy-api를 사용하여 API 호출을 관리합니다.
 */
export const useAuth = () => {
  // API 훅을 사용하여 각 엔드포인트별 함수 생성
  const {
    execute: signUpApi,
    loading: isSigningUp,
    error: signUpError,
  } = useApi<string, SignUpRequest>({
    endpoint: "/jv/signup",
    method: "POST",
    axiosInstance: axiosClient,
  });

  const {
    execute: loginApi,
    loading: isLoggingIn,
    error: loginError,
  } = useApi<LoginResponse, LoginRequest>({
    endpoint: "/jv/login",
    method: "POST",
    axiosInstance: axiosClient,
  });

  const {
    execute: refreshTokenApi,
    loading: isRefreshing,
    error: refreshError,
  } = useApi<{ accessToken: string }, { refreshToken: string }>({
    endpoint: "/jv/refresh",
    method: "POST",
    axiosInstance: axiosClient,
  });

  const {
    execute: logoutApi,
    loading: isLoggingOut,
    error: logoutError,
  } = useApi<string, void>({
    endpoint: "/jv/logout",
    method: "POST",
    axiosInstance: axiosClient,
  });

  /**
   * 회원가입을 처리하는 함수
   */
  const signUp = async (data: SignUpRequest): Promise<string> => {
    try {
      console.log("🔐 회원가입 시작:", data);
      console.log("🔗 API URL:", process.env.NEXT_PUBLIC_API_URL);

      // react-easy-api 사용
      const response = await signUpApi(data);
      console.log("✅ 회원가입 응답:", response);

      // null 응답도 성공으로 처리 (서버에서 빈 응답을 보낼 수 있음)
      if (response === null || response === undefined) {
        console.log("✅ 회원가입 완료 (서버에서 빈 응답)");
        return "회원가입이 완료되었습니다.";
      }

      return response || "";
    } catch (error) {
      console.error("❌ 회원가입 중 오류 발생:", error);
      throw error;
    }
  };

  /**
   * 로그인을 처리하는 함수
   */
  const login = async (
    data: LoginRequest & { rememberMe?: boolean }
  ): Promise<LoginResponse> => {
    try {
      const response = await loginApi({
        userEmail: data.userEmail,
        password: data.password,
      });

      // 로그인 성공 시 토큰 저장 (rememberMe 옵션 적용)
      if (response) {
        setTokens(
          response.accessToken,
          response.refreshToken,
          data.rememberMe || false
        );
      }

      return response || { accessToken: "", refreshToken: "" };
    } catch (error) {
      console.error("로그인 중 오류 발생:", error);
      throw error;
    }
  };

  /**
   * 토큰 갱신을 처리하는 함수
   */
  const refreshAccessToken = async (): Promise<string> => {
    try {
      const refreshToken = getRefreshToken();
      if (!refreshToken) {
        throw new Error("Refresh token이 없습니다.");
      }

      console.log("🔄 토큰 갱신 시작");

      const response = await refreshTokenApi({ refreshToken });

      if (!response) {
        throw new Error(refreshError?.message || "토큰 갱신에 실패했습니다.");
      }

      console.log("✅ 토큰 갱신 성공");

      // 새로운 accessToken 저장 (기존 rememberMe 설정 유지)
      updateAccessToken(response.accessToken);

      return response.accessToken;
    } catch (error) {
      console.error("❌ 토큰 갱신 실패:", error);

      // 리프레시 토큰도 만료된 경우 모든 토큰 제거
      clearTokens();

      throw error;
    }
  };

  /**
   * 로그아웃을 처리하는 함수
   */
  const logout = async () => {
    try {
      await logoutApi();
      clearTokens();
    } catch (error) {
      console.error("❌ 로그아웃 실패:", error);
      throw error;
    }
  };

  return {
    signUp,
    login,
    refreshAccessToken,
    isSigningUp,
    isLoggingIn,
    isRefreshing,
    signUpError,
    loginError,
    refreshError,
    logout,
    isLoggingOut,
    logoutError,
  };
};

다음과 같이 실행될 함수를 execute 에 정의한 뒤 비동기 처리를 위해 async await 키워드를 사용하여 Promise 객체를 반환하도록 하였다. API 통신은 설정 했으니 이제 이 데이터들을 캐싱하기 위한 mutation 을 작성해주었다!


useAuthMutation

typescript
import { useMutation } from "@tanstack/react-query";
import { useAuth } from "./useAuth";
import { SignUpRequest } from "../types/SignUp";
import { LoginRequest } from "../types/Login";

/**
 * 회원가입 mutation 훅
 * TanStack Query를 사용하여 회원가입을 처리합니다.
 */
export const useSignUpMutation = () => {
  const { signUp } = useAuth();

  return useMutation({
    mutationFn: async (data: SignUpRequest) => {
      const result = await signUp(data);
      return result;
    },
    onSuccess: (data) => {
      console.log("🎉 회원가입 성공:", data);
      // 성공 알림이나 리다이렉트 로직 추가 가능
    },
    onError: (error) => {
      console.error("회원가입 실패:", error);
    },
  });
};

/**
 * 로그인 mutation 훅
 * TanStack Query를 사용하여 로그인을 처리합니다.
 */
export const useLoginMutation = () => {
  const { login } = useAuth();

  return useMutation({
    mutationFn: async (data: LoginRequest & { rememberMe?: boolean }) => {
      const result = await login(data);
      return result;
    },
    onSuccess: (data) => {
      console.log("🎉 로그인 mutation 성공:", data);
      // 토큰은 이미 useAuth에서 저장됨
    },
    onError: (error) => {
      console.error("❌ 로그인 mutation 실패:", error);
      // 에러 토스트 메시지 표시 가능
    },
  });
};

export const useLogoutMutation = () => {
  const { logout } = useAuth();

  return useMutation({
    mutationFn: async () => {
      await logout();
    },
    onSuccess: () => {},
  });
};

이제 해당 데이터들은 TanStack-query 에 의해 캐싱 되며 데이터가 필요한 컴포넌트에서 해당 mutation 을 호출하여 로직을 작성하면 끝이다.


LoginPage.tsx

typescript
useAuthForm({
      onSubmit: async (data: AuthFormData) => {
        console.log("Login data:", data);
        try {
          const response = await loginMutation.mutateAsync({
            userEmail: data.userEmail,
            password: data.password,
            rememberMe: data.rememberMe || false,
          });

          console.log("🎉 로그인 성공:", response);
          console.log(
            `💾 로그인 상태 유지: ${data.rememberMe ? "활성화" : "비활성화"}`
          );

          // 로그인 성공 후 서버 연결 페이지로 이동
          router.push("/server-connect");
        } catch (error) {
          console.error("❌ Login error:", error);
        }
      },
    });

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    handleSubmit("login");
  };

image.png

image.png

image.png

image.png

RegisterPage.tsx

typescript
useAuthForm({
      initialData: {
        userName: "",
        confirmPassword: "",
        agreeToTerms: false,
      },
      onSubmit: async (data: AuthFormData) => {
        console.log("onSubmit 호출됨 - 폼 데이터:", data);
        try {
          // 1. 회원가입 먼저 진행
          const signUpResponse = await signUpMutation.mutateAsync({
            userEmail: data.userEmail,
            userName: data.userName || "",
            password: data.password,
          });
          console.log("✅ 회원가입 성공:", signUpResponse);

          // 2. 회원가입 성공 후 바로 로그인
          console.log("🔄 자동 로그인 시작...");
          const loginResponse = await loginMutation.mutateAsync({
            userEmail: data.userEmail,
            password: data.password,
          });
          console.log("✅ 자동 로그인 성공:", loginResponse);

          // 3. 로그인 성공 후 서버 연결 페이지로 이동
          console.log(
            "🎉 회원가입 및 로그인 완료! 서버 연결 페이지로 이동합니다."
          );
          router.push("/server-connect");
        } catch (error) {
          console.error("❌ 회원가입 또는 로그인 에러:", error);
        }
      },
    });

image.png

image.png

image.png

image.png

image.png

image.png

회원가입의 경우 성공적으로 가입이 완료되면 바로 로그인이 가능하도록 구현을 했고 로그인은 로그인 상태 유지를 위한 변수를 정의해두었다. 다음은 자동 로그인 기능을 구현하였는데 별도의 훅으로 분리하여 작성하였다.


useAutoLogin.ts

typescript
import { useEffect, useState } from "react";
import { useAuth } from "./useAuth";
import {
  getAccessToken,
  getRefreshToken,
  clearTokens,
} from "@/app/lib/tokenStorage";

/**
 * 자동 로그인을 처리하는 훅
 * 페이지 로드 시 저장된 토큰을 확인하고 유효성을 검사합니다.
 */
export const useAutoLogin = () => {
  const [isChecking, setIsChecking] = useState(true);
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const { refreshAccessToken } = useAuth();

  useEffect(() => {
    const checkAuthStatus = async () => {
      try {
        const accessToken = getAccessToken();
        const refreshToken = getRefreshToken();

        if (!accessToken && !refreshToken) {
          console.log("🔍 토큰이 없습니다. 로그인이 필요합니다.");
          setIsLoggedIn(false);
          return;
        }

        if (accessToken) {
          // 토큰 유효성 검사 (간단한 만료 시간 체크)
          try {
            const payload = JSON.parse(atob(accessToken.split(".")[1]));
            const currentTime = Math.floor(Date.now() / 1000);

            if (payload.exp > currentTime) {
              console.log("✅ 유효한 토큰이 있습니다.");
              setIsLoggedIn(true);
              return;
            } else {
              console.log("⏰ 토큰이 만료되었습니다. 갱신을 시도합니다.");
            }
          } catch (error) {
            console.log("❌ 토큰 파싱 실패, 갱신을 시도합니다.");
          }
        }

        // 토큰이 만료되었거나 없는 경우 갱신 시도
        if (refreshToken) {
          try {
            await refreshAccessToken();
            console.log("✅ 토큰 갱신 성공, 자동 로그인 완료");
            setIsLoggedIn(true);
          } catch (error) {
            console.log("❌ 토큰 갱신 실패, 로그인이 필요합니다.");
            setIsLoggedIn(false);
          }
        } else {
          setIsLoggedIn(false);
        }
      } catch (error) {
        console.error("❌ 자동 로그인 체크 중 오류:", error);
        setIsLoggedIn(false);
      } finally {
        setIsChecking(false);
      }
    };

    checkAuthStatus();
  }, [refreshAccessToken]);

  /**
   * 수동 로그아웃 함수
   */
  const logout = () => {
    clearTokens();
    setIsLoggedIn(false);
    console.log("👋 로그아웃 완료");
  };

  return {
    isChecking,
    isLoggedIn,
    logout,
  };
};

이렇게 자동 로그인 기능까지 구현을 하였고 마지막으로 로그인 상태 유지와 로그아웃 기능이다.


tokenStoage.ts

typescript
/**
 * 토큰 저장 관리 유틸리티
 * rememberMe 옵션에 따라 localStorage 또는 sessionStorage 사용
 */

const TOKEN_KEYS = {
  ACCESS_TOKEN: "accessToken",
  REFRESH_TOKEN: "refreshToken",
  REMEMBER_ME: "rememberMe",
} as const;

/**
 * 토큰을 저장합니다
 */
export const setTokens = (
  accessToken: string,
  refreshToken: string,
  rememberMe: boolean = false
) => {
  const storage = rememberMe ? localStorage : sessionStorage;

  storage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken);
  storage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken);

  // rememberMe 설정 저장 (항상 localStorage에 저장)
  localStorage.setItem(TOKEN_KEYS.REMEMBER_ME, rememberMe.toString());

  console.log(
    `💾 토큰 저장 완료 (${rememberMe ? "localStorage" : "sessionStorage"})`
  );
};

/**
 * 액세스 토큰을 가져옵니다
 */
export const getAccessToken = (): string | null => {
  // localStorage와 sessionStorage 둘 다 확인
  return (
    localStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN) ||
    sessionStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN)
  );
};

/**
 * 리프레시 토큰을 가져옵니다
 */
export const getRefreshToken = (): string | null => {
  // localStorage와 sessionStorage 둘 다 확인
  return (
    localStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN) ||
    sessionStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN)
  );
};

/**
 * rememberMe 설정을 가져옵니다
 */
export const getRememberMe = (): boolean => {
  const rememberMe = localStorage.getItem(TOKEN_KEYS.REMEMBER_ME);
  return rememberMe === "true";
};

/**
 * 액세스 토큰을 업데이트합니다 (갱신 시 사용)
 */
export const updateAccessToken = (accessToken: string) => {
  const rememberMe = getRememberMe();
  const storage = rememberMe ? localStorage : sessionStorage;

  storage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken);
  console.log(
    `🔄 액세스 토큰 업데이트 완료 (${
      rememberMe ? "localStorage" : "sessionStorage"
    })`
  );
};

/**
 * 모든 토큰을 제거합니다
 */
export const clearTokens = () => {
  // 양쪽 스토리지에서 모두 제거
  localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN);
  localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN);
  localStorage.removeItem(TOKEN_KEYS.REMEMBER_ME);

  sessionStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN);
  sessionStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN);

  console.log("🗑️ 모든 토큰 제거 완료");
};

/**
 * 토큰이 존재하는지 확인합니다
 */
export const hasTokens = (): boolean => {
  return !!(getAccessToken() || getRefreshToken());
};

image.png

image.png

image.png

image.png

초기에는 로그인 기억 옵션에 따라 어느 저장소에 저장할 지 정하는 로직이었는데 생각해보니 저장소 개념이 있으면 좋을 것 같다고 판단하여 저장소 유틸리티 함수를 작성하여 적용해보았다. 로그인 상태 유지를 체크하면 rememberme 변수와 함께 localStorage 에 저장하고 그게 아니라면 sessionStorage 에 저장하여 브라우저 종료 시 사라지도록 하였다.


logout

typescript
const logout = async () => {
    try {
      await logoutApi();
      clearTokens();
    } catch (error) {
      console.error("❌ 로그아웃 실패:", error);
      throw error;
    }
  };
typescript
export const useLogoutMutation = () => {
  const { logout } = useAuth();

  return useMutation({
    mutationFn: async () => {
      await logout();
    },
    onSuccess: () => {},
  });
};

로그아웃 기능은 간단하게 구현하였다.


로그인 기능은 계속 해봤던 기능이라서 비교적 쉽게 구현을 하였지만 TanStack-Query 랑 내가 만든 라이브러리랑 같이 연동해서 쓰는 것이 좀 힘들었던 것 같다.

태그

#aurora#Next.js