[TIL/React] 2024/07/12

원민관·2024년 7월 12일
0

[TIL]

목록 보기
143/159

reference:
1) https://brunch.co.kr/@theopenproduct/58
(무한 스크롤에 대한 정의, 페이지네이션과의 비교를 중심으로)
2) https://tech.kakaoenterprise.com/149
(카카오 엔터프라이즈 포스팅)
3) https://www.bucketplace.com/post/2020-09-10-%EC%98%A4%EB%8A%98%EC%9D%98%EC%A7%91-%EB%82%B4-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0/
(오늘의 집 무한 스크롤 개발기)
4) https://medium.com/myrealtrip-product/%EC%83%81%ED%99%A9%EC%97%90-%EB%A7%9E%EB%8A%94-%EB%A1%9C%EB%94%A9-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-2018af51c197
(미디엄, 상황에 맞는 로딩 인디케이터 적용하기)
5) https://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/

✅ 무한 스크롤(Infinite Scrolling) 톺아보기

1. 무한 스크롤이란? 🚀

1-1. 무한 스크롤의 정의 ✍️

무한 스크롤이란, 스크롤이 페이지의 끝에 도달했을 때 자동으로 다음 데이터를 요청하여 받아오는 UX 방식을 의미한다. 별도의 페이지 이동 없이 데이터를 지속적으로 불러오기 때문에 직관적이며 편리하다는 장점을 갖는다. 유튜브, 페이스북, 인스타그램 등 많은 서비스들이 모바일과 웹에서 콘텐츠를 제시하는 방식으로서 '무한 스크롤' 기법을 활용하고 있다.

1-2. 무한 스크롤 VS 페이지네이션 ✍️

무한 스크롤과 페이지네이션은, 결국 콘텐츠 데이터를 사용자에게 보여주는 UX 방식이라는 점에서는 동일하다. 다만, 페이지네이션은 전체 콘텐츠를 페이지를 기준으로 적절한 분량으로 나누어 사용자에게 콘텐츠를 제시한다.

페이지네이션의 장점은, 일단 전체 페이지의 수(=전체 볼륨)를 사용자가 확인할 수 있기 때문에, 사용자가 콘텐츠에 대한 통제감을 느끼며 탐색을 진행할 수 있다. 또한 특정한 규칙에 따라 콘텐츠가 정렬되기에 콘텐츠의 정확한 인덱스를 파악할 수 있으며, 페이지마다 제시되는 콘텐츠의 양이 적절히 정해져 있기 때문에 빠른 로딩 속도를 제공할 수 있다.

1-3. 무한 스크롤을 프로젝트에 적용해야 하는 이유 ✍️

페이지네이션은 사용자가 처음부터 목적을 가지고 콘텐츠를 탐색할 때 유용하다. 특정한 기준에 따라서 콘텐츠가 정렬되어 있기 때문에 목적에 맞는 자료를 쉽게 찾을 수 있고, 언제든지 원하는 위치로 돌아갈 수 있기 때문이다.

하지만 이번에 진행할 MERN stack 프로젝트에서는, CRUD를 기반으로 콘텐츠를 제시하는 것이 주 목적이기 때문에 무한 스크롤 UX 기법을 연습해 보는 것이 더 적절할 것이라고 판단했다.

2. 이벤트 처리 방법 🚀

2-1. Window 객체의 scroll event ✍️

export interface PaginationResponse<T> {
  contents: T[]; // 현재 페이지에 포함된 데이터 리스트
  pageNumber: number; // 현재 페이지 번호
  pageSize: number; // 페이지 크기
  totalPages: number; // 전체 페이지 수
  totalCount: number; // 전체 아이템 수
  isLastPage: boolean; // 마지막 페이지 여부
  isFirstPage: boolean; // 첫 페이지 여부
} 

Typescript로 작성된 인터페이스 정의이다. 위 인터페이스를 통해, 페이지네이션 된 API 응답을 처리할 수 있게 될 것이다.

다음은 모킹 API이다.

// 0부터 1023까지의 숫자를 포함하는 배열을 생성하고 각 요소를 User 객체로 변환
const users = Array.from(Array(1024).keys()).map(
  (id): User => ({
    id,
    name: `denis${id}`,
  })
);

// 핸들러 배열에는 REST API의 엔드포인트와 그에 대한 응답을 정의하는 함수가 포함됨
const handlers = [
  // '/users' 경로에 대한 GET 요청을 처리하는 핸들러
  rest.get('/users', async (req, res, ctx) => {
    // 요청 URL에서 searchParams를 추출
    const { searchParams } = req.url;
    // 쿼리 파라미터에서 size와 page를 추출하여 숫자로 변환
    const size = Number(searchParams.get('size'));
    const page = Number(searchParams.get('page'));
    // 전체 유저 수
    const totalCount = users.length;
    // 전체 페이지 수를 계산 (전체 유저 수를 페이지 크기로 나눈 후 반올림)
    const totalPages = Math.round(totalCount / size);

    // 응답 생성 및 반환
    return res(
      // 응답 상태 코드를 200으로 설정 (성공)
      ctx.status(200),
      // JSON 형태로 응답을 생성
      ctx.json<PaginationResponse<User>>({
        // 요청된 페이지에 해당하는 유저 리스트를 slice 메서드를 사용해 가져옴
        contents: users.slice(page * size, (page + 1) * size),
        // 요청된 페이지 번호
        pageNumber: page,
        // 각 페이지의 크기 (유저 수)
        pageSize: size,
        // 전체 페이지 수
        totalPages,
        // 전체 유저 수
        totalCount,
        // 현재 페이지가 마지막 페이지인지 여부를 나타냄
        isLastPage: totalPages <= page,
        // 현재 페이지가 첫 페이지인지 여부를 나타냄
        isFirstPage: page === 0,
      }),
      // 응답을 500ms 지연시킴
      ctx.delay(500)
    );
  }),
];

다음은 프론트엔드 React 코드다.

// 페이지 크기를 계산, 카드 크기(CARD_SIZE)와 뷰포트 너비에 따라 동적으로 설정
const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE);

function UsersPage() {
  // 페이지 상태를 관리하기 위한 useState 훅
  const [page, setPage] = useState(0);
  // 유저 데이터를 저장할 상태
  const [users, setUsers] = useState<User[]>([]);
  // 데이터 로딩 상태를 관리하기 위한 상태
  const [isFetching, setFetching] = useState(false);
  // 다음 페이지가 있는지 여부를 관리하기 위한 상태
  const [hasNextPage, setNextPage] = useState(true);

  // 유저 데이터를 비동기적으로 가져오는 함수
  const fetchUsers = useCallback(async () => {
    // axios를 사용하여 '/users' 경로에 GET 요청을 보냄, 쿼리 파라미터로 페이지와 크기를 전달
    const { data } = await axios.get<PaginationResponse<User>>('/users', {
      params: { page, size: PAGE_SIZE },
    });
    // 현재 유저 리스트에 새로운 데이터를 추가
    setUsers(users.concat(data.contents));
    // 다음 페이지 번호를 설정
    setPage(data.pageNumber + 1);
    // 다음 페이지가 있는지 여부를 설정
    setNextPage(!data.isLastPage);
    // 로딩 상태를 false로 설정
    setFetching(false);
  }, [page]);

  // 컴포넌트가 마운트될 때와 스크롤 이벤트를 처리하기 위한 useEffect 훅
  useEffect(() => {
    const handleScroll = () => {
      const { scrollTop, offsetHeight } = document.documentElement;
      // 스크롤이 페이지 하단에 도달했을 때 로딩 상태를 true로 설정
      if (window.innerHeight + scrollTop >= offsetHeight) {
        setFetching(true);
      }
    };
    // 초기 로딩 상태를 true로 설정
    setFetching(true);
    // 스크롤 이벤트 리스너 추가
    window.addEventListener('scroll', handleScroll);
    // 컴포넌트가 언마운트될 때 스크롤 이벤트 리스너 제거
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  // 로딩 상태가 변경될 때와 다음 페이지가 있을 때 유저 데이터를 가져오는 useEffect 훅
  useEffect(() => {
    // 로딩 상태가 true이고 다음 페이지가 있을 때 유저 데이터를 가져옴
    if (isFetching && hasNextPage) fetchUsers();
    // 다음 페이지가 없을 때 로딩 상태를 false로 설정
    else if (!hasNextPage) setFetching(false);
  }, [isFetching]);

  return (
    <Container>
      {/* 유저 데이터를 카드 형태로 렌더링 */}
      {users.map((user) => (
        <Card key={user.id} name={user.name} />
      ))}
      {/* 로딩 상태일 때 로딩 컴포넌트를 렌더링 */}
      {isFetching && <Loading />}
    </Container>
  );
}

코드를 처음부터 다 이해할 필요는 없다. 아니, 사실 처음부터 다 이해할 수는 없다. 중요한 것은, 무한 스크롤이라는 건 '특정 페이지 하단에 도달' 했을 때 'API 요청'이 실행된다는 점이다. 그렇다면 'Window 객체의 scroll event'를 통해 '특정 페이지 하단에 도달'을 어떻게 구현했는지를 정확히 이해하는 것이 핵심이다.

useEffect(() => {
  const handleScroll = () => {
    const { scrollTop, offsetHeight } = document.documentElement
    if (window.innerHeight + scrollTop >= offsetHeight) {
      setFetching(true)
    }
  }

  setFetching(true)
  window.addEventListener('scroll', handleScroll)
  return () => window.removeEventListener('scroll', handleScroll)
}, [])

위 코드가 알짜배기라는 것이다. 그러면 innerHeight, scrollTop, offsetHeight라는 재료가 무엇을 의미하는지, 그리고 그 재료들로 요리된 window.innerHeight + scrollTop >= offsetHeight가 무엇인지만 알면 되겠네.

innerHeight: 브라우저 창의 내부 뷰포트 높이
scrollTop: 현재 페이지의 스크롤 위치(=사용자가 페이지를 스크롤 하여 위로 올린 정도)
offsetHeight: 페이지의 전체 높이

즉, 브라우저 창의 내부 뷰포트 높이 + 현재 페이지의 스크롤 위치 >= 페이지의 전체 높이를 의미한다.

2-2. Intersection Observer API ✍️

자, 그렇다면 Window 객체의 scroll을 쓰면 될 것이지, Intersection Observer API는 또 왜 쓰냐? 왜 나를 자꾸 힘들게 하는 것이냐?

기존 scroll 이벤트는 document에 스크롤 이벤트를 등록하고, 특정 지점을 관찰하여 엘리먼트가 위치에 도달했을 때 실행할 콜백 함수를 등록하는 방식으로 구현되어 있다. 하지만 scroll 이벤트는 단시간에 수백 번, 수천 번 호출될 수 있다. 동시에 스크롤 이벤트는 동기적으로 실행되기 때문에 메인 스레드에 영향을 주게 된다. 게다가 특정 지점을 관찰하기 위해서는 getBoundingClientRect() 함수를 사용해야 하는데, 이 함수는 리플로우(reflow) 현상이 발생한다는 단점이 있다. 리플로우(reflow)란 브라우저가 웹 페이지의 일부 또는 전체를 다시 그려야 하는 경우 발생한다.

Intersection Observer API를 사용하면 위와 같은 문제를 해결할 수 있다. 비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다. 또한 IntersectionObserverEntry의 속성을 활용하면 getBoundingClientRect()를 호출한 것과 같은 결과를 알 수 있기 때문에 따로 getBoundingClientRect() 함수를 호출할 필요가 없어 리플로우 현상을 방지할 수 있게 된다.

3. 성능 최적화(Debounce & Throttle) 🚀

그리고 무한 스크롤과 관련하여 알고 있어야 할 개념에 두 가지가 있다. Debounce와 Throttle이다. Debounce와 Throttle은 둘 다 함수의 호출을 제어(=지연)하여 성능 최적화나 이벤트 처리를 관리하는 기술이다.

각각의 용어가 어떤 함의를 갖고 있는지 가볍게 살펴보겠다.

3-1. Debounce ✍️

Debounce는 연이어 발생하는 이벤트에서 '마지막 이벤트'가 발생한 후 일정 시간이 지난 후에 해당 이벤트를 처리하는 기술이다. 주로 입력 필드에서 사용자의 입력을 처리하거나, 스크롤 이벤트 등에서 발생할 수 있는 연속적인 이벤트 처리를 제어하는 데 유용하다.

3-2. Throttle ✍️

Throttle은 연속적인 이벤트의 발생을 제어하여 '일정 시간 간격'으로 이벤트 핸들러가 실행되도록 하는 기술이다. 주로 스크롤 이벤트나 DOM 요소의 드래그 이벤트와 같이 빈번하게 발생하는 이벤트를 제한하는 데 사용된다.

3-3. 그래서 하고 싶은 말이 뭔데 ✍️

결국 Debounce와 Throttle은 모두 함수의 호출을 지연시키는 것이다. 준비되기 전까지 호들갑 떨지 말라는 거다. 그런데 이제 Debounce를 '간격'이라는 그릇에 담는 순간 Throttle이 되는 것이다.

4. UI/UX 고려사항(feat.로딩 인디케이터) 🚀

무한 스크롤을 공부하는 김에, 로딩 인디케이터에 관한 내용도 가볍게 살펴봤다. 마이리얼트립에서 사용하는 로딩 인디케이터를 기준으로 학습했다. 간단하게 스크린 샷을 아카이빙 하겠다.

5. 예제 코드 및 구현 화면 🚀

import React, { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";
import styled from "styled-components";

const AppContainer = styled.div`
  text-align: center;
  padding: 20px;
`;

const Title = styled.h1`
  margin-bottom: 20px;
`;

const CardContainer = styled.div`
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 20px;
`;

const Card = styled.div`
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 10px;
  width: 150px;
  text-align: left;
  transition: transform 0.3s;

  &:hover {
    transform: translateY(-10px);
    cursor: pointer;
  }
`;

const CardImage = styled.img`
  max-width: 100%;
  border-radius: 4px;
`;

const CardText = styled.p`
  margin: 10px 0 0;
  font-size: 14px;
`;

const LoadingText = styled.p`
  margin-top: 20px;
`;

const EndText = styled.p`
  margin-top: 20px;
  color: grey;
`;

const App = () => {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef();

  const fetchItems = async (page) => {
    setLoading(true);
    try {
      const response = await axios.get(
        `https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`
      );
      setItems((prevItems) => [...prevItems, ...response.data]);
      setHasMore(response.data.length > 0);
    } catch (error) {
      console.error("Error fetching data:", error);
    }
    setLoading(false);
  };

  useEffect(() => {
    fetchItems(page);
  }, [page]);

  const lastItemRef = useCallback(
    (node) => {
      if (loading) return;
      if (observer.current) observer.current.disconnect();
      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasMore) {
          setPage((prevPage) => prevPage + 1);
        }
      });
      if (node) observer.current.observe(node);
    },
    [loading, hasMore]
  );

  return (
    <AppContainer>
      <Title>Infinite Scroll Cards</Title>
      <CardContainer>
        {items.map((item, index) => (
          <Card
            key={item.id}
            ref={items.length === index + 1 ? lastItemRef : null}
          >
            <CardImage src={item.thumbnailUrl} alt={item.title} />
            <CardText>{item.title}</CardText>
          </Card>
        ))}
      </CardContainer>
      {loading && <LoadingText>Loading...</LoadingText>}
      {!hasMore && <EndText>No more items to load</EndText>}
    </AppContainer>
  );
};

export default App;

✅ 회고

가장 경쟁력 있는 상품은
'서사(narrative)'입니다.

성장과 좌절이
진실하게 누적된 나의 기록은
유일무이한 나만의 서사입니다.

시대예보(송길영) 中

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글