Next.js

랜덤 팔레트 생성기로 공부해본 Next.js 서버 액션 (Server Actions)

Taeeun Kim
2025년 6월 11일

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

서버 액션의 장점:

  1. 서버에서 실행되어서 db 연결, 인증, 환경 변수 등이 클라이언트에 노출되지 않아 보안 측면에서 더 안전하다.
  2. API 엔드포인트를 따로 설정하지 않아도 된다.
  3. 점진적 향상 (Progressive Enhancement) 이 지원되어 JS 가 실행되지 않는 환경에서도 동작한다
  4. 타입 안정성이 높다.
    1. fetch 방식은 JSON 파싱 후 수동 타입 체크가 필요했지만 서버 액션은 함수의 매개변수와 반환값에 타입 추론이 적용되며 zod를 사용해 타입을 보장한다.
  5. 서버 액션 후 업데이트 된 데이터가 반영된 UI 를 캐싱 무효화 (revalidate) 를 이용해 응답할 수 있다.

서버 액션: 서버 컴포넌트 vs 클라이언트 컴포넌트

  1. 서버 컴포넌트에서는 아래와 같이 함수 안에서 '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>
      );
    }
  2. 서버 컴포넌트에서는 아래와 같이 함수 안에서 '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>
      );
    }
  3. 클라이언트 컴포넌트에서는 아래와 같이 구분된 파일에 'use server' 가 위에 선언된 파일을 import 해서 서버 액션을 사용할 수 있다.

    // app/actions.ts
    'use server';
     
    export async function savePaletteAction() // 서버 액션 로직
    'use client';
     
    import { savePaletteAction, getPalettes } from '@/app/actions';

서버 액션을 활성화 하는 방법은 두 가지가 있다:

  1. 서버/클라이언트 컴포넌트에서 <form> 의 action prop 을 통해 서버 액션을 직접 전달한다.

    1. 랜덤 팔래트 적용 예시: 생성된 팔레트 저장

      {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>
      )}
    2. 서버 컴포넌트에서 자동으로 캐싱되기 때문에 서버 액션 후에는 revalidate 해야 UI에 새로운 데이터가 반영된다.

  2. 클라이언트 측에서 이벤트 헨들러를 통한 활성화

    1. 랜덤 팔래트 적용 예시: 팔레트 삭제

      <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);
      };
      • 이 방식은 점진적 향상 지원이 되지 않는다
  3. 랜덤 팔래트 적용 예시: 팔레트 삭제

    <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);
    };
    • 이 방식은 점진적 향상 지원이 되지 않는다

추가 인사이트

pending state ui 구현

폼 테그를 사용하면 지원하는 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 를 활용한 폼 유효성 검사

요청의 데이터 유효성을 검사하기 위해 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/

Screenshot_2025-06-11_at_6.30.58_PM.png

관련 깃허브 링크: https://github.com/esunn0412/random-color-palette


참고