junsobi

Menu

Close

Next Safe Action으로 안전한 서버 액션 구현하기

Next.js와 Next Safe Action을 사용하여 타입 안전하고 보안 강화를 위한 서버 액션을 구현하는 방법을 알아봅니다.

List

server

Next Safe Action으로 안전한 서버 액션 구현하기

이번 글에서는 Next.js의 서버 액션 구현을 더 안전하고 효율적으로 만들어주는 Next Safe Action 라이브러리를 사용하여 프로젝트에 적용하는 방법을 소개합니다. 특히, 실제 프로젝트 코드 기반으로 설명하며, 기존 방식과의 차이점, 그리고 이를 통해 얻을 수 있는 이점을 상세히 다룹니다.


왜 Next Safe Action인가?

Next.js에서는 클라이언트와 서버 간의 데이터 통신이 필수적입니다. 그러나 전통적인 방식은 다음과 같은 문제를 유발할 수 있습니다:

1. 보안 문제

  • CSRF 공격: 클라이언트에서 서버로 데이터를 보낼 때 요청의 출처를 확인하지 않으면 공격에 노출될 수 있습니다.
  • 데이터 검증 부족: 클라이언트 데이터가 적절히 검증되지 않으면 시스템 무결성을 해칠 수 있습니다.

2. 유지 보수 문제

  • 타입 안전성이 부족한 경우, 클라이언트-서버 간 데이터 불일치로 에러가 발생할 가능성이 높습니다.

3. 복잡한 코드

  • 요청 검증, 에러 핸들링, 데이터 전처리를 모두 수작업으로 처리해야 하는 경우 코드가 복잡해질 수 있습니다.

Next Safe Action 소개

Next Safe Action은 Next.js에서 서버 액션을 보다 안전하고 편리하게 관리하도록 도와주는 라이브러리입니다. 주요 기능은 다음과 같습니다:

  • 타입 안전성: 서버와 클라이언트 간 데이터 흐름을 타입으로 보장.
  • CSRF 방지: 기본적으로 CSRF 보호 로직 내장.
  • 코드 간소화: 반복적인 작업을 자동화하여 코드 가독성을 높임.

설치 및 기본 설정

1. 설치

npm install next-safe-action

2. 기본 설정

lib/safe-action.ts 파일을 생성하고, 다음과 같이 설정합니다:

import {
  createSafeActionClient,
  DEFAULT_SERVER_ERROR_MESSAGE
} from 'next-safe-action';
 
export class ActionError extends Error {}
 
export const actionClient = createSafeActionClient({
  handleServerError(e) {
    console.error('Failed to execute action:', e.message);
 
    if (e instanceof ActionError) {
      return e.message;
    }
 
    return DEFAULT_SERVER_ERROR_MESSAGE;
  }
});

이 설정은 서버 에러 핸들링을 간소화하고, 사용자 정의 에러를 처리할 수 있도록 도와줍니다.


프로젝트 예제 - 현재 포트폴리오 내에서

1. 서버 액션 정의

app/actions.ts 에 Contact Form 처리를 위한 액션을 정의합니다:

'use server';
import { Resend } from 'resend';
import { ContactEmail } from '@/components/emails/contact-template';
import { validateTurnstileToken } from '@/lib/turnstile';
import { actionClient, ActionError } from '@/lib/safe-action';
import { ContactActionSchema } from '@/lib/validators';
 
const EMAIL_FROM = process.env.EMAIL_FROM;
const EMAIL_TO = process.env.EMAIL_TO;
 
export const contactSubmit = actionClient
  .use(async ({ next, clientInput }) => {
    const data = clientInput as { token?: string };
 
    if (!data?.token) {
      throw new ActionError('CAPTCHA 검증에 실패했습니다.');
    }
    const res = await validateTurnstileToken(data.token);
 
    if (!res.success) {
      throw new ActionError('CAPTCHA 검증에 실패했습니다.');
    }
 
    return next();
  })
  .schema(ContactActionSchema)
  .action(async ({ parsedInput: { name, email, message } }) => {
    const resend = new Resend(process.env.RESEND_API_KEY);
 
    if (!EMAIL_FROM || !EMAIL_TO) {
      throw new Error('Email 설정이 잘못되었습니다.');
    }
 
    const { error } = await resend.emails.send({
      from: EMAIL_FROM,
      to: EMAIL_TO,
      subject: `${name}의 문의`,
      react: ContactEmail({ name, email, message })
    });
 
    if (error) throw new Error(JSON.stringify(error));
 
    return { success: '연락 주셔서 감사합니다!' };
  });

2. 클라이언트에서 서버 액션 호출

components/ContactForm.tsx 에서 서버 액션을 호출합니다:

'use client';
 
// <import 생략>
 
const ContactForm = () => {
  const form = useForm({
    resolver: zodResolver(ContactFormSchema),
    defaultValues: {
      name: '',
      email: '',
      message: ''
    }
  });
 
  const { execute, result, status } = useAction(contactSubmit);
 
  const onSubmit = async (values: any) => {
    execute(values);
  };
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register('name')} placeholder="Name" />
      <input {...form.register('email')} placeholder="Email" />
      <textarea {...form.register('message')} placeholder="Message" />
      <button type="submit" disabled={status === 'executing'}>
        Submit
      </button>
    </form>
  );
};
 
export default ContactForm;

기존 방식과의 비교

1. 기존 방식 : Fetch API 사용

fetch('/api/contact', {
  method: 'POST',
  body: JSON.stringify(data),
  headers: { 'Content-Type': 'application/json' }
})
  .then((res) => res.json())
  .then((data) => console.log(data));

2. 기존 방식 : Axios

axios
  .post('/api/contact', data)
  .then((res) => console.log(res.data))
  .catch((err) => console.error(err));
  • 단점 :
    • 타입 안정성이 부족.
    • CSRF 공격에 취약.
    • 에러 핸들링이 복잡.

3. Next Safe Action 사용

const { execute, result } = useAction(contactSubmit);
execute({ name: 'John', email: 'john@example.com', message: 'Hello!' });
  • 장점:
    • 타입 안전성.
    • 에러 핸들링 내장.
    • 코드 간결화.

clap


마치며

Next Safe Action은 보안과 생산성을 동시에 만족시키는 Next.js 프로젝트의 필수 도구입니다. 특히 서버 액션을 자주 사용하는 프로젝트에서 코드 품질을 크게 향상시킬 수 있습니다.