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

리액트 딥다이브 스터디를 현재 진행하고 있습니다. 이 내용을 포스팅하여 공유하고자 합니다.

2.1 JSX란?

JSX란 자바스크리브의 표준의 일부가 아닌 XML과 유사한 내장형 구문입니다.

const Component = (
  <div>
    <input type="text" value="hello" />
  </div>
);

따라서 다음과 같이 아무 처리 없이 쓰면 에러가 발생합니다. 이는 반드시 함수나 클래스 안에 작성해야 합니다.

하지만 함수형 컴포넌트와 React 훅이 더 간결하고 사용성이 좋아 함수형으로 표현하겠습니다.

function Example() {
  return (
    <div>
      <input type="text" value="hello" />
    </div>
  );
}

2.1.1 JSX의 정의

JSX는 기본적으로 JSXElement, JSXAttributes, JSXChildren, JSXStrings라는 4가지 컴포넌트를 기반으로 구성돼 있습니다.

JSXElement

  • JSX를 구성하는 가장 기본 요소로, HTML의 요소와 비슷한 역할을 합니다.

JSXOpeningElement: 일반적인 요소로 이로 시작했으면 JSXClosingElement가 동일한 요소로 같은 단계에 선언이 되어야 올바른 JSX 문법으로 간주합니다.

JSXClosingElement: JSXOpeningElement가 종료됐음을 알리는 요소로, 반드시 JSXOpeningElement와 쌍으로 사용돼야 합니다.

JSXSelfClosingElement: 요소가 시작되고, 스스로 종료되는 형태를 의미합니다. <script />와 동일한 모습을 띠고 있어 내부적으로 자식을 포함할 수 없는 형태를 의미합니다.

JSXFragment: 아무런 요소가 없는 형태로 JSXSelfClosingElement 형태를 띨 수는 없습니다 </>는 불가능합니다. <></>는 가능합니다.

🖍️ 요소명은 대문자로 시작해야하는 것 아니었나?

리액트에서는 HTML 구문 이외에 컴포넌트를 만들어 사용할 때에는 반드시 대문자로 시작하는 컴포넌트를 만들어야 합니다.

리액트에서 HTML 태그명과 구분 짓기 위해서 입니다.

function hello(text) {
  return <div>hello {text}</div>;
}
 
export function App() {
  return <hello text="하이요" />; // 이는 HTML 태그로 인식되어 정상 실행이 되지 않습니다.
}

JSXElementName: JSXElement의 요소 이름으로 쓸 수 있는 것을 의미합니다.

  • JSXIdentifier: JSX 내부에서 사용할 수 있는 식별자를 의미합니다. <$></$> <_></_> 도 가능하지만 $ _ 외의 다른 특수문자로는 시작할 수 없습니다.

  • JSNamespaceName: JSXIdentifier:JSXIdentifier의 조합, 즉 :을 통해 서로 다른 식별자를 이어주는 것도 하나의 식별자로 취급됩니다.

function Valid() {
  return <foo:bar></foo:bar>;
}
 
function Invalid() {
    return <foo:bar:baz></foo:bar:baz> // 불가능
}

JSXMemberExpression: JSXIdentifier.JSXIdentifier의 조합, .을 통해서 서로 다른 식별자를 이어주는 것도 하나의 식별자로 취급됩니다.

function Valid() {
  return <foo.bar></foo.bar>;
}
 
function Valid2() {
  return <foo.bar.baz></foo.bar.baz>;
}
 
function InValid() {
    return <foo:bar.baz></foo:bar.bar> // 불가능
}

JSXAttributes

JSXElement에 부여할 수 있는 속성을 의미합니다. 단순한 속성을 의미하기 때문에 존재하지 않아도 에러가 나지 않습니다.

  • JSXSpreadAttributes: 자바스크립트의 spread연산자와 같은 역할을 합니다.
  • JSXAttribute: 속성을 나타내는 key, value로 짝을 이루어 표현합니다.
  • JSXAttributeValue: 속성의 key에 할당할 수 있는 값으로 다음 중 하나를 만족해야 합니다.

""나 ''로 구성된 문자열 : 안에 아무런 내용이 없어도 상관 없습니다.

JSXElement: 값으로 다른 JSX 요소가 들어갈 수 있습니다. 흔히 보긴 힘들지만 다음과 같이 사용 가능합니다.

function Child({ attribute }) {
  return <div>{attribute}</div>;
}
 
export default function App() {
  return (
    <div>
      <Child attribute=<div>hello</div> /> // prettier 에러입니다.
    </div>
  );
}

JSXFragment: 값으로 별도의 속성을 갖지 않는 형태의 JSX 요소가 들어갈 수 있습니다.

JSXChildren: JSXElement의 자식 값을 나타내며 JSX는 속성을 가진 트리 구조를 나타내기 위해 만들어 졌기 때문에

JSX로 부모 자식 관계를 나타낼 수 있고 이를 JSXChilren이라고 합니다.

JSXStrings: JSXAttributeValue, JSXText는 JSX 사이에 복사와 붙여넣기를 쉽게 할 수 있도록 설계돼 있으며 HTML에서

사용 가능한 문자열은 모두 JSXStrings에서 사용 가능합니다.

2.1.3 JSX는 어떻게 자바스크립트에서 변환될까?

const ComponentA = <A required={true}>Hello World</A>;
const ComponentB = <>Hello World</>;
 
const ComponentC = (
  <div>
    <span>hello world</span>
  </div>
);

이는 babel을 통해 이 처럼 변환합니다.

'use strict'
var ComponentA = React.createElement(
    A,
    {
        required: true,
    },
    'Hello World',
)
 
var ComponentB = React.createElement(React.Fragment, null, 'Hello World')
var componentC = React.createElement(
    'div',
    null,
    React.createElement('span', null, 'hello world')
)
 
type, props, ...children의 값으로 들어갑니다.

따라서 다음과 같이 children의 요소만 달라지는 경우에는 삼항 연산자로 전체를 처리할 필요가 없습니다.

import { createElement, PropsWithChildren } from "react";
 
function TextOrHeading({
  isHeading,
  children,
}: PropsWithChildren<{ isHeading: boolean }>) {
  return createElement(
    isHeading ? "hi" : "span",
    { className: "text" },
    children
  );
}
 

2.2 가상 DOM과 리액트 파이버

리액트의 특징으로 가장 많이 언급하는 것 중 하나는 실제 DOM이 아닌 가상 DOM을 운영하는 것입니다.

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

브라우저가 웹 사이트 접근 요청을 받고 화면을 그리는 과정을 살펴보겠습니다.

  1. 브라우저가 사용자가 요청한 주소를 방문해 HTML 파일을 다운로드 한다.
  2. 브라우저의 렌더링 엔진은 HTML을 파싱해 DOM 노드로 구성된 트리를 만든다.
  3. 2번 과정에서 CSS 파일을 만나면 해당 CSS 파일도 다운로드 한다.
  4. 브라우저의 렌더링 엔진은 CSS도 파싱해 CSS 노드로 구성된 트리(CSSOM)를 만든다.
  5. 브라우저는 2번에서 만든 DOM 노드를 순회하는데 눈에 보이는 노드만 방문한다.
  6. 5번에서 제외된, 눈에 보이는 노드를 대상으로 해당 노드에 대한 CSSOM 정보를 찾고 여기서 바견한 CSS 스타일 정보를 이 노드에 적용한다. 이 DOM 노드에 CSS 적용하는 과정은 레이아웃, 페인팅 과정이 있다.

2.2.2 가상 DOM의 탄생 배경

렌더링 이후 추가 렌더링 작업은 하나의 페이지에서 모든 작업이 일어나는 SPA에서 더욱 많아집니다.

페이지가 변경되는 경우 다른 페이지로 가서 처음부터 HTML을 새로 받아서 다시 렌더링을 하는 페이지와 다르게 하나의 페이지에서

계속해서 요소의 위치를 재계산하게 됩니다. 이러한 DOM의 변경 사항을 모두 추적하는 것은 너무 수고스러운 일입니다.

대부분의 상황에선 결과적으로 만들어진 DOM만 확인하고 싶어할 것입니다.

가상DOM은 웹페이지가 표시해야 할 DOM을 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 브라우저 DOM에 반영합니다.

DOM 계산을 메모리에서 계산하는 과정을 함으로써 브라우저와 개발자가 부담을 덜 수 있습니다.

하지만 많이들 가지는 오해는 가상 DOM이 더 빠르다는 것인데 어플리케이션을 만들기에 충분히 빠른 것이지 기존 방식보다 무조건 더 빠른 것은 아닙니다.

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

가상DOM과 렌더링 과정 최적화를 리액트 파이버가 담당합니다.

리액트 파이버는 작은 단위로 쪼개고 우선순위를 매긴 후 재사용 하거나 폐기하고 중지하거나 다시 시작할 수 있습니다.

이는 모두 비동기적으로 이루어집니다. 파이버는 다음과 같이 작동합니다.

  1. 렌더 단계에서 리액트는 사용자에게 노출되지 않는 모든 비동기 작업을 수행합니다. 여기서 앞서 말한 작업이 일어납니다.

  2. 커밋 단계에서 DOM에 실제 변경 사항을 반영하기 위한 작업 commitWork()가 실행 되는데 이 과정은 동기적으로 일어나며 중단될 수 없습니다.

커밋은 가상 DOM에서의 변경 사항을 브라우저의 실제 DOM에 반영하는 단계입니다

이렇게 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행됩니다.

리액트 파이버 트리

파이버 트리는 리액트 내부에서 두 개가 존재하는데 하나는 파이버 트리, 하나는 workInProgress 트리입니다.

파이버 트리는 현재 브라우저에 렌더링되어 있는 상태를 나타냅니다. UI에 현재 보여지고 있는 모든 컴포넌트는 이 트리의 정보에 기반하여 렌더링됩니다.

WorkInProgress 트리는 새로운 작업이 수행되는 동안 생성되는 트리로, React가 다음 렌더링을 위해 준비 중인 상태입니다.

렌더링 단계에서는 이 트리가 조정되고, 커밋 단계에서 완료되면 WorkInProgress 트리가 현재 트리로 바뀌게 됩니다.

즉 파이버 트리는 햔재 React 컴포넌트의 상태를 나타내고 WorkInProgress 트리는 변경된 부분을 반영하는 작업을 진행하는 트리입니다.

2.3.1 클래스 컴포넌트

import React, { Component } from "react";
 
export default class CounterClass extends Component {
  constructor(props) {
    super(props);
    // state 초기화
    this.state = { count: 0 };
  }
 
  // 카운트 증가 메서드
  incrementCount = () => {
    this.setState({ count: this.state.count + 1 });
  };
 
  render() {
    return (
      <div>
        <p>클래스 컴포넌트 - 카운트: {this.state.count}</p>
        <button onClick={this.incrementCount}>증가</button>
      </div>
    );
  }
}

클래스 컴포넌트에서는 상태 관리를 위해 this.state와 this.setState()를 사용하고,

생명주기 메서드(componentDidMount, componentDidUpdate, componentWillUnmount 등)를 사용해 컴포넌트의 특정 시점에 실행되는 로직을 관리합니다.

class MyComponent extends React.Component {
  componentDidMount() {
    // 컴포넌트가 마운트된 후 실행
    console.log("컴포넌트가 마운트되었습니다.");
  }
 
  componentDidUpdate() {
    // 상태나 props가 업데이트된 후 실행
    console.log("컴포넌트가 업데이트되었습니다.");
  }
 
  componentWillUnmount() {
    // 컴포넌트가 언마운트되기 직전에 실행
    console.log("컴포넌트가 언마운트됩니다.");
  }
 
  render() {
    return <div>클래스형 컴포넌트입니다.</div>;
  }
}

2.3.2 함수 컴포넌트

무상태 컴포넌트를 구현하기 위한 하나의 수단에 불과했던 함수 컴포넌트가 함수에서 사용 가능한 훅이 등장하면서 모두가 사용하고 있습니다.

import React, { useState } from "react";
 
export default function CounterFunction() {
  // useState 훅을 사용하여 상태 선언
  const [count, setCount] = useState(0);
 
  // 카운트 증가 함수
  const incrementCount = () => {
    setCount(count + 1);
  };
 
  return (
    <div>
      <p>함수 컴포넌트 - 카운트: {count}</p>
      <button onClick={incrementCount}>증가</button>
    </div>
  );
}

이 처럼 훅을 사용하면 훨씬 더 간결하게 사용할 수 있는 것을 알 수 있습니다.

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

생명주기 메서드의 부재

함수 컴포넌트에는 클래스 컴포넌트의 생명주기 메서드가 존재하지 않습니다.

함수 컴포넌트는 props를 받아 리액트 요소만 반환하는 반면에 클래스 컴포넌트는 render 메서드가 있는 React.Component를 상속받아 구현하는 클래스이기 때문입니다.

다만 useEffect 훅을 사용하여 생명주기를 비슷하게 구현할 수 있습니다.

하지만 비슷할 뿐이지 같다는 의미는 아닙니다. useEffect는 state를 활용해 사이드 이펙트를 만드는 메커니즘 일 뿐입니다.

함수 컴포넌트와 렌더링된 값

import React, { useEffect, useState } from "react";
 
interface Props {
  user: string;
}
 
function FunctionalComponent(props: Props) {
  const showMessage = () => {
    alert("Hello " + props.user);
  };
 
  const handleClick = () => {
    setTimeout(showMessage, 3000); // 3초 후 실행
  };
 
  return <button onClick={handleClick}>Follow (Functional)</button>;
}
 
class ClassComponent extends React.Component<Props, {}> {
  private showMessage = () => {
    alert("Hello " + this.props.user);
  };
 
  private handleClick = () => {
    setTimeout(this.showMessage, 3000); // 3초 후 실행
  };
 
  public render() {
    return <button onClick={this.handleClick}>Follow (Class)</button>;
  }
}
 
export default function Home() {
  const [user, setUser] = useState("haha");
 
  useEffect(() => {
    setTimeout(() => {
      setUser("hoho");
    }, 2000);
  }, []);
 
  return (
    <>
      <FunctionalComponent user={user} />
      <ClassComponent user={user} />
    </>
  );
}

이 코드에서 함수 컴포넌트는 Hello haha가 나오지만 클래스 컴포넌트는 Hello hoho가 나옵니다.

클래스 컴포넌트는 props를 항상 this로부터 가져오기 때문에 props는 mutable입니다. 따라서 render 메서드를 비롯한 리액트의 생명주기 메서드가 최신 props 값을 항상 참조할 수 있습니다.

반면, 함수형 컴포넌트는 props가 클로저로 캡처되기 때문에, setTimeout이 실행될 때 타이머가 설정된 시점의 props 값을 사용합니다.

이는 불변의 값처럼 동작하며, 따라서 변경된 후의 props 값이 아니라 타이머 설정 당시의 값이 출력됩니다.

클로저 캡처란 함수가 생성된 시점의 외부 변수 값을 기억한다는 의미입니다.

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

렌더링이란 HTML과 CSS 리소스를 기반으로 웹 페이지의 UI를 그리는 과정을 의미합니다.

리액트의 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정을 의미합니다. 리액트도 브라우저와 마찬가지로 이 렌더링 작업을 위한 렌더링 프로세스가 있으며, 이를 이해해야만 합니다.

2.4.1 리액트의 렌더링이란?

애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고

이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미합니다.

만약 컴포넌트가 props와 state와 같은 상태값을 가지고 있지 않다면 JSX에 기반해 렌더링이 일어나게 됩니다.

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

  1. 최초 렌더링

  2. 리렌더링: state변경, props변경, 부모 컴포넌트의 렌더링, Context 값의 변경, forceUpdate 호출, useReducer의 dispatch가 실행되는 경우, key props가 변경되는 경우가

2.4.4 렌더와 커밋

렌더는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말합니다. 즉 컴포넌트를 실행해 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한지 체크하는 단계입니다.

비교하는 것은 크게 type, props, key로 이 세 가지 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크합니다.

그 다음 커밋 단계에서는 렌더 단계의 변경 사항을 실제 DOM에 적용시킵니다.

이 단계가 끝나야 브라우저 렌더링이 발생합니다. 리액트가 커밋 단계에서 업데이트 한다면 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조를 업데이트 합니다.

이 과정에서 클래스 컴포넌트는 componentDidMount, componentDidUpdate 메서드를 호출하고, 함수 컴포넌트는 useLayoutEffect 훅을 호출합니다.

즉 리액트의 렌더링이 일어났다고 해서 무조건 DOM의 업데이트가 일어나지는 않습니다.

컴포넌트를 렌더링하는 작업은 부모가 변경됐다면 props가 변경됐는지와 상관없이 무조건 자식 컴포넌트도 리렌더링 됩니다.

하지만 memo를 추가하면 props가 변경되지 앟았기 때문에 렌더링이 생략됩니다.

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

useMemo, useCallback 훅과 고차 컴포넌트인 memo는 리액트에서 발생하는 렌더링을 최소한으로 줄이기 위해서 제공됩니다.

메모이제이션이란 함수의 결과를 캐싱하여, 같은 입력이 주어졌을 때 이전 계산 결과를 재사용하는 최적화 기법입니다.

하지만 정확히 어떻게 사용하는지에 대해 명확히 답변하기가 어렵습니다. 렌더링 비용과 메모이제이션 비용 중 어떤게 더 비싼걸까?

이는 오랜 논쟁 주제 중 하나입니다.

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

function sum(a, b) {
  return a + b;
}

위와 같이 매우 간단한 연산을 수행하는 함수가 있다고 생각해보자. 이는 메모리에 두었다가 다시 꺼내오는 것보다 매번 이 작업을 수행해 반환하는 것이 더 빠를 수 있다.

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