티스토리 뷰

Nextjs로 작업하면서 대부분을 서버에서 작업하고 있습니다. 

이번에는 내 정보 수정 페이지를 만들면서 put요청을 server action으로 구현하고 있습니다.

client component에서 state로 데이터를 관리하고 저장버튼에 이벤트를 걸어서 put요청을 하면 아주 쉽겠지만

server action으로 함수를 만들면서도 유저와의 인터렉션으로 이미지 미리보기 기능이 있어서 이를 구현한 기록입니다.

 

input만 있는 form에서는 server action으로 formdata를 만들고 보내는 것이 아주 단순했습니다.

'use client';

function LoginForm() {
  const initialState = { message: '', errors: {} };
  const [state, dispatch] = useFormState(loginServerAction, initialState);

  return (
    <form action={dispatch}>
      <LabeledInput
        name="email"
        error={state.errors?.email}
      />

      <LabeledInput
        type="password"
        name="password"
        error={state.errors?.password}
      />

      <Button type="submit">로그인</Button>
    </form>
  );
}

export default LoginForm;

 

단순히 input에 name어트리뷰트를 작성해서 폼데이터에 들어갈 이름만 지정해주면 loginServerAction이라는 서버액션의 두 번째 매개변수로 formdata를 받을 수 있습니다.

이번에는 이미지를 받기 위해 <input type='file' />을 만들어보겠습니다.

'use client';

function EditForm({ user }: { user: LoginUser }) {

  const updateUserSAWithId = updateUserSA.bind(null, user.id);

  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {};

  const handleDeleteProfile = () => {};

  return (
    <form action={updateUserSAWithId}>
      <label>
        <Image
          alt="profile-image"
          src={???}
        />
        <input
          type="file"
          className="hidden"
          accept="image/*"
          name="profileImage"
          onChange={handleImageChange}
        />
      </label>
      
      <div onClick={handleDeleteProfile}>
        프로필 삭제
      </div>

      <LabeledInput
        label="이메일"
        value={user.email}
        disabled
      />
      <LabeledInput
        label="닉네임"
        name="nickname"
        defaultValue={user.nickname}
      />
      
      <Button type="submit">저장</Button>
    </form>
  );
}

export default EditForm;

저는 닉네임과 profileImage만 수정가능한 폼을 만들기 때문에 위 코드처럼 닉네임 input과 file input에만 name어트리뷰트를 지정했습니다. 이렇게 하면 파일이 바뀔 때마다 server action 함수에서 매개변수로 들어오는 formdata에 잘 들어갑니다. 만약 유저가 선택하는 이미지를 미리보기로 보여줄 필요가 없고, 수정하는 상황이 아니라 입력하는 상황이면 이렇게만 해도 될 것 같습니다.

근데 저는 이미지 파일 입력에 따라 미리보기를 보여줘야 하고, 이미지 삭제 버튼을 누르면 폼데이터의 profileImage에 빈값을 보내야 합니다. 그렇게 백엔드를 코딩해 놨거든요.

그래서 저는 이 이미지 상태를 유저와 상호작용하기 위해 client state를 하나 만들어서 이 state만으로 데이터를 관리하고 input type='file'은 formdata에서 제거했습니다.

'use client';

function EditForm({ user }: { user: LoginUser }) {
  const [clientImage, setClientImage] = useState(user.profileImage);

  const updateUserSAWithId = updateUserSA.bind(null, user.id);

  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files === null) return;
    const file = e.target.files[0];
    setClientImage(file);
  };

  const handleDeleteProfile = () => {
    setClientImage(null);
  };

  return (
    <form action={updateUserSAWithId}>
      <label>
        <Image
          alt="profile-image"
          src={clientImage}
        />
        <input
          type="file"
          className="hidden"
          accept="image/*"
          onChange={handleImageChange}
        />
      </label>
      
      <div onClick={handleDeleteProfile}>
        프로필 삭제
      </div>

      <LabeledInput
        label="이메일"
        value={user.email}
        disabled
      />
      <LabeledInput
        label="닉네임"
        name="nickname"
        defaultValue={user.nickname}
      />
      
      <Button type="submit">저장</Button>
    </form>
  );
}

export default EditForm;

clientImage라는 상태에는 총 세 가지 타입이 들어가게 됩니다. string, null, file

이렇게 의도했고 백엔드도 이 상황을 의도하고 코딩했습니다. null이 들어가면 프로필을 초기화하고, string이 들어오면(아마 수정을 안 했겠죠?) 그대로 image url을 유지하고, file이 들어오면 s3에 올리고 url을 수정합니다.

 

Server Action에 id값을 인자로 보내기 위해 bind를 메서드를 호출해서 인자가 혼합된 함수를 새로 만들어서 호출했습니다.

공식문서에 나와있는 방식입니다.

이렇게 하면 이제 서버액션의 첫 번째 매개변수는 id가 되고, formdata는 두 번째 매개변수로 밀립니다.

두번째 매개변수로 받은 formdata를 보면 nickname밖에 없습니다. 

당연합니다. form내부에 name어트리뷰트가 지정된 태그가 nickname input밖에 없기 때문입니다.

server action이 실행되기 전에 formdata에 clientImage를 첨부해 줘야겠습니다.

 const updateUser = (formdata: any) => {
    formdata.append('profileImage', clientImage ?? '');
    updateUserSAWithId(formdata)
      .then(() => {
        toast.success('프로필이 수정되었습니다.');
      })
      .catch(() => {
        toast.error('프로필 수정에 실패했습니다.');
      });

  };

  return (
    <form action={updateUser} className="flex flex-col gap-5 items-center">
    ....

action에 새로운 함수를 만들어주고 updateUserSAWithId 함수가 호출되기 직전에 profileImage를 첨부해 줬습니다.

그리고 clientImage가 첨부된 formdata를 인자로 넘겨주면 server action의 두 번째 매개변수로 받는 formdata에서 profileImage를 확인할 수 있습니다. null은 formdata에 첨부할 수 없으므로 빈 문자열을 첨부했고, 백엔드에서 빈문자열인 경우는 null로 초기화하도록 했습니다.

Server Action은 이렇게 생겼습니다.

export const updateUserSA = async (id: number, data: FormData) => {
  try {
    await updateUser({
      id,
      data,
      accessToken: getAccessToken(),
    });
  } catch (err: any) {
    throw new Error(err.response.data.message);
  }
  
  revalidatePath('/mypage');
  redirect('/mypage');
};

updateUser요청이 성공하면 mypage를 revalidate 해서 fetch로 가져온 데이터를 업데이트해주고 mypage로 이동시켜 줍니다.

 

해당 server action이 이미지를 업로드하므로 약간의 pending시간이 있을 수 있습니다. 함수가 pending일 때 저장버튼을 disabled 처리하고 loading을 띄워보도록 하겠습니다.

function EditForm({ user }: { user: LoginUser }) {
  const [clientImage, setClientImage] = useState<ClientImageType>(
    user.profileImage
  );
  const updateUserSAWithId = updateUserSA.bind(null, user.id);
  const [pending, startTransition] = useTransition();

  const updateUser = (formdata: any) => {
    formdata.append('profileImage', clientImage ?? '');
    startTransition(() => {
      updateUserSAWithId(formdata)
        .then(() => {
          toast.success('프로필이 수정되었습니다.');
        })
        .catch(() => {
          toast.error('프로필 수정에 실패했습니다.');
        });
    });
  };

  return (
    <form action={updateUser} className="flex flex-col gap-5 items-center">
      
      ......


      <Button
        disabled={pending}
        type="submit"
      >
        {pending ? <Loading /> : '저장'}
      </Button>

    </form>
  );
}
export default EditForm;

처음에는 이렇게 startTransition의 콜백으로 실행되게 하여 pending상태를 가져왔습니다.

이렇게 해도 작동을 하지만 공식문서에 더 좋은 방법이 있습니다.

form내부의 children컴포넌트에서 useFormStatus를 사용하면 form의 pending상태를 가져올 수 있습니다.

보통은 Button 컴포넌트가 form의 pending상태를 궁금해하고 인터렉션이 있을 테니 Button컴포넌트 내부에 useFormStatus를 넣어주겠습니다.

'use client';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

function Button({ children, className, ...props }: ButtonProps) {
  const { pending } = useFormStatus();
  return (
    <button
      type="button"
      {...props}
      disabled={pending}
      className={clsx('px-4 py-2 rounded-sm text-sm bg-gray200', className)}
    >
      {props.type === 'submit' && pending ? <Loading /> : children}
    </button>
  );
}

export default Button;

 

 

form태그 안에 들어가는 Button컴포넌트가 useFormStatus 훅을 호출하면 form의 pending상태와 연동됩니다. 신기합니다.

저는 type이 submit이 아닌 버튼들은 loading을 띄우고 싶지 않았기 때문에 ui부분에 두 개의 조건을 걸었습니다.

반면 disabled속성에는 pending만 넣었습니다.

예를 들어 pending 기간 동안 form안에 있는 '취소'버튼에 로딩 ui가 뜨기를 바라진 않지만 클릭은 방지하고 싶었기 때문입니다.

pending에 취소버튼을 누르지 않았으면 좋겠거든요.

 

이렇게 해서 유저 프로필 업데이트 함수를 server action으로 수정했습니다.

 

 

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

 

Data Fetching: Server Actions and Mutations | Next.js

Learn how to handle form submissions and data mutations with Next.js.

nextjs.org

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함