Next.js 캐럿마켓 클론코딩(CACHING - code challenge)

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

Code Challenge

1 . 우리의 제품 페이지 (/home) 을 위한 caching전략을 고르자.
구현하고 제품 업로드 server action에 연결시켜보자.
우리가 caching을 사용하고 있지만, 캐시를 재검증하는 전략을 가지고 있지 않다면 유저가 제품을 업로드할 때마다 그들은 홈페이지에서 최신화된 페이지를 볼 수 없을 것이다

2 . 제품 편집 페이지 구축 (+ 제품 소유자가 제품을 편집할 때 그 cache가 다시 새로고침 되도록)

캐럿 마켓의 caching 전략은?

사실 강의들을 때에는 오~ 하고 이해하고 넘어갔는데 막상 해보려니 진짜 진짜 막막해서 한 20분동안 계속 이곳 저곳 찾아보면서 멍을 때렸다는..

사실 그래서 지금 code challenge를 제출하고 (?) 니꼬의 응답을 기다리는 중이라 이게 맞는 방법인지는 잘 모르겠지만, 우선 내가 생각하고 진행한 코드를 설명해보도록 하겠다.

우선, /home, /products/[id] 이 두개를 언제 새로고침을 할 것인지를 생각해보면,
1 . 상품을 추가할 때,
2 . 상품을 수정할 때

딱 이 두개인 것 같다는 생각이 들었다.

고로, home/page.tsx에서
getInitialProducts() 함수를 호출할 때, 'home-products'라는 이름으로 데이터에 저장한다.

또한 태그 'products-page' 가 호출되거나 관련 동작이 있을 때 이 캐시가 무효화되거나 갱신될 수 있다.

여기서, getInitialProducts()가 호출되어야 할 때는 언제일까 ? -> 바로 상품을 추가할 때 , 상품을 수정할 때 이다. (db.product.create, db.product.update 되는 순간)

add/actions.ts 에서 새로운 products를 저장하고, revalidateTag()를 호출하면서 getInitialProducts()가 새로고침이 되도록 해주었다.

edit/actions/ts에서 3개의 revalidateTag()를 실행시켜주었다.

'products-page' -> home/page.tsx와

getProductTitle은 produts/[id] 페이지에서 페이지 title를 상품의 title로 동적으로 변경하기 위한 함수이고, getProduct()함수는 상품의 상세 정보를 보여주기 위한 함수이다.

edit/page.tsx에서 수정 완료 -> products/[id] 페이지로 하기 때문에, 이때 products/[id] 페이지의 데이터들도 모두 수정이 되었을 때, 새로고침이 되어야 하기 때문에 각각의 'product-title', 'product-detail'을 revalidateTag()로 호출해 주었다!


edit.page

내가 만들었지만, 생각보다 진짜 잘만들었음 ..ㅋㅋ

이게 edit/page.tsx인데, 여기서 이전에 내가 저장한 데이터를 불러다가 보여주고, 수정을 할 수 있다.

이전에 저장되어있던 데이터를 불러와주고, 수정하고 싶은 것만 수정할 수 있도록 해주었다!
+ 사진 또한 파일 선택을 누르면 사진 또한 수정이 가능하다

📦products
 ┣ 📂[id]
 ┃ ┣ 📂edit
 ┃ ┃ ┣ 📜actions.ts
 ┃ ┃ ┗ 📜page.tsx

폴더 구조는 이렇게 이루어져 있다.

edit/page.tsx

import db from '@/lib/db';
import { notFound } from 'next/navigation';
import EditForm from '@/components/EditForm';

async function getProduct(id: number) {
  const product = await db.product.findUnique({
    where: { id },
    select: {
      photo: true,
      title: true,
      price: true,
      description: true,
      id: true,
    },
  });
  return product;
}

export default async function EditPage({ params }: { params: { id: string } }) {
  const id = Number(params.id);
  const product = await getProduct(id);
  if (!product) {
    return notFound();
  }

  return <EditForm product={product} />;
}

getProduct()로 params로 받아온 id로 상품의 상세 정보를 가져오고, 이 상세 정보를 <EditForm/>에 넘겨주었다.

왜 따로 컴포넌트로 분리했느냐 ? 하면,

<EditForm /> 컴포넌트에서 useFormState()와 같은 훅을 사용하여 상태를 관리하고 검증을 하게 되는데, "use client" 지시문을 사용해야 한다. 이 지시문은 해당 컴포넌트가 클라이언트 사이드에서 실행되어야 함을 Next.js에게 알리는 역할을 하게 된다.

이에, db에서 데이터를 불러오는 코드와 같이 사용할 수 없기 때문에 컴포넌트를 따로 분리했다.

<EditForm/>

'use client';

import Image from 'next/image';
import Input from './input';
import Button from './button';
import { useState } from 'react';

import EditAction from '@/app/products/[id]/edit/actions';
import { useFormState } from 'react-dom';
import { getUploadUrl } from '@/app/products/add/actions';

interface IEditProps {
  product: {
    id: number;
    title: string;
    photo: string;
    description: string;
    price: number;
  };
}

export default function EditForm({ product }: IEditProps) {
  const [preview, setPreview] = useState<string | null>(null);
  const [uploadUrl, setUploadUrl] = useState<string>('');
  const [photoId, setPhotoId] = useState<string>('');

  const onImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const {
      target: { files },
    } = e;
    if (!files) {
      return;
    }
    const file = files[0];

    if (!file.type.startsWith('image/')) {
      return {
        error: '이미지 파일만 업로드 가능합니다.',
      };
    }

    const fileSizeInMb = file.size / (1024 * 1024);

    if (fileSizeInMb > 2) {
      return {
        error: '이미지의 크기가 2MB를 초과하는 이미지는 업로드 할 수 없습니다.',
      };
    }

    // 새로 선택된 이미지의 미리보기 URL 생성
    const url = URL.createObjectURL(file);
    setPreview(url);

    // 업로드 URL 가져오기
    const { result, success } = await getUploadUrl();
    if (success) {
      const { id, uploadURL } = result;
      setUploadUrl(uploadURL);
      setPhotoId(id);
    }
  };

  const interceptAction = async (prevState: any, formData: FormData) => {
    const file = formData.get('photo');
    if (file instanceof File) {
      // 파일 객체인지 확인
      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/2YRH3jpkhrWOOYZOL3zGhA/${photoId}`;
      formData.set('photo', photoUrl);
    }
    return EditAction(prevState, formData);
  };

  const [state, action] = useFormState(interceptAction, null);

  return (
    <div>
      <form className="p-5 flex flex-col gap-5" action={action}>
        <div className="relative aspect-square w-full bg-contain">
          {preview ? (
            <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 bg-center bg-cover"
              style={{
                backgroundImage: `url(${preview})`,
              }}
            />
          ) : (
            <Image
              src={`${product.photo}/public`}
              alt={product.title}
              fill
              className="object-cover rounded-md"
            />
          )}
        </div>

        <input
          onChange={onImageChange}
          type="file"
          id="photo"
          name="photo"
          accept="image/*"
          className="mt-2"
        />
        <input type="hidden" name="id" value={product.id} />
        <Input
          name="title"
          required
          placeholder="제목"
          type="text"
          errors={[]}
          defaultValue={product.title}
        />
        <Input
          name="price"
          type="number"
          required
          placeholder="가격"
          errors={[]}
          defaultValue={product.price}
        />
        <Input
          name="description"
          type="text"
          required
          placeholder="자세한 설명"
          errors={[]}
          defaultValue={product.description}
        />
        <Button text="작성 완료" />
      </form>
    </div>
  );
}

거의 add/page.tsx와 비슷해서 중복되는 컴포넌트는 분리시키고 싶긴하다.
어쨌든 거의 설명할 부분은 없어서 스무스 하게 넘어가고,

edit/page.tsx

'use server';

import z from 'zod';
import getSession from '@/lib/session';
import db from '@/lib/db';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

const productSchema = z.object({
  photo: z.string({
    required_error: 'Photo is required',
  }),
  title: z.string({
    required_error: 'Title is required',
  }),
  description: z.string({
    required_error: 'Description is required',
  }),
  price: z.coerce.number({
    required_error: 'Price is required',
  }),
  id: z.coerce.number(),
});

export default async function EditAction(prevState: any, formData: FormData) {
  const data = {
    photo: formData.get('photo'),
    title: formData.get('title'),
    price: formData.get('price'),
    description: formData.get('description'),
    id: formData.get('id'),
  };

  const result = productSchema.safeParse(data);
  if (!result.success) {
    return result.error.flatten();
  } else {
    const session = await getSession();
    if (session.id) {
      const updateProduct = await db.product.update({
        where: {
          id: result.data.id,
        },
        data: {
          title: result.data.title,
          photo: result.data.photo,
          price: result.data.price,
          description: result.data.description,
        },
        select: {
          id: true,
        },
      });

      console.log(result);
      revalidateTag('products-page');
      revalidateTag('product-title');
      revalidateTag('product-detail');

      redirect(`/products/${updateProduct.id}`);
    }
  }
}

db.product.update({}) 를 활용해서 상품의 id를 가져와 데이터를 update를 해주었다.

진짜 여기서 삽질을 거의 2시간을 했다.

처음에,

const updateProduct = await db.product.update({
        where: {
          id: session.id,
        },

를 해버리니.. session.id는 로그인한 id를 나타내는건데...
될리가 있나...
이러고 있다가

아...? 상품 id에 데이터를 update해야 하잖아.. 싶어서 급하게 edit/page.tsx에서
getProduct()에서 id를 받아온 뒤,
<EditForm/>에서 <input type="hidden" name="id" value={product.id} /> input을 hidden으로 숨긴 뒤, name에 id을 지정해주고, value 값에 product.id를 넘겨주어서, actions.ts에서 formData에서 id를 가져와서 사용할 수 있게 되었다..!

삽질 2시간.. 나름 뿌듯했다 ❤️

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

0개의 댓글