리액트의 핵심철학

#React

이 시리즈는 김기수님의 <코딩 자율학습 리액트 프런트엔드 개발 입문> 자습서를 보고 공부한 내용을 담고 있습니다. 좋은 책 펴내주셔서 감사합니다😊


리액트의 핵심철학

리액트(React)는 사용자 인터페이스(UI, User Interface)를 구축하기 위한 자바스크립트 라이브러리입니다.

리액트는 틀에 갇혀있지않은 확장성을 강조하기 위해 프레임워크라는 칭하는 것은 지양하고 라이브러리라는 용어를 사용한다고 한다. 오늘 포스팅에서는 자습서에서 소개하는 리액트의 핵심철학인 컴포넌트 기반 아키텍처, 가상 DOM, 선언적 프로그래밍 3가지를 정리해보려고 한다.

컴포넌트 기반 아키텍처

리액트의 가장 큰 특징은 복잡한 UI를 작고 재사용 가능한 컴포넌트로 나누어 개발한다는 점입니다.

이 부분을 보고 컴포넌트라는 개념도 리액트에서 먼저 시작되었나 싶어 Gemini에게 물어봤다. 아니라고 한다.

지금 내가 이해한 바로는, 리액트 이전에도 함수나 파일로 컴포넌트화 하려는 노력은 계속 있었다. 하지만 구현이 힘들었고, 리액트는 그걸 수월하게 해주었다는 것이다.

예를 들어 리액트는 컴포넌트 태그와 간단한 if문 만으로 컴포넌트를 바꿔가며 출력할 수 있는데, 이전에는 그렇게 하기 위해서 js에서 HTML은 모두 새로 그리고 이벤트도 다시 연결해야했다.


리액트 도입 이전

let isLoggedIn = false; // 상태 관리 (전역)

function handleLogin() {
    isLoggedIn = true;
    renderApp(); // 상태 변경 후 반드시 수동으로 렌더링 함수 호출
}

function handleLogout() {
    isLoggedIn = false;
    renderApp(); // 상태 변경 후 반드시 수동으로 렌더링 함수 호출
}

function renderApp() {
    const root = document.getElementById('root');
    root.innerHTML = ''; // 1. 이전 내용을 모두 지움 (비효율적)

    if (isLoggedIn) {
        // 2. 로그인 상태 UI (환영 메시지 + 로그아웃 버튼)
        const welcome = document.createElement('p');
        welcome.textContent = '환영합니다! 사용자님.';
        
        const logoutButton = document.createElement('button');
        logoutButton.textContent = '로그아웃';
        // 3. 수동으로 이벤트 리스너 연결
        logoutButton.addEventListener('click', handleLogout); 

        root.appendChild(welcome);
        root.appendChild(logoutButton);
        
    } else {
        // 4. 로그아웃 상태 UI (로그인 버튼)
        const loginButton = document.createElement('button');
        loginButton.textContent = '로그인';
        // 5. 수동으로 이벤트 리스너 연결
        loginButton.addEventListener('click', handleLogin); 

        root.appendChild(loginButton);
    }
}

// 초기 실행
renderApp();


리액트 도입 이후

import React, { useState } from 'react';

function AuthComponent() {
  // 1. 상태를 useState 훅으로 관리
  const [isLoggedIn, setIsLoggedIn] = useState(false); 

  // 2. 상태를 변경하는 함수 (DOM 조작 로직 없음)
  const handleLogin = () => {
    setIsLoggedIn(true);
  };

  const handleLogout = () => {
    setIsLoggedIn(false);
  };
  
  // 3. UI의 모습을 선언적으로 정의 (JavaScript 표현식을 사용한 조건부 렌더링)
  return (
    <div id="root">
      {/* JavaScript의 삼항 연산자 또는 if 문을 사용하여 UI를 조건부로 분기 */}
      {isLoggedIn ? (
        // A. 로그인 상태일 때의 모습 (별도의 컴포넌트로 분리 가능)
        <> 
          <p>환영합니다! 사용자님.</p>
          <button onClick={handleLogout}>로그아웃</button>
        </>
      ) : (
        // B. 로그아웃 상태일 때의 모습 (별도의 컴포넌트로 분리 가능)
        <button onClick={handleLogin}>로그인</button>
      )}
    </div>
  );
  // 4. 상태(isLoggedIn)가 바뀌면 리액트가 자동으로 A와 B 중 필요한 부분만 효율적으로 업데이트합니다.
}

export default AuthComponent;

컴포넌트 기반 아키텍처는 나중에 나오는 선언적 프로그래밍 내용과 관련이 깊은 것 같다.

가상 DOM

가상 DOM(virtual DOM)은 실제 DOM을 복사한 자바스크립트 객체 형태의 트리 구조로, 메모리에서 관리합니다. 실제 화면에 구성 요소를 추가하거나 변경하면 리액트는 이를 가상 DOM에 먼저 반영합니다. 모든 추가 및 변경이 끝난 뒤에 가상 DOM과 실제 DOM을 비교합니다. 이 과정을 디핑(diffing)이라고 합니다. 디핑 결과에 따라 실제 DOM에는 변경한 부분만 최소한으로 업데이트하며, 이 과정을 재조정(reconciliation)이라고 합니다.

DOM을 업데이트 하면, 화면 렌더링이 일어난다. 다르게 말하면 DOM을 업데이트 할 때 마다, 화면 렌더링이 일어난다. 또 다르게 말하면 JQuery에서는 버튼을 한 번 눌렀을 때 업데이트 되는 요소가 10가지라면 화면 렌더링이 10번 일어나게 된다.

웹을 배우기 시작한 나도 90년대 웹 브라우저를 켜놓고 하염없이 기다리던 경험이 있기 때문에, 이건 안다, 화면 렌더링 비용은 적을 수록 좋다!

리액트는 DOM을 업데이트 할 때 마다 우선 가상 DOM에 내용을 반영한다. 포인트는 가상 DOM은 진짜 화면이 아니라 메모리 상의 데이터 객체라서 실제 렌더링에 비해 속도가 빠르다. 또한 디핑이 끝난 후 마지막으로 반영할 때만 화면 렌더링이 일어난다. 결론을 말하자면 리액트는 버튼을 한 번 눌렀을 때 업데이트 되는 요소가 10가지여도 화면 렌더링은 1번 밖에 일어나지 않는다는 것이다.

추가로 가상 DOM에서 말하는 메모리에 대해 궁금해서 찾아보았다. 웹 브라우저도 프로세스의 한 종류이기 때문에 프로세스 메모리 구조를 가지는데, 가상 DOM에서 말하는 메모리는 이 중 힙 영역의 메모리이다. 웹 브라우저의 힙에 가상 DOM이 생성되어 사용되는 것이다.

선언적 프로그래밍

기존 웹 개발 도구는 대부분 명령형 프로그래밍(imperative programming) 방식을 사용합니다. 이는 UI를 반들 때 ‘어떻게 변경할지(how)‘를 작성하는 방식입니다. … 반면, 리액트는 선언적 프로그래밍(declarative programming) 방식을 따릅니다. 이는 UI를 만들 때 ‘무엇을 보여줄지(what)‘를 선언하는 방식입니다.

나는 Vue로 웹 개발을 시작해서 사실 선언적 프로그래밍 방식에 익숙하다. 그래서 도리어 명령형 프로그래밍 방식의 불편함을 이해하기 위해 코드를 살펴봐야했다. 마치 랜선 컴퓨터에 익숙해서 모뎀 컴퓨터의 불편함을 이해하지 못하는 것 같은 느낌이랄까…


명령형 프로그래밍

<div id="root">
  <p>클릭 횟수: <span id="count">0</span></p>
  <button id="increment-btn">클릭!</button>
</div>

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="app.js"></script>

$(document).ready(function() {
    let count = 0;

    $("#increment-btn").on("click", function() {
        count += 1;
        $("#count").text(count);
    });
});


선언적 프로그래밍

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>클릭 횟수: <span>{count}</span></p>
      <button onClick={handleClick}>클릭!</button>
    </div>
  );
}

export default Counter;

명령형 방식의 코드를 보면 HTML의 id를 가지고 와서 말 그대로 직접 DOM을 조작하는데, 개인적으로는 DOM의 id를 직접 다루지 않는 것만으로도 선언형 방식의 승리인 것 같다. id를 각각 다 관리하고 실수로 스펠링 틀리는 바람에 디버깅하는 동안 헤맬 생각하면 벌써 머리가 아프다.

명령형 방식은 변수 값 변경DOM 업데이트를 모두 직접 처리해야 합니다.

변수 값 변경에 대한 두 방식의 차이를 보면, 우선적으로 보이는 것은 변수가 저장되는 위치이다. 명령형 방식은 let을 가지고 지역 변수로 count를 처리하는 반면, 선언적 방식은 useState를 사용하여 리액트 내부의 상태 저장 공간에 count를 저장해두었다.


명령형 방식

$(document).ready(function() {
    let count = 0;


선언적 방식

 function Counter() {
  const [count, setCount] = useState(0);

이 부분을 보고 처음 떠오르는 생각은 명령형 방식에서는 지역 변수로 값이 있으니까 값을 공유하기가 힘들겠다는 것이였다. 그렇다면 전역 변수 등으로 바꿔 변수의 범위를 확장하면? 그러면 경쟁 조건이 발생될 가능성이 있을 것 같다. 거의 동시에 값 변경이 여러개 요청될 경우, 요청이 제대로 처리되지 못 할 수 있다는 가능성이다.

선언적 방식은 이 두 가지 문제를 알아서 처리한다. 우선 변수가 저장되는 공간이 분리되어있기 때문에, 컴포넌트 내에서는 useState 내부에 저장한 변수는 어디서든 자유롭게 접근이 가능하다. 동기화 문제의 경우는 메서드를 쓰는 방식에 따라 해결이 가능하다. 리액트에서 권장하는 함수형 업데이트 방식을 사용하면, 거의 동시에 변수를 변경하려는 경우에도 경쟁 조건이 발생하지 않고 순차적으로 요청이 처리가 된다.


setCount(count + 1); // 기본 업데이트 방식, 이 지점의 count 값으로 업데이트
setCount(count => count + 1); // 함수형 업데이트 방식, 이전 상태 값을 인수로 받아 업데이트

다음으로 DOM 업데이트에 대한 두 방식의 차이이다. 명령형 방식에서는 변수 값을 업데이트 한 후, 화면에 업데이트를 반영하기 위해서는 DOM의 id를 가져와 그 값을 다시 출력하는 코드를 추가해주어야한다. 하지만 선언적 방식에서는 변수 값만 업데이트하면 화면의 업데이트는 자동으로 반영이 된다. 리액트의 경우 JSX의 원하는 위치에 변수를 넣어주기만 하면 된다.

익숙한 방식이라 잘 못느꼈지만, 이렇게 비교해 보니 UI를 직접 조작하는 코드를 아예 없애버려 휴먼 에러도 방지하고 직관성도 살렸다. 굉장해 엄청나!