React 상태 업데이트와 브라우저 렌더링 사이클 feat.무한스크롤
최근 무한스크롤을 React로 구현하다가 만난 문제를 해결하면서 브라우저 렌더링 사이클에 관해 공부한 내용 정리입니다.
요구사항 : 무한스크롤 구현하기 - 카드목록UI를 스크롤할 때마다 20개씩 더 보여주기
요구사항을 구현하기 위해 아래 4가지 단계가 필요합니다.
- 스크롤 감지
- 스크롤 감지후 목록 데이터 추가 요청하기
- 기존 목록 데이터와 합치기
- 목록 데이터를 추가로 가져온 뒤에도 기존에 스크롤하던 위치 유지하기
일단 첫번째 스크롤 감지 를 위해서 아래 2가지 방법이 떠올랐고
- scroll 이벤트를 이벤트리스너로 구독해 현재 스크롤 위치를 받아옵니다.
- IntersectionObserver를 이용해 detect할 엘리먼트를 구독하고 해당 엘리먼트가 뷰포트에 들어오는지 감지합니다.
그중 IntersectionObserver를 활용해 목록 데이터를 추가로 가져와 기존 목록 데이터와 합쳐주는 것까지는 완성!
그러나 목록 state가 업데이트될 때마다 화면이 리렌더링되면서 스크롤 위치가 최상단으로 리셋되어버리는 문제가 발생했습니다. 유저 입장에서 스크롤하던 위치에서 데이터만 아래로 더 불러오는게 아니라 스크롤이 위로 쭉 끌어당겨지니 불편합니다. 다시 보던 위치까지 스크롤을 내려줘야 하니까요. 목록 데이터를 추가로 가져온 뒤에도 기존의 스크롤하던 위치를 유지 하도록 해보겠습니다.
기존 코드의 동작순서를 적어보면 이렇습니다.
- 새로운 목록 데이터 fetching 완료
- 상태(state) 업데이트 (list 배열에 새 데이터 추가)
- React 컴포넌트 리렌더링 시작
- DOM 업데이트
React 라이프사이클에 따라 state 변경시 해당 state를 사용하는 List 컴포넌트가 리렌더링되면서 스크롤이 최상단으로 리셋됩니다. 따라서 스크롤 위치를 이동시켜주는 코드를 추가합니다.
- 새로운 목록 데이터 fetching 완료
- 상태(state) 업데이트 (list 배열에 새 데이터 추가)
- window.scrollTo(기존 스크롤 위치) 실행 <- 추가
- React 컴포넌트 리렌더링 시작
- DOM 업데이트
그러나 이 경우에도 문제는 해결이 되지 않았는데, 그 이유는 window.scrollTo(기존 스크롤 위치) 가 실행되는 시점이 실제 DOM이 업데이트 되기 전인 경우 제대로된 스크롤 위치를 찾을 수 없기 때문이었습니다.
좀 더 React의 상태 업데이트와 브라우저 렌더링 사이클 관점에서 설명해보겠습니다.
1 . React Render Phase
- 상태(state) 업데이트 : list 배열에 새로 fetching한 data merge
- Virtual DOM 생성 : 새로 업데이트된 list만큼 React children 엘리먼트 생성
- 이전과 이후의 Virtual DOM diff 비교 -> 최소한의 DOM 업데이트 계획 수립
2 . React Commit Phase
- 실제 DOM 업데이트 : 새로운 React children 엘리먼트가 추가되면서 새로운 DOM 노드 생성(or 수정/삭제)
- refs 업데이트
3 . 브라우저 렌더링 사이클
- Layout 업데이트(Reflow) : 변경된 엘리먼트의 위치, 크기 계산
- Paint 페인팅 : 변경된 레이아웃 위에 픽셀 렌더링 (색상, 그림자, 테두리 등등 시각적 요소)
- Composite : 최종 화면 구성
이런 순서를 바탕으로 작성한 코드의 실행 시점을 대입해보면 아래와 같습니다. (수도코드입니다.)
loadMore = () => {
// API 요청
fetching({
//API 응답 후 실행되는 콜백
onCompleted: () => {
// 동기적으로 실행됨
window.scrollTo({
top: currentScrollPosition.current,
behavior: 'auto'
})
// 1. React Render Phase
// - list 상태 업데이트
// - Virtual DOM 업데이트
// - Diff 계산
// 2. React Commit Phase
// - 실제 DOM에 새로운 ListItem 추가
// 3. 브라우저 렌더링 사이클
// - Style: 새로운 ListItem 스타일 계산
// - Layout: 페이지 레이아웃 재계산
// - Paint: 새로운 콘텐츠 그리기
// - Composite: 최종 화면 구성
}
});
};
실행 시점:
- API 응답 수신 (비동기)
- onCompleted 콜백 실행 (동기)
- scrollTo 실행 (동기)
- React 상태 업데이트 (동기적 호출, React 내부에서 비동기적 batch처리, 마이크로태스크 큐에서 처리)
- React 렌더링 (render phase, commit phase, 마이크로태스크 큐에서 처리)
- 브라우저 렌더링 (렌더링 큐에서 처리, 비동기)
onCompleted 콜백에서 window.scrollTo 는 React의 상태 업데이트나 브라우저 렌더링을 "기다리지 않고" 즉시 실행됩니다. 그렇기 때문에 새로운 list 데이터를 불러오고 난 뒤라도 DOM을 모두 업데이트하기 전, 즉 레이아웃 하단에 구독하고 있던 intersecting ref의 위치가 미처 계산되기 전 스크롤을 이동하게 되므로, 정확한 위치로 스크롤을 이동시킬 수 없게 되는 것입니다.
그러면 정확히
- 새로운 list 데이터를 가져오고
- React 상태를 업데이트하고
- React 렌더링 과정을 거쳐
- 브라우저 렌더링을 마치고 난 뒤
를 기다렸다가 스크롤 위치를 이동해주려면 어떻게 해야할까요?
setTimeout을 활용하여 이 모든 순서를 기다려 실행 순서를 보장할 수 있습니다.
onCompleted : () => {
setTimeout(() => {
window.scrollTo({
top: currentScrollPosition.current,
behavior: "auto",
});
}, 20);
}
setTimeout을 이용해 콜백을 매크로태스크 큐로 보내고 실행순서를 보장할 수 있습니다. 이렇게 무한스크롤시 새로운 데이터를 불러오고, 스크롤 위치도 기존에 보여주던 위치로 고정해 보여줄 수 있게 됐습니다.
마이크로태스크 큐 vs 매크로태스크 큐 어떻게 다른건데?
예시 코드를 보면 실행 순서를 더 잘 알 수 있습니다.
console.log('1'); // 동기 코드
Promise.resolve().then(() => {
console.log('2'); // Micro Task Queue
});
setTimeout(() => {
console.log('3'); // Macro Task Queue
}, 0);
console.log('4'); // 동기 코드
// 출력 순서: 1 -> 4 -> 2 -> 3
이벤트 루프 짚고가기
이벤트 루프(Event Loop) 는 자바스크립트 엔진이 콜 스택(Call Stack)과 태스크 큐(Task Queue)를 활용해서 비동기 작업을 처리하는 동작 방식 입니다. 이벤트 루프는 특정한 메모리 공간(콜 스택, (매크로)태스크 큐, 마이크로태스크 큐 등)을 활용하여 자바스크립트 코드가 실행되는 순서를 관리 합니다.
즉, 이벤트 루프는 "콜 스택이 비었는지" 계속 확인하며, 비어 있으면 큐에 있는 작업을 실행합니다. 위의 예시코드를 설명해보면 아래와 같습니다.
console.log("1")
콜 스택에서 실행 → 출력:1
Promise
콜 스택에 등록 후 제거 → 마이크로태스크 큐로 이동setTimeout
콜 스택에 등록 후 제거 → 매크로태스크 큐로 이동console.log("4")
콜 스택에서 실행 → 출력:4
- 마이크로태스크 큐에 있는
console.log("2")
실행 → 출력:2
매크로태스크보다 마이크로태스크가 먼저 실행! - 매크로태스트 큐에 있는
console.log("3")
실행 → **출력: `3
마이크로 태스크 큐와 매크로 태스크 큐가 처리되는 순서가 다른데, 정리하면
콜 스택(Call Stack)에 남은 동기 코드가 있으면 순서대로 실행
2️⃣ 콜 스택이 비면, 마이크로태스크 큐(Microtask Queue)에 있는 모든 작업을 실행
3️⃣ 마이크로태스크가 모두 실행된 후, 매크로태스크 큐(Macro Task Queue)에서 하나를 실행
4️⃣ 다시 2️⃣~3️⃣ 과정을 반복
따라서 아래와 같은 경우 출력 순서는 1 -> 5 -> 3 -> 4 -> 2 가 됩니다.
console.log("1"); // 동기 코드 실행
setTimeout(() => {
console.log("2 (매크로태스크)");
}, 0);
Promise.resolve().then(() => {
console.log("3 (마이크로태스크 1)");
});
Promise.resolve().then(() => {
console.log("4 (마이크로태스크 2)");
});
console.log("5"); // 동기 코드 실행
아래와 같은 경우는 출력 순서 : 1 -> 5 -> 3 -> 4 -> 2 -> 3-1 이 됩니다.
console.log("1"); // 동기 코드 실행
setTimeout(() => {
console.log("2 (매크로태스크)");
}, 0);
Promise.resolve().then(() => {
console.log("3 (마이크로태스크 1)");
setTimeout(() => {
console.log("3-1 (매크로태스크 2)");
}, 0);
});
Promise.resolve().then(() => {
console.log("4 (마이크로태스크 3)");
});
console.log("5"); // 동기 코드 실행
결론
문제 해결을 하다 궁금증이 생겨
- React 상태 업데이트와 렌더링 사이클
- 브라우저 렌더링 사이클
- 자바스크립트 동기/비동기 실행
- 매크로태스크 큐 vs 마이크로 태스크 큐
- 이벤트 루프
까지 의식의 흐름대로 이어져 왔는데,
결론은 무한스크롤 구현시 기존의 스크롤 위치를 유지하기 위한 과정에서
- 비동기적으로 처리되는 React 상태 업데이트와 브라우저 렌더링 사이클을 이해했어야 했고,
- DOM 업데이트가 모두 완료되기까지를 기다려서 스크롤 위치를 이동시킬 수 있도록 실행 순서를 보장하기 위한 자바스크립트 동작방식을 이해했어야 했다.
는 배움을 기록해봤습니다.