zero-wiki Help

리액트 핵심 요소 깊게 살펴보기

가상돔과 리액트 파이버

DOM과 브라우저 렌더링 과정

웹 사이트에 접근 요청을 할 때 되는 과정

  1. 브라우저가 사용자가 요청한 주소를 방문하여 HTML 파일을 다운로드 한다.

  2. 브라우저 렌더링 엔진은 HTML을 파싱하여 DOM 노드로 구성된 DOM 트리를 구현한다.

  3. 2의 과정에서 CSS 파일을 만나면 CSS 파일도 다운로드 한다.

  4. CSS 파일도 파싱하여 CSS 노드로 구성된 CSSOM을 만든다.

  5. 브라우저는 2번에서 만들어진 노드중 사용자 눈에만 보여지는 노드만 방문한다.

  6. 5에서 만들어진 노드 중 눈에 보이는 대상으로 노드에 대한 CSSOM 정보를 찾으며 발견한 CSS 스타일 정보를 적용한다. 이후 두 가지의 과정을 거친다.

    • 레이아웃: 각 노드가 어떤 좌표에 정확하게 나타나야 하는지 계산하는 과정

    • 페인팅: 레이아웃에 걸쳐 노드에 색과 같은 실제 유효한 모습을 그리는 과정

가상 돔의 탄생 배경

위처럼 브라우저가 웹 페이지를 렌더링하는 과정은 복잡하고 많은 비용을 요구합니다. 이에 그치지 않고 사용자의 인터랙션을 통해 다양한 정보를 노출합니다.

따라서 렌더링이 완료된 이후에도 사용자의 인터랙션으로 웹 페이지가 변경되는 상황 또한 고려해야 합니다.

특정한 요소의 위치가 변경되는 레이아웃 과정이 있다고 하였을 때 이는 리페인팅 과정까지 가지게 됩니다. 이는 더 많은 비용을 브라우저와 사용자가 지불하게 합니다.

특히 이러한 렌더링 비용은 렌더링이 하나의 페이지에서 일어나는 SPA의 특성상 비용이 더 많아집니다. 페이지의 깜빡임 없이 자연스럽게 페이지의 흐름을 볼 수 있지만 그만큼 DOM을 관리하는 과정에서 부담해야하는 비용은 커지는 것입니다.

이러한 문제점을 해결하기 위해 나온 것이 가상 DOM입니다. 가상 DOM은 말 그대로 실제 브라우저의 DOM이 아닌 리액트가 관리하는 가상 DOM을 말하는데 웹 페이지에 표시해야할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영합니다.

가상 돔을 위한 아키텍처, 리액트 파이버

리액트는 여러 번의 렌더링 과정을 압축하여 리액트 파이버를 이용하여 보여줍니다.

리액트 파이버란?

리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체입니다. 이는 파이버 재조정자(fiber reconciler)가 관리하는데 가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 둘의 차이가 있다면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청합니다.

이전에 파이버 엔진을 사용하기 이전에는 스택을 이용하여 관리를 하였습니다. 하지만 자바스크립트의 한계로 인해 싱글 스레드라는 점으로 동기 작업은 중단이 될 수 없었으며 이는 리액트의 비효율성으로 이어졌습니다.

이후 이러한 문제점을 타파하기 위해 파이버라는 개념을 탄생시킵니다.

리액트 파이버의 목표는 웹 애플리케이션에서 발생하는 애니메이션, 레이아웃, 그리고 사용자 인터렉션에 올바른 결과물을 만드는 반응성 문제를 해결하는 것입니다.

또 중요한 것은 이러한 과정들은 모두 비동기로 일어난다는 것입니다. 때문에 기존 스택에서의 문제점을 개선할 수 있었습니다.

파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 이후에는 재사용이 됩니다.

const UserCard = () => { return ( <section> <div/> <div/> </section> ) }

위와 같은 컴포넌트가 렌더링 된다고 하였을때 객체로 객체로 표현한다면 다음과 같습니다.

const section = { child: div1 } const div1 = { sibling: div2, return: section, index:0 } const div2 = { return: section, index: 1 }

위 객체를 본다면 다음 구성을 알 수 있습니다.

  • index: 여러 형제들(sibling) 사이에서 자신이 몇번째 차례인지 나타냅니다.

  • return: 자기 자신의 부모 요소를 나타냅니다.

  • sibling: 자기 자신의 (다음)형제 요소를 나타냅니다.

또한 이러한 구성과 별개로 다음 사항들로도 구성이 되어 있습니다.

  • pendingProps: 아직 렌더링 작업을 진행 중인 props를 나타냅니다.

  • memoizedProps: 렌더링이 끝난 상태에서 완성된 props를 관리합니다.

이 둘이 같다면, 파이버 노드에 변경 사항이 없는 것으로 간주됩니다.

리액트에서의 파이버 트리

파이버의 트리는 현재 모습을 담고 있는 파이버 트리와, 작업 중인 상태를 나타내는 workInProgress 트리로 나눠집니다. 두개로 나눠져있는 이유는 workInProgress 변경점을 적용하고 적용이 끝났을 때 현재 트리에서 다음 트리로 포인터만 변경하여 바꿉니다. 이러한 기술을 더블 버퍼링이라고 합니다.

파이버의 작업 순서

파이버 트리와 파이버가 어떤 식으로 작동하는지 흐름을 보면 다음과 같습니다.

  1. 리액트는 beginWork() 함수를 실행해 파이버 작업을 시작합니다. 자식이 없는 노드를 만날 때까지 실행합니다.

  2. 작업이 끝났다면 그 다음 completeWork() 함수를 실행하여 파이버 작업을 완료합니다.

  3. 형제가 있다면 다음 형제로 갑니다.

  4. 모두 끝났다면 return 즉 부모로 돌아가 자신의 작업이 끝났음을 알립니다.

  5. 이후 루트 노드에서 commitWork()가 실행이 되고 이 중에 변경 사항이 있는 트리만 DOM에 반영됩니다.

클래스 컴포넌트

사람들이 지금은 함수형 컴포넌트를 많이 쓰고 있지만 이전에는 클래스형 컴포넌트를 사용하여야 했다. 하지만 복잡한 보일러 플레이트로 인해 함수형 컴포넌트를 많이 쓰고있다. 그래도 이전에 사용하던 코드를 알기 위해서는 클래스 컴포넌트를 이해할 필요가 있다.

// Component에 제네릭으로 props, state를 순서대로 넣어줍니다. class ExampleComponent extends React.Component<ExampleProps, ExamleState> { private contructor(props: ExapleProps) { super(props) this.state = { name: "Zero-1016", age: 20 } } // render에서 사용될 함수를 정의합니다. private handleClick = () => { const newValue = this.state.age + 1; this.setState({ ...this.state, age: newValue }}) } // 컴포넌트가 렌더링할 내용을 정의합니다. public render(){ // props와 state 값을 this, 클래스에서 가져옵니다. const { props: { isShow }, state: { name, age } } = this return ( <section> <div>유저 프로필</div> { isShow ? name : "익명" } <div>나이 : { age }</div> <button onClick={this.handleClick}}>이름 공개</button> </section> ) } }

위 코드의 특징은 다음과 같습니다.

  • constructor(): 함수가 정의되어 있어 해당 컴포넌트가 초기화 되는 시점에 해당 함수를 실행합니다. 또한 super() 함수로 인해 상속 받은 상위 컴포넌트를 먼저 호출해 필요한 상위 컴포넌트에 접근할 수 있도록 도와줍니다.

  • state: 클래스 컴포넌트 내부에서 관리하는 값으로 항상 객체여야 합니다. 변화가 있을 때마다 리렌더링 합니다.

  • function: 화살표 함수를 통해 실행 시점이 아닌 작성 시점에 상위 스코프로 결정되는 화살표 함수를 사용하여 함수를 사용했습니다.

클래스 컴포넌트의 생명주기

클래스 컴포넌트를 사용하면서 가장 언급되는 것은 생명주기입니다. 다음과 같이 나눠질 수 있습니다.

리액트의 생명주기
  • 마운트: 컴포넌트가 생성되는 시점

  • 업데이트: 이미 생성된 컴포넌트의 내용이 변경되는 시점

  • 언마운트: 컴포넌트가 더 이상 존재하지 않는 시점

각 생명주기 메서드를 알아보겠습니다.

  • render: 생명주기 메서드 중 하나로 순수함수로 구성이 되어있어야 합니다. 즉 side-effect가 없어야 하므로 상태가 변경이되는 과정같은 경우 다른 생명주기 메서드에 있어야합니다.

  • componentDidMount: 컴포넌트가 보여질 준비가 되었다면 바로 실행이 되는 함수로 해당 함수에는 render와 달리 상태를 변경하는 함수를 넣을 수가 있습니다. 하지만 해당 함수가 실행되는 시점은 컴포넌트가 그려지기 이전 단계로 보여지기 이전 함수를 실행할 수 있습니다.

  • componentDidUpdate: 컴포넌트가 업데이트 일어난 이후 바로 실행되는 함수입니다. 그러나 여기서 적절한 조치를 하지 않으면 계속해서 update가 일어날 수 있습니다.

    componentDidUpdate(prevProps, prevState){ if(this.props.userAge !== prevProps.userAge){ this.fetchData(this.userAge) } }

    다음과 같이 이전 Props와 다를 경우 fetch를 해달라고 요청을 하지 않을 경우 업데이트가 될 경우 다시 업데이트 함수가 불러와 계속해서 렌더링하는 현상이 일어나게 됩니다.

  • componentWillUnmount: 컴포넌트가 더이상 보여지지 않거나 사용되기 이전 메모리 누수나 불필요한 호출을 막을 수 있는 클린업 함수를 호출할 수 있ㅅ브니다. 하지만 해당 메서드 내에서는 this.setState 함수를 호출할 수 없습니다.

  • shouldComponentUpdate: 컴포넌트가 업데이트가 되는지의 여부를 결정할 수 있습니다. state나 props 변경으로 컴포넌트가 다시 렌더링 되는 것을 막기 위해서는 해당 함수를 설정하면 됩니다.

shouldComponentUpdate(nextProps, nextState){ // true인 경우 컴포넌트를 업데이트 합니다. (이전과 현재의 유저 이름이 다를 경우) return this.props.userName !== nextProps.userName; }
  • componentDidCatch: 자식 컴포넌트에서 에러가 발생했을 때 실행됩니다. 해당 함수는 getDerivedStateFromError에서 에러를 잡고 실행이 됩니다.

다음은 에러바운더리 예제입니다.

export default ErrorBoundary extends React.Component { constructor(props){ this.state = { hasError: false, errorMessage: "", } } // 자식에서 에러를 감지할 때 실행하는 함수 static getDerivedStateFromError(error: Error){ return { hasError: true, errorMessage: error.toString(), } } // 에러가 잡힌 이후 실행되는 함수 componentDidCatch(error: Error, info: ErrorInfo){ /* 에러를 확인하는 함수 slack을 통해서 에러를 전달. */ } render(){ if(this.state.hasError){ return ( <div> <h2>에러가 발생하였습니다. </h2> <p>{this.state.errorMessage}</p> </div> ) } } return this.props.children; }

여기까지 작성한 클래스의 컴포넌트와 사용 메서드들의 문제점을 가지고 함수형 컴포넌트가 나온 이유를 설명하면 다음과 같다.

  • 데이터의 흐름을 추적하기 어렵다. 메서드의 순서가 정해져있기 때문에 코드를 읽는 과정에서 헷갈릴 수 있다.

  • 애플리케이션의 내부 로직의 재사용이 어렵다. 고차 컴포넌트로 감싸거나 해야하는데 이럴 경우 wrapper hell에 빠져들 수 있다.

  • 기능이 많아질수록 컴포넌트의 크기가 커진다. 생명주기 메서드의 사용이 잦아지는 경우 컴포넌트 크기가 기하급수적으로 커진다.

  • 클래스는 함수에 비해 어렵다. this를 비롯하여 클래스 컴포넌트를 처음 접하는 사람에게 어렵다.

  • 코드 크기를 최적화 하기 어렵다. 이는 최종 결과물인 번들 크기를 줄이는데도 어렵다. props를 통해 이름만 변경했을 뿐인데도 번들러는 두 개의 다른 함수로 인식하기 때문에 번들 크기를 줄이기 어렵다.

  • 개발 중간중간 확인할 수 있게 핫 리로딩을 하는데에 있어서 어렵다. 코드의 수정내용이 바로 반영되지 않는다.

렌더링은 어떻게 일어나는가?

리액트의 렌더링이란?

리액트의 렌더링이란 리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 가지고 있는 props와 state를 가지고 어떻게 UI를 구성하고 어떤 DOM을 꾸릴지 계산하는 일련의 과정을 의미한다.만약 같은 값을 가지고 있지 않다면 빈번하게 렌더링이 일어날 것이다.

리액트에서 렌더링이 일어나는 이유

  • 최초 렌더링

  • 리렌더링

    • 클래스 컴포넌트의 setState가 실행되는 경우

    • 클래스 컴포넌트의 forceUpdate가 실행되는 경우

    • 함수형 컴포넌트의 useState의 두번째 요소인 상태 변경 함수가 실행되는 경우

    • 함수형 컴포넌틔의 useReducer의 두번째 요소인 dispatch 상태 변경 함수가 실행되는 경우

    • 컴포넌트의 key props가 변경되는 경우 컴포넌트의 key는 따로 명시하지 않아도 모든 곳에서 사용 가능한 특수한 props입니다. 이는 보통 배열을 통해 컴포넌트를 만들때 사용하게 됩니다. 리액트에서 key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소를 식별하는 값입니다. 파이버 노드에서는 sibling이라는 속성값을 사용했습니다. 때문에 key가 없다면 sibling에 의존하여 컴포넌트를 기억해야 합니다. 그러나 만약 key에 Math.random() 과 같은 랜덤한 값을 넣게 된다면 리액트는 매번 새롭게 렌더링된 노드로 판단하고 리렌더링을 합니다. 즉 sibling과 함께 정확하게 컴포넌트를 구분하기 위해서 사용합니다.

    • props가 변경되는 경우

    • 부모 컴포넌트가 렌더링 되는 경우, 자식 컴포넌트도 모두 리렌더링 하지만 props가 바뀌지 않는다면 React.memo를 통해서 방지할 수 있습니다.

리액트에서의 렌더링 프로세스

리액트에서는 렌더링 프로세스가 시작되면 하향식으로 변경이 필요한 모든 컴포넌트를 찾습니다. 업데이트가 필요하다고 생각된다면 classComponent의 경우 render 함수를 실행하고 함수형 컴포넌트의 경우 FunctionComponent 함수 자체를 실행하고 해당 결과물을 저장합니다. 이런식으로 계산을 하는 과정을 바로 **재조정(Reconciliation)**이라고 합니다. 이후 하나의 동기 시퀀스로 DOM에 적용하여 변경된 결과물을 보이게 합니다.

렌더와 커밋

렌더 단계는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말합니다. 즉 렌더링 프로세스에서 컴포넌트를 실행하여 결과와 이전 렌더의 결과를 비교하는 단계를 체크하는 단계를 말합니다. 여기서 크게 비교하는 것은 type, props, key로 하나라도 변경된다면 변경이 필요한 컴포넌트로 체크합니다.

그 다음은 커밋 단계로 렌더 단계의 변경 사항을 실제 DOM에 적용하여 실제 사용자에게 보여주는 단계를 말합니다. 해당 단계가 끝나야 비로소 브라우저의 렌더링 과정이 발생합니다. 이후 ComponentDidMound, ComponentDidUpdate, useLayoutEffect 훅등을 호출합니다.

여기서 알 수 있는 중요한 사실은 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니다. 즉 변경사항이 없다면 커밋 단계는 생략될 수 있습니다.

컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션

메모이제이션에 대한 생각을 두가지로 나눠진다.

  1. 언제나 메모이제이션을 해두는 것은 웹을 무겁게 합니다. 코드를 작성하고 수정해보세요. 아무데서나 PureComponent를 사용하지 마세요.

  2. 렌더링 과정의 비용은 비쌉니다. 언제나 메모이제이션 하세요

메모이제이션을 하지 않았을 때 생기는 문제는 다음과 같습니다.

  1. 렌더링 함으로써 발생하는 비용

  2. 컴포넌트 내부의 복잡한 로직의 재실행

  3. 위 두가지 모두 모든 자식 컴포넌트에서 일어남

  4. 구 트리와 신트리의 비교.

이처럼 메모이제이션을 하지 않는 것보다 메모이제이션을 했을 때 더 많은 이점을 누릴 수 있는 것을 알았습니다. 최적화에 대한 확신이 없다면 가능한 메모이제이션을 활용한 최적화를 하는 것이 좋습니다.

Last modified: 11 August 2024