이미지 업로드 & 서버에서 폼 다루기

By Yoon
·
thumbnail
Supabase를 사용해 이미지 업로드와 서버에서 폼을 다룬 내용입니다 - 블로그 개발 후기 (3)

글을 시작하며

블로그에 글을 쓰기 위해서 은 빠질 수 없는 필수 요소입니다.
더욱이 글에서의 이미지는 글을 풍성하게 해주고, 한눈에 요점을 파악하게 도와줍니다.

이미지 업로드 기능을 어떻게 구현하고, 폼을 어떻게 관리했는지 이야기해 보려고 합니다.

이미지 업로드 구현 (with Supabase)

생각 정리

이미지를 본문에 어떻게 담을까?

  • 이미지를 본문에 담고 싶으면 어떻게 해야 할까에 대해 생각해 보았습니다.

그냥 DB에 담으면 되는 거 아닌가?
-> 비용이 많이 들다..
-> 그러면 URL을 클라우드 서비스의 버킷에 넣고 브라우저가 가지고 올 수 있게 하자!


클라우드 서비스

  • 클라우드 서비스에도 다양한 종류(IaaS, PaaS, SaaS등)가 있습니다.
  • 개발 및 배포 프로세스를 빠르게 확보하기 위해 PaaS를 사용했습니다.
  • PaaS 중에서도 요즘 뜨고 있는 SupabaseStorage를 사용해 이미지 업로드를 구현했습니다.

Supabase 사용하기

1. bucket 생성

  • bucket을 생성해 주고, 각각의 값을 .env 파일에 보관합니다.
// .env
SUPABASE_BUCKET=
SUPABASE_URL=
SUPABASE_KEY=

2. Supabase Storage에 파일을 업로드하는 방법

// app/_utils/bucket.ts
import { createClient } from "@supabase/supabase-js";

// Create Supabase client
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_KEY!,
);

// Upload file using standard upload
export async function uploadFile(file: File, path: string) {
  const { data, error } = await supabase.storage
    .from(process.env.SUPABASE_BUCKET!)
    .upload(path, file);

  if (error) {
    throw new Error(`${error.message} + ${path}`);
  }

  const url = supabase.storage
    .from(process.env.SUPABASE_BUCKET!)
    .getPublicUrl(data.path);

  return url.data.publicUrl;
}

이미지 업로드 구현하기

  1. inputonChange 속성으로 file 값을 가져오고 uploadFile 함수로 넘겨줍니다.
  2. uploadFile 함수에서 /api/admin/upload로 이미지 업로드 요청을 보냅니다.
  3. Route handlersupload API를 만들고, Supabase에 업로드 된 이미지 URL을 가져옵니다.
  4. 가져온 URL을 마크다운 형식으로 입력 가능하게 합니다.
  5. 전역 상태인 content에 이미지 URL을 추가해 줍니다.
  • 고도화: Drag and Drop도 가능하도록 구현했습니다.
// app/_components/PostForm/ImageInput.tsx
"use client";

import { ChangeEvent, useCallback } from "react";
import useDragAndDrop from "@/app/_hooks/useDragAndDrop";
import useContentContext from "@/app/_context/ContentContext/useContentContext";

export default function ImageInput() {
  const { setNewContent } = useContentContext();

  const uploadFile = useCallback(
    async (file: File) => {
      const formData = new FormData();
      formData.append("file", file);
      try {
        const res = await fetch("/api/admin/upload", {
          method: "POST",
          body: formData,
        });
        const data = (await res.json()) as {
          imageFileName: string;
          imageUrl: string;
        };
        let url = `<p align="center"><img src=${""} alt=${""} width="100%" height="100%"/></p>`;
        if (data.imageUrl) {
          url = `<p align="center"><img src="${data.imageUrl}" alt="${data.imageFileName}" width="100%" height="100%" /></p>`;
        }
        setNewContent((content) => content + url);
      } catch (error) {
        // 생략..
      } finally {
        // 생략..
      }
    },
    [setNewContent],
  );

  const handleFileInputChange = useCallback(
    (e: ChangeEvent<HTMLInputElement> | DragEvent) => {
      let file;
      if ("dataTransfer" in e) {
        file = e.dataTransfer?.files[0];
      } else {
        file = e.target.files?.[0];
      }
      if (file) {
        uploadFile(file);
      }
    },
    [uploadFile],
  );

  const [, dragRef] = useDragAndDrop<HTMLLabelElement>(
    handleFileInputChange,
  );

  return (
    <>
      <label htmlFor="image" ref={dragRef}>
        // 생략..
      </label>
      <input
        id="image"
        type="file"
        onChange={handleFileInputChange}
        disabled={// 생략..}
      />
    </>
  );
}
// app/(route-handlers)/api/admin/upload/route.ts
import { uploadFile } from "@/app/_utils/bucket";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuid } from "uuid";

export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const imageFile = formData.get("file") as File;
    const imageFileName = imageFile.name;
    const imageFilepath = `${uuid()}.${imageFileName.split(".").pop()}`;
    const imageUrl = await uploadFile(imageFile, imageFilepath);
    return NextResponse.json({ imageFileName, imageUrl });
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 500 },
    );
  }
}

결과

  • Get URL로 이미지 url을 얻을 수 있습니다.

스크린샷 2024-03-08 오후 8.12.35.png

  • 글 생성 페이지에서 이미지 업로드 시에 다음과 같이 나타납니다.

스크린샷 2024-03-08 오후 8.51.06.png


서버에서 폼 다루기

  • 블로그 구현 시에 사용한 많은 폼이 있지만, 본문에서는 로그인 폼으로 다루겠습니다.
  • 로그인 페이지 폴더 구조입니다.

스크린샷 2024-03-08 오후 9.07.17.png


서버에서 로그인 폼 다루기

Server Action

  • Server Action은 폼의 Mutation(생성, 업데이트, 삭제)을 할 수 있게 해주는 Next.js의 강력한 기능입니다.
  • Server Action을 사용하면, API 엔드포인트를 생성하지 않고도 컴포넌트 내에서 비동기 함수를 직접 정의할 수 있습니다.
  • Server Action 더 알아보기

useFormState

  • useFormState는 폼 액션의 결과를 기반으로 state를 업데이트할 수 있도록 제공하는 Hook입니다.
  • 컴포넌트 최상위 레벨에서 useFormState를 호출하여 폼 액션이 실행될 때 업데이트되는 컴포넌트 state를 생성합니다.
  • useFormState 더 알아보기

useFormStatus

폼 관리하기

  • Server action에서 form에 입력된 input 값을 input 태그의 name 속성으로 가져옵니다.
  • useFormState를 사용하려면 최상위 컴포넌트여야 하므로 폼이 있는 LoginForm에서 사용합니다.
  • LoginFormClient Component이므로 Server Component에서 Props를 통해 Server action을 받아옵니다.
  • action 의결과 값으로 useFormStatestate 값(success, error, message)을 업데이트해 줍니다.
  • SubmitButton으로 폼을 제출하면 useFormStatusLoginForm의 상태를 알 수 있습니다.
  • 상태 값으로 pending 상태일 때, SubmitButtondisabled 해 줄 수 있습니다.
// app/(root)/login/loginAction.ts
"use server";

export async function loginAction(
  prevState: {
    success: boolean;
    error: boolean;
    message: string;
  },
  formData: FormData,
) {
  const inputPW = formData.get("password")?.toString();
  // 생략..
  if (inputPW) {
    // 생략..
    if (isAdmin) {
      // 생략..
      return {
        success: true,
        error: false,
        message: "로그인 성공!",
      };
    } else {
      return {
        success: false,
        error: true,
        message: "비밀번호가 일치하지 않습니다.",
      };
    }
  }
  return {
    success: false,
    error: true,
    message: "비밀번호를 입력해주세요!",
  };
}
// app/(root)/login/page.tsx
import LoginForm from "./LoginForm";
import { loginAction } from "./loginAction";

export default async function LoginPage({
  searchParams,
}: {
  searchParams: { redirect: string };
}) {
  return (
    <section>
      <LoginForm
        redirectUrl={searchParams.redirect}
        handleLogin={loginAction}
      />
    </section>
  );
}
// app/(root)/login/LoginForm.tsx
"use client";

import SubmitButton from "./SubmitButton";
import { useFormState } from "react-dom";

type Props = {
  handleLogin: (
    prevState: {
      success: boolean;
      error: boolean;
      message: string;
    },
    formData: FormData,
  ) => Promise<{
    success: boolean;
    error: boolean;
    message: string;
  }>;
  redirectUrl: string;
};

export default function LoginForm({ handleLogin, redirectUrl }: Props) {
  const [formState, formAction] = useFormState(handleLogin, {
    success: false,
    error: false,
    message: "",
  });

  return (
    <form action={formAction}>
      <div>
        <div>
          <label htmlFor="password">
            관리자 로그인
          </label>
          <span>
            글을 작성하거나 수정할 수 있는 권한은 관리자만 가능합니다.
          </span>
        </div>
        <div>
          <input
            id="password"
            name="password"
            type="password"
            placeholder="비밀번호를 입력해주세요.."
          />
        </div>
        <SubmitButton />
      </div>
    </form>
  );
}
// app/(root)/login/SubmitButton.tsx
import { useFormStatus } from "react-dom";

export default function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending ? "로그인 중.." : "로그인"}
    </button>
  );
}

결과

  • 로그인 시 다음과 같이 폼 액션의 결과를 기반으로 success, error, messagestate가 업데이트되고 있습니다.

gif

글을 마치며

배운것

  • Supabase로 이미지 업로드
  • Server action을 사용해 폼 다루기
  • Server Component, ClientComponent 분리하기
  • useFormState, useFormStatus 사용법

마무리

지금까지 Supabase를 사용해 이미지 업로드와 서버에서 폼을 다룬 내용입니다.

읽어주셔서 감사합니다.

YoonLog 깃허브 레포