Next.js 캐럿마켓 클론코딩( PRODUCTS 2)

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

Image Hostnames

NextJS의 Image(<Image/>)는 이미지를 자동으로 최적화를 해 주어 성능을 향상시키고 빠른 로딩이 되도록 해 준다.
하지만 외부 호스트의 이미지(다른 사이트의 이미지 링크 등)를 불러올 때는 보안 상의 이유로 이 기능이 허용되지 않는다.

만약 내 깃허브에 있는 이 이미지를 avatar에 등록하고 싶다.
우선, 이 이미지의 주소를 복사한다. https://avatars.githubusercontent.com/u/155455327?v=4

그리고 이 주소를 이렇게 넣어주었다. 그랬더니 오류가 발생한다!

이 오류는 Next.js에서 next/image 컴포넌트를 사용하여 이미지를 불러올 때 발생하게 된다.
next/image 컴포넌트 는 이미지의 최적화를 위해 특정 호스트에서 이미지를 불러올 때, 해당 호스트가 next.config.js 파일에 명시되어 있어야 한다. 하지만 오류 메시지에 따르면, avatars.githubusercontent.com 도메인이 아직 next.config.js 파일의 이미지 호스트 설정에 추가되지 않았기 때문에 발생한 것이다.

그럼 next.config.js 파일에 해당 도메인을 추가하면 될 것이다.

user가 image에 URL를 업로드할 수 있도록 허용된 경우, 우리들은 실제로 image component를 사용하여 해당 이미지를 표시한다. --> 우리들은 user가 업로드한 image들을 최적화하는 것 --> image 최적화에는 수많은 돈이 들게 된다..

user가 URL을 업로드하도록 허용하고 이미지를 보여주게 되면 최적화에 대한 비용을 지불하게 될 것임. -> 이미지를 최적화하려는 URL과 그렇지 않는 URL를 구체적으로 지정할 수 있음 -> next.configure.mjs파일에서 이를 구체화 해야한다.

--> hostname : 이 도메인을 가진 이미지를 image component로 최적화시켜달라고 이야기 하는 것이라고 이해하면 될 것이다.


Pagination Actions

이제 페이지 무한스크롤에 대해서 구현해볼 예정이다.

무한스크롤을 구현하기 이전에, 더보기 버튼을 눌렀을 때, 상품이 보여질 수 있도록 먼저 구현을 해보도록 하겠다. 여기서 더보기 버튼을 눌렀을 때, 상품이 한개 더 보여질 수 있도록 해보도록 하겠다!

우선, 기존의 (tab)/products/page.tsx 페이지에서

take: 1 : 이 속성은 데이터베이스에서 쿼리를 실행할 때 가져올 레코드의 수를 제한한다. 1로 설정하면 단 하나의 레코드만 가져온다. 즉, 최신 레코드 하나만 가져오는 것을 의미한다.

orderBy : { created_at: 'desc' }: 이 속성은 데이터베이스에서 데이터를 정렬하는 방식을 지정한다.
created_at 필드를 기준으로 내림차순(desc)으로 정렬하도록 설정해주었다. 내림차순 정렬이므로 가장 최근에 생성된 데이터가 먼저 오게 된다.

우선 이렇게 지정하게 되면, 상품 한개를 가져오게 되는데 -> initialProducts로 설정해두고,
<ProductList/>를 하나 생성해 주었다.
<ProductList/> 에서는 initialProducts를 넘겨주고, initialProducts와 더보기 버튼을 생성해서 추가적으로 product를 보여줄 수 있도록 할 예정이다.

component/product-list.tsx

ProductList 함수: ProductList는 initialProducts라는 하나의 props를 받는다.
initialProducts는 ProductListProps로 타입을 지정해두었는데,

initialProducts : InitialProducts 속성을 지정해두었다.
InitialProducts의 타입은-> Prisma에게 요청하는것이다. getInitialProducts에서 받아온 데이터의 타입을 알려줘~ 라고 요청한 것이라고 생각하면 된다.

useState:

  • products: 현재 렌더링된 제품 목록을 관리합니다. 초기 값으로 initialProducts를 사용해서 처음 렌더링된 제품은 1개를 보여주게 된다.
  • isLoading: 추가 제품을 로드하는 중인지 여부를 나타내는 상태-> 초기 값은 false이다.

onLoadMoreClick 함수: "Load more" 버튼이 클릭되었을 때 호출된다.

  • setIsLoading(true): 제품을 로드하는 중임을 나타내기 위해 isLoading 상태를 true로 설정한다.
  • getMoreProducts(1): 추가 제품 데이터를 가져온다.

여기서 getMoreProducts()를 가져온 이유는, initialProduct에서 우리는 한개의 제품을 넘겨받고, Load More 버튼을 눌러야 다른 제품들을 더 받을 수 있다.

다른 제품들을 더 받기 위해 actions.ts에서 데이터를 정의했다.

  • setProducts: 이전 제품 목록(prev)에 새로 가져온 제품을 추가하여 products 상태를 업데이트한다.
  • setIsLoading(false): 제품 로드가 완료되었음을 나타내기 위해 isLoading 상태를 false로 설정한다.

getMoreProducts()
여기서 skip : 1는 db.product에서 가져오는 데이터중에 첫번째를 건너뛰고,take : 1 로 한개 가져오라~ 를 의미한다.

위에서 getMoreProduct()를 호출하고, 여기서 우선 page를 1페이지로 설정해 둔 다음, 먼저 initialProducts로 불러온 첫번째 제품을 제외하고 한개씩 불러오게 된다.

그럼 이렇게 Load more 버튼이 생성되고,
눌렀을 경우, 요렇게 다음 1개가 출력되는 것을 확인할 수 있다.

그런데, 여기서 skip : 1, take : 1 속성 때문에, 첫번째것은 건너뛰고, 나머지 하나를 가져오게 되서, 떡볶이만 계속해서 가져오는 모습이다.

그럼, Load more을 눌렀을 때 다음 다음 하나씩 출력할 수 있도록 할 수있을까?

우선, getMoreProducts()를 불러올 때, skip을 받아오고, skip값 또한 받아온 값으로 변경할 수 있도록 해주었다. const [skip, setSkip] = useState(1) 를 통해서 skip의 기본값을 1로 주어서, 기본적으로 1번 (initialProducts)를 제외할 수 있도록 해주었고,

Load more 버튼을 눌렀을 때, getMoreProducts()를 호출할 때, skip을 넘겨주고, setSkip(prevSkip => prevSkip +1) 를 통해 기존의 skip에 1를 더해줌으로써

Load more 버튼을 눌렀을 때, skip 버튼을 한개씩 증가시켜서 보여주는 상품을 하나씩 증가시켜주었다!


Infinite Scrolling

useRef -> variable 내부에 data를 저장할 수 있게 해주게 된다.
useRef 안에 data를 넣으면, 예로 (1) 이렇게 넣어버리면, rerender시에도 계속 유지가 된다.

const trigger = useRef<HTMLSpanElement>(null);

이 useRef를 HTMLspanElement에 저장하겠다고 타입스크립트에게 알려줌 -> <HTMLSpanElement>라고
isLastPage가 아닐 때, span을 보여주면서, span에 ref={trigger} 속성을 주었다. 즉, trigger를 useRef<HTMLSpanElement>(null) 로 선언하고, 이를 <span> 요소에 ref={trigger} 속성으로 연결했다는 것은 trigger.current가 해당 <span> 요소를 참조하도록 하겠다는 의미이다.

useEffect()
여기서 해야할 일은 -> page에 대해서 listen을 하는 것임.
--> page가 변경되면 바로 여기에 있는 이 코드가 다시 실행될 것이기 때문이다.

아래로 스크롤할 때, trigger(span)가 화면에 있는걸 감지하고, observe하는 것을 멈추고 DB에서 상품을 더 불러오는 코드를 실행하고 싶어하는 것이다.

page가 증가하면, useEffect() 코드를 실행시켜서 observe하는 역할을 하게 될 것임. ---> user가 trigger(span)를 보게 되면, trigger를 observe하다가 trigger 관찰을 중단할 것임. 여기서 page가 증가시키면, 이 코드가 다시 실행되고, trigger 관찰이 다시 시작된다.

여기서 observer란?

Intersection Observer는 브라우저에서 특정 요소(Element)가 뷰포트 내에서 얼마나 보이는지(교차 여부)를 감지하고, 이를 기반으로 콜백 함수를 실행하게 된다.

useEffect(() => {
    const observer = new IntersectionObserver(
      async (
        entries: IntersectionObserverEntry[],
        observer: IntersectionObserver
      ) => {
        const element = entries[0];
        if (element.isIntersecting && trigger.current) {
          observer.unobserve(trigger.current);
          setIsLoading(true);
          const newProducts = await getMoreProducts(page + 1);
          if (newProducts.length !== 0) {
            setPage((prev) => prev + 1);
            setProducts((prev) => [...prev, ...newProducts]);
          } else {
            setIsLastPage(true);
          }
          setIsLoading(false);
        }
      },
      {
        threshold: 1.0,
      }
    );
    if (trigger.current) {
      observer.observe(trigger.current);
    }

    return () => {
      observer.disconnect();
    };
  }, [page]);

useEffect 훅:

  • useEffect는 컴포넌트가 렌더링된 후에 특정 사이드 이펙트를 실행할 수 있도록 해준다.
  • 이 훅은 page 값이 변경될 때마다 실행된다. 즉, 페이지 번호(page)가 증가할 때마다 새로운 데이터를 불러오는 로직이 트리거되는 것이다.

IntersectionObserver 생성:
const observer = new IntersectionObserver()
여기서 IntersectionObserver 객체를 생성합니다.
이 객체는 특정 요소(trigger.current)가 뷰포트에 진입할 때 콜백 함수를 실행하게 된다.

entries와 observer 파라미터:

  • entries: 현재 관찰 중인 요소들에 대한 정보를 담은 배열이다. 일반적으로 이 배열에는 관찰 대상 요소(trigger.current)가 하나만 포함된다.
  • observer: IntersectionObserver 자체를 참조한다. 이 객체를 통해 관찰을 시작하거나 중단할 수 있게 된다.

const element = entries[0]; if (element.isIntersecting && trigger.current) {

  • entries[0]는 관찰 대상 요소에 대한 정보(IntersectionObserverEntry)를 가져온다.
  • isIntersecting은 요소가 뷰포트에 들어와 있는지를 나타내는 불리언 값이다.
  • 만약 요소가 뷰포트에 들어와 있고(isIntersecting이 true), trigger.current가 참조하는 DOM 요소가 존재하면, 아래의 로직이 실행된다.
observer.unobserve(trigger.current);
setIsLoading(true);
const newProducts = await getMoreProducts(page + 1);
  • observer.unobserve(trigger.current) 를 통해 현재 요소에 대한 관찰을 중단합니다. 이는 중복 요청을 방지하기 위한 것이다.
  • setIsLoading(true)로 로딩 상태를 true로 설정하여, 로딩 중임을 UI에 반영한다.
  • getMoreProducts(page + 1) 함수는 현재 페이지의 다음 페이지(page + 1)에 해당하는 데이터를 비동기적으로 가져온다.

오우.. 어렵다!
간단하게 설명하자면,

useRef()로 <span> 요소를 참조:

  • useRef()를 사용해 <span> 요소 를 참조한다. 이 참조는 trigger.current를 통해 접근할 수 있다.
  • trigger.current는 <span> 요소의 실제 DOM 노드를 가리키게 된다.

useEffect()가 페이지 변경 시 실행:

  • useEffect()는 page가 변경될 때마다 실행된다. 즉, page 값이 바뀔 때마다 useEffect 내부의 코드가 실행되는 것이다.

IntersectionObserver 설정:

  • useEffect 내부에서 IntersectionObserver 객체를 생성한다. 이 observer는 trigger.current가 가리키는 <span> 요소가 뷰포트에 진입하거나 나갈 때 그 상태를 감지하게 된다.

observer가 <span>의 상태를 감지:

  • IntersectionObserver는 <span> 요소가 뷰포트에 진입했는지(isIntersecting이 true) 또는 진입하지 않았는지(isIntersecting이 false) 를 판단하게 된다.

조건 충족 시 추가 데이터 로드:

  • 만약 <span> 요소가 뷰포트에 진입했으며, trigger.current가 유효하다면(isIntersecting이 true이고 trigger.current가 존재한다면):
    현재 관찰 중인 trigger.current 요소에 대한 관찰을 중단한다.(observer.unobserve(trigger.current)).
  • 비동기 함수 getMoreProducts를 호출해, 새로운 제품 데이터를 불러오고(page + 1).
    새로 불러온 데이터가 있으면 페이지를 업데이트하고(setPage), 불러온 데이터를 기존 제품 목록에 추가한다(setProducts).
    만약 불러온 데이터가 더 이상 없다면, setIsLastPage(true)를 호출하여 마지막 페이지임을 표시하게 된다.
profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글