Next.js 캐럿마켓 클론코딩( PRODUCT UPLOAD)

짜스의 하루 ·2024년 8월 18일

product 등록하기

  • 버튼을 눌렀을 때, products/add 로 이동하고, 상품 등록 할 수 있는 화면을 만들 예정이다.
    이전에는 prisma에 직접 상품을 등록했다면, 이제는 사용자가 직접 등록할 수 있도록 해볼 예정이다.

제품 업로드 form을 이전에 인증 form 을 만들 때 사용했던 방식으로 먼저 구현을 해볼 것이다
--> zod, server Actions 만을 사용해서 만들 예정이고,
그리고 나중에 form을 refactor하면서 React Hook Form을 추가해볼 것이다.

먼저, 이전에 component로 만들어 두었던 <Input/><Button/>을 불러와서 화면을 먼저 꾸려보자!

'use client';

import Button from '@/components/button';
import Input from '@/components/input';
import { PhotoIcon } from '@heroicons/react/24/solid';
import { useState } from 'react';

export default function AddProduct() {
  const [preview, setPreview] = useState('');
  const onImageChange = () => {};
  return (
    <div>
      <form className="p-5 flex flex-col gap-5">
        <label
          htmlFor="photo"
          className="border-2 aspect-square flex items-center justify-center flex-col text-neutral-300 border-neutral-300 rounded-md border-dashed cursor-pointer"
        >
          <PhotoIcon className="w-20" />
          <div className="text-neutral-400 text-sm">사진을 추가해주세요.</div>
        </label>
        <input
          onChange={onImageChange}
          type="file"
          id="photo"
          name="photo"
          accept="image/*"
          className="hidden"
        />
        <Input name="title" required placeholder="제목" type="text" />
        <Input name="price" type="number" required placeholder="가격" />
        <Input
          name="description"
          type="text"
          required
          placeholder="자세한 설명"
        />
        <Button text="작성 완료" />
      </form>
    </div>
  );
}

여기서 label -> htmlFor 속성이 있는데 photo라고 되어있고 input은 id photo라고 되어있기 때문에 label를 클릭하는 것은 input을 클릭하는 것과 동일하다 -> 그래서 input을 hidden으로 숨겨놓아도 -> label을 선택만하면 파일 오픈을 할 수 있다 ! (가장 기본적인 html 속성이다)

input에 onImageChange event를 걸어두었으며, 이미지를 추가했을 때 -> 추가된 이미지를 보여줄 수 있도록 해볼 예정이다.

Form Action

우리는 이제 사진을 업로드 했을 때, 어떤 사진을 업로드 했는지 확인할 수 있도록 코드를 작성해 볼 예정이다. onImageChange 는 image를 입력할 Input에 onChange={}로 작성해 두었는데, input에 onChange 리스너가 있으면, change될때마다 React.js가 이 함수를 실행하게 된다 (onImageChange)

그리고 onImageChange함수에서 typescript에게 e: React.ChangeEvent<HTMLInputElement> 타입을 설명을 해주어야 한다.

const { target: { files } } = e;: 구조 분해 할당(destructuring)을 사용하여 이벤트 객체 e에서 target 객체의 files 속성을 추출 할 수 있다.
const file = files[0]; : files[]가 배열인 것을 보아 여러 파일을 업로드 할 수 있지만, 그 중에서 우리는 한개만 가져오도록 할 것이다.

const url = URL.createObjectURL(file) :
URL.createObjectURL 메서드를 사용하여 file 객체로부터 브라우저에서 임시로 접근 가능한 URL을 생성한다. 이 URL은 해당 파일을 가리키며, 이미지 미리보기 등에 사용할 수 있게 된다. 그리고 이 URL를 출력해보면
이런식으로 출력이 되는데, 유저가 방금 업로드한 파일을 우리가 볼 수 있게 해준 것이다.

파일을 볼 수 있게 그 메모리의 URL를 달라고 한 것임 -> 이 URL를 복사해서 검색창에 검색해보면 내가 업로드한 파일이 나온다.

내 문서에 들어가서 실제 파일을 읽는 것이 아니라 파일이 그 브라우저의 메모리에 업로드가 되었고, 페이지를 새로고침할 때까지는 거기에 저장된다는 의미임.
(새로고침을 하게 되면 URL은 만료된다)
--> 그리고 우리는 그 파일이 저장된 메모리의 URL를 알려달라고 하면 되는 것

url를 setPreview(url)를 넣어주고, label styles ={{}}background = url(${preview}) 를 넣어주면서 background의 image를 preview로 변경해준 것이다! 또한 bg-center, bg-cover 속성을 주면서, 가운데, 크기에 맞게 사진이 출력될 수 있도록 해주었다.

이제 actions를 만들어서 검증 및, 데이터 저장을 확인해보자.
우리가 가져와 할 것들을 formData.get()으로 가져온 후 data의 객체에 저장했다.
이렇게 data를 출력해보면, 아직 DB에 저장이 되지는 않지만, 이렇게 console.log에 유저가 입력한 정보가 출력되는 것을 확인할 수 있다.


Product Upload

이제 schema를 추가해서 product를 검증과정을 거쳐볼 것이다.


productSchema를 통해서 photo, title, description, price를 모두 검증을 해두었다. 여기서 price -> z.coerce.number인 이유는 input에 number를 입력하게 되면, string으로 저장이 되게 된다. 그렇기 때문에 string-> number로 변경시켜주기 위해서 coerce 속성을 붙여주었다.

이제, 이미지 검증을 해야 함 -> photo를 string()으로 받는다고 하지만, 현재 File형태로 받아오고 있기 때문이다. 여기서 /goguma.jpg와 같은 url만 저장할 수 있도록 해야한다.

여기서 간단하게 /public photo를 저장한다는 가정하에 진행해보도록 하겠다.

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}`;
  }

이 코드 블록은 data.photoFile 객체인지 확인한 후, 그 파일을 서버의 ./public/ 디렉토리에 저장하고, data.photo에 그 파일의 경로를 문자열로 설정하는 작업을 수행한다.

if (data.photo instanceof File) {:

  • 이 조건문은 data.photo가 JavaScript의 File 객체인지 확인

const photoData = await data.photo.arrayBuffer();:

  • File 객체의 arrayBuffer() 메서드를 호출하여 파일 데이터를 ArrayBuffer 형식으로 비동기적으로 읽어온다.
    --> ArrayBuffer 는 바이너리 데이터를 저장하는 메모리 덩어리로, 파일의 내용을 바이트 배열로 표현한 것이다.

await fs.appendFile(./public/${data.photo.name}, Buffer.from(photoData));:

  • fs.appendFileNode.js의 파일 시스템(fs) 모듈에서 제공하는 메서드로, 지정된 파일에 데이터를 추가(혹은 새 파일 생성) 한다.
  • 파일이 저장될 경로는 ./public/${data.photo.name}로 지정되며, 여기서 ${data.photo.name} 은 업로드된 파일의 원래 이름을 사용하여 경로를 만든다.
  • Buffer.from(photoData) 는 앞서 읽어온 ArrayBuffer 데이터를 Node.js의 Buffer 객체로 변환하게 된다. Buffer는 Node.js에서 바이너리 데이터를 처리할 때 사용되는 클래스이다.

data.photo = /${data.photo.name};:

  • 파일을 서버의 ./public/ 디렉토리에 저장한 후, data.photo의 값을 파일 이름포함하는 URL 경로로 업데이트한다.

!result.success 일 경우, error.flatten()을 반환하고,
result.success인 경우, db.product.create() 로 데이터베이스에 저장한다.

여기서, 중요한 점은 product 또한 user와 연결이 되어있다.
로그인 후-> 상품을 등록한다는 것은 로그인한 세션의 id가 user의 id라는 점!

고로, getSession()을 통해서 session을 가져온 뒤, 만약, session.id가 있다면, db.product.create()로 데이터베이스에 저장하고, user의 id는 session의 id로 user를 connect(연결)한다. 한다.

그리고 데이터베이스에 저장이 성공적으로 이루어지면, redirect()를 통해서 저장된 상품으로 이동하면 끝!

상품이 등록이 완료되고, 이미지 또한 ./public 에 저장된 것을 확인할 수 있다! 코코


Cloudflare

지금은 이미지를 서버에 저장하고 있다 ( /public )
이렇게 저장하고, 나중에 vercel에 배포를 완료하게 되면, vercel은 접속할 때마다 새로운 서버를 제공해주게 되는데, 그럴 때마다 이미지는 늘 삭제될 것이다!

그러기 위해서는 AWS S3, Google Cloud Storage, Cloudflare Images 등과 같은 클라우드 스토리지 서비스를 사용하는 것이 좋은데

니꼬는 Cloudflare Images를 사용한다고 한다

지금 우리의 상황 : user----> public
Cloudflare를 사용할 경우 : User ---> CF에게 "안전한 업로드 URL를 알려줘~" 라고 요청함 ---> CF서버가 업로드 URL를 줄거임 ---> 그럼 이 업로드 URL를 가져와서 ---> 이걸 유저에게 알려줄 것임 ---> 유저가 업로드 버튼을 누르면, 유저는 그 업로드 URL를 사용해서 CF에 파일을 업로드하게 된다. ---> 그리고 유저를 통해서 우리도 업로드 URL를 얻게 되면서, 그리고 이 업로드를 URL를 데이터베이스에 저장할 것이다.

먼저 Cloudflare 사이트에 들어가서 회원가입을 하면, 5달러 정도 결제를 해야 한다(매월 결제.. 한 7천원 정도 되는데 커피 한잔 참는다 생각하고 하면 맘이 편해진다.. 취준생에게 7천원은 크지만 이 프로젝트의 완성도를 위해서라면 ....!


Get an API token here를 통해서 token을 얻어온 뒤, token과
Account ID, Account hash를 .env 파일에 저장해두면 우선 준비는 끝난다!


Upload URLs

그럼 cloudflare에서 어떻게 이미지를 저장하는 것일까?
1 . Cloudflare에서 업로드 URL을 받아올 것이다.
먼저 업로드 URL를 받고 나서, cloudflare에 이미지를 업로드를 하고, 그리고나서 그 URL을 우리 데이터베이스에 저장한다고 이해하면 될 것 같다.

upload를 허용하기 위해서 어떻게 해야하는지 공식 홈페이지를 살펴보면, one-time-upload URL를 요청해야 한다고 써있다.

2 . one-time upload URL은 언제, 어떻게 만들어야 할까?
--> 유저가 사진 업로드를 준비할 때 즉, 유저가 사진을 선택할 때를 의미한다고 보면 된다.
(유저가 form을 제출할 때 업로드 URL을 요청할 수도 있지만, 그러면 업로드 프로세스가 좀 더 오래 걸리게 된다)

유저가 여기서 사진을 선택할 때 -> 업로드 URL을 받아올 수 있음
유저가 사진을 선택하는 즉시 업로드 URL을 요청하게 되면, 업로드 프로세스가 좀 더 빠르게 느껴지게 된다.

그럼, 이제 actions.ts에 돌아가서 upload URL를 request 요청하는 코드를 작성해보자

여기서는 이 URL로 POST request만 보내주면 된다.
위의 공식 홈페이지에서 제공하는 request POST 부분에서 그대로 따라서 하면 되기 때문에 간단하다!
--> 우리의 .env파일에 있는 account ID랑 API token을 사용하면 된다.
--> 가장 기본적이지만, 이건 우리 백엔드, 즉 서버에서 실행될 것이다!

그럼 이렇게 불러온 uploadURL은 어디서 사용할 수 있을까? onImageChange()함수에서 사용이 가능하다. onImageChange에서 업로드한 이미지를 가져온 뒤, 그 이미지가 이미지 형태인지, 사이즈가 2MD미만인지 확인한 뒤, URL.createObjectURL(file) URL을 사용하면 파일을 미리보기할 수 있게 된다.

이런 과정을 거친 후, getUploadUrl()함수를 호출해서 결과를 가져오면,

const result = await getUploadUrl();
   console.log(result);

이미지만 업로드를 했을 때, 여러 정보들이 출력되는 것을 확인할 수 있다. 여기서 result(id, uploadURL), success를 가져오기 위해서

const {result, success} = await getUploadUrl()를 호출했다!
만약, success-> true라면, result에서 id와 uploadURL를 분해할당 구조를 이용해 출력한 뒤, setUploadUrl에 넣어두었다!

이 과정까지가 사용자가 이미지를 업로드할 때, uploadURL를 불러오는 과정이다.

유저가 이미지를 업로드 하고 싶어하는 순간에 -> cloudflare로부터 업로드 URL을 받아왔음 (업로드 URL를 만드는 것은 무료임) -> URL을 사용하지 않으면 즉 이미지를 업로드 하지 않으면 URL은 만료될것임.


Image Upload

그럼 이제 image를 cloudflare에 upload할 시간이다!
어떻게 해야할까?
--> cloldflare Form을 생성하고, 이 Form을 uploadUrl에 보내면 된다.

1 . cloudflare Form을 생성하고, 이 Form을 uploadUrl에 보낸다.
2 . 그전에 photo -> string()으로 저장되고 있다. 고로 우리는 데이터에 저장할 photo의 타입을 string 타입으로 변경을 시켜주어야 한다.

그리고 이 두가지 과정을 거친 후, uploadProduct()를 호출하면 된다.

이 과정을 하기 위해 interceptAction() 함수를 생성할 것이다.

먼저, interceptAction이라는 함수를 생성하고, 새로운 FormData 객체인 cloudflareForm을 생성해 주었다.

Cloudflare에 업로드할 데이터 준비 : cloudflareForm.append(uploadUrl, file)는 cloudflareForm 에 파일을 추가한다.

파일 업로드 요청 : fetch API를 사용하여 파일을 업로드 한다.
--> uploadUrl로 HTTP POST 요청을 보낸다.
--> 요청 본문(body)에는 앞서 생성한 cloudflareForm이 사용된다.

이렇게 이미지가 정상적으로 업로드 되었다면, 우리가 해야할 일은, formData안에 photo를 image의 URL로 변경하는 것이다.

그럼 formData의 photo는 어떻게 변경될까?-> user가 작성완료 (submit) 버튼을 클릭하면 formData가 우리에게 넘어오게 된다.

여기서 사용한 photoUrl은 cloudflare 공식 홈페이지에서 제공해주는데, 우리는 위에서
getUploadUrl()를 통해서 이미지를 업로드 했을 때, result에서 id를 얻어왔다. 이 id의 형태는 해당 이미지의 id를 나타낸다. 이 id를 활용해서 photoUrl에 id를 붙여서 formData.set('photo', photoUrl) 는 기존의 photo 필드 값(파일 데이터)을 photoUrl로 덮어쓰게 된다.

이 과정은 클라이언트가 서버로부터 응답을 받아 이미지가 성공적으로 업로드되었음을 확인하고, 그 이미지의 URL을 formData에 저장하는 것이다--> string() 타입에 맞게!

그리고 마지막에 return uploadProduct(prevState, formData); 를 통해서 uploadProduct라는 함수를 호출한다.

  • interceptAction() 함수는 useFormState()의 action으로 전달된다.
  • 이 함수는 클라우드플레어(Cloudflare)에 이미지를 업로드하고, 업로드된 이미지의 URL을 받아온다
  • 그 후, 이 URL을 formData의 photo 필드에 저장한다. 즉, 원래 FormData에 담긴 파일 데이터가 이미지 URL(string 타입) 로 변경된다.
  • 마지막으로 uploadProduct() 함수를 호출하면서, 변경된 formData를 전달하여 이후의 제품 업로드 작업을 수행한다.
  • 따라서, 이 함수는 이미지를 업로드하고, 해당 이미지의 URL을 FormData에 저장한 뒤, 그 데이터를 사용해 uploadProduct()를 호출하는 역할을 하고 있다.

이렇게 getUploadUrl()함수를 통해서 uploadUrl을 불러온 뒤, cloudflareForm을 생성해서, uploadUrl에 POST 요청을 보낸 후, 이미지를 저장한 뒤,
저장한 이미지의 id와 image delivery URL을 합친 뒤, 해당 URL을 formData에 photo에 데이터로 저장하게 되면서 string() 타입이 일치하게 된다!


Variants

cloudflare에 저장된 image는 불러올 때, variants를 지정해주어야 한다.
variants는 같은 이미지를 여러 크기나 해상도로 제공하기 위해 미리 정의된 설정을 의미한다.

기본적으로 public, avatar이 있는데,

  • public 변형은 원본 이미지와 비교적 큰 해상도와 품질을 유지
  • avatar 설정은 사용자 프로필 사진과 같은 작은 크기의 이미지를 제공하기 위해 최적화

여기서 새로운 Variants를 생성할 수도 있다.

transform-images 해당 사이트에 들어가서 다양한 속성 (rotate, sharpen)를 추가할 수 있다.


profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글