Leo Jeong
그래서 React가 뭔데요?

나의 엔지니어로서 첫 코드는 React 로 만든 달력 컴포넌트였다. 처음부터 컴포넌트에 손을 댔던 건 아니고, 같이 작업하던 사람이 만든 컴포넌트의 스타일링을 손보는 것부터 시작했다. 그러다 로직을 건드리고, 컴포넌트 클래스를 수정하게 됐다. 아무 것도 모른 채로 extends Component 를 적던 나는 어느새 React, Vue 를 거쳐 Svelte 에 이르기까지 몇 개의 웹 프론트엔드 프레임워크 (라이브러리)를 경험했다. Class 에서 Function Component 로 변화하는 패러다임을 겪으며 나는 React 에 가장 많이 익숙해졌고, 매일 React 코드를 마주하며 일과 여가시간을 보내고 있다. 하지만 매일 React 를 만진다고 해서 내가 그것을 충분히 이해하고 있다고 말할 수 있을까? React 가 무엇이며 어떤 일을 하는지 물어보면 나는 흔들림 없는 확신으로 대답할 수 있는가? 이 글은 이러한 고민에서 시작해, 결과적으로 나 자신에게 다시 한 번 React 를 가르치는 목적으로 작성하였다.

So, what is React?

그래서, React 는 무엇인가? React 의 공식 홈페이지에서는 이렇게 정의하고 있다.

사용자 인터페이스를 만들기 위한 Javascript 라이브러리

좋다. 여기서 우리는 3가지를 알 수 있는데,

  1. React 는 Javascript 를 통해 사용한다. (소스코드 또한 마찬가지로 Javascript 로 작성되었다)
  2. React 는 라이브러리이다.
  3. React 는 사용자 인터페이스, 즉 UI 를 만들기 위해 사용한다.

1번을 읽고 우리는 React 를 Javascript 가 구동가능한 어디서든 사용할 수 있다는 결론을 얻는다. 즉 JS 가 내장된 브라우저는 말할 것도 없고, Node.js 환경에서도 사용할 수 있으며(이것이 Server Side Rendering 이다), 약간의 도움을 얻으면 Native Platform 에서도 사용할 수 있다(그리고 이것이 React Native 가 된다).

2번은 React 가 프레임워크가 아님을 시사하고 있다. 즉 어떤 고정된 용법이 있는 것이 아니라, Javascript 를 할 줄 안다면 자유롭게 활용할 수 있다는 의미가 된다. 물론 React 는 best practice 와 convention 이 많이 있어서, 처음 시작하는 사람도 빠르고 쉽게 배울 수 있다. 상황에 따라 알맞게 다른 코드 혹은 라이브러리, 프레임워크와 혼용 가능하다는 점이 포인트.

3번은 React 의 목적이자 해결 과제를 의미한다. 우리는 UI 개발을 위해 React 를 사용한다. UI 가 무엇인지 디테일하게 짚고 넘어가는 건 이 글의 주제를 벗어나므로 간략하게 말하자면, React로 우리는 사용자가 상호작용 가능한 모든 형태의 visual 한 환경을 구현할 수 있다.

이 내용을 통해서 우리는 이제 React 명함을 받은 셈이다. 그럼 React 가 구체적으로 하는 일은 무엇일까?

How it works

일단 아주 간단한 코드를 써보자.

const element = React.createElement('div', null, 'hello, world!')

ReactDOM.render(
  element,
  document.getElementById('main')
)

이 코드는 main 이라는 id 를 갖는 DOM node 하위에, element 변수가 가리키는 React Element 를 포함한 React tree 를 생성한다. 결과적으로 화면에 출력되는 HTML 은 대략 다음과 같을 것이다.

<html>
  <head>{...}</head>
  <body>
    <div id="main">
      <div>hello, world!</div>
    </div>
  </body>
</html>

React Element 는 DOM Node 혹은 Component Instance(뒤에 나온다)를 가리킨다. 또한 immutable 객체이기 때문에 런타임 내에 임의로 레퍼런스를 유지한 채로 속성을 변경할 수 없다.

Vanilla JS 와 HTML 로 페이지를 만들 때는 직접 html 의 변경을 제어해야 한다. HTML 은 JS 와 맞물려 유기적으로 동작하며 우리가 브라우저에게 건네주는 HTML의 변경 사항이 그대로 브라우저의 렌더링 프로세스에 영향을 준다. React가 관심을 갖는 것은 이 지점이다. 일반적인 상황에서, DOM tree 자체에 대한 연산은 비교적 오래 걸리지 않는다. 문제는 그 다음이다. 브라우저는 변경된 DOM tree 에 맞춰서 reflow 와 repaint 라는 작업을 수행하는데, 이 작업은 큰 비용이 들며 웹 페이지의 퍼포먼스에 영향을 준다.

Virtual DOM

React의 전략은 Virtual DOM 이라는 개념을 활용하는 것이다. Virtual DOM 은 React의 기능이 아니며 일종의 패러다임으로 생각해야 한다. 실제로 Virtual DOM 개념을 활용하는 프론트엔드 라이브러리는 다수가 있다. Virtual DOM 의 개념은 심플하다. 유저가 작성한 코드를 토대로 실제 DOM 에 적용될 변경 사항을 미리 계산해 내는 것이다. 즉 코드로부터 비롯되는 DOM 의 변경사항을 Virtual DOM으로 미리 간접적으로 계산해보고, 최적화된 변경사항만을 실제 DOM 에 적용하여 reflow / repaint 비용을 최소화 하는 것이다.

이 과정은 React Element (Virtual DOM)의 변경 전 후에 대한 비교로 이루어진다. React Element 는 react-dom, react-native 등의 코어 라이브러리를 통해 해석되고 Flat 한 Virtual DOM 으로 만들어진다. 이 Virtual DOM 의 변경 사항을 계산하는 과정을 통해 React 는 DOM 에 적용할 최종 변경 사항을 만들어낸다. 이 과정을 재조정, 혹은 Reconciliation 이라고 부른다. 자세한 과정은 공식 문서에 소개되어 있다. 간단하게 코드로 확인해보자.

만약 어떤 Element 가 다음과 같이 변경된다고 생각해보자.

<!-- from -->
<div>
  <span>1</span>
</div>

<!-- to -->
<div>
  <b>1</b>
</div>

이때 React 는 두개의 Element tree 를 비교한다. 먼저 최상위 Element 는 div type 으로 같고 attribute 도 동일하므로 (여기선 비어있다) 바꾸지 않고 넘어간다. 하위 Element 는 각각 span 과 b 로 서로 다르다. React 는 span element 가 사라지고 b element 가 들어오는 것임을 이해하고, 이 변경사항만 실제 DOM 에 적용한다. 결과적으로 우리의 코드에서는 최상위 div 부터 다시 적었지만, 실제로 DOM 에서 업데이트 된 부분은 span 과 b 가 위치하는 부분 뿐이다.

Component

React 의 또다른 철학은 재사용성독립성이다. 이미 프로그래밍에 익숙한 사람이라면 재사용성과 독립성이 얼마나 중요한 것인지 잘 알고 있을 것이다. 익숙하지 않은 사람을 위해 설명하자면, 재사용성과 독립성은 똑같은 코드의 불필요한 반복 작성, 코드 변경에 따른 예측할 수 없는 side effect를 막으며 이는 곧 버그의 감소와 유지 보수의 용이함으로 이어진다. React 는 Element 를 효과적으로 제어하고 유저의 로직에 독립성을 부여하기 위해 Component 라는 개념을 도입했다.

Component 는 순수한 함수에서 시작한다. 순수한 함수란 동일한 input 에 대해 언제나 같은 output 을 기대할 수 있는 함수를 말한다. React 의 Component 는 props 라는 인자를 받아 React Element 를 반환하는 함수이다. 컴포넌트는 크게 Class Component 와 Function Component 로 나눌 수 있는데, 간단하게 JS Function 으로 작성해보자.

function SimpleComponent(props) {
  return React.createElement(
    'div',
    null,
    `hello, ${props.name}!`
  )
}

주의: Component 의 이름은 언제나 대문자로 시작해야 한다.

주의2: 사실 이 함수는 언제나 Component 로 취급되지는 않는다. 이것이 정말 React 에게 있어서 Component 로서 의미를 가지려면 React.createElement 를 통해 사용되어야만 한다. 만약 이 함수를 단순한 함수로 취급 및 사용할 경우, SimpleComponent 는 그저 React Element 를 반환하는 function 일 뿐이다. 참고

SimpleComponent 는 props 를 받아서, div 와 몇 가지 string 그리고 props 의 name property 를 조합해 만든 React Element 를 반환한다. 이 반환값은 props 가 동일하다면 언제나 예측가능하다. React 를 사용한 프로젝트의 완성도는 모두 Component 를 얼마나 효율적이고 아름답게 설계하느냐에 달려있다. 재사용 가능하고 예측 가능한 Component 가 많을 수록 코드는 간결해지며 수정하기 쉬워진다. 간결하고 고치기 쉬운 코드만큼 좋은 것이 뭐가 또 있을까?

이제 이 Component를 사용해보자.

React.createElement(SimpleComponent, { name: 'leo' }, null)

SimpleComponent 는 Function Component 이므로 위 코드가 실행되면 Simple Component 는 React Element 를 반환한다. 이후에 새롭게 SimpleComponent 로 Element 를 만들면 function 이 다시 실행되면서 새로운 Element 반환하게 된다. 만약 props 가 달라진다면? 당연히 달라진 props 에 따라서 다른 Element 를 반환하고, Reconciliation 이 일어난다.

반면, Class Component 는 Instance 를 갖는다. Instance는 생명주기를 갖는 JS class instance 이다. 아까 React Element 는 DOM Node 혹은 Component Instance 를 가리킨다고 했다. Virtual DOM 의 Reconciliation 을 통해 특정 React Element 의 추가 혹은 삭제가 결정되면 그 Element 가 가리키는 Instance 도 함께 생성 혹은 해제되며, 어떤 React Element 가 Virtual DOM 에 계속 남아있다면 그것이 가리키는 Instance 도 계속해서 살아있다.

class ComplicateComponent extends React.Component {
  render() {
    return React.createElement(
      'div',
      null,
      React.createElement(
        SimpleComponent,
        { name: 'leo' },
        null,
      ),
    )
  }
}

ReactDOM.render(
  React.createElement(ComplicateComponent, null, null),
  document.getElementById('main'),
)

ComplicateComponent 는 Instance 를 만들며 SimpleComponent 의 렌더링을 유발하고, 이때 SimpleComponent 는 React Element 를 반환하게 된다. 최종적으로 React Element 에 대한 Virtual DOM Tree 가 만들어지며, 또한 React Element 는 Reconciliation 과정을 지나며 Instance 를 생성한다. 여기서 만약 ComplicateComponent 가 props 의 name 을 변경할 수 있다면 어떻게 될까? 이 질문은 Component 의 LifeCycle 과 state 에 대한 설명의 시작이 된다.

LifeCycle, State

아까 Instance 는 생명주기, LifeCycle을 갖는다고 이야기했다. Instance 는 연관되는 Element 의 생성, 수정, 삭제에 따라 몇 가지 상태를 갖게 되며 이러한 상태의 흐름을 생명주기라고 한다. 크게 나누면 Mounted, Updated, Unmounted 의 세 단계가 있겠다. Component 는 Instance 에 대한 스펙을 정의해놓는 것이므로, Instance 의 LifeCycle에 따라 동작을 조정하기 위해서는 Component 의 LifeCycle methods 를 활용해야 한다.

class ComplicateComponent extends React.Component {
  componentDidMount() {
    console.log('Nice to meet you :)')
  }
  
  componentWillUnmount() {
    console.log('Bye!')
  }
  
  shouldComponentUpdate() {
    console.log('I need to be update')
    return true
  }
  
  render() {
    ...
  }
}

각 method 의 의미를 자세하게 알고 싶다면 여기를 참고하는 것이 좋다. 요점은 Instance (Component)는 LifeCycle 을 가지며, React Element (Virtual DOM)의 변화에 따라 생성, 수정, 삭제가 일어난다는 것이다. 위에서는 Class Component 이므로 LifeCycle method 를 활용했지만, Function Component 의 경우에는 Hook API(useEffect)를 사용하면 된다.

function SimpleComponent(props) {
  React.useEffect(() => {
    console.log('Nice to meet you :)')
    
    return function cleanUp() {
      console.log('Bye!')
    }
  }, [])
  
  return React.createElement('div', null, `hello, ${props.name}!`)
}

또한 Component Instance 는 상태, State를 가질 수 있다. 이것은 props 와는 구분되는 것으로 Instance 가 독자적으로 가지며 다른 어떤 Instance 도 이에 접근해 값을 수정할 수 없다(간접적인 방법으로는 가능하다). LifeCycle과 State 를 통해 Component 는 비로소 독립된 하나의 살아있는 개체로서 의미를 갖게 된다.

상태의 변경은 크게 두 가지 경로로 일어날 수 있는데,

  1. props 변화에 따른 state 의 변화 (이는 선택적이다)
  2. 사용자로부터의 상호작용에 따른 state 의 변화

production 수준의 프로그램에는 굉장히 다양하고 복잡한 시나리오가 있지만, 최대한 단순하게 요약하자면 이렇게 두가지가 된다. 어떤 경로로든 State 가 변화하면, React 는 해당 Component Instance 에 변화가 생겼음을 인지하고 다시 렌더링한다. 이것은 더 깊은 원리가 필요하다기 보다는 React 의 규칙이자 철학이다. React 는 Data-driven UI 설계를 지향하고 있는데, 이것은 데이터가 업데이트 되면 화면도 업데이트 되어야 한다는 의미다. 따라서 state 가 변경된다면 화면을 다시 그려야 하므로 re-render 가 발생하는 것은 매우 자명해진다.

이어서, 특정 컴포넌트의 re-render 는 해당 Component Instance 의 하위에 해당하는 모든 Instance 의 재랜더링(re-render), 즉 하위 Instance 들의 Update LifeCycle 를 유발한다. 물론 반드시 하위의 모든 Instance 가 re-render 되지는 않으며, 중간에 re-render 를 멈추는 컴포넌트가 있다면(shouldComponentUpdate lifeCycle 이용) 그 하위에 해당하는 Component Instance 는 re-render 가 발생하지 않는다.

다만 Function Component 의 경우에는 약간 사정이 다르다. Function Component 는 Update (re-render)가 곧 해당 function 전체의 재실행을 의미하기 때문에 shouldComponentUpdate 에 해당하는 lifeCycle method 가 없다. 따라서 Function Component 는 상위 Component 로 인해 re-render 될 경우 반드시 하위의 Component 도 re-render 시킨다. 이를 해결하고 싶다면 React.memo HoC 혹은 useMemo hook을 사용하자. Function Component 의 re-render 에 대해 조금 더 자세하게 알고 싶다면 이 글을 참고하는 것도 좋다.

여담: 조금 더 정확하게 설명하자면, Function Component 는 Instance 를 가지는 것이 아니다. 실제로 Function Component 에서는 render 시마다 전체 function 의 execution 이 다시 일어나며, 이들의 LifeCycle 및 State 는 순전히 Hook API 의 closure 기반 설계를 통해 가능한 일이다.

React.zip

이제 앞서 이야기한 내용을 React 사용자 입장에서 거꾸로 요약하면 다음과 같다.

React를 사용할 때, 우리는 일반적으로 Component 를 작성하고 조합한다. 이러한 Component 의 뭉치를 React 에게 던져주면 React 는 Element tree 를 만들어내고 각 Component 의 사용처에 맞게 Instance 를 다수 생성한다. 어떤 Instance 의 상태가 변화하여 Element tree (Virtual DOM)를 변경하게 되면, React 는 Instance 의 LifeCycle, State, render 결과 등을 종합하여 새로운 Virtual DOM 을 만든다(render phase). 그리고 이를 이전의 Virtual DOM 과 비교하는 Reconciliation 을 수행하고(Reconciliation phase), 최적화된 DOM change 를 적용하여 전체 퍼포먼스를 일정 수준 이상으로 유지하게 된다(commit phase).

이것은 결국 우리가 '특정 데이터에 따른 화면의 결과'에만 집중하면 된다는 것을 의미한다. 데이터가 어떻게 변할 때 화면의 어디를 최종적으로 바꾸어야 하는지 우리는 신경쓸 필요가 없다. re-render 를 했지만 결과가 변하지 않는 곳들은 React 가 알아서 DOM 에 적용하지 않기 때문이다. 즉 UI의 변화를 데이터의 변화와 연결지어 이해 및 설계하고, 재사용 가능한 몇 가지 컴포넌트로 분리한 후, 이를 그대로 코드로 옮기면 끝이다. 아마도 이것이 'React'라는 이름이 붙은 까닭이지 않을까?

물론 데이터의 변화에 따른 Component Instance 내외부의 Javascript execution 은 여전히 존재할 수 있으며 이것이 다른 JS 코드의 수행(Reconciliation 도 JS 로 수행되므로)을 방해할 수 있다. 이러한 부분을 최적화하는 것은 매우 중요한 일이지만, 그건 다음에 다루기로 한다.

이상의 내용을 종합하면 React 를 사용할 때 명심할 점을 두 가지로 간추릴 수 있다.

  • 데이터의 흐름을 파악하고, 데이터가 영향을 주는 UI 의 부분을 확인할 것.
  • 재사용 가능한 코드의 범위를 파악하고 이를 컴포넌트로 만들 것.

나는 특히 첫 번째 내용이 매우 중요하다고 생각한다. 모든 React 의 철학은 결국 '데이터' 가 어떻게 'UI'로 변화하는 지를 최대한 예측 가능하도록, 사용자가 실수를 덜 하도록 풀어내는 것에 있다. 이것이 잘 기능하려면 나의 데이터가 어떤 UI 로 승화하는지 React 에게 정확히 알려주어야만 한다. state 를 엉뚱한 곳에 선언하거나 쓸데없는 LifeCycle 이 낭비된다면 React 를 잘 사용하고 있는 것이라고 말할 수 없다.

Conclusion

React 는 상대적으로 젊고 아직 많은 것들이 개발 중에 있는 라이브러리이다. 이미 우리는 Hook API 라는 커다란 변화를 한 번 경험했고, Concurrent mode 나 Server Component 가 production 수준으로 올라오면 또 다른 변화를 경험하게 될 것이다. Convention 은 계속해서 변화하고 있으며, 지금 이 순간에도 기발하고 독창적인 코드들이 쏟아지고 있다. 하지만 React 의 근본 철학은 거듭되는 업데이트 속에서도 변하지 않을 것이다. 왜냐하면 그 본질이란 rerender 시에도 변함없이 그대로 있을 Root Element 이기 때문이다. ;)

모든 종류의 피드백은 환영합니다. 작성을 위해 참고한 문서는 모두 글 중간에 하이퍼링크를 걸어 두었습니다.