• 무한스크롤 구현시 offsetHeight가 innerHeight와 같은 현상

    2023. 8. 14.

    by. Soozinyy

    무한스크롤 기능 첫 도전🎉
    카카오 엔터프라이즈의 코드 따라해보기 : 배경지식이 0이기 때문에 카카오 엔터프라이즈의 코드를 빌렸으며 '동작의 흐름 파악'에 의의를 두었습니다.

     

     

    scroll 이벤트를 활용한 무한스크롤 기능을 구현하던 중,

    화면상에서는 스크롤이 있음에도 불구하고, 전체 컨텐츠의 높이인 offsetHeight화면 높이인 innerHeight와 같은 현상이 발생했습니다.

     

    // ThumbnailWrapper.tsx
    import React, { useState, useEffect, useCallback } from 'react';
    import { Thumbnail } from '../Thumbnail/Thumbnail';
    import { StThumbnailWrapper } from './ThumbnailWrapper.styles';
    import { ResponseData, ReviewsList } from 'api/reviewsApi';
    import { axiosInstance } from 'api/api';
    
    
    export const ThumbnailWrapper = () => {
      const type = 'likes';
      const [page, setPage] = useState(1);
      const [reviews, setReviews] = useState<ReviewsList[]>([]);
      const [isFetching, setFetching] = useState(false);
      const [hasNextPage, setNextPage] = useState(true);
    
      const fetchReviews = useCallback(async () => {
        const { data } = await axiosInstance.get<ResponseData>('/reviews', {
          params: { type, page },
        });
        setReviews(reviews.concat(data.content));
        setPage(page + 1);
        setNextPage(!data.last);
        setFetching(false);
      }, [page]);
    
      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);
      }, []);
    
      useEffect(() => {
        if (isFetching && hasNextPage) fetchReviews();
        else if (!hasNextPage) setFetching(false);
      }, [isFetching]);
    
      return (
        <StThumbnailWrapper>
          {reviews.map((review) => (
            <Thumbnail
              key={review.id}
              id={review.id}
              roadName={review.roadName}
              img_url={review.img_url}
              likeCnt={review.likeCnt}
              tag={review.tag}
              profileImg_url={review.profileImg_url}
            />
          ))}
          {isFetching && <div>Loading...</div>}
        </StThumbnailWrapper>
      );
    };

    innerHeight와  scrollTop을 더한 값이 offsetHeight와 같거나 크다면 isFetching을 true로 하고, 만약hasNextPage가 true라면 fetchReviews함수를 실행하여 다음 페이지가 로드되어야합니다.

    하지만 전체 컨텐츠의 총 높이인 offsetHeight의 정보를 제대로 가져오지 못해서 이벤트가 동작하지 않았습니다.

     

     

    01 가리키는 요소가 잘못된 경우

    scrollTopoffsetHeight가 가리키는 document.documentElement 는 전체 컨텐츠여야 하는데, 혹시 다른 요소를 가리키고 있는 것은 아닌가? 곧바로 확인했지만 document.documentElement는 올바르게 요소를 가리키고 있었습니다. 

    하지만 콘솔에는 여전히 667로  offsetHeightinnerHeight가 동일하게 출력됩니다.

     

     

    02 문제의 페이지에 포함된 컴포넌트의 height 100% 또는 100vh

    stackoverflow에서 비슷한 문제를 발견했는데, 원인은 Style Component에서의 높이 설정이 문제였습니다.

    Homepage, ThumbnailWrapper, GlobalStyle 3곳에서는 높이 설정을 하지 않았으므로 남은 곳은 CommonLayout.

    StLayoutBody에 높이가 설정되어 있는 것을 확인했습니다. 해당 부분을 주석 처리하고 다시 실행해보았습니다.

    // HomePage.tsx
    import React from 'react';
    import { Input } from 'components/common';
    import { CommonLayout, NavBar } from 'components/layout';
    import { ThumbnailWrapper } from 'components/homePage';
    
    export const HomePage = () => {
      return (
        <CommonLayout
          header={
            <NavBar btnLeft={'logo'} btnRight={'mypage'}>
              <Input />
            </NavBar>
          }
        >
          <ThumbnailWrapper />
        </CommonLayout>
      );
    };
    // CommonLayout.styles.ts
    
    // 생략
    
    export const StLayoutBody = styled.div`
      position: relative;
      width: 100vw;
      max-width: 390px;
      /* height: 100vh; */
      max-height: 850px;
      box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
    `;
    
    // 생략
    // CommonLayout.tsx
    // 생략
    export const CommonLayout: React.FC<CommonLayoutProps> = ({
      children,
      header,
      footer,
      headerHeight = '50px',
      hideHeader = true,
    }) => {
    // 생략
      return (
        <StLayoutOuter>
          <StLayoutBody>
            {header && <StSlidingHeader $isShow={isShow}>{header}</StSlidingHeader>}
            <StLayoutSection ref={scrollRef} $headerHeight={headerHeight}>
              {children}
            </StLayoutSection>
            {footer && <FloatingFooter>{footer}</FloatingFooter>}
          </StLayoutBody>
        </StLayoutOuter>
      );
    };

     

    아래와 같이 잘 실행되는 것을 확인했습니다.

     

    하지만 최종적으로 고정 값이 있는 CommonLayout이 필요했습니다. 어떻게 하면 무한스크롤과 고정된 높이를 동시에 적용할 수 있을까요?

     

     

    카카오 엔터프라이즈의 글에서도 소개하고 있지만,

    무한스크롤을 검색해보면 IntersectionObserver라는 API가 대부분을 차지합니다.

     

    IntersectionObserver API는 루트 요소와 타겟 요소의 교차점을 관찰하고, 교차 여부를 구별합니다.

    앞서 사용했던 scroll 이벤트는 동기적으로 실행되는 반면, IntersectionObserver API는 비동기적으로 실행됩니다. 전자의 경우 만약 짧은 시간 내에 수 천 번의 이벤트가 동기적으로 실행된다면 많은 양의 콜백이 실행되며, 큰 부하를 줄 수 있습니다.  또 IntersectionObserver API는 scroll 이벤트와는 다르게 reflow를 발생시키지 않습니다.

    이러한 측면에서 IntersectionObserver API가 성능이 뛰어남을 알 수 있습니다.

     

    그러니 scroll 이벤트 관련된 기능을 구현할 때 IntersectionObserver API를 쓰지 않을 이유가 없네요 !!

     

     

    저의 케이스는 CommonLayout의 스타일 적용으로 인해서 offsetHeight가 제대로 인식되지 않는다는 것이 문제였습니다. 무한스크롤 첫 도전이라, IntersectionObserver를 이용하지 않고 scroll 이벤트만으로 구현해보며, 동작의 흐름을 익히는 것이 이번 목표였는데요.

    IntersectionObserver API를 이용할 경우, offsetHeight 대신 div 같은 사각형 요소를 target으로 하여 계산되기 때문에 문제를 해결할 수 있다는 생각이 들었고, scroll 이벤트만으로 구현할 경우 성능저하 등의 부작용이 있기 때문에 방향을 바꾸게 되었습니다.

     

    // useIntersect.ts
    import { useEffect, useRef, useCallback } from 'react';
    
    interface IntersectionObserverInit {
      root?: Element | Document | null;
      rootMargin?: string;
      threshold?: number | number[];
    }
    
    type IntersectHandler = (
      entry: IntersectionObserverEntry,
      observer: IntersectionObserver
    ) => void;
    
    export const useIntersect = (
      onIntersect: IntersectHandler,
      options?: IntersectionObserverInit
    ) => {
      const ref = useRef<HTMLDivElement>(null);
      const callback = useCallback(
        (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) onIntersect(entry, observer);
          });
        },
        [onIntersect]
      );
    
      useEffect(() => {
        if (!ref.current) return;
        const observer = new IntersectionObserver(callback, options);
        observer.observe(ref.current);
        return () => observer.disconnect();
      }, [ref, options, callback]);
    
      return ref;
    };

     

    이렇게 하면 바로 잘 작동할 줄 알았는데, IntersectionObserver 적용하기 전과 같은 결과였습니다.

     

    이유는 IntersectionObserver 적용 전 offsetHeight의 문제를 해결해보려고 이리저리 테스트 코드를 넣었는데요. 스타일에 flex: 1;을 적용했던 것을 지우지 않았던 것이었습니다..!

    원인을 찾기 위해 많은 시간을 들였는데, style쪽의 문제라서 조금 허무했던...

    그래도 실무에서 이런 일이 발생하지 않으리란 법은 없죠 !

     

    배운 점: 등잔 밑이 어둡다. 코드에 문제가 없는데 작동이 잘 안된다면, style 코드를 유심히 살펴보자 !

     

    해당 부분을 삭제해보니 문제없이 잘 동작하였습니다🎉

    'Troubleshooting > Issues' 카테고리의 다른 글

    이미지 fetchpriority로 빠르게 불러오기  (0) 2023.08.25
    scroll event와 style  (0) 2023.08.17

    댓글