App Router에서 Firebase Auth로 관리자 접근을 지키는 방법

김민석·2025년 10월 9일
0

Tech Deep Dive

목록 보기
11/58

Intro

  • 고객용 페이지와 관리자 대시보드가 같은 Next.js 프로젝트 안에 엮여 있어서 역할 검증이 꼬이던 경험이 있나요? 저는 최근 프로젝트에서 그런 혼선을 겪었고, 덕분에 Firebase Auth와 App Router를 다시 뜯어보게 됐어요.
  • 이메일·비밀번호 로그인과 SNS 로그인이 뒤섞인 환경이라, 단순히 useEffect로 비동기 체크를 걸어두는 것만으로는 미묘한 깜빡임과 접근 허용 타이밍 이슈가 해결되지 않았습니다.

핵심 아이디어 요약

  • 서버 컴포넌트 루트에서 AuthProvider를 감싸 두고, 클라이언트 전용 컨텍스트로 인증 상태와 로딩을 한 번에 노출합니다.
  • 관리자 레이아웃(src/app/admin/layout.tsx)이 컨텍스트를 바라보며 Firestore userInfos 컬렉션에서 역할을 조회하고, 역할이 맞지 않을 때는 즉시 가드합니다.
  • AdminTemplate는 UI 레이어를 책임지고, 접근 거부 시 메시지와 현재 역할을 보여줘 운영자가 스스로 계정 상태를 파악하게 했습니다.

준비와 선택

  • Next.js 13 App Router 구조를 쓰는 만큼, 서버 컴포넌트와 클라이언트 컴포넌트의 경계가 명확해야 했습니다. 저는 인증 상태가 페이지 전반에서 필요하다고 판단해 src/app/layout.tsx에서 컨텍스트를 미리 주입했어요.
  • Firebase Auth는 onAuthStateChanged가 믿음직하지만, 초기 렌더 타이밍에 따라 레이아웃이 빈 화면을 보여줄 수 있습니다. 그래서 loading 플래그를 함께 다루고, 관리자 레이아웃에서는 로딩이 끝난 뒤에만 Firestore를 두드리도록 했습니다.
  • 구현 초반에 GPT에게 “App Router에서 Firebase Auth 컨텍스트를 어디에 물리는 게 안전할까?”라고 묻고, SSR과 CSR 경계를 복기한 뒤로 방향을 확정했습니다.

구현 여정

  1. 루트 레이아웃에서 인증 컨텍스트 주입
    src/app/layout.tsxAuthProvider와 환율 컨텍스트를 감싸 두면서, 모든 하위 라우트가 같은 인증 정보를 공유하게 만들었습니다.

    // src/app/layout.tsx
    <html lang="ko">
      <AuthProvider>
        <LocalizationProvider>
          <body className={cls(montserrat.className)}>
            {children}
            <ToastContainer />
          </body>
        </LocalizationProvider>
      </AuthProvider>
    </html>

    이렇게 해두니 페이지 전환 때마다 로그인 상태를 다시 확인할 필요가 없어졌고, 토스트 피드백도 일관되게 작동했어요.

  2. AuthProvider로 초기 로딩 제어
    Firebase SDK가 브라우저 전용이라는 점 때문에 컴포넌트를 use client로 선언하고, 로딩 플래그를 명시했습니다.

    // src/utils/AuthProvider.tsx
    export function AuthProvider({ children }: { children: React.ReactNode }) {
      const [currentUser, setCurrentUser] = useState<User | null>(null);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        const unsubscribe = onAuthStateChanged(auth, (user) => {
          setCurrentUser(user);
          setLoading(false);
        });
        return unsubscribe;
      }, []);
    
      return (
        <AuthContext.Provider value={{ currentUser, loading }}>
          {children}
        </AuthContext.Provider>
      );
    }

    로딩 상태를 분리해 두면 관리자 라우트에서는 loading이 false일 때만 Firestore를 조회하도록 분기할 수 있고, 깜빡임이 확실히 줄어듭니다.

  3. 관리자 레이아웃에서 역할 확인
    src/app/admin/layout.tsxuseAuth()를 호출해 로그인 상태를 감지하고, Firestore에서 역할을 확인한 뒤에만 AdminTemplate을 렌더링합니다.

    // src/app/admin/layout.tsx
    export default function AdminLayout({ children }: { children: React.ReactNode }) {
      const { currentUser } = useAuth();
      const [isAdmin, setIsAdmin] = useState(false);
      const [role, setRole] = useState("");
    
      useEffect(() => {
        if (!currentUser) return;
        getUserInfo(currentUser.uid).then((userInfo) => {
          setRole(userInfo?.role || "로그인 필요");
          if (userInfo?.role === "ADMIN") setIsAdmin(true);
        });
      }, [currentUser]);
    
      return (
        <AdminTemplate
          isAdmin={isAdmin}
          role={role}
          Header={<AdminHeader title="시스템 관리자 대시보드" />}
          Navigation={<AdminNavigation navList={navList} />}
        >
          {children}
        </AdminTemplate>
      );
    }

    저는 여기서 “권한 없어요” 같은 딱딱한 메시지 대신, 실제로 감지한 역할을 함께 보여주도록 했습니다. 운영자가 잘못된 계정으로 로그인했을 때 즉시 파악할 수 있었고, 헉- 하는 상황을 줄였어요.

  4. 가드 UI 정리
    AdminTemplateisAdmin이 false일 때 전체 화면 안내를 띄워줍니다. 뷰 레벨에서 접근을 한 번 더 차단하면, 네트워크 지연으로 isAdmin 값이 늦게 설정되더라도 사용자가 페이지 내부를 엿보는 걸 막을 수 있습니다.

  5. 보너스: 라우터 전환 처리
    loginUsergoogleLogin에서 인증 성공 후 router.push("/")를 실행하면서 동시에 lastLoginAt을 갱신하도록 했는데, 이 타이밍 덕분에 관리자 계정은 로그인 직후에 바로 Firestore 권한 확인으로 넘어갑니다.

결과와 회고

  • 같은 코드베이스에 있는 퍼블릭 페이지와 관리자 도구가 서로 영향을 주지 않으면서도, 역할이 바뀌면 즉시 반영되는 경험을 제공할 수 있었습니다.
  • loading 플래그를 따로 관리한 덕분에 “관리자 페이지에 들어왔는데 하얀 화면만 보인다”는 신고가 사라졌어요. 진짜로 마음이 편해지더라고요.
  • 앞으로는 React.Suspense나 Next.js Middleware로 더 일찍 가드해 보는 실험도 해보고 싶습니다. 혹시 비슷한 문제를 겪는다면 어떤 접근을 쓰고 있는지 댓글로 남겨주세요.

Reference

profile
동업자와 함께 창업 3년차입니다. Nextjs 위주의 프로젝트를 주로 하며, React Native, Supabase, Nestjs를 주로 사용합니다. 인공지능 야간 대학원을 다니고 있습니다.

0개의 댓글