⚠️ Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.
This can happen if a SSR-ed Client Component used
- A server/client branch 'if (typeof window !== 'undefined')'.
- Variable input such as 'Date.now()' or 'Math.random()' which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed
which messes with the HTML before React loaded.
See more info here: https://nextjs.org/docs/messages/react-hydration-error
탭마다 고정된 레이아웃이 있어서 공용 컴포넌트로 만들어서 RootLayout에 추가했다. 그랬더니 하이드레이션 에러
개발자가 기능 구현에만 ‘딱’ 집중할 수 있도록 필요한 모든 프로그래밍적 재원을 지원하는 ‘기술의 조합’
React.js가 가지고 있는 기능을 확장, 웹 애플리케이션 개발에 필요한 다양한 기능과 구조를 제공한다.
💡 6가지 원칙
1. out-of-the-box functionality requiring no setup : 별도의 설정 없이 바로 사용할 수 있는 기능을 제공
2. JavaScript everywhere + all functions are written in JavaScript : JavaScript를 통해 모든 작업을 수행
3. automatic code-splitting and server-rendering : 서버에서 렌더링을 수행하여 초기 로딩 속도를 개선
4. configurable data-fetching : 개발자가 필요에 따라 다양한 방식으로 데이터를 패칭
5. anticipating requests : 사용자가 원하는 것이 무엇인지를 먼저 예측 = 요구사항 예측
6. simplifying deployment : 애플리케이션을 쉽게 배포
다양한 렌더링 기법
SSG(Static Site Generaton)
fetch한 데이터는 영원히 변치 않아요. 계속 컴포넌트를 갱신할 필요가 없어요.
서버에서 페이지를 렌더링하여 클라이언트에게 HTML을 전달하는 방식
최초 빌드시에만 생성
첫 페이지 로딩 시간이 매우 짧아(TTV) 사용자가 빠르게 페이지를 볼 수 있음
정적인 데이터에만 사용
마이페이지 처럼 데이터에 의존하여 화면을 그려주는 경우 사용 불가
이전에 SSG를 구현하기 위해서는 예전 getStaticPaths 가 필요했지만 13 버전에서는 generateStaticParams 함수로 바뀌었다. App Route 등장
// (1) 첫 번째 방법 : 아무 옵션도 부여 x
const SSG = async () => {
const response = await fetch(`https://randomuser.me/api`);
const { results } = await response.json();
const user: RandomUser = results[0];
}
// (2) 두 번째 방법 : force-cache
const SSG = async () => {
const response = await fetch(`https://randomuser.me/api`, {
cache: "force-cache",
});
const { results } = await response.json();
const user: RandomUser = results[0];
}
// 결과 : 아무리 새로고침을 하여도 동일한 페이지만 출력
ISR(Incremental Static Regeneration)
fetch한 데이터는 가끔 변해요. 일정 주기마다 가끔씩만 컴포넌트를 갱신해줘요.
정적 페이지를 먼저 보여주고, 필요에 따라 서버에서 페이지를 재생성하는 방식
설정한 주기만큼 페이지를 계속 생성
정적 페이지를 먼저 제공하므로 사용자 경험이 좋으며, 콘텐츠가 변경되었을 때 서버에서 페이지를 재생성하므로 최신 상태를 (그나마) 유지
동적인 콘텐츠를 다루기에 한계
마이페이지 처럼 데이터에 의존하여 화면을 그려주는 경우 사용 불가
// (1) 첫 번째 방법 : 옵션 추가
const ISR = async () => {
const response = await fetch(`https://randomuser.me/api`, {
next: {
revalidate: 5,
},
});
const { results } = await response.json();
const user: RandomUser = results[0];
}
// (2) 두 번째 방법 : page.tsx 컴포넌트에 revalidate 추가
// src>app>rendering>page.tsx
import ISR from "@/components/rendering/ISR";
import React from "react";
export const revalidate = 5;
const RenderingTestPage = () => {
return (
<div>
<h1>4가지 렌더링 방식을 테스트합니다.</h1>
<ISR />
</div>
);
};
export default RenderingTestPage;
// 결과 : 주어진 시간에 한 번씩 갱신
SSR(Server Side Rendering)
fetch한 데이터는 실시간으로 계속 바뀌어요. 컴포넌트 요청이 있을 때 마다 데이터를 갱신해서 최신 데이터만 제공해야 해요.
빌드 시점에 모든 페이지를 미리 생성하여 서버 부하를 줄이는 방식
빠른 로딩 속도(Time To View)와 높은 보안성을 제공
SEO 최적화 좋음, 실시간 데이터 사용
마이페이지 구성 가능
요청할 때 마다 페이지를 만들어야 하며, 콘텐츠 변경 시 전체 사이트를 다시 빌드 해야하므로 서버 과부하 가능성
// (1) 첫 번째 방법 : no-cache 옵션
const SSR = async () => {
const response = await fetch(`https://randomuser.me/api`, {
cache: "no-cache",
});
const { results } = await response.json();
const user: RandomUser = results[0];
}
// 결과 : 요청이 있을 때 마다 지속해서 갱신
CSR(Client Side Rendering)
fetch한 데이터는 실시간으로 계속 바뀌어요. 컴포넌트 요청이 있을 때 마다 데이터를 갱신해서 최신 데이터만 제공해야 해요.
브라우저에서 JavaScript를 이용해 동적으로 페이지를 렌더링하는 방식
사용자와의 상호작용이 빠르고 부드러움
서버 부하가 적음
첫 페이지 로딩 시간(Time To View)이 김
JavaScript가 로딩되고 실행될 때까지 페이지가 비어있어 검색 엔진 최적화(SEO)에 불리
// (1) 첫 번째 방법 : “use client” 옵션
“use client”
const SSG = () => {
const [user, setUser] = useState<RandomUser | null>(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`https://randomuser.me/api`);
const { results } = await response.json();
setUser(results[0]);
};
fetchUser();
}, []);
if (!user) {
return <div>로딩중...</div>;
}
}
// 결과 : 요청이 있을 때 마다 지속해서 갱신
✍🏻 렌더링 기법 표 정리
구분
빌드
SEO
응답 시간
최신 데이터
SSG
길다
좋음
짧다
아님
ISR
길다
좋음
짧다
아닐 수 있음
SSR
짧다
좋음
길다
맞음
CSR
짧다
안좋음
보통
맞음
Next.js 프로젝트 생성
npx create-next-app@latest
Link
Next.js는 <Link>라는 리액트 컴포넌트를 제공합니다. <Link>태그는 기본 HTML의 <a>태그를 확장한 개념
1. prefetching을 지원
- 뷰포트(현재 보이는 부분)에 링크가 나타나는 순간 해당 페이지의 코드와 데이터를 미리 가져오는 프리페칭 기능을 지원
❓ 사용자의 마우스가 링크 위에 mouseover 되는 순간 네트워크 요청이 생긴다는 것일까?
💡 렌더링된 링크가 사용자의 뷰포트 내에 나타나는 순간,
Next.js는 해당 페이지의 데이터와 필요한 자원(예: JavaScript 파일)을 미리 가져오기 시작
2. client-side navigation 지원
- 브라우저가 새 페이지를 로드하기 위해 서버에 요청을 보내는 대신
클라이언트 측에서 페이지를 바꾸어 주기 때문에 페이지 전환 시 매우 빠른 사용자 경험(UX)을 제공
- 필요한 JSON 데이터만 서버로부터 가져와서 클라이언트에서 페이지를 재구성하여 렌더링
요청된 데이터의 URL에서 쿼리 파라미터를 가져온다. supabase에서 range라는 범위 속성을 제공해준다.
(page - 1) * limit, page * limit - 1) 는 만약 페이지가 2, 리밋이 10일 경우 (2-1) * 10 = 시작 인덱스, 2 * 10 - 1 = 끝 인덱스 이렇게 한다면 시작 인덱스는 10, 끝 인덱스는 19가 된다. 하나의 페이지마다 10개의 데이터를 보여주는 것!
`/api/review?product_id=${productId}&page=${page}&limit=${limit}` //요청 url
const { searchParams } = new URL(request.url); //상대 경로는 URL 객체에서 직접 설정
const productId = searchParams.get('product_id'); // 쿼리 파라미터에서 product_id 가져오기
const page = Number(searchParams.get('page')) || 1; // 페이지 번호
const limit = Number(searchParams.get('product_id')) || 10; // 리뷰 10개씩
페이지네이션을 구현할 때 총 리뷰 개수를 알아야 전체 페이지 수를 계산할 수 있다. 사용자 경험으로 보았을 땐 사용자가 특정 페이지를 선택했을 때, 그 페이지가 유효한지 확인할 수 있다. 만약 총 데이터 수가 25개이고 페이지당 리밋이 10개라면 2.5페이지가 나와야하는데 이때 Math.ceil() 사용해 정수로 바꿔주면서 3페이지로 올림처리한다.
const { count: totalReviews, error: countError } = await supabase
.from('reviews')
.select('*', { count: 'exact' }) // 총 행 수 계산 => 반환, count 속성에 총 리뷰 수
.eq('product_id', productId);
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 400 });
}
const totalPages = Math.ceil((totalReviews || 0) / limit); // 총 페이지 수 계산
응답 값
데이터 패칭
쿼리키에 데이터를 저장해줬다. fetch를 사용해서 API엔드포인트에 GET요청을 보내고, 응답을 JSON으로 변환해서 반환해준다. 여기서 productId는 리뷰를 가져올 특정 상품의 id이다.
페이지네이션 ui는 shadcn을 사용했다. shadcn은 쓰면 쓸수록 좋은 것 같다. 간단하고 예쁘고..~
PaginationPrevious는 현재 페이지가 1보다 클때 이전 페이지로 이동할 수 있다.
PaginationNext는 리뷰 총 페이지보다 작을 때 다음 페이지로 이동할 수 있다.
Array.from은 reviewData.totalPages 만큼 배열을 생성한다. 현재 총 페이지는 2페이지이다. onClick={() => handlePageChange(index + 1)} 클릭한 페이지 번호를 인자로 받아서 현재 페이지를 변경한다. isActive={currentPage === index + 1} 현재 페이지와 클릭한 페이지 번호가 일치하면 active한 상태를 보여준다.
삭제까지 모두 완료해서 체크 상품만 결제 되도록 해보려고한다. 처음엔 체크 전에도 장바구니에 담긴 상품의 결제금액이 바로 브라우저에 보였는데 체크 전까진 0원으로 보이도록 하려고한다.
처음 구조는 아래와 같다. CartList는 장바구니 테이블을 보여주는 페이지이다. 그 아래 DataTable과 CartFixedButtons를 임포트하고 있는데 여기서 내가 사용한 shadcn의 데이터 테이블 컴포넌트는 컬럼과 데이터 테이블 파일구조를 분리해서 쓰는 것을 추천해서 그렇게 사용했다.
shadcn의 데이터 테이블 컴포넌트를 처음에 쓸 때 생소했던게 컬럼 컴포넌트는 함수형 컴포넌트가 아니라 배열만 덜렁 들어있었다. 컬럼을 추가하면 테이블에 데이터를 추가할 수 있었지만 함수형 컴포넌트가 아니라 따로 상태로 관리해주거나 하기 힘들었다. 그래서 수량 변경이나 삭제 같은 경우에도 새로고침을 해야 브라우저에 반영됐다. 그래서 아래와 같이 구조를 변경했다.
📌 shadcn 제공 코드
DataColumns.tsx
"use client"
import { ColumnDef } from "@tanstack/react-table"
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: "status",
header: "Status",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "amount",
header: "Amount",
},
]
<PayButton product={product} orderNameArr={orderNameArr} /> product에서 아래와 같은 오류가 났다. 타입 오류인데 null 값이 포함된 배열이 '{ name: string | null; amount: number; quantity: number; id?: string | undefined; }' 이런 특정 타입에 할당될 수 없다는 것 같다. 오늘도 난 구글링을 한다..,,
export const CartFixedButtons = ({ data, selectedItems }: CartButtonProps) => {
const totalPrice =
data?.reduce((acc, item) => {
if (selectedItems.includes(item.product_id ?? '')) {
const price = item.product_price ?? 0;
const quantity = item.count ?? 0;
return acc + price * quantity;
}
return acc;
}, 0) || 0;
const totalAmount = totalPrice + DELIVERY - COUPON;
const orderNameArr = data
.map((item) => {
if (selectedItems.includes(item.product_id as string)) {
return item.product_name;
}
return null;
})
.filter((name): name is string => name !== null);
// 전달 데이터 형식
// {
// name: "청송 사과",
// amount: 8000,
// quantity: 3,
// }
const product = data
.map((item) => {
if (selectedItems.includes(item.product_id as string)) {
return {
name: item.product_name,
amount: (item.product_price ?? 0) * (item.count ?? 0),
quantity: item.count ?? 0
};
}
return null; //장바구니 선택 상품 외 null 처리
})
타입가드로 is를 사용했는데 사용한 이유는특정 타입을 체크하는 함수에서 반환되는 값이 true 이면 해당 함수를 사용하는 블록 안에서도 인자로 받은 값의 타입을 특정 타입으로 확정시켜 줄 수 있도록 하는 기능을 한다고한다. 원래 특정 변수를 함수로 넘겨서 타입을 체크하게 되면 그 함수를 사용하고 있는 스코프에서는 타입 축소가 안되어 불편했었는데, 이 키워드를 활용하면 그 부분을 해결 할 수 있다고해서 사용했다 ㅎ is 는 typescript 에서 지원하고 있는 키워드라고 한다.
spaceBetween : 슬라이드 사이 간격(margin) slidesPerView : 보여질 슬라이드 수 centeredSlides: 센터 모드 pagination={{ clickable: true }} : 페이지네이션 버튼을 눌렀을 때 이동 가능 navigation : Prev, Next 버튼 autoplay={{ delay: 2000, disableOnInteraction: false }} : 자동 재생 / 지연시간 / 사용자 상호작용시 슬라이더 일시 정지 비활성 loop={true} : 반복여부
프로젝트 적용
스타일은 tailwind css를 사용하고 있어서 global.css에 최소한의 스와이퍼 슬라이드 스타일을 적용해줬다. 페이지네이션(인디케이터)에 스타일을 적용해줘야했기때문도 있다 ㅎ 의문점은 페이지네이션 스타일을 페이지마다 다르게 할 순 없을까..? 모듈로 들어가 있어서 스타일을 어떻게 먹여줘야할지 잘모르겠다.
이전에 카운트 버튼을 참고해서 구현했다. useDeleteProduct 훅을 생성하고 아래 쿼리문을 썼다. 원래는 함수로 만들었는데 빌드 테스트할때 에러가 났다. useQueryClient 훅이 deleteProduct 함수 안에서 호출되는 것은 React 훅 규칙에 위배된다는 에러였고, 훅은 반드시 React 함수 컴포넌트 또는 사용자 정의 훅 내에서만 호출해야한다는 오류였다.
Error: React Hook "useQueryClient" is called in function "deleteProduct" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use". react-hooks/rules-of-hooks
여기서 또 나타난 빌드에러..☠️ columns가 배열로 되어있어서 const mutation = useDeleteProduct(); 을 컬럼 배열 안에 넣는 것은 훅 규칙에 위배된다는 말이었다. 훅은 반드시리액트 컴포넌트 안에 호출해야한다고 한다.
Error: React Hook "useDeleteProduct" cannot be called at the top level. React Hooks must be called in a React function component or a custom React Hook function. react-hooks/rules-of-hooks