form의 submit 제어는 기존의 리액트와 같이 form에 submit 속성을 추가하고 함수를 정의해 form이 제출될 때 실행하는 방식을 사용할 수도 있지만, NextJS의 경우 백엔드와 프론트엔드를 모두 갖고 있는 풀스택 어플리케이션으로 이미 일종의 백엔드 서버에 있다. 따라서 form이 있는 컴포넌트에 직접 함수를 만드는 방식을 사용할 수 있다.
// app/meals/share/page.js
export default function ShareMealPage() {
// form의 input태그에 의해 수집된 데이터가 formData 객체로 수집
async function shareMeal(formData) {
'use server'
// formData에서 meal 데이터를 추출
const meal = {
// formData의 get() 메서드는 input 필드에 입력된 값을 얻기 위해 사용: name으로 구분
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
return ()
}
함수 안에 use server
지시어를 사용하면 server action을 생성하는데 이는 오직 서버에서만 실행될 수 있도록 보장하는 기능이다. 함수의 경우 함수 안에 use server
를 입력해줌으로써, 이 함수가 서버에서 동작한다고 명시해주어야 한다. 이와 같은 server action을 가지고 form의 action 속성에 값으로 할당할 수 있다.
form이 제출되면 NextJS가 자동으로 요청을 생성하여 웹사이트를 제공하는 NextJS 서버로 보내고, 그렇게 함수가 실행되어 서버측에서 form의 제출을 처리할 수 있다.
함수 안에 use server
를 사용하여 server action을 생성하는 방법은 해당 컴포넌트가 클라이언트 컴포넌트가 아닐 때만 동작한다. 따라서 해당 컴포넌트 내 어딘가에서 클라이언트용 기능을 사용하고자 한다면, 해당 코드는 분리해주는 것이 좋다. 컴포넌트를 분리한 뒤,파일 맨 위에 use server
지시어를 사용하면 해당 파일에서 정의 하는 모든 함수가 server action이 된다.
// lib/actions
'use server'
// form의 input태그에 의해 수집된 데이터가 formData 객체로 수집
export async function shareMeal(formData) {
// formData에서 meal 데이터를 추출
const meal = {
// formData의 get() 메서드는 input 필드에 입력된 값을 얻기 위해 사용: name으로 구분
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
}
npm install sluguify xss
meal을 데이터베이스에 저장하기 위해 우선, slug를 생성해야 한다. slug를 생성하기 위해 slugify
라는 패키지와 XSS 공격을 막기 위한 xss
패키지를 추가적으로 설치해준다.
사용자가 만든 instructions을 HTML 형태로 출력하기에 크로르 사이트 스크립트 공격에 취약하고 이를 방어할 필요가 있다.
// lib/meals.js
import slugify from "slugify";
import xss from "xss";
export function saveMeal(meal) {
// slug를 생성하여 meal.slug 속성에 추가
meal.slug = slugify(meal.title, { lower: true });
// meal.instructions을 검열하여 덮어 띄워줌
meal.instructions = xss(meal.instructions);
}
이미지는 데이터베이스가 아니라 파일 시스템에 저장되는 것이 성능에 좋다. 따라서 업로드된 파일을 public
폴더에 저장하고자 한다.
// lib/meals.js
import xss from "xss";
import fs from "node:fs";
import slugify from "slugify";
// 이미지 파일을 저장하고 meal 데이터를 데이터베이스에 저장하는 함수
export async function saveMeal(meal) {
// slug를 생성하여 meal.slug 속성에 추가
meal.slug = slugify(meal.title, { lower: true });
// meal.instructions을 검열하여 덮어 띄워줌
meal.instructions = xss(meal.instructions);
// 이미지의 확장자를 가져옴
const extension = meal.image.name.split(".").pop();
// slug를 이용해 새로운 파일 이름을 만들어 줌
const fileName = `${meal.slug}.${extension}`;
// createWriteStream()을 통해 stream 객체 생성
const stream = fs.createWriteStream(`public/images/${fileName}`);
// 이미지를 buffer로 변환: arrayBuffer()는 promise를 반환하기 때문에 await 필요
const bufferedImage = await meal.image.arrayBuffer();
stream.write(Buffer.from(bufferedImage), (error) => {
if (error) {
throw new Error("Saving image failed!");
}
});
// meal 객체에 저장된 image를 이미지 경로로 덮어 띄워줌: 이때 public 세그먼트는 지워야 하는데, 모든 이미지에 관한 요청은 자동적으로 public 폴더로 보내지기 때문
meal.image = `/images/${fileName}`;
// 전체 데이터를 데이터베이스에 저장: 순서가 매우 중요!
db.prepare(
`
INSERT INTO meals
(title, summary, instructions, creator, creator_email, image, slug)
VALUES(
@title,
@summary,
@instructions,
@creator,
@creator_email,
@image,
@slug
)
`
).run(meal);
}
Node JS가 제공하는 fs
모듈을 통해 파일 시스템을 이용할 수 있다. fs 모듈을 통해 createWriteStream()
함수를 실행할 수 있는데, 이는 파일을 데이터로 쓸 수 있도록 해주는 stream을 생성한다. createWriteStream()
경로를 필요로 하고 stream 객체를 반환한다.
생성한 stream 객체를 변수에 담아주고, 이 stream에 write()
함수를 쓸 수 있다. write()
함수의 첫 번째 인수는 저장할 파일이고, 두 번째 인수는 쓰기를 마치면 실행할 함수이다. 따라서 두 번째 인수로 실행할 함수는 error를 인수로 받고 error가 있을 경우 오류를 발생시킨다.
// lib/actions.js
"use server";
import { saveMeal } from "./meals";
export async function shareMeal(prevState, formData) {
const meal = {
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
// saveMeal을 호출하고 인자로 meal 객체를 전달
await saveMeal(meal);
// 더 나은 사용자 경험을 위해 meal 데이터 제출 후 리다이렉트
redirect("/meals");
}
데이터가 제출되는 동안 정상적으로 처리되고 있음을 보여주는 피드백을 제공한다면 더 나은 사용자 경험을 제공할 수 있다. 따라서 제출 버튼을 누르고 나면 제출되는 동안 제출 버튼을 바꾸어 요청이 진행중인 것을 알려주고자 한다.
// components/meals/meals-form-submit.js
"use client";
import { useFormStatus } from "react-dom";
export default function MealsFormSubmit() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? "Submiiting..." : "Share Meal"}
</button>
);
}
// app/meals/share/page.js
<p className={classes.actions}>
<MealsFormSubmit />
</p>
// app/melas/share/page.js
export default function ShareMealPage() {
const [state, formAction] = useFormState(shareMeal, { message: null });
return (
<>
<main className={classes.main}>
<form className={classes.form} action={formAction}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker label="Your image" name="image" />
{state.message && <p>{state.message}</p>}
<p className={classes.actions}>
<MealsFormSubmit /> // 일반 버튼을 MealsFormSubmit로 변경
</p>
</form>
</main>
</>
);
}
이처럼 양식 제출 상태를 관리하기 위해서 react-dom
에서 제공하는 useFormStatus
라는 훅을 사용할 수 있다. 이 훅은 클라이언트 컴포넌트에서 동작하는데, 이는 진행 중인 요청에 따라 클라이언트 측의 UI를 변경할 것이기 때문이다.
useFormStatus
을 통해 status 객체를 받을 수 있는데, form 안에 있을 때만 form의 status를 받을 수 있다. 또한, status 객체는 pending
속성을 가지고 있는데, pending
속성은 요청이 진행중이면 true, 아니면 false인 속성이다.
보다 나은 사용자 경험을 위해 해야할 것 중 하나는 받아 오는 값들을 검사하는 것이다. 따라서 클라이언트 측 뿐만 아니라 서버 측에서도 유효성 검사를 할 필요가 있다. 유효성 검사를 하는 방법을 여러가지가 있지만 현재 프로젝트에서는 간단한 유효성 검사만 해보도록 하겠다.
// lib/action.js
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { saveMeal } from "./meals";
// 유효성 검사 (빈 문자열)
function isInvalidText(text) {
return !text || text.trim() === "";
}
export async function shareMeal(formData) {
const meal = {
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
// 입력값에 대한 유효성 검사
if (
isInvalidText(meal.title) ||
isInvalidText(meal.summary) ||
isInvalidText(meal.instructions) ||
isInvalidText(meal.creator) ||
isInvalidText(meal.creator_email) ||
!meal.creator_email.includes("@") ||
!meal.image ||
meal.image.size === 0
) {
throw new Error('Invalid Input');
}
await saveMeal(meal);
revalidatePath("/meals");
redirect("/meals");
}
server action에서 리다이렉트나 에러를 발생시킬 수도 있지만, 어떤 값 정확히 말하면 reponse 객체를 반환할 수 있다.
// lib/action.js
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { saveMeal } from "./meals";
// 유효성 검사 (빈 문자열)
function isInvalidText(text) {
return !text || text.trim() === "";
}
// shareMeal은 초기 값과 제출된 데이터 두 가지 인수를 받아야 함
export async function shareMeal(prevState, formData) {
const meal = {
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
// 입력값에 대한 유효성 검사
if (
isInvalidText(meal.title) ||
isInvalidText(meal.summary) ||
isInvalidText(meal.instructions) ||
isInvalidText(meal.creator) ||
isInvalidText(meal.creator_email) ||
!meal.creator_email.includes("@") ||
!meal.image ||
meal.image.size === 0
) { // reponse 객체를 반환
return {
message: "Invalid input",
};
}
await saveMeal(meal);
revalidatePath("/meals");
redirect("/meals");
}
해당 객체는 message를 키와 Invalid input이라는 값을 가진다. 이 객체의 형태는 제한이 없지만 직렬화가 가능한 객체이어야만 한다. 왜냐하면 클라이언트로 보내어지는 동안 손실될 수 있기 때문이다.
이렇게 반환한 response 객체는 react-dom에서 제공 하는 다른 훅인 useFormState
를 이용해 사용할 수 있다. useFormState
는 server actions을 통해 제출될 form을 사용하는 페이지나 컴포넌트의 state를 관리한다.
useFormState
는 두 개의 인수를 필요로 하는데, 첫 번째 인수는 form이 제출될 때 동작하는 실제 server action이고, 두 번째 인수는 컴포넌트의 초기 상태로 action이 동작하기 전이나 response가 돌아오기 전에 useFormState
가 반환할 초기 값을 의미한다.
// app/melas/share/page.js
"use client";
import { useFormState } from "react-dom";
import { shareMeal } from "@/lib/action";
import ImagePicker from "@/components/meals/image-picker";
import MealsFormSubmit from "@/components/meals/meals-form-submit";
import classes from "./page.module.css";
export default function ShareMealPage() {
const [state, formAction] = useFormState(shareMeal, { message: null });
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
// form의 action 속성 값을 formAction으로 설정
<form className={classes.form} action={formAction}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker label="Your image" name="image" />
// state 상태에 따른 메시지 출력
{state.message && <p>{state.message}</p>}
<p className={classes.actions}>
<MealsFormSubmit />
</p>
</form>
</main>
</>
);
}
그러면 useFormState
는 두 요소가 든 배열을 반환하는데, 첫째는 현재 state = response이고 두 번째로 formAction을 받는 데 이는 form의 action 속성에 값으로 설정한다. 이때, action의 값을 shareMeal로 설정하는 대신에 action의 값으로 useFormState
에게 받은 formAction을 설정한다.
이렇게 설정되어야 useFormState
가 이 컴포넌트에 접근해서 state를 관리할 수 있으며, 이 상태는 shareMeal이라는 server action의 실행과 응답에 따라 변경된다. 이제 이 state는 null 값이 있는 객체이거나, shareMeal로부터 받은 응답 = "Invalid input"일 것이다. 이제 이 state를 컴포넌트에 데이터를 출력하는 데 사용할 수 있다.
마지막으로 useFormState
는 결국 클라이언트를 수정하려 하기 때문에 클라이언트 컴포넌트로 실행되어야 한다.