TIL

2024.05.31 #Next.js #Server-Action3 #유효성검사 #ErrorHandling #낙관적_업데이트

inz1234 2024. 5. 31. 20:02

이전 포스팅에서는
- Server-Action으로 업데이트 한 데이터를 어떻게 화면에 바로 반영하는지
- 로딩 중 상태는 어떻게 보여줄 수 있는지
에 대해서 다뤘다.

이번 포스팅에서는
1. 유효성 검사와 error Handling은 어떻게 하는지
2. Server-Action으로 낙관적 업데이트는 어떻게 할 수 있는지, useOptimistic()의 버그(?)
에 대해서 다룰 예정이다.


1. Server-Action에서 유효성 검사와 ErrorHandling은 어떻게 할까?

유효성 검사 - zod 라이브러리 사용

왜 zod를 사용해야 하는가? 
보통 Server-Action을 사용하지 않고 유효성 검사를 할 때에는
useState()를 사용해서 form 태그나 input 태그에 입력되는 값을 state에 할당하고,
그 값이 유효한지를 정규식이나 JS 함수(inlcude, startswith 등)를 사용해서 실행했었다.

그러나 Server-Action은 JS와 별도로 동작하기 때문에 
server 측에서 실행되는 유효성 검사 도구가 필요하기 때문이다.

설치

tsconfig.json 파일 확인

// tsconfig.json
{
  // ...
  "compilerOptions": {
    // ...
    "strict": true
  }
}

schema 만들기

// app > lib > schema.ts
import { z } from 'zod';

export const testSchema = z.object({
  name: z
    .string({ required_error: 'Name is required', invalid_type_error: 'Name must be a string' })
    .min(1, { message: '최소 1글자 이상이어야 합니다.' })
    .max(5, { message: '최대 5글자 이하여야 합니다.' }),
  message: z
    .string({ required_error: 'Message is required', invalid_type_error: 'Message must be a number' })
    .min(1, { message: '최소 1글자 이상이어야 합니다.' })
    .max(10, { message: '최대 10글자 이하여야 합니다.' })
});

- Zod에서 제공해주는 유효성 검사 함수들이 여러 개 있다.
위 코드의 경우에는 
required_error: 아무 값이 입력되지 않았을 때
invalid_type_error: 타입 오류일 때
.min(1): 최소 1 글자
.max(5): 최대 5 글자
라는 뜻이다.
이 외에도 zod 공식문서에 보면 제공해주는 유효성 검사 함수가 되게 많다. 

Server-Action에 Zod import & 활용하기

export const serverAdd = async (data: FormData) => {
  const formData = Object.fromEntries(data);

  // 외부 통신 전 유효성 검사 먼저
  const { error: zodErr } = testSchema.safeParse(formData);
  if (zodErr) {
    return { error: zodErr.format() };
  }

  try {
    await addDoc(collection(db, 'test'), formData);
    revalidatePath('/daily');
  } catch (e) {
    throw new Error('fail to add a new item');
  }
};

shema.parse() 함수도 있고 safeParse() 함수도 있다.
parse() 는 ZodError 자체를 뱉기 때문에 Error Boundary에 걸린다.
safeParse() 는 오류 데이터 객체를 반환한다.
나는 유효성 검사를 통과하지 못하면 input 창 밑에 바로 오류 메세지를 보여주기 위해서 
오류 데이터 객를 반환해주는 safeParse()를 사용했다.

'use client'
import { serverAdd } from ' @/app/action';

const DailyClientComponent = () => {
  const [validationErr, setValidationErr] = useState<ValidationErr | null>(null);
  
  const serverActionWithZod = async (data: FormData) => {
    const result = await serverAdd(data);
    if (result?.error) {
      setValidationErr(result.error);
    } else {
      setValidationErr(null);
    }
    formRef.current?.reset();
  };

return (
...
 <div className="border-4 py-4 px-4">
        <form action={serverActionWithZod} ref={formRef} className="flex flex-col gap-2">
          <h1>server-action Imported with Zod: </h1>
          <input type="text" name="name" className="border" placeholder="string 형식이어야 합니다."></input>
          {validationErr ? <p>{validationErr.name?._errors}</p> : null}
          <input type="number" name="message" className="border" placeholder="number 형식이어야 합니다."></input>
          {validationErr ? <p>{validationErr.message?._errors}</p> : null}
          <button className="border">send</button>
        </form>
      </div>
 ...
)}


Error Handling

Server-Action도 마찬가지로 error.tsx 파일을 만들어서 Error-Boundary를 만들었다.
특히 form 태그에 Server-Action을 연결하고 그 컴포넌트 자체를 Server 컴포넌트로 만들면
Client 컴포넌트로 만들었을 때보다 Error-Boundary로 UX 가 좀 더 개선되는 듯 하다.

'use client'; // Error components must be Client Components

import { useEffect } from 'react';

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  );
}

왜냐하면,
에러 발생 시 Error-boudary 즉, Error 페이지로 이동하고 try-again을 누르면 해당 페이지로 redirect가 되는데,
Client 컴포넌트로 만들면 client 페이지로 redirect 되면서 또 오류가 있는 함수(useQuery가 문제라면)가 실행되며
다시 error-boundary에 걸림 -> Error 페이지로 감


그런데 Server 컴포넌트로 만들면,  try-again을 누를 시 Server 컴포넌트를 가진 페이지로 redirect가 되면서
오류가 있는 함수는 form 태그 내 action이 실행될 때만 실행되기 때문에 에러 페이지가 아니라 원래 페이지가 렌더링 됨

=> 아주 사소한 것이지만, User의 입장에서는 try-again을 아무리 눌러도 Error 페이지만 렌더링 되는 것 보다는, 원래 페이지가 보이는 게 나을 것 같다 생각했다.


2. Server-Action에서의 낙관적 업데이트 - useOptimistic()

Next.js 공식문서에서는 useOptimistic()를 제안한다.
useOptimistic()은 React에서 제공하는 Canary hook으로 아직 실험적인 hook 이다.

그래서 사용해보았는데.. 약간의 버그가 있다.

form 태그 action prop에 넘겨지는 Server-Action 함수에 네트워크 통신 로직이 있으면 
UI가 끊기는 현상이 발생한다.
코드로 보면

'use client';

import { deliverMessage } from ' @/app/action';
import { useOptimistic, useRef, useState } from 'react';

const UseOptimistic = () => {
  const [msg, setMsg] = useState<
    {
      text: string | unknown;
      sending: boolean;
    }[]
  >([{ text: '', sending: false }]);
  const formRef = useRef<HTMLFormElement | null>(null);

  const serverActionWithUseOptimistic = async (formData: FormData) => {
    const bindServerAction = deliverMessage.bind(null, msg);
    await bindServerAction(formData);

    setMsg((prev) => [...prev, { text: '이거 되나', sending: false }]);
    formRef.current?.reset();
  };

  const [optimisticMessages, addOptimisticMessage] = useOptimistic(msg, (state, newMessage) => [
    ...state,
    {
      text: newMessage,
      sending: true
    }
  ]);

  const action = async (formData: FormData) => {
    addOptimisticMessage(formData.get('name'));
    await serverActionWithUseOptimistic(formData);
  };

  return (
    <div className="border-4 py-4 px-4">
      <form action={action} className="flex flex-col gap-2" ref={formRef}>
        <h1>Optimistic Server-action : </h1>
        <input type="text" name="name" className="border"></input>
        <input type="number" name="message" className="border"></input>
        <button className="border">send</button>
      </form>

      {optimisticMessages.map((message: any, index) => (
        <div key={index}>
          {message.text.length ? message.text : null}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
    </div>
  );
};

export default UseOptimistic;
// app > action.ts

export const deliverMessage = async (message: { text: string | unknown; sending: boolean }[], data: FormData) => {
  await new Promise((res) => setTimeout(res, 1000));
  const formData = Object.fromEntries(data);

  try { // 외부 네트워크 통신 로직
    await addDoc(collection(db, 'optimistic'), formData);
    revalidatePath('/daily');
  } catch (e) {
    console.error(e);
    throw new Error('fail to add a new item');
  }
  return message;
};

> 1이 없어짐과 동시에 "이거 되나"가 생겨야 하는데, 이거 되나가 생기고도 1이 없어지지 않는 순간이 있다.

공식문서에는 네트워크 통신 로직이 없었기 때문에, deliverMessage() 함수에 있던 네트워크 통신 로직을 제외해봤다.

export const deliverMessage = async (message: { text: string | unknown; sending: boolean }[], data: FormData) => {
  await new Promise((res) => setTimeout(res, 1000));
  // const formData = Object.fromEntries(data);

  // try {
  //   await addDoc(collection(db, 'optimistic'), formData);
  //   revalidatePath('/daily');
  // } catch (e) {
  //   console.error(e);
  //   throw new Error('fail to add a new item');
  // }
  return message;
};

이렇게 하면 

끊기는 현상 없이 잘 렌더링 된다.



이 이유에 대해서는 진짜 React 측 Canary 버전의 버그인지,
아니면 내가 무언가를 잘못하고 있는지 더 생각해봐야 할 것 같다.
우선 Server-Action으로 3일을 날려버려서 여기까지만 포스팅 해야겠다ㅠ

+) 추가로 Server-Action에 대해 공부하다가 You-tube 영상을 보게 되었는데
https://www.youtube.com/watch?v=sdKFEo6978U

처음부터 끝까지 이해가 잘 되게 설명해준다.

직접 댓글로 질문해서 답도 얻어냈다ㅋㅋ

영상 마지막 부분에 Server-Action의 실행이 Hydration이 다 끝날 때까지 대기열에 기다렸다가 실행된다는 말이 있어서,
분명 Server-Action은 JS의 관여없이 동작할 수 있다고 했는데 무슨소리지?? 해서 질문을 남겼다.

Server-Action의 실행은 Server 컴포넌트 안에 Server-Action이 있으면 바로 실행되고, Client 컴포넌트 안에 있으면 Hydration이 다 된 뒤에 실행된다고 한다.

유레카!