Next.js Server Action์™€ React useActionState ์•Œ์•„๋ณด๊ธฐ

๐Ÿš€ Next.js 15v์˜ Server Action

1. Server Action์ด๋ž€?

Next.js 15๋ฒ„์ „์— ์ƒˆ๋กญ๊ฒŒ ๋„์ž…๋œ Server Action์€ ์„œ๋ฒ„์—์„œ ์ง์ ‘ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜๋กœ, ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„ ๊ฐ„์˜ ๋ณต์žกํ•œ ๋ฐ์ดํ„ฐ ์š”์ฒญ ํ๋ฆ„์„ ๋‹จ์ˆœํ™”ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•  ์ž‘์—…๋“ค์„ ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ์—์„œ ๋ถ„๋ฆฌํ•˜๊ณ , ํด๋ผ์ด์–ธํŠธ์—์„œ ์„œ๋ฒ„ ํ•จ์ˆ˜ ํ˜ธ์ถœ์„ ๊ฐ„ํŽธํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

2. Server Action์ด ํ•ด๊ฒฐํ•˜๋Š” ๋ฌธ์ œ๋“ค

  • ๋ณต์žกํ•œ ๋ฐ์ดํ„ฐ ์š”์ฒญ ๋กœ์ง: ๊ธฐ์กด์—๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ fetchํ•˜๊ฑฐ๋‚˜ ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•˜๊ธฐ ์œ„ํ•ด ์—ฌ๋Ÿฌ API ํ˜ธ์ถœ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ํด๋ผ์ด์–ธํŠธ-์„œ๋ฒ„ ๋ถ„๋ฆฌ: ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ์—์„œ ์„œ๋ฒ„ ๋กœ์ง์„ ๋ถ„๋ฆฌํ•จ์œผ๋กœ์จ ์ฝ”๋“œ์˜ ์œ ์ง€๋ณด์ˆ˜์„ฑ๊ณผ ๊ฐ€๋…์„ฑ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค.
  • ์ง์ ‘์ ์ธ ์„œ๋ฒ„ ์‹คํ–‰: Server Action์„ ํ†ตํ•ด ์„œ๋ฒ„์—์„œ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๋กœ์ง์„ ํด๋ผ์ด์–ธํŠธ์—์„œ ๊ฐ„๋‹จํžˆ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ๋ฒˆ๊ฑฐ๋กœ์šด REST API๋‚˜ GraphQL ์„ค์ •์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

REST API ์„ค์ •์˜ ๋ฒˆ๊ฑฐ๋กœ์›€ ์˜ˆ์‹œ

// api/handler.js
import db from './db';

export default async function handler(req, res) {
    if (req.method === 'POST') {
        const { userId } = req.body;
        const user = await db.users.findOne({ id: userId });
        res.status(200).json(user);
    } else {
        res.status(405).json({ error: 'Method not allowed' });
    }
}

// ํด๋ผ์ด์–ธํŠธ์—์„œ ํ˜ธ์ถœ ์ฝ”๋“œ
import axios from 'axios';

async function fetchUser(userId) {
    const response = await axios.post('/api/handler', { userId });
    return response.data;
}

export default function UserComponent() {
    const handleFetch = async () => {
        const user = await fetchUser('123');
        console.log(user);
    };

    return <button onClick={handleFetch}>Fetch User</button>;
}

Server Action ์‚ฌ์šฉ ์‹œ

'use server';

export async function fetchUser(userId) {
    const user = await db.users.findOne({ id: userId });
    return user;
}

'use client';
import { fetchUser } from './actions/fetchUser';

export default function UserComponent() {
    const handleFetch = async () => {
        const user = await fetchUser('123');
        console.log(user);
    };

    return <button onClick={handleFetch}>Fetch User</button>;
}

์ด์™€ ๊ฐ™์ด Server Action์„ ์‚ฌ์šฉํ•˜๋ฉด API ํ•ธ๋“ค๋Ÿฌ ์ž‘์„ฑ๊ณผ ํด๋ผ์ด์–ธํŠธ์—์„œ์˜ ๋ณ„๋„ HTTP ์š”์ฒญ ์ฝ”๋“œ ์ž‘์„ฑ์ด ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค.

3. Server Action์˜ ์‚ฌ์šฉ๋ฒ•

Server Action์€ Next.js์˜ ํŠน์ˆ˜ํ•œ ํŒŒ์ผ ๊ตฌ์กฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด

// app/actions/myAction.js
'use server';

export async function myAction(data) {
    // ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋  ๋กœ์ง
    console.log('Received data:', data);
    return { success: true };
}

ํด๋ผ์ด์–ธํŠธ์—์„œ ์ด๋ฅผ ํ˜ธ์ถœํ•˜๋ ค๋ฉด

'use client';
import { myAction } from './actions/myAction';

export default function MyComponent() {
    const handleAction = async () => {
        const result = await myAction({ key: 'value' });
        console.log(result);
    };

    return <button onClick={handleAction}>Call Action</button>;
}

โš›๏ธ React 19v์˜ useActionState

1. useActionState๋ž€?

React 19๋ฒ„์ „์— ์ถ”๊ฐ€๋œ useActionState๋Š” Server Action๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜๋ฉฐ, Action์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” React ํ›…์ž…๋‹ˆ๋‹ค. ์ฃผ๋กœ Action์˜ ์š”์ฒญ ์ƒํƒœ(pending, success, error)๋ฅผ ์ถ”์ ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

2. useActionState๊ฐ€ ํ•ด๊ฒฐํ•˜๋Š” ๋ฌธ์ œ๋“ค

  • ๋น„๋™๊ธฐ ์ƒํƒœ ๊ด€๋ฆฌ: ๊ธฐ์กด์—๋Š” Action ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด useState์™€ useEffect๋ฅผ ์กฐํ•ฉํ•ด ์ž‘์„ฑํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. useActionState๋Š” ์ด๋ฅผ ๋‹จ์ˆœํ™”ํ•ฉ๋‹ˆ๋‹ค.
  • ๋กœ๋”ฉ ์ƒํƒœ ๊ด€๋ฆฌ: ์š”์ฒญ ์ค‘ ์ƒํƒœ๋ฅผ ์‰ฝ๊ฒŒ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ์–ด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ฐœ์„ ํ•ฉ๋‹ˆ๋‹ค.
  • ์ค‘๋ณต ๋กœ์ง ์ œ๊ฑฐ: ์š”์ฒญ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ๋ณ„๋„์˜ ๋กœ์ง์ด ํ•„์š” ์—†์œผ๋ฏ€๋กœ ์ฝ”๋“œ๊ฐ€ ๊ฐ„๊ฒฐํ•ด์ง‘๋‹ˆ๋‹ค.

3. useActionState์˜ ์‚ฌ์šฉ๋ฒ•

import { useActionState } from 'react';
import { myAction } from './actions/myAction';

export default function MyComponent() {
    const [isPending, action] = useActionState(myAction);

    const handleClick = async () => {
        const result = await action({ key: 'value' });
        console.log(result);
    };

    return (
        <div>
            <button onClick={handleClick} disabled={isPending}>
                {isPending ? 'Loading...' 'Call Action'}
            </button>
        </div>
    );
}

๐Ÿ› ๏ธ Server Action๊ณผ useActionState ์‚ฌ์šฉ์˜ ์˜ˆ์‹œ

์ž์„ธํ•œ ์ฝ”๋“œ๋ฅผ ํ™•์ธํ•˜์‹œ๊ณ  ์‹ถ์œผ์‹œ๋ฉด ์•„๋ž˜ ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”!

https://github.com/lurgi/action-state-prac

1. Server Action ํ•จ์ˆ˜ ๋งŒ๋“ค๊ธฐ

"use server";

// ...
export async function login(currentState: LoginReturn, formData: FormData): Promise<LoginReturn> {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (email !== "lurgi@gmail.com" || password !== "qwer1234") {
    return {
      email,
      password,
      message: "fail",
      isError: true,
    };
  }

  return {
    email,
    password,
    message: "success",
    isError: false,
  };
}

2. useActionState๋กœ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ

"use client";

import { useActionState, useEffect, useState } from "react";
import { login } from "./action/login";

export default function Home() {
  const [state, formAction, isPending] = useActionState(login, {
    email: "",
    password: "",
    message: undefined,
    isError: false,
  });

  const { email, password, message, isError } = state;

  return (
    <div className="grid place-items-center h-screen">
      <form className="w-96" action={formAction}>
        <label className="form-control w-full">
          <div className="label">
            <span className="label-text">Email</span>
          </div>
          <input
            name="email"
            type="email"
            placeholder="Email"
            className="input input-bordered w-full"
            defaultValue={email}
          />
        </label>
        <label className="form-control w-full">
          <div className="label">
            <span className="label-text">Password</span>
          </div>
          <input
            name="password"
            type="password"
            placeholder="Password"
            className="input input-bordered w-full"
            defaultValue={password}
          />
        </label>
        <div className="label">
          <button className="btn btn-primary w-full" disabled={isPending}>
            Login
          </button>
        </div>
      </form>
    </div>
  );
}

3. ๋กœ๋”ฉ ์ƒํƒœ(isPending) ์ปจํŠธ๋กค

isPending์„ ํ™œ์šฉํ•ด ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•˜๊ณ , ์‚ฌ์šฉ์ž๊ฐ€ ์š”์ฒญ์ด ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฌ๋„๋ก ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๋” ๋‚˜์€ UX๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์•„๋ž˜๋Š” isPending์„ ์ด์šฉํ•˜์—ฌ Toast ๋ฉ”์„ธ์ง€๋ฅผ ์ปจํŠธ๋กค ํ•˜๋Š” ์˜ˆ์‹œ์ž…๋‹ˆ๋‹ค.

export default function Home() {
  const [state, formAction, isPending] = useActionState(login, {
    email: "",
    password: "",
    message: undefined,
    isError: false,
  });

  const [hasToast, setHasToast] = useState(false);
  const { email, password, message, isError } = state;

  useEffect(() => {
    let timer: NodeJS.Timeout | undefined;

    if (isPending) {
      setHasToast(true);
    }
    if (!isPending) {
      timer = setTimeout(() => setHasToast(false), 3000);
    }

    return () => {
      if (timer) clearTimeout(timer);
    };
  }, [isPending, setHasToast]);

  return (
      {/*...*/}
      <div className="toast toast-bottom toast-center">
        {hasToast &&
          message &&
          (isError ? (
            <div role="alert" className="alert alert-error">
              <span>Error! {message}</span>
            </div>
          ) : (
            <div role="alert" className="alert alert-success">
              <span>{message}</span>
            </div>
          ))}
      </div>
    )
}

๐Ÿ“ ๋ณ€ํ™”ํ•˜๋Š” ๋ฐ์ดํ„ฐ fetching๊ณผ mutate ์ƒํƒœ๊ณ„

Next.js์˜ Server Action๊ณผ React์˜ useActionState๋Š” ํด๋ผ์ด์–ธํŠธ-์„œ๋ฒ„ ํ†ต์‹ ์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ณต์žก์„ฑ์„ ํฌ๊ฒŒ ์ค„์—ฌ์ฃผ๋Š” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. ์ด ๋‘ ๊ธฐ๋Šฅ์€ ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋˜๋Š” ๋กœ์ง๊ณผ ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๊ฒฐํ•ฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์š”์ฒญ๊ณผ ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ํ›จ์”ฌ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์กด์˜ mutate ๋ฐฉ์‹๊ณผ์˜ ๋น„๊ต

React ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€ํ˜•(mutate)ํ•˜๋Š” ๊ฐ€์žฅ ์ผ๋ฐ˜์ ์ธ ๋ฐฉ์‹์€ Tanstack Query์˜ useMutate ํ›…์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฐฉ์‹์€ ๊ฐ•๋ ฅํ•œ ์บ์‹ฑ๊ณผ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋ฉฐ, ๋Œ€๊ทœ๋ชจ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋„ ์•ˆ์ •์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋„๊ตฌ๋กœ ์ž๋ฆฌ ์žก์•˜์Šต๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ useActionState๊ฐ€ ๋“ฑ์žฅํ•˜๋ฉด์„œ mutate ์ƒํƒœ ๊ด€๋ฆฌ๋Š” ์ด์ „๋ณด๋‹ค ํ›จ์”ฌ ๊ฐ„์†Œํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค. useActionState๋Š” ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋˜๋Š” Server Action๊ณผ ์ง์ ‘ ํ†ตํ•ฉ๋˜์–ด, ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•˜๋Š” ๋กœ์ง๊ณผ ์ƒํƒœ ๊ด€๋ฆฌ ๋กœ์ง์„ ํ•œ ๊ณณ์— ๊ฒฐํ•ฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๋ณ„๋„์˜ ์บ์‹ฑ์ด๋‚˜ ๋กœ์ง ๋ถ„๋ฆฌ๊ฐ€ ํ•„์š” ์—†์œผ๋ฉฐ, ์ง๊ด€์ ์ธ ์‚ฌ์šฉ ๋ฐฉ์‹ ๋•๋ถ„์— ์ฝ”๋“œ ์ž‘์„ฑ์ด ๊ฐ„๊ฒฐํ•ด์กŒ์Šต๋‹ˆ๋‹ค.

๊ฒฐ๋ก ์ ์œผ๋กœ, Next.js์™€ React๊ฐ€ ์ œ๊ณตํ•˜๋Š” ์ตœ์‹  ๋„๊ตฌ๋“ค์€ ๋ฐ์ดํ„ฐ ์š”์ฒญ๊ณผ ์ƒํƒœ ๊ด€๋ฆฌ์˜ ๋ฐฉ์‹์— ํฐ ๋ณ€ํ™”๋ฅผ ๊ฐ€์ ธ์˜ค๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด Tanstack Query์™€ ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” ๋” ํŠนํ™”๋œ ์˜์—ญ์—์„œ ํ™œ์šฉ๋  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„์ง€๋ฉฐ, React์˜ ์ƒํƒœ๊ณ„๋Š” ์ ์  ๋” ๊ฐ„๋‹จํ•˜๊ณ  ํšจ์œจ์ ์ธ ๋ฐฉํ–ฅ์œผ๋กœ ๋ฐœ์ „ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ถ”ํ›„์— useLoaderState์™€ ๊ฐ™์€ fetching์„ ๋‹ด๋‹นํ•˜๋Š” API๊ฐ€ ์ œ๊ณต๋œ๋‹ค๋ฉด, Tanstack Query์™€ ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋“ค์€ ์–ด๋–ค ์—ญํ• ๋กœ ํ™œ์šฉ๋ ๊นŒ์š”?