리액트가 실제로 동작하는 방식
리액트는 사용자 인터페이스 구축을 위한 자바스크립트 라이브러리이며, 리액트의 핵심은 컴포넌트이다.
여기서 중요한 개념 중의 하나가 리액트 DOM 이란 것이다.
리액트 DOM은 웹에 대한 인터페이스이다.
리액트는 컴포넌트를 어떻게 다루는지 알고 있지만, 브라우저와는 전혀 관계가 없고 리액트는 웹을 모른다.
이런 것들은 실제 HTML 요소들을 화면에 표시해주는 리액트 DOM이 고려할 것들이다.
리액트 DOM은 브라우저의 일부인 실제 DOM에 대한 작업을 한다.
이에 반해 리액트는 컴포넌트만 신경쓴다.
props를 관리하고, 부모-자식 컴포넌트간의 통신을 연결해주고, 내부의 데이터인 상태라는 것을 다룬다.
화면에 뭔가를 그리려 할때는 리액트는 리액트 DOM에게 알리고, 리액트 DOM이 새로운 화면과 새로운 컴포넌트, 새출력을 표시할 수 있게 해준다.
즉, 최종적으로 리액트가 하는 역할이란, 가상 DOM이라는 개념을 사용한다.
가상 DOM은 앱이 마지막에 만들어내는 컴포넌트 트리를 결정한다.
예를들어, 상태가 업데이트 되면 이 정보는 리액트 DOM으로 전달되어 갱신 전후의 상태 차이를 인식하고 리액트가 컴포넌트 트리를 통해 구성한 가상 스냅샷인 가상 DOM과 일치하도록 실제 DOM을 조작하는 방법알 알 수 있게 한다.
리액트가 상태나 props, 컨텍스트, 컴포넌트에 변경이 발생하면 컴포넌트 함수가 재실행되고, 리액트는 이를 재평가하지만, 이 재평가가 DOM을 다시 렌더링하는 것은 아님을 알아야 한다.
즉, 컴포넌트 부분, 리액트 부분, 실제 DOM을 구분해야한다.
리액트가 컴포넌트 함수를 다시 실행할 때, 실제 DOM은 리액트가 구성한 컴포넌트의 이전 상태와 트리, 그리고 현재 상태간의 차이점을 기반으로 변경이 필요할 때만 업데이트가 된다.
이때, 이전과 현재의 상태를 가상으로 비교한다는 것은 간편하고 자원도 적게 들기 때문에(메모리 안에서만 발생하기 때문) 실제 DOM을 사용하지는 않는다.
리액트는 가상 DOM과의 비교를 통해 최종 스냅샷과 현재의 스냅샷을 실제 DOM에 전달하는 구조를 갖는다.
즉, 리액트는 이전과 현재의 두 스냅샷간의 차이점을 확인하고 이를 리액트 DOM에 보고하고, 리액트 DOM은 실제 DOM을 업데이트 한다.
(즉, 리액트는 상태 변화가 발생하면 변화된 버전의 가상 DOM을 만들어서 이를 리액트 DOM에 보고하고, 리액트 DOM은 이전 스냅샷과 비교해서 변화된 실제 DOM을 업데이트 한다.)
// 이전에 평가된 결과
<div>
<hi>Hi there!</hi>
</div>
// 새롭게 평가된 결과
<div>
<hi>Hi there!</hi>
<p>This is new!</p>
</div>
위의 예제에서 새로 평가된 결과와 이전 평가된 결과, 즉 두 개의 스냅샷을 비교하고, 리액트 DOM은 실제 DOM을 업데이트 한다. 이때, 기존과 동일한 요소는 건드리지 않고, 새로 추가된 <p>태그만 추가한다.
컴포넌트 업데이트 실행 중
이전 스냅샷에 기반해 작업을 해야할때에는, 이후의 상태 업데이트에 대한 함수를 사용하는 것이 좋다.
// 수정전
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = () => {
setShowParagraph(!showParagraph);
};
// 수정후
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = () => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
};
return (
<div className="app">
<h1>Hi there!</h1>
{showParagraph && <p>This is new!</p>}
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
개발자 도구의 Element 탭에서는 DOM에서 발생한 변경분을 강조 표시해주는데, 바뀐 부분이 강조가 된다.
위 예시 애플리케이션을 실행시키고 button을 클릭하게 되면, 기존의 div, h1 태그와 달리 새로 추가되는 p 태그에 강조 표시가 되는 것을 볼수 있다.
즉, 기존 스냅샷과 동일한 div, h1 태그를 제외하고 새로운 변경사항인 p 태그만이 업데이트 된 것이다.
자식 컴포넌트 재평가 자세히 살펴보기
// 새로 추가되는 <p> 태그의 내용을 DemoOutput으로 분리
// App.js
import React, { useState } from 'react';
import Button from './components/UI/Button/Button';
import DemoOutput from './components/UI/Button/Demo/DemoOutput';
import './App.css';
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = () => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
};
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={false} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
// DemoOutput.js
const DemoOutput = (props) => {
console.log('DemoOutput RUNNING');
return <p>{props.show ? 'This is new!' : ''}</p>;
};
export default DemoOutput;
위 예제에서 App 컴포넌트는 Button의 클릭이벤트가 발생할때마다, 상태가 변경되기 때문에 재실행이 된다.
컴포넌트가 재실행되면 이의 모든 자식 컴포넌트들 역시 재실행, 재평가가 된다. 하지만, 실제 DOM이 다시 렌더링되거나 변경되는 것은 아니다.
DemoOutput 컴포넌트에 고정된 props를 전달하고 있더라도, DemoOutput 컴포넌트는 재실행된다.
그 이유는, 모든 컴포넌트는 반환문이 있고 JSX를 반환하는데, 모든 JSX 요소들은 결국 컴포넌트 함수에 대한 함수 호출과 같다.
즉, props 값이 고정되어 있더라고, App 컴포넌트가 실행되면 DemoOutput 컴포넌트 등 자식 컴포넌트 함수를 호출하고 출력한다.
이것이 자식 컴포넌트들이 다시 실행되고 재평가되는 이유이다.
실제 DOM이 다시 렌더링되거나 변경되는 것은 가상 DOM과 이전 스냅샷 DOM의 차이가 있어야지 업데이트가 된다.
그렇다면 연결된 모든 컴포넌트 함수가 재실행되면 많은 함수가 가상 비교가 된다는 것인데, 성능에 영향을 미치지는 않을까?
-> 전혀 그렇지 않다, 예시에서 봤듯이 DemoOutput은 변경되지 않았으므로 재평가되지 않는다. 단순히 하드 코드된 props만 바뀌었을 뿐이다. 만약 props가 없다면 App 컴포넌트의 상태 변경이 없으므로 출력 결과 역시 바뀌는 것이 없다.
하위 컴포넌트들은 상위 컴포넌트의 상태가 변경되더라도 하위 컴포넌트의 props에 전달되는 값이 변경되지 않는 한 재평가 및 가상 DOM 생성을 하지 않습니다. 이는 리액트의 성능 최적화 메커니즘 중 하나로서 "얕은(prop) 비교" 개념에 기반합니다.
예시 코드에서 DemoOutput 컴포넌트는 props.show 값을 받아와서 특정 문자열을 렌더링하는 역할을 합니다. 하지만 App 컴포넌트의 상태 변화에 따라서 DemoOutput 컴포넌트의 props.show 값이 변하지 않으면, 리액트는 이전과 동일한 props로 DemoOutput을 재평가하며, 가상 DOM을 새로 생성하지 않습니다.
따라서 DemoOutput 컴포넌트 내에서 props.show 값이 변경되지 않는 한, 해당 컴포넌트는 성능 최적화를 위해 재평가와 가상 DOM 생성을 건너뛰게 됩니다. 이는 가상 DOM을 사용하여 성능을 향상시키는 리액트의 핵심 원리 중 하나입니다.
React.memo()로 불필요한 재평가 방지하기
리액트는 이런 식의 실행과 가상 비교 작업에 최적화가 되어있지만, 더 큰 애플리케이션의 경우 최적화가 필요할 수도 있다.
위 예제의 경우, 리액트에게 특정한 상황일 경우에만 DemoOutput을 재실행하도록 지시할 수 있다.
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={false} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
만약, DemoOutput의 prop인 show 값이 바뀔 때만 DemoOutput을 재실행한다고 가정해보자.
먼저, props가 바뀌었는지 확인할 컴포넌트를 지정한 다음에, 랩해주면 된다(?).
React.memo는 함수형 컴포넌트에서만 동작한다.
React.memo는 인자로 들어간 컴포넌트에 어떤 props가 입력되는지 확인하고 입력되는 모든 props의 신규 값을 확인한 뒤 이를 기존의 props의 값과 비교하도록 리액트에게 전달한다.
즉, props의 값이 바뀐 경우에만 컴포넌트를 재실행 및 재평가하게 된다.
만약 부모 컴포넌트가 변경되었지만, React.memo의 인자에 전달된 컴포넌트의 props 값이 바뀌지 않았다면, 해당 컴포넌트의 실행은 건너뛴다.
하지만, React.memo를 남발할 수 없는 이유는 최적화에는 비용이 들기 때문이다.
React.memo 메서드는 App 컴포넌트에 변경이 생길 때마다, DemoOutput 컴포넌트로 이동하여 기존 props 값과 새로운 값을 비교한다.
이때, 기존의 props 값을 저장할 공간이 필요하고, 비교하는 작업도 필요하다.
즉, 컴포넌트를 재평가하는 데에 필요한 성능 비용가 props를 비교하는 성능 비용을 서로 맞바꾸는 것인데, 이는 props의 개수와 컴포넌트의 복잡도, 그리고 자식 컴포넌트의 숫자에 따라 달라지기 때문에 어느 쪽의 비용이 더 높다고 말하는 것은 불가능하다.
만약, 자식 컴포넌트가 많아서 컴포넌트 트리가 매우 크거나, 컴포넌트 트리의 상위에 위치해있어서 전체 컴포넌트 트리에 대한 쓸데없는 재렌더링을 막을 수 있다면 React.memo는 유용하게 쓰일 수 있다.
너무 작은 앱의 경우에는 React.memo는 불필요할 수 있고, 규모가 큰 앱에서는 모든 컴포넌트를 랩핑하는 것이 아닌, 컴포넌트 트리에서 잘라낼 수 있는 몇 가지의 주요 컴포넌트 부분을 선택해서 사용하면 된다.
반대로, 부모 컴포넌트를 재평가 할때마다 컴포넌트의 변화가 있거나 props의 값이 변화할 수 있는 경우에는, 즉 컴포넌트의 리렌더링이 필요한 경우에는 React.memo는 크게 의미를 갖지 못한다.
// App.jsx
import React, { useState } from 'react';
import Button from './components/UI/Button/Button';
import DemoOutput from './components/UI/Button/Demo/DemoOutput';
import './App.css';
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = () => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
};
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={showParagraph} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
// Button.jsx
import React from 'react';
import classes from './Button.module.css';
const Button = (props) => {
console.log("Button Running")
return (
<button
type={props.type || 'button'}
className={`${classes.button} ${props.className}`}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</button>
);
};
export default React.memo(Button);
위 예제 코드에서 Button 컴포넌트는 onClick으로 함수를 전달받고, Text는 정해져있기에 불변하는 값들을 전달받고 있기 때문에 리렌더링이 필요 없다고 생각해서 React.memo를 사용했다.
하지만, 실제 애플리케이션을 동작시키면, App 컴포넌트의 리렌더링이 일어날 때마다 Button 컴포넌트의 "Button Running"이라는 콘솔이 실행되는 것을 확인할 수 있다.abs
이는 JavaScript의 특징을 이해해야하는데, 문자열 또는 숫자와 같은 원시값은 App 컴포넌트가 재실행될 때에도 동일한 값으로 인식이 되지만, 함수와 같은 객체의 경우 내부의 값이 동일하더라도 JavaScript는 이를 동일한 객체로 인식하지 않기 때문이다.
즉, App 컴포넌트에서 Button 컴포넌트로 전달하는 toggleParagraphHandler 함수는 우리가 볼때는 동일한 함수이지만, App 컴포넌트가 재실행될때 마다 새로운 객체로 인식이 되기 때문에 Button 컴포넌트 입장에서는 새로운 prop을 전달받는 것이고 리렌더링을 일으킨다.
useCallback()으로 함수 재생성 방지하기
객체를 생성하고 저장하는 방식을 변경해서 React.memo가 객체 외에 prop 값에도 작동하게 할 수 있다.
useCallback을 이용하는 방법인데, useCallback 훅은 컴포넌트 실행 전반에 걸쳐 함수를 저장할 수 있게 하는 훅이다.
useCallback의 첫 번째 인자로는 저장하고 싶은 함수를 전달하고, 두 번째 인자로는 useEffect와 동일하게 의존성 배열을 전달하면 된다.
// App.jsx
import React, { useState, useCallback } from 'react';
import Button from './components/UI/Button/Button';
import DemoOutput from './components/UI/Button/Demo/DemoOutput';
import './App.css';
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = useCallback(() => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
}, []);
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={showParagraph} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
// Button.jsx
import React from 'react';
import classes from './Button.module.css';
const Button = (props) => {
return (
<button
type={props.type || 'button'}
className={`${classes.button} ${props.className}`}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</button>
);
};
export default React.memo(Button);
위의 예제에서, useCallback의 의존성 배열에 setShowParagraph 업데이터 함수를 전달할 수도 있지만, 리액트가 useCallback을 통해 이 함수는 절대 바뀌지 않는다는 것을 보장하고 있기 때문에, 추가할 필요가 없다.
대신, useCallback을 사용하는 컴포넌트에서의 상태나 props, 컨텍스트를 지정할 수 있다.
즉, 위 애플리케이션을 실행하면, App 컴포넌트가 리렌더링 되더라도, Button 컴포넌트는 리렌더링이 되지 않는다.
useCallback() 및 해당 종속성
그렇다면, useCallback 훅의 두 번째 인자로 전달되는 의존성 배열에는 어떤 것들을 추가해야 할까?
앞선 예제만을 봤을 때에는, 항상 동일한 로직에서 함수가 변경될 일이 없다고 판단할 수 있고, 그렇다면 의존성 배열의 존재가 불필요하다고 느낄수도 있다.
하지만, 자바스크립트에서 함수는 클로저임을 생각해보면 의존성 배열의 존재는 필요한 존재라고 인식할 수 있다.
클로저란? 함수가 선언될 때의 환경을 기억하여, 함수 내부에서 해당 환경의 변수와 스코프에 접근할 수 있는 기능을 말합니다.
즉, 자바스크립트의 함수는 선언되있는 곳의 환경의 변수, 상수를 함수 안에서 사용하는 경우 이를 저장하고 기억해서 접근할 수 있다.abs
만약 useCallback의 첫 번째 인자로 들어있는 함수가 클로저를 사용하고 있다면, 의존성 배열은 필수적인 존재가 된다.
// App.jsx
import React, { useState, useCallback } from 'react';
import Button from './components/UI/Button/Button';
import DemoOutput from './components/UI/Button/Demo/DemoOutput';
import './App.css';
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const [allowToggle, setAllowToggle] = useState(false);
const toggleParagraphHandler = useCallback(() => {
if (allowToggle) {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
}
}, [allowToggle]);
const allowToggleHandler = () => {
setAllowToggle(true);
};
console.log('App RUNNING');
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={showParagraph} />
<Button onClick={allowToggleHandler}>Allow Toggling!</Button>
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
// Button.jsx
import React from 'react';
import classes from './Button.module.css';
const Button = (props) => {
console.log('Button RUNNING');
return (
<button
type={props.type || 'button'}
className={`${classes.button} ${props.className}`}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</button>
);
};
export default React.memo(Button);
위 예제에서는 Allow Toggling이라는 버튼과 Toggle Paragraph라는 두 개의 버튼이 존재한다.
Allow Toggling 버튼의 상호작용은 allowToggle이라는 state를 true로 설정하는 이벤트 핸들러를 가지고 있다.
그리고 Toggle Paragraph 버튼은 allowToggle state의 값에 따라서 이벤트 핸들러가 동작하거나 동작하지 않는 로직이다.
이때, allowToggle을 의존성 배열에 추가해주는 이유는 Toggle Paragraph가 선언될 당시 allowToggle 값을 저장하고 있는데, 만약 의존성 배열에 allowToggle 값이 포함되지 않는다면, Toggle Paragraph 함수 내부에서 참조하고 있는 allowToggle의 값은 항상 Toggle Paragraph가 선언될 당시의 false 값이기 때문이다.
즉, allowToggle 값이 변하게 되면, Toggle Paragraph는 새로운 함수로 생성되어야 하며, true로 변경된 allowToggle 값을 참조해야 한다.
위 애플리케이션에서는 처음 렌더링이 일어날 때와 Allow Toggling 버튼을 클릭했을 때는 "App RUNNING", "Button RUNNING", "Button RUNNING" 이 콘솔에서 확인되지만, allowToggle이 true로 변경된 상태에서 Toggle Paragraph 버튼을 클릭하게 되면 "App RUNNING"과 Allow Toggling 버튼이 가지고 있는 "Button RUNNING"만 콘솔에서 확인할 수 있다.
즉, allowToggle이 true로 변경되었을 때는, 의존성 배열에 의해 toggleParagraphHandler는 새로운 함수로 생성이 되었고 Toggle Paragraph 버튼의 리렌더링이 발생했지만, true로 변경된 이후에는 toggleParagraphHandler 함수는 동일한 함수이기 때문에 리렌더링이 발생하지 않는다.
[자바스크립트 클로져 MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)
[Udemy] React 완벽 가이드 with Redux, Next.js, TypeScript 강의를 듣고 정리했습니다
'TIL' 카테고리의 다른 글
2023.8.24일 TIL (0) | 2023.08.24 |
---|