[STYNA] Firebase Auth Context 분석 – 인증·권한·라우팅 관리

주요 포인트 한눈에 보기

이 글은 완성된 포트폴리오 코드를 기준으로 Next.js + TypeScript + Firebase를 사용할 때
어떤 방식으로 제작되었는지 프로젝트를 분석합니다.
그 중 이번 챕터에서는 Firebase Auth Context를 분석합니다.

전체 코드 구조

[포트폴리오 링크]
[Git 코드 링크]

// src/context/authProvider.tsx
"use client";

// Auth Context 생성 및 타입 정의
const AuthContext = createContext<AuthContextType>({ /* 기본값 */ });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  // Firebase 인증 상태 구독
  const { user, loading } = useAuthUser();

  // 관리자 여부, 사용자 데이터 로딩, 에러 상태 관리
  const [isAdmin, setIsAdmin] = useState(false);
  const [isUserDataLoading, setIsUserDataLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 라우팅 제어를 위한 Next Router
  const router = useRouter();
  const pathname = usePathname();

  const login = async (email: string, password: string, keepAlive: boolean) => {
    /*
      로그인 처리 요약
      - 로그인 유지 여부에 따라 persistence 분기
      - Firebase Auth 인증 수행
      - Firestore 사용자 문서 조회
      - inactive / banned 계정 차단
      - 에러 메시지 통합 처리
    */
  };

  const logout = async () => {
    /*
      로그아웃 처리 요약
      - Firebase Auth 인증 정보 제거
      - 인증/라우터 상태 초기화를 위해 강제 페이지 이동
    */
  };

  const signUp = async (email: string, password: string) => {
    /*
      회원가입 처리 요약
      - Firebase Auth 회원 생성
      - 에러 메시지 가공 및 Context 상태 반영
    */
  };

  const clearError = () => setError(null);

  // Firestore 사용자 데이터 조회 (권한 판단용)
  const { data: userData, isLoading: userDataLoading } = useUserData(user?.uid || "");

  useEffect(() => {
    /*
      인증 상태 + 현재 경로 기반 접근 제어
      - 비로그인 사용자의 마이페이지 접근 차단
      - 로그인 사용자의 로그인 페이지 접근 차단
    */
  }, [user, loading, pathname]);

  useEffect(() => {
    /*
      관리자 권한 판별 요약
      - Firestore 사용자 role 기준
      - 인증 로딩 + 사용자 데이터 로딩 통합 관리
    */
  }, [user, userData, userDataLoading, loading]);

  return (
    <AuthContext.Provider 
      value={{ user, login, logout, signUp, userData, loading, isUserDataLoading, isAdmin, error, clearError }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  /* AuthContext 접근 전용 커스텀 훅 */
}

AuthContext Provider는 Client Component로 동작합니다.
Firebase Auth, window 객체, Context API는 모두 브라우저 환경에서만 정상적으로 동작하므로,서버 컴포넌트에서는 사용할 수 없습니다.
따라서 이 Provider 상단에 “use client” 선언은 선택이 아니라 필수입니다.

이 Provider의 역할을 한 문장으로 정리하면, 브라우저에서 발생하는 로그인·로그아웃·세션 만료 같은 인증 상태 변화를 한곳에서 관리하는 역할입니다. 서버는 요청 시점의 정보만 판단할 수 있지만, 실제 인증 상태의 변화는 모두 사용자 브라우저에서 실시간으로 발생하기 때문에 인증 흐름의 중심은 자연스럽게 Client Component가 됩니다.

AuthContext 타입과 기본값

interface AuthContextType {
  user: any;
  login: (email: string, password: string, keepAlive: boolean) => Promise;
  logout: () => Promise;
  signUp: (email: string, password: string) => Promise;
  loading: boolean;
  userData: any;
  isAdmin: boolean;
  error: string | null;
  clearError: () => void;
  isUserDataLoading: boolean;
}

AuthContextType은 말 그대로 로그인과 관련된 값들이 어떤 형태로 존재하는지를 정의한 타입입니다.
즉, AuthContext를 통해
어떤 값(user, loading, error 등)을 쓰고
어떤 함수(login, logout, signUp 등)를 호출할 수 있는지를
TypeScript로 미리 정해두는 역할을 합니다.

쉽게 말하면
“이 프로젝트에서 로그인 기능을 사용할 때
이런 데이터와 이런 함수들을 사용한다”라고
규칙을 정해둔 선언부입니다.
인증 로직의 범위를 어렵게 확장하기보다는,
로그인과 사용자 상태를 다루기 위한 타입을 정의했다고 이해하면 충분합니다.

const AuthContext = createContext<AuthContextType>({
  user: null,
  login: () => Promise.resolve(),
  logout: () => Promise.resolve(),
  signUp: () => Promise.resolve(),
  loading: true,
  userData: null,
  isAdmin: false,
  error: null,
  clearError: () => {},
  isUserDataLoading: false,
});

createContext에 전달하는 객체는
AuthContextType에서 정의한 타입에 맞는 초기값입니다.
타입만 선언해두면 Context를 만들 수 없기 때문에,
TypeScript 요구사항을 만족시키기 위해
동일한 구조의 기본값을 함께 작성합니다.

이 값들은 실제 서비스에서 사용되는 값이 아니라,
AuthProvider가 아직 적용되지 않은 상태에서도
코드가 깨지지 않도록 하기 위한 기본 형태입니다.
예를 들어 login, logout 같은 함수는
일단 호출만 가능하도록 빈 함수 형태로 정의되어 있습니다.

정리하면 AuthContextType은 “로그인 관련 타입 선언”이고,
createContext의 객체는
그 타입에 맞춰 작성한 “초기값 선언”입니다.
둘은 항상 한 쌍으로 함께 작성된다고 이해하시면 됩니다.

AuthProvider 내부 상태

이 섹션에서는 AuthProvider 내부에서 관리하는 상태들을
실제 코드 흐름 기준으로 나누어 살펴봅니다.
각각의 상태는 역할이 명확히 다르며,
어디에서 사용되는지를 알고 보면 구조가 훨씬 쉽게 보입니다.

1) Firebase 인증 상태

const { user, loading } = useAuthUser();

이 코드는 Firebase Auth가 제공하는
“현재 로그인된 사용자가 있는지”를 구독하는 부분입니다.

user는 로그인된 경우 Firebase User 객체를 가지고, 로그아웃 상태라면 null이 됩니다.
다만 이 값은 즉시 확정되지 않으며, Firebase가 인증 상태를 확인하는 동안에는 loading이 true로 유지됩니다.

loading이 true인 동안에는 로그인 여부를 단정할 수 없기 때문에,
이 시점에 라우팅 이동이나 권한 판별을 수행하면 안 됩니다.

2) 서비스 권한 및 사용자 데이터 상태

const [isAdmin, setIsAdmin] = useState(false);
const [isUserDataLoading, setIsUserDataLoading] = useState(true);

isAdmin은 현재 사용자가 관리자 권한을 가지고 있는지를 나타내는 상태입니다.
이 값은 Firebase Auth만으로는 알 수 없고,
Firestore에 저장된 사용자 데이터를 기준으로 판단됩니다.

isUserDataLoading은 Firebase 인증 로딩과
Firestore 사용자 데이터 로딩을 하나의 기준으로 묶기 위한 상태입니다.
인증은 끝났지만 사용자 정보가 아직 준비되지 않은 상황을
명확히 구분하기 위해 사용됩니다.

3) 인증 관련 에러 상태

const [error, setError] = useState<string | null>(null);

error 상태는 로그인 또는 회원가입 과정에서 발생한 에러 메시지를 Context 차원에서 공통으로 관리하기 위한 값입니다.

이를 통해 각 컴포넌트는 개별적으로 에러를 해석하거나 처리할 필요 없이, AuthContext에서 제공하는 메시지를 그대로 사용하면 됩니다.

정리하면 AuthProvider의 내부 상태는 인증 여부, 서비스 권한, 사용자 데이터 준비 상태, 그리고 인증 에러를 각각 분리해서 관리합니다.
이 분리가 이후 라우팅 제어와 권한 판별 로직을 안정적으로 동작하게 만드는 핵심 구조입니다.

login 함수 전체 흐름

login 함수는 로그인 처리와 관련된 모든 판단을 한 곳에서 처리하기 위한 중심 로직입니다.
실제 구현 코드는 길지만, 흐름을 단계별로 나누면 구조는 단순합니다.

1) 로그인 시도 전 상태 초기화

setError(null);

로그인 시도가 시작되면 이전 시도에서 남아 있을 수 있는 에러 메시지를 먼저 초기화합니다.
이 처리가 없으면 과거 에러가 다음 로그인 시도까지 남아
UI에 잘못 표시될 수 있습니다.

2) 로그인 유지 방식 선택

if (keepAlive) {
  firebaseLoginKeepAlive(email, password);
} else {
  firebaseSignIn(email, password);
}

keepAlive 값은 로그인 상태를 얼마나 유지할지를 결정하는 옵션입니다.
UI는 이 값만 전달하고, 실제 persistence 설정은 Firebase Auth 유틸 함수에서 처리합니다.

브라우저를 닫아도 로그인 상태를 유지할지, 아니면 창을 닫는 순간 로그아웃할지를 이 단계에서 분기합니다.

3) Firestore 사용자 상태 검증

getDoc(doc(db, 'users', uid));

Firebase Auth 인증이 성공하면 uid를 기준으로 Firestore 사용자 문서를 조회합니다. 이 단계는 “로그인 가능 여부”를 판단하는 것이 아니라, 해당 사용자가 현재 서비스 이용이 가능한 상태인지 확인하기 위한 과정입니다.

계정 상태가 inactive 또는 banned로 확인되면 즉시 로그아웃 처리 후 에러를 발생시켜 이후 흐름을 중단합니다. 이는 인증 자체는 성공했더라도, 서비스 이용은 허용하지 않기 위한 명확한 차단 장치입니다.

4) 로그인 성공 처리

return userCredential;

모든 검증을 통과한 경우에만 userCredential을 반환합니다.
이 반환값을 기준으로 UI는 로그인 성공 이후의 흐름을 진행하게 됩니다.

5) 에러 처리 흐름

setError(errorMessage);

catch 블록에서는 서비스에서 정의한 커스텀 에러와 Firebase Auth 에러를 구분하여 처리합니다.

이를 통해 사용자에게는 기술적인 에러 코드가 아닌 서비스 기준의 메시지만 노출됩니다.

logout / signUp 구현

const logout = async () => {
  try {
    await firebaseLogout();
    if (typeof window !== 'undefined') {
      window.location.href = "/auth/login";
    }
  } catch (error) {
    if (typeof window !== 'undefined') {
      window.location.href = "/auth/login";
    }
  }
};

logout 함수는 현재 로그인된 사용자의 인증 상태를 확실하게 종료하기 위한 역할을 합니다. 먼저 firebaseLogout을 호출해 Firebase Auth 내부에 저장된 로그인 정보를 제거함으로써, 더 이상 인증된 사용자가 없도록 만듭니다.

이후 window.location.href를 사용해 로그인 페이지로 이동합니다. 이 방식은 Next.js의 router.replace와 달리, 브라우저 자체를 기준으로 페이지를 새로 로드합니다. 그 결과 인증 상태, 라우터 상태, 메모리에 남아 있던 UI 상태까지 함께 초기화되어 이전 화면 정보가 남지 않습니다.

코드에 포함된 if (typeof window !== 'undefined') 조건은 현재 실행 환경이 브라우저인지 확인하기 위한 안전장치입니다. Next.js 환경에서는 동일한 코드가 서버와 클라이언트 양쪽에서 실행될 수 있기 때문에, 서버 환경에서 window 객체에 접근하면 오류가 발생합니다.

따라서 이 조건을 통해 “지금 이 코드는 브라우저에서 실행되고 있는가”를 먼저 확인한 뒤에만 window.location을 사용하도록 제한합니다. 이를 통해 서버 렌더링 단계에서는 오류를 방지하고, 클라이언트에서는 의도한 대로 강제 로그아웃과 페이지 이동을 수행할 수 있습니다.

const signUp = async (email: string, password: string) => {
  try {
    setError(null);
    return await firebaseSignUp(email, password);
  } catch (err: any) {
    const errorMessage = getErrorMessage(err.code);
    setError(errorMessage);
    throw err;
  }
};

signUp 함수는 회원가입 자체를 직접 구현하지 않고, Firebase Auth 유틸 함수인 firebaseSignUp을 그대로 호출하는 역할만 담당합니다. 즉, 이메일과 비밀번호를 받아 실제 사용자 생성은 Firebase Auth에게 위임하고, Context는 그 흐름을 감싸는 얇은 레이어로 동작합니다.

Context가 개입하는 핵심 지점은 인증 처리 자체가 아니라 에러 관리입니다. 회원가입 과정에서 Firebase Auth가 반환하는 에러 코드는 그대로 노출하지 않고, getErrorMessage 함수를 통해 서비스에서 정의한 메시지로 변환한 뒤 error 상태에 저장합니다.

이 구조를 사용하면 회원가입 로직은 단순하게 유지하면서도, 사용자에게 보여지는 에러 메시지는 항상 동일한 기준과 톤으로 관리할 수 있습니다. 결과적으로 UI 컴포넌트는 “회원가입 성공 여부”와 “에러 메시지 표시”에만 집중할 수 있고, 인증 세부 로직에 의존하지 않게 됩니다.

관리자 권한 판별

useEffect(() => {
  if (user && userData !== undefined) {
    if (userData?.role === 'admin') {
      setIsAdmin(true);
    } else {
      setIsAdmin(false);
    }
  } else if (!user) {
    setIsAdmin(false);
  }

  setIsUserDataLoading(userDataLoading || loading);
}, [user, userData, userDataLoading, loading]);

관리자 권한 여부는 Firebase Auth가 아닌 Firestore에 저장된 사용자 데이터(role)를 기준으로 판단합니다. 이는 인증(Auth)과 권한(Role)을 명확히 분리하기 위한 설계입니다.

isAdmin 상태는 관리자 페이지 접근 제어, 관리자 전용 UI 노출 여부 판단 등 서비스 전반에서 공통 기준으로 사용됩니다.

isUserDataLoading은 인증 상태 로딩과 사용자 데이터 로딩이 모두 끝났는지를 하나의 기준으로 묶기 위한 값입니다. 이를 통해 권한 판별이 로딩 중 섣불리 실행되는 상황을 방지합니다.

라우팅 제어 로직

if (!user && pathname.includes('/mypage')) {
  router.replace('/auth/login');
}
if (user && pathname === '/auth/login') {
  router.replace('/mypage');
}

인증 상태와 경로를 기준으로 접근 제어를 수행합니다.
이 로직을 Context에 두면 페이지마다 중복 검사가 필요 없습니다.

즉, 각 페이지는
“접근 가능 여부”를 직접 판단하지 않고,
AuthContext Provider의 결정을 신뢰하는 구조가 됩니다.

관리자 권한 판별

관리자 여부는 Firebase Auth 정보만으로는 판단할 수 없기 때문에,
Firestore에 저장된 사용자 데이터(role)를 기준으로 결정합니다.
이를 위해 이 프로젝트에서는 TanStack Query 기반 커스텀 훅을 사용해
사용자 데이터를 조회합니다.

import { useQuery } from "@tanstack/react-query";
import { doc, getDoc } from "firebase/firestore";
import { db } from "@/shared/libs/firebase/firebase";

async function fetchUserData(uid: string | null) {
  if (!uid) throw new Error("uid is required");
  const docRef = doc(db, "users", uid);
  const snap = await getDoc(docRef);
  if (!snap.exists()) throw new Error("User not found");
  return snap.data();
}

export function useUserData(uid: string | null) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user", uid],
    queryFn: () => fetchUserData(uid),
    enabled: !!uid,
  });

  return { data, isLoading, error };
}

useUserData 훅은 Firebase Auth에서 전달받은 uid를 기준으로
Firestore users 문서를 조회하는 역할을 합니다.
uid가 아직 준비되지 않은 상태에서는 쿼리를 실행하지 않도록
enabled: !!uid 옵션을 사용해 불필요한 요청을 방지합니다.

이렇게 가져온 사용자 데이터 안의 role 값을 기준으로
AuthProvider 내부에서 isAdmin 상태를 계산합니다.
그 결과 관리자 페이지 접근 제어, 관리자 UI 노출 여부 판단 등
서비스 전반에서 일관된 기준을 사용할 수 있습니다.

또한 인증 상태 로딩과 사용자 데이터 로딩이 모두 끝난 이후에만
권한 판별이 이루어지도록 설계되어 있어,
로딩 중 잘못된 권한 판단이 발생하는 상황을 방지합니다.

FAQ

Q. Firebase Auth 유틸 함수만 직접 사용하면 안 되나요?
기술적으로는 가능합니다.
하지만 로그인 처리, 권한 판단, 접근 제어 로직이
각 페이지와 컴포넌트에 흩어지게 됩니다.

Q. 인증 정책과 권한 판단이 분산되면 왜 문제가 되나요?
어떤 페이지는 접근을 막고,
어떤 페이지는 허용하는 식의 정책 불일치가 발생할 수 있습니다.
또한 정책 변경 시 관련된 모든 페이지를 찾아 수정해야 합니다.

Q. AuthContext Provider를 두면 무엇이 달라지나요?
인증 상태, 권한 정보, 접근 제어 기준이
하나의 기준점으로 모이게 됩니다.
각 페이지는 직접 판단하지 않고,
AuthContext Provider의 결정을 신뢰하면 됩니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기