프론트엔드/React

React 동작 이해하기

.log('FE') 2021. 10. 18. 17:18
728x90
반응형

https://www.npmtrends.com/

지난 5년간 npm 다운로드수를 확인해 봤습니다. 리액트는 이제 명실상부 프론트엔드 개발에 대표적인 라이브러리로 자리잡은것같습니다. 

버전도 벌써 17.x.x 대 버전으로 꾸준한 업데이트와 최신화가 진행되고 있는걸 확인할 수 있습니다. 그만큼 자바스크립트의 중요성은 더 높아지면서 리액트에 대한 이해를 하는것도 중요해진것같습니다.

그래서 이버 포스팅에서는 리액트가 갖는 주요 특징들 위주로 실제 리액트코드는 어떻게 구성되어있나 확인하면서 리액트의 동작에 대해 알아보려고 합니다.

 

JSX

리액트에서는 JSX 문법을 통해서 자바스크립트파일에 마크업 로직을 넣을 수 있습니다. 이 방식덕분에 UI 별로 컴포넌트를 생성하여 재사용하고 동적인 돔을 쉽게 생성해낼 수 있습니다. 순수 자바스크립트로 이런 재사용성있는 컴포넌트와 동적인 돔을 만들어 내려면 꽤 복잡하고 돔의 구조를 파악하기 어려운 코드로 작성해야만 했습니다.

const body = document.querySelector('body');
const div = document.createElement('div');
div.classList.add('test');
div.innerText = "Hello world";
body.appendChild(div);

// 또는
const body = document.querySelector('body');
body.innerHTML = `
	<div class="text">Hello world</div>
`
const div = document.querySelector('.text');
div.addEventListener('click', () => {console.log('click')});

 

appendChild 메서드는 타겟 엘리면트의 자식요소로 생성한 엘리먼트를 넣어주는 방식이고 innerHTML 역시 동일하게 자식 엘리먼트로 작성한 마크업 엘리먼트를 추가합니다. 둘의 차이가 있다면 innerHTML 은 body 내에 새롭게 html 구조를 덮어 씌우는 형태이고 appendChild 는 해당 돔트리내의 가장 마지막에 엘리먼트를 추가합니다.

 

아무튼 이벤트 추가나 돔생성을 정적으로 하던 동적으로 하려고하던 준비코드가 많이 필요했었습니다. JSX 문법은 좀더 편하게 작성할 수 있습니다.

const element = (
    <div
      className="test"
      onClick={() => console.log("test")}
    >
    	Hello, world
    </div>
)

 

JSX 로 작성했을때 가져갈 수 있는 이점이 하나 더있습니다. 바로 주입공격(XSS - cross-site-scripting) 을 방지 할 수 있습니다. XSS 란 웹 페이지내에 악성 스크립트를 삽입하여 쿠키를 탈취한다던가 특정 이벤트에 대해서 다른동작을 하게 만들거나 하는 공격을 말합니다. 사용자의 정보를 중간에 가로챌 수도있고 서버에 잘못된 스크립트를 저장하게 만들기 때문에 심각한 이슈를 발생 시킬 수 있습니다. 주로 순수 자바스크립트로 작성할때 innerHTML 로 돔을 생성했을때 공격당할 위험성이 높습니다. 

 

React DOM 은 렌더링 하기전에 JSX 에 있는 모든 내용을 이스케이프 처리합니다. 예를 들자면 태그에 사용되는 `<` `>` 이런 것들을 

`&lt;`, `&gt;` 로 치환합니다. 그래서 외부에서 주입된 태그가 있을경우 그냥 문자처리되기때문에 스크립트로 인식하지 않게되어 공격을 방지할 수 있습니다.

 

그렇다면 어떻게 자바스크립트내에서 작성한 마크업이 리액트에서 어떻게 해석되는지 확인해보겠습니다. 만약 JSX 문법을 사용하지않고 리액트에 돔을 추가하려면 React.createElement() 를 호출해야만 합니다.

 

const element = React.createElement(
  "div",
  {
    className: 'test',
    onClick: () => console.log("test")
  },
  'Hello world'
);

 

React.createElement() 는 몇가지 검사를 통해서 리액트가 읽을 수 있는 객체로 만들고 이 객체는 돔을 구성하고 최신상태로 유지하는데 사용합니다.

 

React.createElement()

위의 내용들은 리액트 공식문서에서도 찾아볼 수 있는 내용이고 여기서부터는 실제 소스코드를 까보고 어떻게 객체화를 시키는지 확인해보려고합니다.  createElement 를 호출하면 객체를 리턴한다고 했는데 그 객체를 만들어주는 ReactElement() 라는 함수를 내부에서 리턴해주고 있습니다. 그래서 어떤 객체를 리턴하는지 확인하려면 ReactElement() 함수를 확인해 봐야 합니다.

 

// react/packages/react/src/ReactElement.js 

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };
  ....
  return element
}

 

위와같은 객체를 생성하여 리턴하고 있습니다. 다른 객체들은 매개변수로 넘겨받은 값들을 참조하고있는데 

$$typeof: REACT_ELEMENT_TYPE 은 다른 값을 참조하고있습니다. 주석에 따르면 React 요소로 고유하게 식별하는 값이라고합니다. 

 

// react/packages/shared/ReactSymbols.js

export let REACT_ELEMENT_TYPE = 0xeac7; // 60103

if (typeof Symbol === 'function' && Symbol.for) {
  const symbolFor = Symbol.for;
  REACT_ELEMENT_TYPE = symbolFor('react.element');
}

 

확인해보니 기본값으로 16진법을 사용한 값이 할당되어있습니다. 해당 값을 10진법으로 확인해보면 60103 이란 값을 갖습니다. 일단은 이게 무슨 의미를 갖는 값인지 잘 모르겠습니다. 그 밑에 조건문을 보면 Symbol 이란 원시타입을 사용하고있는데 Symbol 은 자바스크립트에 새롭게 추가된 원시타입중 하나입니다. 

 

Symbol

Symbol 은 유일한 식별자를 만들때 사용할 수 있습니다.

let id = Symbol(); // 새로운 심볼 생성
let id = Symbol("id"); // 심볼에 id 라는 이름을 가진 설명을 붙임

let id1 = Symbol("id");
let id2 = Symbol("id");
console.log(id1 === id2) // false

 

심볼의 특징은 암묵적 형변환이 발생하지않으며 이런 심볼의 특성을 활용하면 숨겨진 프로퍼티를 만들 수 있습니다. 숨김 프로퍼티는 외부코드에서 직접적인 접근이 불가능하고 덮어 쓸수도 없습니다. 때문에 동일한 설명을 가진 심볼을 여러개 만들어도 전혀 다른 값으로 취급합니다.

 

만약 해당 심볼을 가르키길 원하는 경우, 여러 애플리케이션에서 동일한 심볼이름을 활용하여 특정 프로퍼티에 접근해야 하는 경우 Symbol.for(key) 를 활용할 수 있습니다.

 

// 전역 레지스트리에서 심볼을 읽고 만약 심볼이 없다면 새로운 심볼을 생성
let id = Symbol.for("id");

// 동일한 이름을 사용해 심볼을 읽어옴
let idAgain = Symbol.for("id")

console.log(id === idAgain) // true

 

그럼 다시한번 원래 알아보려던 코드를 확인해보겠습니다.

 

// react/packages/shared/ReactSymbols.js

export let REACT_ELEMENT_TYPE = 0xeac7; // 60103

if (typeof Symbol === 'function' && Symbol.for) {
  const symbolFor = Symbol.for;
  REACT_ELEMENT_TYPE = symbolFor('react.element');
}

 

하나씩 다시 확인해보면 기본값이 할당되어있는데 16진법으로 할당되어있습니다. 아마 컴퓨터가 컴파일 하는 과정에서의 연산을 줄이기위한 할당이라고 생각합니다. 10진법일때보다 16진법일때 바이트코드로 컴파일 하는 과정에서 단계를 줄일 수 있기때문입니다. 그리고 심볼객체를 할당하기전에 왜 초기값을 지정했는지 생각해보면 심볼은 새롭게 추가된 원시타입으로 모든 브라우저에서 지원하는 객체가 아닙니다. 때문에 조건문을 통해 심볼객체가 존재하는지 검증하는 과정이 필요했다고 생각합니다.

 

const REACT_ELEMENT_TYPE = symbolFor('react.element');

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,

    type: type,
    key: key,
    ref: ref,
    props: props,

    _owner: owner,
  };
  ....
  return element
}

 

React 에서 JSX 문법을 통해 돔을 자바스크립트 파일 내에서 조작할 수 있는데 그 과정에서 우리가 신뢰를갖고 안정성있게 해당 문법을 사용할 수 있었던 이유중에 하나는 이스케프문자열로의 치환과 심볼객체를 활용하여 해당 돔 객체가 내부에서 작성되었다는것을 검증하는 과정이 있기때문에 가능한것같습니다. 

 

render()

JSX 문법을 React.createElement() 를 통해서 안정적이고 최신화된 객체를 생성하는것을 확인했습니다. 그럼 이 객체를갖고 리액트가 어떻게 렌더링을 하는지 React.render() 를 확인해보려고합니다. 리액트는 가상돔을 메모리에 생성하여 변경사항이 있는 엘리먼트만 변경 후 렌더링 하도록하여 최적화 하는 로직을 갖고있습니다.

 

ReactDOM.render(element, container[, callback])

 

element 는 JSX 문법을 객체로 반환한 값을 넘겨주는 첫번째 인자이고 두번째 인자는 첫번째 인자를 렌더링할 돔을 넘겨줍니다.

 

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

 

CRA 로 프로젝트를 생성하면 src/index.js 에 자동으로 생성되어있는 render 코드입니다. 처음 호출할땐 기존의 DOM 엘리먼트를 교체하고 두번째 호출부터는 React 의 DOM diffing 알고리즘을 사용하여 효율적으로 업데이트를 진행합니다.

 

React 공식문서를 보면 render() 이외에도 비슷하게 동작하는 hydrate() 라는 메소드가 있습니다. 둘의 차이가 공식문서만 봐서는 사실 크게 확 와닿지 않아서 좀더 찾아봤습니다. 일단 hydrate() 는 SSR 처리를 할때 사용할 수 있다고합니다. 이미 서버에서 렌더링된 마크업이 있는 노드를 호출했을경우 노드는 그대로 놔두고 이벤트핸들러만 첨부하는 방식으로 처리하여 빠른 렌더링 처리가 가능하다고 합니다.

 

index.js 에 ReactDOM.render() 를 hydrate() 로 변경하면 아래와 같은 에러가 발생합니다.

 

Warning: Expected server HTML to contain a matching <div> in <div>.

 

이를 해결하려면 클라이언트 서버를 생성하고 서버에서 하이드레이트 하려는 컴포넌트를 미리 생성한 후에 응답해 줄 수 있습니다.

 

app.get('/', (req, res) => {
  const component = ReactDOMServer.renderToString(<App />)
  res.send(component)
})

 

이것은 hydrate 를 이해하기위한 하나의 예시코드로서 작성된것이라 실제 동작에는 추가적인 세팅이 필요할 수 있습니다. 

 

render 라는 메소스도 실제 코드가 어떻게 되어있나 확인해보기위해 소스를 뒤져보는중에 하나 발견한게있는데 render 는 react 18 이후에는 제거될 예정이라고합니다. 그래서 React 18 은 레거시 Root API 와 New Root API 라고하는 두개의 루트 API 를 제공한다고 합니다. 둘다 유지하는 이유는 예상하다시피 버전 업데이트로 인한 크리티컬한 이슈를 피하기 위해서 입니다. 여전히 레거시 방식 사용은 가능하지만 18버전에서부터는 콘솔에 경고가 띄워질 것입니다.

 

왜 새로운 API 를 사용하는지 이유를 알면 render 라는 메소드에 대해 좀더 잘 이해할 수 있을것같습니다.

 

// Legacy API
const container = document.getElementById('app');

// 초기렌더
ReactDOM.render(<App tab="home" />, container);

// 업데이트 하는동안 container 를 계속 넘겨주어야함
ReactDOM.render(<App tab="profile" />, container);



// New API
const container = document.getElementById('app');

const root = ReactDOM.createRoot(container);

root.render(<App tab="home" />);
root.render(<App tab="profile" />);

 

자세한 내용은 여기서 확인할 수 있습니다.

 

아무튼 이러한 이유 때문인지 render 함수에 대한걸 찾으니 깃헙에서도 레거시폴더에 render 메소드가 포함되어 있습니다.

 

// react/packages/react-dom/src/client/ReactDOMLegacy.js

export function render(
  element,
  container,
  callback
) {
  return legacyRenderSubtreeIntoContainer(
    null,
    element,
    container,
    false,
    callback,
  )
}



function legacyRenderSubtreeIntoContainer (
  parentComponent,
  children,
  container,
  forceHydrate,
  callback
) {
	updateContainer(children, fiberRoot, parentComponent, callback);
}

 

이런식으로 기존의 render 메소드는 container 가 변경되지 않았더라도 계속 넘겨주어야 하는 이슈가 있었던것같습니다.

render 를 보통 한번만 호출된 상태로 사용하기때문에 사실 체감상 큰 이슈로 느껴지거나 얼마나 최적화가 될지는 18버전이 나오고나서 확인해봐야 할것같습니다.

 

render 함수는 루트엘리먼트로 넘겨진 컨테이너를 객체로 변환된 JSX 문법을 활용하여 객체로 생성된 가상 돔트리를 갖고 업데이트 하는 방식을 취하는걸 간접적으로나마 확인할 수 있었습니다. 이러한 과정은 꼭 리액트를 사용하지않더라도 구현할 수 있습니다.

How ro write your own Virtual DOM

 

가상돔이 등장하고 이걸 활용하는 이유는 기존의 돔 조작방식일때의 문제점에 대해서 생각해보면 알 수 있습니다. 기존의 돔 조작은 30번의 변경사항이 있을경우 30번의 렌더링이 발생하여 브라우저를 느리게 하는 원인이 될 수 있습니다. 가상돔을 활용하면 이런 30번의 변경사항에 대해서 이미 변경사항이 모두 반영된 돔 트리를 생성하고 이를 반영하기때문에 브라우저에서의 연산횟수를 줄일 수 있습니다.

 

setState 또한 같은 이유로 비동기로 동작합니다. setState 가 비동기로 동작하는 이유는 여러번 state 에 대한 변경사항이 있을때 매번 렌더링을 실행하는것이 아닌 한꺼번에 state 를 변경하는 방식을 사용하고 있습니다. 때문에 setState 를 포함하고 있는 함수 내에서 곧바로 state 값을 확인하려고 하면 변경된 값을 확인 할 수 없습니다.

 

요약정리

  • 리액트에서는 JSX 문법 사용으로 자바스크립트내에서 마크업 문법을 사용할 수 있다.
  • JSX 문법은 바벨에 의해서 React.createElement() 를 호출하여 돔 객체를 생성한다.
  • 이 돔 객체는 랜더 메소드에 의해서 가상돔과 실제 돔을 비교하는 알고리즘이 수행되고 변경사항이 모두 반영된 돔트리를 실제 브라우저에 반영하게 된다.
  • render 메소드는 리액트 18 버전에서는 레거시로 취급될 예정이며 createRoot 를 통해 컨테이너를 최초 한번만 넘겨서 사용할 수 있다.
  • hydrate 는 render 메소드와 기본적으로 동일한 동작을 하지만 업데이트과정에서 이벤트 핸들러만 첨부하여 더 빠른 초기 랜더링을 가능하게 한다.
  • Symbol 객체 사용과 이스케이프로 치환으로 인해 외부 주입 공격에 방어할 수 있고 안정적인 돔 객체생성이 가능하다.

 

728x90
반응형

'프론트엔드 > React' 카테고리의 다른 글

forwardRef 알아보기  (0) 2022.03.11
React Lifecycle - class & hook 2부  (0) 2021.12.17
React Lifecycle - class & hook 1부  (0) 2021.12.16