블로그에 Firebase Google 로그인을 붙였을 때, 데스크톱에서는 멀쩡한데 모바일 사파리에서만 로그인 버튼이 먹통이 되는 현상을 만났습니다. 처음에는 모바일 브라우저 호환성 문제로 보였는데, 파고들어 보니 popup과 redirect 두 흐름의 트레이드오프와 Firebase authDomain 제약이 얽혀 있었습니다. 같은 함정을 다시 밟지 않으려고 정리해 둡니다.

popup이 먼저, redirect는 fallback#

Firebase Auth의 Google 로그인 흐름은 두 가지입니다.

  • signInWithPopup: 새 창을 띄워서 Google OAuth 화면을 보여주고, 사용자가 동의하면 부모 창으로 결과를 전달합니다.
  • signInWithRedirect: 현재 페이지를 통째로 Google 도메인으로 보내고, 인증이 끝나면 원래 사이트로 돌아옵니다.

데스크톱은 popup이 압도적으로 편합니다. 사용자가 글 읽던 화면을 잃지 않고, 인증 후 바로 그 자리에서 댓글 달기를 이어갈 수 있습니다. 모바일은 다릅니다. iOS Safari나 인앱 브라우저는 popup을 막거나 다른 탭으로 보내 버려서 결과가 부모 창으로 돌아오지 않는 경우가 있습니다. 이때 Firebase는 auth/popup-blocked 또는 auth/operation-not-supported-in-this-environment 에러를 던집니다.

가장 깔끔한 처리는 popup을 먼저 시도하고, 위 두 에러일 때만 redirect로 넘어가는 패턴입니다.

try {
  await signInWithPopup(auth, provider);
} catch (error) {
  if (shouldUseRedirectFallback(error)) {
    await signInWithRedirect(auth, provider);
    return;
  }
  throw new Error(getFirebaseErrorMessage(error));
}

function shouldUseRedirectFallback(error: unknown) {
  const code = getFirebaseErrorCode(error);
  return (
    code === "auth/popup-blocked" ||
    code === "auth/operation-not-supported-in-this-environment"
  );
}

핵심은 fallback 조건을 좁게 잡는 것입니다. 사용자가 popup을 직접 닫은 auth/popup-closed-by-user, 네트워크 실패, 권한 문제까지 redirect로 넘기면 의도하지 않은 페이지 이동이 발생합니다. popup이 환경적으로 불가능한 경우에만 redirect를 사용한다는 규칙을 코드로 굳혔습니다.

redirect fallback이 위험할 때가 있다#

여기까지는 흔한 패턴입니다. 함정은 다음 줄에 있습니다.

signInWithRedirect가 동작하려면 현재 호스트가 Firebase 콘솔의 authDomain과 같은 도메인이거나, 로컬 개발 환경(localhost, 127.x)이어야 안전합니다. custom domain을 쓰는 정적 사이트라면 보통 lifeisonecoin.com에서 운영되는데 authDomainyour-project.firebaseapp.com처럼 다른 도메인일 가능성이 높습니다. 이 상태에서 redirect를 쓰면 OAuth 흐름이 다른 도메인으로 갔다가 돌아오면서 세션이 끊기거나 unauthorized domain 에러로 떨어집니다.

그래서 fallback 조건에 가드를 한 단계 더 두었습니다.

export function canUseFirebaseRedirectFallback(options: {
  authDomain?: string | null;
  currentHost?: string | null;
  currentHostname?: string | null;
}) {
  const authDomainHost = normalizeHost(options.authDomain);
  const currentHost = normalizeHost(options.currentHost);
  const currentHostname = normalizeHostname(options.currentHostname);

  if (!currentHost) return false;
  if (isLoopbackHostname(currentHostname)) return true;

  return Boolean(authDomainHost) && authDomainHost === currentHost;
}

브라우저에서는 window.location.host를 넘겨서 같은 함수를 호출합니다. 이 가드가 false면 redirect로 넘어가는 대신, 사용자에게 popup 차단을 풀어달라는 명시적인 메시지를 띄웁니다. 무리해서 redirect를 시도하기보다, "이 환경에서는 popup이 필요합니다"라고 알려주는 편이 사용자 경험이 낫다고 판단했습니다.

if (shouldUseRedirectFallback(error)) {
  if (!canUseFirebaseRedirectFallbackInBrowser()) {
    throw new Error(getRedirectFallbackUnavailableMessage());
  }
  await signInWithRedirect(auth, provider);
  return;
}

authorized domains와 popup의 cross-origin 동작#

Firebase Authentication 콘솔에는 authorized domains 목록이 있습니다. 이 목록에 운영 도메인과 Cloudflare Pages preview 도메인까지 모두 들어가 있어야 popup도 redirect도 정상 동작합니다. 한 줄이 누락되면 로그인 자체는 되는데 getRedirectResult()에서 빈 결과가 돌아오거나, popup 창에서 동의를 마쳤는데 부모 창으로 결과가 전파되지 않는 식의 미묘한 실패가 생깁니다.

저는 다음 도메인을 모두 등록했습니다.

  • 운영 custom domain (lifeisonecoin.com)
  • Firebase 기본 authDomain (*.firebaseapp.com)
  • Cloudflare Pages preview 도메인 (*.pages.dev)
  • 로컬 개발용 localhost

Cloudflare Pages 정적 배포 함정 글에서 환경변수가 빌드 시점에 박힌다는 점을 다뤘는데, authorized domains도 같은 결의 운영 작업입니다. 코드 쪽이 아무리 정확해도 콘솔 한 줄이 빠지면 결과물이 망가집니다.

사용자 피드백 메시지를 sessionStorage로 넘기기#

Redirect 흐름에서 까다로운 점은 페이지가 한 번 통째로 떠난다는 사실입니다. popup 흐름에서는 try/catch로 잡은 에러를 그 자리에서 토스트로 보여주면 끝나지만, redirect는 다른 도메인을 거쳐 돌아오기 때문에 "방금 일어난 일"의 컨텍스트가 사라집니다.

이 문제는 sessionStorage로 우회했습니다.

const authFeedbackStorageKey = "lifeisonecoin.firebaseAuthFeedback";

export function persistFirebaseAuthFeedback(message: string) {
  window.sessionStorage.setItem(authFeedbackStorageKey, message);
}

export function consumeFirebaseAuthFeedback() {
  const message = window.sessionStorage.getItem(authFeedbackStorageKey);
  if (!message) return null;
  window.sessionStorage.removeItem(authFeedbackStorageKey);
  return message;
}

Redirect 직전에 의도한 메시지를 저장해 두고, 돌아온 후 첫 렌더에서 한 번만 읽고 비웁니다. 이 한 줄짜리 패턴이 redirect 후의 사용자 컨텍스트 단절을 메워 줍니다.

정적 export 사이트라는 제약#

이 블로그는 Next.js 정적 export로 빌드된 사이트입니다. 서버 세션, middleware auth, route handler auth 같은 도구를 쓸 수 없습니다. 모든 인증 상태는 클라이언트 Firebase SDK가 들고 있고, 관리자 권한은 NEXT_PUBLIC_OWNER_EMAIL과 로그인 계정 이메일 비교로 결정합니다.

이 제약이 popup-first 결정에 더 무게를 실어 줬습니다. 정적 사이트는 redirect 후 돌아왔을 때 서버에서 세션을 다시 만들어 줄 수 없고, 모든 복원이 클라이언트 SDK의 onAuthStateChanged 콜백에 달려 있습니다. popup이 가능한 환경에서는 popup이 단순히 더 빠르고 안정적입니다.

정리하면#

Firebase Google 로그인을 모바일에서 안정화하려면 결국 세 줄짜리 규칙으로 압축됩니다.

  • popup을 먼저 시도하고, 환경적 실패에 한해서만 redirect로 넘어간다
  • redirect는 현재 host가 authDomain과 같거나 localhost일 때만 허용한다
  • redirect를 쓰지 못하는 상황에서는 명시적인 메시지를 사용자에게 보여준다

코드 쪽 분기보다 더 중요한 건 Firebase 콘솔의 authorized domains 목록입니다. 이게 어긋나면 위 코드가 아무리 정확해도 모바일 인증이 무너집니다. 블로그를 처음 만들 때 운영 인프라가 코드만큼 중요하다는 걸 배웠는데, Firebase Auth는 그 교훈이 가장 또렷하게 드러나는 영역이었습니다.