1 . 우리의 제품 페이지 (/home) 을 위한 caching전략을 고르자.
구현하고 제품 업로드 server action에 연결시켜보자.
우리가 caching을 사용하고 있지만, 캐시를 재검증하는 전략을 가지고 있지 않다면 유저가 제품을 업로드할 때마다 그들은 홈페이지에서 최신화된 페이지를 볼 수 없을 것이다
2 . 제품 편집 페이지 구축 (+ 제품 소유자가 제품을 편집할 때 그 cache가 다시 새로고침 되도록)
사실 강의들을 때에는 오~ 하고 이해하고 넘어갔는데 막상 해보려니 진짜 진짜 막막해서 한 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.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시간.. 나름 뿌듯했다 ❤️