> Hello, AX!_

공지사항 개발 가이드

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=require
02

스키마 정의

src/db/schema/notice.schema.ts

Drizzle 스키마로 테이블 구조를 정의합니다. $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:push
04

Repository 계층

src/repositories/notice.repository.ts

DB 쿼리만 담당합니다. 비즈니스 로직 없이 순수하게 데이터 접근만 처리하세요.

// 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.ts

Repository를 감싸 비즈니스 로직을 처리합니다. 도메인 타입으로 변환하거나, 권한 검사, 가공 등을 여기서 합니다.

// 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.tsx

Server 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>;
}