Mutating Data(1)

이전 장에서는 URL 검색 매개변수와 Next.js API를 사용하여 검색 및 페이지네이션 기능을 구현했었습니다. 이번에는 송장 페이지(Invoices page)에 송장 생성, 수정 및 삭제 기능을 추가하는 작업을 진행하려 합니다.

이번에 소개드릴 내용은 분량이 길기 때문에, 해당 챕터는 2개의 포스트로 분리하여 소개드리겠습니다!

1. Server Action이란?

React Server Actions는 비동기 코드를 서버에서 직접 실행할 수 있게 해주는 기능입니다. 이를 통해 데이터 변형을 위한 API 엔드포인트를 생성할 필요가 없어집니다.

API 엔드포인트: 애플리케이션 프로그래밍 인터페이스(API)에서 특정한 요청을 처리하는 URL

대신 서버에서 실행되는 비동기 함수를 작성하고, 이를 클라이언트 또는 서버 컴포넌트에서 호출할 수 있습니다.

보안 측면에서의 장점

웹 애플리케이션은 다양한 위협에 취약할 수 있기 때문에 보안이 최우선입니다. Server Actions는 강력한 보안 솔루션을 제공하여 여러 유형의 공격으로부터 데이터를 보호하고, 권한 있는 접근만 허용합니다.

Server Actions가 사용하는 보안 기술:

  • POST 요청
  • 암호화된 클로저
  • 엄격한 입력 검사
  • 오류 메시지 해싱
  • 호스트 제한

이러한 기술들을 통해 앱의 안전성을 크게 향상시킬 수 있습니다.

2. fetch와 Server Action의 차이점

데이터 처리 방식

fetch 함수 사용 방식

  • 목적: 주로 데이터 가져오기(Read)
  • 특징: 단방향 데이터 처리
  • 실행 위치: 서버 컴포넌트에서 실행되지만, 데이터 변형은 클라이언트에서 별도 API 요청으로 처리
export default async function Page() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();

  return (
    <div>
      {/* 데이터를 렌더링 */}
    </div>
  );
}

Server Actions 방식

  • 목적: 데이터 변형(Create, Update, Delete)
  • 특징: 양방향 데이터 처리 가능
  • 실행 위치: 서버에서 실행되며, 클라이언트나 서버 컴포넌트에서 호출
// 서버 컴포넌트 내에서 서버 액션 정의
export default function Page() {
  // 액션 함수
  async function create(formData: FormData) {
    'use server';

    // 데이터 변형 로직
  }

  // 폼을 사용하여 액션 호출
  return <form action={create}>...</form>;
}

보안 측면 비교

구분fetch 함수Server Actions
API 노출별도 API 엔드포인트 필요 (외부 노출)API 엔드포인트 외부 노출 없음
인증/권한추가적인 인증 및 권한 부여 필요서버에서 직접 처리로 더 안전
통신추가적인 클라이언트-서버 통신통신 최소화

요약

  1. fetch 함수: 주로 데이터 읽기 용도, 별도 API 필요, 추가 보안 작업 필요
  2. Server Actions: 데이터 읽기/변형 모두 처리, 강화된 보안, 효율적인 통신

Server Actions는 보안과 효율성 측면에서 더 나은 솔루션을 제공하며, 데이터 변형 작업을 더 간편하게 수행할 수 있게 해줍니다.

3. Server Action과 Form 활용

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

점진적 향상(Progressive Enhancement)

서버 컴포넌트 내에서 Server Action을 호출하는 가장 큰 장점 중 하나는 점진적 향상입니다.

점진적 향상이란?
웹 애플리케이션의 기능이 점진적으로 개선될 수 있음을 의미합니다. 클라이언트의 환경이나 기능에 상관없이 기본적인 동작이 보장되고, 추가적인 기능이 가능할 때 이를 활용할 수 있다는 뜻입니다.

점진적 향상의 원칙:
1. 기본 기능 보장: 최소한의 기능을 모든 사용자가 이용할 수 있도록 보장
2. 추가 기능 제공: 사용자의 브라우저나 네트워크 상태가 더 좋은 경우, 추가 기능으로 사용자 경험 개선

Server Action과 점진적 향상 예시

// 서버 컴포넌트 내에서 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>
  );
}
  1. 기본 기능 보장: JavaScript가 비활성화되어 있어도 Form이 작동합니다. 브라우저는 Form 데이터를 서버로 POST 요청을 통해 전송하고, 서버에서 데이터를 처리합니다.

  2. 추가 기능 제공: JavaScript가 활성화된 환경에서는 Form 제출 후 페이지를 다시 로드하지 않고도 결과를 표시할 수 있습니다.

Next.js 캐싱과의 연동

Server Actions는 Next.js의 캐싱과도 긴밀하게 연관되어 있습니다. Server Action을 통해 Form이 제출되면 데이터를 변형하는 것뿐만 아니라 revalidatePathrevalidateTag 같은 API를 사용하여 관련 캐시를 재검증할 수도 있습니다.

4. Invoice 생성 구현하기

4-1. Invoice 생성 단계

Invoice를 생성하는 전체 과정은 다음과 같습니다:

  1. 사용자의 입력을 수집하기 위한 양식 생성
  2. 서버 작업을 생성하고 양식에서 호출
  3. 서버 작업 내부에서 formData 객체로부터 데이터 추출
  4. 데이터베이스에 삽입할 데이터를 검증하고 준비
  5. 데이터를 삽입하고 오류 처리
  6. 캐시를 다시 검증하고 사용자를 송장 페이지로 리디렉션

4-2. 새로운 라우트와 폼 생성

먼저 /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> 컴포넌트는 고객 선택 드롭다운, 금액 입력 필드, 상태 선택 라디오 버튼, 제출 버튼으로 구성

4-3. 서버 액션 생성

폼이 제출될 때 호출될 서버 액션을 생성합니다. 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 엔드포인트를 만들 필요가 없습니다.

4-4. Form 데이터에서 데이터 추출

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 - 콘솔 출력 결과]

4-5. 데이터 검증 및 준비

폼 데이터를 데이터베이스로 보내기 전에 사용자 입력값이 올바른 형식과 타입인지 확인해야 합니다.

현재 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

4-6. 데이터베이스에 데이터 삽입

준비된 값으로 데이터베이스에 새로운 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})
  `;
}

4-7. 캐시 재검증 및 리디렉션

캐시 재검증

Next.js는 클라이언트 사이드에서 사용자의 브라우저에 route segment를 일정 시간 동안 저장하는 라우터 캐시를 가지고 있습니다.

라우터 캐시
사용자가 웹 페이지를 이동할 때, 이전에 방문한 페이지나 데이터의 정보를 브라우저에 저장하는 기능입니다. 이를 통해 웹 애플리케이션이 더 빠르게 반응하고 부드럽게 작동할 수 있습니다.

사전 로딩(Prefetching)
사용자가 현재 보고 있는 페이지와 다른 페이지를 미리 로드해 놓는 기술입니다. 사용자가 특정 페이지로 이동하기 전에 그 페이지의 리소스를 미리 받아오는 것입니다.

라우터 캐시와 사전 로딩의 장점:

  • 사용자가 route 간에 빠르게 이동 가능
  • 서버에 대한 요청 수 감소
  • 페이지 전환의 매끄러운 진행

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 경로로 리디렉션되어 업데이트된 목록을 확인할 수 있습니다.

profile
프론트엔드 입문 개발자입니다.

0개의 댓글