[모던 리액트 Deep Dive] 02장 리액트 핵심 요소 깊게 살펴보기

H Kim·2024년 2월 18일
0

기술 책 읽기

목록 보기
13/20
post-thumbnail
post-custom-banner

2.1 JSX란?

  • JSX는 흔히 개발자들이 알고 있는 XML과 유사한 내장형 구문이며, 리액트에 종속적이지 않은 독자적인 문법으로 보는 것이 옳다. 그리고 페이스북에서 독자적으로 개발했다는 사실에서 미루어 알 수 있듯이 JSX는 이른바 ECMAScript라고 불리는 자바스크립트 표준의 일부는 아니다. 즉, V8이나 Deno와 같은 자바스크립트 엔진이나 크롬, 웨일, 파이어폭스 같은 브라우저에 의해서 실행되거나 표현되도록 만들어진 구문이 아니다.

  • JSX가 포함된 코드를 아무런 처리 없이 그대로 실행하면 에러가 발생한다. 앞서 언급했던 것처럼 JSX는 자바스크립트 표준 코드가 아닌 페이스북이 임의로 만든 새로운 문법이기 때문에 JSX는 반드시 트랜스파일러를 거쳐야 비로소 자바스크립트 런타임이 이해할 수 있는 의미 있는 자바스크립트 코드로 변환된다.

  • JSX는 HTML이나 XML을 자바스크립트 내부에 표현하는 것이 유일한 목적은 아니다. JSX의 설계 목적은 다양한 트랜스파일러에서 다양한 속성을 가진 트리 구조를 토큰화해 ECMAScript로 변환하는 데 초점을 두고 있다. 조금 더 쉽게 이야기하자면 JSX 내부에 트리 구조로 표현하고 싶은 다양한 것들을 작성해두고, 이 JSX를 트랜스파일이라는 과정을 거쳐 자바스크립트(ECMAScript)가 이해할 수 있는 코드로 변경하는 것이 목표라고 볼 수 있다. 즉, JSX가 주로 사용되는 곳은 리액트 내부에서 반환하는 HTML과 자바스크립트 코드이지만 꼭 그것에 한정돼 있는 것은 아니다. 즉, JSX는 HTML, XML 외에도 다른 구문으로도 확장될 수 있게끔 고려돼 있으며 최대한 구문을 간결하고 친숙하게 작성할 수 있도록 설계돼 있다. XML과 비슷하게 보이는 것은 단순히 자바스크립트 개발자로 하여금 친숙함을 느낄 수 있도록 하는 것이다.

  • 요약하자면 JSX는 자바스크립트 재부에서 표현하기 까다로웠던 XML 스타일의 트리 구문을 작성하는 데 많은 도움을 주는 새로운 문법이라고 볼 수 있다.



2.2 가상 DOM과 리액트 파이버

2.2.1 DOM과 브라우저 렌더링 과정

  • DOM (Document Object Model)
    웹페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.

  • DOM 노드에 CSS를 적용하는 과정은 크게 두 가지로 나눌 수 있다.
    레이아웃(layout, reflow): 각 노드가 브라우저 화면의 어느 좦에 정확히 나타나야 하는지 계산하는 과정. 이 레이아웃 과정을 거치면 반드시 페인팅 과정도 거치게 된다.
    페인팅(painting): 레이아웃 단계를 거친 노드에 색과 같은 실제 유효한 모습을 그리는 과정


2.2.2 가장 DOM의 탄생 배경

  • 어떤 특정한 요소의 노출 여부가 변경되거나 사이즈가 변경되는 등 요소의 위치와 크기를 재계산하는 시나리오다. 이 경우에는 레이아웃이 일어나고, 이 레이아웃운 필연적으로 리페인팅이 발생하기 때문에 더 많은 비용이 든다. 또한 DOM 변경이 일어나는 요소가 많은 자식 요소를 가지고 있는 경우에는 하위 자식 요소도 덩달아 변경돼야 하기 때문에 더 많은 비용을 부라우저와 사용자가 지불하게 된다.

  • 하나의 인터랙션으로 인해 페이지 내부의 DOM의 여러 가지가 변경되는 시나리오는 요즘의 웹페이지에서 흔히 볼 수 있다. 이러한 사용자의 인터랙션에 따라 DOM의 모든 변경 사항을 추적하는 것은 개발자 입장에서 너무나 수고스러운 일이다. 그리고 대부분의 경우에 개발자는 인터랙션에 모든 DOM의 변경보다는 결과적으로 만들어지는 DOM 결과물 하나만 알고 싶을 것이다. 이렇게 인터랙션에 따른 DOM의 최종 결과물을 간편하게 제공하는 것은 브라우저뿐만 아니라 개발자에게도 매우 유용하다.

  • 가상DOM은 말 그대로 실제 부라우저의 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다. 가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영한다. (여기서 말하는 리액트는 정확히 이야기하면 package.json에 있는 react가 아닌 react-dom을 의미한다.) 이렇게 DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 때 렌더링 과정을 최소화할 수 있고 브라우저와 개발자의 부담을 덜 수 있다.

  • 무조건 빠른 것이 아니라 리액트의 이 가상 DOM 방식은 대부분의 상황에서 웬만한 애플리케이션을 만들 수 있을 정도로 충분히 빠르다는 것이다.


2.2.3 가상 DOM을 위한 아키텍쳐, 리액트 파이버

  • 리액트에서 관리하는 평범한 자바스크립트 객체다. 파이버는 파이버 재조정자(fiber reconciler)가 관리하는데, 이는 앞서 이야기한 가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 만약 이 둘 사이에 차이가 있으면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다. 재조정(reconciliation)이라는 용어가 낯설겠지만 리액트에서 어떤 부분을 새롭게 렌더링해야 하는지 가상 DOM과 실제 DOM을 비교하는 작업(알고리즘)이라고 이해하면 된다.

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

  • 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.
    이러한 작업을 일시 중지하고 나중에 다시 시작할 수 있다.
    이전에 했던 작업을 다시 재사용하거나 필요하지 않은 경우에는 폐기할 수 있다.

  • 그리고 한 가지 중요한 것은 이러한 모든 과정이 비동기로 일어난다는 것이다.

  • 파이버는 일단 하나의 작업 단위로 구성돼 있다. 리액트는 이러한 작업 단위를 하나씩 처리하고 finishedWork()라는 작업으로 마무리한다. 그리고 이 작업을 커밋해 실제 브라우저 DOM에 가시적인 변경 사항을 만들어 낸다. 그리고 이러한 단계는 아래 두 단계로 나눌 수 있다.

  1. 렌더 단계에서 리액트는 사용자에게 노출되지 않는 모든 비동기 작업을 수행한다. 그리고 이 단계에서 앞서 언급한 파이버의 작업, 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 일어난다.
  2. 커밋 단계에서는 앞서 언급한 것처럼 DOM에 실제 변경 사항을 반영하기 위한 작업, commitWorkt()가 실행되는데, 이 과정은 앞서와 다르게 동기식으로 일어나고 중단될 수도 없다.
  • 파이버는 리액트 요소와 유사하다고 느낄 수 있지만 한 가지 중요한 차이점은 리액트 요소는 렌더링이 발생할 때마다 새롭게 생성되지만 파이버는 가급적이면 재사용된다는 사실이다. 파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 이후에는 가급적이면 재사용된다.

  • 이렇게 생상된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행된다. 그리고 중요한 것은 리액트가 파이버를 처리할 때마다 이러한 작업을 직접 바로 처리하기도 하고 스케줄링하기도 한다는 것이다. 즉, 이러한 작업들은 작은 단위로 나눠서 처리할 수도, 애니메이션과 같이 우선순위가 높은 작업은 가능한 한 빠르게 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다.

  • 리액트 파이버의 가상 DOM이 생각보다 단순한 자바스크립트 객체로 관리되고 있다는 사실에 놀랄 수도 있다. 리액트 개발 팀은 사실 리액트는 가상 DOM이 아닌 Value UI, 즉 값을 가지고 있는 UI를 관리하는 라이브러리라는 내용을 피력한 바 있다. 파이버의 객체 값에서도 알 수 있듯이 리액트의 핵심 원칙은 UI를 문자열, 숫자, 배열과 같은 값으로 관리한다는 것이다. 변수에 이러한 UI 관련 값을 보관하고, 리액트의 자바스크립트 코드 흐름에 따라 이를 관리하고, 표현하는 것이 바로 리액트다.

  • 파이버 트리는 사실 리액트 내부에서 두 개가 존재한다. 하나는 현재 모습을 담은 파이버 트리이고, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 바꿔버린다. 이러한 기술을 더블 버퍼링이라고 한다.

  • 더블 버퍼링은 리액트에서 새롭게 나온 개념이 아니고 컴퓨터 그래픽 분야에서 사용하는 용어다. 그래픽을 통해 화면에 표시되는 것을 그리기 위해서는 내부적으로 처리를 거쳐야 하는데, 이러한 처리를 거치게 되면 사용자에게 미처 다 그리지 못한 모습을 보는 경우가 발생가헤 된다. (한 번에 모든 작업을 마무리해 다 그릴 수 없기 때문이다.) 그래서 이러한 상황을 방지하기 위해 보이지 않는 곳에서 그다음으로 그려야 할 그림을 미리 그린 다음, 이것이 완성되면 현재 상태를 새로운 그림으로 바꾸는 기법을 의미한다.

  • 리액트에서도 미처 다 그리지 못한 모습을 노출시키지 않기 위해(불완전한 트리를 보여주지 않기 위해) 더블 버퍼링 기법을 쓰는데, 이러한 더블 버퍼링을 위해 트리가 두 개 존재하며, 이 더블 버퍼링은 커밋 단게에서 수행된다.



2.3 클래스형 컴포넌트와 함수형 컴포넌트

2.3.1 클래스형 컴포넌트

import React from 'react'

class SampleComponent extends React.Component {
  render() {
    return <h2>Sample Component</h2>
  }
}

  • 생명주기 메서드
    마운트(mount): 컴포넌트가 마운팅(생성)되는 시점
    업데이트(update): 이미 생성된 컴포넌트의 내용이 변경(업데이트)되는 시점
    언마운트(unmount): 컴포넌트가 더 이상 존재하지 않는 시점

  • render()
    생명주기 메서드 중 하나로, 리액트 클래스형 컴포넌트의 유일한 필수 값으로 쓰인다.
    항상 순수해야 하며 부수 효과가 없어야 한다. 이 말인즉슨, 같은 입력값(props 또는 state)이 들어가면 항상 같은 결과물을 반환해야 한다는 뜻이다. 따라서 render() 내부에서 state를 직접 업데이트하는 this.setState를 호출해서는 안 된다. state를 변경하는 일은 클래스형 컴포넌트의 메서드나 다른 생명주기 메서드 내부에서 발생해야 한다.

  • componentDidMount()
    클래스형 컴포넌트가 마운트되고 준비가 됐다면 그다음으로 호출되는 생명주기 메서드이다. 이 함수는 컴포넌트가 마운트되고 준비되는 즉시 실행된다.
    여기서 this.setState를 허용하는 것은 생성자 함수에서는 할 수 없는 것, API 호출 후 업데이트, DOM에 의존적인 작업(이벤트 리스너 추가 등) 등을 하기 위해서다. 꼭 componentDidMount에서 할 수밖에 없는 작업인지 철저히 확인해보고 사용해야 한다.

  • componentDidUpdate()
    컴포넌트 업데이트가 일어난 이후 바로 실행된다. 일반적으로 state나 props의 변화에 따라 DOM을 업데이트하는 등에 쓰인다. 그러나 적절한 조건문으로 감싸지 않는다면 this.setState가 계속해서 호출되는 일이 발생할 수 있다. 이는 성능적으로 매우 좋지 못하다.

  • componentWillUnmount()
    컴포넌트가 언마운트되거나 더 이상 사용되지 않기 직전에 호출된다. 메모리 누수나 불필요한 작동을 막기 위한 클린업 함수를 호출하기 위한 최적의 위치다.
    이벤트를 지우거나, API 호출을 취소하거나, setInterval, setTimeout으로 생성된 타이머를 지우는 등의 작업을 하는 데 유용하다.

  • shouldComponentUpdate()
    state나 props의 변경으로 리액터 컴포넌트가 다시 리렌더링되는 것을 막고 싶을 때 사용한다. 그러나 일반적으로 state의 변화에 따라 컴포넌트가 리렌더링되는 것은 굉장히 자연스러운 일이므로 이 메서드를 사용하는 것은 특정한 성능 최적화 상황에서만 고려해야 한다.

  • static getDervibedStateFromProps()
    componentWillReceiveProps를 대체할 수 있는 메서드다. 이 메서드는 render()를 호출하기 직전에 호출된다. 한 가지 눈에 띄는 점은 static으로 선언돼 있어 this에 접근할 수 없다는 것이다. 여기서 반환하는 객체는 해당 객체의 내용이 모두 state로 들어가게 된다. 이에 반해 null을 반환하면 아무 일도 일어나지 않는다.

  • getSnapShotBeforeUpdate()
    componentWillUpdate()를 대체할 수 있는 메서드디ㅏ. 이는 DOM이 업데이트되기 직전에 호출된다. 여기서 반환되는 값은 componentDidUpdate로 전달된다. DOM에 렌더링되기 전에 윈도우 크기를 조절하거나 스크롤 위치를 조정하는 등의 작업을 처리하는 데 유용하다.


  • 클래스형 컴포넌트의 한계

  • 데이터의 흐름을 추적하기 어렵다.

  • 애플리케이션 내부 로직의 재사용이 어렵다.

  • 기능이 많아질수록 컴포넌트의 크기가 커진다.

  • 클래스는 함수에 비해 상대적으로 어렵다.

  • 코드 크기를 최적화하기 어렵다.


2.3.3 함수형 컴포넌트 vs. 클래스형 컴포넌트

  • 클래스형 컴포넌트의 생명주기 메서드가 함수형 컴포넌트에서는 존재하지 않는다. 그 이유는 함수형 컴포넌트는 props를 받아 단순히 리액트 요소만 반환하는 함수인 반면, 클래스형 컴포넌트는 render 메서드가 있는 React.Component를 상속받아 구현하는 자바스크립트 클래스이기 때문이다. 즉, 생명주기 메서드는 React.Component에서 오는 것이기 때문에 클래스형 컴포넌트가 아닌 이상 생명주기 메서드를 더는 사용할 수 없다는 뜻이다.

  • 함수형 컴포넌트는 useEffect 훅을 사용해 앞서 언급했던 생명주기 메서드인 componentDidMount, componentDidUpdate, componentWillUnmount를 비슷하게 구현할 수 있다. 이 문장에서 주목해야 할 사실은 '비슷'할 뿐이지 '똑같다'는 것이 아니라는 점이다. 즉, useEffect는 생명주기를 위한 훅이 아니다. useEffect 컴포넌트의 state를 활용해 동기적으로 부수 효과를 만드는 메커니즘이다.

  • 리액트 개발자 댄 아브라모프가 블로그에서 이야기한 유명한 내용 중 하나로, 함수형 컴포넌트는 렌더링 된 값을 고정하고, 클래스형 컴포넌트는 그렇지 못하다는 사실이다.

  • 함수형 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 props와 state를 기준으로 렌더링된다. props와 state가 변경된다면, 다시 한 번 그 값을 기준으로 함수가 호출된다고 볼 수 있다. 반면 클래스형 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링이 일어난다.



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

  • 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정을 의미한다. 따라서 리액트 개발자라면 렌더링이 어떻게, 왜, 어떤 순서로 일어나는지 알고 있어야 하며 이러한 렌더링 과정을 최소한으로 줄여야 한다.

2.4.1 리액트의 렌더링이란?

  • 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state 값을 기반으로 어떻게 UI를 구성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미한다.

2.4.2 리액트의 렌더링이 일어나는 이유

  • 최초 렌더링 : 사용자가 처음 애플리케이션에 진입할 때

  • 리렌더링 : 최초 렌더링이 발생한 이후로 발생하는 모든 렌더링

    • 클래스형 컴포넌트의 setState가 실행되는 경우
    • 클래스형 컴포넌트의 forceUpdate가 실행되는 경우
    • 함수형 컴포넌트의 useState()의 두 번째 배열 요소인 setter가 실행되는 경우
    • 함수형 컴포넌트의 useReducer()의 두 번째 배열 요소인 dispatch가 실행되는 경우
    • 컴포넌트의 key props가 변경되는 경우
    • props가 변경되는 경우
    • 부모 컴포넌트가 렌더링될 경우 : 부모 컴포넌트가 리렌더링된다면 자식 컴포넌트도 무조건 리렌더링이 일어난다.

2.4.4 렌더와 커밋

  • 렌더 단계(Render Phase)
    컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다. 즉, 렌더링 프로세스에서 컴포넌트를 실행해(render() 또는 return)이 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계다.
    type, props, key 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크해 둔다.

  • 커밋 단계(Commit Phase)
    렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나야 비로소 브라우저의 렌더링이 발생한다.

  • 리액트가 먼저 DOM을 커밋 단계에서 업데이트한다면 이렇게 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조를 업데이트한다. 그다음, 생명주기 개념이 있는 클래스형 컴포넌트에서는 componentDidMount, componentDidUpdate 메서드를 호출하고, 함수형 컴포넌트에서는 useLayoutEffect 훅을 호출한다.

  • 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니다. 렌더링을 수행했으나 커밋 단계까지 갈 필요가 없다면, 즉 변경 사항을 계산했는데 아무런 변경 사항이 감지되지 않는다면 이 커밋 단계는 생략될 수 있다. 즉 리액트의 렌더링은 꼭 가시적인 변경이 일어나지 않아도 발생할 수 있다. 렌더링 과정 중 첫 번째 단계인 렌더 단계에서 변경 사항을 감지할 수 없다면 커밋 단계가 생략되어 브라우저의 DOM 업데이트가 일어나지 않을 수 있다.



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

2.5.1 주장 1: 섣부른 최적화는 독이다. 꼭 필요한 곳에서만 메모이제이션을 추가하자

  • 미리 개발자가 렌더링이 많이 될 것 같은 부분을 예측해 메모이제이션하는, 이른바 섣부른 최적화는 옳지 못한 행동이다. 일단 애플리케이션을 어느 정도 만든 이후에 개발자 도구나 useEffect를 사용해 실제로 어떻게 렌더링이 일어나고 있는지 확인하고 필요한 곳에서만 최적화하는 것이 옳다.

2.5.2 주장 2: 렌더링 과정의 비용은 비싸다, 모조리 메모이제이션해 버리자

  • 일부 컴포넌트에서는 메모이제이션을 하는 것이 선응에 도움이 된다. 섣부른 최적화인지 여부와는 관계없이, 만약 해당 컴포넌트가 렌더링이 자주 일어나며 렌더링 사이에 비싼 연산이 포함돼 있고, 심지어 그 컴포넌트가 자식 컴포넌트 또한 많이 가지고 있다면 memo나 다른 메모이제이션 방법을 사용하는 것이 이점이 있을 때가 분명히 있다.

  • 잘못된 memo로 지불해야 하는 비용은 바로 props에 대한 얕은 비교가 발생하면서 지불해야 하는 비용이다. 메모이제이션을 위해서는 CPU와 메모리를 사용해 이전 렌더링 결과물을 저장해 둬야 하고, 리렌더링할 필요가 없다면 이전 결과물을 사용해야 한다.

  • memo를 하지 않았을 때 발생할 수 있는 문제는 다음과 같다.

    • 렌더링을 함으로써 발생하는 비용
    • 컴포넌트 내부의 복잡한 로직의 재실행
    • 그리고 위 두 가지 모두가 모든 자식 컴포넌트에서 반복해서 일어남
    • 리액트가 구 트리와 신규 트리를 비교

얼핏 살펴보더라도 memo를 하지 않았을 때 치러야 할 잠재적인 위험 비용이 더 크다는 사실을 알 수 있다.

  • 정리하자면, 메모이제이션은 하지 않는 것보다 메모이제이션했을 때 더 많은 이점을 누릴 수 있다. 이것이 비록 섣부른 초기화라 할지라도 했을 때 누릴 수 있는 이점, 그리고 이를 실수로 빠트렸을 때 치러야 할 위험 비용이 더 크기 때문에 최적화에 대한 확신이 없다면 가능한 한 모든 곳에 메모이제이션을 활용한 최적화를 하는 것이 좋다.

2.5.3 결론 및 정리

  • 만약 현업에서 리액트를 사용하고 있거나 실제로 다룰 예정이지만 성능에 대해 깊게 연구해 볼 시간적 여유가 없는 상황이라면 일단 의심스러운 곳에는 먼저 다 적용해 볼 것을 권장한다. 앞서 리액트 파이버에서의 작동과 흐름을 살펴봐서 알겠지만 lodash나 간단한 함수의 경우와는 다르게 일반적으로는 props에 대한 얕은 비교를 수행하는 것보다 리액트 컴포넌트의 결과물을 다시 계산하고 실제 DOM까지 비교하는 작업이 더 무겁고 비싸다. 조금이라도 로직이 들어간 컴포넌트는 메모이제이션이 성능 향상에 도움을 줄 가능성이 크다. useMemo와 useCallback 또한 마찬가지다. useCallback의 경우는 대부분 다른 컴포넌트의 props로 넘어가는 경우가 많을 것이다. 이 props로 넘어갔을 때 참조 투명성을 유지하기 위해서는 useCallback을 사용하는 것이 좋다. useMemo 또한 마찬가지다. props로 넘어가거나 이를 활용할 여지가 있다면 사용하는 것이 좋다. 성능에 대해서 지속적으로 모니터링하고 관찰하는 것보다 섣부른 메모이제이션 최적화가 주는 이점이 더 클 수 있다.
post-custom-banner

0개의 댓글