Published on

Next.js OAuth 로그인 무한 리다이렉트 해결 가이드

Authors

서드파티 OAuth(구글/깃허브/카카오 등)를 Next.js에 붙이면, “로그인 버튼 → OAuth 동의 → /callback → 다시 /login → 다시 OAuth…” 같은 무한 리다이렉트 루프가 종종 발생합니다. 겉으로는 인증이 성공한 것처럼 보이지만, 앱이 세션을 “없다”고 판단하면서 보호 라우트에서 다시 로그인으로 보내기 때문입니다.

이 글에서는 Next.js(App Router/Pages Router 모두)에서 자주 발생하는 무한 리다이렉트의 원인을 증상 기반으로 분해하고, 쿠키/세션/미들웨어/프록시(Cloudflare·ALB 등)·OAuth 설정까지 한 번에 정리합니다.

> 참고로 OAuth 흐름 자체에서 invalid_grant나 PKCE 오류가 섞여 보인다면, 먼저 OAuth PKCE인데 invalid_grant 뜨는 9가지도 함께 확인하는 것이 빠릅니다.

무한 리다이렉트의 전형적인 구조

대부분의 루프는 아래 구조로 만들어집니다.

  1. 사용자가 /dashboard 접근
  2. 미들웨어/서버에서 “세션 없음” 판단 → /login 리다이렉트
  3. /login에서 OAuth 시작 → 공급자 로그인
  4. 공급자가 /api/auth/callback(또는 /callback)로 돌아옴
  5. 콜백에서 세션 쿠키를 심으려 하지만 실패(또는 심었는데 다음 요청에서 못 읽음)
  6. 다시 /dashboard 접근 시 세션이 없다고 판단 → 2로 회귀

핵심은 콜백에서 발급한 세션(쿠키/토큰)이 다음 요청에서 일관되게 인식되느냐입니다.

1) 가장 흔한 원인: 쿠키가 저장/전송되지 않는다

증상

  • 콜백 응답에는 Set-Cookie가 보이는데, 다음 요청에 Cookie 헤더가 없음
  • 로컬에서는 되는데, 배포(HTTPS/프록시 뒤)에서만 루프

체크리스트

  • Secure 쿠키인데 실제로는 HTTP로 인식됨(프록시/터널 환경)
  • SameSite 정책 때문에 콜백에서 쿠키가 무시됨
  • Domain/Path가 잘못되어 다른 경로에서 쿠키가 안 붙음
  • 서브도메인(app.example.com)과 루트 도메인(example.com) 혼용

해결 포인트

  • 운영은 기본적으로 HTTPS + Secure 쿠키가 맞습니다. 문제는 “서버가 HTTPS로 인식하느냐”입니다.
  • 프록시(ALB/Cloudflare/Nginx) 뒤에서는 X-Forwarded-Proto: https가 전달되고, Next.js가 이를 신뢰하도록 구성해야 합니다.

(예시) NextAuth를 쓴다면 NEXTAUTH_URL과 프록시 헤더가 중요

# .env
NEXTAUTH_URL=https://app.example.com
NEXTAUTH_SECRET=...긴 랜덤...

Nginx/ALB/Cloudflare 뒤에서 http로 오인하면 NextAuth가 쿠키를 예상과 다르게 굽거나, 콜백 URL을 http로 만들면서 꼬일 수 있습니다.

프록시에서 헤더를 확실히 전달하세요.

# Nginx reverse proxy 예시
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Cloudflare를 쓴다면, 520/521 등 엣지 오류와 함께 리다이렉트 루프가 겹쳐 보이기도 합니다. 이 경우 원인이 애플리케이션이 아니라 프록시/원본 간 설정일 수 있으니 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단처럼 원본 로그부터 확인하는 접근이 도움이 됩니다.

SameSite는 어떻게?

  • OAuth 콜백은 “외부 사이트에서 돌아오는” 플로우입니다.
  • 최신 브라우저는 크로스사이트 쿠키에 엄격합니다.

일반적으로 세션 쿠키는 다음을 권장합니다.

  • 동일 사이트 내에서만 쓰면: SameSite=Lax (대부분 OK)
  • 임베디드/서드파티 컨텍스트가 필요하면: SameSite=None; Secure

NextAuth 기준으로는 보통 기본값이 안전하지만, 커스텀 쿠키를 쓰거나 프록시 환경에서 Secure 판단이 틀리면 루프가 나기 쉽습니다.

2) 콜백 URL/redirect_uri 불일치로 “성공처럼 보이는데” 세션이 안 생김

증상

  • 공급자 콘솔에서는 로그인 성공 로그가 남음
  • 앱은 계속 /login으로 돌아감
  • 네트워크 탭에서 콜백이 302로 여러 번 반복

원인

  • 공급자에 등록한 redirect_uri와 실제 콜백 URL이 미묘하게 다름
    • http vs https
    • www 유무
    • 트레일링 슬래시
    • 포트 포함 여부

해결

  • 공급자(구글/깃허브 등) 콘솔에 등록한 콜백 URL을 실제 사용자 브라우저에서 보이는 URL로 맞추세요.
  • NextAuth 사용 시 NEXTAUTH_URL이 이 URL과 정확히 일치해야 합니다.

3) Next.js Middleware에서 인증 예외 경로를 빼지 않아 루프가 난다

App Router/Pages Router 모두에서 middleware.ts로 보호 라우트를 만들 때, 콜백/로그인/API 경로까지 보호해버리면 루프가 발생합니다.

나쁜 예: 모든 경로를 보호

// middleware.ts (나쁜 예)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const hasSession = req.cookies.has("session");
  if (!hasSession) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/:path*"],
};

이렇게 하면 /login 자체도 다시 /login으로 보내거나, /api/auth/callback까지 막아버려 세션이 만들어질 기회를 없애버립니다.

좋은 예: 예외 경로 분리 + 정적 자원 제외

// middleware.ts (개선 예)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const PUBLIC_PATHS = [
  "/login",
  "/api/auth",        // NextAuth
  "/oauth/callback",  // 커스텀 콜백
  "/_next",
  "/favicon.ico",
];

function isPublicPath(pathname: string) {
  return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/"));
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  if (isPublicPath(pathname)) {
    return NextResponse.next();
  }

  const hasSession = req.cookies.has("session");
  if (!hasSession) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/:path*"],
};

핵심은 콜백 엔드포인트와 로그인 페이지는 반드시 통과시켜야 한다는 점입니다.

4) 서버/클라이언트에서 서로 다른 “로그인 여부”를 보고 있다

증상

  • 서버 컴포넌트에서는 로그인으로 보이는데, 클라이언트에서 useEffect로 다시 /login 이동
  • 또는 반대(클라이언트는 로그인인데 SSR에서 비로그인 처리)

원인

  • 서버는 쿠키 기반 세션을 보는데, 클라이언트는 로컬스토리지 토큰을 본다(혹은 반대)
  • CSR에서 세션 로딩 전 “비로그인”으로 간주하고 리다이렉트

해결 전략

  • 단일 소스(SSOT): 쿠키 기반 세션이면 서버/클라이언트 모두 쿠키를 기준으로 판단
  • 클라이언트 가드가 필요하면 “로딩 상태”를 둬서 성급한 리다이렉트를 막기
// app/dashboard/page.tsx (예: 서버에서 1차 판정)
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export default function DashboardPage() {
  const hasSession = cookies().has("session");
  if (!hasSession) redirect("/login");

  return <div>Dashboard</div>;
}

클라이언트에서 추가 검증이 필요하다면, 최소한 세션 조회가 끝나기 전에는 리다이렉트하지 않도록 합니다.

5) redirect()/router.push()를 잘못된 타이밍에 호출

특히 App Router에서 다음 패턴이 루프를 만들기 쉽습니다.

  • 서버 컴포넌트에서 redirect('/login')
  • 로그인 페이지에서 조건부로 다시 보호 페이지로 redirect('/dashboard')
  • 세션이 아직 반영되기 전이라 서로 핑퐁

해결

  • 로그인 완료 후에는 반드시 세션이 저장된 다음 요청에서 판정하도록 설계
  • 콜백 처리 후 즉시 보호 페이지로 보내더라도, 그 요청에서 세션 쿠키가 포함되는지 확인

콜백 핸들러를 커스텀으로 만들었다면 응답에서 쿠키를 설정하고, 그 다음 리다이렉트를 하되 브라우저가 쿠키를 받아들일 속성인지 재점검하세요.

// app/api/oauth/callback/route.ts (예시)
import { NextResponse } from "next/server";

export async function GET(req: Request) {
  // ... code -> token 교환 후 세션 생성했다고 가정
  const res = NextResponse.redirect(new URL("/dashboard", req.url));

  res.cookies.set({
    name: "session",
    value: "signed-session-value",
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });

  return res;
}

배포 환경에서 secure: true인데도 쿠키가 안 들어가면, 대부분 “HTTPS로 인식되지 않는 프록시 구성” 문제로 되돌아갑니다.

6) 프록시/로드밸런서 뒤에서 호스트/프로토콜이 뒤틀린다

증상

  • 콜백 URL이 예상과 다르게 생성됨 (예: http://internal:3000/...)
  • 로그인 성공 후 외부 도메인이 아닌 내부 도메인으로 리다이렉트
  • 특정 환경(EKS/ALB/Cloudflare)에서만 재현

해결

  • X-Forwarded-Host, X-Forwarded-Proto를 원본까지 전달
  • 애플리케이션에서 “외부로 노출된 base URL”을 환경변수로 고정
  • 멀티 도메인/프리뷰 환경이면 허용 목록을 명확히

또한 엣지/프록시 장애가 겹치면 리다이렉트가 문제인지, 원본 장애로 인한 재시도인지 구분이 어려워집니다. 이럴 때는 먼저 원본 접근성을 분리해 확인하는 것이 좋습니다(예: Cloudflare 우회 테스트, 원본 ALB 직접 호출 등).

7) 진단 방법: 5분 안에 루프 원인 좁히기

1) 브라우저 DevTools에서 확인

  • Network 탭에서 302 체인을 펼쳐서
    • 어디서 /login으로 돌아가는지
    • 콜백 응답에 Set-Cookie가 있는지
    • 다음 요청에 Cookie가 붙는지

2) 서버 로그에 최소한 이것만 남기기

  • 요청 URL, Host, X-Forwarded-Proto, Cookie 유무
  • 리다이렉트 결정 지점(미들웨어/서버 컴포넌트/라우트 핸들러)
// middleware.ts에 임시 로깅 (운영에서는 PII 주의)
console.log("MW", {
  path: req.nextUrl.pathname,
  host: req.headers.get("host"),
  proto: req.headers.get("x-forwarded-proto"),
  hasCookie: !!req.headers.get("cookie"),
});

3) 쿠키 속성 검증

  • Application 탭에서 쿠키가 실제로 저장됐는지
  • Domain/Path/SameSite/Secure/Expires 확인

결론: 루프를 끊는 가장 확실한 처방전

무한 리다이렉트는 대개 “인증 로직”이 아니라 **세션 전달(쿠키)과 라우팅 가드(미들웨어)**의 경계에서 발생합니다. 아래 3가지만 맞추면 대부분 해결됩니다.

  1. 콜백이 세션 쿠키를 제대로 굽고, 다음 요청에 쿠키가 실려 간다(Secure/SameSite/Domain/프록시 헤더)
  2. 미들웨어/가드에서 콜백·로그인·정적 자원 경로를 예외 처리한다
  3. 서버/클라이언트가 같은 기준으로 로그인 여부를 판단하고, 로딩 중 섣부른 리다이렉트를 하지 않는다

위 체크리스트로도 해결이 안 되면, 리다이렉트 체인(302)과 쿠키 저장 여부를 캡처한 뒤 “어느 단계에서 쿠키가 사라지는지”를 기준으로 원인을 더 좁혀보면 됩니다.