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

Supabase를 사용해 이미지 업로드와 서버에서 폼을 다룬 내용입니다 - 블로그 개발 후기 (3)
글을 시작하며
블로그에 글을 쓰기 위해서 폼
은 빠질 수 없는 필수 요소입니다.
더욱이 글에서의 이미지
는 글을 풍성하게 해주고, 한눈에 요점을 파악하게 도와줍니다.
이미지 업로드 기능을 어떻게 구현
하고, 폼을 어떻게 관리
했는지 이야기해 보려고 합니다.
이미지 업로드 구현 (with Supabase)
생각 정리
이미지를 본문에 어떻게 담을까?
- 이미지를 본문에 담고 싶으면 어떻게 해야 할까에 대해 생각해 보았습니다.
그냥
DB
에 담으면 되는 거 아닌가?
-> 비용이 많이 들다..
-> 그러면URL
을 클라우드 서비스의 버킷에 넣고 브라우저가 가지고 올 수 있게 하자!
클라우드 서비스
- 클라우드 서비스에도 다양한 종류(IaaS, PaaS, SaaS등)가 있습니다.
- 개발 및 배포 프로세스를 빠르게 확보하기 위해
PaaS
를 사용했습니다. PaaS
중에서도 요즘 뜨고 있는Supabase
의Storage
를 사용해 이미지 업로드를 구현했습니다.
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;
}
이미지 업로드 구현하기
input
의onChange
속성으로file
값을 가져오고uploadFile
함수로 넘겨줍니다.uploadFile
함수에서/api/admin/upload
로 이미지 업로드 요청을 보냅니다.Route handlers
로upload API
를 만들고,Supabase
에 업로드 된 이미지URL
을 가져옵니다.- 가져온
URL
을 마크다운 형식으로 입력 가능하게 합니다. - 전역 상태인
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
을 얻을 수 있습니다.
- 글 생성 페이지에서 이미지 업로드 시에 다음과 같이 나타납니다.
서버에서 폼 다루기
- 블로그 구현 시에 사용한 많은 폼이 있지만, 본문에서는 로그인 폼으로 다루겠습니다.
- 로그인 페이지 폴더 구조입니다.
서버에서 로그인 폼 다루기
Server Action
Server Action
은 폼의Mutation
(생성, 업데이트, 삭제)을 할 수 있게 해주는Next.js
의 강력한 기능입니다.Server Action
을 사용하면, API 엔드포인트를 생성하지 않고도 컴포넌트 내에서 비동기 함수를 직접 정의할 수 있습니다.- Server Action 더 알아보기
useFormState
useFormState
는 폼 액션의 결과를 기반으로state
를 업데이트할 수 있도록 제공하는Hook
입니다.- 컴포넌트 최상위 레벨에서
useFormState
를 호출하여 폼 액션이 실행될 때 업데이트되는 컴포넌트state
를 생성합니다. - useFormState 더 알아보기
useFormStatus
useFormStatus
는 마지막 폼 제출의 상태 정보를 제공하는Hook
입니다.- useFormStatus 더 알아보기
폼 관리하기
Server action
에서form
에 입력된input
값을input
태그의name
속성으로 가져옵니다.useFormState
를 사용하려면 최상위 컴포넌트여야 하므로 폼이 있는LoginForm
에서 사용합니다.LoginForm
은Client Component
이므로Server Component
에서Props
를 통해Server action
을 받아옵니다.action
의결과 값으로useFormState
로state
값(success, error, message)을 업데이트해 줍니다.SubmitButton
으로 폼을 제출하면useFormStatus
로LoginForm
의 상태를 알 수 있습니다.- 상태 값으로
pending
상태일 때,SubmitButton
을disabled
해 줄 수 있습니다.
// 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, message
등state
가 업데이트되고 있습니다.
글을 마치며
배운것
- Supabase로 이미지 업로드
- Server action을 사용해 폼 다루기
- Server Component, ClientComponent 분리하기
- useFormState, useFormStatus 사용법
마무리
지금까지 Supabase를 사용해 이미지 업로드와 서버에서 폼을 다룬 내용입니다.
읽어주셔서 감사합니다.