Next.js App Router에서 Supabase 인증을 처음 붙이면 의아한 점이 많다. 클라이언트를 왜 두 개 만들지? 미들웨어는 왜 필요하지? getUser와 getSession은 뭐가 다르지? 쿠키를 왜 request와 response 양쪽에 써야 하지?
실제로 셀러용 어드민 로그인을 구축하면서 마주친 이 질문들을, 동작 원리와 함께 정리했다.
0. 왜 부품이 많은가
전통적인 SPA에서도 인증 토큰은 보통 쿠키에 담는다(나도 localStorage 대신 쿠키를 써왔다). 다만 그 구조에서는 보통 서버가 단일 백엔드(예: Express)이고, 인증 검증도 그 백엔드 한 곳에서 처리한다.
Next.js App Router는 다르다. 화면의 상당 부분이 서버 컴포넌트(RSC) 로 서버에서 렌더링된다. 그래서 "이 사용자가 누구인지"를 서버 렌더링 시점에도 알아야 하고, 브라우저에서도 알아야 한다. 즉 서버와 브라우저가 같은 세션을 일관되게 봐야 한다. 부품이 많아지는 이유는 전부 여기서 나온다.
Supabase의 Next.js 통합(@supabase/ssr)은 세션을 쿠키에 저장한다. 쿠키는 요청마다 자동으로 서버에 전송되므로, 서버 컴포넌트도 그 쿠키를 읽어 세션을 알 수 있다.
1. 클라이언트가 두 개인 이유
@supabase/ssr로 Supabase를 쓰면 클라이언트를 두 개 만든다.
TYPESCRIPT
// lib/supabase/client.ts — 브라우저용import { createBrowserClient } from '@supabase/ssr';export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process
.
env
.
NEXT_PUBLIC_SUPABASE_ANON_KEY
!
,
);
}
TYPESCRIPT
// lib/supabase/server.ts — 서버용import { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';export async function createClient() { const cookieStore = await cookies(); // Next 15: async return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options), ); } catch { // Server Component에서 set 호출 시 무시 (아래 설명) } }, }, }, );}
둘로 나뉘는 이유는 쿠키에 접근하는 방법이 환경마다 다르기 때문이다.
브라우저에서는 document.cookie로 쿠키를 다룬다. createBrowserClient가 이걸 처리한다.
서버에서는 Next.js의 cookies() API로 요청에 담긴 쿠키를 읽는다. createServerClient에 그 쿠키 핸들러를 직접 연결해야 한다.
같은 Supabase지만 코드가 도는 환경에 따라 쿠키를 다루는 통로가 달라서 클라이언트를 분리한다. 브라우저 컴포넌트는 client.ts, 서버 컴포넌트·액션·라우트 핸들러는 server.ts를 쓴다.
Next 15의 cookies()가 async가 된 점
Next 14까지 cookies()는 동기 함수였는데 Next 15부터 await cookies()로 바뀌었다. Next의 렌더링이 비동기·스트리밍 중심으로 가면서 요청 컨텍스트(쿠키·헤더) 접근을 비동기로 통일한 결과다. 그래서 createClient도 async가 되고, 호출부에서 const supabase = await createClient()로 써야 한다. 빠뜨리면 런타임 에러가 난다.
setAll의 try/catch
위 server.ts의 setAll에 빈 catch가 있다. 버그를 숨기는 게 아니라 의도된 무시다.
React Server Component는 렌더링 중에 쿠키를 읽을 수는 있지만 쓸 수는 없다. RSC는 응답 스트림을 이미 만들기 시작했을 수 있어서, 그 시점에 쿠키 헤더를 바꾸면 모순이 생기기 때문이다. 그런데 Supabase 클라이언트는 토큰이 만료되면 자동 갱신을 시도하며 setAll을 호출한다. RSC에서 이게 호출되면 에러가 나는데, 무시하는 이유는 실제 토큰 갱신을 미들웨어가 담당하기 때문이다(다음 섹션). RSC에서의 set 실패는 어차피 미들웨어가 할 일이라 무시해도 된다.
2. 미들웨어 — 세션 유지를 담당
Supabase의 access token은 수명이 짧다(기본 1시간). 만료되면 refresh token으로 새 access token을 받아야 한다. 이 갱신을 매 요청마다 자동으로 하는 게 미들웨어다. 미들웨어가 없으면 토큰 만료 후 로그인이 풀린다.
TYPESCRIPT
// lib/supabase/middleware.tsimport { createServerClient } from '@supabase/ssr';import { NextResponse, type NextRequest } from 'next/server';export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll(); }, setAll(cookiesToSet) { // 1) request에 쓰기 cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value), ); // 2) response를 새로 만들고 거기에도 쓰기 supabaseResponse = NextResponse.next({ request }); cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options), ); }, }, }, ); // 이 호출이 토큰 refresh를 트리거한다 await supabase.auth.getUser(); return supabaseResponse;}
TYPESCRIPT
// middleware.ts (프로젝트 루트)import { type NextRequest } from 'next/server';import { updateSession } from '@/lib/supabase/middleware';export async function middleware(request: NextRequest) { return await updateSession(request);}export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ],};
쿠키를 request와 response 양쪽에 쓰는 이유
setAll에서 쿠키를 두 곳에 쓴다.
request.cookies.set — 이번 요청을 이어서 처리할 다운스트림(서버 컴포넌트 등)이 갱신된 토큰을 즉시 보도록. request에 안 쓰면 같은 요청 안에서 뒤따르는 서버 코드가 만료된 토큰을 본다.
response.cookies.set — 갱신된 토큰을 브라우저에 다시 내려주도록. 응답 헤더의 Set-Cookie로 실려서, 브라우저가 다음 요청부터 새 토큰을 보낸다.
한쪽만 쓰면 갱신이 반쪽이 된다. request에만 쓰면 브라우저는 옛 토큰을 갖고, response에만 쓰면 이번 요청의 서버 코드가 옛 토큰을 본다. 양쪽에 써야 지금 이 요청과 다음 요청이 모두 새 토큰을 본다.
getUser()와 getSession()의 차이
미들웨어에서는 getUser()를 쓴다. getSession()이 아니다.
getSession()은 쿠키에 저장된 세션을 그냥 읽어서 반환한다. 네트워크 호출이 없어 빠르지만, 쿠키가 위·변조됐는지 검증하지 않는다. 클라이언트가 쿠키를 조작하면 가짜 세션을 믿을 수 있다.
getUser()는 Supabase 인증 서버에 토큰을 보내 검증한다. 유효한 토큰인지 확인하고, 만료됐으면 refresh까지 트리거한다.
서버에서 사용자 신원을 판단하는 자리에서는 getUser()를 쓴다. 미들웨어와 서버 컴포넌트의 인증 확인은 getUser 기준이어야 한다. getSession을 신뢰하면 보안 문제가 생긴다.
공식 패턴의 두 가지 주의사항
Supabase 문서는 미들웨어에서 두 가지를 경고한다.
createServerClient와 getUser() 사이에 코드를 넣지 말 것. 그 사이에 로직이 끼면 세션 갱신 타이밍이 어긋나 디버깅하기 어려운 로그아웃 버그가 생긴다.
supabaseResponse 객체를 그대로 반환할 것. 새 NextResponse를 만들어 반환하면 setAll이 심어둔 갱신 쿠키가 사라진다. 응답을 커스터마이즈해야 한다면 기존 supabaseResponse의 쿠키를 새 응답으로 복사해야 한다.
matcher — 미들웨어 적용 범위
matcher 정규식은 정적 자산(_next/static, 이미지 등)을 제외한다. 이유는 두 가지다. CSS·이미지 요청마다 getUser()로 인증 서버를 호출하면 불필요한 부하이고, 정적 자산은 인증과 무관하다. 단, API 라우트(app/api)는 제외하지 않는다. API도 세션이 필요할 수 있기 때문이다.
3. 미들웨어가 막지 않는 이유 — 갱신과 가드의 분리
여기서 결정한 부분. 우리 미들웨어는 세션을 갱신만 하고, 로그인 안 한 사용자를 막지 않는다. getUser() 결과로 리다이렉트하지 않고 응답을 그대로 통과시킨다.
이유는 앱에 로그인이 필요 없는 영역(손님 챗봇)과 필요한 영역(어드민)이 함께 있기 때문이다. 미들웨어가 "미인증이면 차단"하면 익명 손님이 챗봇을 못 쓴다. 미들웨어는 모든 요청에 끼어들기 때문에 여기에 가드를 넣으면 영향 범위가 너무 넓다.
'use client';const supabase = createClient(); // 브라우저 클라이언트const { error } = await supabase.auth.signInWithPassword({ email, password });if (error) throw new AppError('이메일 또는 비밀번호가 올바르지 않습니다.');showSuccess('로그인되었습니다.');router.refresh(); // (1) 서버가 새 세션을 인지router.push('/admin'); // (2) 그 다음 이동
signInWithPassword가 성공하면 브라우저 클라이언트가 세션 쿠키를 심는다. 여기서 한 가지 주의할 점이 있다.
router.refresh()를 먼저 하는 이유
로그인은 브라우저(클라이언트 컴포넌트)에서 일어나고, 세션 쿠키도 브라우저가 막 심었다. 그런데 /admin은 서버 컴포넌트다. 서버는 이미 렌더된 상태라 방금 생긴 세션을 아직 모른다.
이때 router.push('/admin')만 하면 서버가 로그인 이전 상태로 /admin을 보여줄 수 있다. 로그인했는데 화면은 미인증처럼 보이는 문제다.
router.refresh()는 서버 컴포넌트를 다시 렌더링하게 한다. 이때 미들웨어와 서버 클라이언트가 새 쿠키를 읽어 로그인 상태를 인지한다. refresh로 서버를 먼저 갱신한 뒤 push로 이동해야 상태가 일치한다. 순서가 바뀌면 깜빡임이나 미인증 표시가 생긴다.
정리
전체 흐름은 아래 도표로 정리했다.
부품이 많아 보이지만 공통된 목적은 하나다. 서버와 브라우저가 같은 세션을 보게 하는 것. 클라이언트 분리, 미들웨어의 양쪽 쿠키 쓰기, router.refresh 모두 이 목적을 위한 장치다.