리액트의 렌더링 프로세스
렌더링이 일어나는 경우는 아래의 경우로 제한 됩니다.
- 사용자가 애플리케이션에 처음 진입하였을 경우 최초 렌더링이 발생한다.
- 리렌더링 - setState가 실행되어서 state 값의 변경이 일어났을 경우 이 경우는 클래스 컴포넌트와 함수 컴포넌트와 상관 없이 모두 state setter 로 인해서 state가 변경 되었을 경우 리렌더링이 발생하게 된다. - useReducer 함수를 이용하여 dispatch 함수를 실행 시켰을 경우 이 메서드는 useState와 마찬가지로 state를 변경하는 메서드이기 때문에 렌더링이 일어나게 된다. - 컴포넌트의 key props 가 변경되는 경우 - 부모 컴포넌트가 리렌더링이 되었을 경우 부모 컴포넌트의 모든 하위 컴포넌트에서 리렌더링이 일어난다. - 부모 컴포넌트가 하위 컴포넌트의 props 를 변경 시켰을 경우 해당 하위 컴포넌트는 리렌더링이 일어난다.
react의 렌더링 프로세스는 크게 렌더링 단계와 커밋으로 구분이 됩니다. 이 두 가지 방법은 리엑트만이 아니라 다른 부분에서도 많이 이용되는 단계이다.
예를 들어 Git의 staging-commit 모델과 유사합니다. git add를 실행 할 경우 변경 사항을 바로 적용시키지 않고 staged area에 등록하여 변경사항을 준비 하고 검토하는 과정을 거친 후 commit을 실행할 경우 staged area에 있는 변경사항들을 최종 적용하는 과정을 거쳐서 업데이트를 수행합니다.
렌더 단계 (Render Phase)
-> Virtual DOM에서 컴포넌트의 변경사항을 준비
-> Reconciliation(재조정) 과정을 통해 다음 요소들을 비교
- type (예: div, span)
- _ props (컴포넌트에 전달된 속성들)
- key (리스트 렌더링시 사용되는 고유값)
->위의 요소들 중 변경이 필요한 부분을 표시(mark)
이 과정은 비동기적으로 진행될 수 있음
컴포넌트의 변경 사항을 바로 업데이트 하지 않고 렌더 단계에서는 Virtual DOM에서 재조정(Reconciliation) 과정에서 컴포넌트의 type, props, key 의 요소들을 비교하고 변경이 필요한 부분들을 표기하고 해당 부분들 commit 단계에서 실제 DOM에 업데이트하여 컴포넌트를 업데이트 하는 과정을 갖게 됩니다.
여기서 중요한 사항은 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니라는 것입니다. 위의 설명에서 알 수 있듯이 렌더링과 DOM의 업데이트는 동시에 일어나는 작업이 분리가 되어 있기 때문에 렌더링이 일어났더라도 DOM 이 업데이트 되지 않을 수 있다는 사실을 잊지 않아야 합니다!
메모이제이션
메모이제이션의 사용 용도에 대해서 생각을 해볼 필요가 있습니다. useMemo는 언제 사용해야 할까요? 대표적으로는 아래와 같은 경우로 제한을 할 수 있을 것입니다.
- 렌더링이 자주 일어나는 컴포넌트
- 컴포넌트가 렌더링 되는 과정에서 무거운 연산이 포함되어 있는 경우
위의 두 가지 경우에 대한 설명은 참 모호합니다. 왜 모호할까요? 렌더링이 자주 일어나는 기준은 무엇일까요? 모든 경우에 대해서 실제 렌더가 일어나는 횟수를 비교하여 현재 페이지 내의 컴포넌트들 중 렌더가 많이 일어나는 경우 일까요? 무거운 연산은 렌더링 속도들을 매번 계산해서 확인해서 속도가 느린 경우일까요? 계산을 할 경우 이 성능에 대한 계산은 어떤 것을 기준으로 계산을 해야 하는 것일까요?
이러한 기준을 정하는 것은 결코 쉬운일이 아닙니다. 그렇기 때문에 메모이제이션을 사용하는 것에 대해서는 두 가지 주장이 아직까지 합의를 이루지 못하고 갑론을박이 이어지고 있습니다.
비용이 발생하기 때문에 메모이제이션을 자제해야 한다.
이 주장이 의미하는 것은 무엇일까요? 메모이제이션 자체도 비용이 발생하기 때문에 메모이제이션을 남용할 경우 오히려 성능의 개선보다는 느려지는 경험을 하게 될 수도 있다는 주장이 있습니다.
이러한 주장은 일리가 있습니다. 과연 메모이제이션을 통해서 저장한 값을 비교하고 렌도링 또는 재계산이 필요한지 확인하는 과정이 리렌더링을 하는 비용보다 더 저렴한 비용이 발생한다고 단언할 수 있을까요?
그렇지 않습니다. 메모이제이션은 항상 어느 정도의 트레이드 오프가 있는 기법입니다. 이는 당연한 이치입니다. 메모이제이션을 하기 위해 저장해놓은 값들은 메모리에 차례대로 저장해놓게 됩니다. 그렇기 때문에 메모이제이션을 너무 남용할 경우 메모리에 저장 공간을 차지하고 있기 때문에 비용이 발생하고 또한 메모리에 저장되어 있는 값을 탐색하여 찾는 과정에서도 비용이 발생하게 됩니다.
위에서 설명하였듯이 메모이제이션이 무조건 리렌더링보다 확신할 수 없고 리렌더링이 많이 일어나거나 많은 계산이 일어난다는 것은 예측하여 사용하는 것이 아니라 실제로 컴포넌트가 렌더가 되고 속도가 어느 정도의 속도인지에 대한 계산한 결과를 기준으로 하는 것이 바람직하기 때문에 모든 곳에 메모이제이션을 적용시키지 않고 실제로 개발자 도구를 통해서 확인한 후 필요에 의해서 사용하는 것이 적합하다는 것입니다.
렌더링 과정은 비싸고 모조리 메모이제이션을 수행하라
이 주장은 실제로 메모이제이션이 일어나는 것은 렌더링 과정을 기준으로 하기 때문에 메모이제이션은 props에 대한 얕은 비교만을 수행하기 때문에 리렌더링을 하는 비용보다는 저렴할 것이라는 것입니다.
이 주장 또한 일리가 있는 주장입니다. 리렌더링이 일어나는 경우 해당 컴포넌트에 많은 하위 컴포넌트가 있을 경우 해당 비용은 불필요한 메모이제이션을 통해서 재조정(Reconciliation)을 하는 비용 보다 비쌀 수 밖에 없다는 것입니다.
재조정(Reconciliation)은 앞서 설명했던 실제 react 에서 렌더링 단계에서 일어나는 작업입니다. 이 작업은 기존의 컴포넌트에 대한 렌더 결과물을 저장해놓고 있습니다. 그렇기 때문에 기본적인 리액트 알고리즘 단계에서 이미 결과물에 대한 props에 결과를 저장하고 있고 메모이제이션을 수행할 경우 이미 저장되어 있는 결과물에 대한 얕은 비교를 수행하는 것은 리렌더링 보다는 비용이 저렴할 것입니다.
하지만 이 경우에 해당이 안되는 경우도 물론 존재합니다. props가 크로 복잡해진다면 비용이 커질 수 있습니다. 하지만 memo를 사용하지 않았을 경우의 문제를 살펴보겠습니다.
- 렌더링을 함으로써 발생하는 비용
- 컴포넌트 내부의 복잡한 로직의 재실행
- 그리고 위 두 가지 모두가 자식 컴포넌트에서 반복해서 일어난다.
- 구 트리와 신규 트리를 비교하는 과정을 갖는다. 트리의 깊이가 깊을 수록 문제가 생길 수 있다.
이러한 문제점을 살펴보았을 경우 메모이제이션을 하지 않았을 경우의 문제보다 했을 경우에 발생할 수 있는 문제가 더 적기 때문에 메모이제이션을 하지 않는 것보다 하는 것이 더 유리할 것입니다. 이러한 의견을 뒷받침하는 또 다른 의견이 있습니다.
참조의 투명성을
useEffect
클린업 함수
클린업 함수에 대해서 이해하기 위해서는 useEffect 가 실행되는 실행 순서에 대해서 알고 있을 필요가 있습니다.
React.useEffect(() => {
function addMouseEvent() {
console.log(count);
}
window.addEventListener("click", addMouseEvent);
return () => {
console.log("클린업 함수 실행!", count);
window.removeEventListener("click", addMouseEvent);
};
}, [count]);
위의 함수가 실행되는 순서는 어떻게 될까요?
클린업 함수 실행! 0
1
클린업 함수 실행! 1
2
클린업 함수 실행! 2
3
위와 같은 순서로 실행이 됩니다. 위의 결과가 약간 이상해보이지 않나요? 클린업 함수가 먼저 실행이 되고 이 후에 업데이트 된 상태 값이 출력되고 있다는 사실을 알 수 있습니다. 왜 이런 현상이 나타나는 걸까요?
클린업 함수는 렌더링 과정에서 일어나기 때문입니다. 이러한 이유는 React가 안정성을 위해서 렌더링 과정에서 이전의 Effect 를 정리하는 과정을 갖기 때문입니다. 이러한 과정을 갖는 것은 새로운 Effect 실행되면서 생기는 sideEffect 를 막기 위해서 입니다.
렌더링 과정에서 이전의 Effect 를 정리하고 새로운 Effect 통해서 state가 변경 되어서 리렌더링을 할 경우 commit 단계에서 DOM을 업데이트 하기 때문에 위와 같은 실행 순서가 나올 수 밖에 없는 것입니다.
즉, 렌더링을 통해서 DOM을 업데이트 하기 이전에 상태에 대한 것들을 청소하는 것이라고 이해해야 합니다. 이는 언마운트와는 다른 개념입니다. 언마운트는 특정 컴포넌트가 DOM에서 사라진다는 것을 의미하지만 클린업 함수는 언마운트라기보다는 함수 컴포넌트가 리렌더링 되었을 경우 state나 어떤 참조 값에 대한 의존성 변화가 있었을 당시의 이전의 값을 청소하는 개념이라고 생각해야 합니다.
의존성 배열
React.useEffect(() => {
console.log(number);
}, [number]);
React 를 사용하시고 useEffect 를 사용하셨다면 의존성 배열
useEffect(() => ,[])
에 대해서 다양한 경우를 경험해보셨을 것입니다. 다양한 경우 중 빈 배열, 즉 의존성이 없을 경우에 대한 것을 주의해야할 필요가 있습니다.function Component() {
console.log("렌더링");
}
function Component() {
React.useEffect(() => {
console.log("렌더링 됨");
}, []);
}
이러한 이유는 무엇일까요? 사실 의존성이 없는 빈 배열의 경우 위의 useEffect 를 사용하지 않은 경우와 큰 차이가 없다고 생각할 수 있습니다. 하지만 그렇지 않습니다. 두 코드는 명확한 차이가 존재합니다.
- 서버사이드 렌더링의 관점에서는 useEffect 내부에서 사용되는 로직은 클라이언트 사이드에서 실행되는 것을 보장해줄 수 있다. window나 document 기능을 서버사이드 렌더링에서 사용하기 위해서 위와 같이 해보신 적이 있을 것입니다.
- useEffect 는 컴포넌트 렌더링의 side effect(부수 효과) 가 생긴다는 것이 큰 차이점이다. useEffect를 사용하지 않고 직접 실행하는 방법은 렌더링 도중에 실행이 되지만 useEffect를 사용하는 방법은 렌더링이 완료가 된 이 후에 실행이 됨으로 서버 사이드 렌더링의 경우 서버에서도 실행이 될 수 있다.
위와 같이 명백한 차이가 존재하긴 하지만 그래도 우리는 빈 의존성 배열은 기피해야 할 사항입니다. 이는 예상치 못한 버그를 발생시킬 가능성이 존재하기 때문입니다.
대부분 useEffect 를 사용하는 경우는 컴포넌트가 마운트 된 시점에서 특정 함수를 한 번 실행 시키는 용도로 많이 사용하고는 합니다.
function WindowSizeTracker({ onSizeChange }) {
// 🚨 잘못된 사용
useEffect(() => {
const handleResize = () => {
// prop으로 전달된 onSizeChange가 변경되어도
// 이전 함수를 계속 사용
onSizeChange(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // onSizeChange prop이 변경되어도 이전 함수 참조
return null;
}
위의 경우를 살펴볼 때 onSizeChange 가 아무리 변하도로 어떠한 부수 효과도 실행되지 않고 이러한 사용법으로 인해서 기존의 흐름과 위의 방법을 사용하는 컴포넌트의 흐름이 맞지 않아서 생기는 버그가 생길 가능성이 존재합니다.
그렇기 때문에 위와 같이 빈 의존성 배열을 사용해야 겠다고 판단한 경우 부모 컴포넌트에서 실행되는 것이 옳을 수 있습니다. 이렇게 기존의 의존성을 상위 컴포넌트에서 작동시킬 수 있을 지 고민하고 변경하는 것이 버그 가능성을 줄일 수 있기 때문에 주의하시는 것이 좋습니다.