인증은 layout에서 한 번 확인하는 것이 아니라 데이터 접근 경계마다 확인하는 것이다
Next.js App Router에서 layout 인증 체크와 streaming을 같이 쓰면, 보안 경계와 성능 경계는 어디에 두어야 할까?
인증은 layout에서 한 번 확인하는 것이 아니라 데이터 접근 경계마다 확인하는 것이다
- 카테고리: web_app_dev
- 예상 읽기 시간: 10분
- 오늘의 질문: Next.js App Router에서 layout 인증 체크와 streaming을 같이 쓰면, 보안 경계와 성능 경계는 어디에 두어야 할까?
- 핵심 출처:
- Next.js Authentication Guide - n.d., 확인일 2026-06-26
- Next.js Streaming Guide - n.d., 확인일 2026-06-26
- Next.js Data Security Guide - n.d., 확인일 2026-06-26
- vercel/next.js PR #95056: docs(authentication): add Auth and streaming - 2026-06-22, 확인일 2026-06-26
1. 왜 지금 봐야 하나
Next.js 문서에 최근 병합된 PR #95056은 거창한 신기능 릴리스가 아니다. 인증 가이드의 작은 문서 수정처럼 보인다. 하지만 내용은 App Router를 쓰는 팀에게 꽤 중요한 설계 신호다. PR은 Layouts and auth checks 아래에 “Auth and streaming” 설명을 추가하면서, layout 최상단에서 await로 세션을 읽으면 첫 HTML chunk가 늦어질 수 있고, 동적 접근은 더 아래의 Suspense 경계로 밀어 넣으라고 설명한다.
이 말은 단순히 “빠른 로딩을 위해 Suspense를 쓰자”가 아니다. 인증을 어디서 확인할지, 어떤 UI를 먼저 stream할지, 어떤 데이터 접근이 실제 보안 경계인지 분리해서 생각하라는 뜻이다.
많은 App Router 코드가 이런 모양으로 시작한다.
// app/(dashboard)/layout.tsx
export default async function DashboardLayout({ children }) {
const user = await getUserFromCookie()
if (!user) redirect('/login')
return <DashboardShell user={user}>{children}</DashboardShell>
}
처음에는 좋아 보인다. layout에서 한 번 막으면 하위 페이지가 안전해 보이고, 모든 화면에서 사용자 정보를 쉽게 쓸 수 있다. 문제는 두 가지다.
- shared layout은 클라이언트 내비게이션에서 계속 유지될 수 있어 “모든 요청마다 실행되는 보안 관문”처럼 믿으면 안 된다.
- layout 최상단의
await cookies()·DB 세션 조회는 static shell과 streaming 시작을 막아 UX를 느리게 만들 수 있다.
오늘의 핵심 문장: App Router 인증은 layout 하나에 몰아넣는 guard가 아니라, Data Access Layer와 Server Action·Route Handler·Page마다 반복되는 접근 계약이다.
2. 핵심 개념
Next.js 인증 가이드는 인증을 세 부분으로 나눈다.
- Authentication: 사용자가 누구인지 확인한다.
- Session Management: 그 상태를 cookie나 DB session으로 요청 사이에 유지한다.
- Authorization: 이 사용자가 특정 route, data, action에 접근할 수 있는지 판단한다.
웹앱 버그는 보통 세 번째에서 난다. “로그인했는가?”와 “이 row를 수정할 권한이 있는가?”는 다른 질문이다. 그래서 Next.js Data Security 가이드는 새 프로젝트에서 Data Access Layer, 즉 DAL을 권한다. DAL은 서버 전용 모듈에서 다음을 책임진다.
- 서버에서만 실행된다.
server-only로 실수 방지. - 데이터 조회 또는 mutation 전에 인증·인가를 수행한다.
- raw DB row가 아니라 화면에 필요한 최소 DTO만 반환한다.
- request 안에서 반복되는 현재 사용자 조회는
React.cache로 묶을 수 있다.
layout은 이 구조에서 “UI shell을 그리는 곳”에 가깝다. nav에 사용자 이름을 보여줄 수는 있다. 하지만 layout에서 admin을 확인했다고 해서 하위 Server Action의 delete 요청이 안전해지는 것은 아니다. form을 렌더링하지 않았더라도 action endpoint는 직접 POST될 수 있고, route handler도 별도 진입점이다.
나쁜 구조는 이런 식이다.
// app/admin/layout.tsx
export default async function AdminLayout({ children }) {
const user = await auth()
if (!user?.isAdmin) redirect('/login')
return children
}
// app/admin/actions.ts
'use server'
export async function deletePost(id: string) {
// layout에서 막았으니 괜찮다고 가정
await db.post.delete({ where: { id } })
}
더 안전한 구조는 action과 DAL에서 다시 확인한다.
// app/admin/actions.ts
'use server'
import { deletePostForViewer } from '@/lib/dal/posts'
export async function deletePost(id: string) {
await deletePostForViewer(id)
}
// lib/dal/posts.ts
import 'server-only'
import { getCurrentUser } from './auth'
export async function deletePostForViewer(id: string) {
const viewer = await getCurrentUser()
if (!viewer?.isAdmin) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id } })
if (!post) return
if (!canDelete(viewer, post)) throw new Error('Forbidden')
await db.post.delete({ where: { id } })
}
여기서 layout은 “관리자 UI를 보여줄지”의 빠른 안내 역할을 할 수 있지만, 실제 삭제 권한은 DAL이 가진다.
3. 최신 이슈와 연결
이번에 확인한 Next.js 자료의 연결점은 세 가지다.
첫째, Authentication Guide는 layout auth check를 설명하면서도 DAL을 중심으로 세션 검증을 반복하라고 안내한다. 즉 layout에서 사용자 정보를 가져와 nav를 그릴 수는 있지만, 각 data request, Server Action, Route Handler에서 권한 확인을 다시 해야 한다.
둘째, Streaming Guide는 동적 접근을 가능한 아래로 밀라고 설명한다. params, searchParams, cookies(), headers(), fetch 같은 동적 작업을 layout 또는 page 최상단에서 await하면 그 아래 전체가 static shell로 먼저 나가기 어렵다. 반대로 promise를 아래 컴포넌트로 전달하고 Suspense 안에서 읽으면 shell은 먼저 보내고 개인화 영역만 나중에 채울 수 있다.
셋째, Data Security Guide는 RSC가 서버에서 DB와 secret에 접근할 수 있게 해주지만, 그만큼 raw data가 Client Component나 action return으로 새어 나갈 수 있다고 경고한다. 그래서 DTO와 DAL이 중요하다. 보안 경계는 “컴포넌트가 서버에서 실행된다”가 아니라 “이 모듈이 어떤 데이터를 누구에게 반환하는지”에 있다.
PR #95056은 이 세 문서를 연결하는 작은 패치다. layout 최상단 auth가 streaming을 늦춘다는 성능 문제와, layout-only check가 보안 경계가 아니라는 설계 문제를 한 지점에서 만나게 한다.
4. 개발자 관점 해석
실무에서는 다음처럼 나누면 이해가 쉽다.
1) Middleware 또는 Proxy: 빠른 coarse gate
인증 cookie가 전혀 없으면 /login으로 보내는 정도는 middleware/proxy에서 처리할 수 있다. 하지만 여기서 DB 소유권이나 세밀한 권한을 다 해결하려 하면 edge/runtime 제약, stale token, 복잡한 예외 때문에 유지보수가 어려워진다.
2) Layout: shared shell과 사용자 힌트
layout은 nav, sidebar, breadcrumb, “로그인한 사용자 이름” 같은 공유 UI에 적합하다. 단, layout이 하위 모든 요청의 보안 보증서라고 생각하지 않는다. 또한 layout 최상단에서 느린 세션 DB 조회를 await하면 shell streaming이 막힐 수 있다.
3) Page: 화면 단위 접근 결정
페이지가 민감한 화면이면 초기에 notFound(), redirect(), unauthorized() 같은 결정을 할 수 있다. 특히 Streaming Guide가 말하듯 stream이 시작된 뒤에는 HTTP status를 되돌릴 수 없다. 진짜 404/401/302가 필요하면 Suspense fallback이 나가기 전에 결정해야 한다.
4) DAL: 데이터와 권한의 실제 경계
DAL은 “현재 사용자가 이 데이터를 읽거나 쓸 수 있는가?”를 판단한다. 같은 사용자라도 조직, 프로젝트, row owner, billing plan에 따라 결과가 달라진다. 이 판단은 UI 위치가 아니라 데이터 접근 함수 옆에 있어야 한다.
5) Server Action / Route Handler: 외부에서 호출 가능한 entry point
Server Action은 UI를 통해서만 호출된다고 믿으면 안 된다. 이전 글에서 다룬 것처럼 Server Action은 POST 요청 계약이다. 따라서 action은 FormData 검증, auth, resource ownership, return DTO 제한을 반드시 가져야 한다. Route Handler도 동일하다.
5. 내 프로젝트에 적용할 체크포인트
아래 질문에 “예”라고 답하지 못하면 layout auth가 너무 많은 책임을 갖고 있을 가능성이 높다.
- layout의
auth()결과 없이도 Server Action이 스스로 권한을 확인하는가? - Route Handler가 page 접근 여부와 별개로 token/session/owner를 검증하는가?
- DAL 함수 이름이 권한 맥락을 드러내는가? 예:
getProjectForViewer,deletePostForOwner. - Client Component로 넘기는 데이터가 raw DB row가 아니라 최소 DTO인가?
cookies()또는headers()를 layout 최상단에서 읽어야 하는 이유가 명확한가?- 사용자 이름·avatar 같은 개인화 UI를
Suspense경계 안으로 내려 shell streaming을 살릴 수 있는가? - 진짜 HTTP status가 필요한
notFound()·redirect는 stream 시작 전에 실행되는가? - session 검증 결과를 여러 곳에서 쓰는 경우
React.cache같은 request-level memoization을 고려했는가?
작은 팀이라면 처음부터 거대한 권한 프레임워크를 만들 필요는 없다. 대신 하나의 규칙만 정해도 효과가 크다.
“DB를 읽거나 쓰는 함수 이름에는 viewer/user/org context가 들어가고, 그 함수 안에서 권한을 확인한다.”
이 규칙은 코드 리뷰에서 바로 보인다. getPost(id)보다 getPostForViewer(id)가 낫다. deleteProject(id)보다 deleteProjectForMember(id)가 낫다. 이름이 책임을 강제하기 때문이다.
6. 오늘 10분 액션
- App Router 프로젝트에서
(dashboard)또는(admin)layout을 하나 연다. - 그 layout 최상단에
await auth(),await cookies(), DB session 조회가 있는지 찾는다. - 하위 Server Action 하나와 Route Handler 하나를 골라, layout 없이 직접 호출되어도 권한 검사가 되는지 확인한다.
- 민감한 DB 접근 함수 하나를 DAL로 옮기고
import 'server-only'를 붙인다. - 반환값을 raw row에서 DTO로 줄인다. 예:
{ id, title, canEdit }만 반환. - nav의 사용자 프로필처럼 보안 판단이 아닌 개인화 UI는
Suspense경계 안으로 내릴 수 있는지 검토한다.
예시 리팩터링 목표는 크지 않아도 된다.
// before
const post = await db.post.findUnique({ where: { id } })
return <Editor post={post} />
// after
const post = await getEditablePostDTOForViewer(id)
return <Editor post={post} />
중요한 것은 “어디서 막았는가?”를 파일 위치가 아니라 함수 계약으로 설명할 수 있게 만드는 것이다.
7. 더 볼 자료
- Next.js Authentication Guide: auth, session, authorization, layout checks, Server Actions 흐름을 한 번에 볼 수 있다.
- Next.js Streaming Guide: static shell, Suspense boundary, 동적 접근을 아래로 미는 이유를 설명한다.
- Next.js Data Security Guide: DAL, DTO, server-only, action 재검증의 근거가 정리되어 있다.
- vercel/next.js PR #95056: 인증 문서에 Auth and streaming 설명이 추가된 실제 변경 맥락이다.
중복 회피 메모
로컬 content/generated와 Supabase 최근 web_app_dev 글을 확인했다. 최근 글은 Hono SSR request context 격리와 escaping, Server Actions의 POST 요청 계약, npm trusted publishing, Supabase OAuth/RLS 위임 권한, View Transition, Supabase 브랜칭, React Compiler, Next.js Cache Components를 다뤘다. 이번 글은 Next.js와 auth를 다시 언급하지만 Server Action 입력 계약이나 Cache Components 자체를 반복하지 않고, 2026-06-22에 병합된 Next.js 인증 문서의 Auth and streaming 변경을 계기로 layout auth check, streaming shell, DAL 권한 경계를 어떻게 분리할지에 집중했다.
핵심 출처
로그인하면 이 글을 북마크하고, 나만 보는 한 줄 메모를 남길 수 있어요.
댓글 0
최신순 ▾혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)