정말 이렇게 오래 걸릴줄은 몰랐다

하나가 되면 하나가 막히고

어떤게 되면 또 다른게 안되서 엄청 고생했다

realworld 프로젝트를 작업을 하는데
backend 서버가 쿠키를 활용한 auth를 제공을 안해주어

"Bearer Token Authentication" 방식을 사용하는데

이 방식을 사용할 경우 client에서 localstorage를 사용하여 jwt 토큰을 관리해야 했는데
(생각해보니 nextjs에서 세션을 등록해서 했으면 어땠을까)

nextjs의 생명주기와 swr의 cache의 특성 때문에 고생을 꽤 했다

 

1. useUser hook

"use client";

import useSWR from "swr";
import { API_ENDPOINTS } from "@/constant/api";
import { fetchCurrentUser } from "@/utils/auth/user";
import { useAuthStore } from "@/lib/zustand/authStore";

export const useUser = () => {
  const token = useAuthStore((state) => state.token);

  const { data, error, isLoading, mutate } = useSWR(
    token ? [API_ENDPOINTS.CURRENT_USER, token] : null,
    fetchCurrentUser,
    {
      revalidateOnFocus: false,
      revalidateOnMount: true,
    }
  );
  const user = data?.user;

  return {
    user,
    error,
    isLoggedIn: !!user,
    isLoading,
    mutate,
  };
};

AuthGuard를 만들었는데 에러가 발생하거나 새로고침을 했을 때

자꾸만 login 페이지로 가져서 문제가 발생했다

최외각의 원인은 AuthGuard에서 토큰을 검증하면 좋을 것 같아서

swr을 이용하여 useUser 훅을 만들어 서버로 current user를 가져오는 fetch를 수행하여 검증을 했는데
정적 키를 사용할 경우 처음 fetching 됐을 때 undefined가 캐시에 남아

토큰이 있음에도 받아오는 데이터가 없었다

그래서 [API_ENDPOINTS.CURRENT_USER, token]

를 이용해 동적 키로 만들었다
동적 키로 만들지 않으면

  • 캐시 무효화 및 갱신 문제:
    동적 키를 사용하지 않으면, 토큰이나 요청에 필요한 파라미터가 변경되어도 캐시 키가 고정되어 기존 데이터가 계속 재사용됩니다.
    이로 인해 최신 데이터로 갱신되지 않아, 사용자가 최신 상태를 보지 못하는 문제가 발생합니다.
  • 데이터 불일치:
    예를 들어, 토큰이 변경되었음에도 불구하고, 고정된 캐시 키를 사용하면 이전 토큰으로 패칭된 데이터가 그대로 남아 있을 수 있습니다.
    이는 보안상 문제가 될 뿐만 아니라, 실제 사용자 정보와 불일치하는 데이터를 표시하는 원인이 됩니다.
  • 상태 업데이트의 어려움:
    고정된 캐시 키는 SWR이 토큰 등의 파라미터 변경을 감지하지 못하게 하므로, 데이터 재패칭(revalidation)이 제대로 이루어지지 않습니다.
    결과적으로, 사용자가 새로운 상태로 업데이트된 정보를 보지 못하게 됩니다.

와 같은 문제점이 발생한다고 한다

이렇게 했음에도 불구하고 문제가 해결이 안됐는데

token이 null일 경우에도 요청이 발생해 캐싱된 데이터와 현재 상태가 일치하지 않을 가능성이 있다고 하여

const { data, error, isLoading, mutate } = useSWR(
    token ? [API_ENDPOINTS.CURRENT_USER, token] : null,
    fetchCurrentUser,
    {
      revalidateOnFocus: false,
      revalidateOnMount: true,
    }
  );

위와 같은 최종 형태를 만들었다.

조건부 데이터 패칭을 사용하지 않으면 

 

  • 불필요한 API 호출:
    조건 없이 데이터를 패칭하게 되면, 토큰이 없거나 인증되지 않은 상태에서도 API 요청이 발생합니다.
    이는 서버에 불필요한 부하를 주고, 네트워크 비용을 증가시킬 수 있습니다.
  • 에러 발생 및 예외 처리 복잡성:
    토큰이 없는 상태에서 요청이 발생하면 API 서버에서 인증 에러(예: 401 Unauthorized)를 반환할 가능성이 큽니다.
    이 경우, 에러 핸들링 로직이 복잡해지고, 사용자에게 불필요한 에러 메시지를 노출시킬 수 있습니다.
  • 불안정한 데이터 상태:
    불필요한 호출로 인해 불필요한 데이터 패칭이 이루어지고, 캐싱된 데이터와 현재 상태가 일치하지 않아 예상치 못한 UI 문제나 데이터 불일치가 발생할 수 있습니다.

이런 문제가 발생한다고 한다.

 

 

또한 revalidateOnMount: true, 옵션도 중요한 역할을 하는데
새로고침 등을 했을 때 재마운트가 되는데 이 옵션이 꺼져있다면

초기값으로 돌아가 로그인 한 상태가 유지가 안된다.

 

또 이 훅에서 문제점이

localstorage에서 토큰값을 가져왔는데 이것을 그대로 사용하면

데이터 동기화에 문제가 발생할 수 있어서

zustand를 도입해 토큰 값을 관리하였다.

"use client";

import useSWR from "swr";
import { API_ENDPOINTS } from "@/constant/api";
import { fetchCurrentUser } from "@/utils/auth/user";
import { useAuthStore } from "@/lib/zustand/authStore";

export const useUser = () => {
  const token = useAuthStore((state) => state.token);

  const { data, error, isLoading, mutate } = useSWR(
    token ? [API_ENDPOINTS.CURRENT_USER, token] : null,
    fetchCurrentUser,
    {
      revalidateOnFocus: false,
      revalidateOnMount: true,
    }
  );
  const user = data?.user;

  return {
    user,
    error,
    isLoggedIn: !!user,
    isLoading,
    mutate,
  };
};

최종 useUser

 

2. SSR/CSR 간 상태 불일치, hydration mismatch

이렇게 바꾸고 진행을 하니깐 

SSR/CSR 간 상태 불일치, hydration mismatch 에러가 발생하였다

1.
export default function AuthGuard({ children }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);

  if (!mounted) return null; // 여기서 문제 발생

2.
 if (!mounted) return null;
  if (isProtected && isLoading) {
    return <div>로딩 중...</div>;
  }

정확한 코드는 아니지만 

이런 느낌으로 SSR/CSR이 서로 맞지가 않아 문제가 생겼었다.

이것을 맞추기 위해서는 일관된 레이아웃을 사용해야 했는데

"use client";

export function MainLayout({ children }: { children: React.ReactNode }) {
  return (
    <main className="dark:bg-gray-900 min-h-screen flex justify-center">
      {children}
    </main>
  );
}


"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/zustand/authStore";
import { MainLayout } from "@/components/Auth/MainLayout";
import { useUser } from "@/hooks/useUser";

export default function AuthGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const token = useAuthStore((state) => state.token);
  const isLoaded = useAuthStore((state) => state.isLoaded);
  const { user, isLoading, error } = useUser();

  // useEffect를 통해 조건에 맞으면 리다이렉트 처리
  useEffect(() => {
    if (isLoaded) {
      if (!token || error || (!isLoading && !user)) {
        router.push("/login");
      }
    }
  }, [isLoaded, token, error, isLoading, user, router]);

  // 초기화 중이거나 사용자 데이터 로딩 중일 때 fallback UI (공통 레이아웃 사용)
  if (!isLoaded || isLoading) {
    return (
      <MainLayout>
        <div>{!isLoaded ? "Initializing..." : "Loading user data..."}</div>
      </MainLayout>
    );
  }

  return <>{children}</>;
}

이렇게 AuthGuard에 추가하여 사용해 해결하였다.

 

3. localstorage와 zustand 값 초기화 시점

zustand의 값이 로드되지 않은 상태에서 토큰값을 가져오니깐 또 데이터가 불러와지지 않은 문제가 발생하여
이 타이밍을 맞춰야하는 요구사항이 생겼다

그래서

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface AuthState {
  token: string | null;
  isLoaded: boolean; // 클라이언트 초기화 완료 여부
  setToken: (token: string) => void;
  setIsLoaded: (loaded: boolean) => void;
  clear: () => void;
}

export const useAuthStore = create(
  persist<AuthState>(
    (set) => ({
      token: null,
      isLoaded: false,
      setToken: (token: string) => set({ token, isLoaded: true }),
      setIsLoaded: (loaded: boolean) => set({ isLoaded: loaded }),
      clear: () => set({ token: null, isLoaded: true }),
    }),
    {
      name: "auth-storage", // 로컬스토리지에 저장될 key 이름
    }
  )
);

1. Zustand 스토어 설정
역할
인증 토큰(token)과 클라이언트 초기화 여부(isLoaded)를 상태로 관리합니다.
초기 상태를 token: null, isLoaded: false로 설정하여 SSR 시 undefined 문제가 발생하지 않도록 합니다.

토큰 관리: 서버 사이드에는 아무런 인증 정보가 없으므로 token은 null로 시작합니다.
초기화 플래그: 클라이언트에서 localStorage를 읽고 토큰을 업데이트한 후 isLoaded를 true로 변경합니다.

'use client';

import { useEffect } from 'react';
import { useAuthStore } from '@/store/useAuthStore';

export function ClientInitializer({ children }: { children: React.ReactNode }) {
  const setToken = useAuthStore((state) => state.setToken);
  const setIsLoaded = useAuthStore((state) => state.setIsLoaded);

  useEffect(() => {
    // localStorage에서 토큰 읽기 (클라이언트 전용 API)
    const token = localStorage.getItem('accessToken');
    if (token) {
      setToken(token);
    } else {
      // 토큰이 없어도 초기화 플래그를 업데이트해줌
      setIsLoaded(true);
    }
  }, [setToken, setIsLoaded]);

  return <>{children}</>;
}

2. 클라이언트 초기화 컴포넌트

"use client";

import { useEffect } from "react";
import { useAuthStore } from "@/lib/zustand/authStore";

export function ClientInitializer({ children }: { children: React.ReactNode }) {
  const setToken = useAuthStore((state) => state.setToken);
  const setIsLoaded = useAuthStore((state) => state.setIsLoaded);

  useEffect(() => {
    const authStorage = localStorage.getItem("auth-storage");
    const token = authStorage ? JSON.parse(authStorage).token : null;

    if (token) {
      setToken(token);
    } else {
      // 토큰이 없더라도 초기화 완료 플래그 설정
      setIsLoaded(true);
    }
  }, [setToken, setIsLoaded]);

  return <>{children}</>;
}

역할
컴포넌트가 클라이언트에서 실행될 때 localStorage에 저장된 토큰을 읽어 Zustand 스토어에 반영합니다.
토큰이 없더라도 isLoaded를 true로 만들어 추후 인증 로직을 진행할 수 있게 합니다.

설명
클라이언트 전용 코드: 'use client'; 선언으로 이 컴포넌트는 클라이언트 사이드에서만 작동합니다.
토큰 초기화: useEffect를 통해 localStorage에 저장된 accessToken을 Zustand 스토어에 저장합니다.

3. 공통 레이아웃 컴포넌트

 

'use client';

export function MainLayout({ children }: { children: React.ReactNode }) {
  return (
    <main className="dark:bg-gray-900 min-h-screen flex justify-center">
      {children}
    </main>
  );
}

역할
모든 fallback UI와 콘텐츠에 동일한 HTML 구조를 적용하여 서버와 클라이언트에서 동일한 마크업을 렌더링합니다.
예제에서는 <main> 태그 및 Tailwind CSS 클래스로 구성된 레이아웃을 사용합니다.

설명
일관성 확보: fallback UI(초기 로딩 메시지 등)를 MainLayout으로 감싸면 서버와 클라이언트 간의 HTML 구조 차이가 줄어들어 hydration mismatch를 예방할 수 있습니다.

 

4. 인증 가드 컴포넌트

"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/zustand/authStore";
import { MainLayout } from "@/components/Auth/MainLayout";
import { useUser } from "@/hooks/useUser";

export default function AuthGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const token = useAuthStore((state) => state.token);
  const isLoaded = useAuthStore((state) => state.isLoaded);
  const { user, isLoading, error } = useUser();

  // useEffect를 통해 조건에 맞으면 리다이렉트 처리
  useEffect(() => {
    if (isLoaded) {
      if (!token || error || (!isLoading && !user)) {
        router.push("/login");
      }
    }
  }, [isLoaded, token, error, isLoading, user, router]);

  // 초기화 중이거나 사용자 데이터 로딩 중일 때 fallback UI (공통 레이아웃 사용)
  if (!isLoaded || isLoading) {
    return (
      <MainLayout>
        <div>{!isLoaded ? "Initializing..." : "Loading user data..."}</div>
      </MainLayout>
    );
  }

  return <>{children}</>;
}

역할
클라이언트 상태 초기화(isLoaded)와 인증 상태(token, user 데이터)를 확인하여, 로그인되지 않은 경우 /login으로 리다이렉트합니다.
초기화 진행 중 또는 데이터 로딩 중일 때는 fallback UI를 보여줍니다.

리다이렉트: 인증 정보가 준비된 후, 토큰이 없거나 오류가 있으면 사용자를 /login 페이지로 보냅니다.
일관된 fallback UI: MainLayout 내에 로딩 메시지를 표시해 서버와 클라이언트가 동일한 구조를 갖게 합니다.
조건부 렌더링: 인증 과정이 완료되면 보호된 콘텐츠(children)를 렌더링합니다.

 

5. 보호된 레이아웃

import AuthGuard from '@/components/AuthGuard';

export default function ProtectedLayout({ children }: { children: React.ReactNode }) {
  return <AuthGuard>{children}</AuthGuard>;
}

역할
ProtectedLayout은 (protected) 폴더 내의 모든 페이지를 감싸며 AuthGuard를 적용해 인증된 사용자만 접근할 수 있도록 합니다.

설명
페이지 보호: 이 레이아웃으로 감싸진 모든 페이지는 AuthGuard의 인증 로직에 따라 보호됩니다.

7. 최상위 레이아웃

"use client";

import { useEffect } from "react";
import { useAuthStore } from "@/lib/zustand/authStore";

export function ClientInitializer({ children }: { children: React.ReactNode }) {
  const setToken = useAuthStore((state) => state.setToken);
  const setIsLoaded = useAuthStore((state) => state.setIsLoaded);

  useEffect(() => {
    const authStorage = localStorage.getItem("auth-storage");
    const token = authStorage ? JSON.parse(authStorage).token : null;

    if (token) {
      setToken(token);
    } else {
      // 토큰이 없더라도 초기화 완료 플래그 설정
      setIsLoaded(true);
    }
  }, [setToken, setIsLoaded]);

  return <>{children}</>;
}

역할
애플리케이션의 루트 레이아웃에서 ClientInitializer를 사용해 모든 페이지에서 클라이언트 상태 초기화를 진행합니다.
설명
전역 초기화: 페이지가 로드될 때마다 localStorage에서 토큰을 읽어 전역 상태를 업데이트합니다.
SSR 대비 안전: 서버 사이드 렌더링 시 ClientInitializer 내부의 useEffect는 실행되지 않기 때문에, 클라이언트 전용 API를 안전하게 사용할 수 있습니다.

이 구조를 통해서:
1. 초기 상태 관리: Zustand 스토어로 SSR 시에도 일관된 초기 상태(token: null, isLoaded: false)를 유지합니다.
2. 클라이언트 상태 초기화: ClientInitializer 컴포넌트가 localStorage에서 토큰을 읽어 상태를 업데이트하면서 클라이언트 초기화를 진행합니다.
3. 일관된 UI 렌더링: MainLayout을 사용해 SSR과 클라이언트가 동일한 HTML 구조를 렌더링하도록 하여 hydration mismatch를 예방합니다.
4. 보호된 페이지 처리: AuthGuard에서 인증 상태를 확인하고, 조건에 따라 로그인 페이지로 리다이렉트 하므로 인증되지 않은 사용자가 보호된 페이지에 접근하지 못하도록 합니다.
5. 조건부 API 호출: useUser 훅은 token이 있을 때만 API를 호출하여 불필요한 네트워크 요청을 줄입니다.
이와 같이 단계별로 구성하면 인증, 상태 초기화, 레이아웃 일관성, 그리고 조건부 데이터 패칭 문제를 효과적으로 해결할 수 있습니다.

이런 과정을 거쳐 원하는 기능을 구현할 수 있었다.

+ Recent posts