본문 바로가기
Frontend/React

프로젝트 5개 하고 돌아보는 React를 쓰는 이유

by 그냥하는거지뭐~ 2024. 4. 4.
목차
- 프롤로그
- SPA
- 브라우저의 렌더링 원리
- React의 렌더링 과정
- React의 특징 3가지
- React+TypeScript

 

프롤로그

 나의 2023년은 프로젝트로 가득했다. 7~8개월 동안 vanilla JS, React, React Native를 이용한 팀프로젝트만 5개를 했더라. 프로젝트를 하면 확실히 해당 기술의 숙련도를 빠르게 향상할 수 있고, 무엇보다 재밌다. 그리고 해당 프레임워크의 장단점을 몸으로 느끼며 그 단점을 보완하기 위해 어떤 또 다른 기술이 등장했는지 흐름을 파악하기에는 프로젝트만 한 것이 없다. 
 
 하지만 눈앞에 놓인 기능 구현에만 집중하다 보면 "진짜 공부"를 놓칠 때가 많다. 예를 들어 React의 렌더링 과정에 대해 알고 코드를 치는 사람과 모르고 치는 사람의 코드 품질 차이는 몇 배가 날 것이다. 이번 기회에 React가 왜 등장했고 왜 쓰는지에 대해 처음부터 제대로 정리해 보려고 한다. 


SPA

SPA는 single page application의 약자이다. 웹사이트가 단일 페이지로 구성되어 있고, 사용자와의 상호작용을 통해 동적으로 컨텐츠를 갱신하는 방식이다. 이와 대비되는 MPA(multi page application)는 새로운 페이지를 로드할 때마다 서버에서 렌더링 된 정적 리소스(HTML, CSS, JS)가 다운로드되며, 페이지를 이동하거나 새로고침을 하면 전체 페이지를 다시 렌더링 한다. SPA는 초기에 모든 HTML, CSS, JS를 가져온 다음, AJAX를 통해 필요한 데이터만 서버로부터 비동기적으로 요청하고 client단에서 특정 부분만 업데이트한다. (CSR)

SPA 구현에 특화된 프레임워크가 바로 React, Vue, Angular이다.
CSR은 SSR에 비해 SEO에 취약하다는 단점이 있어 SEO가 중요한 프로젝트에서는 SSR을 사용하는 Next.js를 많이 쓴다. 


브라우저의 렌더링 원리

브라우저의 렌더링은 Navigation > Response > Parsing > Render의 과정을 거친다. 
 

1. Navigation 

웹페이지를 로딩하는 첫 단계로, 사용자가 주소창에 URL을 입력하거나, 링크를 클릭하고, 폼(form)을 제출하는 등의 동작을 통해 요청을 보낼 때마다 발생한다. 
- DNS Lookup 
- TCP Handshake
- TLS Negotiation

출처: MDN

 

 
2. Response

웹서버와 연결에 성공하면, 브라우저는 웹 페이지를 표시하기 위해 필요한 자료를 요청하기 위해 HTTP GET Request를 보낸다. 요청을 받은 서버는 관련 응답 헤더와 함께 HTML 내용을 바이트(bytes)의 형태로 응답하게 되는데, 브라우저가 HTML의 첫 응답 패킷(14kb)을 받는데 걸리는 시간을 TTFB(Time To First Byte)라고 한다. 
 
 

3. Parsing

브라우저가 첫 번째 데이터의 청크를 받으면, 수신된 정보를 parsing 하는 과정을 거친다. 

① Building the DOM tree
: 브라우저는 HTML 문서를 읽고, HTML 태그들을 노드로 변환하고 노드 간의 계층 관계를 표현하는 DOM tree로 변환한다.
② Preload Scanner
: 사용 가능한 컨텐츠를 분석하고 CSS나 JS, web font와 같이 우선순위가 높은 리소스를 미리 요청한다. 
③ Building the CSSOM
: CSS 파일과 태그 내부의 스타일 정보도 파싱된다. 이 정보는 렌더 트리 구성에 필요한 스타일 정보로 변환되어, 각 DOM 노드에 어떤 스타일이 적용될지 결정한다.

 
 
DOM tree 형성 과정을 좀 더 자세히 알아보자.
- 서버는 HTML 파일을 바이트 스트림의 형태로 전송한다.
- 브라우저는 이 HTML 문서의 바이트 스트림을 지정된 인코딩(주로 UTF-8)에 따라 문자열로 변환한다. 
- UTF-8로 변환된 문자열을 W3C HTML5 표준에 의거하는 토큰(태그 <html>, <body>등, 꺽쇠괄호로 묶인 문자열)으로 변환한다.
- 각 토큰(태그)를 속성과 규칙을 가지는 객체(Nodes)로 변환한다.
- HTML마크업이 여러 태그 간의 관계를 정의하기 때문에 트리 데이터 구조 내에 연결된다. 이 트리구조에는 부모태그와 자식태그의 관계도 포함된다.

 

 
4. Render

Style: Render Tree 생성
: Parsing 과정에서 생성된 DOM, CSSOM tree를 결합하여 렌더 트리를 형성한다. (Attachment)
Layout: Render Tree 배치
: 어떤 노드가 화면에 표시될지 식별하고, 각 객체의 정확한 크기와 위치를 결정한다(레이아웃). DOM manipulation이 일어나면, DOM tree를 수정한다(리플로우).
 Paint: Render Tree 페인팅
: 레이아웃 단계에서 계산된 각 박스를 실제 화면의 픽셀로 변환하고, 화면에 그린다. 

React의 렌더링 과정

왜 가상 DOM을 쓰는가? 

 기본적으로 웹페이지는 사용자와 상호작용하는 interactive site이다. 개발자는 사용자의 행동에 따라 웹 페이지의 구조, 스타일, 콘텐츠를 동적으로 조정하기 위해 JavaScript를 사용해 DOM을 수정한다. 이러한 DOM manipulation은 웹 애플리케이션을 더 인터랙티브하게 만들어 주지만, 과도하면 웹 페이지의 성능을 저하시킬 수 있다. 브라우저는 DOM의 변경사항을 렌더링 트리에 반영하고, 필요에 따라 페이지 레이아웃을 재계산한 후 화면을 다시 그려야 하기 때문이다.(reflow) 또한 요소의 색상이나 테두리 등 style이 변경되면 해당 요소를 다시 그려야 한다. (repaint)
 따라서 성능을 고려한 효율적인 DOM 조작 방법을 사용하는 것이 중요한데, React는 가상 DOM을 사용하여 이러한 문제를 해결한다. 변경 사항들을 가상 DOM에 반영하고 마지막에 한 번에 묶어서 DOM에 반영해서 DOM 조작을 최소화하는 것이다. 변경 사항이 생기면, 즉 리렌더링을 해야 할 상황이 생기면 react는 실제 DOM의 가벼운 복사본인 가상 DOM을 생성하며, 이는 JS 객체 형태로 존재한다. 변경 사항 전과 후의 가상 DOM들을 비교하여 변경된 부분만 실제 DOM에 반영하는 것이다. 이때, 비교 과정에서 diffing 알고리즘을 사용한다. 
 정리하면, React는 가상 DOM을 통해 실제 DOM에 직접적인 변경을 가하기 전에 이루어지는 모든 업데이트를 먼저 가상 DOM에 적용하여, 실제 DOM의 업데이트를 최소화함으로써 성능 최적화를 한다. 


React에서 렌더링이란, 컴포넌트가 props와 state를 통해 UI를 어떻게 구성할지 컴포넌트에게 요청하는 작업을 말한다. 즉, 브라우저의 렌더링은 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그려내는 과정이라면, react에서 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정이다. React의 렌더링 과정은 render phase와 commit phase로 나뉜다. 이 구분은 React 16 버전에서 도입된 Fiber 아키텍처에 기반을 두고 있다. 

 

Step 1: Render Phase

이 단계는 React가 컴포넌트 트리를 탐색하고 변화를 결정하는 단계이다. 
 
① 컴포넌트의 렌더 함수 호출
React는 루트 컴포넌트부터 시작해서 아래쪽으로 가며 업데이트가 필요한 컴포넌트(플래그가 지정되어 있음)를 찾고, 해당 컴포넌트가 클래스 컴포넌트면 classComponentInstance.render()를, 함수형 컴포넌트의 경우 FunctionComponent()를 호출하고, 렌더링된 결과를 저장한다.
 
이 렌더링된 결과물은 JSX(Javascript XML)로 작성되어 있다. JSX는 HTML과 비슷한 문법을 사용해 컴포넌트의 구조를 쉽게 작성할 수 있게 해 주지만, 브라우저는 JSX를 직접 이해할 수 없기 때문에 Babel과 같은 트랜스파일러를 통해 React element로 변환해야 한다.
 
다음과 같은 JSX 코드가

const element = <h1>Hello, world!</h1>;

런타임 시점에 다음과 같이 변환되어

const element = React.createElement(
  'h1',
  null,
  'Hello, world!'
);

 
다음과 같은 호출 결과를 return 한다. 

{
  "type": "h1",
  "props": {
    "children": "Hello, world!"
  },
  "key": null,
  "ref": null
}

 
이와 같이 React.createElement에 의해 생성된 객체가상 DOM을 구성하는 기본 단위라고 볼 수 있다. 
 
② 가상 DOM 생성
React는 컴포넌트의 상태가 변경될 때마다 해당 컴포넌트의 가상 DOM을 새로 생성한다. 가상 DOM은 실제 DOM의 가벼운 복사본으로, 실제 브라우저의 DOM 구조와 유사한 메모리 기반의 구조이다.
 
Diffing 알고리즘을 통한 변화 감지
새로운 가상 DOM과 이전 가상 DOM을 비교하여 변화를 감지하는 과정으로, React는 트리 비교 알고리즘을 사용하여 효율적으로 두 가상 DOM 사이의 차이를 찾아낸다.
 
렌더 단계는 중단되거나 다시 시작될 수 있기 때문에, 이 단계에서는 실제 DOM에 어떠한 변경도 반영하지 않는다. 즉, 이 단계에서의 작업은 순수한 계산 과정이라고 할 수 있다.
 

Step 2: Commit Phase

커밋 단계는 렌더 단계에서 준비된 변경사항을 실제 DOM에 반영하는 단계이다. 이 과정에서 실제 DOM 노드의 추가, 삭제, 업데이트가 일어난다. 또한 사용자의 행동에 따라 DOM manipulation이 발생하면 reflow(레이아웃 계산)와 repaint(화면 갱신) 과정이 일어난다. 커밋 단계는 한 번 시작되면 중단될 수 없으며, 모든 변경사항이 실제 DOM에 반영될 때까지 일어난다. 이 단계 이후에 사용자에게 업데이트된 UI가 보이게 된다.


React의 특징 3가지

1. 선언적 UI(Declarative UI)

선언형 UI가 뭘까? How가 아니라 What에 집중한다. 코드는 무엇을 해야 하는지를 설명하고 그 방법은 react에 맡긴다. 즉, 동작 원리보다 무엇을 할지에 초점이 맞춰진 것이다. React 공식 문서에서도 애플리케이션의 각 상태에 대한 간단한 뷰만 설계하면 React가 데이터가 변경됨에 따라 적절한 컴포넌트만 효율적으로 갱신하고 렌더링 한다고 적혀있다. 
 
선언형 UI가 얼마나 편한지 명령형 UI와 비교해 보자. 
 
선언형 UI (React)

import React, { useState } from 'react';

function MessageToggle() {
  const [showMessage, setShowMessage] = useState(false);

  const toggleMessage = () => {
    setShowMessage(!showMessage);
  };

  return (
    <div>
      <button onClick={toggleMessage}>Toggle Message</button>
      {showMessage && <p>Hello, World!</p>}
    </div>
  );
}

export default MessageToggle;

버튼을 클릭할 때마다 toggleMessage메서드가 호출되어 상태가 업데이트되고, React는 이 상태에 따라 UI를 자동으로 업데이트한다. 여기서 개발자는 UI가 어떤 상태를 기반으로 어떻게 보여야 하는지를 "선언"한다. 직관적이고 간결하다. 
 

명령형 UI (vanilla JS)

<button id="toggleButton">Toggle Message</button>
<div id="messageContainer"></div>

<script>
  document.getElementById('toggleButton').addEventListener('click', function() {
    var messageContainer = document.getElementById('messageContainer');
    if (messageContainer.innerHTML === '') {
      messageContainer.innerHTML = '<p>Hello, World!</p>';
    } else {
      messageContainer.innerHTML = '';
    }
  });
</script>

버튼을 클릭할 때마다 DOM을 직접 조회하고 조작하여 메시지를 표시하거나 숨긴다. 여기서 개발자는 "어떻게" DOM을 변경할 것인지를 단계별로 명시해야 한다. 이 명령형의 방식은 세밀한 조작이 가능하지만, 프로젝트의 규모가 커질수록 관리하기 힘들어진다는 단점이 있다. 
 
filter, map, reduce 등의 메서드도 선언형이라고 할 수 있다. 메서드 안에 동작이 서술되어 있기 때문에 개발자는 구체적인 절차는 신경 쓸 필요 없이 메서드를 그대로 선언해 사용하기만 하면 된다. 
선언형의 장점을 정리해 보자. 우선 가독성이 좋고 유지보수하기가 좋다. 또한 개발자가 수동으로 돔을 조작하는 게 아니라 프레임워크나 라이브러리 등에 맡김으로써 버그가 감소하는 효과도 있다. 


 

2. 컴포넌트 기반 아키텍처

스스로 상태를 관리하는 캡슐화된 컴포넌트를 만들고, 이를 조합해 복잡한 UI를 만드는 컨셉이다. 컴포넌트 로직은 템플릿이 아닌 JavaScript로 작성되므로 다양한 형식의 데이터를 앱 안에서 손쉽게 전달할 수 있고, DOM과는 별개로 상태를 관리할 수 있다. 
 
재사용을 극대화하기 위한 폴더 구조에 관한 고민을 담은 글이니 읽어보길 바란다. 

 

[TaskStock] Atomic Design Pattern 도입기

프롤로그 프로젝트 TaskStock 기간 기획, 개발 : 2023.11.7~ 베타테스트 : 2024.2.22~3.14 정식 출시 : 2024.3 플랫폼 iOS, Android 언어 FE: TypeScript / BE: JavaScript 프레임워크 FE: React Native / BE: Node.js Express 라이브러

hwanheejung.tistory.com

 
 

3. 함수형 프로그래밍 

리액트의 함수 컴포넌트는 함수형 프로그래밍에서 아이디어를 가져왔다. 우선 OOP와 FP의 차이점을 짚고 넘어가자. 

  1. OOP: 객체를 다루는 프로그래밍 방식으로, 여기서 객체는 내부 상태들을 갖고 있으며 이 상태들을 수정할 수 있는 메서드의 호출 모음이 포함된 작은 캡슐이다. 이 객체들 간의 상호작용을 통해 애플리케이션을 구성하며, 상속과 다형성 같은 개념을 사용하여 코드의 재사용성을 높인다.
  2. FP: 함수형 프로그래밍은 상태 변화나 데이터 변경을 피하고, 순수 함수(pure functions)를 사용하여 함수 간의 데이터 흐름으로 애플리케이션의 동작을 설명한다. FP에서는 데이터가 불변(immutable)하며, 같은 입력에 대해 항상 동일한 출력을 반환하는 순수 함수를 중심으로 로직을 구성한다. 어떤 프로그램을 실행했을 때 부수 효과(side effects)를 최소화하고, 모든 입력에 대해 정확한 결과를 반환해 프로그램의 예측 가능성을 높인다.

데이터를 관리하기에 어떤 방법이 더 편한지 아직 와닿지 않는다. 코드로 비교해 보자. 
다음은 OOP 방식으로 구현한 class component이다. 

//Class Component
class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
    };
  }

  componentDidMount() {
  }

  incrementCount = () => {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    <h1>Count: {this.state.count}</h1>
    <button onClick={this.incrementCount}>Increase</button>
  }
}

컴포넌트의 상태는 컴포넌트 객체에 등록되어 있으며, 그 상태는 메서드에 의해 변화하고 변화된 상태에 따라 다른 결과를 보여주게 된다. 자신의 상태 즉, 변경될 수 있는 자료구조를 가지고 있기 때문에 어느 시점에 어떤 결과가 나오게 될지 예상할 수 없다. 
 
그럼 FP 방식의 함수형 컴포넌트를 보자. 

//Functional Component
function MyComponent({count}) {
  //컴포넌트 상태를 어떻게 관리하지?
  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

 
props로 받은 count(입력)에 대해 UI(정확한 결과)를 반환한다. 하지만 프로그래밍을 하다 보면 부수 효과가 필수적으로 끊임없이 일어나는데, 어떻게 관리할까? 데이터 가져오기, 구독 설정하기, 수동으로 리액트 컴포넌트의 DOM을 변경하는 행위 등 부수 효과를 관리하기 위해서 react 팀은 hook을 도입했다. 

import React, { useState, useEffect } from 'react';

function FetchDataComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('https://api.example.com/data')
      .then(response => response.json())
      .then(data => setData(data));
  }, []); // 빈 배열을 전달하여 컴포넌트 마운트 시에만 실행되도록 함

  return (
    <div>{data ? <p>{data}</p> : <p>Loading...</p>}</div>
  );
}

useEffect hook을 사용하여 데이터를 가져오는 부수 효과를 관리했다. 


React+TypeScript

 JavaScript는 동적 타입 언어이므로 개발자의 의도에 맞지 않는 형변환이나 오류가 발생할 수 있다. 렌더링 과정에서 데이터가 굉장히 중요한데, 단순 JavaScript로는 데이터의 타입을 다루기가 힘들다. TypeScript가 활성화되기 전 이 문제를 극복하기 위해 JSDoc(주석, 강제성 없음), propTypes와 같은 도구들을 사용했다. 
 
TypeScript의 등장
정적 타입 언어에서는 모든 변수의 타입이 컴파일 타임에 결정된다. (유저가 코드를 실행하는 런타임에 발생하는 것이 아님) TS 코드에 에러가 있으면, JS로 컴파일되지 않는다. TS를 JS로 변환하기 위해서는 babel이나 tsc(typescript compiler)와 같은 컴파일러를 사용해야 한다. 
 
TS를 사용하면, 
- 코드에 버그가 줄어든다
- 런타임 에러가 줄어든다
- 생산성이 늘어난다
 


Reference
브라우저 렌더링 원리
https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work
https://oliviakim.tistory.com/80
React의 렌더링 과정 
https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/
https://react-ko.dev/learn/render-and-commit
React의 특징
https://ko.legacy.reactjs.org/
https://medium.com/@4538asd/react-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EA%B3%BC-usestate-312a5e5a3c70

'Frontend > React' 카테고리의 다른 글

React의 최적화 전략  (0) 2024.04.11
React의 원칙: Immutable Data Pattern  (4) 2024.04.11