무한 스크롤링은 캐릭터보드에 있어서 가장 중요한 요소라고 할 수 있습니다. 가장 중요한 컨테이너, 즉 정보를 보여주는 공간이기 때문이죠. 따라서 이 부분을 굉장히 신경 써서 만들었습니다. 이 글에서는 캐릭터보드에 사용되는 무한 스크롤링의 방법적인 부분을 간단히 설명해 보도록 하겠습니다.

저는 우선 링크드인의 개발 블로그 글을 봤습니다. 이 글에서는 이미지 언로드, 페이지 숨기기, 페이지 제거 등등의 방법을 설명하고 있는데, 사실 구현해본 결과 페이지 제거 외에는 크게 효과가 있지 않았습니다. 이미지 언로드는 사실상 의미조차 없으며, 오히려 너무 자주 이미지를 리로드 하면 아무리 캐시가 있더라도 CDN 서버에 부담이 갑니다. 페이지 숨기기도 크게 효과를 보지 못했습니다. visibility:hidden으로 해도 display: none으로 해도 말이죠. 위 아래 40개정도를 제외한 나머지 컨테이너를 숨겼는데, 그 컨테이너에서 일어나는 CSS연산이 너무 느렸기도 했습니다.

그래서 이런 결론을 얻었습니다. 아! 다 소용 없구나! DOM을 줄여야 한다!!! 저는 가상 컨테이너를 만들어보기로 했습니다. 간단한 아이디어를 설명하자면, 컨테이너의 정보를 전부 담고 있는 리스트를 만든 다음, 스크롤 주변을 제외한 모든 컨테이너를 DOM에 올리지도 않는 겁니다.

그런데… 정보를 담고 있는 컨테이너? 그런 거는 바닐라 JS로는 턱도 없었습니다. 바닐라 JS는 타입에 대해 너무 빈약하기 짝이 없어서 나중에 간단한 타입 오류 하나 잡기도 힘들고 생산성도 저하되기 때문입니다.. 그래서 점진적으로 typescript를 도입했습니다. (사실 타입 스크립트도 처음 배우는 거라서…)

서론이 길었네요, 그럼 바로 시작하겠습니다.

TypeScript의 점진적 도입

타입스크립트

TS 샴푸 아닙니다

타입 스크립트는 이미 c#을 사용하신 분이라면 제가 장담할 수 있습니다. 한 시간이면 충분합니다. 지금 공식 홈페이지에서 도큐먼트 읽어보시면 됩니다. 기존 빌드 시스템을 조금 고쳐야 한다는 게 조금 번거롭긴 하지만, 여러 안내서들이 있으니 쉽게 따라하실 수 있을 겁니다. 저 같은 경우에는… 지금 당장 출시가 15일도 남지 않았고, 이미 있는 코드 베이스 파일이 50개가 넘어가기에… 점진적으로 채택하기로 했습니다.

babel-loader 7이상부터는 구지 ts-loader를 쓰지 않아도 설정에서 @babel/preset-typescript만 추가해 주시면, 간단하게 바로 적용할 수 있습니다. 또 웹팩 설정 파일에 .ts와 .tsx를 추가해 줍니다.

resolve: {
            extensions: [".ts", ".tsx", ".js", ".jsx", '.scss'],
        },

그럼 끝입니다! 간단하죠? 추가적으로 source-map-loader를 사용해줄 수도 있습니다. 저는 isDevelopment이라는 bool 변수를 사용해서, 개발 버전에서만 사용하도록 만들어줬습니다. 리스트 끝에 .filter(Boolean)을 추가해주시는 것을 잊지 마세요!

                isDevelopment && {
                    enforce: "pre",
                    test: /\.js$/,
                    exclude: /node_modules/,
                    loader: "source-map-loader"
                }
            ].filter(Boolean)

사실 타입 스크립트는 syntax적으로 일반 자바스크립트와 크게 다르지 않습니다. 주요한 건, 바로 정적 타입 언어라는 거죠. 기존 자바스크립트는 타입이 너무 모호해 다음과 같은 실수가 자주 일어났습니다. 따로 주석으로 도스를 적어주지 않는 이상은 말이죠.

function sum(a, b) //아니 대체 문자열을 더한다는거야? 숫자를 더한다는거야?
const sumResult = sum(10, 20); //??
const sumResult = sum("phruse", "pig"); //??

타입 스크립트를 사용해주면 다음과 같이 작성해서 이상한 타입의 값을 넣는 간단한 실수는 일어나지 않을 것입니다.

function sum(a: number, b: number)
const sumResult = sum(10, 20); //OK
const sumResult = sum("phruse", "pig"); // ERROR

간단하죠? 제가 쓰는IDE Clion은 타입 스크립트와도 잘 작동해서 추가적인 IDE설정은 필요 없었습니다.

Scroll custom hook

무한 스크롤링 최적화

그런데 대체 어느 부분의 가상 컨테이너를 보여지게 할지, 즉 스크롤 주변을 제외한 모든 컨테이너를 DOM에 올리지 않기 위해서는 스크롤이 얼마나 내려갔는지 계산해야 합니다. 특정 간격으로 스크롤 할 때마다 숫자를 카운터 해서, 그 인근의 가상 컨테이너만 보여지게 만들고 싶었습니다.

왜 Intersection Observer API를 사용하지 않을까요? 관찰 대상이 필요하기 때문입니다. 관찰 대상이 늘어나면 DOM 요소도 증가하게 됩니다. DOM을 가장 가볍게 유지하는 게 목적이므로 메모리 효율적이지 않은 방법을 채택했습니다.

근데 여기서 문제가….. 컨테이너의 높이가 모두 달랐던 것이죠. 더더욱 문제는 컨테이너의 높이를 미리 예측할 수 없었다는 거였죠.

죽겠어요..

네! 그래서 컨테이너의 모든 높이를 계산해 배열에 넣고, 현재 스크롤 Math.round(window.pageYOffset)을 이용해 분할 정복 알고리즘으로 현재 보고 있는 컨테이너의 값을 구했습니다.

function useContainerScroll(): number{
    //...
	const [containerNumber, setContainerNumber] = useState<number>(0);    
	useEffect(()=>{
        const handle = () => {
            const divide = getContainerIndex(Math.round(window.pageYOffset), containerHeights.slice(), 0);
            setContainerNumber(divide);
        };
        window.addEventListener('scroll', handle);
        return () => window.removeEventListener('scroll', handle);
    },[containerHeights]);
	//...
    return containerNumber;
}

이렇게 이벤트를 등록해서 스크롤 이벤트가 발생할 때마다 현재 컨테이너의 번호를 구하는 getContainerIndex함수를 이용해서 containerNumber의 값을 업데이트 해줬습니다.

왜 slice()로 array를 복사할까요? JS는 array를 참조로 전달하기 때문입니다. slice은 선형 시간 함수이니 크게 걱정하실 필요는 없습니다. 물론 getContainerIndex함수도 매우 효율적인 로그 시간을 가지는 함수이기에 array크기에 대해서도 크게 걱정할 필요가 없죠.
이건 여담인데..크롬 기준으로 window.pageYOffset자체가 애초에 number값이라서 bigInt같은 것도 필요 없습니다.
스크롤 개수 세기

매우 잘 작동하는 모습을 볼 수 있습니다. 10000개까지 테스트해본 결과 오차 없이 잘 읽는 모습을 볼 수 있습니다.

Data Class만들기

이제 컨테이너의 데이터를 담고 있을 클레스를 하나 만들어봅시다.

class ContainerData{
    data: any
    index: number
    calculatedHeight: number
    isLoading = false
    private _height = 0
    constructor(index: number, data: any, isLoading?: boolean) {
        if (index !== 0) {
            this.index = index;
        }
        this.data = data;
        if(isLoading !== undefined)
            this.isLoading = isLoading;
    }
  //...

왜 이미 계산된 높이면 있으면 되는데, 굳지 기존 높이까지 저장할까요? 이유는 계산된 높이가 다음 컨테이너에서 계산되기 때문입니다. 이전 컨테이너의 기존 높이가 있어야 계산이 가능하거든요. 모든 컨테이너의 높이가 일정하다는 보장이 없기 때문이기도 합니다. (컨테이너의 높이 등은 글의 길이에 따라 가변적임.)

여기서 data는 any타입으로 두어 컨테이너 컴포넌트 내부에서 해석될 수 있도록 했습니다.

계산된 높이만을 사용하면 여러 가지 문제가 생길 수 있습니다. (예를 들어 첫 번째 컨테이너는 100px, 두 번째 컨테이너는 340px 과 같이 고정된 위치로 지정) 만약 뷰포트의 크기가 변해 컨테이너의 높이가 변한다면 컨테이너의 높이를 첫 번째 컨테이너부터 다시 계산해야 하는 불상사가 생길 수 있습니다. (꽤 오래 스크롤 했다면 몇 천, 몇 만개의 컨테이너를 재계산해야 합니다.) 또 한 번에 빨리 스크롤 하게 되면 보이지도 않을 컨테이너를 하나하나 표시한 뒤 높이를 얻고 다시 숨겨야 합니다.

이런 일을 방지하기 위해 컨테이너를 상대적인 위치로 배치해야 현재 보이는 첫 번째 컨테이너부터 다시 계산하고, 위아래로 스크롤 하더라도 마치 스크롤 된 위치에 그 컨테이너가 있던 것처럼 만들 수 있습니다. 이 내용의 구현은 너무 복잡해서 따로 설명하진 않겠습니다.

물론 이미지 크기를 일정하게 하는 등의 방법으로 브라우저 내에서 각각의 컨테이너별로 높이를 계산하는 것이 아닌 계산된 값을 이용해 더 편하게 구현하는 방법도 있습니다. (예를 들어 (텍스트의 줄 수)*(텍스트 높이) + (컨테이너 패딩))

InfiniteScroll

InfiniteScroll은 단순히 로딩 표시자와 표시될 컨테이너의 개수를 연산하는 컴포넌트입니다.

function InfiniteScroll(props: {
    children: React.ReactNode
    dataLength: number
    loader: ReactElement
    ...
}){
    const [isLoad, setIsLoad] = useState(true);
    ...
    useLayoutEffect(()=>{
        //로딩 표시자 위치 계산
    },[...]);
    return (
        <ErrorBoundary onError={<div>
             ...
        </div>}>
            <div className={style.containerRender} ref={divRef}>
                {props.children}
                {!isLoad && <div ref={loaderRef}>
                    {props.loader}
                </div>}
            </div>
        </ErrorBoundary>
    );
}

뭐 이런식으로 간단하게 말이죠! 여기서 컨테이너에 대한 wrapper컴포넌트를 children으로 넘겨주면 되는겁니다.

아무튼 사용되는 무한 스크롤링의 기본적인 구조는 대략 이렇습니다. 개선돼야 할 부분도 몇몇 보이지만 일단은 개발 시간 맞추는 것이 급선무라서… 무언가 급하게 끝낸 것 같은 건 기분 탓이 아닙니다. 정말로 개발 기간이 얼마 안 남아서 급하기 때문이죠.

글쓴이

phruse

쉬운 길보다는 어려운 길을 즐깁니다. 다양한 분야에 관심이 많으며 언젠가 많은 사람이 사용하는 기반 기술을 개발하는 것이 목표입니다.