[Next.js + TS 실전] 5. 로그인 붙이기 (Clerk)
지금까지 메모 앱은 누구나 같은 목록을 보고 고칠 수 있습니다.
이번 편은 로그인을 붙이고, 내 메모만 보이게 만듭니다.
도구: Clerk (Next.js App Router와 연동이 단순함)
이 편에서 할 일
- 왜 직접 인증을 안 짜는지 짧게 이해하기
- Clerk 프로젝트·키·패키지 준비
ClerkProvider+ 미들웨어 +auth.protect()로 보호- 내비에 로그인/유저 버튼 넣기
- Prisma
Memo에userId추가 → 내 메모만 CRUD
UI 클릭 경로는 Clerk 대시보드가 자주 바뀝니다.
키 발급 → env → 코드 연결 흐름만 정확히 따라가면 됩니다.
왜 Clerk인가
직접 만들면 대략 이만큼입니다.
- 회원가입 / 로그인 / 세션 / 쿠키
- 비밀번호 해시, 이메일 인증
- 로그아웃, 비밀번호 찾기…
학습 목표가 “인증 프로토콜”이 아니라면,
검증된 서비스 + SDK로 붙이는 편이 앱에 집중하기 좋습니다.
Clerk를 고른 이유:
- Next App Router 공식 가이드가 친절함
<SignInButton />,<UserButton />같은 UI가 바로 있음- 서버에서
auth()로 현재 유저 id를 얻기 쉬움
나중에 Auth.js 등으로 바꿔도 “보호된 라우트 + userId로 데이터 필터” 개념은 같습니다.
전체 그림
브라우저
↓
Clerk (로그인/세션)
↓
Next 미들웨어 (세션 연결)
↓
app/memos/layout (auth.protect)
↓
Server Action / Prisma
↓
Memo.userId === 현재 유저만 조회·수정·삭제
로그인만 하고 DB를 안 나누면,
여전히 남의 메모가 전부 보입니다.
이번 편의 진짜 목표는 userId 연결입니다.
1) Clerk 계정·애플리케이션
- https://clerk.com 가입
- 새 Application 생성 (Next.js 선택)
- API Keys에서 아래 두 값을 복사
| 이름 | 용도 |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
브라우저에 노출돼도 되는 공개 키 |
CLERK_SECRET_KEY |
서버 전용. GitHub에 올리면 안 됨 |
memo-app/.env (또는 .env.local)에 추가:
DATABASE_URL="file:./dev.db"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
.gitignore에 .env / .env.local이 있는지 다시 확인하세요.
2) 패키지 설치
npm install @clerk/nextjs
3) 미들웨어 + 페이지(레이아웃)에서 보호
Next.js 16은 네트워크 경계 파일 이름이 proxy.ts인 경우가 있고,
15 이하는 보통 middleware.ts입니다.
(공식 문서가 middleware.ts를 말하면, 버전 기준으로 파일명만 맞추면 됩니다.)
proxy.ts — Clerk 연결만
경로: memo-app/proxy.ts (또는 middleware.ts)
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
최신
@clerk/nextjs에서는createRouteMatcher로 미들웨어에서 경로를 막는 방식이 deprecated입니다.
미들웨어는 Clerk 세션만 연결하고, 보호는 각 페이지/레이아웃에서 합니다.
app/memos/layout.tsx — /memos 전체 보호
import { auth } from "@clerk/nextjs/server";
export default async function MemosLayout({
children,
}: {
children: React.ReactNode;
}) {
await auth.protect();
return children;
}
의미:
/memos,/memos/new,/memos/1… → 이 레이아웃을 거치므로 로그인 필요/(홈) → 레이아웃 밖이라 비로그인 가능auth.protect()→ 없으면 로그인 화면으로
4) 레이아웃에 ClerkProvider + 버튼
app/layout.tsx (루트 레이아웃):
import Link from "next/link";
import type { Metadata } from "next";
import {
ClerkProvider,
SignInButton,
SignUpButton,
Show,
UserButton,
} from "@clerk/nextjs";
import "./globals.css";
export const metadata: Metadata = {
title: "Memo App",
description: "Next.js + TypeScript 실습",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ko">
<body>
<ClerkProvider>
<header>
<nav>
<Link href="/">홈</Link>
{" | "}
<Link href="/memos">메모</Link>
{" | "}
<Show when="signed-out">
<SignInButton /> <SignUpButton />
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</nav>
</header>
{children}
</ClerkProvider>
</body>
</html>
);
}
| 컴포넌트 | 역할 |
|---|---|
ClerkProvider |
앱 전체에 Clerk 컨텍스트 (<body> 안) |
Show when="signed-out" |
로그아웃일 때 보일 UI |
Show when="signed-in" |
로그인일 때 보일 UI |
SignInButton / SignUpButton |
로그인·가입 |
UserButton |
프로필·로그아웃 |
예전
SignedIn/SignedOut은 최신 Clerk에서Show로 바뀌었습니다.
npm run dev 후 홈에서 로그인 → /memos 접근이 되면 1차 성공입니다.
5) Prisma: 메모에 주인(userId) 붙이기
스키마를 바꿉니다. prisma/schema.prisma:
model Memo {
id String @id @default(cuid())
title String
content String
userId String
createdAt DateTime @default(now())
@@index([userId])
}
마이그레이션:
npx prisma migrate dev --name add-memo-user-id
이미 시드/메모가 있다면 userId 때문에 충돌할 수 있습니다.
연습 단계면 DB를 비우고 다시 만들어도 됩니다.
npx prisma migrate reset
(reset은 데이터 삭제 + 마이그레이션 + 시드. 로컬에서만.)
시드(prisma/seed.ts)에도 임시 userId가 필요합니다.
예: "seed-user" — 나중에 실제 Clerk 유저 id와는 다릅니다.
실습은 로그인 후 새로 만든 메모로 확인하는 편이 낫습니다.
6) 데이터 레이어: 내 것만
lib/memos.ts를 userId 기준으로 바꿉니다.
import type { Memo } from "@/types/memo";
import { prisma } from "@/lib/prisma";
function toMemo(row: {
id: string;
title: string;
content: string;
createdAt: Date;
}): Memo {
return {
id: row.id,
title: row.title,
content: row.content,
createdAt: row.createdAt.toISOString().slice(0, 10),
};
}
export async function getMemos(userId: string): Promise<Memo[]> {
const rows = await prisma.memo.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
return rows.map(toMemo);
}
export async function getMemoById(
id: string,
userId: string
): Promise<Memo | undefined> {
const row = await prisma.memo.findFirst({
where: { id, userId },
});
return row ? toMemo(row) : undefined;
}
export async function addMemo(
userId: string,
input: { title: string; content: string }
): Promise<Memo> {
const row = await prisma.memo.create({
data: {
userId,
title: input.title,
content: input.content,
},
});
return toMemo(row);
}
export async function updateMemo(
id: string,
userId: string,
input: { title: string; content: string }
): Promise<Memo | undefined> {
try {
const row = await prisma.memo.update({
where: { id },
data: input,
});
// 남의 메모면 위에서 이미 다른 유저 것일 수 있으니, 소유권 확인
if (row.userId !== userId) {
return undefined;
}
return toMemo(row);
} catch {
return undefined;
}
}
export async function deleteMemo(
id: string,
userId: string
): Promise<boolean> {
const existing = await prisma.memo.findFirst({
where: { id, userId },
});
if (!existing) return false;
await prisma.memo.delete({ where: { id } });
return true;
}
더 안전한 update 패턴 (추천):
const result = await prisma.memo.updateMany({
where: { id, userId },
data: input,
});
if (result.count === 0) return undefined;
return getMemoById(id, userId);
updateMany + where: { id, userId }면 남의 id를 알아도 수정이 안 됩니다.
7) Action / 페이지에서 auth()로 userId
서버에서 현재 유저:
import { auth } from "@clerk/nextjs/server";
const { userId } = await auth();
if (!userId) {
throw new Error("로그인이 필요합니다");
}
createMemo 예시 핵심만:
const { userId } = await auth();
if (!userId) {
return { ok: false, message: "로그인이 필요합니다", values };
}
// ... zod 검증 후
await addMemo(userId, parsed.data);
목록 페이지:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { getMemos } from "@/lib/memos";
export default async function MemosPage() {
const { userId } = await auth();
if (!userId) redirect("/");
const memos = await getMemos(userId);
// ... 기존 UI
}
상세·수정도 getMemoById(id, userId)로 바꿉니다.
없으면 notFound() — 남 메모 URL을 쳐도 404.
updateMemoAction / deleteMemoAction에도 똑같이 userId를 넘기세요.
동작 확인 체크리스트
- 로그아웃 상태에서
/memos→ 로그인으로 유도 - 가입/로그인 후 메모 작성
- 목록에 내 메모만 보임
- (가능하면) 다른 계정으로 로그인 → 이전 계정 메모가 안 보임
- 다른 유저 메모 id를 URL에 넣으면 404
한 계정만 있어도 1~3이면 기본 연동은 성공입니다.
자주 하는 실수
1. CLERK_SECRET_KEY를 클라이언트에 노출
→ NEXT_PUBLIC_ 붙이지 말 것
2. 미들웨어만 하고 DB는 그대로
→ 로그인은 됐는데 데이터는 공유. userId 필수
3. auth()를 await 안 함
→ 최신 SDK는 await auth()
4. update를 where: { id }만 함
→ id를 알면 남의 글 수정 가능. 반드시 userId 조건
5. env를 넣고 서버를 안 재시작
→ .env 변경 후 npm run dev 다시
6. Root에 Clerk를 설치하고 memo-app에서 import
→ 패키지·env는 memo-app 기준
이 편에서 가져갈 것
- 인증은 직접 구현보다 서비스+SDK가 실무에서 흔하다
- 미들웨어 = Clerk 세션 연결 (
clerkMiddleware) - 접근 보호 = 레이아웃/페이지에서
auth.protect() auth().userId= 서버에서 “지금 누구인지”- 보호의 핵심은 DB 쿼리에 userId를 넣는 것
- UI 컴포넌트(
SignInButton등)와 데이터 소유권은 같이 설계한다
다음으로 이어가면 좋은 것
- Neon Postgres + Vercel에
CLERK_*환경 변수까지 넣어 배포본 로그인 - 조직(Organization) / 역할(role) — 팀 메모
- Auth.js로 같은 구조를 직접 비교
시리즈 목차 (실전)
- 메모 수정과 삭제
- Prisma + SQLite로 DB 붙이기
- Vercel에 배포하기
- 폼 검증하기 (zod)
- 로그인 붙이기 (Clerk) ← 지금
'frontend > NextJs' 카테고리의 다른 글
| # [Next.js + TS 실전] 7. 삭제 확인 UI (0) | 2026.07.31 |
|---|---|
| # [Next.js + TS 실전] 6. Neon Postgres 연결하기 (1) | 2026.07.31 |
| # [Next.js + TS 실전] 4. 폼 검증하기 (zod) (0) | 2026.07.30 |
| # [Next.js + TS 실전] 3. Vercel에 배포하기 (0) | 2026.07.30 |
| # [Next.js + TS 실전] 2. Prisma + SQLite로 DB 붙이기 (1) | 2026.07.30 |
