Intersection Observer
Intersection Observer API는 타겟 요소와 상위 요소(혹은 최상위 요소인 document)의 viewport 사이의 intersection의 변화를 비동기적으로 관찰한다. (MDN에서 정의된 바는 이렇다.) 보다 간단하게 정리하면 특정 Element가 기준 화면(viewport)에 노출되었는지 여부를 감지한다고 보면 된다.
사실 element의 가시성을 판단하기 위해서는 다른 방법도 존재한다. window에 scroll 이벤트에 대한 event listener을 추가하고 Element.getBoundingClientRect( )를 통해 element가 viewport에 상대적으로 가지는 위치를 점검한다면 특정 element의 가시 여부를 판단할 수 있다. 그러나 scroll 이벤트를 감지하는 방법은 사용자가 실제로 이용할 때 수천번, 수만번씩 실행되며 동기적인 특성 때문에 메인 스레드에 영향을 줄 수도 있다. 또한 Element.getBoundingClientRect( ) 메소드는 매번 reflow가 발생하기 때문에 프로그램의 성능을 저하시킬 수 있다는 단점이 있다. Intersection Observer은 훨씬 적은 리소스를 소모하면서 동일 기능을 구현할 수 있다는 장점을 가진다.
Intersection Observer API는 다음과 같은 목적으로 활용할 수 있다. (이번 포스트에서는 제목에 나와있듯이 lazy loading과 infinite scroll을 다뤄볼 예정이다.)
(1) 페이지 스크롤 과정에서 발생하는 이미지 혹은 컨텐츠의 지연 로딩(Lazy Loading)
(2) 사용자가 페이지를 이동하는 과정없이 다량의 컨텐츠를 계속 로드할 수 있는 무한 스크롤 (Infinite Scroll)
(3) 광고 수익을 계산하기 위해 광고 가시성 분석
(4) 사용자에게 노출 여부에 따라 애니메이션 혹은 작업 부여
Intersection Observer 사용법
Intersection Observer은 생성자 함수를 통해 만들 수 있다. Intersection Observer을 생성할 때에는 첫 번째 파라미터로 콜백 함수를, 두 번째 파라미터로 옵션들을 넣어준다.
const observer = new IntersectionObserver(callback, options?);
- callback
callback은 다음 두 가지 경우에 호출된다.
(1) 대상 element가 기준 viewport 혹은 요소와 교차하였을 때
여기서 element가 교차하는 대상 혹은 교차 여부를 판단하는 기준 등은 options를 통해 설정해줄 수 있다.
(2) observer가 최초로 대상을 관측하라고 요청받을 때
- options
(1) root
대상 element의 가시성을 판단하는 기준 요소로 대상의 조상 element이어야 한다. 기본값은 브라우저의 viewport이다.
(2) rootMargin
위의 root가 가진 여백으로 CSS의 margin 스타일값과 유사하게 지정해줄 수 있다. (ex. "10px 20px 30px 40px") 이 여백은 교차성을 계산하기 전에 적용되며 기본값은 0이다.
(3) threshold
교차 여부를 판단하는 기준을 정해준다. 0~1 사이의 값을 지정해주면 된다. 예를 들어서 0.5의 값을 지정해주면 대상 element가 50%만큼 교차하였을 때 콜백함수가 실행된다. 기본값은 0으로 이는 대상 element가 1px만이라도 root와 교차하면 콜백이 실행됨을 의미한다. 참고로 threshold는 배열의 형태로 여러 값을 지정해줄 수 있다. (ex. [0, 0.5, 1]) 여러 값을 지정하는 경우 해당 단위로 교차범위가 변경될 때마다 콜백 함수가 실행된다.
생성된 observer 객체는 observe 메소드와 unobserve 메소드를 통해 인자로 넘겨주는 element을 관찰하거나 관찰을 그만둘 수 있다. (참고로
useIntersectionObserver Hook
위에서 배운 내용들을 모두 종합하여 편리하게 intersectionObserver을 사용하는 Hook을 만들어줄 수 있다. 먼저 Intersection Observer을 생성하는데 필요한 options를 optional parameter로 받는다. 해당 훅은 총 3가지 값들을 반환한다. observer.current는 해당 훅이 참조하고 있는 실제 observer 객체이고 setElements는 observer의 관찰 대상을 지정해준다. 마지막으로 entries는 타겟 element와 root 사이의 교차를 묘사하는 IntersectionObserverEntry들을 담고 있으며 실제로 해당 훅을 호출하는 곳에서 entries.forEach를 통해 자의적으로 콜백 함수를 지정할 수 있게 해준다.
observe 함수는 관찰할 대상(elements) 혹은 observer 옵션들(root, rootMargin, threshold)가 변경될 때마다 실행되며 주어진 설정에 맞춰 새로운 Intersection Observer 객체를 참조하고 해당 객체가 타겟 element들을 관찰하게 한다. 제일 중요한 것은 useEffect 함수 내에서 disconnect( ) 메소드를 리턴해줌으로써 언마운트할 때 observer 객체가 존재하면 해당 observer을 끊어주는 것이다. 만약 해당 작업을 수행하지 않는다면 원치 않는 side effect들이 발생할 수 있으니 해당 문장을 작성하길 바란다.
전체적인 코드는 다음과 같다. (공부용 프로젝트에서 따온 거라서 Typescript로 작성된 점 양해 바란다.)
Infinite Scroll
위에서 생성한 useIntersectionObserver 훅을 사용해서 마지막 아이템이 화면에 노출될 때마다 추가로 아이템을 가져오는 무한 스크롤을 구현해보겠다. 간단한 예시를 위해서 아이템은 실제 fetch 작업으로 가져오는 것이 아니라 setTimeout 함수를 통해 2초 후에 아이템 5개를 추가하는 방법으로 진행했다. 참고로 ItemType은 number 타입의 num 프로퍼티 하나만을 가지는 간단한 타입이다. 실제로 실행되는 로직의 순서는 다음과 같다.
(1) 처음 훅이 생성되면 getItems( )를 통해 아이템들을 가져온다.
(2) getItems( )를 통해 아이템이 생성되면 items에 의존성을 가지는 두 번째 useEffect문이 실행된다. 그 안에서는 items가 존재할 경우 querySelector을 통해서 아이템 Element들을 모두 가져오고 그 중 마지막 아이템을 observer의 타겟 element로 지정한다.
(3) target element가 지정되었기 때문에 useIntersectionObserver 훅 내에서 새로운 observer을 생성 후 참조한다.
(4) observer이 변경됨에 따라 useIntersectionObserver에서 반환하는 entries와 observer이 모두 바뀌었다. 그에 따라 세 번째 useEffect문이 실행된다. 세 번째 useEffect문은 실질적인 콜백 함수, 즉 타겟 element가 교차되었을 때 실행되기를 원하는 실제 작업들을 넣어준다.
(5) 우리가 만들어준 observer의 관찰 대상은 마지막 item element이다. 만약 item element가 viewport 만으로 들어온다면 이전에 세 번째 useEffect문을 통해서 지정해준 콜백 함수가 실행된다. 해당 예시에서는 마지막 item이 교차되면 observer가 해당 element에 대한 관찰을 중단하고 추가로 아이템을 가져오는 불러오는 작업을 수행한다. 이후 (2)부터의 과정이 반복된다.
실제 코드는 다음과 같다. 첫 번째 이미지는 위에서 언급한 내용이 실질적으로 들어가 있는 useItemPage.ts 파일이고 두 번째 파일은 useItemPage 훅에서 반환된 값을 통해 실질적인 view를 제공하는 itemPage.tsx 파일이다. (참고로 위와 같이 파일을 분류한 이유는 컴포넌트의 로직적인 부분과 뷰적인 부분을 분리하기 위해서이다. 개인적으로 컴포넌트 내부 로직의 추상화가 잘 이루어지는 것 같아서 좋아하는 방법이다.)
Image Lazy Loading (+Inifinite Scroll)
이번에는 조금 더 복잡한 코드를 작성해볼 것이다. 우선적으로 많은 이미지들 컨텐츠를 불러올 때 화면에 보이지 않는 저 밑의 element까지 한 번에 로딩하는 것은 비효율적이다. 어차피 사용자들은 화면 밖에 있는 element들은 확인할 수 없기 때문이다. 화면에 보이는 컨텐츠들만을 우선적으로 로딩하고 이후 나머지 컨텐츠들은 화면에 보일 때마다 로딩하는 Lazy Loading 기법을 활용한다면 프로그램의 성능을 더욱 향상시킬 수 있을 것이다.
이번 예시에서는 Lazy Loading과 앞서 구현한 Infinite Scroll을 한 번에 적용할 것이다. 또한 Infinite Scroll을 구현할 때에는 React Query의 Infinite Query를 활용할 것이다. (우리가 정말 많은 컨텐츠를 보여줘야할 때는 API를 통해 데이터를 가져올 때이기 때문이다.)
이번 예시의 목표점을 종합하면 이렇다. 랜덤한 강아지의 이미지를 제공하는 Public API를 통해서 강아지 이미지를 가져온다. 가져온 강아지 이미지들에는 lazy loading을 적용하여 화면에 실질적으로 노출될 때 이미지 src loading을 실행한다. 또한 마지막 강아지 사진이 화면에 보여지면 API를 통해 강아지 이미지를 추가로 가져온다.
실제 구현
Lazy Image 컴포넌트
우선 가장 핵심이 되는 것 중 하나인 Lazy Image 컴포넌트인다. LazyImage 컴포넌트는 일반적인 img와 큰 차이를 가지지 않는다. 다만 이미지의 src를 직접 지정해주는 것이 아니라 dataset 속성을 통해 지정해줘야 한다는 점을 꼭 기억해야 한다. 이미지의 src는 바로 대입해주지 않으며 이후 해당 이미지가 화면에 노출되었을 때 dataset에서 src를 획득한 뒤 자바스크립트상으로 element에 넣어준다.
React Query - Infinite Query
이어서 강아지 사진들을 여러번 가져오는 Infinite Query이다. 아래 코드에서 실질적인 fetch를 담당하는 getRandomImages 함수는 Public API를 통해 10장씩 이미지를 가져온다. fetch가 성공한다면 message에는 이미지 url들의 배열이 담겨져 있을 것이고 실패한다면 undefined가 담겨져 있을 것이다. 만약 성공한다면 프로그램의 Dog Type에 맞춰서 결과를 가공해주고 실패했다면 그냥 undefined를 리턴해주면 된다. useInfiniteQuery의 세 번째 인자인 options에 들어가는 getNextPageParam은 첫 번째 인자인 이전 쿼리의 결과를 통해 다음 쿼리 실행의 parameter를 구해준다. 만약 getNextPageParam의 결과가 undefined라면 더 이상 추가 fetch가 이루어지지 않는다. 이번 예시에서는 fetch에 실패한 경우 undefined가 리턴되기 때문에 이후 fetch를 더 이상 실행하지 않는 방식으로 코드를 작성하였다. 일반적인 경우에는 fetch의 결과에서 hasNextPage와 같은 항목을 반환함으로써 더 가져올 데이터가 있는지를 확인하게 해준다. 만약 hasNextPage가 true라면 이전 page + 1인 다음 페이지값을 파라미터로 넘겨주고 그렇지 않다면 undefined를 넘겨서 쿼리 실행을 중단하면 된다.
이제 만들어놓은 LazyImage 컴포넌트, useDogsQuery, 그리고 useIntersectionObserver 훅을 조합해서 코드를 작성한다. 이번에는 전체적인 이해를 위해 먼저 코드 이미지를 업로드하고 로직을 설명하겠다.
우선 useDogsQuery( )를 통해 강아지 이미지들을 가져온다. 그에 따라 data에 의존이 있는 첫 번째 useEffect가 실행된다. 그를 통해 data가 변경될 때마다 가져온 data를 통해 만들어준 모든 Lazy Image들을 관찰 대상으로 지정한다. 그러면 entries와 observer을 의존 배열로 가지고 있는 두 번째 useEffect 내부가 실행되어 교차시 실행될 작업을 각각의 entry에 지정해준다. 위의 예시에서는 관찰 대상 element가 window viewport로 올라오면 entry.target을 통해 element를 가져온 뒤 혹시나 img element가 아닐 경우를 대비해서 검사를 수행한다. 만약 이미지가 맞다면 loadLazyImage를 통해 dataset에서 src 값을 가져와 src에 직접 넣어준다. 이를 통해 Lazy Image는 실질적으로 화면에 로드된다. 이어서 observer가 해당 element를 관찰 대상에서 제외한다. 추가적으로 해당 element가 마지막 lazy image이면서 더 가져올 데이터가 있고(hasNextPage) 현재 데이터를 추가로 가져오는 중이 아니면(!isFetchingNextPage) useDogsQuery의 fetchNextPage를 통해 추가로 데이터를 가져온다. 마지막으로 Infinite Query를 통해 가져온 데이터는 한 번 쿼리를 실행할 때마다 그 결과물(group)이 data.pages 배열 안으로 들어간다. 따라서 모든 데이터를 한 곳으로 모아준 dogs 변수를 생성해준다. dogs 변수를 생성하기 위해서는 우선 filter 함수를 통해 fetch를 실패한 결과물인 undefined가 담긴 group을 제거해준다. 이어서 flat( ) 함수를 실행해 전체 DogType 객체들이 단일 레벨 Array로 들어갈 수 있도록 해준다.
참고로 isLoading은 처음 데이터를 가져오는 과정인지 여부를, isFetchingMore은 추가로 데이터를 가져오는 과정인지를 보여준다. 만약 isLoading 대신 isFetching을 활용하고 싶다면 처음 데이터를 가져올 때인지를 기준으로 하는 isLoading과는 다르게 isFetching은 initial fetch와 refetch 두 가지 경우 모두 true 상태임을 주의해야 한다. 실제로 웹사이트를 작성할 때에는 isLoading일 경우에는 로딩 안내 문구를 보여주고 isFetchingMore일 때에는 스피너를 보여주는 등 두 상태를 서로 구별하는 것이 바람직하겠지만 이번 예시는 학습이 우선이므로 그냥 Loader 컴포넌트 하나를 두 경우 모두에 사용하였다.
만약 코드를 직접 확인하고 싶으면 깃허브 링크를 이용하길 바란다.
'프론트엔드 기본개념 복습 > Javascript' 카테고리의 다른 글
[Javascript] 명시적 타입 변환 (Explicit Type Conversion) (0) | 2022.04.16 |
---|---|
[Javascript/React] element가 오버플로우중인지 여부 판단하기 (0) | 2022.04.14 |
[Javascript] ES6 (ECMASCRIPT 2015) (0) | 2022.03.04 |
[Javascript] 콘솔 제대로 써먹기 (How to properly use console) (0) | 2022.03.03 |
[Javascript] Call by Value vs Call by Reference (0) | 2022.03.02 |