리액트에서 Intersection Observer API 재사용성 높이기

개요

이번 글에서는 리액트에서 Intersection Observer API의 재사용성을 높이기 위해 어떤 점들을 고민했고 그 방법들은 무엇이었는지에 대해 공유드리려고 합니다.

Intersection Observer API란?

Intersection Observer API는 기본적으로 브라우저 뷰포트와 타겟 엘리먼트의 교차점을 관찰하여 해당 요소가 뷰포트에 포함되는지 판별하는 역할을 합니다. 쉽게 말해, 현재 사용자에게 보이는 화면에 요소가 나타나는지 구별하는 기능을 제공한다고 할 수 있습니다.

출처: https://heropy.blog/2019/10/27/intersection-observer/

요소가 뷰포트에 포함되는지 알 수 있기 때문에 헤더 컴포넌트가 뷰포트에 보이지 않을 때 position을 fixed로 처리하고 배경 색상을 변경하거나, 스크롤이 페이지의 하단에 닿을 때 추가적인 데이터를 불러오는 InfiniteScrolling 등 뷰포트와 관련된 다양한 상황에 대해 쉽게 대처가 가능해집니다.

사용 방법

Intersection Observer를 사용하기 위해서는 생성자를 호출해야 하며, callbackoptions 이 두 개의 파라미터를 전달해줘야 합니다.

const options = {
root: document.querySelector('#root'),
rootMargin: '0px',
threshold: 1.0
};

const observer = new IntersectionObserver(callback, options);

callbackisIntersecting, rootBounds, target등 읽기 전용 속성들의 배열인 entries와 옵저버 객체인 observer파라미터를 받고 각 entry의 isIntersecting속성이 true일 경우 뷰포트와 교차됐다고 판단하게 됩니다.

const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 교차된 상태
}
});
};

options를 통해서는 observer 콜백이 호출되는 상황을 조작할 수 있으며, root, threshold등의 핵심 속성들에 대한 설명은 다음과 같습니다.

  • root : 대상 요소의 가시성을 검사하기 위해 사용될 객체(루트 요소) 입니다. 기본적으로는 브라우저의 뷰포트로 설정됩니다.
  • threshold : 옵저버가 실행되기 위해 대상 요소가 얼만큼 보여야하는지에 대한 수치입니다. 백분율로 표시하며, 0.5 로 값을 전달해줬을 때는 요소가 절반만큼 노출됐을 때 옵저버가 실행됩니다.
  • rootMargin : 루트 요소의 범위를 확장하거나 축소할 수 있습니다. 해당 값의 설정을 통해 대상 요소가 화면에 보이지 않더라도 미리 옵저버를 실행시키는 등의 행위가 가능합니다.

요구 사항

개인적으로 진행하는 프로젝트에서 아래와 같이 각 우측 GIF 영역에 도달할 시 좌측의 STEP 컴포넌트도 업데이트가 되어야 한다는 요구사항이 들어왔습니다.

요구사항

함수로 일단 분리해보자

처음 들었던 생각은 ‘다른 곳에서도 사용이 될 수 있으니 일반적인 함수로 분리해보자’ 였습니다.

관찰 대상이 될 target, 대상이 뷰포트에 들어왔을 때 혹은 나갔을 때 실행 할 onEnter, onLeave 콜백과 threshold 옵션, 이 4개의 파라미터만 전달받고 옵저버 객체를 반환하는 형식이었는데요.

// interface
interface WaypointParams {
target: HTMLElement | null;
onEnter?: () => void;
onLeave?: () => void;
threshold?: number;
}
// concrete
const setWaypoint = (
params: WaypointParams = {} as WaypointParams
) => {
const { target, onEnter, onLeave, threshold } = params;
if (!target) {
return;
}
const observer = new window.IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
onEnter?.();
return;
}
onLeave?.();
}, {
root: null,
threshold: threshold ?? 0
});
observer.observe(target); return observer;
};

생기는 의문점들

해당 함수를 적용하니 문제없이 동작은 됐습니다. 다만, 무언가 구리고(?) 아쉬운 느낌이 남았습니다.

  1. 굳이 개별 컴포넌트에서 ref를 선언하고 관리해야 하나?
  2. 그렇게 되면 mount와 unmount의 라이프 사이클을 항상 추가해야 될 텐데 꼭 필요할까?
  3. 관리해야 하는 대상이 더욱 많아진다면?
  4. 비슷한 로직이 중복되는 것 같은데?

와 같은 것들이었죠.

더 나아가 ‘꼭 일반 함수일 필요가 있을까?’ 라는 고민으로 이어지게 됐습니다.

그럼 컴포넌트는 어떨까?

View와 관련된 로직이기 때문에 ‘컴포넌트에 관련 기능을 이관하는 것이 좋은 선택이 될 수 있겠다’라는 생각을 했고, 기대 이상의 장점들이 있다는 것을 발견했습니다.

  1. Wrapper 형태로 컴포넌트를 제공한다면 children에 접근할 수 있게 됨.
  2. ref를 생성하고 부착하는 기능을 Wrapper가 담당할 수 있음.
  3. 따라서 옵저버를 observe하고 disconnect하는 책임 역시 이관이 가능해짐.
  4. 하위 컴포넌트나 엘리먼트를 감싸기 때문에 보다 직관적인 표현을 제공할 수 있음.

결론적으로, 상위 컴포넌트에서 불필요하게 ref를 선언하고 mount와 unmount의 라이프 사이클에서 옵저버를 observe, disconnect 하는 로직을 완전히 제거할 수 있게 되는 것이죠.

관리 대상이 늘어날 수록 얻게 되는 이점은 훨씬 증가한다고 생각했습니다.

고려사항

실제 구현에 앞서 몇가지 고려해야 할 점들을 정해봤습니다.

  1. Intersection Observer API와 동떨어진 기능과 props 명칭을 제공하지 말 것
  2. threshold와 rootMargin을 구분하여 좀 더 나이스한 네이밍을 정해볼 것
  3. children을 넘겨주지 않을 때도 동작이 가능하도록 대응할 것
  4. 개인 프로젝트 뿐만 아니라 사내 서비스들, 더욱 나아가 리액트를 사용하는 환경에서는 컴포넌트를 문제 없이 사용할 수 있도록 NPM 패키지로 분리해볼 것

결과

먼저 컴포넌트(Waypoint) 가 인자로 받는 인터페이스입니다.

interface WaypointProps {
children?: React.ReactNode;
onEnter?: () => void;
onLeave?: () => void;
root?: Element | Document | null | undefined;
threshold?: number | number[] | undefined;
topOffset?: string;
bottomOffset?: string;
leftOffset?: string;
rightOffset?: string;
}

현재 단계에서는 Intersecion Observer API가 지닌 모든 속성들을 지원할 필요는 없다고 판단하였고, 기존 API의 rootMargin 속성을 offset이라는 명칭으로 변경하여 제공했습니다. rootMargin이 실제 css의 margin과 같이 top, bottom, left, right 속성을 하나의 구문으로 표현하기 때문에, 좀 더 사용이 편리하도록 각 위치에 대한 값을 개별로 받고 있는 모습입니다.

다음은 실제 구현체입니다.

// 로직 처리
const customRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!customRef.current) {
return;
}
const observer = new window.IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
onEnter?.();
return;
}
onLeave?.();
}, {
root,
threshold,
rootMargin: `${topOffset} ${rightOffset} ${bottomOffset}
${leftOffset}`
});
observer.observe(customRef.current); return () => {
observer.disconnect();
};
}, [deps...]);
// UI
if (!children) {
return (
<span ref={customRef} />
);
}
if (typeof children === 'string') {
return <span ref={customRef}>{children}</span>
}
return cloneElement(children as React.ReactElement, {
ref: customRef
});

useEffect hook에서 observer를 생성하고 구독, 해제하는 역할을 담당하고 있고, children을 넘겨주지 않을 때나 ‘ABC’, ‘가나다’와 같은 문자열 데이터만 넘겨줄 경우에도 ref로 관리할 수 있도록 선처리를 해주고 있습니다.

전체 코드는 링크에서 확인하실 수 있습니다.

비교

아래는 일반 함수로 구현했을 때와 컴포넌트로 분리하여 실제 코드에 적용했을 때의 결과입니다.

함수와 컴포넌트를 적용했을 때의 차이 - 좌측에서 UI 리턴 구문은 생략

컴포넌트로 분리했을 때의 실제 적용 코드가 굉장히 짧아진 것을 볼 수 있습니다. 사실 코드의 양이 줄은 것 보다 가장 눈에 띄는 것은 이전과 비교했을 때 ref와 옵저버를 생성하고 특정 라이프사이클에서 구독, 해제하는 로직이 모두 제거된 것입니다. 상위 컴포넌트에서 굳이 추가하지 않아도 될 불필요한 로직이 제거됐다는 것 만으로도 리팩토링의 확실한 소득이라고 생각했습니다.

추상화의 정도

아래는 react-infinite-scroll-component의 실제 사용 예시입니다.

<InfiniteScroll
dataLength={items.length}
next={fetchData}
hasMore={true}
loader={<h4>Loading...</h4>}
endMessage={
<p style={{ textAlign: 'center' }}>
<b>Yay! You have seen it all</b>
</p>
}
refreshFunction={this.refresh}
pullDownToRefresh
pullDownToRefreshThreshold={50}
pullDownToRefreshContent={
<h3 style={{ textAlign: 'center' }}>&#8595; Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{ textAlign: 'center' }}>&#8593; Release to refresh</h3>
}
>
{items}
</InfiniteScroll>

언뜻 보면 웬 뜬금 없는 코드냐고 생각할 수 있지만, loaderWaypoint 컴포넌트로, next props의 fetchData 함수를 컴포넌트가 받는 onEnter 로 대입해서 생각해보면 충분히 구현 가능한 코드라는 것을 알 수 있게 됩니다.

즉, 추상화의 정도에 따라 구현과 사용 방법들이 다양해진다는 사실을 알 수 있습니다.

DOM의 Scroll과 Resize Event를 추상화해서 Intersection Observer API를 만들었고, API를 추상화해서 Waypoint 라는 컴포넌트를 만들었고, 또 다시 컴포넌트를 추상화해서 InfiniteScroll 이라는 좀 더 구체화된 컴포넌트를 만들 수 있는 것 처럼 말이죠.

정리

컴포넌트 분리 작업을 진행하며 가장 크게 느꼈던 것은 방금 말했던 것 처럼 ‘추상화의 정도에 따라 구현과 사용 방법들이 다양해진다.’라는 것이었습니다. 내가 현재 필요한 기능이 무엇인지에 따라 추상화의 기준을 잘 설정하면 코드를 직접 작성하거나 라이브러리를 선택할 때의 폭이 굉장히 명확해질 것 같습니다.

추가로, 컴포넌트로 분리할 때는 단순히 중복을 제거하는 목적보다는 해당 컴포넌트가 가져야 할 역할과 책임이 무엇인지 고민하는게 중요하다고 생각했습니다. ‘정보를 누가 가지고 있는 것이 좋을까’, ‘책임을 누구에게 할당하는 것이 좋을까’ 등을 고민하며 작업하니 방향성이 명확해지고 의도했던 결과물을 빠르게 낼 수 있었던 것 같습니다.

이번 글에서는 단순히 구현체에 대한 설명을 하는 것 보다는 좀 더 좋은 코드를 작성하기 위해 고민했던 내용들을 공유드리고 싶었습니다.

여기까지 글을 읽어주셔서 정말 감사합니다!

참조

--

--