[CloudFlare] Images
Why Use?
유저가 업로드한 데이터를 cloud가 아닌 서버에 저장하면, 서버에 사진이 가득 차게 된다.
서버에 저장한다면,
1. 항상 같은 서버를 사용해야한다.
만약 서버를 바꾸게 되면(예를 들어 유저가 많아져서 서버가 여러 대 필요하게 되면)
사진을 어느 서버에 저장했는지 파악하느라 힘들 것이다.
2. Vercel 등 serverless 서비스로 배포한다면, 해당 서비스들은 서버를 만들었다가 간단하게 없애버린다.
새로운 코드를 배포할 때마다 새로운 서버가 만들어지므로,
모든 데이터들이 초기화되기에, 기존 유저의 데이터들이 전부 초기화되어 사라진다.
AWS와 비교
AWS S3와 마찬가지로 Cloud에 이미지를 저장하며, 저장한 이미지의 URL를 준다.
CloudFlare는 이미지 크기 조절과 회전, 투명성 등을 조절하여 이미지를 받을 수 있고,
AWS S3에 비해 사용방법이 간단하다.
단, 비용이 지불된다.
Settings
1. 로그인 후 왼쪽 탭의 Images를 클릭한다.
2. Images 화면 중앙에 API 사용 탭을 누른다.
3. API 사용 탭의 중앙에 [여기서 API 토큰 얻기]를 누른다.
4. API 토큰 탭의 화면 오른쪽에 있는 토큰 생성을 클릭한다.
5. API 토큰 템플릿에서 Cloudflare Stream 및 Images 읽기 및 쓰기의 템플릿 사용을 누른다.
6. 요약 계속을 누른다.
7. 토큰 생성을 누른다.
8. 발급된 토큰을 env 파일에 저장한다.
9. 처음 로그인 했을 때의 Images 탭의 오른쪽 화면에 있는 계정 ID와 계정 해시도 env 파일에 저장한다.
// .env
CLOUDFLARE_API_KEY = '~~~'
CLOUDFLARE_ACCOUNT_ID = '~~~'
CLOUDFLARE_ACCOUNT_HASH = '~~~'
Code
기존 코드
유저 --> 서버로 이미지 업로드 --> Cloudflare로 이미지 업로드
해당 방법은 업로드를 두 번 해야 하기에 cloudflare 비용뿐만 아니라, 서버의 비용도 지불해야 한다.
즉, 업로드도 2번, 비용도 2 군데서 지불해야 하므로, 시간도 2배로 걸리고, 비용도 더 많이 나간다.
// app/add/products/actions.ts
"use server";
import fs from "fs/promises";
import db from "@/lib/db";
import getSession from "@/lib/session/getSession";
import { redirect } from "next/navigation";
export async function uploadProduct(_: any, formData: FormData) {
const data = {
photo: formData.get("photo"),
title: formData.get("title"),
price: formData.get("price"),
description: formData.get("description"),
};
if (data.photo instanceof File) {
const photoData = await data.photo.arrayBuffer();
await fs.appendFile(`./public/${data.photo.name}`, Buffer.from(photoData));
data.photo = `/${data.photo.name}`;
}
const result = productSchema.safeParse(data);
if (!result.success) {
return result.error.flatten();
} else {
const session = await getSession();
if (session.id) {
const product = await db.product.create({
data: {
title: result.data.title,
description: result.data.description,
price: result.data.price,
photo: result.data.photo,
user: {
connect: {
id: session.id,
},
},
},
select: {
id: true,
},
});
redirect(`/products/${product.id}`);
}
}
}
유저가 업로드 버튼을 누르면
fs 모듈을 사용하여 public 폴더 안에 해당 이미지를 저장하였고,
public안에 파일로 저장된 이미지를 db에 url로 불러와서 사용하였습니다. (예시 : ./public/carrot.jpg)
변경된 코드
🔗 공식문서를 따라 만들면 된다.
필자는 공식문서의 Request a one-time upload URL를 사용할 것이다.
윗 코드의 fs모듈을 사용한 부분을 지우고, 공식문서에 있는 코드를 사용한다.
(아래 코드에는 생략된 부분이 약간 있다.)
유저 --> 유저가 업로드를 하려고 하면 서버가 Cloudflare에게 안전한 업로드 URL를 알려달라고 한다.
--> cloudflare에게 받은 URL를 서버가 유저에게 알려준다.
--> 유저가 업로드 버튼을 누르면 유저는 그 업로드 URL를 사용하여 cloudflare에게 파일을 업로드한다.
--> 유저를 통해 우리도 업로드 URL를 얻고, 그 업로드 URL를 DB에 저장한다.
한 번만 Cloudflare에 업로드하며 서버에서는 이미지에 대해 URL만 저장할 뿐 관여하는 것이 없다.
즉, 한 번 업로드를 하기에 시간과 비용을 아꼈다.
언제 업로드 URL를 받아와야 할까?
사실 DB에 저장하기 전에 받아오면 되므로 상관은 없으나,
사진을 선택할 때 업로드 URL를 받아오는 것이 좋다.
왜냐하면 submit 할 때 업로드 URL를 받아와서 할 수 도 있지만,
submit 할 때 받으면 업로드 URL를 받아오는 시간 + DB에 업로드하는 시간이므로 더욱 오래 걸린다.
업로드 URL를 받는 데는 별도의 비용이 들지 않고, 사용하지 않으면 자동적으로 URL이 만료되므로 사진을 선택할 때 업로드 URL를 받는 것이 좋다.
// app/add/products/actions.ts
"use server";
import fs from "fs/promises";
import db from "@/lib/db";
import getSession from "@/lib/session/getSession";
import { redirect } from "next/navigation";
export async function uploadProduct(_: any, formData: FormData) {
const data = {
photo: formData.get("photo"),
title: formData.get("title"),
price: formData.get("price"),
description: formData.get("description"),
};
const result = productSchema.safeParse(data);
if (!result.success) {
return result.error.flatten();
} else {
const session = await getSession();
if (session.id) {
const product = await db.product.create({
data: {
title: result.data.title,
description: result.data.description,
price: result.data.price,
photo: result.data.photo,
user: {
connect: {
id: session.id,
},
},
},
select: {
id: true,
},
});
redirect(`/products/${product.id}`);
}
}
}
export async function getUploadUrl() {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
},
}
);
const data = await response.json();
return data;
}
// app/add/products/page.tsx
"use client";
import Button from "@/components/buttons/Button";
import Input from "@/components/Input";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { getUploadUrl, uploadProduct } from "./actions";
import { useFormState } from "react-dom";
export default function AddProduct() {
const [isImgSizeOk, setIsImgSizeOk] = useState(true);
const [preview, setPreview] = useState("");
const [photoId, setPhotoId] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const onImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
// 구조분해로 event.target.files 만 가져온다.
// files = event.target.files와 같다.
const {
target: { files },
} = event;
if (!files) {
return;
}
const file = files[0];
// 파일 사이즈 크기 확인 (file < 3MB)
if (file.size < IMAGE_MAX_SIZE) {
setIsImgSizeOk(true);
const url = URL.createObjectURL(file);
setPreview(url);
const { success, result } = await getUploadUrl();
if (success) {
const { id, uploadURL } = result;
setUploadUrl(uploadURL);
setPhotoId(id);
}
} else {
setIsImgSizeOk(false);
setPreview("");
}
};
const interceptAction = async (_: any, formData: FormData) => {
const file = formData.get("photo");
if (!file) {
return;
}
const cloudflareForm = new FormData();
cloudflareForm.append("file", file);
const response = await fetch(uploadUrl, {
method: "post",
body: cloudflareForm,
});
console.log(await response.text());
if (response.status !== 200) {
return;
}
const photoUrl = `https://imagedelivery.net/~~~/${photoId}`;
formData.set("photo", photoUrl);
return uploadProduct(_, formData);
};
const [state, action] = useFormState(interceptAction, null);
return (
<div>
<form action={action} className="p-5 flex flex-col gap-5">
<label
htmlFor="photo"
className="relative border-2 aspect-square flex items-center justify-center flex-col text-neutral-300 border-neutral-300 rounded-md border-dashed cursor-pointer bg-center bg-cover"
style={{ backgroundImage: `url(${preview})` }}
>
{preview === "" ? (
<>
<PhotoIcon className="w-20" />
<div className="text-center text-neutral-400 text-sm flex flex-col gap-1 mt-1">
<div>Please add a photo</div>
{/* server action photo error */}
<div>{state?.fieldErrors.photo}</div>
<div className="font-semibold text-[16px] flex flex-row gap-1">
<span>The maximum file size is</span>
<span className="text-orange-500">3MB</span>
</div>
</div>
</>
) : null}
</label>
<input
onChange={onImageChange}
type="file"
id="photo"
name="photo"
accept="image/*"
className="hidden"
required
/>
...
{isImgSizeOk ? (
<Button text="Upload" />
) : (
<button
disabled={true}
className="primary-btn h-10 disabled:bg-neutral-400 disabled:text-neutral-300 disabled:cursor-not-allowed"
>
Please check the file size
</button>
)}
</form>
</div>
);
}