공지사항 개발 가이드
Neon DB 백엔드 작업 흐름을 확인하고 CRUD 기능을 직접 테스트하세요.
백엔드 작업 흐름
이 프로젝트는 Neon DB (PostgreSQL) + Drizzle ORM을 사용합니다. 아래 순서대로 작업하세요.
① DB 연결② 스키마 정의③ 마이그레이션④ Repository⑤ Service⑥ Server Action⑦ Page
01
DB 연결 설정
src/db/index.ts.env.local에 DATABASE_URL을 설정한 뒤, getDb()로 lazy하게 연결합니다. 빌드 타임에 환경 변수가 없어도 오류가 나지 않습니다.
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
export function getDb() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL이 설정되지 않았습니다.");
}
return drizzle(neon(process.env.DATABASE_URL), { schema });
}
// .env.local
// DATABASE_URL=postgresql://user:pass@ep-xxx.neon.tech/dbname?sslmode=require02
스키마 정의
src/db/schema/notice.schema.tsDrizzle 스키마로 테이블 구조를 정의합니다. $inferSelect / $inferInsert 타입을 그대로 활용하세요.
// src/db/schema/notice.schema.ts
import { boolean, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
export const noticeTable = pgTable("notices", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
content: text("content").notNull(),
isPinned: boolean("is_pinned").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export type NoticeRow = typeof noticeTable.$inferSelect;
export type NewNoticeRow = typeof noticeTable.$inferInsert;03
마이그레이션 실행
drizzle.config.ts스키마를 변경할 때마다 generate → migrate 순서로 실행합니다. push는 개발 환경에서만 사용하세요.
# 스키마 → SQL 파일 생성
pnpm db:generate
# SQL 파일 → DB 적용
pnpm db:migrate
# 개발 환경 빠른 적용 (프로덕션 금지)
pnpm db:push04
Repository 계층
src/repositories/notice.repository.tsDB 쿼리만 담당합니다. 비즈니스 로직 없이 순수하게 데이터 접근만 처리하세요.
// src/repositories/notice.repository.ts
import { desc, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { noticeTable } from "@/db/schema";
export async function findNoticeList() {
const db = getDb();
return db
.select()
.from(noticeTable)
.orderBy(desc(noticeTable.isPinned), desc(noticeTable.createdAt));
}
export async function insertNotice(data: { title: string; content: string; isPinned: boolean }) {
const db = getDb();
const rows = await db.insert(noticeTable).values(data).returning();
return rows[0];
}
export async function deleteNoticeById(id: number) {
const db = getDb();
return db.delete(noticeTable).where(eq(noticeTable.id, id)).returning();
}05
Service 계층
src/api/services/notice.service.tsRepository를 감싸 비즈니스 로직을 처리합니다. 도메인 타입으로 변환하거나, 권한 검사, 가공 등을 여기서 합니다.
// src/api/services/notice.service.ts
import { findNoticeList, insertNotice } from "@/repositories/notice.repository";
export async function getNoticeList() {
return findNoticeList();
}
export async function createNotice(data: {
title: string;
content: string;
isPinned?: boolean;
}) {
return insertNotice({
title: data.title,
content: data.content,
isPinned: data.isPinned ?? false,
});
}06
Server Action
src/app/notices/actions.ts'use server' 파일에서 Server Action을 작성합니다. FormData를 받아 유효성 검사 후 service를 호출하고, revalidatePath로 캐시를 갱신합니다.
// src/app/notices/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createNotice } from "@/api/services/notice.service";
export type ActionResult = { success: true } | { success: false; error: string };
export async function createNoticeAction(formData: FormData): Promise<ActionResult> {
const title = formData.get("title");
const content = formData.get("content");
if (!title || typeof title !== "string") {
return { success: false, error: "제목을 입력해주세요." };
}
try {
await createNotice({ title, content: String(content) });
revalidatePath("/notices");
return { success: true };
} catch {
return { success: false, error: "생성에 실패했습니다." };
}
}07
Page에서 사용
src/app/notices/page.tsxServer Component에서 service를 직접 호출해 초기 데이터를 가져옵니다. Client Component에는 props로 전달하고, 뮤테이션 후 router.refresh()로 서버 재요청을 트리거합니다.
// src/app/notices/page.tsx (Server Component)
import { getNoticeList } from "@/api/services/notice.service";
import { NoticesCrudTab } from "./_components/CrudTab";
export default async function NoticesPage() {
const noticeList = await getNoticeList(); // 서버에서 직접 DB 조회
return <NoticesCrudTab initialList={noticeList} />;
}
// src/app/notices/_components/CrudTab.tsx (Client Component)
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { createNoticeAction } from "../actions";
export function NoticesCrudTab({ initialList }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
async function handleCreate(formData: FormData) {
const result = await createNoticeAction(formData);
if (result.success) {
startTransition(() => router.refresh()); // 서버 재요청 → 최신 목록 반영
}
}
return <form action={handleCreate}>{/* ... */}</form>;
}