포스트

[이제와서 시작하는 Next.js 마스터하기 #9] 인증과 권한 관리 실전 가이드

[이제와서 시작하는 Next.js 마스터하기 #9] 인증과 권한 관리 실전 가이드

“로그인은 시작일 뿐입니다.” - Auth.js와 App Router에서 인증, 세션, 권한 검사를 안전하게 나눠봅니다.

🎯 이 글에서 배울 내용

  • Auth.js/NextAuth.js v5 설정
  • 이메일/비밀번호 인증
  • OAuth (Google, GitHub)
  • 역할 기반 접근 제어 (RBAC)
  • 세션 관리
  • Proxy와 데이터 접근 계층의 역할 분리

예상 소요 시간: 50분


🔐 Auth.js/NextAuth.js v5 설정

1. 설치

1
2
npm install next-auth
npm install @auth/prisma-adapter bcryptjs zod

2. 환경 변수

# .env.local
AUTH_URL=http://localhost:3000
AUTH_SECRET=your-secret-key-here-generate-with-openssl

# Google OAuth
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret

# GitHub OAuth
AUTH_GITHUB_ID=your-github-id
AUTH_GITHUB_SECRET=your-github-secret

AUTH_SECRET은 프로덕션에서 반드시 필요합니다. 로컬에서는 npx auth secret 명령으로 .env.local에 안전한 값을 만들 수 있습니다.

Auth.js 공식 설치 흐름에서 필수 환경 변수는 AUTH_SECRET입니다. OAuth provider의 ID/Secret은 선택한 provider에 따라 추가되고, AUTH_URL은 배포 환경에서 URL 추론이 맞지 않을 때 명시적으로 둡니다.

1
npx auth secret

비밀값은 저장소에 커밋하지 마세요. .env.local은 로컬 전용이고, Vercel이나 다른 배포 환경에서는 프로젝트 설정의 Environment Variables에 넣어야 합니다.

3. Auth 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { z } from 'zod';

const credentialsSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const parsed = credentialsSchema.safeParse(credentials);

        if (!parsed.success) {
          return null;
        }

        const { email, password } = parsed.data;

        const user = await prisma.user.findUnique({
          where: { email },
        });

        if (!user || !user.hashedPassword) {
          return null;
        }

        const isValid = await bcrypt.compare(
          password,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const protectedPaths = ['/dashboard', '/admin'];
      const isProtected = protectedPaths.some(path =>
        nextUrl.pathname.startsWith(path)
      );

      if (isProtected) {
        return isLoggedIn;
      }

      return true;
    },
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role;
      }
      return session;
    },
  },
  pages: {
    signIn: '/login',
    error: '/error',
  },
});

Credentials provider는 사용자가 직접 비밀번호를 다루는 방식입니다. 실제 서비스라면 rate limit, 이메일 인증, 비밀번호 재설정, MFA, 로그인 실패 로그 같은 운영 장치를 함께 설계해야 합니다. 단순 튜토리얼 코드만으로 “안전한 인증이 완성됐다”고 보면 안 됩니다.

4. API Route

1
2
3
4
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

5. Proxy로 보호 경로 연결

Next.js 16에서는 proxy.ts를 사용합니다. Auth.js의 auth 함수를 Proxy로 export하면 callbacks.authorized에서 요청 단계의 보호 경로를 확인할 수 있습니다.

1
2
3
4
5
6
// proxy.ts
export { auth as proxy } from '@/auth';

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
};

Proxy는 빠른 redirect와 optimistic check에 적합합니다. 데이터베이스 조회, 복잡한 권한 계산, 결제/관리자 작업 같은 최종 보안 판단은 페이지, Server Action, Route Handler, 데이터 접근 계층에서 다시 확인해야 합니다.

위치 역할 주의점
proxy.ts 로그인 여부 기반 빠른 redirect DB 조회처럼 느린 작업을 피한다
Server Component 페이지 진입 시 세션 확인 UI를 보여주기 전 redirect 가능
Server Action 데이터 변경 전 권한 확인 form 조작과 API 우회를 막는다
Route Handler 외부 호출/API 권한 확인 method별로 인증/인가를 반복 확인
DAL 실제 데이터 조회 직전 최종 권한 확인 민감한 필드 반환을 DTO로 제한

인증에서 흔한 실수는 Proxy만 믿는 것입니다. Proxy는 UX를 좋게 만드는 앞단 필터이고, 보안의 마지막 문은 데이터 가까이에 있어야 합니다.


🔑 로그인 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// app/login/page.js
'use client';

import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const router = useRouter();
  const [error, setError] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const result = await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirect: false,
    });

    if (result?.error) {
      setError('이메일 또는 비밀번호가 잘못되었습니다');
    } else {
      router.push('/dashboard');
      router.refresh();
    }
  }

  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">로그인</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block mb-2">이메일</label>
          <input
            name="email"
            type="email"
            required
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        <div>
          <label className="block mb-2">비밀번호</label>
          <input
            name="password"
            type="password"
            required
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        {error && (
          <p className="text-red-600">{error}</p>
        )}

        <button
          type="submit"
          className="w-full px-4 py-2 bg-blue-600 text-white rounded"
        >
          로그인
        </button>
      </form>

      <div className="mt-8 space-y-2">
        <button
          onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
          className="w-full px-4 py-2 border rounded flex items-center justify-center gap-2"
        >
          <img src="/google.svg" className="w-5 h-5" />
          Google로 로그인
        </button>

        <button
          onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
          className="w-full px-4 py-2 border rounded flex items-center justify-center gap-2"
        >
          <img src="/github.svg" className="w-5 h-5" />
          GitHub으로 로그인
        </button>
      </div>
    </div>
  );
}

👤 회원가입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// app/actions/auth.ts
'use server';

import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { z } from 'zod';

const signUpSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(1).max(50),
});

export async function signUp(formData: FormData) {
  const parsed = signUpSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
    name: formData.get('name'),
  });

  if (!parsed.success) {
    return { error: '입력값을 다시 확인해주세요' };
  }

  const { email, password, name } = parsed.data;

  // 이미 존재하는 사용자 확인
  const existing = await prisma.user.findUnique({
    where: { email },
  });

  if (existing) {
    return { error: '이미 존재하는 이메일입니다' };
  }

  // 비밀번호 해싱
  const hashedPassword = await bcrypt.hash(password, 10);

  // 사용자 생성
  await prisma.user.create({
    data: {
      email,
      name,
      hashedPassword,
      role: 'USER',
    },
  });

  return { success: true };
}

Server Action은 브라우저에서 호출되지만 서버에서 실행됩니다. 그래서 클라이언트 입력을 믿지 말고 서버에서 다시 검증해야 합니다. 가입, 결제, 권한 변경처럼 데이터가 바뀌는 작업은 항상 같은 원칙을 적용하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// app/signup/page.js
'use client';

import { signUp } from '@/app/actions/auth';
import { useRouter } from 'next/navigation';

export default function SignUpPage() {
  const router = useRouter();

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const result = await signUp(formData);

    if (result.success) {
      router.push('/login?registered=true');
    } else {
      alert(result.error);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-8 space-y-4">
      <h1 className="text-3xl font-bold mb-8">회원가입</h1>

      <input name="name" placeholder="이름" required className="w-full px-4 py-2 border rounded" />
      <input name="email" type="email" placeholder="이메일" required className="w-full px-4 py-2 border rounded" />
      <input name="password" type="password" placeholder="비밀번호" required className="w-full px-4 py-2 border rounded" />

      <button type="submit" className="w-full px-4 py-2 bg-blue-600 text-white rounded">
        가입하기
      </button>
    </form>
  );
}

🛡️ 보호된 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/dashboard/page.js
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

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

  if (!session) {
    redirect('/login');
  }

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-4">대시보드</h1>
      <p>환영합니다, {session.user.name}님!</p>
      <p>이메일: {session.user.email}</p>
      <p>역할: {session.user.role}</p>
    </div>
  );
}

페이지 단위 확인은 사용자 경험을 정리하는 데 좋습니다. 하지만 여러 페이지에서 같은 권한 규칙을 반복하면 빠르게 흩어집니다. 실제 프로젝트에서는 공통 함수를 둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// lib/authz.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export async function requireUser() {
  const session = await auth();

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

  return session.user;
}

export async function requireRole(role: 'ADMIN' | 'USER') {
  const user = await requireUser();

  if (user.role !== role) {
    redirect('/unauthorized');
  }

  return user;
}

이렇게 분리하면 페이지, Server Action, Route Handler에서 같은 기준을 재사용할 수 있습니다.


👑 역할 기반 접근 제어 (RBAC)

1
2
3
4
5
6
7
8
9
10
11
12
13
// app/admin/page.js
import { requireRole } from '@/lib/authz';

export default async function AdminPage() {
  const user = await requireRole('ADMIN');

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-4">관리자 페이지</h1>
      <p>{user.name}님은 관리자 권한으로 접근했습니다.</p>
    </div>
  );
}

권한 검사는 화면 표시용과 데이터 보호용을 나눠 생각하세요. 버튼을 숨기는 것은 UX이고, 서버에서 권한을 재검사하는 것이 보안입니다.


🧱 데이터 접근 계층에서 최종 확인하기

Next.js 공식 인증 가이드는 권한 로직을 데이터 접근 계층(DAL)에 모으는 흐름을 권장합니다. 예를 들어 게시글 조회 API가 있다면, UI와 API 양쪽에서 같은 getPostForUser 함수를 쓰게 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// lib/posts.ts
import 'server-only';
import { cache } from 'react';
import { prisma } from '@/lib/prisma';
import { requireUser } from '@/lib/authz';

export const getPostForUser = cache(async (postId: string) => {
  const user = await requireUser();

  const post = await prisma.post.findFirst({
    where: {
      id: postId,
      ownerId: user.id,
    },
    select: {
      id: true,
      title: true,
      content: true,
      updatedAt: true,
    },
  });

  return post;
});

여기서 중요한 점은 두 가지입니다.

  • ownerId: user.id처럼 데이터베이스 쿼리 자체에 권한 조건을 넣는다.
  • select로 필요한 필드만 반환해 비밀번호 해시, 내부 메모, 결제 정보 같은 민감 필드가 새어나가지 않게 한다.

Route Handler도 같은 함수를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/api/posts/[id]/route.ts
import { getPostForUser } from '@/lib/posts';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const post = await getPostForUser(id);

  if (!post) {
    return Response.json({ error: 'Not found' }, { status: 404 });
  }

  return Response.json(post);
}

이 구조를 잡아두면 Proxy를 우회하거나 클라이언트에서 URL을 직접 호출해도 데이터 계층에서 다시 막을 수 있습니다.


🔄 로그아웃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// components/LogoutButton.js
'use client';

import { signOut } from 'next-auth/react';

export default function LogoutButton() {
  return (
    <button
      onClick={() => signOut({ callbackUrl: '/' })}
      className="px-4 py-2 bg-red-600 text-white rounded"
    >
      로그아웃
    </button>
  );
}

🎯 오늘 배운 내용 정리

  1. NextAuth.js v5
    • 안정 패키지는 next-auth
    • AUTH_SECRET, AUTH_* 환경 변수 사용
    • auth.ts에서 handlers, auth, signIn, signOut export
    • Credentials provider는 validation, rate limit, MFA 등 운영 장치와 함께 설계
  2. 인증 방법
    • Credentials (이메일/비밀번호)
    • OAuth (Google, GitHub)
  3. 권한 관리
    • Proxy는 optimistic check와 redirect에 사용
    • 페이지, Server Action, Route Handler에서 재검사
    • DAL에서 최종 권한 조건과 DTO 반환을 관리

📚 시리즈 네비게이션


“안전한 인증은 필수입니다!” 🔐

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.