예전 프로젝트에서 tanstack query의 in useInfiniteQuery를 활용하여 무한스크롤을 구현한 적이 있습니다.
그 당시에 굉장히 수월하게 구현할 수 있었는데, 원티드 프리온보딩에서 라이브러리 없이 Intersection Oberserver API을 활용하여 무한스크롤을 구현하라는 사전과제를 받은 김에 정리하고자 글을 작성하게 되었습니다.
Intersection Observer API란?
MDN에 따르면 Intersection Observer API의 필요성은 아래와 같습니다.
역사적으로, 요소의 가시성 또는 관련된 두 요소 사이의 상대적 가시성을 감지하는 것은 해결책을 신뢰할 수 없고 브라우저와 사용자가 접근하는 사이트를 느리게 만드는 어려운 작업이었습니다. Web이 성숙해짐에 따라, 이러한 종류의 정보의 요구가 늘어났습니다. 교차 정보는 다음과 같은 많은 이유로 필요합니다.
즉, 특정 요소가 얼만큼 노출되었는지(교차 정보)를 확인할 필요성이 점점 늘어났고 이를 위해 생긴 API입니다.
이때 상호작용 요소를 메인스레드가 아닌 콜백함수에서 관리함으로써 , 브라우저는 적합하다고 판단되는 대로 교차 관리를 자유롭게 최적화할 수 있게 됩니다.
문법
new IntersectionObserver(callback)
new IntersectionObserver(callback, options)
콜백함수 콜백 함수는 대상 요소가 지정한 가시성 임계값을 넘을 때 호출됩니다. 콜백 함수는 두 개의 매개변수를 입력받는데, - entries는 콜백함수가 생길 때 발생한 정보를 담고 있습니다. - observer는 관측자에 대한 정보를 담고 있습니다.
options option은 콜백함수가 발생하는 조건에 대해 커스텀 할 때 쓸 수 있습니다. 선택적인 요소라서 필요할 때 적용하면 됩니다. - root : 상호작용의 경우에 뷰포트 기준으로 할 때가 많습니다. 하지만 특정 모달 내부의 스크롤 등 관측 대상자의 상위 요소를 지정해줘야 할 경우에 사용합니다. -rootMargin : 이미지 등 미리 보여야할 경우에는 뷰포트에 도달하기 전에 사용해야할 수 있습니다. 이때 해당 관측대상 기준으로 미리 감지할 수 있는 여백의 크기를 결정합니다. -threshold : 관측 대성이 얼만큼의 가시성을 확보해야 상호 작용할 지 설정할 떄 사용합니다.
이를 바탕으로 무한 스크롤에 사용한 옵션의 예시는 아래와 같습니다.
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fn();
}
},
{ threshold: 1.0 }
);
관측 대상이 완벽하게 보일 때 fn이라는 함수를 실행하겠다 입니다.
Intersection Observer를 활용하여 React에서 무한스크롤 구현하기
무한스크롤을 구현하는 방법은 여러가지가 있습니다. 스크롤 높이 기준으로 구현할 수도 있고, 특정 물체가 감지될 때마다 다음페이지를 요청하는 옵저버 방식도 있습니다.
Intersection Observer API를 통해 구현할 옵저버 방식으로 감지대상이 감지되면 다음 페이지에 대한 정보를 요청할 것 입니다.
우선 완성된 화면은 아래와 같습니다. 상품 리스트를 담고, 상품의 가격을 전부다 합산한 내용이 우측 상단에 표시했습니다. 그리고 하단으로 내리다 불러온 자료 끝부분에 도달하면, 새로운 요청을 합니다.
코드 구조는 다음과 같습니다. App에서 무한스크롤 영역과 가격 총합을 보여주는 Header부분이 있습니다. 이때 가격을 계산하는 부분은 커스텀훅으로 따로 구현하였습니다.
Proxy객체를 사용하면 원래Object대신 사용할 수 있는 객체를 만들지만, 이 객체의 속성 가져오기, 설정 및 정의와 같은 기본 객체 작업을 재정의할 수 있습니다. 프록시 객체는 일반적으로 속성 액세스를 기록하고, 입력의 유효성을 검사하고, 형식을 지정하거나, 삭제하는 데 사용됩니다.
let proxy = newProxy(target, handler)
target– 감싸게 될 객체로, 함수를 포함한 모든 객체가 가능함
handler– 동작을 가로채는 메서드인 '트랩(trap)'이 담긴 객체로, 여기서 프락시를 설정함
Counter 1 2는 각각 상태 count 12 를 사용하고 있다. zustand나 redux처럼 별도의 selector를 사용하지 않기에, 리렌더링 최적화가 안될거 같지만 앞서 언급한 상태 사용 추적기법에 의해서 리렌더링이 최적화가 되어있다.
1을 눌렀을 때 2가 리렌더링 되지 않음
useSnapshot 훅에서 나온 스냅샷을 컴퍼넌트가 사용하게 되면 해당 컴퍼넌트가 특정 상태를 사용하는 것을 기억한다. 이후 상태가 변경하였으면 proxy에서 특정 상태의 변경을 알리고 이를 통해 useSnapshot훅이 컴퍼넌트가 사용하기 있는 객체가 변경 되었으면 리렌더링을 하는 것이다.
Valtio내부 코드 확인
state (proxy함수 리턴값) 와 snap을 각각 콘솔로 찍어보면 Proxy 객체임을 확인할 수 있습니다. 그리고 각각의 Handler를 보면 state에는 deleteProperty와 set이 존재하고 snap에는 get, getOwnPropertyDescription ,has , ownKeys 프로퍼티가 존재하는 것을 확인할 수 있습니다.
이제 각각의 코드를 확인하려고 합니다. 분량이 많아서 전체코드는 다루지 않겠습니다.
각각의 코드를 확인하기전에 proxy, useSnapshot 모두 Proxy를 다룰 때, proxy-compare 라이브러리에 의존하고 있고, 해당 부분은 관심사의 밖이기에 간단하게 어떤 역할을 하는지만 언급하려고 합니다. 자세한 내용은 상단 링크의 레포에서 확인할 수 있습니다.
addPropListener 특정 속성에 리스너를 추가하는 함수입니다.속성의 변경을 감지하고, 구독자에게 알림을 보내기 위해 사용
removePropListener 속성에 연결된 리스너를 제거하는 함수
notifyUpdate 속성 값이 변경되었을 때 변경 사항을 알리는 함수로 변경된 속성의 이름과 값, 이전 값 등을 구독자에게 전달함
또한 앞서 console.log에서 확인했듯이 creatHandler는 set 와 deleteProperty 두가지 핸들러 메서드를 반환하는 것을 확인할 수 있습니다.
이제 각각의 핸들러 메서드에서 어떤 작업이 진행되는지 알아봅시다.
deleteProperty 1. 삭제하려는 속성의 기존 값을 Reflect.get으로 가져옴 2. 그 다음, 해당 속성에 연결된 리스너를 제거함 3. Reflect.deleteProperty를 통해 속성을 삭제함 4. 속성이 성공적으로 삭제되면, notifyUpdate를 호출하여 삭제 작업을 listener에게 알림 5.삭제가 여부를 반환합니다.
set 1. 속성의 이전 값이 존재하는지 확인하고 초기화 중이 아니라면, Reflect.has로 해당 속성이 이미 있는지 확인하고, 이전 값을 가져옵니다. 2.만약 이전 값과 새 값이 동일하거나, 새 값이 이미 캐시된 프록시 값과 동일하다면 true를 반환하고 설정 작업을 종료 3. 새로운 값이 설정될 속성에 대해 기존 리스너를 제거함 4. 새 값이 객체일 경우, getUntracked를 호출해 프록시되지 않은 원본 객체를 가져오거나, 새 값이 프록시 가능한 경 우 proxy()를 사용해 새로운 프록시로 변환. 5. addPropListener를 통해 새 값에 대한 리스너를 추가 6. Reflect.set을 호출하여 속성에 새로운 값을 설정 7. notifyUpdate를 호출해 속성 값이 변경되었음을 알림
결국 proxy는 캐시를 활용한 최적화에 대한 작업과 쓰기 등록 시 구독자에게 알려준다는 사실을 알았습니다. 하지만
컴퍼넌트가 해당 객체의 어떤 속성을 사용하는지 어떻게 알고 리렌더링 시키는지에 대해서 알기 위해선 useSnapshot훅을 분석할 필요성이 있습니다.
subscribe: 하나의 callback 인수를 받아 store에 구독하는 함수입니다. store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다. subscribe 함수는 구독을 정리하는 함수를 반환해야 합니다.
getSnapshot: 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수입니다. store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다. 저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링합니다.
optional getServerSnapshot: store에 있는 데이터의 초기 스냅샷을 반환하는 함수입니다. 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용됩니다. 서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며 일반적으로 직렬화되어 서버에서 클라이언트로 전달됩니다. 이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생합니다.
이제 다시 useSnapshot 훅을 보면 1. useCallback으로 감싸진 subscribe부분, 2. 화살표 함수로된 getSnapshot, 3. ()=>snapshot(proxyObject)인 getServerSnapshot 을 확인할 수 있습니다.
const currSnapshot = useSyncExternalStore(
useCallback(
(callback) => {
const unsub = subscribe(proxyObject, callback, notifyInSync)
callback() // Note: do we really need this?return unsub
},
[proxyObject, notifyInSync],
),
() => {
const nextSnapshot = snapshot(proxyObject)
try {
if (
!inRender &&
lastSnapshot.current &&
!isChanged(
lastSnapshot.current,
nextSnapshot,
affected,
newWeakMap(),
)
) {
// not changedreturn lastSnapshot.current
}
} catch {
// ignore if a promise or something is thrown
}
return nextSnapshot
},
() => snapshot(proxyObject),
)
1. subscribe 함수에서 전달된 callback에서는 proxyObject의상태 변화를 감지하고 상태가 변경될 때마다 이 callback이 호출되어 새로운 스냅샷을 가져오고 컴포넌트를 리렌더링합니다. 2. getSnapshot 함수에 전달된 화살표 함수 내부에 있는 isChanged 함수는 상태가 변경되었는지 확인하는 역할을 합니다. 만약 상태가 변하지 않았으면 리렌더링을 방지합니다.
이제 proxy 객체가 변화가 되었을 때, 왜 컴퍼넌트가 리렌더링하는지는 알게 되었습니다. 하지만 아직 어떻게 특정 컴퍼넌트가 객체를 사용하고 있는지에 대해서는 언급이 없었습니다.
해당 내용은 createProxyToCompare 이란 별칭으로 사용된 proxy-compare 라이브러리의 createProxy 함수에 있습니다.
공식 문서에서 createProxy 함수에 대한 설명은 아래와 같습니다.
createProxy 함수는 최상위에서 프록시를 생성하고, 접근할 때 중첩된 객체들도 프록시로 만들어, get/has 프록시 핸들러를 통해 어떤 프로퍼티가 접근되었는지를 추적합니다.
해당 함수에 관한 내용은 방대하기에 소스코드 링크만 남겨두고, 어떻게 proxy 핸들러에서 접근을 감지하는지에 대한 부분만 간단하게 추츨하는지 알아보기 위해 proxy의 핸들러의 내용만 추출하면 아래와 같습니다.
이떄 get이 proxy를 읽을 때 핸들링하는 내용입니다. key에 접근할 때, recordUsage를 호출해 KEYS_PROPERTY와 함께 사용 기록을 남기고, 다시 createProxy를 호출하여 그 값을 프록시로 감쌉니다. 따라서 우리가 컴퍼넌트에서 useSnapshot훅의 반환 proxy를 사용하게 되면 get 핸들러 메서드에 의해 기록되어서 추후 상태가 변경되었을 때 관련 상태를 사용한 컴퍼넌트만 리렌더링이 되게 됩니다.
요약
리액트 훅을 활용한 마이크로 상태관리훅을 통해서 proxy와 Valtio의 상태 사용 추적이 어떻게 일어나는지에 대해서 알아봤습니다.
proxy를 통해서 원본 객체에 핸들링 메서드를 적용할 수 있는데, Valtio에서는 이를 통해서 proxy의 상태 변화를 감지하고 이를 기반으로 불변의 snapshot을 찍습니다. 이때 상태관리하는 프록시에서는 proxy의 핸들러 메서드 중 set과 deleteProperty를 통해 변경을 listener들에게 알립니다.
그리고 리엑트 컴퍼넌트에서는 useSnapshot 훅 내부의 useSyncExternalStore와 createProxy를 통해서 2차 가공된 proxy 객체를 얻습니다. 해당 객체는 원본 proxy가 상태 변화를 할 때마다 새로운 proxy가 형성되는 불변 객체이며 컴퍼넌트에서 가공된 proxy객체(추후 스냅샷이라 부름)를 사용할 경우 createProxy훅 내부에 있는 get 메서드에 의해서 사용이 감지되고 등록이 됩니다. 이후 proxy객체가 변경이되면 useSyncExternalStore훅에 의해서 이전 상태와 차이점이 있는지를 비교하고 있을 경우 리렌더링이 됩니다.
Let's start with an easy example. An atom is just a function that will return a configuration object. We are using WeakMap to map atom with their state.
WeakMap doesn't keep its keys in memory, so if an atom is garbage collected, its state will be garbage collected too. This helps avoid memory leaks.
요약하면 아래와 같습니다.
1. 조타이에서는 atom으로 상태관리를 하는데 atom은 함수이다.
2. WeakMap은 키를 메모리에 유지하지 않기 때문에 가비지 콜렉터가 atom을 수집하면 상태도 제거해준다.
즉, 손쉽게 메모리 누수를 방지하기 위해서 조타이에서는 WeakMap을 사용합니다. 그렇다면 왜 WeakMap은 무엇일까요?
WeakMap에 대해서
WeakMap은 이름부터 JS의 Map과 관계가 있어보입니다. 따라서 우선 Map에 대해서 알아보려고 합니다.
ECMAScript 6에서 값들을 매핑하기 위한 새로운 데이터 구조를 소개 하고 있다. 그중 하나인 Map객체는 간단한 키와 값을 서로 연결(매핑)시켜 저장하며 저장된 순서대로 각 요소들을 반복적으로 접근할 수 있도록 한다.
Object와 Map 비교 1. Object의 키는 Strings이며, Map의 키는 모든 값을 가질 수 있다. 2. Object는 크기를 수동으로 추적해야하지만, Map은 크기를 쉽게 얻을 수 있다. 3. Map은 삽입된 순서대로 반복된다. 4. 객체(Object)에는 prototype이 있어 Map에 기본 키들이 있다. (이것은 map = Object.create(null) 를 사용하여 우회할 수 있다. )
자바스크립트에서는 key,value가 쌍으로 이루어진 자료구조에는 Object, Map, WeakMap 3가지가 중요합니다.
앞서 말했듯이 조타이는 atom이라는 함수를 키로 상태를 관리합니다. 그리고 함수는 객체이기에, Map이나 WeakMap이 적합합니다.
따라서 아래의 예시를 통해서 값을 확인해보면, 첫 번째 예시의 object의 경우 collector에 의해 참조되고 있기 때문에 도달할 수 있다고 평가되어 지고 값이 유지되어 있음을 볼 수 있습니다. 하지만 weakMap의 키로 사용된 obj2의 경우에는 참조로 덮어씌워 졌을 때, 가비지 컬렉터에 의해 수거됩니다. 그리고 WeakMap에서 키가 수거된 경우에는 자연스럽게 값도 같이 가비지 컬렉터에 의해 수거됐음을 확인할 수 있습니다.
Jotai에서 WeakMap
다이시 카토에 의하면 Jotai도 구독 모델 기반의 라이브러리입니다.
아래는 Jotai의 createStore입니다. WeakMap을 사용하는 것을 확인할 수 있네요.