React Server Action은 비동기 코드를 서버에서 직접 실행할 수 있게 해준다. 데이터를 변경해야할 때 API 엔드포인트를 만드는 대신 서버에서 동작하는 비동기 함수를 작성할 수 있게 해주는 것이다.
웹 어플리케이션은 다양한 위협에 취약하기 때문에 보안은 최우선 순위이다. Server Action은 여러 공격을 방어하고, 데이터를 안전하게하고, 허가된 접근을 보장하기 위해 효율적인 보안 솔루션을 제공한다. Server Action은 POST 요청, 암호화된 클로져들, 엄격한 입력 검사, 에러 메세지 해쉬, 호스트 제한 등을 이용해 우리의 앱이 안전하게 해준다.
Server Action은 Next.js의 캐싱과도 깊게 연관되어있다. form이 Server Action을 통해 제출되었을 때, data를 변경하는 뿐만아니라, revalidatePath
와 revalidateTag
와 같은 API들을 이용하는 것으로 연관된 캐시들을 다시 업데이트 할 수 있다.
'use server';
위 라인을 파일의 최상단에 작성하는 것으로, 해당 파일에서 export되는 모든 함수가 서버 함수임을 표시하는 것이다. 이 서버 함수들은 클라이언트 또는 서버 컴포넌트에 import되어 다양하게 사용될 수 있다.
물론 이렇게 파일을 따로 분리하는 것 뿐만 아니라 서버 컴포넌트의 action 내부에 “use server”를 추가하는 것으로 직접 Server Action을 작성할 수도 있다.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
예를 들면 위와 같다.
DB로 데이터를 전송하기 전에, 타입 검사를 확실하게 해줄 필요가 있다. 타입 검사를 구현하기 위해 여러 방법이 있지만, Zod 라이브러리를 사용하면 쉽고 효율적으로 코드를 작성할 수 있다.
'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(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
이렇게 Zod 라이브러리를 통해 입력받을 form의 schema를 미리 정의해두고,
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
이런식으로 사용하면 된다. 타입 검사의 오류처리는 뒤에서 더 자세하게 알아보자. 추가적으로 Zod의 자세한 사용법은 공식 문서가 잘 설명하고 있으므로 해당 문서를 참고하자.
form이 제출되면, 화면의 데이터를 변경하고 유저의 위치를 리다이렉트 시켜줄 필요가 있다. 화면의 데이터를 재평가하기 위해서 revalidatePath
를, 리다이렉트를 위해서 redirect
함수를 이용해주자.
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// ...
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
id
Next.js는 정확한 세그먼트 이름을 모르고, 데이터에 기반한 라우트를 만들고 싶을 때 다이나믹 라우트 세그먼트를 만들 수 있게해준다. 이는 블로그 포스트 제목, 제품 페이지 등등 일 수 있다. 폴더명을 대괄호로 감싸는 것으로 다이나믹 라우트 세그먼트를 만들 수 있다.
// path : /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;
// ...
}
마찬가지로 Page 컴포넌트의 params
prop을 통해 파라미터를 읽어올 수 있다.
id
to the Server Actionid
값을 다음과 같이 인자로 전달할 수는 없다.
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
이를 가능케 하려면, JS의 bind
를 이용하면 된다.
// ...
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}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
bind를 통해 인자를 전달한 새로운 함수를 리턴받아 사용하면 원하는 대로 id
를 전달할 수 있다.
try/catch
to Server Actions먼저 JS의 try/catch
구문을 이용해 Server Action에서 에러를 처리해보자.
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];
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
redirect
가 try/catch
블록의 밖에서 호출됨에 주목해보자. 이는 redirect
가 내부적으로 에러를 throw 함으로써 동작하기 때문이다. 만약 try/catch
블록 안에 redirect
가 존재한다면 catch
블록에 잡힐 것이다. 이를 방지하기 위해 try/catch
블록 밖에 선언해주고, redirect
는 try
가 성공적일 때만 호출될 것이다.
그렇다면 만약 에러가 발생한다면 어떻게 되는 것일까?
여기서 Next.js의 error.tsx
파일이 등장한다.
error.tsx
error.tsx
파일은 해당 라우트에서 발생할 수 있는 모든 에러를 잡아 fallback UI를 사용자에게 제공한다.
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service
console.error(error);
}, [error]);
return (
<main className="flex h-full flex-col items-center justify-center">
<h2 className="text-center">Something went wrong!</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
onClick={
// Attempt to recover by trying to re-render the invoices route
() => reset()
}
>
Try again
</button>
</main>
);
}
몇가지 특징들을 살펴보자.
error.tsx
파일은 클라이언트 컴포넌트여야만 한다.error
- JS의 Error 객체의 인스턴스이다.reset
- 에러 바운더리를 리셋하는 함수이다. 실행되면 함수는 라우트 재렌더링을 시도할 것이다.notFound
functionerror.tsx
함수는 모든 에러를 잡아내지만, notFound
는 존재하지 않는 리소스를 요청했을 때 사용될 수 있다.
따로 처리해두지 않으면 존재하지 않는 리소스 요청에도 사용자는 error.tsx
에 정의된 fallback UI를 만나게 되지만, notFound
를 통해 좀 더 구체적인 정보를 알도록 해줄 수 있다.
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
if (!invoice) {
notFound();
}
// ...
}
먼저 notFound
함수를 통해 존재하지 않는 리소스를 요청했을 때 404 에러를 발생하게 해준다.
그 후 라우트 폴더 내에 not-found.tsx
파일을 통해 404 에러 발생 시 유저에게 보여줄 fallback UI를 정의할 수 있다.