이전 장에서는 URL 검색 매개변수와 Next.js API를 사용하여 검색 및 페이지네이션 기능을 구현했었습니다. 이번에는 송장 페이지(Invoices page)에 송장 생성, 수정 및 삭제 기능을 추가하는 작업을 진행하려 합니다.
이번에 소개드릴 내용은 분량이 길기 때문에, 해당 챕터는 2개의 포스트로 분리하여 소개드리겠습니다!
React Server Actions는 비동기 코드를 서버에서 직접 실행할 수 있게 해주는 기능입니다. 이를 통해 데이터 변형을 위한 API 엔드포인트를 생성할 필요가 없어집니다.
API 엔드포인트: 애플리케이션 프로그래밍 인터페이스(API)에서 특정한 요청을 처리하는 URL
대신 서버에서 실행되는 비동기 함수를 작성하고, 이를 클라이언트 또는 서버 컴포넌트에서 호출할 수 있습니다.
웹 애플리케이션은 다양한 위협에 취약할 수 있기 때문에 보안이 최우선입니다. Server Actions는 강력한 보안 솔루션을 제공하여 여러 유형의 공격으로부터 데이터를 보호하고, 권한 있는 접근만 허용합니다.
Server Actions가 사용하는 보안 기술:
이러한 기술들을 통해 앱의 안전성을 크게 향상시킬 수 있습니다.
fetch
함수 사용 방식export default async function Page() {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return (
<div>
{/* 데이터를 렌더링 */}
</div>
);
}
// 서버 컴포넌트 내에서 서버 액션 정의
export default function Page() {
// 액션 함수
async function create(formData: FormData) {
'use server';
// 데이터 변형 로직
}
// 폼을 사용하여 액션 호출
return <form action={create}>...</form>;
}
구분 | fetch 함수 | Server Actions |
---|---|---|
API 노출 | 별도 API 엔드포인트 필요 (외부 노출) | API 엔드포인트 외부 노출 없음 |
인증/권한 | 추가적인 인증 및 권한 부여 필요 | 서버에서 직접 처리로 더 안전 |
통신 | 추가적인 클라이언트-서버 통신 | 통신 최소화 |
Server Actions는 보안과 효율성 측면에서 더 나은 솔루션을 제공하며, 데이터 변형 작업을 더 간편하게 수행할 수 있게 해줍니다.
React에서 <form>
요소의 action
속성을 사용하면 Server Actions를 호출할 수 있습니다. action은 캡처된 데이터를 포함하는 native FormData 객체를 자동으로 받습니다.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// 데이터 변형 로직...
}
// action 속성을 사용하여 action 호출
return <form action={create}>...</form>;
}
서버 컴포넌트 내에서 Server Action을 호출하는 가장 큰 장점 중 하나는 점진적 향상입니다.
점진적 향상이란?
웹 애플리케이션의 기능이 점진적으로 개선될 수 있음을 의미합니다. 클라이언트의 환경이나 기능에 상관없이 기본적인 동작이 보장되고, 추가적인 기능이 가능할 때 이를 활용할 수 있다는 뜻입니다.
점진적 향상의 원칙:
1. 기본 기능 보장: 최소한의 기능을 모든 사용자가 이용할 수 있도록 보장
2. 추가 기능 제공: 사용자의 브라우저나 네트워크 상태가 더 좋은 경우, 추가 기능으로 사용자 경험 개선
// 서버 컴포넌트 내에서 Server Action 정의
export default function Page() {
// 서버 액션 함수
async function create(formData: FormData) {
'use server';
// 서버에서 데이터 처리 로직
// 예: 데이터베이스에 새 항목 추가
}
// form을 사용하여 서버 액션 호출
return (
<form action={create} method="post">
<input type="text" name="name" required />
<button type="submit">Submit</button>
</form>
);
}
기본 기능 보장: JavaScript가 비활성화되어 있어도 Form이 작동합니다. 브라우저는 Form 데이터를 서버로 POST 요청을 통해 전송하고, 서버에서 데이터를 처리합니다.
추가 기능 제공: JavaScript가 활성화된 환경에서는 Form 제출 후 페이지를 다시 로드하지 않고도 결과를 표시할 수 있습니다.
Server Actions는 Next.js의 캐싱과도 긴밀하게 연관되어 있습니다. Server Action을 통해 Form이 제출되면 데이터를 변형하는 것뿐만 아니라 revalidatePath
및 revalidateTag
같은 API를 사용하여 관련 캐시를 재검증할 수도 있습니다.
Invoice를 생성하는 전체 과정은 다음과 같습니다:
formData
객체로부터 데이터 추출먼저 /invoices/create
에 해당하는 route를 생성하고, 이 페이지에서 invoice를 생성하기 위한 폼을 렌더링합니다.
/app/dashboard/invoices/create/page.tsx
import Form from "@/app/ui/invoices/create-form";
import Breadcrumbs from "@/app/ui/invoices/breadcrumbs";
import { fetchCustomers } from "@/app/lib/data";
export default async function Page() {
const customers = await fetchCustomers();
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: "Invoices", href: "/dashboard/invoices" },
{
label: "Create Invoice",
href: "/dashboard/invoices/create",
active: true,
},
]}
/>
<Form customers={customers} />
</main>
);
}
주요 코드 내용:
fetchCustomers()
를 통해 고객 데이터를 가져와 <Form>
컴포넌트에 전달<Form>
컴포넌트는 고객 선택 드롭다운, 금액 입력 필드, 상태 선택 라디오 버튼, 제출 버튼으로 구성폼이 제출될 때 호출될 서버 액션을 생성합니다. lib/actions.ts
파일을 만들고 createInvoice
함수를 정의합니다.
/app/lib/actions.ts
"use server";
export async function createInvoice(formData: FormData) {}
이후 <Form>
컴포넌트에서 createInvoice
함수를 import하고 <form>
요소에 action
속성을 추가합니다.
"use client";
import { CustomerField } from "@/app/lib/definitions";
import Link from "next/link";
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "../button";
import { createInvoice } from "@/app/lib/actions";
export default function Form({ customers }: { customers: CustomerField[] }) {
return (
<form action={createInvoice}> {/* 서버 액션 연결 */}
{/* ... */}
</form>
);
}
참고사항
- HTML에서는
action
속성에 URL을 전달하여 폼 데이터를 제출할 대상(API 엔드포인트)을 지정합니다.- React에서는
action
속성이 특별한 prop으로 간주되어 액션 함수를 직접 호출할 수 있습니다.- 백그라운드에서 서버 액션은 POST API 엔드포인트를 생성하므로, 수동으로 API 엔드포인트를 만들 필요가 없습니다.
actions.ts
파일에서 formData
에서 값을 추출합니다. .get(name)
메서드를 사용합니다.
/app/lib/actions.ts
"use server";
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
};
// 출력 테스트
console.log("rawFormData is ", rawFormData);
}
[이미지 삽입 위치: create invoice step 3 form 입력.png - 폼 입력 예시]
[이미지 삽입 위치: create invoice step 3 form 입력결과.png - 콘솔 출력 결과]
폼 데이터를 데이터베이스로 보내기 전에 사용자 입력값이 올바른 형식과 타입인지 확인해야 합니다.
현재 Form에서 받는 데이터: customerId
, amount
, status
Invoice Table이 기대하는 데이터 형식:
export type Invoice = {
id: string;
customer_id: string;
amount: number;
date: string;
status: "pending" | "paid"; // string union type
};
type="number"
인 입력 요소도 실제로는 문자열을 반환합니다:
console.log(typeof rawFormData.amount); // output: string
TypeScript 우선 검증 라이브러리인 Zod를 사용하여 validation을 진행합니다:
"use server";
import { z } from "zod";
// 전체 폼 데이터를 검증하는 스키마
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(), // 문자열을 숫자로 자동 변환
status: z.enum(["pending", "paid"]), // 열거형
date: z.string(),
});
// id와 date 필드를 제거한 새 스키마
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// 스키마로 데이터 검증 및 변환
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
});
console.log(typeof amount); // output: number
}
JavaScript의 부동 소수점 오류를 제거하고 더 높은 정확성을 보장하기 위해 데이터베이스에 금액을 센트 단위로 저장합니다:
const amountInCents = amount * 100;
Invoice 생성날짜를 "YYYY-MM-DD" 형식으로 생성합니다:
const date = new Date().toISOString().split("T")[0];
console.log("date is ", date); // output: 2024-08-23 (현재날짜기준)
날짜 변환 과정:
1. new Date()
: 현재 날짜와 시간을 나타내는 Date 객체 생성
2. .toISOString()
: ISO8601 형식의 문자열로 변환 ("YYYY-MM-DDTHH:MM:SS.SSSZ")
3. .split("T")[0]
: "T" 문자 기준으로 분리하여 날짜 부분만 추출
console.log("date is ", new Date()); // 2024-08-23T05:31:18.628Z
console.log("date is ", new Date().toISOString()); // 2024-08-23T05:31:18.630Z
console.log("date is ", new Date().toISOString().split("T")); // [ '2024-08-23', '05:31:18.630Z' ]
console.log("date is ", date); // 2024-08-23
준비된 값으로 데이터베이스에 새로운 Invoice 데이터를 삽입하는 SQL 쿼리를 생성합니다:
"use server";
import { sql } from "@vercel/postgres";
import { z } from "zod";
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(["pending", "paid"]),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split("T")[0];
// 데이터베이스에 새 Invoice 삽입 (오류처리 미구현)
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
Next.js는 클라이언트 사이드에서 사용자의 브라우저에 route segment를 일정 시간 동안 저장하는 라우터 캐시를 가지고 있습니다.
라우터 캐시
사용자가 웹 페이지를 이동할 때, 이전에 방문한 페이지나 데이터의 정보를 브라우저에 저장하는 기능입니다. 이를 통해 웹 애플리케이션이 더 빠르게 반응하고 부드럽게 작동할 수 있습니다.
사전 로딩(Prefetching)
사용자가 현재 보고 있는 페이지와 다른 페이지를 미리 로드해 놓는 기술입니다. 사용자가 특정 페이지로 이동하기 전에 그 페이지의 리소스를 미리 받아오는 것입니다.
라우터 캐시와 사전 로딩의 장점:
Invoice 경로에서 표시되는 데이터를 업데이트했으므로, 캐시를 지우고 서버에 새로운 요청을 트리거해야 합니다. Next.js의 revalidatePath
함수를 사용합니다:
import { revalidatePath } from "next/cache";
// ... 기존 코드 ...
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath("/dashboard/invoices"); // 캐시 재검증
데이터베이스 업데이트 후 사용자를 /dashboard/invoices
페이지로 리디렉션합니다. Next.js의 redirect
함수를 사용합니다:
최종 완성된 createInvoice
함수:
"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(),
status: z.enum(["pending", "paid"]),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split("T")[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath("/dashboard/invoices"); // 캐시 재검증
redirect("/dashboard/invoices"); // 사용자 리디렉션
}
이제 데이터베이스에 새로운 Invoice가 추가된 후, 사용자가 자동으로 /dashboard/invoices
경로로 리디렉션되어 업데이트된 목록을 확인할 수 있습니다.