왓풀(whatpull)
React Rendering 본문
렌더링이란 무엇인가?
리액트에서 렌더링이란, 컴포넌트가 현재 props와 state의 상태에 기초하여 UI를 어떻게 구성할지 컴포넌트에게 요청하는 작업을 의미한다.
렌더링 프로세스 살펴보기
렌더링이 일어나는 동안, 리액트는 컴포넌트의 루트에서 시작하여 아래쪽으로 쭉 훑어 보면서, 업데이트가 필요하다고 플래그가 지정되어 있는 모든 컴포넌트를 찾는다. 만약 플래그가 지정되어 있는 컴포넌트를 만난다면, 클래스 컴포넌트의 경우 classComponentInstance.render()를, 함수형 컴포넌트의 경우 FunctionComponent()를 호출하고, 렌더링된 결과를 저장한다.
컴포넌트의 렌더링 결과물은 일반적으로 JSX 문법으로 구성되어 있으며, 이는 js가 컴파일되고 배포 준비가 되는순간에 React.createElement()를 호출하여 변환된다. createElement는 UI 구조를 설명하는 일반적인 JS 객체인 React Element를 리턴한다. 아래 예제를 살펴보자.
// 일반적인 jsx문법
return <SomeComponent a={42} b="testing">Text here</SomeComponent>
// 이것을 호출해서 변환된다.
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// 호출결과 element를 나타내는 객체로 변환된다.
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
전체 컴포넌트에서 이러한 렌더링 결과물을 수집하고, 리액트는 새로운 오브젝트 트리 (가상돔이라고 알려져있는)와 비교하며, 실제 DOM을 의도한 출력처럼 보이게 적용해야 하는 모든 변경 사항을 수집한다. 이렇게 비교하고 계산하는 과정을 리액트에서는 reconciliation이라고 한다.
그런 다음, 리액트는 계산된 모든 변경사항을 하나의 동기 시퀀스로 DOM에 적용한다.
렌더와 커밋 단계
리액트는 이 단계를 의도적으로 두개로 분류하였다.
- Render phase:컴포넌트를 렌더링하고 변경사항을 계산하는 모든 작업
- Commit phase: 돔에 변경사항을 적용하는 과정
리액트가 DOM을 커밋페이즈에서 업데이트 한 이후에, 요청된 DOM 노드 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 업데이트 한다. 그런 다음 클래스 라이프 사이클에 있는 componentDidMount componentDidUpdate 메소드를 호출하고, 리액트 함수형 컴포넌트에서는 useLayoutEffect훅을 호출 한다.
리액트는 짧은 timeout을 세팅한 이후에, 이것이 만료되면 useEffect를 호출한다. 이러한 단계는 Passive Effects 단계라고도 알려져 있다.
이러한 클래스 라이브 사이클 메소드 다이어그램은 여기에서 확인해 볼 수 있다.
이번에 리액트 18에서 나온 Concurrent Mode의 경우, 브라우저가 이벤트를 처리할 수 있도록 렌더링 단계에서 작업을 일시 중지 할 수 있다. 리액트는 해당 작업을 나중에 다시시작하거나, 버리거나, 다시 계산할 수 있다. 렌더링이 패스가 된 이후에도, 리액트는 커밋단계를 한단계 동기적으로 실행한다.
여기서 중요한 사실은, 렌더링은 DOM을 업데이트 하는 것과 같은것이 아니고, 컴포넌트는 어떠한 가시적인 변경이 없이도 컴포넌트가 렌더링 될 수 있다는 것 이다.리액트가 컴포넌트를 렌더링하는 경우
- 컴포넌트는 이전과 같은 렌더링 결과물을 리턴해서, 아무런 변화가 일어나지 않을 수 있다.
- Concurrent Mode에서는, 리액트는 컴포넌트를 렌더링 하는 작업을 여러번 할 수 있지만, 다른 업데이트로 인해 현재 작업이 무효화 되면 매번 렌더링 결과물을 버린다.
리액트 렌더링 규칙
리액트 렌더링의 중요한 규칙 중 하나는 렌더링은 '순수' 해야하고 '부수작용' 이 없어야 한다는 것 이다. 근데 이는 매우 복잡하고 어려운데, 왜냐하면 대다수의 부수 작용이 왜 이러났는지 뚜렷하지 못하고, 어떤 것도 망가 뜨리지 않기 때문이다. 예를 들어, 엄밀히 말하면 console.log()도 부수작업을 야기하지만, 그 어떤 것도 망가 뜨리지 않는다. prop 가 변경되는 것은 명백한 부수효과 이며, 이는 무언가를 망가 뜨릴 수 있다. 렌더링 중간에 ajax 호출 또한 부수효과를 일으키고, 이는 요청의 종류에 따라서 명백하게 앱에 예기치 못한 결과를 야기할 수 있다.
Rules of React라는 글이 있다. 이 글에서는, 렌더링을 표함한 다양한 리액트의 라이프 사이클 메소드의 동작과, 어떠한 동작이 '순수' 한지, 혹은 안전한지를 나타내고 있다. 요약하자면
렌더링 로직이 할 수 없는 것
- 존재하는 변수나 객체를 변경해서는 안된다.
- Math.random() Date.now()와 같은 랜덤 값을 생성할 수 없다.
- 네트워크 요청을 할 수 없다.
- state를 업데이트
렌더링 로직은
- 렌더링 도중에 새롭게 만들어진 객체를 변경
- 에러 던지기
- 아직 만들어지지 않은 데이터를 lazy 초기화 하는일 (캐시 같은)
등이 가능하다.
컴포넌트 메타데이터와 파이버
리액트는 애플리케이션에 존재하는 모든 현재 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 가지고 있다. 이 데이터 구조의 핵심적인 부분은, 다음과 같은 메타데이터 필드를 포함하고 있는 Fiber라고 불리는 객체다.
- 컴포넌트 트리의 특정 시점에서 렌더링 해야하는 컴포넌트 타입의 유형
- 이 컴포넌트와 관련된 prop, state의 상태
- 부모, 형제, 자식 컴포넌트에 대한 포인터
- 리액트가 렌더링 프로세스를 추적하는데 사용되는 기타 메타데이터
리액트 17의 fiber 타입은 여기에서 볼 수 있다.
렌더링 패스 동안, 리액트는 fiber 객체의 트리를 순회하고, 새로운 렌더링 결과를 계산한 결과로 나온 업데이트 된 트리를 생성한다.
fiber 객체는 실제 컴포넌트 prop과 state 값을 저장하고 있다. 컴포넌트에서 prop와 state의 값을 꺼내서 쓴다는 것은, 사실 리액트는 이러한 값을 fiber 객체에 있는 것으로 전달해준다. 사실, 클래스 컴포넌트의 경우, 리액트는 컴포넌트를 렌더링 하기 직전에 componentInstance.props = newProps를 통해서 복사본을 저장해준다. this.props가 존재한다는 것은, 리액트가 내부 데이터 구조의 참조를 복사해 두었다는 뜻이기도 하다. 즉, 컴포넌트라는 것은 리액트 fiber 객체를 보여주는 일종의 외관이라고 볼 수 있다.
비슷하게, 리액트 훅의 작동 또한 해당 컴포넌트의 fiber 객체에 연결된 링크드 리스트 형태로 저장하는 방식으로 동작한다. 리액트가 함수형 컴포넌트를 렌더링하면, fiber에 연결된 후의 링크드 리스트롤 가져오며, 다른 훅을 호출할 때마다 훅에 저장된 적절한 값을 반환한다.
부모 컴포넌트가 렌더링되어 자식 컴포넌트가 주어진다면, 리액트는 fiber 객체를 만들어 이 컴포넌트의 인스턴스를 추적한다. 클래스 컴포넌트의 경우, const instance = new YourComponentType(props) 가 호출되고 새로운 컴포넌트 인스턴스를 fiber 객체에 저장한다. 함수형 컴포넌트의 경우에는, YourComponentType(props)를 호출한다.
컴포넌트 타입과 재조정 (Reconciliation)
재조정 페이지에 언급되어 있는 것 처럼, 리액트는 기존 컴포넌트 트리와 DOM 구조를 가능한 많이 재사용함으로써 리렌더링의 효율성을 추구한다. 동일한 유형의 컴포넌트, 또는 HTML 노드를 트리의 동일한 위치에 렌더링하도록 리액트에 요청하게 되면, 리액트는 해당 컴포넌트 또는 HTML 노드를 만드는 대신에 해당 업데이트만 적용한다. 즉, 리액트에 해당 컴포넌트 타입을 같은 위치에 렌더링 하도록 계속 요청이 있다면, 리액트는 계속 컴포넌트의 인스턴스를 유지한다는 뜻이다. 클래스 컴포넌트의 경우, 실제 컴포넌트의 실제 인스턴스와 동일한 인스턴스를 사용한다. 함수형 컴포넌트는, 클래스와 같은 느낌의 인스턴스는 없지만, <MyFunctionComponent /> 가 보여지고 활성화 상태로 유지되고 있다는 관점에서 인스턴스를 나타내는 것으로 볼수도 있다.
그렇다면, 리액트는 어떻게 결과물이 실제로 변경된 시기와 방법을 알 수 있을까?
리액트 렌더링 로직은 elements를 그들의 type 필드를 기준으로 먼저 비교하는데, 이 때 ===를 사용한다. 만약 지정된 element가 <div>에서 <span>으로, 또는 <ComponentA />에서 <ComponentB />로 변경된 경우, 전체 트리가 변경되었다고 가정하여 비교 프로세스의 속도를 높인다. 결과적으로 리액트는 모든 DOM노드를 포함한 기존 컴포넌트 트리를 삭제하고 새로운 컴포넌트 인스턴스를 처음부터 다시 만든다.
즉, 렌더링 동안에는 절대로 새로운 컴포넌트 타입을 만들어서는 안된다. 새로운 컴포넌트 타입을 만들다면, 이는 참조가 다르고, 이는 리액트가 하위 컴포넌트 트리를 모두 파괴하고 새로운 트리를 만들게 된다.
코드로 설명하자면,
function ParentComponent() {
// 이는 매번 새로운 컴포넌트 참조를 만들게 된다.
function ChildComponent() {}
return <ChildComponent />
}
대신에
// 컴포넌트 타입 참조가 한번 딱 만들어진다.
function ChildComponent() {}
function ParentComponent() {
return <ChildComponent />
}
를 사용하자.
key와 Reconciliation
또한가지, 리액트가 컴포넌트 인스턴스를 식별하는 방법으로 key prop이 있다. key는 실제 컴포넌트로 전달되는 요소는 아니다. 리액트는 이를 활용해 컴포넌트 타입의 특정 인스턴스를 구별하는데 사용할 수 있는 고유한 식별자로 사용한다.
아마도 key를 가장 많이 사용하는 경우는 리스트를 렌더링 할 때 일 것이다. key는 목록의 순서변경, 추가, 삭제와 같은 방식으로 변경될 수 있는 데이터를 렌더링하는 경우에 매우 중요하다. 여기서 중요하다는 것은 고유한 값을 사용해야 한다는 것이다. 고유한 값을 사용할 수 없는 최후의 수단으로, 배열의 인덱스를 사용해야 한다.
왜 중요한지 한번 살펴보자. <TodoListItem /> 컴포넌트 10개를 렌더링하고, 이를 키로 index를 사용하여 0..9를 할당했다. 이제, 6, 7을 지우고, 새롭게 3개를 추가해서 이제 키가 0..10이 되었다. 리액트는 이 때 단순히 하나만 추가하고 마는데, 리액트가 보기엔 10개에서 11개로 늘어난 차이밖에 없기 때문이다. 리액트는 이제 기존에 있던 컴포넌트와 DOM 노드를 재활용할 것이다. 그러나 이 뜻은, <TodoListItem key={6} />가 8로 넘겨받은 props를 사용하여 렌더링 할 것이다. 컴포넌트 인스턴스는 살아있지만, 이전과 다른 데이터 객체를 기반으로 하고 있다. 이는 효과가 있을 수도 있지만, 예기치 못한 문제가 발생할 수 있다. 또한 기존 목록의 아이템이 이전과 다른 데이터를 표시해야 하기 때문에, 리액트는 텍스트와 다른 DOM내용을 변경하기 위해 목록의 아이템중 몇개에 업데이트를 적용해야 한다. 그러나, 목록의 아이템이 사실상 변한 것이 아니므로 업데이트가 필요하지 않는 것으로 간주된다.
대신에 key={todo.id}와 같은 것으로 처리했다면, 리액트는 올바르게 2개의 아이템을 지우고 3개를 추가할 것이다. 이는 두개의 컴포넌트 인스턴스와 DOM노드를 지우고, 새롭게 3개의 컴포넌트 인스턴스, DOM노드를 만드는 것을 의미한다.
key는 리스트에 있는 컴포넌트의 인스턴스를 식별하는데 유용하다. 어떤 리액트 컴포넌트에든 key를 추가하여 식별자를 부여할 수 있고, key를 변경하는 것은 리액트가 오래된 컴포넌트 인스턴스를 없애고, 새로운 DOM을 만든다는 것을 의미한다. 일반적인 유즈케이스는 앞서 언급한 리스트의 경우이다. <Form key={selectedItem.id}>을 렌더링하면 선택한 항목이 변경될 때 리액트가 form을 삭제하고 다시 생성하므로, form의 오래된 상태 문제를 방지할 수도 있다.
불변성과 렌더링
리액트의 상태 업데이트는 항상 불변적으로 수행되어야 한다. 그 이유는 두가지가 있다.
- mutate한 값의 대상과 위치에 따라 컴포넌트가 렌더링 되지 않을 수 있다.
- 데이터가 실제로 업데이트 된 시기와 이유에 대해 혼란을 겪을 수 있다.
몇 가지 구체적인 예제를 살펴보자.
앞서 보았던 것 처럼, React.memo PureComponent shouldComponentUpdate는 얕은 비교를 기반으로 이전과 이후의 prop 값을 비교한다. props.value !== prevProps.newValue로 비교할 것이다.
만약 값의 불변성을 지키지 않았을 경우, someValue는 같은 참조를 가지고 있기 때문에 컴포넌트는 아무것도 변경되지 않았다고 생각할 것이다.
불필요한 리렌더링을 방지하여 성능을 최적화해야 한다는 것을 인지해야 한다. props가 변경되지 않은 경우 렌더링은 불필요하거나 낭비일 뿐이다. mutate 한 값을 사용하면, 컴포넌트가 아무것도 변하지 않았다고 잘못생각할 수 있으며, 개발자는 컴포넌트가 다시 렌더링 되지 않은 이유에 대해서 헷갈릴 수 있다.
또다른 문제는 useState와 useReducer 훅이다. setCounter()나 dispatch()가 호출될 때 마다, 리액트는 리렌더링을 큐에 밀어넣을 것이다. 그러나 리액트는 모든 훅의 상태 업데이트에 새 객체/배열의 참조이거나, 새 원시(문자열, 숫자.. 등)로 전달, 반환해야 한다.
리액트는 렌더링 단계 동안 모든 상태 업데이트를 적용한다. 리액트는 훅에서 상태 업데이트를 적용하려고 하면, 새 값이 동일한 참조인지 확인한다. 리액트는 항상 업데이트 대기열에 있는 컴포넌트 렌더링을 끝낸다. 그러나 이전과 값이 동일한 참조이고, 렌더링을 해야하는 다른 이유가 없다면 (부모 컴포넌트의 리렌더링 등) 리액트는 컴포넌트에 대한 렌더링 결과를 버리고 렌더링 패스를 벗어난다.
const [todos, setTodos] = useState(someTodosArray)
const onClick = () => {
todos[3].completed = true
setTodos(todos)
}
이는 컴포넌트 리렌더링에 실패한다.
기술적으로, 가장 바깥쪽 참조만 반드시 업데이트 해야 한다.
const onClick = () => {
const newTodos = todos.slice()
newTodos[3].completed = true
setTodos(newTodos)
}
이렇게 하면 새로운 바열 객체를 넘겨줄 수 있고, 컴포넌트는 반드시 리렌더링 될 것이다.
한가지 알아둬야 할 것은, 클래스 컴포넌트와 함수형 컴포넌트 사이엔 동작에 뚜렷한 차이가 있다는 것이다. 클래스 컴포넌트의 this.setState()을, 함수형 컴포넌트의 useState useReducer 훅을 사용한단 것이다. this.setState()는 값이 불변이 아니어도 된다. 항상 리렌더링을 한다.
const { todos } = this.state
todos[3].completed = true
this.setState({ todos })
사실 이는 빈객체를 넘겨주는 것과 다를게 없다.
모든 실제 렌더링 동작의 이면에는, 불변하지 않은 값은 리액트의 단방향 데이터 플로우에 혼란을 야기한다. 불변하지 않은 값은 코드로 하여금 다른 값을 보게 하는데, 기대와는 다르게 동작할 가능성이 크다. 이로 인해 특정 상태가 실제로 업데이트 되어야 하는 시기와 이유, 또 변경사항이 어디에서 발생했는지 알기 어려워진다.
다시한번 정리하면, 리액트, 그리고 리액트의 에코시스템에서는 모든 것이 불변한 update로 간주된다. 불변하지 않은 값은 버그를 유발할 수 있다.
리액트 컴포넌트 렌더링 성능 측정하기
React DevTools Profiler를 활용하여 어떤 컴포넌트가 각 커밋 마다 렌더링되는지 살펴보자. 예기치 못하게 리렌더링 되는 컴포넌트를 찾아서 왜 리렌더링 되었는지, 그리고 어떻게 고칠 수 있는지 확인 해보자. (React.memo()로 감싸거나, 부모 컴포넌트가 넘겨주는 props를 메모이즈 하는 등의 방법이 있을 수 있다.)
또한, 리액트는 dev build에서 느리게 실행된다는 점을 기억해야 한다. development 모드에서는 어떤 컴포넌트가 왜 렌더링 되었는지 살펴보고, 컴포넌트가 렌더링되는데 소요되는 시간등을 비교할 수 있다. 그러나 절대 리액트 development 모드로 렌더링 속도를 측정하서는 안된다. 반드시 프로덕션 빌드로 렌더링 속도를 측정해야 한다.
컨텍스트(Context)와 렌더링 동작
리액트의 Context API는 주어진 <MyContext.Provider/> 내에 모든 하위 컴포넌트에서 단일한 사용자 지정 값을 사용하라 수 있도록 하는 메커니즘이다. 이를 사용하면, prop을 번거롭게 넘길 필요 없이 하위 컴포넌트에서 값을 사용할 수 있다.
Context API는 절대 상태관리 도구가 아니다 상황에 맞게 전달되는 값을 직접 관리 해야 한다. 이는 일반적으로 리액트 컴포넌트 state 내부의 값을 유지하고, 해당 데이터를 기반으로 context 값을 만드는 데 사용된다.
Context API 기초
Context provider는 <MyContext.Provider value={42}>와 같은 형태로 value prop을 받는다. 자식 컴포넌트는 컨텍스트 consumer를 렌더링하고 prop을 전달받음으로서 해당 값을 사용할 수 있다.
<MyContext.Consumer>{(value) => <div>{value}</div>}</MyContext.Consumer>
useContext()를 사용하면 다음과 같이 쓸 수 있다.
const value = useContext(MyContext)
Context 값 업데이트
리액트는 감싸져 있는 컴포넌트가 provider를 렌더링 할 때, 컨텍스트 provider에 새로운 값이 지정되어 있는지 확인한다. 만약 해당 값이 새로운 참조인 경우, 리액트는 값이 변경되었으며 해당 컨텍스트를 사용하는 컴포넌트를 업데이트 해야 한다는 사실을 알게 된다.
이제 컨텍스트 provider에 새로운 값을 전달하면 다음과 같이 업데이트가 진행된다.
function GrandchildComponent() {
const value = useContext(MyContext)
return <div>{value.a}</div>
}
function ChildComponent() {
return <GrandchildComponent />
}
function ParentComponent() {
const [a, setA] = useState(0)
const [b, setB] = useState('text')
const contextValue = { a, b }
return (
<MyContext.Provider value={contextValue}>
<ChildComponent />
</MyContext.Provider>
)
}
위 예제에서, ParentComponent가 렌더링 될 때 마다 리액트는 해당 값을 MyContext.Provider에 기록하고, 아래로 루프를 돌면서 MyContext를 사용하는 컴포넌트를 찾는다. Context Provider에 새로운 값이 있다면, 해당 컨텍스트를 사용하는 모든 중첩 컴포넌트가 강제로 리렌더링 된다.
리액트 관점에서 각 Context Provider는 단일 값만 가진다. 객체, 배열, 원시 값이든 상관 없이 하나의 컨텍스트 값일 뿐이다. 현재로서는 해당 컨텍스트를 사용하는 모든 컴포넌트는 새 값의 일부만 변경되었다 하더라도, 새 컨텍스트 값으로 인한 업데이트를 건너 뛸 수 없다.
state 업데이트, 컨텍스트, 그리고 리렌더링
앞서 이야기 했던 내용을 종합해보자.
- setState()를 호출하면 컴포넌트 렌더링을 큐에 집어넣는다.
- 리액트는 재귀적으로 하위 컴포넌트를 렌더링한다.
- Context provider는 컴포넌트에 의해 렌더링해야할 값을 받는다.
- 위에서 언급했던 값은 보통 부모 컴포넌트의 state에 기반한다.
이 말인 즉슨, 기본적으로 Context Provider를 구성하는 상위 컴포넌트에 대한 state 업데이트는 모든 하위 항목이 해당 Context 값을 읽는지 여부에 상관없이 다시 렌더링 되도록 한다.
위 예제에서 살펴본다면, Parent/Child/Grandchild의 경우, GrandchildComponent는 컨텍스트가 업데이트 되어서가 아니라 ChildComponent가 리렌더링되는 것 만으로도 리렌더링 될 수 있다는 것이다. 위 예제에서는, 불필요한 리렌더링을 최적화하려는 것이 없으므로, 리액트는 ParentComponent가 렌더링 할 때마다 ChildComponent GrandchildComponent를 렌더링 한다. 부모가 새 컨텍스트 값을 넣는 경우, GrandchildComponent는 그 값을 사용하기 때문에 리렌더링 된다. 그러나 이는 어차피 상위 컴포넌트가 리렌더링되기 때문에 발생할 일이었을 뿐이다.
Context 업데이트와 렌더링 최적화
위 예시를 최적화 해보는 동시에, GreatGrandChildComponent를 하나 더 만들어서 살펴보자.
function GreatGrandchildComponent() {
return <div>Hi</div>
}
function GrandchildComponent() {
const value = useContext(MyContext)
return (
<div>
{value.a}
<GreatGrandchildComponent />
</div>
)
}
function ChildComponent() {
return <GrandchildComponent />
}
const MemoizedChildComponent = React.memo(ChildComponent)
function ParentComponent() {
const [a, setA] = useState(0)
const [b, setB] = useState('text')
const contextValue = { a, b }
return (
<MyContext.Provider value={contextValue}>
<MemoizedChildComponent />
</MyContext.Provider>
)
}
여기에서 이제 setA(100)를 호출하면 다음과 같은 일들이 일어난다.
- ParentComponent가 렌더링됨
- 새로운 contextvalue가 세팅
- 리액트는 MyContext.Provider에 새로운 값이 들어왔음을 감지하고, MyContext을 사용하는 컴포넌트에 업데이트가 필요하다고 표시
- MemoizedChildComponent를 렌더링하려고 한다. 그리고 이는 memo로 메모이즈 되어 있고, props가 전혀 넘어가지 않으므로 변경이 일어나지 않은 것으로 간주된다. 따라서 ChildComponent의 렌더링을 스킵한다.
- 하지만 MyContext.Provider는 업데이트 되었으므로, 이 아래에는 아마 업데이트가 되어야할 컴포넌트가 있을 수도 있다.
- 리액트는 자식 컴포넌트를 순회하다가 GrandchildComponent를 만난다. 해당 컴포넌트는 컨텍스트를 사용하므로, 새로운 값으로 렌더링 되어야 하므로 새로운 context 값으로 렌더링 한다.
- GrandchildComponent가 렌더링 되었으므로, 하위 컴포넌트인 GreatGrandchildComponent도 리렌더링 된다.
Context Provider 하위에 있는 컴포넌트는 React.memo가 되어 있어야 한다.
이렇게 최적화한다면, 부모 컴포넌트의 state 업데이트는 더이상 모든 컴포넌트의 리렌더링을 강요하지 않고, 단순히 context를 사용하는 컴포넌트만 리렌더링 하게 된다. 그러나, GrandchildComponent의 경우에는 Context의 값을 사용하였기 때문에 리렌더링 되었고, 그 자식인 GreatGrandchildComponent는 Context를 사용하지 않았다 하더라도 리렌더링 된다.
요약
- 리액트는 기본적으로 재귀적으로 컴포넌트를 렌더링 한다. 그러므로, 부모가 렌더링 되면 자식도 렌더링 된다.
- 렌더링 그 자체로는 문제가 되지 않는다. 렌더링은 리액트가 DOM의 변화가 있는지 확인하기 위한 절차일 뿐이다.
- 그러나 렌더링은 시간이 소요되며, UI 변화가 없는 불필요한 렌더링은 시간을 소비한다.
- 콜백함수와 객체에 새로운 참조로 값을 전달하는 것은 대부분 괜찮다.
- React.memo를 사용하면, props가 변하지 않는다면 렌더링을 막는다.
- 그러나 항상 새로운 참조 값을 props로 React.memo()를 전달하면 렌더링을 스킵할 수 없으므로, 이러한 값들은 적절히 메모이제이션 해야 한다.
- Context를 사용하면 해당 값에 관심이 있는 컴포넌트들이 중첩되어있는 상태에서도 props 없이 엑세스할 수 있게 해준다.
- Context Provider는 값이 변하였는지 확인하기 위해 참조를 비교한다.
- 새로운 Context 값은 중첩된 모든 컨슈머들의 리렌더링을 야기한다.
- 그러나 이러한 Context의 값의 변화가 아닌 일반적인 부모 > 자식 리렌더링 프로세스로 인해 리렌더링 되는 경우가 많다.
- 이를 방지하기 위하여 Context Provider 하위 컴포넌트에 React.memo를 사용하거나 {props.children}을 사용해야 한다.
- 하위 컴포넌트가 Context 값을 사용하고 있다며느 그 하위 컴포넌트 또한 순차적으로 리렌더링 된다.
Context API, 상태관리 언제 써야 할까?
Context API로만 충분한 경우
- 자주 변하지 않는 간단한 값만 전달하는 경우
- 애플리케이션 일부에 일부 state나 함수를 전달하지만, 이 값이 props로 많은 부분 넘기고 싶지 않은 경우
- 추가적인 라이브러리 없이 리액트 기능만으로 구현하고 싶을때
상태관리 솔루션이 필요할때
- 애플리케이션 여러 위치에 많은 양의 애플리케이션의 상태 값이 필요한 경우
- 애플리케이션의 상태가 시간에 따라 자주 업데이트 되는 경우
- 상태 관리 로직이 복잡한 경우
- 애플리케이션이 매우 크고, 많은 사람이 개발하는 경우