Skip to content

리액트, 나의 동반자

Published:

프론트엔드 개발을 한다면 당연히 브라우저의 표준 스크립트언어인 자바스크립트를 사용하여 개발을 할것이고 많은 분들이 자바스크립트를 활용한 웹 프레임워크를 같이 사용할 것입니다, 현재 웹 생태계에서는 프론트엔드 개발을 위한 수많은 자바스크립트 프레임워크가 존재합니다. 가장 많이 사랑받는지?는 의문이지만 리액트는 제가 처음 개발을 시작할 때부터 지금까지 가장 인기가 많은 프레임워크입니다.

저한테 그 많은 선택지 중 왜 리액트냐? 라고 묻는다면 다소 식상한 대답이지만 저 뿐만 아니라 대부분의 사람들도 아마 같은 대답을 할 것이라 생각합니다. 입문하기에 가장 무난하기 때문이죠. 생태계가 크고 대부분의 회사에서 채택하기 때문에 리액트를 배우는 것은 현대 프론트개발 입문에 있어 필수적인 요소로 자리잡고 있습니다.

허나 처음 프론트엔드 개발을 시작했던 그 때와 달리 지금도 그 질문에 똑같은 대답을 한다면 좋은 프론트엔드 개발자가 아니라고 생각합니다. 지난 2년간의 리액트 개발을 통해 어떤 점을 배웠고 느꼈는지 그리고 왜 리액트를 게속 사용하는지 리액트의 중요한 특징과 함께 돌아봅니다.

라이브러리? 프레임워크?

리액트는 공식 문서에서 다음과 같이 정의됩니다.

The library for web and native user interfaces

먼저 주목할 점은 리액트는 라이브러리라는 것입니다. 리액트를 사용하다보면 라이브러리보다는 프레임워크의 느낌이 더 강한데 말이죠. Vue.jssvelte.js는 웹 프레임워크로 분류되는 반면 왜 리액트는 스스로 라이브러리라고 칭하는 것일까요?

라이브러리와 프레임워크의 결정적 차이점은 코드에 대한 제어권(Inversion of Control)의 주체입니다. 개발자는 프레임워크의 코드에 의해 제어되지만 라이브러리는 개발자가 코드를 제어할 수 있게 해줍니다. 하지만 React가 제공하는 메소드와 개념이 꽤 방대하기 때문일까요? 리액트로 코드를 작성하다보면 코드의 제어에 대한 주체가 리액트에 있다는 느낌을 받을 때가 많습니다.

저는 또 다른 키워드인 web and native에 주목을 하였는데요. 리액트는 단순 웹에 국한되어 있는 패키지가 아닙니다. 모바일 환경 개발을 위한 React Native도 있기 때문이죠. 또한 웹도 react-dom과 react패키지로 나누어져 있습니다. 이러한 모듈화된 구조가 리액트가 라이브러리로 불리는데에 중요한 역할을 한다고 생각합니다.

리액트 패키지의 구조

react 패키지:

  • 이는 React의 핵심 라이브러리입니다.
  • 컴포넌트의 정의, 상태 관리, 생명주기 메서드 등 React의 핵심 기능을 포함합니다.
  • 플랫폼에 독립적입니다. 즉, 웹, 모바일, 데스크톱 등 어떤 환경에서도 사용할 수 있습니다.

react-dom 패키지:

  • 이 패키지는 React를 웹 브라우저의 DOM과 연결합니다.
  • ReactDOM.render() 같은 메서드를 제공하여 React 컴포넌트를 실제 DOM에 렌더링합니다.
  • 웹 특화 기능을 담당하므로, 웹 애플리케이션 개발에만 필요합니다.
  • react-dom/client와 react-dom/server 패키지로 나누어 클라이언트와 서버에 대한 처리를 모두 담당합니다.

react-native 패키지:

  • 이는 React를 모바일 플랫폼(iOS, Android)에 연결합니다.
  • 네이티브 모바일 UI 컴포넌트와 API를 제공합니다.
  • 모바일 앱 개발에 사용되며, 웹 개발에는 필요하지 않습니다.

이렇게 개별적으로 분리된 패키지는 각 환경에 필요한 부분만 사용할 수 있어 효울적이며, 핵심 로직(react)는 여러 플랫폼에서 재사용 가능합니다. 만약에 리액트가 프레임워크였다면 통합된 패키지로 제공되었을 가능성이 높고 크로스 플랫폼 지원이 제한적이었을 수 있습니다. 또한 상태관리, 라우팅등 별도의 라이브러리(예: Redux, React Router)를 통해 해결해야하는 부분은 개발자의 선택에 달려있습니다.

하지만 위에도 언급했다시피 리액트는 분명 프레임워크적 특성을 보이기도 합니다. 저희는 리액트로 개발하면서 알게모르게 수많은 리액트의 규칙을 따르고 있습니다.

  1. 컴포넌트 기반 아키텍처 안에서
  2. 리액트의 단방향 데이터 흐름을 따라야하고(부모 => 자식)
  3. 생명주기 메서드와 훅을 활용하며
  4. JSX문법을 사용합니다.

따라서 저는 리액트가 프레임워크냐 라이브러리냐에 대한 정의는 리액트의 공식적 입장을 따르되 프레임워크와 라이브러리 2가지 특성을 모두 가진다고 생각합니다.

리액트의 선언적 프로그래밍

제가 처음 사용한 버전은 리액트 16버전이었습니다. 현재는 18버전까지 왔고 19버전 출시를 앞두고 있지만 버전이 업그레이드 되더라도 리액트가 이루고자 하는 목적은 항상 동일하다고 생각합니다. 리액트는 개발자로 하여금 어떻게가 아니라 무엇을 원하는지 명시하게 합니다.

무엇을 명시하는 프로그래밍, 선언적(declarative) 프로그래밍이라고도 하죠. 어떻게 할지를 단계별로 지시하는 명령형(imperative) 프로그래밍과 반대되는 개념입니다. 제가 단순히 리액트의 개념을 배우고 적용하면서 개발한 수많은 UI들이 모두 리액트의 선언적 프로그래밍의 목적 아래 탄생한 결과물들이죠. 저희는 이것을 의식하면서 개발하지 않았지만 알게 모르게 리액트의 도움을 받고 있습니다. 리액트는 어떠한 방식으로 선언적으로 UI를 그릴 수 있게 도와주는 것일까요?

전체적으로 얘기하자면 리액트에서 선언적 프로그래밍은 컴포넌트를 통해 구현됩니다. 개발자는 각 상태에 대한 UI를 선언하고, 리액트는 실제 DOM 업데이트를 리액트의 렌더링 방식에 따라 효율적으로 처리합니다. DOM 업데이트를 위해 DOM API를 활용하여 조작하는 방법도 있지만 이러한 명시적인 코드를 선언적으로 작성하기 위해서는 개발자의 노력이 많이 들어갈 것입니다. 반면 리액트가 상태에 대한 DOM 업데이트를 처리하는 과정은 라이브러리 내부의 로직에 의해 실행됩니다. 이 어떻게 과정을 크게 신경쓰지 않을 수 있죠.

함수형 컴포넌트와 Hooks

함수형 컴포넌트와 Hooks은 실무적으로 가장 많이 사용하는 API입니다. 리액트의 컴포넌트를 작성하는 방법은 함수형과 클래스형이 있는데요. 함수형 컴포넌트로 개발을 시작한 저는 클래스형 컴포넌트를 사용한 경험이 많지않아 피부로 느끼는 각 컴포넌트의 장단점을 알긴 어렵지만 함수형 컴포넌트가 클래스 컴포넌트를 대체하기 시작한 이유가 꽤 궁금해졌습니다. 왜 리액트는 클래스 컴포넌트를 두고 함수형 컴포넌트를 새로 만들고 Hooks를 도입한 것일까요?

저는 처음에 그 이름에 이유가 있다고 생각했습니다. 함수형 컴포넌트의 도입이 순전히 함수형 프로그래밍의 특성을 강화하기 위한 것이라고 생각했습니다. 함수형 프로그래밍이 선언적 프로그래밍의 대표적인 패러다임이니, 리액트가 더 선언적인 방식을 추구하기 위해 이러한 변화를 선택했다고 여겼죠.

하지만 이는 반만 맞는 생각입니다. 사실, 클래스형 컴포넌트도 함수형 컴포넌트와 같이 React의 선언적 특성을 충분히 구현하고 있었기 때문입니다. 다음 예시의 코드를 통해 확인해볼까요?

// 클래스형 컴포넌트
class ClassCounter extends React.Component {
  state = { count: 0 };

  increment = () => {
    this.setState(prevState => ({ count: prevState.count + 1 }));
  };

  render() {
    return (
      <Card>
        <CardHeader>
          <CardTitle>클래스형 컴포넌트</CardTitle>
          <CardDescription>선언적 특성 예시</CardDescription>
        </CardHeader>
        <CardContent>
          <p className="mb-4 text-2xl font-bold">카운트: {this.state.count}</p>
          <Button onClick={this.increment}>증가</Button>
        </CardContent>
      </Card>
    );
  }
}

// 함수형 컴포넌트
function FunctionCounter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>함수형 컴포넌트</CardTitle>
        <CardDescription>선언적 특성 예시</CardDescription>
      </CardHeader>
      <CardContent>
        <p className="mb-4 text-2xl font-bold">카운트: {count}</p>
        <Button onClick={increment}>증가</Button>
      </CardContent>
    </Card>
  );
}

상태 선언:

  • 클래스형 컴포넌트: state = { count: 0 };
  • 함수형 컴포넌트: const [count, setCount] = useState(0);

두 방식 모두 상태를 선언적으로 정의합니다. 개발자는 상태가 어떻게 관리되는지 신경 쓰지 않고, 단순히 상태가 존재한다는 것만 선언합니다.

UI 선언:

  • 클래스형 컴포넌트: render() 메서드 내부
  • 함수형 컴포넌트: 반환값

두 방식 모두 JSX를 사용하여 UI를 선언적으로 정의합니다. 개발자는 DOM을 직접 조작하지 않고, 원하는 UI 구조를 선언합니다.

이벤트 핸들링:

  • 클래스형 컴포넌트: <Button onClick={this.increment}>
  • 함수형 컴포넌트: <Button onClick={increment}>

두 방식 모두 이벤트 핸들러를 선언적으로 연결합니다. 개발자는 이벤트가 발생했을 때 어떤 함수가 호출되어야 하는지만 선언합니다.

상태 업데이트:

  • 클래스형 컴포넌트: this.setState(prevState => ({ count: prevState.count + 1 }));
  • 함수형 컴포넌트: setCount(prevCount => prevCount + 1);

두 방식 모두 상태 업데이트를 선언적으로 처리합니다. 개발자는 새로운 상태가 어떻게 계산되어야 하는지만 선언하고, 실제 업데이트 프로세스는 React에 맡깁니다.

클래스형 컴포넌트의 한계

클래스형 컴포넌트가 리액트의 선언적 목적을 달성하는 데 문제가 없었다면, 왜 리액트 팀은 함수형 컴포넌트와 Hooks를 도입했을까요? 클래스형 컴포넌트의 한계에서 그 이유를 찾을 수 있습니다.

  • 복잡성: 클래스형 컴포넌트는 this 키워드의 사용, 바인딩 문제, 생명주기 메서드의 복잡성 등으로 인해 초보자들이 이해하기 어려웠습니다.

  • 로직 재사용의 어려움: 고차 컴포넌트(HOC)나 Render Props 같은 패턴은 로직 재사용을 위한 해결책이었지만, 이로 인해 컴포넌트 트리가 복잡해지는 “래퍼 지옥” 문제가 발생했습니다.

  • 최적화의 어려움: 클래스 컴포넌트의 생명주기 메서드는 때때로 관련 없는 로직들을 하나의 메서드에 모아놓게 만들어, 코드 분할과 최적화를 어렵게 만들었습니다.

함수형 컴포넌트와 Hooks의 장점

함수형 컴포넌트와 Hooks의 도입은 이러한 문제들을 해결하고자 하는 노력의 결과였습니다

  • 간결성과 가독성: 함수형 컴포넌트는 더 간결하고 읽기 쉬운 코드를 작성할 수 있게 해줍니다. this 키워드의 복잡성도 제거되었습니다.

  • 로직의 재사용성: Hooks를 사용하면 상태 관련 로직을 쉽게 재사용할 수 있습니다. 또한 커스텀 Hooks를 사용하면 컴포넌트 간에 로직을 공유하기가 훨씬 쉽습니다.

  • 관심사의 분리: useEffect와 같은 Hooks를 사용하면, 이전에 생명주기 메서드에 흩어져 있던 관련 로직들을 한 곳에 모을 수 있습니다.

  • 테스트 용이성: 함수형 컴포넌트와 Hooks는 순수 함수에 가깝기 때문에 테스트하기가 더 쉽습니다.

저는 로직의 재사용성관심사의 분리가 함수형 컴포넌트와 hooks의 가장 큰 장점이라고 생각합니다. 컴포넌트의 jsx를 통한 viewhooks를 통한 상태에 대한 로직을 분리함으로서 유지보수가 용이한 코드를 더욱 쉽게 작성할 수 있기 때문입니다.

Rules of Hooks

훅(use로 시작하는 함수들)은 컴포넌트의 최상위 수준 또는 커스텀 훅에서만 호출할 수 있습니다.

제가 생각하는 hooks의 핵심입니다. 이는 리액트의 기본 린트 설정에도 들어가 있을 만큼 리액트가 강조하는 규칙중 하나입니다. hooks의 규칙에 따르면 hooks는 반복문, 조건문 혹은 중첩된 함수 내에서 호출될 수 없습니다. 이 규칙을 지키는 것이 왜 중요한지를 알아야합니다.

useState과 같은 Hooks을 쓰다 보면 리액트가 각 변수에 대한 값을 어떻게 추적할 수 있는지 궁금한 적이 있지 않나요? 코드상으로는 그 어떤 식별자도 전달하지 않거든요.

const [count, setCount] = useState(0);
const [name, setName] = useState("");

비밀은 바로 호출 순서에 있습니다. React는 내부적으로 각 컴포넌트에 대한 상태 쌍의 배열을 유지합니다. 렌더링할 때마다 이 배열의 인덱스를 0으로 초기화하고, useState를 호출할 때마다 다음 상태 쌍을 제공하고 인덱스를 증가시킵니다. 이 메커니즘에 대해서는 React Hooks: Not Magic, Just Arrays.을 통해 더 자세히 알 수 있습니다. 따라서 컴포넌트 최상단에 호출하는 규칙을 지키지 않을 경우 렌더링 마다 호출 순서가 깨져 hooks들이 기대한 바 작동하지 않을 것입니다.

함수로서의 리액트

함수형 컴포넌트와 Hooks가 클래스형 컴포넌트를 대체한 이유와 특징과 장점에 대해 충분히 알았습니다. 하지만 여전히 함수형이라는 키워드에 주목할 필요가 있습니다.

리액트는 함수형 컴포넌트를 도입한 이후 순수함수로서의 컴포넌트를 많이 강조합니다. 동일한 input에 대해 항상 동일한 output을 반환한다는 점이 순수함수의 가장 중요한 목적인데요. 이를 리액트에 적용해보면 하나의 컴포넌트에 대하여 동일한 props와 state에 대해 항상 동일한 view(UI)를 반환해야한다는 것입니다.

리액트는 또한 상태의 불변성(immutability)를 강조합니다. 상태를 직접 변경하는 것이 아닌 반드시 setState 함수를 통해서 변경해야하는데요. 리액트는 얕은 비교를 통해 컴포넌트의 리렌더링 여부를 결정하기 때문에 참조값의 경우 주소가 다른 새로운 객체를 생성하여 setState의 인자로 전달해야합니다. 리액트는 불변성을 유지하여 성능최적화와 예측 가능한 상태 관리의 이점을 취하고 이는 순수함수(pure function)의 개념과도 잘 어울립니다.

UI = f(state)

저는 결국 리액트의 본질은 상태에 따른 뷰를 그리는 역할이라고 생각합니다. 그리고 이 식은 리액트의 본질을 함수형 프로그래밍의 원칙아래 잘 녹여내었습니다.

종합하여..

하지만 이는 반만 맞는 생각입니다.

이 문장이 말하고자 했던 바는 함수형 컴포넌트와 Hooks의 도입이 단순히 선언적 프로그래밍을 강화하기 위한 것이 아니라, 리액트의 전반적인 개발 경험을 개선하고 함수형 프로그래밍의 이점을 더 잘 활용하기 위한 선택이었다는 것입니다.

useEffect에 대한 심층탐구

useEffect 혹은 리액트를 개발하면서 제 개인적으로 가장 애증하게 되는 hook인것 같습니다. 실제 개발하다보면 useEffect가 가장 디버깅하기 어렵고 실제로 버그도 많이 납니다. 사람들은 useEffect을 최대한 줄이면서 코드를 작성하는 것을 지향하지만 실제로는 꽤 쉽지 않습니다. useEffect의 역할이 절대 작은 역할이 아니기 때문이죠. 리액트를 오래 안전하게 개발하기 위해서는 useEffect를 자세히 알아볼 필요성이 있습니다.

리액트의 생명주기(life cycle)

useEffect가 어떻게 탄생했는지 알아봅시다. 그럼 먼저 리액트의 생명주기에 대해서 알아봐야 할 필요성이 있습니다. 리액트 컴포넌트의 생명주기는 컴포넌트가 생성되고, 업데이트되며, 제거되는 전체 과정을 말합니다. 이 생명주기는 크게 세 단계로 나눌 수 있습니다: 마운팅(Mounting), 업데이트(Updating), 언마운팅(Unmounting). 이들은 렌더 페이즈(Render Phase)와 커밋 페이즈(Commit Phase)라는 두 가지 주요 단계를 거치게 됩니다.

Hook이 탄생하기 전 클래스 컴포넌트에는 생명주기의 각 단계에서 필요한 로직을 수행하기 위해 관련된 여러 메소드가 있었습니다. 렌더링 단계에 맞는 생명주기 메소드를 살펴봅시다.

렌더 페이즈(Render Phase)

렌더 페이즈는 React가 가상 DOM(Virtual DOM)을 생성하고 이전 상태와 비교하는 단계입니다. 이 단계에서 실행되는 생명주기 메소드들은 다음과 같습니다:

  • constructor()
  • getDerivedStateFromProps()
  • shouldComponentUpdate()
  • render()

이 단계에서 React는 순수하고 부작용이 없는 연산만을 수행합니다. 즉, DOM을 직접 조작하거나 네트워크 요청을 보내는 등의 부수 효과(side effect)를 발생시키지 않습니다.

커밋 페이즈(Commit Phase)

커밋 페이즈는 실제 DOM을 변경하고 부수 효과를 실행하는 단계입니다. 이 단계에서 실행되는 생명주기 메소드들은 다음과 같습니다:

  • componentDidMount()
  • componentDidUpdate()
  • componentWillUnmount()

이 단계에서 React는 렌더 페이즈에서 계산된 변경사항을 실제 DOM에 적용하고, 필요한 부수 효과(예: 데이터 페칭, DOM 조작 등)를 실행하는데 위의 생명주기 메소드들을 사용합니다.

리액트의 생명주기를 이해하기 위해 도표를 보시는 것도 추천드립니다.

Effect의 의미

먼저 useEffect자체의 의미를 주목할 필요가 있습니다. Hook을 나타내는 use 키워드 옆에 명시되어 있는 effect는 그 자체로 리액트의 부수 효과(side effect)를 온전히 담당하는 역할을 뜻합니다.

리액트의 핵심은 위에서도 설명했다시피 순수성(purity)에 있습니다. 하지만 현실 세계의 어플리케이션은 복잡한 인터랙션을 가지고 이러한 순수성만으로는 완성될 수 없습니다. 데이터를 서버에서 가져오고, DOM을 조작하며, 여러 Web API와 상호작용하는, 이러한 작업들은 모두 부수효과(side effect)를 발생시킵니다. useEffect의 effect는 이 부수효과를 일컫는 단어입니다.

useEffect hook은 commit phase의 생명주기 메소드들을 대체합니다. 해당 메소드들을 하나의 API로 통합하면서 코드가 간결해지고 하나의 useEffect안에서 관련 로직을 모아 관심사의 분리가 용이해졌습니다.

Effect는 기본적으로 모든 커밋(계산된 변경사항을 실제 DOM에 적용하는 단계) 이후에 실행됩니다.

// 클래스형 컴포넌트
class Example extends React.Component {
  componentDidMount() {
    console.log("컴포넌트가 마운트되었습니다");
  }

  componentWillUnmount() {
    console.log("컴포넌트가 언마운트됩니다");
  }

  render() {
    return <div>예제 컴포넌트</div>;
  }
}

// 함수형 컴포넌트
import React, { useEffect } from "react";

function Example() {
  useEffect(() => {
    console.log("컴포넌트가 마운트되었습니다");
    return () => {
      console.log("컴포넌트가 언마운트됩니다");
    };
  }, []);

  return <div>예제 컴포넌트</div>;

주의점

useEffect는 반드시 주의할 점이 몇개 있습니다. 이는 제가 다년간의 골치아픈 디버깅을 통해 알아낸 소중한 팁입니다. 😅

  1. 반드시 그 목적에 맞게 사용할 것

    • React 공식 문서에 따르면:
    • Effects는 특정 이벤트가 아니라 렌더링 자체로 인해 발생하는 부수 효과를 지정할 수 있도록 해줍니다.

    • 이는 useEffect가 특정 이벤트(클릭, 제출 등)에 의해 발생하는 것이 아닌, 렌더링 자체로 인해 발생하는 사이드 이펙트를 처리하기 위한 것임을 의미합니다.
    • 동시에, useEffect의 핵심 목적은 React 컴포넌트를 외부 시스템과 동기화하는 것입니다. 이 두 개념은 밀접하게 연결되어 있습니다:
      1. 렌더링으로 인한 사이드 이펙트: 컴포넌트가 화면에 나타나거나, 업데이트되거나, 사라질 때 발생하는 작업
      2. 외부 시스템과의 동기화: React 상태와 외부 세계(DOM, API, 타이머, 구독 등) 간의 연결 관리
    • useEffect는 “컴포넌트의 렌더링으로 인해 외부 시스템과 동기화가 필요할 때” 사용하는 Hook입니다. 이벤트 핸들러가 “사용자 상호작용에 대응”하는 것과 달리, useEffect는 “렌더링 결과에 대응”하여 외부 시스템과의 동기화를 처리합니다.
    • 즉, useEffect는 특정 이벤트(클릭, 제출 등)가 아닌 렌더링 자체로 인해 발생하는 사이드 이펙트를 지정하기 위한 것입니다
  2. exhaustive-deps lint 규칙을 반드시 따를것. 이 규칙은 의존성 배열에 필요한 모든 값(반응형 값)이 포함되어 있는지 확인합니다.

    • 누락된 의존성으로 인한 버그를 방지합니다.
    • 의도치 않은 무한 루프를 예방합니다.
    • 컴포넌트의 동작을 예측 가능하게 만듭니다.
  3. 의존성 배열(dependency array)에 mutable한 값을 넣지 말것

    • 리액트는 얕은 비교(shallow comparison)를 사용하여 의존성이 변경되었는지 확인합니다.
    • mutable한 값은 실제 의도한 값이 동일하더라도 참조하는 주소값이 바뀌어 리액트가 변경을 감지하여 불필요한 렌더링을 유발할 수 있습니다.
    • 따라서 참조값의 경우 컴포넌트 외부에 선언하거나 effect 로직 내부에 선언하는 것을 권장합니다. 혹은 useMemouseCallback과 같은 메모이제이션을 활용하여 불변성을 유지하세요.
  4. data fetching시 race condition을 고려할 것

  5. clean up 함수를 적극 사용하여 리소스 누수를 방지하고, 언마운트 시 필요한 정리 작업을 수행할 것

useEffect의 단순 주의점 뿐만 아니라 어떻게 사용하면 좋은 방법인지는 리뉴얼된 리액트 공식문서에서 자세히 다뤄줍니다. 리액트 개발에 익숙하신 분들이라면 더 유익하게 보실 수 있을 것입니다.(꼭 보세요!)

Virtual DOM

Virtual DOM은 한 때 리액트 개발자의 면접 단골 문제 중 하나였습니다. 저 역시 취업을 위해서 그 정의를 달달 외웠던 기억이 있는데요. 리액트에 대해 익숙해진 지금 Virtual DOM을 다시 돌아볼 필요가 있습니다.

Virtual DOM이 무엇인가요?

상태에 따른 UI를 그리는게 리액트의 본질이라고 설명을 해드렸죠. 단순 리액트 뿐만 아니라 상태에 따라 화면을 그리는 일은 경우에 따라 큰 비용을 발생시킬 수 있습니다. DOM을 그리는 과정인 reflowrepaint 등이 너무 자주 실행되고 그 규모가 클수록 비용이 커지죠. 리액트는 이러한 비용을 최적화 하기 위해 Virtual DOM을 고안한 것입니다.

Virtual DOM은 리액트가 구현하는 자바스크립트 메모리 상의 객체입니다. 쉽게 말하자면 리액트가 만드는 실제 DOM 트리의 가벼운 복사본입니다.

Virtual DOM에 대한 오해

처음 리액트 개발을 배울 때 Virtual DOM의 성능에 강력한 이점이 있는 것으로 알았습니다. 하지만 이는 오해입니다. 리액트는 사실 오롯히 자바스크립트 런타임에만 구현되어 컴파일러가 있는 다른 웹 프레임워크에 비해서는 성능적으로 앞서지 않습니다. 스벨트의 창시자인 Rich Harris는 Virtual DOM이 빠른 것은 오해라고 주장하였죠. 그렇다면 Virtual DOM이 안 좋은 것일까요?

사실 Virtual DOM을 바라보는 관점이 잘못되어 있다고 생각합니다. 왜 그럴까요? 리액트가 Virtual DOM을 활용하여 렌더링 프로세스를 효율적으로 처리하려는 방식에 대해 알아봅시다.

  1. 렌더링 단계 (Render Phase): React는 컴포넌트의 render 함수를 호출하여 Virtual DOM 트리를 생성합니다. 이 과정에서 이전 Virtual DOM 트리와 새로운 트리를 비교합니다.
  2. 재조정 (Reconciliation): Virtual DOM 트리 간의 차이를 찾아내는 과정입니다. React는 효율적인 diffing 알고리즘을 사용하여 변경된 부분만을 식별합니다.
  3. 커밋 단계 (Commit Phase): 실제 DOM에 변경사항을 적용하는 단계입니다. 이 단계에서 React는 필요한 DOM 조작을 수행합니다.

재조정 과정의 diffing 알고리즘을 자세히 살펴볼까요? Diffing 알고리즘은 아래와 같은 작업을 합니다.

  • React Element의 타입(JSX 태그 종류) 비교
  • 타입이 동일할 경우 속성(attribute) 비교
  • key 값 비교
  • 재귀적으로 자식 Element 비교

그리고 리액트는 효율적인 비교를 위해 여러가지 가정과 휴리스틱을 사용합니다.

  • 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낼 것이라 가정합니다.
  • 개발자가 key prop을 통해 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 하는지 표시해 줄 수 있다고 가정합니다.

리스트 컴포넌트를 만들 때 순회하는 요소에 대하여 key props를 반드시 고유한 값으로 명시해야하는 이유에 대한 단서도 diffing 알고리즘에서 발견할 수 있습니다.

리액트는 애플리케이션의 규모가 커짐에 따라 재조정과정을 더욱 효율적으로 처리하기 위해 React Fiber Architecture을 도입하였습니다. React Fiber는 렌더링 작업을 작은 단위로 나누고 우선순위를 부여함으로 써, 작업들의 우선순위를 지정하고 일시 중지, 재개, 또는 폐기할 수 있게 해줍니다. 또한 여러 작업들의 일괄처리(batch)도 수행하여 렌더링 프로세스의 효율성을 높여줍니다. 이 아키텍처는 리액트 18의 주요 업데이트 내용인 Concurrent React Mode의 기반이기도 합니다.

리액트는 이렇게 최소한의 DOM 조작으로 UI를 효율적으로 업데이트하려는 목적 아래 내부 로직을 구현합니다. Virtual DOM은 현대 웹 프론트엔드 기술의 정답이 아닐뿐더러 리액트의 렌더링 프로세스를 구현하는 하나의 도구일 뿐입니다. 더 이상 리액트가 공식 문서에서도 다루지 않는 이유이기도 합니다. Virtual DOM 자체에 지나치게 집중하기보다는, 리액트가 이를 통해 이루고자 했던 본질적인 목표를 이해하는 것이 더 중요합니다.

리액트의 다양한 생태계

리액트는 그 인기와 함꼐 정말 다양한 생태계를 가지고 있습니다. 지금도 매일 리액트를 활용한 라이브러리 또는 프레임워크들이 하루가 멀다하고 출시됩니다. 리액트는 이미 내장된 메소드로도 충분히 값어치가 있지만 개발을 하면서 여전히 더 고민하고 욕심이 나는 부분들이 있습니다. 제 경험상 크게 다음과 같습니다.

  1. 어떻게 상태를 더 효율적으로 관리 할 것인가?
  2. 어떻게 UI단위의 컴포넌트를 더 효율적으로 관리하여 생산성을 높일 수 있을까?
  3. 어떻게 리액트 코드 기반의 웹 성능을 더 최적화 할 수 있을까? 등이 있습니다.

그리고 각 문제를 어떻게 해결할지에 대한 고민은 이미 많은 개발자들이 거친 과정이고 그 고민에 대한 결과가 여러 third-party 라이브러리나 프레임워크로 존재합니다.

1. 상태 관리의 다양한 선택지

상태 관리에 관해서는 정말 다양한 선택지가 존재합니다.

  • Redux, Recoil과 같은 전역 상태 관리 라이브러리들은 각각의 특징을 가지고 있습니다:

    • Redux는 Flux 패턴의 상태관리 도구로 대형 어플리케이션에 강점을 보이며
    • Recoil은 atomic 패턴의 상태관리 도구로 비교적 간단합니다.
  • 서버 데이터 관리를 위한 Tanstack Query(구 React Query)나 SWR은:

    • 캐싱, 재시도, 낙관적 업데이트 등의 기능을 제공하여 서버 상태 관리를 획기적으로 단순화했고
    • 서버 상태와 클라이언트 상태를 명확히 구분하여 관리할 수 있게 해주었습니다
  • 폼 처리를 위한 React Hook Form이나 Formik은:

    • 복잡한 폼 상태와 유효성 검사를 선언적으로 관리할 수 있게 해주며
    • 불필요한 리렌더링을 최소화하여 폼 성능을 최적화할 수 있게 해줍니다

2. UI 컴포넌트 관리

UI 컴포넌트 관리와 관련해서도 다양한 도구들이 존재합니다.

  • Material-UI, Chakra UI, Tailwind UI 같은 디자인 시스템 라이브러리들이 있어 일관된 UI를 빠르게 구현할 수 있습니다.
  • Shadcn UI와 같은 headless UI를 활용한 UI 컴포넌트는
    • 스타일링과 로직을 분리하여 높은 커스터마이징 자유도를 제공하고
    • 접근성(a11y)이 고려된 견고한 기능을 제공하며
    • 복사-붙여넣기 방식으로 코드를 소유할 수 있어 번들 크기 최적화에도 도움을 줍니다.
  • Storybook을 통해 컴포넌트를 문서화하고 독립적으로 테스트할 수 있습니다.

3. 성능 최적화

성능 최적화를 위한 도구들도 풍부합니다.

  • Next.js나 Remix 같은 프레임워크로 서버 사이드 렌더링(SSR)을 구현하여 초기 로딩 성능을 개선할 수 있고
  • Tanstack Query의 캐싱 전략이나 React.memo, useMemo, useCallback 같은 메모이제이션 기법으로 불필요한 리렌더링을 방지할 수 있으며
  • Webpack이나 Vite 같은 번들러를 통해 코드 분할(Code Splitting)과 지연 로딩(Lazy Loading)을 구현할 수 있습니다.

생태계 활용의 균형점

이처럼 리액트 생태계는 개발자들이 마주하는 다양한 문제들에 대한 해결책을 제시합니다. 하지만 이런 풍부한 생태계가 때로는 역설적으로 개발자들에게 혼란을 줄 수 있습니다. “무엇을 써야 할까?”라는 선택의 고민이 생기는 것이죠. 또한 지나친 양의 부수적 개발 도구들은 피로감을 유발하기도 합니다. 따라서 프로젝트의 요구사항과 팀의 상황에 맞는 적절한 도구를 선택하는 것이 중요합니다. 새로운 도구를 도입할 때는 다음과 같은 점들을 고려해야 한다고 생각합니다.

  • 왜 필요하고 해당 도구가 실제로 해결하고자 하는 문제가 무엇인지
  • 도구 도입에 따른 러닝 커브와 유지보수 비용
  • 커뮤니티의 활성화 정도와 장기적인 지원 가능성
  • 팀 내 기술 스택과의 호환성

이러한 생태계의 홍수 속 무작정 다 수용하거나 무작정 다 배척하는 것 보다는 근본적인 문제 해결에 집중하는 균형 잡힌 시각을 가지는 것이 바람직하다고 생각합니다.

마치며..

제가 리액트의 쏟은 수 많은 시간들을 풀어쓰려하니 글이 길어진 것일까요? 사실 말할 내용이 더 있을 수도 있겠지만 글이 너무 길어질 것을 우려하여 리액트에 대해 더 다룰 수 있는 내용은 추후에 다른 글로 써보려합니다.

자 그럼 처음의 질문으로 돌아가 지금의 저한테 물어볼까요?

왜 리액트를 쓰나요?

리액트는 상태에 따른 UI를 선언적으로 그릴 수 있게 도와주는 라이브러리로서 지난 2년간 저의 프론트엔드 개발을 도와준 동반자였습니다. 리액트가 제공하는 아키텍처, 규칙, 여러 함수, 그리고 풍부한 생태계를 통해 저는 UI를 개발할 때 “어떻게”가 아니라 “무엇을” 개발할 지 집중합니다. 개발자에게 코드를 잘 작성하는 것은 중요하지만 그보다 더 중요한 것은 문제를 해결하는 능력입니다. 리액트는 그 본질에 충실합니다.

여전히 발전하고 있는 리액트 생태계를 꾸준히 따라가는 것은 재밌지만 꽤 피곤하기도 합니다. 하지만 앞으로도 당분간 리액트는 저의 동반자일 것이고 리액트를 통해 더 나은 사용자 경험을 만들어내는 개발자로 성장해 나가고 싶습니다.


출처