
2.1 JSX란?
- JSX는 자바스크립트 표준(ECMAScript)의 일부가 아니기 때문에 반드시 트랜스파일러를 거쳐야 자바스크립트의 런타임이 이해할 수 있는 자바스크립트 코드로 변환
- 자바스크립트 내부에서 표현하기 까다로웠던 XML 스타일의 트리 구문을 작성하기 위한 문법
2.1.1 JSX의 정의
- JSX를 구성하는 4가지 컴포넌트
- JSXElement
- HTML의 요소와 비슷한 역할을 하는 JSX의 기본 요소
- JSXOpeningElement, JSXClosingElement, JSXSelfClosingElement, JSXFragment
- JSXAttributes
- JSXChildren
- JSXStrings
- HTML에서 사용 가능한 문자열은 모두 JSXStrings에서도 사용 가능
- 큰따옴표, 작은 따옴표로 구성된 문자열 또는 JSXText
- 자바스크립트와의 중요한 차이점은 이스케이프 문자 형태소(\로 시작하는 문자열) 제약 없이 사용 가능하다는 것
2.1.3 JSX는 어떻게 자바스크립트에서 변환될까?
@babel/plugin-transform-react-jsx
플러그인을 통해 JSX 구문을 자바스크립트가 이해할 수 있는 코드로 변환
- 자동 런타임으로 트랜스파일(React 17, 바벨 7.9.0 이후)
2.1.4 정리
- JSX는 자바스크립트 코드 내부에 HTML과 같은 트리 구조를 가진 컴포넌트를 표현할 수 있어 인기가 있다.
- 반면, JSX가 HTML 문법과 자바스크립트 문법이 뒤섞여 있어 코드 가독성을 해친다는 의견도 있다.
2.2 가상 DOM과 리액트 파이버
- 리액트 가상 DOM이 무엇인지, 그리고 실제 DOM에 비해 어떤 이점이 있는지 살펴보고 가상 DOM을 다룰 때 주의할 점에 대해서 알아보자.
2.2.1 DOM과 브라우저 렌더링
- DOM(Document Object Model)은 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있는 인터페이스
2.2.2 가상 DOM의 탄생 배경
- 싱글 페이지 애플리케이션(Single Page Application, SPA)는 사용자가 페이지의 깜빡임 없이 자연스러운 웹페이지 탐색을 할 수 있지만 그만큼 DOM을 관리하는 과정에서 부담해야 할 비용이 크다.
- 개발자가 사용자의 인터랙션에 따라 DOM의 모든 변경 사항을 추적하는 것은 너무 수고스럽다. 이 문제점을 해결하기 위해 가상 DOM을 도입.
- 가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료되면 실제 브라우저의 DOM에 반영
- 이를 통해 렌더링 과정을 최소화하고 브라우저와 개발자의 부담을 덜 수 있다.
- 이러한 방식은 일반적인 DOM 관리 방법보다 무조건 빠른 것이 아니라 웬만한 애플리케이션을 만들 수 있을 정도로 합리적으로 빠르다.
2.2.3 가상 DOM을 위한 아키텍처, 리액트 파이버
❓ 리액트 파이버란?
- 리액트는 파이버라는 자바스크립트 객체를 통해 가상 DOM을 만든다.
- 파이버는 파이버 재조정자를 통해 재조정(reconciliation): 가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 둘 사이에 차이가 있으면 화면에 렌더링을 요청
- 즉, 리액트에서 어떤 부분을 새롭게 렌더링해야 하는지 가상 DOM과 실제 DOM을 비교하는 알고리즘
- 과거의 스택 조정자는 이를 동기적으로 처리해 비효율적이었으나, 파이버는 이를 비동기적으로 처리해 효율적
- 파이버의 작업 순서
- 렌더 단계
- 사용자에게 노출되지 않는 모든 비동기 작업 수행
- 우선순위를 지정하거나 중지시키거나 버리는 작업
- 커밋 단계
- DOM에 실제 변경 사항을 반영하는 동기식 작업 수행
- 파이버와 리액트 요소의 차이점은 리액트 요소는 렌더링이 발생할 때마다 새롭게 생성되지만 파이버는 가급적 재사용된다는 점이다.
- 파이버는 하나의 element에 하나가 생성되는 1:1 관계로, state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행
- 중요한 것은 리액트가 파이버를 처리할 때마다 이러한 작업을 직접 바로 처리 하기도 하고 스케줄링하기도 한다는 것
리액트 파이버 트리
- 2개의 트리로 구성: 현재 모습을 담은 파이버 트리와 작업 중인 상태를 나타내는 workInProgress 트리
- 더블 버퍼링: 사용자에게 다 그리지 못한 모습을 보이지 않기 위해 보이지 않는 곳에서 그 다음으로 그려야 할 그림을 미리 그린 다음, 이것이 완성되면 현재 상태를 새로운 그림으로 바꾸는 기법
- 리액트는 커밋 단계에서 포인터를 변경해 workInProgress 트리를 현재 트리로 변경
파이버의 작업 순서
- 트리 생성
- 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작
- 작업이 끝나면 completeWork() 함수를 실행해 파이버 작업을 완료
- 형제가 있다면 형제로 넘어간다.
- return으로 돌아가 작업을 완료
- 트리 업데이트
- 업데이트 발생 시 기존 파이버에서 업데이트된 props 받아 내부 속성값만 초기화하거나 바꾸는 형태로 트리를 업데이트
- 우선순위를 할당하고, 우선순위가 높은 업데이트가 발생하면 현재 작업을 일시 중단하거나 새롭게 만들거나, 폐기할 수 있는 비동기적 처리
2.2.4 파이버와 가상 DOM
- 파이버는 리액트 컴포넌트에 대한 정보를 1:1로 가지고 있는 객체
- 파이버는 리액트 아키텍처 내에서 비동기로 이루어지고, 실제 브라우저 구조인 DOM에 반영하는 것은 동기적으로 처리
- 가상 DOM은 웹 애플리케이션에서만 통용되는 개념이고, 리액트 파이버는 리액트 네이티브와 같은 브라우저가 아닌 환경에서도 사용 가능
2.2.5 정리
가상 DOM과 리액트의 핵심은 값으로 표현하는 것이다. 화면에 표시되는 UI를 값으로 관리하고 이러한 흐름을 효율적으로 관리하기 위한 매커니즘이 리액트의 핵심.
2.3 클래스형 컴포넌트와 함수형 컴포넌트
2.3.1 클래스형 컴포넌트
클래스형 컴포넌트의 생명주기 메서드
- 생명주기 메서드가 실행되는 시점
- 마운트: 컴포넌트가 생성(마운트)되는 시점
- 업데이트: 이미 생성된 컴포넌트의 내용이 변경되는 시점
- 언마운트: 컴포넌트가 더 이상 존재하지 않는 시점
render
- 리액트 클래스형 컴포넌트의 유일한 필수값
- 컴포넌트가 UI를 렌더링하기 위해 쓰임
- 마운트와 업데이트 과정에서 실행
- 항상 순수해야 하며 부수 효과가 없어야 한다. 즉, 여기에서 state를 업데이트하지 않는다.
componentDidMount
- 컴포넌트가 마운트되고 준비되는 즉시 실행
- setState가 가능하지만 생성자 함수에서 할 수 없는 API 호출 후 업데이트, DOM에 의존적인 작업을 위해서만 할 것
componentDidUpdate
- 컴포넌트 업데이트가 일어난 이후 바로 실행
- state나 props의 변화에 따라 DOM을 업데이트하는 데 사용
- 적절한 조건문을 사용해 계속해서 호출되지 않도록 해야 한다
componentWillUnmount
- 컴포넌트가 언마운트되거나 더 이상 사용되지 않기 직전에 호출
- 메모리 누수나 불필요한 작동을 막기 위한 클린업 함수 호출
- 이벤트를 지우거나, API 호출 취소, 타이머를 지우는 작업
shouldComponentUpdate
- state나 props의 변경으로 컴포넌트가 리렌더링 되는 것을 막기 위해 사용
- 함수가 false를 반환하는 경우, 컴포넌트를 업데이트하지 않는다.
- PureComponent는 이를 활용해 state 값에 대한 얕은 비교를 수행해 결과가 다를 때만 렌더링을 수행
- 이는 얕은 비교를 수행했을 때 다른 경우가 잦으면 오히려 성능에 악영향
static getDerivedstateFromProps
- render 호출 직전에 호출
- 반환하는 객체는 해당 객체의 내용이 모두 state로 들어간다.
- 다음에 올 props를 바탕으로 현재의 state를 변경하고 싶을 때 사용
- getSnapShotBeforeUpdate
- DOM이 업데이트되기 직전에 호출되며, 반환값은 componentDidUpdate로 전달
- DOM에 렌더링 되기 전 윈도우 크기를 조정하거나 스크롤 위치 조정에 사용
getDerivedStateFromError
- getDrivedStateFrom, componentDidCath, getSnpshotBeforeUpdate는 클래스형 컴포넌트에서만 사용 가능
- 자식 컴포넌트에서 에러가 발생했을 때 호출되는 메서드
- 에러를 인수로 받고, 반드시 state 값을 반환해야 하며, 부수 효과를 발생시켜서는 안 된다.
componentDidCatch
- 자식 컴포넌트에서 에러가 발생했을 때 실행되며, getDerivedFromError에서 에러를 잡고 state를 결정한 후에 실행
- getDerivedStateFromError와 componentDidCatch는 ErrorBoundary를 만들기 위해 많이 사용. 이를 통해 리액트 애플리케이션 전역이나 컴포넌트별로 에러 처리
⭐️ 클래스형 컴포넌트의 한계
- 데이터의 흐름을 추적하기 어렵다
- 애플리케이션 내부 로직의 재사용이 어렵다
- 고차 컴포넌트 또는 props를 넘겨주는 방식으로 재사용할 수 있는데, 공통 로직이 많아질수록 래퍼 지옥에 빠져들 위험성이 크다
- 기능이 많아질수록 컴포넌트의 크기가 커진다
- 함수에 비해 상대적으로 어렵다
- 코드 크기를 최적화하기 어렵다
- 메서드의 이름이 최소화되지 않고, 사용하지 않는 메서드도 트리쉐이킹 되지 않아 번들링을 최적화하기 어려운 조건
- 핫 리로딩에 상대적으로 불리하다
- 함수형 컴포넌트는 핫 리로딩 후에도 변경된 상태값이 유지되지만, 클래스형 컴포넌트는 핫 리로딩 후 상태값이 초기화
- 함수형 컴포넌트는 상태를 클로저에 저장하는 반면, 클래스형 컴포넌트는 instance를 새로 만들어야 하기 때문이다
2.3.2 함수형 컴포넌트
- this를 신경을 쓸 필요없고, state가 객체 뿐만 아니라 원시값으로 관리된다는 점에서 훨씬 간결하다.
2.3.3 함수형 컴포넌트 vs. 클래스형 컴포넌트
- 생명주기 메서드의 부재
- 생명주기 메서드는 React.Component에서 오는 것이기 때문에 클래스형 컴포넌트에서만 사용 가능
- 함수형 컴포넌트와 렌더링 된 값
- 함수형 컴포넌트는 렌더링 된 값을 고정하고, 클래스형 컴포넌트는 그렇지 못하다.
- 함수형 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 prop와 state를 기준으로 렌더링하는 반면, 클래스형 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링 발생
생명주기 메서드, 렌더링 값 유지 여부, 가독성, 메모리 효율성
2.4 렌더링은 어떻게 일어나는가?
- 1️⃣ 브라우저의 렌더링이란 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정
- 2️⃣ 리액트의 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
2.4.1 리액트의 렌더링이란?
- 컴포넌트들이 props와 state를 기반으로 구성한 UI를 기반으로 산출된 DOM을 브라우저에 제공하는 일련의 과정
- 컴포넌트가 props와 state를 가지고 있지 않다면 반환하는 JSX 값을 기반으로 렌더링
2.4.2 ⭐️ 리액트의 렌더링이 일어나는 이유
- 최초 렌더링
- 리렌더링
- 상태 변경
- 클래스형 컴포넌트의 setState
- 함수형 컴포넌트의 setter 또는 dispatch
- 클래스형 컴포넌트의 forceUpdate
- key props 변경
- key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소를 식별하는 값으로, sibling이 변경되었다고 판단되면 리렌더링 발생
- props 변경
- 부모 컴포넌트 리렌더링
- 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 무조건 리렌더링
- 이를 방지하기 위해서는 고차 컴포넌트
React.memo
사용
2.4.3 리액트의 렌더링 프로세스
- 컴포넌트의 루트에서부터 아래쪽으로 내려가면서 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트 호출
- 업데이트가 필요한 경우 클래스형 컴포넌트는
render
, 함수형 컴포넌트는 컴포넌트 자체
를 호출해 결과물을 저장 (⭐️)
- 재조정(Reconciliation): 가상 DOM과 비교해 실제 DOM에 반경하기 위한 보든 변경 사항을 수집 (⭐️)
- 모든 변경 사항을 동기적으로 DOM에 적용
2.4.4 렌더와 커밋
2.5 컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션
렌더링과 메모이제이션 비용을 비교해 어떻게 최적화할 수 있을까?
2.5.1 1️⃣ 섣부른 최적화(premature optimization)는 독이다. 꼭 필요한 곳에만 메모이제이션을 추가하자
- 메모이제이션은 비용이 든다.
- 값을 비교하고 렌더링 또는 재계산이 필요한지 확인하는 비용
- 이전에 결과물을 메모리에 저장해 두었다가 다시 꺼내오는 비용
- 따라서 대부분의 가벼운 작업은 매번 작업을 수행해 결과를 반환하는 것이 빠를 수 있다
- 일단 애플리케이션을 어느 정도 만든 이후 개발자 도구나 useEffect를 활용해 렌더링이 일어나는 지점을 확인하고 필요한 곳에서만 최적화해야 한다.
2.5.2 2️⃣ 렌더링 과정의 비용은 비싸다, 모조리 메모이제이션하자
- 잘못된 memo로 지불해야 하는 비용은 props에 대한 얕은 비교이다.
- memo를 하지 않았을 때 발생할 수 있는 문제
- 렌더링 및 컴포넌트 내부 복잡한 로직 재실행
- 위의 내용을 반복적으로 실행하고, 이전과 현재 트리 비교
- 의존성 배열의 경우, 사용되는 함수의 반환값을 useMemo를로 감싼다면 값이 변경되지 않는 한 같은 참조를 유지해 사용하는 쪽에서 참조의 투명성 유지 가능
2.5.3 결론 및 정리
- 아직 리액트를 배우고 있거나 깊이 이해할 시간이 있다면 1️⃣과 같이 렌더링 여부를 확인하고 크롬 메모리 프로파일러로 분석하면서 이해도를 높이자.
- 현업에서 사용하는 경우, 로직이 들어간 컴포넌트를 메모이제이션하자.
- useCallback은 다른 컴포넌트의 props로 넘어가는 경우에 사용
- useMemo는 props로 넘어가거나 활용할 여지가 있는 경우에 사용