기존 DOM (문서 객체 모델 Document Object Model) 에 대해 살펴보자면,
브라우저에서 다루는 HTML 문서를 파싱하여 문서의 구성요소 element들을 객체로 구조화하여 트리구조로 나타낸 것을 말한다. DOM은 HTML 과 JasvaScript 를 서로 이어주는 역할을 한다.
우리는 documnet
라는 전역 객체를 통해 접근하여 HTML 문서를 직접 조작할 수 있다.
브라우저가 렌더링하는 순서는 다음과 같다.
1. 불러오기
로더(Loader)가 네트워크 서버로부터 전달받은 리소스 스트림을 읽는 과정
2. DOM, CSSOM 생성
웹 엔진의 HTML 파서가 문서를 파싱하여 DOM 트리를, CSS 파서가 CSSOM 트리를 생성한다.
3. 생성된 DOM과 CSSOM으로 Render 트리 생성
DOM 트리와 CSSOM 트리를 토대로 Reder 트리를 생성한다.
렌더링에 필요한 노드만 선택해서 페이지를 렌더링하는데 사용한다.
4. Layout
Render 트리를 토대로 각 요소가 그려질 노드와 배치될 공간을 계산한다.
5. Paint
실제로 그리는 작업을 실행
다시 DOM과 CSSOM이 변경되어 업데이트되는 각 요소들과 자식들이 렌더링될 때,
레이아웃을 재연산 수행하는 Reflow 와 이를 토대로 화면에 그리는 작업을하는 Repaint 과정을 거친다.
재렌더링하는 과정에서 변화가 필요없는 부분도 업데이트되는 잦은 리플로우가 발생되어 속도도 저하되고 DOM 업데이트 비용이 커서 메모리 누수가 발생하는 문제가 생긴다.
정리하자면
DOM 조작에 의한 렌더링이 비효율적인 문제를 일으키고,
SPA(Single Page Application)특징으로 DOM 복잡도 증가에 따른 최적화 및 유지 보수가 어려워져 Virtual DOM 이 등장하게 되었다.
실제 DOM 과 같은 내용을 담고 있는 복사본으로 자바스크립트 객체 형태로 메모리상에 저장되어있다. 다만 실제 DOM 을 직접 조작하는 방식이 아니라 원래 DOM과 비교해서 달라진 부분을 리렌더링 시켜주는 방식으로 작동한다.
리액트는 두 개의 가상돔을 가지고 있는데,
첫번째는 변경 이전의 내용을 담고있고, 두번째는 변경 이후에 보여진 내용을 담고있다.
가상돔의 동작 과정
1. 상태 변화가 감지되면, 변경 이전의 가상돔과 변경된 새로운 가상돔을 비교한다.
2. 비교 과정에서 Diffing 알고리즘을 통해 변경된 부분을 감지한다.
3. 바뀐 부분만 실제 DOM에 반영하여 업데이트한다.
작은 규모의 리플로우가 여러번 발생하는 것보다 큰 규모의 리플로우가 한 번 발생하므로써 가상돔을 사용하여 실제돔보다 성능을 최적화하고 불필요한 리렌더링을 최소화할 수 있다.
함수형 컴포넌트는 함수로써 컴포넌트가 호출될 때마다 실행되고, 그 함수의 내부에서 선언되는 표현식 또한 매번 다시 선언되어 사용된다. 또한 컴포넌트는 자신의 state 나 부모 컴포넌트로부터 전달받는 props가 변경될 때마다 리렌더링되는 것이 특징이다.
너무 자주 리렌더링 발생 시 좋지 않은 성능을 끼치기 때문에 함수 컴포넌트가 상태를 조작하고 최적화 기능을 사용하게 하는 메서드인 React Hook 중 useMemo 와 useCallback이 그 역할을 한다.
useMemo
에서 memo는 memoization을 뜻하며, 컴퓨터 프로그램이 동일한 계산을 반복할 때 변수나 값을 반복적으로 호출하여 그 값이 필요할 때 다시 계산하는 것이 아니라, 이전에 계산한 값을 메모리에 저장하여 그 값을 꺼내어 재사용하여 반복 수행을 제거하여 실행 속도를 높여 컴포넌트 성능을 최적화시킨다. 쉽게 말하자면 메모이제이션된 값을 반환하는 리액트 훅이다.
const memoizedValue = useMemo(() => computeExpensiveValue(a,b), [a,b]);
첫번째 인자로 콜백함수, 두번째 인자로 의존성 배열을 받는다.
의존성 배열 안에 있는 값이 변경될 때에만 콜백함수를 재호출하여 메모리에 저장된 값을 업데이트해준다.
다음은 브라우저에는 이름값을 입력받는 첫번째 칸과 숫자를 입력받을 수 있는 두,세번째 칸이 존재하고 입력받은 두 숫자를 덧셈 연산을 통해 마지막줄에 결과값을 반환한다.
각가의 세가지 상태는 name
, val1
, val2
로 덧셈연산 값 answer
은 add
함수를 통해 진행되는데, name
이 입력되고 변경될 때마다 add
함수가 호출된다. 이는 굳이 필요없는 함수를 리렌더링 시킴으로써 성능을 악화시키는데 이때 쓰일 수 있는 것이 바로 useMemo
이다.
//add.js
export const add = (val1, val2) => {
return Number(val1) + Number(val2);
}
//App.js
const answer = useMemo(()=>{
return add(val1, val2);
}, [val1, val2]);
val1
이나 val2
상태값이 변경될 때만 add
함수가 호출되도록 한다. 따라서 name
상태값이 변경될 때는 add
함수가 호출되지 않는다.
값이 아닌 메모이제이션된 콜백 함수 자체를 반환하는 리액트 훅이다.
const memoizedCallback = useCallback(() => {
doSomething(a,b);
}, [a,b]);
다음 브라우저는 입력창에 숫자값을 입력하면 그 상태값 input
에 각각 10, 100을 더한 값이 반환된다. 그리고 오른쪽 dark mode 버튼을 누르면 바탕이 어두워지고 다시 아이템을 반환하는 콜백이 실행이 된다. 이를 막기 위해 useCallback이 실행된다.
//List.js
const [items, setItems] = useState([]);
useEffect(() => {
console.log("아이템을 가져옵니다.");
setItems(getItems());
}, [getItems]);
//App.js
const [input, setInput] = useState(1);
const [light, setLight] = useState(true);
const getItems = useCallback(() => {
return [input + 10, input + 100];
}, [input]);
App 컴포넌트에서 List컴포넌트로 getItems 함수가 전달되는데,
이는 input
상태값이 변경될 때마 콜백함수가 실행되고 전달되도록 한다. 버튼을 눌러도 즉,light
상태값이 변경되어도 getItems 는 실행되지 않는다.
사실 공부를 하면서 useMemo 와 useCallback 의 차이는 메모이제이션된 값 혹은 함수를 불러오는 것이라고 생각했고 별 차이가 없다고 생각했다. 하지만, 값이냐 함수이냐 차이에서 크게 작용할 수 있다.
useMemo(() => fn, deps)
deps 가 변한다면, ()=>fn 이라는 함수를 실행 하고 그 함수는 반환값을 반환한다.
useCallback(fn , deps)
deps 가 변한다면, fn 이라는 새로운 함수를 반환한다.
useMemo((...)=>fn, deps) === useCallback(fn,deps)
react 공식 문서에 따르면 위 식은 동일하다.
함수는 객체로 분류가 되기 때문에 값이 아닌 주소가 불러오게 된다. 함수의 호출을 막지 못한다. 즉, 참조 동등성에 의존하다는 의미이다. 두 개의 함수는 동일한 코드를 공유하더라도 메모리 주소가 다르기 때문에, 메모리 주소에 의한 참조 비교 시 다른 함수로 보아서 다른 함수로 보게 된다.
따라서 useCallback을 이용해 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용한다는 것과 같다고 볼 수 있다. 따라서 React 컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제를 막을 수 있다.