# [Next.js + TS 실전] 5. 로그인 붙이기 (Clerk)

[Next.js + TS 실전] 5. 로그인 붙이기 (Clerk)

지금까지 메모 앱은 누구나 같은 목록을 보고 고칠 수 있습니다.
이번 편은 로그인을 붙이고, 내 메모만 보이게 만듭니다.
도구: Clerk (Next.js App Router와 연동이 단순함)


이 편에서 할 일

  1. 왜 직접 인증을 안 짜는지 짧게 이해하기
  2. Clerk 프로젝트·키·패키지 준비
  3. ClerkProvider + 미들웨어 + auth.protect()로 보호
  4. 내비에 로그인/유저 버튼 넣기
  5. Prisma MemouserId 추가 → 내 메모만 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 계정·애플리케이션

  1. https://clerk.com 가입
  2. 새 Application 생성 (Next.js 선택)
  3. 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를 넘기세요.


동작 확인 체크리스트

  1. 로그아웃 상태에서 /memos → 로그인으로 유도
  2. 가입/로그인 후 메모 작성
  3. 목록에 내 메모만 보임
  4. (가능하면) 다른 계정으로 로그인 → 이전 계정 메모가 안 보임
  5. 다른 유저 메모 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로 같은 구조를 직접 비교

시리즈 목차 (실전)

  1. 메모 수정과 삭제
  2. Prisma + SQLite로 DB 붙이기
  3. Vercel에 배포하기
  4. 폼 검증하기 (zod)
  5. 로그인 붙이기 (Clerk) ← 지금