- Published on
Next.js OAuth 로그인 무한 리다이렉트 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 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가지도 함께 확인하는 것이 빠릅니다.
무한 리다이렉트의 전형적인 구조
대부분의 루프는 아래 구조로 만들어집니다.
- 사용자가
/dashboard접근 - 미들웨어/서버에서 “세션 없음” 판단 →
/login리다이렉트 /login에서 OAuth 시작 → 공급자 로그인- 공급자가
/api/auth/callback(또는/callback)로 돌아옴 - 콜백에서 세션 쿠키를 심으려 하지만 실패(또는 심었는데 다음 요청에서 못 읽음)
- 다시
/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이 미묘하게 다름httpvshttpswww유무- 트레일링 슬래시
- 포트 포함 여부
해결
- 공급자(구글/깃허브 등) 콘솔에 등록한 콜백 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가지만 맞추면 대부분 해결됩니다.
- 콜백이 세션 쿠키를 제대로 굽고, 다음 요청에 쿠키가 실려 간다(Secure/SameSite/Domain/프록시 헤더)
- 미들웨어/가드에서 콜백·로그인·정적 자원 경로를 예외 처리한다
- 서버/클라이언트가 같은 기준으로 로그인 여부를 판단하고, 로딩 중 섣부른 리다이렉트를 하지 않는다
위 체크리스트로도 해결이 안 되면, 리다이렉트 체인(302)과 쿠키 저장 여부를 캡처한 뒤 “어느 단계에서 쿠키가 사라지는지”를 기준으로 원인을 더 좁혀보면 됩니다.