리액트는 상태, props, 컨텍스트 변경시에만 컴포넌트를 재실행하고 재평가한다.
import React, { useState } from "react";
import "./App.css";
import Button from "./components/UI/Button/Button";
function App() {
const [showParagraph, setShowParagraph] = useState(false);
console.log("APP RUNNING");
const toggleParagraphHandler = () => {
setShowParagraph((prev) => !prev);
};
return (
<div className="app">
<h1>Hi there!</h1>
{showParagraph && <p>This is new paragraph!</p>}
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
페이지가 로드되면 App 컴포넌트는 화면에 최초로 렌더링된다. 첫 렌더링을 하면서 리액트는 div와 h1, 버튼이 필요함을 알게된다. 컴포넌트 첫 렌더링 시 이전 스냅샷은 존재하지 않는다.
차이점을 비교하는 과정에서 div, h1, 버튼이 재렌더링된다. 그리고 이 정보는 리액트 DOM 패키지로 전달되어 화면에 렌더링 결과가 표시되고 콘솔에 APP RUNNING
이 찍힌다.
이제 버튼을 눌러보자. 콘솔에 APP RUNNING
이 다시 찍힌다. state가 변경되었기 때문에 컴포넌트 전체가 재실행되고 재평가 되는 것이다.
즉, 토글 버튼을 클릭할 때마다 컴포넌트 전체가 재실행되어 재평가되기 때문에 콘솔에 console.log("APP RUNNING");
이 찍히는 것을 확인할 수 있다.
이것이 실제 DOM에는 어떤 영향을 줄까?
real DOM에서는 <p>
만 강조표시되며 변화하는 것을 볼수 있다.
h1 버튼이나 다른 요소들은 변하지 않는 것을 확인할 수 있다.
실제 DOM을 통한 업데이트에는 가상 스냅샷 간의 차이점만 반영된다.
import React, { useState } from "react";
import "./App.css";
import Button from "./components/UI/Button/Button";
import DemoOutput from "./components/Demo/DemoOutput";
function App() {
const [showParagraph, setShowParagraph] = useState(false);
console.log("APP RUNNING");
const toggleParagraphHandler = () => {
setShowParagraph((prev) => !prev);
};
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={showParagraph} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
const DemoOutput = (props) => {
console.log("DemoOutput RUNNING");
return <p>{props.show ? "This is new Paragraph!" : ""}</p>;
};
export default DemoOutput;
<p>
는 항상 있지만 props.show
에 따라 표시되는 문자열이 달라지고 있다. <p>
요소의 문장 부분만 변경되는 것을 확인할 수 있다.
토글 버튼 클릭시 props
가 변경되기 때문에 개발자 탭을 확인하면 리액트가 계속해서 비교작업을 하고 업데이트를 한다.
실제 변경은 자식 컴포넌트인 DemoOutput에서 발생하지만 props.show
로 보내진 상태를 관리하고 있는 App 컴포넌트 역시 재실행, 재평가되어서 콘솔에 DemoOutput RUNNING
뿐만 아니라 APP RUNNING
도 찍힌다.
show props에 false를 보내어 연결을 끊어보자.
<DemoOutput show={false} />
개발자 창을 보자. realDOM에서는 아무런 변경사항이 없다.
콘솔창을 보자. props.show의 값이 false로 고정되어 있어서 props가 바뀌지 않는데도 컴포넌트가 재평가, 재실행되어 APP RUNNING
, DemoOutput RUNNING
이 콘솔에 찍히고 있다.
왜 DemoOutput 컴포넌트가 재실행된 것일까?
setShowParagraph
이 실행되어 부모 컴포넌트인 App 컴포넌트의 상태가 변경되었기 때문이다.props의 변경은 real DOM의 변경으로 이어질 수는 있지만, 함수에서 재평가를 할 때는 부모 컴포넌트가 재평가되는 것으로 충분하다. 물론 DemoOutput이 재실행된다고 해서 real DOM이 변경된다는 것은 아니다.
이처럼 컴포넌트가 재평가된다고해서 무조건 real DOM이 변경되는 것은 아니다. real DOM은 재평가 시 차이가 있을 때만 변경된다.
<App />
컴포넌트의 자식 컴포넌트인 <DemoOutput />
컴포넌트 뿐만 아니라 <Button />
컴포넌트도 재평가되고 있다. 이런식으로 계속 뻗어나가는 컴포넌트 트리를 보면 이런 의문이 생길 수도 있다.
"이거 괜찮은 걸까? 연결된 모든 컴포넌트 함수가 재실행되면 굉장히 많은 함수가 가상 비교되는데, 성능에 영향을 미치지는 않을까?"
사실 전혀 그렇지는 않다. 리액트는 이런식의 실행 및 가상 비교 작업에 최적화되어 있다. 따라서 대부분의 앱 환경에서는 전혀 문제가 되지 않는다.
하지만 좀 더 큰 앱에서는 최적화가 필요하다.
특정한 상황(컴포넌트가 받은 props가 변경된 경우 등)인 경우에만, <DemoOutput />
같은 자식 컴포넌트 재실행하도록 리액트에게 지시할 수 있다.
React.memo()
를 사용하여 props가 바뀌었는지 확인할 컴포넌트를 지정하여 감싸주면 된다.
React.memo()
는 인자로 들어간 컴포넌트에 어떤 props이 입력되는지 확인하고, 입력되는 모든 props의 신규 값을 확인하여 기존 props 값과 비교하도록 리액트에게 전달한다. 따라서 props 값이 바뀐 경우에만 컴포넌트를 재실행 재평가 하도록 한다.자식 컴포넌트인 <DemoOutput />
를 React.memo()
로 감싸주자.
import React from "react";
const DemoOutput = (props) => {
console.log("DemoOutput RUNNING");
return <p>{props.show ? "This is new Paragraph!" : ""}</p>;
};
export default React.memo(DemoOutput);
<DemoOutput />
컴포넌트도 실행되기 때문에 DemoOutput RUNNING
이 콘솔에 찍힌다.<App />
컴포넌트가 재실행 재평가되더라도, 자식 컴포넌트인 <DemoOutput />
은 props이 변경되지 않았기 때문에 재평가되지 않는 것을 확인할 수 있다.이렇게하면 불필요한 재렌더링을 피하여 최적화할 수 있다.
최적화에는 비용이 따른다. memo 메소드는 <App />
에 변경이 발생할 때마다 <DemoOutput />
컴포넌트로 이동하여 기존 props값과 새로운 props 값을 비교하게 한다. 그러면 리액트는 두 가지 작업을 해야 한다.
이러한 작업은 개별적인 성능 비용이 필요하다. 이 성능 효율은 어떤 컴포넌트를 최적화하느냐에 따라 달라진다.
컴포넌트를 재평가하는데 필요한 성능 비용과 props을 비교하는데 필요한 성능 비용을 맞바꾸는 격이다.
현재 <DemoOutput />
컴포넌트와 마찬가지로 <Button />
컴포넌트의 경우에도 변경되는 요소가 없다. 매번 같은 텍스트와 함수를 사용하기 때문에 매번 재평가할 필요가 없어보이니 memo로 감싸도 좋겠다.
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 />
컴포넌트를 memo로 감싸도 재평가되어 재렌더링되어 콘솔에 BUTTON RUNNING
이 찍히고 있다!
이유가 뭘까?
왜냐하면 <Button />
컴포넌트에 전달되는 함수는 매번 재생성되기 때문이다. 이 함수는 사용자가 아닌 리액트에 의해 호출되지만 여전히 일반 함수처럼 실행된다. 즉, 모든 코드가 다시 실행된다.
App 함수의 모든 렌더링, 모든 실행 사이클에서 이는 완전히 새로운 함수이다. 즉, 재사용하지 않는다. 왜냐하면 매번 다시 만드는 상수이기 때문이다.
모든 코드가 재실행되므로, 당연히 새로운 함수가 만들어진다. 즉, 이전과 같은 함수가 아닌 같은 기능을 하지만 새로운 함수인 것이다..😮..!??!
이는 사실 <DemoOutput />
컴포넌트에 props.show
로 false
를 보냈을 때도 동일하다. 하드코딩으로 보낸 false는 절대 변하지 않는 것처럼 보이지만, 사실은 기술적으로 현재 false와 이전 false는 다른 false이다..?!!
App 함수는 재실행된다고 했으니 실제로는 이 false값도 새롭게 만들어진다..! 마지막 렌더링 사이클에서 false가 발생했더라도 재실행하면 새로운 false가 생성되는 것이다..;;
엥..? 기술적으로 둘 다 값이 다르다면, React.memo()
가 <DemoOutput />
컴포넌트에서는 작동하는데, 왜 <Button />
컴포넌트에서는 작동하지 않는 걸까?
props.show로 <DemoOutput />
컴포넌트로 전달된 false와 props.onClick으로 <Button />
컴포넌트로 전달된 togglePraagraphHandler함수는 뭐가 다른걸까?
React.memeo()
는 props.show
의 이전 값과 현재 값을 비교하는데, 일반 비교 연산자===
를 통해 비교한다.
false는 불리언 값으로 문자열, 숫자와 함께 JS의 원시값이다.
false === false //true
"hi" === "hi" //true
1 === 1 //true
각각 다른 원시값인 false와 false를 비교하면 비교값이 true로 나온다.
하지만 JS의 참조값인 배열, 객체, 함수를 비교할 경우, 사람 눈에는 같아보이지만 JS는 그 값을 다르게 취급한다.
[1, 2, 3] === [1, 2, 3] //false
{ name: "BTS" } === { name: "BTS" } //false
fn === fn //false
각각 다른 참조값을 비교하면 비교값이 false로 나온다.
props.onClik
으로 <Button />
컴포넌트에 전달된다. React.memo()
는 자바스크립트의 이러한 작동방식 때문에 값이 변경되었다고 인식하므로 메모를 사용하더라도 컴포넌트가 재평가되는 것이다.^^...
그렇다면 React.memo()
는 객체, 배열, 함수를 가져오는 props를 가져오는 컴포넌트에는 사용할 수 없을까? 노.. 그럴때 사용할 수 있는게 useCallback()
이다.
useCallback()
을 사용하여 객체를 생성하고 저장하는 방식만 조금 변경해주면 작동하게 할 수 있다.
useCallback()
은 컴포넌트 실행 전반에 걸쳐 함수를 저장할 수 있게 하는 훅이다.
리액트에게 이 함수를 저장할 것이고, 매번 이 함수를 재생성할 필요가 없다고 알릴 수 있다. 즉, 동일한 함수 객체가 메모리의 동일한 위치(동일한 참조값)에 저장되므로 이를 통해 비교 작업을 할 수 있다.
아래처럼 객체 1과 객체 2가 같은 메모리 안의 같은 위치를 가리키고 있다면 JS는 두 객체를 같은 객체로 간주한다. 이것이 useCallback()
이 하는 일이다.
따라서 내가 선택한 함수를 리액트의 내부 저장 공간에 저장하여 함수 객체가 실행될 때마다 이를 재사용하여 컴포넌트가 재생성되지 않도록 최적화할 수 있다.
저장하려는 함수(재렌더링이 필요하지 않은 함수, 즉 절대 변경되지 않을 함수를)를 useCallback()
로 감싸주면 된다.
import React, { useCallback, useState } from "react";
import "./App.css";
import Button from "./components/UI/Button/Button";
import DemoOutput from "./components/Demo/DemoOutput";
function App() {
const [showParagraph, setShowParagraph] = useState(false);
console.log("APP RUNNING");
// 저장할 함수를 useCallback의 첫 번째 인자로 보낸다.
const toggleParagraphHandler = useCallback(() => {
setShowParagraph((prev) => !prev);
}, []);
return (
<div className="app">
<h1>Hi there!</h1>
<DemoOutput show={false} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
첫 번째 인자로 함수를 전달하면 useCallback()
은 저장된 함수를 반환해준다.
App 함수가 다시 실행되면 useCallback()
이 리액트가 저장한 함수를 찾아서 같은 함수 객체를 재사용한다.
따라서 어떤 함수가 절대 변경되지 않는다면 useCallback()
을 사용하여 최적화 할 수 있다.
두 번째 인자로는 종속성 배열을 전달한다.
여기서는 업데이트 함수의 상태setShowParagraph
만 명기하면 되는데, 이 경우 리액트에서 이전과 동일한 함수 객체임을 보장하므로 절대 바뀌지 않기 때문에 생략가능하다.
이 배열은 리액트에게 toggleParagraphHandler
에 저장하려고 하는 이 콜백 함수가 절대 변경되지 않을 것이라고 알려주는 배열이다. 따라서 <App />
컴포넌트가 재렌더링될 때 항상 같은 함수 객체가 사용되도록 한다.
이제 <Button />
컴포넌트도 불필요하게 재평가되지 않는다.
<Button />
컴포넌트에 props.onClick
으로 전달된 toggleParagraphHandler
객체가 useCallback()
덕분에 메모리 안에서 항상 같은 객체임이 보증된다.
이제 전달된 모든 props 값이 비교 가능하도록 전달했기 때문에 React.memo()
가 <Button />
컴포넌트에서도 역할을 수행할 수 있다.
의존성 배열을 지정해야 하는데 이게 왜 필요할까?
함수는 모든 재렌더링 주기마다 항상 똑같은 로직을 쓰는데 왜 의존성 배열이 필요한 걸까?
JS에서 함수는 클로저
이다. 즉, 이 환경에서 사용할 수 있는 값에 클로저를 만들게 된다.
리액트의 작동방식을 이해하려면 클로저가 무엇인지 알아야 한다.
📝 클로저(Closure)란?
- 클로저는 폐쇄의 의미를 가지고 있는데, 함수가 선언(생성)될 때, 그 당시 주변 환경과 함께 갇히는 것을 의미한다.
즉, 함수가 속한 렉시컬 스코프(Lexical Environment)를 기억하여, 함수가 렉시컬 스코프 밖에서 실행될 때도 이 스코프에 접근할 수 있게 해주는 기능이다.
- useState()가 기능하는 방식도 은닉된 state를 setState()함수만을 사용하여 스코프에 접근할 수 있는 클로저이다.
- 렉시컬 스코프란 함수가 선언되는 위치에 따라서 상위 스코프가 결정되는 스코프이다.
- 내부함수는 외부함수의 지역변수에 접근할 수 있는데, 외부함수의 실행이 끝나면 외부함수가 소멸된다. 외부함수가 소멸된 후에도 내부함수가 외부함수의 변수에 접근할 수 있는 것이 바로 클로저이다.
내부함수 > 외부함수 > 지역변수 > 글로벌변수
import React, { useCallback, useState } from "react";
import "./App.css";
import Button from "./components/UI/Button/Button";
import DemoOutput from "./components/Demo/DemoOutput";
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const [allowToggle, setAllowToggle] = useState(false);
console.log("APP RUNNING");
const toggleParagraphHandler = useCallback(() => {
// Allow Toggling 버튼에 allowToggle에 allowToggleHandler를 바인딩하는 것 외에,
// 다른 함수에서 allowToggle의 상태 스냅샷을 이용해 setShowParagraph을 사용할 수 있는지 확인해 보자.
if (allowToggle) {
setShowParagraph((prev) => !prev);
}
}, []);
const allowToggleHandler = () => {
setAllowToggle(true);
};
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;
왜 그럴까?
이유는 JS에서 함수는 클로저이고 useCallback을 제대로 사용하지 않았기 때문이다.
JS의 함수는 클로저이다. 이 App 함수 전체 내부 블록 안의 함수인 toggleParagrapHandler가 정의될 때 JS는 이 함수 외부에서 사용하는 모든 변수를 잠근다.
const toggleParagraphHandler = useCallback(() => {
if (allowToggle) {
setShowParagraph((prev) => !prev);
}
}, []);
allowToggle
상수는 함수 외부에 있는 상수인데, 이를 toggleParagraphHandler
함수 안에서 사용하려고 한다.allowToggle
상수에 클로저를 만들고, 함수를 정의할 때 사용하기 위해 상수를 저장한다. 따라서 toggleParagraphHandler
가 실행되면 이 저장된 상수를 그대로 사용하게 된다. 즉, allowToggle
의 값이 App 컴포넌트가 처음 실행된 시점의 값인 false로 잠긴 것이다. useCallback()
를 사용하여 리액트에게 어떤 환경에서든 함수를 재생성하지 않도록 막았기 때문에 App 함수가 토글 상태가 변경되어 재평가, 재실행되더라도 리액트는 이 함수를 재생성하지 않는다. 따라서 리액트가 이 함수에 사용하기 위해 저장한 allowToggle
의 값은 최신값이 아닌 App 컴포넌트가 처음 실행된 시점의 값을 저장하여 가지고 있는 것이다.JS는 함수 생성 시점의 allowToggle 상수의 값을 저장하고 있기 때문에 원하는 대로 작동하지 않는 문제가 발생한다.
이처럼, 함수 외부로부터 오는 값이 변경될 때 함수 재생성이 필요한 경우가 발생한다.
const toggleParagraphHandler = useCallback(() => {
if (allowToggle) {
setShowParagraph((prev) => !prev);
}
}, [allowToggle]); //🌟
결국 원리를 이해하는 건 리액트라기 보다는 JS에 가깝다. ㅎㅎ...
클로저가 어떻게 작동되는지, 원시값과 참조값에 대한 이해가 뒷받침되면 리액트의 작동원리를 더 잘 이해할 수 있다.
이상, 함수들이 실제로 어떻게 실행되고 React.memo()dhk useCallback()이 어떻게 작동되는지 알아봤다.
리액트 앱에서는 컴포넌트
를 통해 작업을 수행한다. (최신 리액트에서는 주로 함수 컴포넌트를 작업한다.)
이 컴포넌트는 JSX코드를 반환하는 작업을 수행한다. 이는 리액트네에게 컴포넌트의 출력이 무엇인지 알려준다.
리액트 컴포넌트는 state, props, context를 이용해 작업할 수 있다.
props와 context는 결국 state의 변경으로 이어지기 때문에, 컴포넌트의 변경과 컴포넌트에 영향을 주거나 앱 일부에 영향을 미치는 데이터를 변경하게 된다.
컴포넌트에서 state(상태)를 변경할 때마다 이 변경된 상태가 있는 컴포넌트는 재평가된다. 즉, 컴포넌트 함수는 재실행된다. 따라서 모든 코드가 재실행되고 새로운 output(출력값)인 JSX 부분을 얻는다.
JSX 출력값은 이전과 동일할 수 있지만, 실제로는 조금 다르다.
예) 단락(p) 전체가 재렌더링될 수도 있고 안 될수도 있다.
리액트는 단순히 모든 컴포넌트의 최신의 재평가를 가져와서 이전의 평가의 결과와 비교한다.
그러고 나서 확인된 모든 변경 사항이나 차이점을 React DOM에 전달한다. React DOM을 통해 index.js 파일을 렌더링하기 때문이다.
그러고 나서 React DOM은 변경사항을 브라우저의 Real DOM에 적용하고, 변경되지 않은 것은 그대로 둔다.
이제 리액트가 컴포넌트를 재평가할 때 단순히 컴포넌트 재평가에서 그치지 않고, 전체 함수를 재실행하고 이를 통해 코드를 전부 리빌드한다. 즉 JSX 코드가 최신 스냅샷의 아웃풋의 결과를 리빌드한다.
그러고 나서 이 JSX 코드에 있는 모든 컴포넌트를 재실행한다.
하위 컴포넌트의 불필요한 재실행을 막기 위해 React.memo()를 통해 리액트에게 props가 실제로 변경되었을 경우에만, 즉 새로운 값이 들어왔을 경우에만 컴포넌트 함수를 재실행하게 할 수 있다.
컴포넌트의 재평가는 App 컴포넌트 함수 전체의 재실행을 의미하는데, 이 App 함수에 있는 모든 것들이 다시 실행된다는 사실을 알지 못한다면 이상한 결과를 초래할 수 있다. 따라서 JS의 원시값과 참조값의 차이와 클로저를 기반으로하는 리액트의 작동원리를 알아둘 필요가 있다.