How to implement authentication in Next.js

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
19/79

안녕하세요! 프론트엔드 개발의 핵심 중 하나인 '인증(Authentication)' 파트를 함께 살펴보겠습니다. 애플리케이션의 데이터를 안전하게 보호하기 위해 인증을 이해하는 것은 정말 필수적입니다. 이 페이지에서는 React와 Next.js의 어떤 기능들을 사용해서 인증을 구현할 수 있는지 차근차근 안내해 드릴 거예요.

문서 내용에 더해, 실무나 기술 면접에서 자주 등장하는 포인트들도 제가 팁으로 덧붙여 드리겠습니다. 시작해 볼까요?

시작하기 전에, 전체적인 과정을 다음 세 가지 핵심 개념으로 나누어 보면 이해하기 훨씬 쉽습니다:

  1. 인증 (Authentication): 사용자가 본인이 맞는지 증명하는 과정입니다. 아이디나 비밀번호처럼 사용자가 가지고 있는 정보를 통해 자신의 신원을 증명해야 하죠.
  2. 세션 관리 (Session Management): 여러 번의 요청(Request)이 오가는 동안 사용자의 인증 상태를 추적하고 유지하는 과정입니다.
  3. 인가/권한 부여 (Authorization): 사용자가 어떤 라우트(경로)나 데이터에 접근할 수 있는지 결정하는 과정입니다.

🧑‍🏫 강사님의 부연 설명 & 팁:
기술 면접에서 "인증(Authentication)과 인가(Authorization)의 차이점이 무엇인가요?"라는 질문은 정말 단골로 나옵니다.
쉽게 말해서 인증은 "너 누구야? (신원 확인)"이고, 인가는 "너 이거 볼 권한 있어? (접근 권한 확인)"입니다. 예를 들어, 포트폴리오로 벨로그(Velog) 뷰어나 AI 문서 번역기 같은 프로젝트를 만드실 때, 사용자가 이메일과 비밀번호로 로그인하는 건 '인증'이고, 내가 번역한 비공개 문서를 나만 볼 수 있게 막아두는 건 '인가'에 해당합니다. 이 두 가지를 명확히 구분해서 설명하실 수 있어야 합니다!

아래 다이어그램은 React와 Next.js 기능들을 사용했을 때의 인증 흐름을 보여줍니다:

React와 Next.js 기능을 활용한 인증 흐름 다이어그램

이 페이지에 있는 예제들은 교육 목적으로 작성된 기본적인 아이디/비밀번호 기반의 인증 방식을 다룹니다. 물론 처음부터 끝까지 직접 커스텀 인증 솔루션을 구현하실 수도 있지만, 보안을 강화하고 개발의 단순화를 위해 가급적 검증된 인증 라이브러리를 사용하는 것을 강력히 추천해 드립니다. 이런 라이브러리들은 인증, 세션 관리, 인가를 위한 내장 솔루션뿐만 아니라 소셜 로그인, 다중 인증(MFA), 역할 기반 접근 제어(RBAC) 같은 부가 기능까지 제공하거든요. 사용할 만한 라이브러리 목록은 인증 라이브러리 (Auth Libraries) 섹션에서 확인하실 수 있습니다.

인증 (Authentication)

회원가입 및 로그인 기능

React의 <form> 요소와 React의 서버 액션 (Server Actions), 그리고 useActionState 훅을 사용하면 사용자의 자격 증명(아이디/비밀번호 등)을 입력받고, 폼 필드를 검증하며, 인증 제공자(Authentication Provider)의 API나 데이터베이스를 호출할 수 있습니다.

서버 액션은 항상 서버 환경에서만 실행되기 때문에, 민감한 인증 로직을 처리하기에 아주 안전한 환경을 제공해 줍니다.

🧑‍🏫 강사님의 팁:
Next.js의 App Router로 넘어오면서 '서버 액션'이 도입된 것은 프론트엔드 개발자에게 엄청난 무기가 되었습니다. 예전에는 폼 데이터를 처리하기 위해 별도의 API 엔드포인트를 파고, fetch나 axios로 통신해야 했지만, 이제는 폼의 action 속성에 서버 함수를 직접 연결할 수 있습니다. 코드가 훨씬 간결해지죠!

회원가입/로그인 기능을 구현하는 단계는 다음과 같습니다:

1. 사용자 자격 증명 입력받기

사용자의 정보를 입력받기 위해, 폼 제출 시 서버 액션을 호출하는 폼을 만듭니다. 예를 들어, 사용자의 이름, 이메일, 비밀번호를 입력받는 회원가입 폼은 이렇게 만들 수 있습니다:

// filename="app/ui/signup-form.tsx" switcher
import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}
// filename="app/ui/signup-form.js" switcher
import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}
// filename="app/actions/auth.ts" switcher
export async function signup(formData: FormData) {}
// filename="app/actions/auth.js" switcher
export async function signup(formData) {}

2. 서버에서 폼 필드 검증하기

서버 액션을 활용해서 서버 측에서 폼 필드의 유효성을 검증하세요. 만약 사용 중인 인증 제공자가 폼 검증 기능을 제공하지 않는다면, ZodYup 같은 스키마 검증 라이브러리를 사용하는 것이 좋습니다.

Zod를 예비로 들어, 적절한 에러 메시지와 함께 폼 스키마를 정의하는 방법은 다음과 같습니다:

🧑‍🏫 강사님의 팁:
요즘 프론트엔드 생태계, 특히 TypeScript 환경에서는 Zod가 거의 표준처럼 쓰이고 있습니다. 타입 추론이 아주 강력하기 때문이죠! 포트폴리오 프로젝트를 만드실 때 TypeScript와 Zod를 결합해서 데이터 유효성 검사를 구현해 두시면 코드의 안정성도 높아지고 면접관들에게 좋은 인상을 줄 수 있습니다.

// filename="app/lib/definitions.ts" switcher
import * as z from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { error: 'Name must be at least 2 characters long.' })
    .trim(),
  email: z.email({ error: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { error: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { error: 'Contain at least one letter.' })
    .regex(/[0-9]/, { error: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      error: 'Contain at least one special character.',
    })
    .trim(),
})

export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined
// filename="app/lib/definitions.js" switcher
import * as z from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { error: 'Name must be at least 2 characters long.' })
    .trim(),
  email: z.email({ error: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { error: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { error: 'Contain at least one letter.' })
    .regex(/[0-9]/, { error: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      error: 'Contain at least one special character.',
    })
    .trim(),
})

인증 제공자의 API나 데이터베이스를 불필요하게 호출하는 것을 막기 위해, 서버 액션 안에서 폼 필드가 정의된 스키마와 일치하지 않을 경우 즉시 return 처리하여 로직을 일찍 종료할 수 있습니다.

// filename="app/actions/auth.ts" switcher
import { SignupFormSchema, FormState } from '@/app/lib/definitions'

export async function signup(state: FormState, formData: FormData) {
  // Validate form fields
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to create a user...
}
// filename="app/actions/auth.js" switcher
import { SignupFormSchema } from '@/app/lib/definitions'

export async function signup(state, formData) {
  // Validate form fields
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to create a user...
}

다시 <SignupForm /> 컴포넌트로 돌아와서, React의 useActionState 훅을 사용하면 폼이 제출되는 동안 검증 에러를 화면에 띄워줄 수 있습니다:

// filename="app/ui/signup-form.tsx" switcher highlight={7,15,21,27-36}
'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  )
}
// filename="app/ui/signup-form.js" switcher highlight={7,15,21,27-36}
'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  )
}

알아두면 좋은 점:

  • React 19에서는 useFormStatus가 반환하는 객체에 data, method, action과 같은 추가적인 키들이 포함됩니다. 만약 React 19 버전을 사용하지 않는다면 pending 키만 사용할 수 있습니다.
  • 데이터를 수정(mutate)하기 전에는 항상 사용자가 해당 액션을 수행할 권한이 있는지도 같이 확인해야 합니다. 인가/권한 부여 (Authorization) 파트를 참고해주세요.

3. 사용자 생성 또는 자격 증명 확인하기

폼 필드 검증을 무사히 마쳤다면, 이제 인증 제공자의 API나 데이터베이스를 호출해서 새로운 사용자 계정을 생성하거나 기존 사용자가 맞는지 확인할 수 있습니다.

이전 예제에 이어서 작성해 볼게요:

🧑‍🏫 강사님의 팁:
아래 코드에서 bcrypt.hash를 사용해 비밀번호를 암호화(Hash)하는 부분이 나오죠? 면접에서 "비밀번호를 DB에 저장할 때 어떻게 저장하나요?" 라는 질문이 나오면 "절대로 평문(Plain text)으로 저장하면 안 되고, bcrypt 같은 알고리즘을 사용해 단방향 해시(Hash) 처리를 한 후 저장해야 합니다. 추가로 솔트(Salt)를 가미해서 레인보우 테이블 공격을 방어합니다" 라고 답변하시면 아주 훌륭합니다!

// filename="app/actions/auth.tsx" switcher
export async function signup(state: FormState, formData: FormData) {
  // 1. Validate form fields
  // ...

  // 2. Prepare data for insertion into database
  const { name, email, password } = validatedFields.data
  // e.g. Hash the user's password before storing it
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Insert the user into the database or call an Auth Library's API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: 'An error occurred while creating your account.',
    }
  }

  // TODO:
  // 4. Create user session
  // 5. Redirect user
}
// filename="app/actions/auth.js" switcher
export async function signup(state, formData) {
  // 1. Validate form fields
  // ...

  // 2. Prepare data for insertion into database
  const { name, email, password } = validatedFields.data
  // e.g. Hash the user's password before storing it
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Insert the user into the database or call an Library API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: 'An error occurred while creating your account.',
    }
  }

  // TODO:
  // 4. Create user session
  // 5. Redirect user
}

사용자 계정을 성공적으로 생성했거나 유효성을 검증했다면, 이제 사용자의 인증 상태를 관리하기 위해 '세션(Session)'을 만들 차례입니다. 사용하시는 세션 관리 전략에 따라 쿠키에 저장할 수도 있고, 데이터베이스에 저장할 수도 있으며, 둘 다 사용할 수도 있습니다. 더 자세한 내용은 세션 관리 (Session Management) 섹션에서 이어집니다.

팁:

  • 위 예제 코드는 교육 목적으로 인증 단계를 하나하나 쪼개서 설명하다 보니 다소 길고 복잡해 보입니다. 이는 역설적으로 직접 안전한 인증 시스템을 구축하는 것이 꽤나 복잡해질 수 있다는 것을 뜻합니다. 과정 자체를 단순화하고 싶다면 인증 라이브러리 사용을 고려해 보세요.
  • 사용자 경험(UX)을 끌어올리려면 회원가입 과정에서 중복된 이메일이나 아이디가 있는지 좀 더 일찍 체크해 주는 것이 좋습니다. 예를 들어 사용자가 아이디를 입력하는 중이거나, 입력 창에서 포커스가 벗어났을 때 바로 알려주는 식이죠. 이렇게 하면 불필요한 폼 제출을 막고 즉각적인 피드백을 줄 수 있습니다. 서버 요청 빈도를 조절하려면 use-debounce 같은 라이브러리를 활용해 요청을 디바운스(debounce) 처리해 보세요.

세션 관리 (Session Management)

세션 관리는 사용자가 로그인한 상태가 여러 번의 요청에 걸쳐 계속 유지되도록 보장하는 역할입니다. 세션이나 토큰을 생성하고, 저장하고, 갱신(refresh)하고, 삭제하는 모든 과정을 포함합니다.

세션에는 크게 두 가지 종류가 있습니다:

  1. 상태 비저장 (Stateless): 세션 데이터(또는 토큰)를 브라우저의 쿠키에 통째로 저장합니다. 이 쿠키는 매 요청마다 서버로 전송되어 서버에서 세션을 검증할 수 있게 해줍니다. 구현 방식은 비교적 단순하지만, 올바르게 구현하지 않으면 보안에 취약해질 수 있습니다. (JWT가 대표적입니다)
  2. 데이터베이스 (Database): 세션 데이터는 서버 측 데이터베이스에 저장하고, 사용자의 브라우저는 암호화된 '세션 ID'만 쿠키 형태로 전달받습니다. 이 방식이 보안상으로는 더 안전하지만, 서버 자원을 더 많이 사용하고 구현이 복잡할 수 있습니다.

알아두면 좋은 점: 두 가지 방법 중 하나를 선택하거나 혼합해서 사용할 수 있지만, 직접 밑바닥부터 짜기보다는 iron-session이나 Jose 같은 세션 관리 전문 라이브러리를 사용하는 것을 권장합니다.

🧑‍🏫 강사님의 팁:
면접에서 "세션 기반 인증과 토큰 기반 인증(JWT)의 차이가 뭔가요?"도 1순위 질문입니다.
문서에서 말하는 'Stateless'가 바로 JWT 같은 토큰 기반입니다. 서버는 토큰이 유효한지만 검사하면 되므로 스케일 아웃(서버 확장)에 유리하지만, 한 번 발급된 토큰은 중간에 강제로 만료시키기 어렵다는 단점이 있습니다. 반면 'Database' 방식(전통적 세션)은 사용자가 브라우저를 닫거나 기기를 분실했을 때 서버에서 강제로 로그아웃 처리를 할 수 있죠. AI 서비스처럼 요금제나 할당량이 엮여서 세밀한 제어가 필요한 경우엔 DB 세션이 훨씬 안전합니다!

상태 비저장 (Stateless) 세션

상태 비저장 세션을 만들고 관리하려면 다음 단계들을 거쳐야 합니다:

  1. 세션에 서명할 때 사용할 '비밀 키(Secret Key)'를 생성하고 이를 환경 변수 (Environment Variable)로 저장합니다.
  2. 세션 관리 라이브러리를 사용해서 세션 데이터를 암호화/복호화하는 로직을 작성합니다.
  3. Next.js의 cookies API를 사용해서 쿠키를 관리합니다.

이 외에도 사용자가 애플리케이션에 다시 방문했을 때 세션을 업데이트(또는 갱신)하거나, 로그아웃 시 세션을 삭제하는 기능도 추가해야 합니다.

알아두면 좋은 점: 여러분이 선택한 인증 라이브러리에서 세션 관리 기능을 이미 지원하고 있는지 한 번 확인해 보세요.

1. 비밀 키 생성하기

세션에 서명하기 위한 비밀 키를 생성하는 방법은 여러 가지가 있습니다. 터미널에서 openssl 명령어를 사용하는 것이 대표적인 예시입니다:

openssl rand -base64 32

이 명령어를 치면 32글자의 무작위 문자열이 생성되는데, 이걸 복사해서 비밀 키로 사용하시고 환경 변수 파일에 저장하시면 됩니다:

SESSION_SECRET=your_secret_key

그러면 세션 관리 로직 코드 안에서 이 키를 불러와 사용할 수 있습니다:

const secretKey = process.env.SESSION_SECRET

2. 세션 암호화 및 복호화하기

그다음, 선호하는 세션 관리 라이브러리를 써서 세션을 암호화하고 복호화할 수 있습니다. 위 예시에 이어, 이번에는 Next.js의 Edge Runtime과 호환되는 Jose 라이브러리를 사용해 보겠습니다. 또한 이 세션 관리 로직이 오직 서버에서만 실행되도록 확실히 보장하기 위해 React의 server-only 패키지도 함께 사용합니다.

// filename="app/lib/session.ts" switcher
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Failed to verify session')
  }
}
// filename="app/lib/session.js" switcher
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session) {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Failed to verify session')
  }
}

:

  • Payload(데이터 덩어리) 안에는 이후의 요청에서 사용할 사용자의 고유 데이터(예: 사용자 ID, 역할(Role) 등)를 최소한으로만 담아야 합니다. 전화번호, 이메일 주소, 신용카드 정보 같은 개인을 식별할 수 있는 정보(PII)나 비밀번호처럼 민감한 데이터는 절대로 담으시면 안 됩니다.

3. 쿠키 설정하기 (권장 옵션)

생성된 세션을 쿠키에 저장하려면 Next.js의 cookies API를 사용하세요. 쿠키는 반드시 서버 측에서 세팅되어야 하며, 보안을 위해 아래의 옵션들을 포함하는 것을 강력히 권장합니다:

  • HttpOnly: 브라우저의 클라이언트 측 JavaScript 코드가 쿠키에 접근하지 못하게 막아줍니다. (XSS 공격 방어에 필수적이죠!)
  • Secure: 오직 HTTPS 연결을 통해서만 쿠키를 전송하게 만듭니다.
  • SameSite: 크로스 사이트 요청(Cross-site requests) 시 쿠키 전송 여부를 제어합니다. (CSRF 방어에 도움을 줍니다.)
  • Max-Age 또는 Expires: 일정 기간이 지나면 쿠키가 자동으로 삭제되도록 설정합니다.
  • Path: 쿠키가 전송될 URL 경로를 지정합니다.

각 옵션에 대한 보다 상세한 내용은 MDN 문서를 참고해주세요.

// filename="app/lib/session.ts" switcher
import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
// filename="app/lib/session.js" switcher
import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

다시 서버 액션 코드로 돌아가 보겠습니다. 이제 createSession() 함수를 호출하고, redirect() API를 써서 인증된 사용자를 적절한 페이지로 이동시켜 주면 됩니다:

// filename="app/actions/auth.ts" switcher
import { createSession } from '@/app/lib/session'

export async function signup(state: FormState, formData: FormData) {
  // Previous steps:
  // 1. Validate form fields
  // 2. Prepare data for insertion into database
  // 3. Insert the user into the database or call an Library API

  // Current steps:
  // 4. Create user session
  await createSession(user.id)
  // 5. Redirect user
  redirect('/profile')
}
// filename="app/actions/auth.js" switcher
import { createSession } from '@/app/lib/session'

export async function signup(state, formData) {
  // Previous steps:
  // 1. Validate form fields
  // 2. Prepare data for insertion into database
  // 3. Insert the user into the database or call an Library API

  // Current steps:
  // 4. Create user session
  await createSession(user.id)
  // 5. Redirect user
  redirect('/profile')
}

:

  • 클라이언트 측에서 멋대로 데이터를 조작하는 것을 막기 위해 쿠키는 무조건 서버 측에서 설정해야 합니다.
  • 🎥 영상 자료: Next.js에서의 상태 비저장 세션과 인증에 대해 더 배우고 싶다면 이 영상을 확인해보세요 → YouTube (11분 분량).

세션 업데이트 (또는 갱신) 하기

세션의 만료 시간을 연장하는 기능도 만들 수 있습니다. 사용자가 애플리케이션에 다시 접속했을 때 로그인 상태를 유지시켜 주는 데 아주 유용하죠. 예제 코드는 다음과 같습니다:

// filename="app/lib/session.ts" switcher
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}
// filename="app/lib/session.js" switcher
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}

팁: 도입하시려는 인증 라이브러리가 Refresh Token(리프레시 토큰) 기능을 지원하는지 확인해 보세요. 사용자 세션을 안전하게 연장하는 데 유용하게 쓰입니다.

세션 삭제하기

로그아웃할 때처럼 세션을 삭제해야 한다면 단순히 쿠키를 지워버리면 됩니다:

//filename="app/lib/session.ts" switcher
import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
//filename="app/lib/session.js" switcher
import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

이후에 로그아웃 처리를 하는 코드 등에서 만들어둔 deleteSession() 함수를 재사용하시면 됩니다:

//filename="app/actions/auth.ts" switcher
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}

데이터베이스 (Database) 세션

데이터베이스 세션을 생성하고 관리하려면 다음 절차를 따라야 합니다:

  1. 세션과 관련 데이터를 저장할 테이블을 데이터베이스에 만듭니다 (사용하시는 Auth 라이브러리에서 알아서 처리해 주는지 먼저 확인해 보세요).
  2. 세션 데이터를 삽입(Insert), 수정(Update), 삭제(Delete)하는 기능들을 구현합니다.
  3. 세션 ID를 사용자의 브라우저에 저장하기 전에 암호화하고, 데이터베이스에 저장된 세션 상태와 브라우저 쿠키의 상태가 항상 동기화되도록 유지합니다. (이 부분은 필수는 아니지만, Proxy (미들웨어)에서 낙관적(Optimistic) 인증 체크를 할 때 권장되는 방식입니다.)

구현 예제입니다:

//filename="app/lib/session.ts" switcher
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. Create a session in the database
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Return the session ID
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. Encrypt the session ID
  const session = await encrypt({ sessionId, expiresAt })

  // 3. Store the session in cookies for optimistic auth checks
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
//filename="app/lib/session.js" switcher
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. Create a session in the database
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Return the session ID
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. Encrypt the session ID
  const session = await encrypt({ sessionId, expiresAt })

  // 3. Store the session in cookies for optimistic auth checks
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

:

  • 속도 개선을 위해 세션이 유지되는 동안 서버 측에서 캐싱을 적용해 볼 수 있습니다. 또한 메인 데이터베이스에 세션 데이터를 보관하면서 관련 데이터 요청 쿼리들을 묶어서 날리면(DB Query 횟수 감소), 성능을 훨씬 높일 수 있습니다.
  • "사용자가 마지막으로 로그인한 시간", "현재 활성화된 기기의 개수", 혹은 "모든 기기에서 로그아웃" 같은 고급 기능이 필요한 서비스라면 상태 비저장 세션보다 데이터베이스 세션을 사용하시는 게 훨씬 유리합니다.

세션 관리 기능을 다 구현하셨다면, 이제 남은 건 사용자가 우리 앱에서 어떤 것들을 볼 수 있고 할 수 있는지 제어하는 인가(Authorization) 로직을 추가하는 것입니다. 인가/권한 부여 (Authorization) 섹션으로 넘어가 볼까요?

인가/권한 부여 (Authorization)

사용자가 인증을 마치고 세션까지 만들어졌다면, 이제 사용자의 권한을 제어하는 인가(Authorization)를 구현할 차례입니다.

인가 체크에는 크게 두 가지 방식이 있습니다:

  1. 낙관적 체크 (Optimistic Checks): 쿠키에 저장된 세션 데이터만 가지고 해당 경로 접근이나 액션 수행 권한이 있는지 빠르게 검사합니다. 권한이나 역할(Role)에 따라 특정 UI 요소를 보여주거나 숨길 때, 혹은 다른 페이지로 가볍게 리다이렉트 시킬 때 유용합니다.
  2. 보안 체계가 강화된 체크 (Secure Checks): 데이터베이스에 실제로 저장되어 있는 세션 데이터와 직접 대조하여 권한이 있는지 검사합니다. 민감한 데이터나 중요한 작업에 접근할 때 반드시 써야 하는 훨씬 안전한 방법입니다.

두 경우 모두 다음 사항들을 지켜주시는 게 좋습니다:

Proxy를 활용한 낙관적 체크 (선택 사항)

권한에 따라 사용자를 우회(Redirect)시키기 위해 Proxy (미들웨어)를 활용해야 하는 상황들이 있습니다:

  • 빠른 낙관적 체크를 수행하고 싶을 때. 미들웨어 성격을 띠는 Proxy는 모든 라우트 요청마다 실행되기 때문에, 리다이렉트 로직을 중앙에서 관리하고 권한 없는 사용자를 미리 컷아웃(Pre-filter)하는 데 제격입니다.
  • 여러 사용자가 공유하는 정적(Static) 라우트를 보호하고 싶을 때 (예: 유료 결제 회원만 볼 수 있는 콘텐츠).

하지만 명심하셔야 할 게 있습니다! Proxy는 사전 로딩(Prefetching)되는 라우트를 포함해서 무조건 모든 라우트에서 실행됩니다. 성능이 저하되는 것을 막으려면 Proxy 안에서는 무조건 쿠키에서 세션만 읽는 낙관적 체크만 수행하시고, 데이터베이스를 조회하는 무거운 로직은 절대 피하셔야 합니다.

구현 예제는 다음과 같습니다:

//filename="proxy.ts" switcher
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. Specify protected and public routes
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function proxy(req: NextRequest) {
  // 2. Check if the current route is protected or public
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. Decrypt the session from the cookie
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 4. Redirect to /login if the user is not authenticated
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 5. Redirect to /dashboard if the user is authenticated
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// Routes Proxy should not run on
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. Specify protected and public routes
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function proxy(req) {
  // 2. Check if the current route is protected or public
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. Decrypt the session from the cookie
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 5. Redirect to /login if the user is not authenticated
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 6. Redirect to /dashboard if the user is authenticated
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// Routes Proxy should not run on
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

이처럼 Proxy는 초기 체크용으로 쓰기엔 아주 유용하지만, 여러분의 소중한 데이터를 보호하기 위한 유일한 방어막으로 쓰시면 안 됩니다. 대부분의 강력한 보안 검사는 데이터 소스와 최대한 가까운 곳에서 이뤄져야 합니다. 이 부분은 Data Access Layer (DAL) 섹션을 확인해 주세요.

:

  • Proxy 내부에서는 req.cookies.get('session').value 처럼 쿠키를 바로 읽어올 수도 있습니다.
  • Proxy는 Node.js 런타임을 사용합니다. 사용하시는 인증 라이브러리와 세션 관리 라이브러리가 호환되는지 체크해 보세요. 만약 인증 라이브러리가 오직 Edge Runtime만 지원한다면 Middleware (미들웨어)를 사용하셔야 할 수도 있습니다.
  • Proxy의 matcher 속성을 이용하면 어느 라우트에서만 Proxy가 동작할지 지정할 수 있습니다. 하지만 인증(Auth)과 관련된 처리를 할 때는 가급적 모든 라우트에서 작동하게 열어두는 것을 권장합니다.

Data Access Layer (DAL) 계층 만들기

저희는 데이터 요청 로직과 권한 검사(인가) 로직을 중앙에서 관리하기 위해 DAL을 만드는 것을 적극 권장합니다.

🧑‍🏫 강사님의 팁:
이 DAL(데이터 접근 계층)과 아래 나오는 DTO 개념은 개발 아키텍처에서 정말 중요한 부분입니다! 특히 이직하시려는 회사에서 임원진 면접이나 시니어 기술 면접을 보실 때, "보안 및 구조 관점에서 프로젝트 코드를 어떻게 작성했나요?"라고 물어볼 때가 있습니다. 이때 "UI 코드와 DB 코드를 분리하기 위해 Data Access Layer를 별도로 구축해서 인가 로직을 한 곳에서 중앙 관리했습니다."라고 답변하시면 정말 좋은 인상을 남길 수 있습니다.

DAL 계층 안에는 사용자가 앱과 상호작용할 때 세션을 검증해 주는 함수가 포함되어 있어야 합니다. 최소한 이 함수는 세션이 유효한지 1차로 확인하고, 권한이 없다면 리다이렉트 시키며, 권한이 있다면 이후 요청에 필요한 유저 정보를 뱉어내도록 만들어야 합니다.

예를 들어 DAL 역할을 할 독립된 파일을 하나 만들고 그 안에 verifySession() 함수를 작성합니다. 그리고 React의 cache API를 감싸주면 React 렌더링 패스 동안 이 함수가 불필요하게 여러 번 호출되는 것을 막고 결괏값을 메모이제이션(기억)할 수 있습니다:

//filename="app/lib/dal.ts" switcher
import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})
//filename="app/lib/dal.js" switcher
import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})

이렇게 만들어둔 verifySession() 함수는 여러분의 데이터 Fetch 함수, 서버 액션, 그리고 Route Handlers 어디에서든 불러서 재사용할 수 있습니다:

//filename="app/lib/dal.ts" switcher
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Explicitly return the columns you need rather than the whole user object
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('Failed to fetch user')
    return null
  }
})
//filename="app/lib/dal.js" switcher
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Explicitly return the columns you need rather than the whole user object
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('Failed to fetch user')
    return null
  }
})

:

  • 동적인 요청(Request time) 시에 불러오는 데이터를 보호할 때 이 DAL 방식을 쓰면 좋습니다. 반면에 다른 사용자들과 데이터를 공유하는 정적(Static) 라우트의 경우, 데이터가 요청 시점이 아니라 빌드 타임에 미리 패칭됩니다. 따라서 정적 라우트를 보호할 때는 앞서 설명드린 Proxy를 사용하셔야 합니다.
  • 더 강력한 보안 체크를 위해서는 세션 ID를 여러분의 데이터베이스와 비교 대조해서 유효성을 검사할 수 있습니다. 렌더링이 일어날 때마다 데이터베이스에 중복 쿼리가 날아가는 것을 방지하기 위해 꼭 React의 cache 함수를 적용해 주세요.
  • JavaScript의 Class 문법을 이용해 관련 데이터 요청 로직들을 모아두고, 어떤 메소드든지 실행되기 전에 클래스 내부에서 verifySession()이 먼저 돌게끔 구조를 짤 수도 있습니다.

Data Transfer Objects (DTO) 활용하기

데이터베이스에서 정보를 가져올 때는, 사용자 객체 전체를 통째로 가져오는 대신 딱 애플리케이션에서 보여줄 데이터만 추려내서 반환하는 것을 권장합니다. 예를 들어 유저 정보를 보여줘야 한다면 유저의 ID와 이름 정도만 넘겨주어야지, 굳이 비밀번호나 전화번호까지 들어있는 유저 객체 통째를 넘기면 큰일 나겠죠.

만약 반환되는 데이터의 구조를 내가 마음대로 제어할 수 없는 상황이거나, 팀원들과 일하면서 클라이언트로 객체 전체가 넘어가는 것을 원천 차단하고 싶다면, '클라이언트에게 노출해도 안전한 필드가 무엇인지 명시적으로 지정하는 패턴(DTO)'을 사용할 수 있습니다.

//filename="app/lib/dto.ts" switcher
import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer: User) {
  return true
}

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Return specific columns here
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // Or return only what's specific to the query here
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}
//filename="app/lib/dto.js" switcher
import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer) {
  return true
}

function canSeePhoneNumber(viewer, team) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Return specific columns here
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // Or return only what's specific to the query here
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}

이렇게 데이터 접근과 권한 검사는 DAL에 모아두고 반환 데이터는 DTO로 통제하면, 앱 내의 모든 데이터 요청이 일관성 있고 안전해집니다. 규모가 커져서 스케일업을 할 때도 유지보수나 디버깅하기가 한결 수월해질 거예요.

알아두면 좋은 점:

  • DTO를 정의하는 방식은 toJSON() 메소드를 쓰거나, 위 예제처럼 개별 함수를 만들거나, 아니면 JavaScript Class로 빼는 등 여러 패턴이 있습니다. 이건 React나 Next.js의 고유 기능이 아니라 순수 자바스크립트 아키텍처 패턴이므로, 본인 프로젝트에 제일 잘 맞는 패턴이 뭔지 조금 더 리서치해 보시는 걸 추천합니다.
  • 저희가 쓴 보안 관련 아티클인 Security in Next.js article에서 더 깊은 모범 사례를 배워보세요.

서버 컴포넌트 (Server Components)에서의 권한 체크

서버 컴포넌트에서의 인가 체크는 사용자의 권한(Role)에 기반한 접근 제어를 할 때 안성맞춤입니다. 예를 들어, 관리자인지 일반 유저인지에 따라 보여주는 컴포넌트를 아예 다르게 렌더링 할 수 있죠:

//filename="app/dashboard/page.tsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // Assuming 'role' is part of the session object

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}
//filename="app/dashboard/page.jsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // Assuming 'role' is part of the session object

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

위 예제를 보시면, 아까 DAL에서 만든 verifySession() 함수를 불러와서 이 사람이 관리자인지, 일반 사용자인지, 아니면 권한 없는 미인증 사용자인지 체크하고 있습니다. 이 패턴을 쓰면 각 유저가 자신에게 맞는 인터페이스하고만 깔끔하게 상호작용하도록 보장할 수 있습니다.

레이아웃 (Layouts)에서의 권한 체크 주의사항

Next.js의 클라이언트 사이드 전환 (Client-side transitions)에 따른 '부분 렌더링(Partial Rendering)' 기능 때문에 레이아웃 (Layouts)에서 인가 체크를 할 때는 주의하셔야 합니다! 왜냐하면 페이지를 넘어갈 때마다 레이아웃 전체가 매번 재렌더링 되는 게 아니기 때문에, 페이지가 바뀌어도 권한 체크 로직이 다시 돌지 않을 수도 있거든요.

그래서 권한 검사는 레이아웃 껍데기보다는 실제 데이터 소스와 가까운 곳, 혹은 조건부로 렌더링 되는 컴포넌트 내부에서 수행하시는 게 정답입니다.

예를 들어볼게요. 모든 페이지가 공유하는 상단 내비게이션 바(Layout)에서 사용자 프로필 사진을 띄워야 한다고 해보겠습니다. 이럴 때는 레이아웃 안에서 직접 권한 체크를 하려고 하지 마시고, 레이아웃에서는 단순히 유저 데이터를 Fetch(getUser())만 하게 두세요. 그리고 인증 검사는 아까 만들어둔 DAL 계층 안에서 무조건 이뤄지도록 강제하는 겁니다.

이렇게 구조를 짜면, 애플리케이션 어디에서 getUser()를 호출하든 무조건 권한 체크가 선행되므로 개발자가 실수로 인가 체크를 빼먹어버리는 대참사를 막을 수 있습니다.

페이지 컴포넌트에서의 권한 체크

예를 들어, 대시보드 페이지 안에 직접 들어가서 세션을 검증하고 해당 유저에게 맞는 데이터를 불러옵니다:

//filename="app/dashboard/page.tsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function DashboardPage() {
  const session = await verifySession()

  // Fetch user-specific data from your database or data source
  const user = await getUserData(session.userId)

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      {/* Dashboard content */}
    </div>
  )
}
//filename="app/dashboard/page.jsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function DashboardPage() {
  const session = await verifySession()

  // Fetch user-specific data from your database or data source
  const user = await getUserData(session.userId)

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      {/* Dashboard content */}
    </div>
  )
}

자식 컴포넌트(Leaf components)에서의 권한 체크

권한에 따라 UI 엘리먼트들을 껐다 켰다 해야 하는 자식 컴포넌트(트리의 끝단에 있는 컴포넌트)에서도 인증 체크 로직을 수행할 수 있습니다. 오직 관리자에게만 보이는 설정 버튼을 예로 들어볼게요:

//filename="app/ui/admin-actions.tsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function AdminActions() {
  const session = await verifySession()
  const userRole = session?.user?.role

  if (userRole !== 'admin') {
    return null
  }

  return (
    <div>
      <button>Delete User</button>
      <button>Edit Settings</button>
    </div>
  )
}
//filename="app/ui/admin-actions.jsx" switcher
import { verifySession } from '@/app/lib/dal'

export default async function AdminActions() {
  const session = await verifySession()
  const userRole = session?.user?.role

  if (userRole !== 'admin') {
    return null
  }

  return (
    <div>
      <button>Delete User</button>
      <button>Edit Settings</button>
    </div>
  )
}

이 패턴을 이용하면 사용자 권한에 따라 화면 요소를 정교하게 보여주거나 숨기면서도, 렌더링 될 때마다 컴포넌트 안에서 안전하게 권한 체크 로직이 동작하도록 보장할 수 있습니다.

알아두면 좋은 점:

  • 기존 SPA(Single Page App) 방식에서는 레이아웃이나 최상단 루트 컴포넌트에서 권한이 없으면 그냥 return null을 뱉어서 전체 렌더링을 막아버리는 패턴을 자주 썼었죠. 하지만 Next.js 생태계에서는 이 방법은 절대 권장하지 않습니다! Next.js는 앱에 진입할 수 있는 경로(엔트리 포인트)가 여러 개이기 때문에 껍데기만 막는다고 해서 중첩된 하위 라우트나 서버 액션으로 직접 들어오는 요청까지 다 막을 수는 없거든요.
  • 프론트엔드 UI만 가려놓았다고 안심하시면 안 됩니다! 클라이언트 UI만 제한하는 건 보안상 안전하지 않으니, 화면에서 호출하는 '서버 액션(Server Actions)' 내부에서도 반드시 자체적인 인가 검사를 수행하도록 코드를 짜셔야 합니다.

서버 액션 (Server Actions)에서의 권한 체크

서버 액션은 외부로 노출된(Public-facing) API 엔트포인트와 똑같이 생각하고 보안에 각별히 신경 써야 합니다. 누군가 어떤 데이터를 수정(Mutation)하려고 액션을 호출했을 때, 이 유저가 진짜 그럴 권한이 있는지 반드시 체크해 주세요.

아래 코드에서는, 서버 액션이 실행되기 전에 먼저 사용자가 '관리자(admin)' 권한을 가지고 있는지 검사합니다:

//filename="app/lib/actions.ts" switcher
'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role

  // Return early if user is not authorized to perform the action
  if (userRole !== 'admin') {
    return null
  }

  // Proceed with the action for authorized users
}
//filename="app/lib/actions.js" switcher
'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction() {
  const session = await verifySession()
  const userRole = session.user.role

  // Return early if user is not authorized to perform the action
  if (userRole !== 'admin') {
    return null
  }

  // Proceed with the action for authorized users
}

Route Handlers (라우트 핸들러)에서의 권한 체크

Route Handlers 또한 일반 API 엔드포인트와 동일한 수준의 보안 기준을 적용해야 합니다. 사용자가 해당 API를 호출할 권한이 있는지 철저히 확인하세요.

예제입니다:

//filename="app/api/route.ts" switcher
import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // User authentication and role verification
  const session = await verifySession()

  // Check if the user is authenticated
  if (!session) {
    // User is not authenticated
    return new Response(null, { status: 401 })
  }

  // Check if the user has the 'admin' role
  if (session.user.role !== 'admin') {
    // User is authenticated but does not have the right permissions
    return new Response(null, { status: 403 })
  }

  // Continue for authorized users
}
//filename="app/api/route.js" switcher
import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // User authentication and role verification
  const session = await verifySession()

  // Check if the user is authenticated
  if (!session) {
    // User is not authenticated
    return new Response(null, { status: 401 })
  }

  // Check if the user has the 'admin' role
  if (session.user.role !== 'admin') {
    // User is authenticated but does not have the right permissions
    return new Response(null, { status: 403 })
  }

  // Continue for authorized users
}

위의 예시는 2단계로 촘촘하게 짜여진 보안 검증 로직을 보여줍니다. 1차로 로그인된 활성 세션이 있는지 확인하고 (401 에러 방어), 2차로 로그인된 사용자가 진짜 '관리자' 권한을 가졌는지 검증합니다 (403 에러 방어).

Context Providers (컨텍스트 프로바이더) 활용하기

React와 Next.js 컴포넌트들을 자연스럽게 혼합(interleaving)해서 쓸 수 있기 때문에, 인증 상태를 Context Provider를 사용해 관리하는 것도 가능합니다. 다만 React의 context 기능은 애초에 서버 컴포넌트에서는 지원하지 않는 스펙이므로 오직 클라이언트 컴포넌트에만 씌워서 사용하셔야 합니다.

물론 작동은 잘 됩니다만, Provider 아래에 감싸진 자식 서버 컴포넌트들은 서버 측에서 먼저 렌더링 될 때 Provider가 쥐고 있는 세션 데이터에는 접근하지 못한다는 점을 유의하셔야 합니다:

//filename="app/layout.ts" switcher
import { ContextProvider } from 'auth-lib'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  )
}
//filename="app/ui/profile.ts switcher
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}
// filename="app/ui/profile.js switcher
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}

만약 클라이언트 사이드 데이터 Fetching 같은 작업을 위해 클라이언트 컴포넌트 내에서 세션 데이터가 꼭 필요하다면, 민감한 세션 정보가 브라우저로 몽땅 노출되는 것을 막기 위해 React의 최신 실험적 기능인 taintUniqueValue API를 꼭 활용하시기 바랍니다!

리소스 (Resources)

지금까지 Next.js에서의 인증 원리에 대해 학습하셨습니다. 이제 여러분의 앱에 더 안전한 인증과 세션 관리를 적용하는 데 도움을 줄 Next.js 호환 라이브러리 및 리소스들을 소개해 드릴게요:

🧑‍🏫 강사님의 팁:
처음 포트폴리오(예: 블로그 뷰어나 AI 웹 서비스)를 만드실 때는 여기 나오는 NextAuth.js(Auth.js)Supabase 같은 라이브러리를 적극 활용해 보시길 추천합니다! 복잡한 세션 관리나 구글, 카카오 같은 소셜 로그인 연동을 믿기 힘들 정도로 쉽게 해결해 주기 때문에 남는 시간을 핵심 비즈니스 로직(AI 번역, 블로그 뷰어 기획 등)을 다듬는 데 훨씬 효율적으로 쓰실 수 있습니다.

인증 라이브러리 (Auth Libraries)

세션 관리 전용 라이브러리 (Session Management Libraries)

더 읽어보기 (Further Reading)

인증 시스템과 웹 보안에 대해 더 깊이 공부하고 싶으시다면, 아래의 훌륭한 자료들을 추천합니다:


전체 문서의 시맨틱(Semantic) 구조를 보시려면 https://nextjs.org/docs/sitemap.md를 확인해주세요.

사용 가능한 모든 문서의 전체 인덱스는 https://nextjs.org/docs/llms.txt에서 보실 수 있습니다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글