Next.js의 Server Actions 는 클라이언트에서 서버로 데이터를 안전하게 전송하고 처리할 수 있는 비동기 함수이며 Server Function 에 속한다. 기존에는 API 라우트를 별도로 만들어야 했지만 서버 액션을 사용하면 직접 서버 코드를 client 측에서 내부 POST
요청으로 부를 수 있어 편리한 기능이다.
이번 프로젝트에서는 랜덤 색상 팔레트 생성기를 만들면서 서버 액션의 개념을 공부했다.
💡 주요 기능
- 랜덤 색상 생성
- 생성한 팔레트 저장
- 저장된 팔레트 리스트 렌더링/삭제
이번 기회에 shadcn/ui 와 강의 들으며 배웠던 라이브러리들을 적용해 볼 수 있었다.
서버 액션은 클라이언트 컴포넌트에서 직접 서버 측 로직을 실행할 수 있는 Next.js 기능이다.
기존에 API 라우트를 설정했어야 한다면
// pages/api/save-palette.ts
export default function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
// 데이터 처리
res.status(200).json({ success: true });
}
서버 액션으로는 API 없이 바로 서버 함수를 호출 가능하다.
// app/actions.ts
'use server';
export async function savePalette(data: FormData) {
// 직접 서버 로직 실행
return { success: true };
}
zod
를 사용해 타입을 보장한다.서버 컴포넌트에서는 아래와 같이 함수 안에서 'use server'
라고 명시해 줄 수 있다.
// app/page.tsx (Server Component)
export default function Home() {
// 인라인 서버 액션
async function handleSave(formData: FormData) {
'use server';
// 데이터 처리
}
return (
<form action={handleSave}>
<input name="palette" />
<button type="submit">저장</button>
</form>
);
}
서버 컴포넌트에서는 아래와 같이 함수 안에서 'use server'
라고 명시해 줄 수 있다.
// app/page.tsx (Server Component)
export default function Home() {
// 인라인 서버 액션
async function handleSave(formData: FormData) {
'use server';
// 데이터 처리
}
return (
<form action={handleSave}>
<input name="palette" />
<button type="submit">저장</button>
</form>
);
}
클라이언트 컴포넌트에서는 아래와 같이 구분된 파일에 'use server'
가 위에 선언된 파일을 import 해서 서버 액션을 사용할 수 있다.
// app/actions.ts
'use server';
export async function savePaletteAction() // 서버 액션 로직
'use client';
import { savePaletteAction, getPalettes } from '@/app/actions';
서버/클라이언트 컴포넌트에서 <form>
의 action prop 을 통해 서버 액션을 직접 전달한다.
랜덤 팔래트 적용 예시: 생성된 팔레트 저장
{palette.length > 0 && (
<form action={formAction}> // 이때 자동으로 FormData 객체가 생성된다
<input type="hidden" name="id" value={generateContentHash(palette)} />
<input type="hidden" name="colors" value={JSON.stringify(palette)} />
<Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Palette'}
</Button>
</form>
)}
서버 컴포넌트에서 자동으로 캐싱되기 때문에 서버 액션 후에는 revalidate
해야 UI에 새로운 데이터가 반영된다.
클라이언트 측에서 이벤트 헨들러를 통한 활성화
랜덤 팔래트 적용 예시: 팔레트 삭제
<Button
onClick={() => handleDelete(palette.id)}
variant="destructive"
>
<X />
</Button>
// 핸들러 함수
const handleDelete = async (id: string) => {
const response = await deletePalette(id);
if (!response.success) {
toast.error(response.message);
return;
}
getPalettes().then(setSavedPalettes);
toast.success(response.message);
};
랜덤 팔래트 적용 예시: 팔레트 삭제
<Button
onClick={() => handleDelete(palette.id)}
variant="destructive"
>
<X />
</Button>
// 핸들러 함수
const handleDelete = async (id: string) => {
const response = await deletePalette(id);
if (!response.success) {
toast.error(response.message);
return;
}
getPalettes().then(setSavedPalettes);
toast.success(response.message);
};
폼 테그를 사용하면 지원하는 react 의 useActionState
훅이 있는데, pending 상태를 보여줘 같이 사용하기에 적합하다.
// app/page.tsx
'use client' // react 훅은 클라이언트 컴포넌트에서만 활용 가능하다.
const [state, formAction, isPending] = useActionState(savePaletteAction, {
success: false,
message: '',
});
// app/action.tsx
interface ActionState {
success: boolean;
message: string;
errors?: z.ZodIssue[]; // 오류 메시지
}
// Save palette to in-memory storage
export async function savePaletteAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
useActionState
는 서버 액션 함수와 initialState
, 즉 처음 마운트 된 상태를 뜻하며 서버 액션 함수의 반환값과 타입이 같다.
이때 FormAction
에서 반환하는 state
를 따로 정의해서 오류 메시지를 표시할 수 있다.
현재 프로젝트에서는 toast
를 사용하기 위해 useEffect
안에 로직을 구현했다.
// app/page.tsx
useEffect(() => {
if (state?.success) {
getPalettes().then(setSavedPalettes);
toast.success(state.message);
} else if (state.message) {
toast.error(state?.message || 'An error occurred');
state?.errors?.forEach((error) => {
toast.error(`${error.path.join('.')}: ${error.message}`);
});
}
}, [state]);
요청의 데이터 유효성을 검사하기 위해 zod 라이브러리를 사용할 수 있다.
// app/actions.tsx
import { z } from 'zod';
// 생상 유효성
const ColorSchema = z.object({
hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid HEX color'),
});
// 팔레트 유효성
const PaletteSchema = z.object({
id: z.string(),
colors: z.array(ColorSchema).min(1).max(5),
});
export async function savePaletteAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
// Validate palette
const rawData = {
id: formData.get('id') as string,
colors: JSON.parse(formData.get('colors') as string), // JSON 파싱
};
await new Promise((resolve) => setTimeout(resolve, 3000)); // 딜레이
const result = PaletteSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
message: 'Validation failed',
errors: result.error.issues,
};
}
작은 프로젝트였지만 배포까지 해보면서 지금까지 배운 것들을 복습할 수 있는 좋은 기회였다.
프로젝트 : https://random-color-palette-iota.vercel.app/
관련 깃허브 링크: https://github.com/esunn0412/random-color-palette