이번 포스트에서는 Next.js Server Actions를 활용하여 Invoice(송장) 데이터를 업데이트하고 삭제하는 방법을 다룹니다. 동적 라우팅, 폼 데이터 처리, 그리고 데이터베이스 CRUD 작업의 전체 과정을 단계별로 살펴보겠습니다.
Invoice를 업데이트하는 과정은 다음 5단계로 구성됩니다:
Next.js에서는 폴더명을 대괄호로 감싸서 동적 경로를 생성할 수 있습니다. 이는 블로그 포스트 ID나 제품 페이지 같이 데이터에 기반한 경로가 필요할 때 유용합니다.
/app/dashboard/invoices/[id]/edit/page.tsx
// /app/dashboard/invoices/[id]/edit/page.tsx
export default function Page() {
return <>Update Page</>;
}
// /app/ui/invoices/button.tsx
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
✨ 포인트: 템플릿 리터럴을 사용하여 동적으로 경로를 생성합니다.
Next.js는 페이지 컴포넌트에 params
객체를 제공하여 동적 경로의 값을 읽을 수 있게 해줍니다.
// /app/dashboard/invoices/[id]/edit/page.tsx
import Form from "@/app/ui/invoices/edit-form";
import Breadcrumbs from "@/app/ui/invoices/breadcrumbs";
import { fetchCustomers } from "@/app/lib/data";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: "Invoices", href: "/dashboard/invoices" },
{
label: "Edit Invoice",
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
Promise.all
을 사용하여 Invoice 데이터와 고객 데이터를 동시에 가져와 성능을 최적화합니다.
// /app/dashboard/invoices/[id]/edit/page.tsx
import Form from "@/app/ui/invoices/edit-form";
import Breadcrumbs from "@/app/ui/invoices/breadcrumbs";
import { fetchCustomers, fetchInvoiceById } from "@/app/lib/data";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
// 병렬로 데이터 가져오기
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: "Invoices", href: "/dashboard/invoices" },
{
label: "Edit Invoice",
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
이 프로젝트에서는 자동 증가 키 대신 UUID를 사용합니다.
참고: 짧고 깔끔한 URL을 원한다면 자동 증가 키를 사용할 수도 있습니다.
// 이 방식은 동작하지 않습니다!
<form action={updateInvoice(id)}>
{/* ... */}
</form>
문제점:
updateInvoice(id)
가 즉시 실행됨action
속성은 URL 경로가 필요하지만 함수 실행 결과를 받게 됨// /app/ui/invoices/edit-form.tsx
import { updateInvoice } from "@/app/lib/actions";
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return (
<form action={updateInvoiceWithId}>
{/* 폼 내용 */}
</form>
);
}
bind 메서드의 동작 방식:
invoice.id
)를 미리 설정한 새로운 함수 생성// /app/lib/actions.ts
"use server";
import { sql } from "@vercel/postgres";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(), // "100" -> 100 자동 변환
status: z.enum(["pending", "paid"]),
date: z.string(),
});
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
export async function updateInvoice(id: string, formData: FormData) {
// 1. 폼 데이터 추출 및 검증
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
});
// 2. 데이터 변환 (달러를 센트로)
const amountInCents = amount * 100;
// 3. 데이터베이스 업데이트
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
// 4. 캐시 무효화
revalidatePath("/dashboard/invoices");
// 5. 리다이렉트
redirect("/dashboard/invoices");
}
formData.get()
메서드 사용revalidatePath
로 클라이언트 캐시 갱신
// /app/ui/invoices/buttons.tsx
import { deleteInvoice } from '@/app/lib/actions';
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
// /app/lib/actions.ts
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath("/dashboard/invoices");
}
이로써 Next.js Server Actions를 사용한 Invoice 데이터의 CRUD 작업 구현이 완료되었습니다. 다음 포스트에서는 에러 처리와 사용자 경험 개선에 대해 다루어보겠습니다.