1. 글을 쓴 이유
리엑트의 동작 과정은 렌더링과 커밋 과정으로 구분된다.
- 렌더링 | 컴포넌트 함수를 호출하여 리엑트 엘리먼트 구성=> 리엑트 엘리먼트를 모아 가상 돔 트리 구성 =>재조정
- 커밋 | 재조정 함수를 통해 반환된 돔 변경 요구사항을 실제 돔에 반영
여기서 살펴볼 재조정 혹은 비교조정이란, 기존 가상 돔과 "상태값 변경 후 재생성된 신 가상 돔" 간의 변화된 부분을 확인하는 작업이다. (노드 간의 위치는 동일한지 && 내부 값은 동일한지)
일반적으로 돔 트리 비교 알고리즘은 모든 돔 노드를 가상돔 노드와 비교하는 방식으로 O(n^3)의 횟수가 필요하므로 매우 비싼 연산 작업이다. 이를 해결하기 위해 리엑트는 다른 비교 알고리즘을 사용한다. 이 알고리즘을 잘 이해할 수 있다면, 예기치 못한 오류를 방지하고 앱 성능을 최적화할 수 있다. 결론을 미리 보자면
1. 리엑트는 루트 엘리먼트가 다르다면, 그 하위 노드는 서로 간 일치여부와 상관없이 모두 파괴하여 재생성한다. 만약, 두 엘리먼트가 매우 비슷하게 생겼는데, 루트만 다르다면 최대한 같은 타입으로 일치시키는 것이 낫다.
2. props만 다르고 타입은 동일한 컴포넌트를 삼항연산자나 조건문으로 토글할 시, 동일한 컴포넌트로 간주되어 state가 유지되고 컴포넌트 사이클의 마운트와 디스마운트가 작동하지 않는다. 이것이 나쁜 건 아니지만, 원리를 정확히 모르면 예상치 못한 동작이 될 수 있다.
3. 동일한 타입의 컴포넌트를 형제로 렌더링할 시, key props를 붙여주면 이들 사이에 추가나 삭제가 있어도 리엑트가 컴포넌트의 이동을 인식할 수 있다. 이는 돔 수정의 최적화와 state 오류를 방지한다.
2. 비교 알고리즘(diffing algorithm)
리엑트의 비교 알고리즘(이하 디핑으로 중략)은 두 가지 가정에 기반한다.
- 타입이 서로 다른 엘리먼트는 다른 트리를 만들어낼 것이다.
- 즉, 같은 타입을 가진 엘리먼트는 같은 트리를 만들어낼 수도 있다.
- key prop을 이용하면 "변경되지 말아야 할 자식 엘리먼트”를 알 수 있다.
- 이 부분은 뒤에서 명확해진다.
그리고 디핑의 기본 작동방식은 서로 동일한 계층에 있는 루트 엘리먼트를 비교하는 것이다. 여기서 나온 결과에 따라 작업방식이 달라진다. 말하기 앞서, 엘리먼트는 크게 두 가지로 구분된다.
- dom 엘리먼트 | reactDOM의 기본 엘리먼트(input, p, form)
- 컴포넌트 엘리먼트 | 함수형 컴포넌트 혹은 클래스형 컴포넌트의 형식으로 export된 엘리먼트
주의사항
필자가 참조한 리엑트 공식문서 legacy 버전에서는 클래스 컴포넌트를 기준으로 정보를 소개하였으나 이 글은 함수형 컴포넌트를 기준으로 작동하였다. 따라서, 작동과정에서는 useState나 useEffect같은 훅을 기준으로 라이프 사이클을 설명할 예정이다.
루트 엘리먼트는 돔 노드 상에서 가장 꼭대기 엘리먼트이기도 하지만, 여기서는 두 노드를 비교할 때 각 노드의 자식 노드와 대비되는 의미로 사용되기도 한다. 즉, 부모 엘리먼트의 의미다.
1) 다른 타입의 엘리먼트
- 두 가상돔 내 동일한 위치에 있는 두 루트 엘리먼트의 타입이 다른 경우이다.
- 이 때는 두 노드가 완전히 바뀐 것으로 간주하고 이전 돔의 엘리먼트를 파괴한다. 이 때 라이프사이클에서 언마운트시 작동과정이 일어난다.
- useEffect의 클린업 작동
- 루트 엘리먼트와 자식 엘리먼트의 상태값이 소실
- 이 시점은 정확히 말하자면, 클래스 컴포넌트에서 componentWillUnmount에 해당하는데, 이는 언마운트 직전을 의미
- 루트 엘리먼트가 파괴되므로, 자식 노드 역시 파괴된다.
예시
- app 컴포넌트 내 toggle 버튼은 section 엘리먼트와 div 엘리먼트 중 어느쪽이 렌더링될지 결정한다.
- 버튼 클릭 시, 상태값이 변화하고 새로운 가상 돔 트리가 구성된 후 재조정이 실시된다.
- 동일 계층의 루트 엘리먼트인 section 엘리먼트와 div 엘리먼트는 서로 다른 타입이므로, 이전 가상돔의 section 인스턴스는 언마운트되기 시작한다. 이 과정에서 자식 엘리먼트인 Counter 컴포넌트 엘리먼트도 언마운트되며 useEffect의 클린업이 작동, 상태값을 잃어버린다.
- 새 가상돔의 div 인스턴스가 마운트된다. 이 과정에서 자식 엘리먼트인 Counter 컴포넌트 엘리먼트도 마운트된다.
- 디핑에 의해 반환된 변화값은 실제 돔에 반영된다.
2) 같은 타입의 DOM 타입 엘리먼트
- 동일한 타입의 dom 엘리먼트라면, 디핑은 기존의 노드를 유지한다.
- className이나 style 등의 속성 중 변화한 부분만 갱신.
- 자식 노드는 디핑 과정에 의해 재귀적으로 처리된다. 즉, 자식 노드는 다시 이 디핑 알고리즘을 적용하여 처리한다.
예시
- 이전 가상돔에서 div 엘리먼트가 새 가상돔에서는 className과 style이 변경되었다.
- 둘의 타입은 동일하므로 속성만 갱신된다.
- 루트 엘리먼트가 파괴로 교체되지 않고 갱신 되었으므로, 자식은 그대로 존재하며 재귀적으로 처리된다.
3) 같은 타입의 컴포넌트 엘리먼트
- 컴포넌트 간의 타입이 동일하다는 말의 의미는 컴포넌트의 루트 엘리먼트와 내부 값이 동일하다는 의미가 아니라, 컴포넌트 함수 자체가 동일하다는 의미다.
- 예컨대, 컴포넌트 Counter1과 Counter2가 이름을 제외하고 내부의 state 구조나 자식 엘리먼트가 동일하다 가정하더라도, 두 컴포넌트는 다른 타입으로 간주된다.
- 동일 타입의 컴포넌트일 때는 해당 인스턴스는 리렌더링 와중에도 유지되고 갱신만 된다. 즉, 내부함수는 동일하게 호출되지만, 라이프사이클의 mount(useEffect)를 실행시키지 않고 state는 유지되며 props는 업데이트된다.
예시
- app 컴포넌트 내 toggle 버튼은 section 엘리먼트와 div 엘리먼트 중 어느쪽이 렌더링될지 결정한다.
- 버튼 클릭 시, 상태값이 변화하고 새로운 가상 돔 트리가 구성된 후 재조정이 실시된다.
- 동일 계층의 루트 엘리먼트인 Counter1과 Counter1은 서로 동일한 타입이므로 기존 인스턴스(노드)가 유지되며 재호출된다.
- 재 호출 결과, props는 갱신되고 내부 함수는 재실행되지만 state는 유지되며 useEffect는 실행되지 않는다.
- 디핑에 의해 반환된 변화값은 실제 돔에 반영된다.
4) 삼항 연산자의 처리방식에 따른 차이
- 리엑트에서는 컴포넌트를 토글할 때 삼항 연산자를 일반적으로 사용한다.
- ex | {isCloseState? null: <Component/> }
- 그렇다면, 다음 두 표현식은 동일하게 작동할까.
//@ 경우1
{!isOpen1 ? (
<Counter1 text="hello" />
) : (
<Counter1 text="bye"/>
)}
//@ 경우2
{!isOpen1 ? (
<Counter1 text="hello"/>
) : (
null
)}
{isOpen1 ? (
<Counter1 text="bye"/>
) : null}
- isOpen에 따라 동일한 타입의 컴포넌트가 props만 다르게 렌더링되는 것 처럼 보인다.
- 하지만, 후자는 동일한 타입의 컴포넌트로 컴포넌트로 인식되지 않는다.
예시: 후자의 경우
후자의 예시를 이해하려면, null도 일종의 노드라 생각하면 된다. JSX의 빌드 코드를 보면 isOpen이 변경될 때, Counter1이 사라지면 그 자리에는 새로운 Counter1이 아니라 null이 할당된다. 새 Counter1은 그 다음 child로 할당된다. 이처럼 트리 구조가 달라지기 때문에 기존 Counter1와 새 Counter1가 동일한 노드로 인식되지 않는 것이다.
4) 자리가 바뀌며 동일한 타입의 엘리먼트로 인식되지 않는 문제
이처럼 리엑트는 가상돔 간 비교 과정에서 노드의 자리가 바뀌는 걸 자동으로 인식하지 못한다. 이를 잘 보여주는 것이 바로 배열 순회 메서드(map)로 컴포넌트를 렌더링하는 경우다.
예시
- 위 코드에서 userlist 배열은 map메소드로 UserList를 렌더링해주고 있다.
- form으로 새로운 user을 추가하면 userlist 배열의 가장 앞 인덱스에 추가된다.
- UserList 컴포넌트중 마지막의 카운트 버튼을 눌러서 값을 변경시킨 후, form에서 user를 추가해보자.
- 새로운 userlist가 추가되는데, 마지막 컴포넌트의 카운트 값이 그 위 컴포넌트로 옮겨간다. 이 컴포넌트의 카운트 값은 한 단계 더 위 컴포넌트로 옮겨진다.
원인
배열 메소드를 통해 동일한 타입의 컴포넌트를 매핑하고 추가하거나 삭제할 시, 리엑트는 이 노드들을 얕은 비교로만 비교하기 때문에 이들의 순서를 파악할 방법이 없다. 그래서 "돔1, 돔2, 돔3.." 식의 컴포넌트에 "돔1, 돔x, 돔2, 돔3..."으로 중간에 하나가 추가되더라도 리엑트는 돔x를 돔2의 갱신으로 인식하고 돔2를 돔3의 갱신으로 인식하는 것이다.
이러면 각 노드에 할당된 state까지 뒤섞이고 만다. 그리고 가장 마지막에 있는 돔[-1]은 항상 돔트리에 다시 추가된다.
대안: key props
이 대안으로서 각 컴포넌트에 key를 추가하면 된다. 리엑트는 키를 바탕으로 컴포넌트의 위치 이동을 인식하고 돔 트리에서 다시 생성하지 않는다.
따라서, 키는 컴포넌트를 구분하기 위한 고유값이어야 하고, 리렌더링마다 변경되는 것이면 안된다. 특히 배열 순회 메소드의 인덱스 인자를 key로 주어선 안된다.
4. 정리
- 리엑트는 루트 엘리먼트가 다르다면, 그 하위 노드의 일치여부와 상관없이 모두 파괴하여 재생성한다. 만약, 두 엘리먼트가 매우 비슷하게 생겼는데, 루트만 다르다면 최대한 같은 타입으로 일치시키는 것이 낫다.
- props만 다르고 타입은 동일한 컴포넌트를 삼항연산자나 조건문으로 토글할 시, 동일한 컴포넌트로 간주되어 state가 유지되고 컴포넌트 사이클의 마운트와 디스마운트가 작동하지 않는다. 이것이 나쁜 건 아니지만, 원리를 정확히 모르면 예상치 못한 동작이 될 수 있다.
- 동일한 타입의 컴포넌트를 형제로 렌더링할 시, key props를 붙여주면 이들 사이에 추가나 삭제가 있어도 리엑트가 컴포넌트의 이동을 인식할 수 있다. 이는 돔 수정의 최적화와 state 오류를 방지한다.
'react' 카테고리의 다른 글
[React] useState의 순차적인 업데이트 방식과 useEffect 문제 (2) | 2024.03.16 |
---|---|
[React] 파이버 아키텍처에 대한 개요 (0) | 2024.03.15 |
[React] 리엑트 디자인 패턴: 컴포넌트와 합성 컴포넌트 (0) | 2024.03.12 |
[React] 리엑트에서의 불변성과 immer 라이브러리 (0) | 2024.03.12 |
[React] 리엑트 살펴보기8: useRef를 사용하는 이유(컴포넌트 사이클에 독립적인 객체 저장, 비제어 컴포넌트) (0) | 2024.03.11 |