⚠️ 에러

컴포넌트화된 svg 아이콘을 사용하려고 하니 아래와 같은 에러가 발생.

검색해보니 컴포넌트의 이름이 잘못되었거나, import 경로가 잘못되었을 때 발생한다고 한다.

근데 import 정상적으로 했는데..?

 

svgr/webpack 설치 후 next.config.ts에 아래와 같은 코드를 추가해주어야한다

아래 코드 설정은 webpack에 맞춰진 설정이기때문에 생긴 에러라는 것을 알게됐다.

기존 웹팩과는 달리 파일로드하는 방법이 변경되어서 발생하는 문제라고 한다.

 webpack(config) {
    config.module.rules.push({
      // 웹팩설정에 로더 추가함
      test: /\.svg$/,
      issuer: {
        test: /\.(js|ts)x?$/,
      },
      use: ["@svgr/webpack"],
    });

    return config;
  },

 

 

💡 해결

"turbopack svgr" 이라고 검색하니 나와 같은 오류를 맞닥드린 이들이 많았다.. 나의 구세주

아직 turbo은 svgr을 아직 정식적으로 지원하지않는다고 한다.

 

[turbopack] SVG via svgr support · Issue #4832 · vercel/turborepo

What version of Turborepo are you using? next@13.4.1 What package manager are you using / does the bug impact? npm What operating system are you using? Mac Describe the Bug In Next.js, I use the SV...

github.com

 

next.config.ts에 아래와 같은 코드를 추가해주면 정상적으로 사용할 수 있다.

  // turbopack에서 svgr 사용방법 - 추가코드
  experimental: {
    turbo: {
      rules: {
        "*.svg": {
          loaders: ["@svgr/webpack"],
          as: "*.js",
        },
      },
    },
  },

 

⚠️ 에러 메세지

⚠️ 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에 추가했다. 그랬더니 하이드레이션 에러

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body className="bg-bg-primary flex flex-col text-white h-screen">
        <Provider>
          <HeaderLayout>{children}</HeaderLayout>
        </Provider>
      </body>
    </html>
  );
}

 

 

 

💡 해결

useEffect 로 클라이언트에서만 실행

서버에서 생성한 html과 리액트에서 렌더링한 DOM 트리가 서로 일치 하지 않기 때문에 생긴 에러로 첫 렌더링때 의도적으로 원하는 상태를 업데이트하지 않게 하는 방법이다.

 

HeaderLayout.tsx

 const CurrentDate = () => {
    const [nowDate, setNowDate] = React.useState<string>(
      dayjs().locale("ko").format("YYYY년 MM월 DD일 (dd)")
    );

    React.useEffect(() => {
      const interval = setInterval(() => {
        setNowDate(dayjs().locale("ko").format(`YYYY년 MM월 DD일 (dd)`));
      }, 60000);

      return () => {
        clearInterval(interval);
      };
    }, []);

    return <li className="px-10 py-3 font-medium">{nowDate}</li>;
  };
  
  return (
    <CurrentDate />
  )

 

 

그 외 다른 방법으로는 아래와 같다

 

Disabling SSR on specific components

import dynamic from 'next/dynamic'
 
const NoSSR = dynamic(() => import('../components/no-ssr'), { ssr: false })
 
export default function Page() {
  return (
    <div>
      <NoSSR />
    </div>
  )
}

 

 

Using suppressHydrationWarning

html 태그에 suppressHydrationWarning 추가

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body className="bg-bg-primary flex flex-col text-white h-screen">
        <Provider>
          <HeaderLayout>{children}</HeaderLayout>
        </Provider>
      </body>
    </html>
  );
}

 

Nextron 소개

Nextron은 Next.js와 Electron의 강력한 기능을 결합하여, 
개발자들이 멀티 플랫폼 데스크탑 애플리케이션을 보다 간단하고 효율적으로 만들 수 있도록 도와주는 패키지

 

Electron.js 장단점

  • 플랫폼 독립성
  • 사용의 용이성
  • 데스크탑 API 접근성
  • 앱 크기 : Electron은 Chromium을 함께 번들링하기 때문에, 생성되는 애플리케이션의 크기가 상당히 클 수 있다.
  • 성능 : Electron 애플리케이션은 시스템 리소스를 많이 사용할 수 있으며, 특히 배터리 에너지와 시스템 RAM 소모가 큰 것으로 알려져 있다.
  • Next.js 프로젝트를 일렉트론으로 패키징할 경우 경로 이슈가 생길 수 있다. (실제 진행해봤을 때 가장 큰 단점)

 

Next.js + Typescript + TailwindCSS 프로젝트 생성

실제 프로젝트에서 이 조합을 가장 많이 사용해서 위 옵션들로 프로젝트를 생성했다.

회사에서 처음 일렉트론으로 패키징을 하는 것을 알게되었고 총 두가지 프로젝트를 패키징하여 납품하였다.

허나.. 넥스트 프로젝트를 일렉트론으로 패키징하려니 경로 이슈가 정말 많이 생겼다.

 

아래 명령어로 하면 안됨 진짜 안됨 스타일도 안먹히고 내가 생성해야하는 파일이 너무 많음

npx create-nextron-app my-app --example basic-lang-typescript with-tailwindcss

 

 

⭐️ 꼭 꼭 꼭 아래 명령어로 앱을 생성해야한다.

이렇게 하니까 기본으로 타입스크립트가 내장되어있고 스타일도 적용됐다

아래 실행한 앱처럼 스타일이 적용되어있어야함!! 꼭!!

yarn create nextron-app my-app --example with-tailwindcss

 

 

넥스트 프로젝트를 일렉트론으로 패키징할땐 라우팅이 안됐는데 넥스트론으로 하니 된다.. 멋지다..

하지만 아직까지 API 경로 지원은 안되는 것 같다. 

 

 

 

파일 구조

Next.js 는 프레임워크

개발자가 기능 구현에만 ‘딱’ 집중할 수 있도록 필요한 모든 프로그래밍적 재원을 지원하는 ‘기술의 조합’

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 데이터만 서버로부터 가져와서 클라이언트에서 페이지를 재구성하여 렌더링

 

 

useRouter

사용 시 "use client"; 최상단에 기입

router.push, router.replace, router.back, router.reload

 

 

router.push

  • 새로운 URL을 히스토리 스택에 추가
  • 이동한 페이지의 URL이 히스토리 스택의 맨 위에 쌓임

router.replace

  • 현재 URL을 히스토리 스택에서 새로운 URL로 대체
  • 현재 페이지의 URL이 새로운 URL로 교체되며, '뒤로 가기'를 클릭했을 때 이전 페이지로 이동

router.back

  • 히스토리 스택에서 한단계 뒤로 이동
  • 브라우저의 뒤로가기 버튼과 같은 효과

router.reload

  • 현재 페이지 새로고침
  • 히스토리 스택에 영향X, 페이지의 데이터를 최신 상태로 업데이트하고 싶을 때 사용할 수 있음

 

 

Route Handlers

GET / POST / PATCH / PUT / DELETE 메서드로 웹 환경에서 요청과 응답 주고 받는 Restful API

export async function GET(request: Request) {
  console.log("GET /api/test");
}

export async function POST(request: Request) {
  console.log("POST /api/test");
}

export async function PUT(request: Request) {
  console.log("PUT /api/test");
}

export async function DELETE(request: Request) {
  console.log("DELETE /api/test");
}

export async function PATCH(request: Request) {
  console.log("PATCH /api/test");
}

 

flex로 대부분 다 해결이되어서 잘 사용하지 않아 해도해도 적응 안되는 grid.. 최종 프로젝트를 진행하면서 grid 공부를 많이 했다. 여러 포스팅 글과 MDN을 참고해서 포스팅해보려고한다! 구글링하지 않도록 여기에 최대한 모든 정보를 담아놔야지

 

 

CSS 그리드 레이아웃

그리드 레이아웃은 가로와 세로 두 방향의 2차원 레이아웃 시스템이다. flex는 1차원 레이아웃 시스템으로 차이점이 있다.

flex보다는 더 복합적인 레이아웃 디자인이 가능하다는 장점이 있고, MDN에 따르면 페이지에서 페이지로 이동할 때 요소가 널뛰거나 너비가 바뀌지 않는 디자인 생성에 도움을 주어 웹 사이트의 일관성을 높여준다는 장점이 있다고 한다.

 

크롬 개발자 도구에서 grid를 명확하게 보여주는 기능이 생겼다고한다! 이번 프로젝트 때 아주 잘 활용했다

 

 

 

CSS 그리드 생성

부모요소 👉🏻 그리드 컨테이너로 감싸고 있어야한다. 부모요소에 grid 속성을 주면 컨테이너는 그리드의 영향을 받는 전체의 영역이 된다.

자식요소 👉🏻 그리드 아이템들은 그리드 속성에 따라 자유롭게 배치된다.

 

 

 

CSS 그리드 용어

 

container 
gird 속성을 적용하는 부모 요소. grid 속성이 영향을 미치는 전체 영역이다.

item
컨테이너의 자식 요소들. 아이템들이 Grid 규칙에 의해 배치

cell
grid의 한칸

gap
셀 사이의 간격

column


row


line
그리드를 그리는 가로 세로의 선

area
그리드 유닛이 묶인 영역으로 고유한 식별자를 가지며, 식별자를 통해 요소를 배치

track
그리드 라인 사이의 행 또는 열

 

 

 

CSS 그리드 형태 정의

컨테이너에 Grid 트랙의 크기들을 지정해주는 속성이다.

grid-template-columns

grid-template-columns: 200px 200px 500px
grid-template-columns: 1fr 1fr 1fr 
grid-template-columns: repeat(3, 1fr) 
grid-template-columns: 200px 1fr 
grid-template-columns: 100px 200px auto

 

 

grid-template-rows

grid-template-rows: 200px 200px 500px
grid-template-rows: 1fr 1fr 1fr 
grid-template-rows: repeat(3, 1fr) 
grid-template-rows: 200px 1fr
grid-template-rows: 100px 200px auto

 

Tailwind CSS

/* col */
grid-cols-1	grid-template-columns: repeat(1, minmax(0, 1fr));
grid-cols-2	grid-template-columns: repeat(2, minmax(0, 1fr));
grid-cols-3	grid-template-columns: repeat(3, minmax(0, 1fr));
grid-cols-4	grid-template-columns: repeat(4, minmax(0, 1fr));
grid-cols-5	grid-template-columns: repeat(5, minmax(0, 1fr));


/* row */
grid-rows-1	grid-template-rows: repeat(1, minmax(0, 1fr));
grid-rows-2	grid-template-rows: repeat(2, minmax(0, 1fr));
grid-rows-3	grid-template-rows: repeat(3, minmax(0, 1fr));
grid-rows-4	grid-template-rows: repeat(4, minmax(0, 1fr));
grid-rows-5	grid-template-rows: repeat(5, minmax(0, 1fr));

 

grid-template-columns: repeat(3, 1fr)

repeat(반복횟수, 반복값)

grid-template-columns: 1fr 1fr 1fr 이 코드와 같은 뜻이다.

 

grid-template-columns: 1fr 1fr 1fr은 아래와 같다. fr은 fraction이라고하는데 잘모르겠다. 똑같은 비율로 주고싶을 때 이렇게 사용하면된다. 1:1:1 비율의 컬럼 3개라는 뜻. 여기서 위 코드를 더욱 간단하게 만들 수 있다.

 

 

auto-fill / auto-fit

auto-fill과 auto-fit은 column의 개수를 미리 정하지 않고 설정된 너비가 허용하는 한 최대한 셀을 채운다.

 

최소 너비는 20%로 지정, 그 이상일 경우 auto

auto-fill의 크기를 20%로 설정했으므로, 1개의 row에는 5개의 셀이 들어간다.

grid-template-columns: repeat(auto-fill, minmax(20%, auto));

 

auto-fill인 경우 row에 셀이 5개보다 적으면 공간을 남기고, auto-fit은 빈 공간을 모두 채운다는 차이점이 있다.

 

 

 

CSS 그리드 간격 Gap

Tailwind CSS

gap-0	    gap: 0px;
gap-x-0	    column-gap: 0px;
gap-y-0	    row-gap: 0px;
gap-px	    gap: 1px;
gap-x-px    column-gap: 1px;
gap-y-px    row-gap: 1px;
gap-0.5	    gap: 0.125rem; /* 2px */
gap-x-0.5   column-gap: 0.125rem; /* 2px */
gap-y-0.5   row-gap: 0.125rem; /* 2px */
gap-1	    gap: 0.25rem; /* 4px */
gap-x-1	    column-gap: 0.25rem; /* 4px */
gap-y-1	    row-gap: 0.25rem; /* 4px */

 

 

셀 영역 지정

  • grid-column-start
  • grid-column-end
  • grid-column
  • grid-row-start
  • grid-row-end
  • grid-row

Grid 아이템에 적용하는 속성으로, 각 셀의 영역을 지정

 

Tailwind CSS

col-start-1	grid-column-start: 1;
col-start-2	grid-column-start: 2;
col-start-3	grid-column-start: 3;
col-start-4	grid-column-start: 4;
col-start-5	grid-column-start: 5;

col-end-1	grid-column-end: 1;
col-end-2	grid-column-end: 2;
col-end-3	grid-column-end: 3;
col-end-4	grid-column-end: 4;
col-end-5	grid-column-end: 5;

col-auto	grid-column: auto;
col-span-1	grid-column: span 1 / span 1;
col-span-2	grid-column: span 2 / span 2;
col-span-3	grid-column: span 3 / span 3;
col-span-4	grid-column: span 4 / span 4;
col-span-5	grid-column: span 5 / span 5;

 

 

item  부터 보면 컬럼 그리드 라인 1부터 2까지 차지하고, 로우 1부터 5까지 차지해서 해당 컨테이너에서 왼쪽에 배치할 수 있다.

item  는 컬럼 2부터 5까지, 로우 4부터 5까지 차지해서 우측 아래 배치할 수 있다.

item  는 컬럼 3부터 5까지, 로우 2부터 4까지 차지해서 우측 중간에 배치할 수 있다.

 

 

최종 프로젝트 웹 ui이를 만들면서 모바일에선 없던 페이지네이션 기능이 생겼다. 좋아요 버튼부터 페이지네이션 기능까지 한번도 구현해보지 못했던 기능인데 이번 최종 프로젝트 때 뭔가 해보고싶었던 기능들은 다 해보는 것 같다. 

 

 

[React] Pagination 구현하기

프로젝트를 하면서 Pagination이 필요해 구현해 보기로 했다. 찾아보니 라이브러리도 여러 개 있던데 머리도 쓸 겸 그냥 직접 구현해 보기로 했다. Pagination의 동작 구조 전반적인 동작 구조는 네이

imdaxsz.tistory.com

 

[리액트] Tanstack Query 쿼리취소, 페이지네이션, 무한스크롤

1. Query Cancellation대용량 fetching을 중간에 취소하거나 사용하지 않는 컴포넌트에서 fetching이 진행 중이면 자동으로 취소시켜 불필요한 네트워크 비용을 줄일 수 있다queryFn 의 매개변수로 Abort Signal

ejunyang.tistory.com

 

 

 

해당 상품의 리뷰 데이터 가져오기

DB는 supabase를 사용하고 있다. 우선 각 상품의 리뷰데이터를 가져온다. 특산물 디테일 페이지는 url에 특산물 id값으로 가져온다. searchParams는 URL 객체의 속성으로, URL에 포함된 쿼리 문자열을 쉽게 다룰 수 있게 해주는 API이다.

 

만약 아래와 같은 코드라면 searchParams url의 값을 쉽게 가져올 수 있다.

const url = new URL('https://example.com?product_id=123&page=2');
const { searchParams } = url;

const productId = searchParams.get('product_id'); // '123'

const page = Number(searchParams.get('page')); // 2

 

 

요청된 데이터의 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개씩
const { data: reviewData, error: reviewError } = await supabase
      .from('reviews')
      .select('*,users(avatar, name)')
      .eq('product_id', productId)
      .order('created_at', { ascending: false })
      .range((page - 1) * limit, page * limit - 1); // 페이지네이션

    if (reviewError) {
      return NextResponse.json({ error: reviewError.message }, { status: 400 });
    }
 

URL: searchParams property - Web APIs | MDN

The searchParams read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL.

developer.mozilla.org

 

 

 

총 리뷰 데이터 가져오기

페이지네이션을 구현할 때 총 리뷰 개수를 알아야 전체 페이지 수를 계산할 수 있다. 사용자 경험으로 보았을 땐 사용자가 특정 페이지를 선택했을 때, 그 페이지가 유효한지 확인할 수 있다. 만약 총 데이터 수가 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이다.

  const [currentPage, setCurrentPage] = useState(1);
  const limit = 10;

  const fetchReview = async (page: number) => {
    const response = await fetch(
      `/api/review?product_id=${productId}&page=${page}&limit=${limit}`
    );
    const data = await response.json();
    return data;
  };

  const {
    data: reviewData,
    error,
    isPending
  } = useQuery<ReviewDataType>({
    queryKey: ['reviews', productId, currentPage],
    queryFn: () => fetchReview(currentPage)
  });

 

사용자가 페이지를 변경하면 setCurrentPage를 호출해 currentPage를 업데이트 해준다 현재 페이지가 업데이트되면 useQuery가 다시 실행되고 fetchReview를 통해 새로운 데이터를 가져온다.

  const handlePageChange = (page: number) => {
    setCurrentPage(page);
  };

 

 

페이지네이션 UI

페이지네이션 ui는 shadcn을 사용했다. shadcn은 쓰면 쓸수록 좋은 것 같다. 간단하고 예쁘고..~

PaginationPrevious는 현재 페이지가 1보다 클때 이전 페이지로 이동할 수 있다. 

PaginationNext는 리뷰 총 페이지보다 작을 때 다음 페이지로 이동할 수 있다.

 

Array.from은 reviewData.totalPages 만큼 배열을 생성한다. 현재 총 페이지는 2페이지이다. onClick={() => handlePageChange(index + 1)} 클릭한 페이지 번호를 인자로 받아서 현재 페이지를 변경한다. isActive={currentPage === index + 1} 현재 페이지와 클릭한 페이지 번호가 일치하면 active한 상태를 보여준다. 

  <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationLink onClick={() => handlePageChange(currentPage - 1)}>
              <button className="flex items-center gap-[6px] text-label-assistive text-[15px]">
                <RiArrowLeftDoubleFill />
                처음
              </button>
            </PaginationLink>
          </PaginationItem>
          <PaginationItem>
            <PaginationPrevious
              onClick={() =>
                currentPage > 1 && handlePageChange(currentPage - 1)
              }
            />
          </PaginationItem>
          {Array.from({ length: reviewData.totalPages }, (_, index) => (
            <PaginationItem key={index + 1}>
              <PaginationLink
                href="#"
                onClick={() => handlePageChange(index + 1)}
                isActive={currentPage === index + 1}
              >
                {index + 1}
              </PaginationLink>
            </PaginationItem>
          ))}
          <PaginationItem>
            <PaginationNext
              onClick={() =>
                currentPage < reviewData.totalPages &&
                handlePageChange(currentPage + 1)
              }
            />
          </PaginationItem>
          <PaginationItem>
            <PaginationLink
              onClick={() => handlePageChange(reviewData.totalPages)}
            >
              <button className="flex items-center gap-[6px] text-label-assistive text-[15px]">
                마지막
                <RiArrowRightDoubleFill />
              </button>
            </PaginationLink>
          </PaginationItem>
        </PaginationContent>
      </Pagination>

 

 

Pagination

Pagination with page navigation, next and previous links.

ui.shadcn.com

 

 

 

완성

페이지 버튼을 눌렀을 때 스크롤이 다시 위로 올라간다. 리뷰 콘텐츠 부분만 변경되게 하고싶은데.. 이 부분은 추후 수정해야겠다.

 

 

프로젝트를 하면 할수록 내가 맡은 기능에 빈틈이 보여 계속 수정하게된다. 에러로직이나 선택한 상품이 없는 하단에 상품 개수가 보인다던가. 특정 상품만 체크하고 수량을 올렸더니 체크가 모두 활성화된다던가 이런 빈틈들이 잘보이고 유저테스트 이후로 기능 구현을 섬세하게 해야한다는게 얼마나 중요한지 뼈저리게 느꼈다ㅎ 오늘은 체크한 상품들을 선택 삭제하는 기능 구현을 해보려고한다!!

 

 

 

 

DeleteButton.tsx

선택 삭제 버튼을 따로 만들어주었다. 선택 삭제를 염두해두고 상품 삭제하는 로직이 있는 커스텀 훅을 만들어놓아서 코드를 간단하게 짤 수 있었다. 여기서 마주친 문제.. 처음 코드를 쓸 때 map을 사용했었다.

import { useDeleteProduct } from '@/hooks/localFood/useDeleteProduct';

interface ButtonProps {
  selectedItems: string[];
  setSelectedItems: React.Dispatch<React.SetStateAction<string[]>>;
}

export const DeleteButton = ({
  selectedItems,
  setSelectedItems
}: ButtonProps) => {
  const mutation = useDeleteProduct();

  const handleSelectedDelete = () => {
    selectedItems.map((productId) => {
      mutation.mutate(productId);
      return null;
    });

    setSelectedItems([]);
  };

  return (
    <button
      onClick={handleSelectedDelete}
      className="text-base text-label-alternative font-normal"
    >
      선택 삭제
    </button>
  );
};

 

 

그런데 생각해보니까 map은 새로운 배열을 반환하는데 상품을 삭제하는데 굳이? 새 배열을 반환할 필요가 있을까 생각이 들었다. 괜한 메모리 낭비같았다 그래서 다른 메서드를 구글링하다가 forEach 가 적합하겠다고 생각했다 분명 배운건데 코드짤 땐 생각이 안날까,,

 

 

 

forEach와 map의 차이점 → 새로운 배열 생성 반환여부

forEach()

함수는 배열의 각 요소를 순회하며 주어진 함수를 호출합니다. 각 요소에 대해 함수를 호출할 때, 해당 요소의 값, 인덱스, 그리고 원본 배열을 인수로 넘겨줍니다. 주어진 함수는 배열의 크기만큼 반복 실행되며, 배열 요소의 개수에 따라 콜백 함수가 호출되는 횟수가 결정됩니다.

forEach() 함수는 반환 값이 항상 undefined 입니다. 새로운 배열을 생성하지 않습니다.

 

map()

map() 함수는 배열을 순회해서 각 요소를 콜백 함수로 적용해서 처리해 모은 새로운 배열을 반환하기 위한 함수입니다.

map() 함수에 전달되는 콜백 함수는 "각 요소를 변환하여 새로운 배열로 매핑(mapping)하는 역할을 한다"라고 말합니다.
이렇게 매핑된 결과를 새로운 배열로 반환하기 때문에 이 함수의 이름이 "map"으로 정해졌습니다.

 

※ 참고

 

[Javascript] 다양한 배열 메서드(순회, 반복)를 알아보자

1. forEach forEach는 단순히 배열을 반복합니다. const arr = [2, 4, 6, 8, 10]; arr.forEach((value, index) => { console.log(`${value} : ${index}`) }); // 실행 결과 // 2 : 0 // 4 : 1 // 6 : 2 // 8 : 3 // 10 : 4 forEach문은 break 문이 없기

lejh.tistory.com

 

 

 

자바스크립트 forEach() 함수 – 개념 정리 및 사용 예제 - 코딩에브리바디

자바스크립트의 forEach() 함수는 배열을 순회해서 각 요소를 콜백 함수로 처리하기 위한 함수입니다. 배열의 각 요소에 대해 주어진 콜백 함수를 적용해서 순서대로 한 번씩 실행합니다.

codingeverybody.kr

 

 

 

수정코드

forEach로 짜면 원본 배열에 대한 작업을 수행해서 새로운 배열을 생성하지 않아 메모리 차지를 하지 않는다는 장점이 있다!

import { useDeleteProduct } from '@/hooks/localFood/useDeleteProduct';

interface ButtonProps {
  selectedItems: string[];
  setSelectedItems: React.Dispatch<React.SetStateAction<string[]>>;
}

export const DeleteButton = ({
  selectedItems,
  setSelectedItems
}: ButtonProps) => {
  const mutation = useDeleteProduct();

  const handleSelectedDelete = () => {
    selectedItems.forEach((productId) => {
      mutation.mutate(productId);
    });

    setSelectedItems([]);
  };

  return (
    <button
      onClick={handleSelectedDelete}
      className="text-base text-label-alternative font-normal"
    >
      선택 삭제
    </button>
  );
};

 

 

 

적용

 

 

아래 코드는 디테일 페이지에서 특정 상품의 데이터를 가져오는 부분이다. 원래는 상세 정보와 리뷰 탭이 없었는데 UT를 진행하면서 리뷰가 있었으면 좋을 것 같다는 피드백이 많아서 구현하게 되었다. UT를 해볼 기회는 없었는데 이번에 해보게 되어서 너무 좋았다(?) 피드백을 받고 수정하는데 테스트 전보다 완성도 높은 작업물이 나올 것 같다.

const {
    data: food,
    isPending,
    error
  } = useQuery({
    queryKey: ['localfood', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('local_food')
        .select('*')
        .eq('product_id', id)
        .single();

      if (error) throw new Error(error.message);

      return data;
    }
  });

 

 

그런데 여기서 문제. 저 리뷰 탭에 리뷰의 개수가 보여야하는데 저 개수만 보여주려고 리뷰 데이터를 패치해야하는 상황이 생겼다. 라우터 핸들러로 만들어놓긴 했지만 저 개수를 띄우려고 코드를 몇줄 이상 써야하는게 비효율적이라고 생각이 들었다. 구글링하다가 supabase join 기능이 있다고해서 참고했다. supabase 초반에 사용할 때 들었던 기능 같은데, 프로젝트에서 써먹어본건 최종까지와서다.. 막상 기억이 잘 안나서 사용하지 못했다,, 이제라도 알아서 다행이다^.~

 

Querying Joins and Nested tables | Supabase Docs

The Data APIs automatically detect relationships between Postgres tables.

supabase.com

 

supabase join 으로 여러 데이터 한번에 가져오기

최근 프로젝트에서 팔로워 리스트를 가져오는 기능을 구현하던 중, 나를 팔로우한 유저의 프로필 정보를 가져오는 과정이 너무 복잡하고 비효율적이라는 생각이 들었다. 그래서 리팩토링을 진

velog.io

 

 

수정코드

리뷰 테이블의 모든 데이터를 가져왔다.

const {
    data: food,
    isPending,
    error
  } = useQuery({
    queryKey: ['localfood', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('local_food')
        .select('*, reviews(*)')
        .eq('product_id', id)
        .single();

      if (error) throw new Error(error.message);

      return data;
    }
  });

 

사용할 땐 두개의 테이블 이름을 같이 써서 아래와 같이 사용하면 된다.

    <li
            className="flex-1 cursor-pointer"
            onClick={() => setActiveTab('리뷰')}
          >
            <p
              className={`pb-2 w-[140px] mx-auto ${
                activeTab === '리뷰'
                  ? 'text-primary-20 border-b-4 border-primary-20'
                  : 'text-label-assistive'
              }`}
            >
              {`리뷰(${food.reviews.length})`}
            </p>
          </li>

+ Recent posts