인증
애플리케이션의 데이터를 보호하기 위해서는 인증에 대한 이해가 중요합니다. 이 페이지에서는 인증을 구현하기 위해 어떤 React와 Next.js 기능을 사용해야 하는지 안내해 드리겠습니다.
시작하기 전에, 이 과정을 세 가지 개념으로 나누는 것이 도움이 됩니다:
- 인증: 사용자가 자신이 주장하는 사람인지 확인합니다. 사용자에게 사용자 이름과 비밀번호와 같이 가지고 있는 것으로 신원을 증명하도록 요구합니다.
- 세션 관리: 요청 간에 사용자의 인증 상태를 추적합니다.
- 권한 부여: 사용자가 접근할 수 있는 경로와 데이터를 결정합니다.
이 다이어그램은 React와 Next.js 기능을 사용한 인증 흐름을 보여줍니다:

이 페이지의 예제들은 교육 목적으로 기본적인 사용자 이름과 비밀번호 인증을 다룹니다. 직접 사용자 정의 인증 솔루션을 구현할 수 있지만, 보안 강화와 단순화를 위해 인증 라이브러리를 사용하는 것을 권장합니다. 이러한 라이브러리들은 인증, 세션 관리, 권한 부여에 대한 내장 솔루션을 제공하며, 소셜 로그인, 다중 요소 인증, 역할 기반 접근 제어와 같은 추가 기능도 제공합니다. 인증 라이브러리 섹션에서 목록을 확인할 수 있습니다.
인증
회원가입 및 로그인 기능
알아두면 좋은 점: 이 예제들은 React 19 RC에서 사용 가능한 React의
useActionState
훅을 사용합니다. 이전 버전의 React를 사용하고 있다면 대신useFormState
를 사용하세요. 자세한 정보는 React 문서를 참조하세요.
React의 서버 액션와 useActionState
와 함께 <form>
요소를 사용하여 사용자 자격 증명을 캡처하고, 폼 필드를 검증하고, 인증 제공자의 API나 데이터베이스를 호출할 수 있습니다.
서버 액션은 항상 서버에서 실행되므로 인증 로직을 처리하기 위한 안전한 환경을 제공합니다.
회원가입/로그인 기능을 구현하는 단계는 다음과 같습니다:
1. 사용자 자격 증명 수집하기
사용자 자격 증명을 수집하기 위해, 제출 시 Server Action을 호출하는 폼을 만듭니다. 예를 들어, 사용자의 이름, 이메일, 비밀번호를 받는 회원가입 폼입니다:
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>
);
}
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>
);
}
export async function signup(formData: FormData) {}
export async function signup(formData) {}
2. 서버에서 폼 필드 검증하기
Server Action을 사용하여 서버에서 폼 필드를 검증합니다. 인증 제공자가 폼 검증을 제공하지 않는 경우, Zod나 Yup과 같은 스키마(데이터 형식) 검증 라이브러리를 사용할 수 있습니다.
Zod를 예로 들어, 적절한 오류 메시지와 함께 폼 스키마를 정의할 수 있습니다:
import { z } from "zod";
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: "Name must be at least 2 characters long." })
.trim(),
email: z.string().email({ message: "Please enter a valid email." }).trim(),
password: z
.string()
.min(8, { message: "Be at least 8 characters long" })
.regex(/[a-zA-Z]/, { message: "Contain at least one letter." })
.regex(/[0-9]/, { message: "Contain at least one number." })
.regex(/[^a-zA-Z0-9]/, {
message: "Contain at least one special character.",
})
.trim(),
});
export type FormState =
| {
errors?: {
name?: string[];
email?: string[];
password?: string[];
};
message?: string;
}
| undefined;
import { z } from "zod";
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: "Name must be at least 2 characters long." })
.trim(),
email: z.string().email({ message: "Please enter a valid email." }).trim(),
password: z
.string()
.min(8, { message: "Be at least 8 characters long" })
.regex(/[a-zA-Z]/, { message: "Contain at least one letter." })
.regex(/[0-9]/, { message: "Contain at least one number." })
.regex(/[^a-zA-Z0-9]/, {
message: "Contain at least one special character.",
})
.trim(),
});
인증 제공자의 API나 데이터베이스에 불필요한 호출을 방지하기 위해, 폼 필드가 정의된 스키마와 일치하지 않을 경우 Server Action에서 일찍 return
할 수 있습니다.
import { SignupFormSchema, FormState } from "@/app/lib/definitions";
export async function signup(state: FormState, formData: FormData) {
// 폼 필드 검증
const validatedFields = SignupFormSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
// 폼 필드가 유효하지 않으면 일찍 리턴
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 사용자를 생성하기 위해 제공자나 데이터베이스 호출...
}
import { SignupFormSchema } from "@/app/lib/definitions";
export async function signup(state, formData) {
// 폼 필드 검증
const validatedFields = SignupFormSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
// 폼 필드가 유효하지 않으면 일찍 리턴
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 사용자를 생성하기 위해 제공자나 데이터베이스 호출...
}
<SignupForm />
으로 돌아가서, React의 useActionState()
훅을 사용하여 검증 오류를 표시하고 폼이 제출되는 동안 대기 상태를 표시할 수 있습니다:
"use client";
import { useActionState } from "react";
import { signup } from "@/app/actions/auth";
export 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 aria-disabled={pending} type="submit">
{pending ? "Submitting..." : "Sign up"}
</button>
</form>
);
}
"use client";
import { useActionState } from "react";
import { signup } from "@/app/actions/auth";
export 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="John Doe" />
</div>
{state.errors.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" placeholder="[email protected]" />
</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 aria-disabled={pending} type="submit">
{pending ? "Submitting..." : "Sign up"}
</button>
</form>
);
}
알아두면 좋은 점: 대안으로,
useFormStatus
훅을 사용하여 대기 상태를 표시할 수 있습니다.
3. 사용자 생성 또는 사용자 자격 증명 확인하기
폼 필드를 검증한 후, 인증 제공자의 API나 데이터베이스를 호출하여 새 사용자 계정을 생성하거나 사용자가 존재하는지 확인할 수 있습니다.
이전 예제에 이어서:
export async function signup(state: FormState, formData: FormData) {
// 1. 폼 필드 검증
// ...
// 2. 데이터베이스에 삽입할 데이터 준비
const { name, email, password } = validatedFields.data;
// 예: 저장하기 전에 사용자의 비밀번호를 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// 3. 사용자를 데이터베이스에 삽입하거나 인증 라이브러리의 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. 사용자 세션 생성
// 5. 사용자 리디렉션
}
export async function signup(state, formData) {
// 1. 폼 필드 검증
// ...
// 2. 데이터베이스에 삽입할 데이터 준비
const { name, email, password } = validatedFields.data;
// 예: 저장하기 전에 사용자의 비밀번호를 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// 3. 사용자를 데이터베이스에 삽입하거나 라이브러리 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. 사용자 세션 생성
// 5. 사용자 리디렉션
}
사용자 계정을 성공적으로 생성하거나 사용자 자격 증명을 확인한 후, 사용자의 인증 상태를 관리하기 위한 세션을 생성할 수 있습니다. 세션 관리 전략에 따라 세션을 쿠키나 데이터베이스, 또는 둘 다에 저장할 수 있습니다. 자세한 내용은 세션 관리 섹션을 계속 읽어보세요.
팁:
- 위의 예제는 교육 목적으로 인증 단계를 상세히 설명하기 위해 상당히 장황합니다. 이는 자체적으로 안전한 솔루션을 구현하는 것이 얼마나 빠르게 복잡해질 수 있는지를 강조합니다. 이 과정을 단순화하기 위해 인증 라이브러리를 사용하는 것을 고려해보세요.
- 사용자 경험을 개선하기 위해, 등록 과정 초기에 중복된 이메일이나 사용자 이름을 확인하고 싶을 수 있습니다. 예를 들어, 사용자가 사용자 이름을 입력하거나 입력 필드에서 포커스를 잃을 때 확인할 수 있습니다. 이는 불필요한 폼 제출을 방지하고 사용자에게 즉각적인 피드백을 제공하는 데 도움이 됩니다. use-debounce와 같은 라이브러리를 사용하여 이러한 확인의 빈도를 관리할 수 있습니다.
세션 관리
세션 관리는 요청 간에 사용자의 인증 상태가 유지되도록 보장합니다. 여기에는 세션이나 토큰의 생성, 저장, 새로 고침, 삭제가 포함됩니다.
두 가지 유형의 세션이 있습니다:
- 무상태: 세션 데이터(또는 토큰)가 브라우저의 쿠키에 저장됩니다. 쿠키는 각 요청과 함께 전송되어 서버에서 세션을 확인할 수 있게 합니다. 이 방법은 더 단순하지만, 올바르게 구현되지 않으면 덜 안전할 수 있습니다.
- 데이터베이스: 세션 데이터가 데이터베이스에 저장되고, 사용자의 브라우저는 암호화된 세션 ID만 받습니다. 이 방법은 더 안전하지만, 복잡하고 더 많은 서버 리소스를 사용할 수 있습니다.
알아두면 좋은 점: 두 방법 중 하나를 사용하거나 둘 다 사용할 수 있지만, iron-session이나 Jose와 같은 세션 관리 라이브러리를 사용하는 것을 권장합니다.
무상태 세션
무상태 세션을 생성하고 관리하려면 다음 단계를 따라야 합니다:
- 세션에 서명하는 데 사용될 비밀 키를 생성하고 환경 변수로 저장합니다.
- 세션 관리 라이브러리를 사용하여 세션 데이터를 암호화/복호화하는 로직을 작성합니다.
- Next.js
cookies()
API를 사용하여 쿠키를 관리합니다.
위의 내용 외에도, 사용자가 애플리케이션으로 돌아올 때 세션을 업데이트(또는 새로 고침)하고, 사용자가 로그아웃할 때 세션을 삭제하는 기능을 추가하는 것을 고려해보세요.
알아두면 좋은 점: 인증 라이브러리에 세션 관리가 포함되어 있는지 확인하세요.
1. 비밀 키 생성하기
세션에 서명할 비밀 키를 생성하는 방법은 여러 가지가 있습니다. 예를 들어, 터미널에서 openssl
명령을 사용할 수 있습니다:
openssl rand -base64 32
이 명령은 비밀 키로 사용할 수 있는 32자의 무작위 문자열을 생성하며, 이를 환경 변수 파일에 저장할 수 있습니다:
SESSION_SECRET=your_secret_key
그런 다음 세션 관리 로직에서 이 키를 참조할 수 있습니다:
const secretKey = process.env.SESSION_SECRET;
2. 세션 암호화 및 복호화하기
다음으로, 선호하는 세션 관리 라이브러리를 사용하여 세션을 암호화하고 복호화할 수 있습니다. 이전 예제에 이어서, Jose(Edge Runtime과 호환됨)와 React의 server-only
패키지를 사용하여 세션 관리 로직이 서버에서만 실행되도록 보장할 수 있습니다.
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");
}
}
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");
}
}
팁:
- 페이로드는 후속 요청에서 사용될 최소한의, 고유한 사용자 데이터(예: 사용자 ID, 역할 등)만 포함해야 합니다. 전화번호, 이메일 주소, 신용카드 정보 등 개인 식별 정보나 비밀번호와 같은 민감한 데이터를 포함해서는 안 됩니다.
3. 쿠키 설정하기 (권장 옵션)
세션을 쿠키에 저장하려면 Next.js cookies()
API를 사용하세요. 쿠키는 서버에서 설정되어야 하며, 다음과 같은 권장 옵션을 포함해야 합니다:
- HttpOnly: 클라이언트 측 JavaScript가 쿠키에 접근하는 것을 방지합니다.
- Secure: https를 사용하여 쿠키를 전송합니다.
- SameSite: 쿠키가 크로스 사이트 요청과 함께 전송될 수 있는지 지정합니다.
- Max-Age 또는 Expires: 일정 기간 후 쿠키를 삭제합니다.
- Path: 쿠키의 URL 경로를 정의합니다.
이러한 각 옵션에 대한 자세한 정보는 MDN을 참조하세요.
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 });
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
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 });
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
Server Action으로 돌아가서, createSession()
함수를 호출하고 redirect()
API를 사용하여 사용자를 적절한 페이지로 리디렉션할 수 있습니다:
import { createSession } from "@/app/lib/session";
export async function signup(state: FormState, formData: FormData) {
// 이전 단계:
// 1. 폼 필드 검증
// 2. 데이터베이스에 삽입할 데이터 준비
// 3. 사용자를 데이터베이스에 삽입하거나 라이브러리 API 호출
// 현재 단계:
// 4. 사용자 세션 생성
await createSession(user.id);
// 5. 사용자 리디렉션
redirect("/profile");
}
import { createSession } from "@/app/lib/session";
export async function signup(state, formData) {
// 이전 단계:
// 1. 폼 필드 검증
// 2. 데이터베이스에 삽입할 데이터 준비
// 3. 사용자를 데이터베이스에 삽입하거나 라이브러리 API 호출
// 현재 단계:
// 4. 사용자 세션 생성
await createSession(user.id);
// 5. 사용자 리디렉션
redirect("/profile");
}
팁:
- 쿠키는 서버에서 설정되어야 합니다 클라이언트 측 변조를 방지하기 위해서입니다.
- 🎥 시청하기: Next.js를 사용한 무상태 세션과 인증에 대해 더 자세히 알아보기 → YouTube (11분).
세션 업데이트 (또는 새로 고침)
세션의 만료 시간을 연장할 수도 있습니다. 이는 사용자가 애플리케이션에 다시 접근한 후에도 로그인 상태를 유지하는 데 유용합니다. 예를 들어:
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
export async function updateSession() {
const session = 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);
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expires,
sameSite: "lax",
path: "/",
});
}
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
export async function updateSession() {
const session = 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);
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expires,
sameSite: "lax",
path: "/",
});
}
팁: 인증 라이브러리가 새로 고침 토큰을 지원하는지 확인하세요. 새로 고침 토큰은 사용자의 세션을 연장하는 데 사용될 수 있습니다.
세션 삭제하기
세션을 삭제하려면 쿠키를 삭제하면 됩니다:
import "server-only";
import { cookies } from "next/headers";
export function deleteSession() {
cookies().delete("session");
}
import "server-only";
import { cookies } from "next/headers";
export function deleteSession() {
cookies().delete("session");
}
그런 다음 애플리케이션에서 deleteSession()
함수를 재사용할 수 있습니다. 예를 들어, 로그아웃 시:
import { cookies } from "next/headers";
import { deleteSession } from "@/app/lib/session";
export async function logout() {
deleteSession();
redirect("/login");
}
import { cookies } from "next/headers";
import { deleteSession } from "@/app/lib/session";
export async function logout() {
deleteSession();
redirect("/login");
}
데이터베이스 세션
데이터베이스 세션을 생성하고 관리하려면 다음 단계를 따라야 합니다:
- 데이터베이스에 세션과 데이터를 저장할 테이블을 생성합니다 (또는 인증 라이브러리가 이를 처리하는지 확인합니다).
- 세션을 삽입, 업데이트, 삭제하는 기능을 구현합니다.
- 사용자의 브라우저에 저장하기 전에 세션 ID를 암호화하고, 데이터베이스와 쿠키가 동기화 상태를 유지하도록 합니다 (이는 선택 사항이지만, 미들웨어에서 낙관적 인증 확인을 위해 권장됩니다).
예를 들어:
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. 데이터베이스에 세션 생성
const data = await db
.insert(sessions)
.values({
userId: id,
expiresAt,
})
// 세션 ID 반환
.returning({ id: sessions.id });
const sessionId = data[0].id;
// 2. 세션 ID 암호화
const session = await encrypt({ sessionId, expiresAt });
// 3. 낙관적 인증 확인을 위해 세션을 쿠키에 저장
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
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. 데이터베이스에 세션 생성
const data = await db
.insert(sessions)
.values({
userId: id,
expiresAt,
})
// 세션 ID 반환
.returning({ id: sessions.id });
const sessionId = data[0].id;
// 2. 세션 ID 암호화
const session = await encrypt({ sessionId, expiresAt });
// 3. 낙관적 인증 확인을 위해 세션을 쿠키에 저장
cookies().set("session", session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
팁:
- 더 빠른 데이터 검색을 위해 Vercel Redis와 같은 데이터베이스 사용을 고려해보세요. 하지만 기본 데이터베이스에 세션 데이터를 유지하고 쿼리 수를 줄이기 위해 데이터 요청을 결합할 수도 있습니다.
- 사용자가 마지막으로 로그인한 시간 추적, 활성 기기 수 추적, 또는 사용자에게 모든 기기에서 로그아웃할 수 있는 기능 제공과 같은 더 고급 사용 사례를 위해 데이터베이스 세션을 사용할 수 있습니다.
세션 관리를 구현한 후에는 사용자가 애플리케이션 내에서 접근하고 수행할 수 있는 작업을 제어하기 위한 권한 부여 로직을 추가해야 합니다. 자세한 내용은 권한 부여 섹션을 계속 읽어보세요.
권한 부여
사용자가 인증되고 세션이 생성되면, 사용자가 애플리케이션 내에서 접근하고 수행할 수 있는 작업을 제어하기 위해 권한 부여를 구현할 수 있습니다.
두 가지 주요 유형의 권한 부여 확인이 있습니다:
- 낙관적: 쿠키에 저장된 세션 데이터를 사용하여 사용자가 경로에 접근하거나 작업을 수행할 권한이 있는지 확인합니다. 이러한 확인은 UI 요소 표시/숨기기 또는 권한이나 역할에 따라 사용자를 리디렉션하는 등의 빠른 작업에 유용합니다.
- 안전: 데이터베이스에 저장된 세션 데이터를 사용하여 사용자가 경로에 접근하거나 작업을 수행할 권한이 있는지 확인합니다. 이러한 확인은 더 안전하며 민감한 데이터나 작업에 대한 접근이 필요한 작업에 사용됩니다.
두 경우 모두 다음을 권장합니다:
- 권한 부여 로직을 중앙 집중화하기 위해 데이터 접근 계층(DAL) 생성하기
- 필요한 데이터만 반환하기 위해 데이터 전송 객체(DTO) 사용하기
- 선택적으로 낙관적 확인을 수행하기 위해 미들웨어 사용하기
미들웨어를 사용한 낙관적 확인 (선택 사항)
미들웨어를 사용하여 권한에 따라 사용자를 리디렉션하고 싶은 경우가 있습니다:
- 낙관적 확인을 수행하기 위해. 미들웨어는 모든 경로에서 실행되므로 리디렉션 로직을 중앙 집중화하고 권한이 없는 사용자를 사전에 필터링하는 좋은 방법입니다.
- 사용자 간에 데이터를 공유하는 정적 경로를 보호하기 위해 (예: 유료 콘텐츠).
그러나 미들웨어는 프리페치된 경로를 포함한 모든 경로에서 실행되므로, 성능 문제를 방지하기 위해 쿠키에서 세션을 읽는 것(낙관적 확인)만 하고 데이터베이스 확인을 피하는 것이 중요합니다.
예를 들어:
import { NextRequest, NextResponse } from "next/server";
import { decrypt } from "@/app/lib/session";
import { cookies } from "next/headers";
// 1. 보호된 경로와 공개 경로 지정
const protectedRoutes = ["/dashboard"];
const publicRoutes = ["/login", "/signup", "/"];
export default async function middleware(req: NextRequest) {
// 2. 현재 경로가 보호된 경로인지 공개 경로인지 확인
const path = req.nextUrl.pathname;
const isProtectedRoute = protectedRoutes.includes(path);
const isPublicRoute = publicRoutes.includes(path);
// 3. 쿠키에서 세션 복호화
const cookie = cookies().get("session")?.value;
const session = await decrypt(cookie);
// 5. 사용자가 인증되지 않은 경우 /login으로 리디렉션
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
// 6. 사용자가 인증된 경우 /dashboard로 리디렉션
if (
isPublicRoute &&
session?.userId &&
!req.nextUrl.pathname.startsWith("/dashboard")
) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
}
// 미들웨어가 실행되지 않아야 하는 경로
export const config = {
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
import { NextResponse } from "next/server";
import { decrypt } from "@/app/lib/session";
import { cookies } from "next/headers";
// 1. 보호된 경로와 공개 경로 지정
const protectedRoutes = ["/dashboard"];
const publicRoutes = ["/login", "/signup", "/"];
export default async function middleware(req) {
// 2. 현재 경로가 보호된 경로인지 공개 경로인지 확인
const path = req.nextUrl.pathname;
const isProtectedRoute = protectedRoutes.includes(path);
const isPublicRoute = publicRoutes.includes(path);
// 3. 쿠키에서 세션 복호화
const cookie = cookies().get("session")?.value;
const session = await decrypt(cookie);
// 5. 사용자가 인증되지 않은 경우 /login으로 리디렉션
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
// 6. 사용자가 인증된 경우 /dashboard로 리디렉션
if (
isPublicRoute &&
session?.userId &&
!req.nextUrl.pathname.startsWith("/dashboard")
) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
}
// 미들웨어가 실행되지 않아야 하는 경로
export const config = {
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
미들웨어가 초기 확인에 유용할 수 있지만, 데이터를 보호하는 유일한 방어선이 되어서는 안 됩니다. 보안 확인의 대부분은 데이터 소스에 가능한 한 가깝게 수행되어야 합니다. 자세한 내용은 데이터 액세스 계층을 참조하세요.
팁:
- 미들웨어에서는
req.cookies.get('session).value
를 사용하여 쿠키를 읽을 수도 있습니다.- 미들웨어는 Edge Runtime을 사용합니다. 인증 라이브러리와 세션 관리 라이브러리가 호환되는지 확인하세요.
- 미들웨어에서
matcher
속성을 사용하여 미들웨어가 실행될 경로를 지정할 수 있습니다. 그러나 인증의 경우, 미들웨어가 모든 경로에서 실행되는 것이 권장됩니다.
데이터 액세스 계층(DAL) 생성하기
데이터 요청과 권한 부여 로직을 중앙 집중화하기 위해 DAL을 생성하는 것을 권장합니다.
DAL은 사용자가 애플리케이션과 상호 작용할 때 사용자의 세션을 확인하는 함수를 포함해야 합니다. 최소한 이 함수는 세션이 유효한지 확인한 다음, 추가 요청을 하는 데 필요한 사용자 정보를 리디렉션하거나 반환해야 합니다.
예를 들어, verifySession()
함수를 포함하는 DAL용 별도의 파일을 만듭니다. 그런 다음 React의 cache API를 사용하여 React 렌더링 과정 동안 함수의 반환 값을 메모이제이션합니다:
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
export const verifySession = cache(async () => {
const cookie = cookies().get("session")?.value;
const session = await decrypt(cookie);
if (!session?.userId) {
redirect("/login");
}
return { isAuth: true, userId: session.userId };
});
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
export const verifySession = cache(async () => {
const cookie = cookies().get("session").value;
const session = await decrypt(cookie);
if (!session.userId) {
redirect("/login");
}
return { isAuth: true, userId: session.userId };
});
그런 다음 데이터 요청, 서버 액션, 라우트 핸들러에서 verifySession()
함수를 호출할 수 있습니다:
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),
// 전체 사용자 객체가 아닌 필요한 열만 명시적으로 반환
columns: {
id: true,
name: true,
email: true,
},
});
const user = data[0];
return user;
} catch (error) {
console.log("Failed to fetch user");
return null;
}
});
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),
// 전체 사용자 객체가 아닌 필요한 열만 명시적으로 반환
columns: {
id: true,
name: true,
email: true,
},
});
const user = data[0];
return user;
} catch (error) {
console.log("Failed to fetch user");
return null;
}
});
팁:
- DAL은 요청 시 가져온 데이터를 보호하는 데 사용될 수 있습니다. 그러나 사용자 간에 데이터를 공유하는 정적 경로의 경우, 데이터는 요청 시가 아닌 빌드 시에 가져옵니다. 정적 경로를 보호하려면 미들웨어를 사용하세요.
- 안전한 확인을 위해 세션 ID를 데이터베이스와 비교하여 세션이 유효한지 확인할 수 있습니다. 렌더링 과정 동안 데이터베이스에 대한 불필요한 중복 요청을 피하기 위해 React의 cache 함수를 사용하세요.
- 모든 메서드 실행 전에
verifySession()
을 실행하는 JavaScript 클래스에서 관련 데이터 요청을 통합하고 싶을 수 있습니다.
데이터 전송 객체(DTO) 사용하기
데이터를 검색할 때, 애플리케이션에서 사용될 필요한 데이터만 반환하고 전체 객체를 반환하지 않는 것이 좋습니다. 예를 들어, 사용자 데이터를 가져올 때 비밀번호, 전화번호 등이 포함될 수 있는 전체 사용자 객체 대신 사용자의 ID와 이름만 반환할 수 있습니다.
그러나 반환되는 데이터 구조를 제어할 수 없거나, 클라이언트에 전체 객체가 전달되는 것을 피하고 싶은 팀에서 작업하는 경우, 클라이언트에 노출해도 안전한 필드를 지정하는 등의 전략을 사용할 수 있습니다.
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),
// 여기서 특정 열 반환
});
const user = data[0];
const currentUser = await getUser(user.id);
// 또는 여기서 쿼리에 특정한 것만 반환
return {
username: canSeeUsername(currentUser) ? user.username : null,
phonenumber: canSeePhoneNumber(currentUser, user.team)
? user.phonenumber
: null,
};
}
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),
// 여기서 특정 열 반환
});
const user = data[0];
const currentUser = await getUser(user.id);
// 또는 여기서 쿼리에 특정한 것만 반환
return {
username: canSeeUsername(currentUser) ? user.username : null,
phonenumber: canSeePhoneNumber(currentUser, user.team)
? user.phonenumber
: null,
};
}
DAL에서 데이터 요청과 권한 부여 로직을 중앙 집중화하고 DTO를 사용함으로써, 모든 데이터 요청이 안전하고 일관성 있게 이루어지도록 보장할 수 있습니다. 이는 애플리케이션이 확장됨에 따라 유지 관리, 감사, 디버깅을 더 쉽게 만듭니다.
알아두면 좋은 점:
- DTO를 정의하는 방법은 여러 가지가 있습니다.
toJSON()
을 사용하는 방법, 위의 예제와 같은 개별 함수, 또는 JS 클래스 등이 있습니다. 이는 JavaScript 패턴이며 React나 Next.js 기능이 아니므로, 애플리케이션에 가장 적합한 패턴을 찾기 위해 조사해 보는 것을 권장합니다.- Next.js의 보안 모범 사례 기사에서 보안 모범 사례에 대해 자세히 알아보세요.
서버 컴포넌트
서버 컴포넌트에서의 인증 확인은 역할 기반 접근에 유용합니다. 예를 들어, 사용자의 역할에 따라 조건부로 컴포넌트를 렌더링하는 경우:
import { verifySession } from "@/app/lib/dal";
export default function Dashboard() {
const session = await verifySession();
const userRole = session?.user?.role; // 'role'이 세션 객체의 일부라고 가정
if (userRole === "admin") {
return <AdminDashboard />;
} else if (userRole === "user") {
return <UserDashboard />;
} else {
redirect("/login");
}
}
import { verifySession } from '@/app/lib/dal'
export default function Dashboard() {
const session = await verifySession()
const userRole = session.role // 'role'이 세션 객체의 일부라고 가정
if (userRole === 'admin') {
return <AdminDashboard />
} else if (userRole === 'user') {
return <UserDashboard />
} else {
redirect('/login')
}
}
이 예제에서는 DAL의 verifySession()
함수를 사용하여 'admin', 'user', 그리고 권한이 없는 역할을 확인합니다. 이 패턴은 각 사용자가 자신의 역할에 적합한 컴포넌트와만 상호 작용하도록 보장합니다.
레이아웃과 인증 확인
부분 렌더링으로 인해, 레이아웃에서 확인을 수행할 때 주의해야 합니다. 레이아웃은 네비게이션 시 다시 렌더링되지 않기 때문에 사용자 세션이 모든 경로 변경에서 확인되지 않습니다.
대신, 데이터 소스나 조건부로 렌더링될 컴포넌트에 가깝게 확인을 수행해야 합니다.
예를 들어, 사용자 데이터를 가져오고 네비게이션에 사용자 이미지를 표시하는 공유 레이아웃을 고려해보세요. 레이아웃에서 인증 확인을 수행하는 대신, 레이아웃에서 사용자 데이터(getUser()
)를 가져오고 DAL에서 인증 확인을 수행해야 합니다.
이렇게 하면 애플리케이션 내에서 getUser()
가 호출되는 곳마다 인증 확인이 수행되도록 보장하며, 개발자가 데이터에 접근할 권한이 있는지 확인하는 것을 잊는 것을 방지합니다.
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
return (
// ...
)
}
export default async function Layout({ children }) {
const user = await getUser();
return (
// ...
)
}
export const getUser = cache(async () => {
const session = await verifySession();
if (!session) return null;
// 세션에서 사용자 ID를 가져와 데이터 조회
});
export const getUser = cache(async () => {
const session = await verifySession();
if (!session) return null;
// 세션에서 사용자 ID를 가져와 데이터 조회
});
알아두면 좋은 점:
- SPA에서 흔한 패턴은 사용자가 인증되지 않은 경우 레이아웃이나 최상위 컴포넌트에서
return null
을 하는 것입니다. 이 패턴은 권장되지 않습니다. Next.js 애플리케이션은 여러 진입점이 있어 중첩된 경로 세그먼트와 서버 액션에 대한 접근을 방지하지 못하기 때문입니다.
서버 액션
서버 액션를 공개 API 엔드포인트와 동일한 보안 고려사항으로 취급하고, 사용자가 변경을 수행할 권한이 있는지 확인하세요.
아래 예제에서는 작업을 진행하기 전에 사용자의 역할을 확인합니다:
"use server";
import { verifySession } from "@/app/lib/dal";
export async function serverAction(formData: FormData) {
const session = await verifySession();
const userRole = session?.user?.role;
// 사용자가 작업을 수행할 권한이 없으면 일찍 반환
if (userRole !== "admin") {
return null;
}
// 권한이 있는 사용자에 대해 작업 진행
}
"use server";
import { verifySession } from "@/app/lib/dal";
export async function serverAction() {
const session = await verifySession();
const userRole = session.user.role;
// 사용자가 작업을 수행할 권한이 없으면 일찍 반환
if (userRole !== "admin") {
return null;
}
// 권한이 있는 사용자에 대해 작업 진행
}
라우트 핸들러
라우트 핸들러를 공개 API 엔드포인트와 동일한 보안 고려사항으로 취급하고, 사용자가 Route Handler에 접근할 권한이 있는지 확인하세요.
예를 들어:
import { verifySession } from "@/app/lib/dal";
export async function GET() {
// 사용자 인증 및 역할 확인
const session = await verifySession();
// 사용자가 인증되었는지 확인
if (!session) {
// 사용자가 인증되지 않음
return new Response(null, { status: 401 });
}
// 사용자가 'admin' 역할을 가지고 있는지 확인
if (session.user.role !== "admin") {
// 사용자가 인증되었지만 적절한 권한이 없음
return new Response(null, { status: 403 });
}
// 권한이 있는 사용자에 대해 계속 진행
}
import { verifySession } from "@/app/lib/dal";
export async function GET() {
// 사용자 인증 및 역할 확인
const session = await verifySession();
// 사용자가 인증되었는지 확인
if (!session) {
// 사용자가 인증되지 않음
return new Response(null, { status: 401 });
}
// 사용자가 'admin' 역할을 가지고 있는지 확인
if (session.user.role !== "admin") {
// 사용자가 인증되었지만 적절한 권한이 없음
return new Response(null, { status: 403 });
}
// 권한이 있는 사용자에 대해 계속 진행
}
위의 예제는 2단계 보안 확인이 있는 Route Handler를 보여줍니다. 먼저 활성 세션이 있는지 확인하고, 그 다음 로그인한 사용자가 'admin'인지 확인합니다.
컨텍스트 프로바이더 (Context Providers)
인터리빙 덕분에 인증을 위한 컨텍스트 프로바이더(Context Providers) 사용이 가능합니다. 그러나 React의 context
는 서버 컴포넌트에서는 지원되지 않아 클라이언트 컴포넌트에만 적용할 수 있습니다.
이 방식은 작동하지만, 자식 서버 컴포넌트는 먼저 서버에서 렌더링되며, 컨텍스트 프로바이더(Context Providers)의 세션 데이터에 접근할 수 없습니다.
import { ContextProvider } from "auth-lib";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ContextProvider>{children}</ContextProvider>
</body>
</html>
);
}
"use client";
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
"use client";
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
Client Components에서 세션 데이터가 필요한 경우(예: 클라이언트 측 데이터 가져오기), React의 taintUniqueValue
API를 사용하여 민감한 세션 데이터가 클라이언트에 노출되는 것을 방지하세요.
리소스
이제 Next.js의 인증에 대해 배웠으니, 안전한 인증 및 세션 관리를 구현하는 데 도움이 되는 Next.js 호환 라이브러리와 리소스를 소개합니다:
인증 라이브러리
세션 관리 라이브러리
추가 읽기
인증과 보안에 대해 계속 학습하려면 다음 리소스를 확인하세요: