상태를 업데이트해서 일어나는 과정과 비밀을 알아보겠습니다.
리액트가 렌더링 된 컴포넌트를 언제 업데이트를 시키나요?
다들 아시다시피 바로 상태가 업데이트 됐을 때입니다.
import { useState } from 'react';
function App() {
const [num, setNum] = useState(0);
const onClick = () => {
setNum(num + 1);
};
return (
<div className='App'>
<h1>{num}</h1>
<button onClick={onClick}>클릭 하면 1이 증가</button>
</div>
);
}
export default App;
보시다시피 클릭하면 +1이 된 값이 화면에 나타납니다. 너무 당연하죠?
하지만 아래와 같이 setNum(num+1)이 여러 개인 경우에는 어떻게 될까요?
5가 될까요?
import { useState } from 'react';
function App() {
const [num, setNum] = useState(0);
const onClick = () => {
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
};
return (
<div className='App'>
<h1>{num}</h1>
<button onClick={onClick}>클릭 하면 1이 증가</button>
</div>
);
}
export default App;
똑같이 1이 됩니다. 왜그럴까요? 이러는 이유가 있을 거 아니에요 그렇죠?
그 이유에는 2가지가 있습니다. 첫 번째 이유부터 봐봅시다.
여러 개의 상태 업데이트가 안 되는 첫 번째 이유: 배치
리액트에서 상태를 업데이트해줄 때는 배치(일괄 처리)로 상태를 업데이트를 하게 됩니다. 이 배치라는 것을 알기 전에 생각을 해봅시다.
만약 setNum(상태 업데이트 함수)가 호출될 때마다. 리렌더링이 일어난다고 생각해 보면 이 onClick 이벤트 핸들러 함수에 의해 App이라는 컴포넌트는 5번씩 리렌더링이 일어납니다. 만약 setNum 함수가 100개가 있었으면 100번 리렌더링이 일어나게 됩니다. 그러면 앱은 클릭 한 번으로 인해 렉이 발생하거나 엄청 느려질 수 있게 됩니다.
import { useState } from 'react';
function App() {
const [num, setNum] = useState(0);
const onClick = () => {
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
};
return (
<div className='App'>
<h1>{num}</h1>
<button onClick={onClick}>클릭 하면 1이 증가</button>
</div>
);
}
export default App;
하지만 리액트는 이런 불상사를 방지하기 위하여 배치처리를 도입하게 됩니다. 배치처리란? setState함수로 상태(state)가 바로바로 바뀌는 것이 아니라 이벤트 핸들러나 코드 안에 있는 내용이 다 실행이 되면 리액트가 컴포넌트를 다시 렌더링 하기 전에 상태(state)를 한 번에 바꿔주는 작업입니다. 즉, 처리해야 할 일을 바로바로 끝내는 것이 아니라 하나씩 모아두었다가 한꺼번에 처리하는 것입니다.
아래 onClick 코드를 보면 클릭이 일어날 때 아래 함수를 실행시키는데 setNum 함수의 인자를 어딘가에 보관하였다가 컴포넌트가 리렌더링 되기 전 보관하였던 값을 일괄 처리하여 상태를 업데이트 후 리렌더링을 하게 됩니다. 이 부분은 이해가 안 가실 수도 있는데 아래에서 자세히 설명드릴게요. 걱정하지 마세요 😊
const onClick = () => {
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
};
여러 개의 상태 업데이트가 안 되는 두 번째 이유: 렉시컬 스코프
렉시컬 스코프는 아래 포스트에서 다루었습니다. 가볍게 읽어주시면 감사하겠습니다.
https://seungyn.tistory.com/33
함수가 실행되기 전 렉시컬 환경이 생성되어 함수가 어떤 변수에 접근할 수 있는지 렉시컬 스코프가 생성되어 렉시컬 환경에 등록됩니다. 맞죠?
위에서 보았듯이 배치처리에 의해 다음 레더링 까지는 상태가 변경이 안되기 때문에, num에 0을 대입해보면 아래처럼 0 + 1이 되어 아무리 버튼을 클릭한다 하더라도 함수가 생성된 시점에 num은 0이어서 최종적인 업데이트는 0 + 1인 1이 됩니다.
import { useState } from 'react';
function App() {
const [0, setNum] = useState(0);
const onClick = () => {
setNum(0 + 1);
setNum(0 + 1);
setNum(0 + 1);
setNum(0 + 1);
setNum(0 + 1);
};
return (
<div className='App'>
<h1>{num}</h1>
<button onClick={onClick}>클릭 하면 1이 증가</button>
</div>
);
}
export default App;
그러면 중간중간 상태를 업데이트를 할 때마다 업데이트된 값을 사용할 수는 없는 건가요?
있습니다! 바로 업데이트 함수를 넘겨주면 됩니다.
아래처럼 setNum 안에 인자로 최신 상태값을 받는 업데이트 함수를 넣어주고 리턴 값으로 업데이트 된 값을 명시해 주면 바로바로 최신의 상태값을 이용할 수 있습니다.
import { useState } from 'react';
function App() {
const [0, setNum] = useState(0);
const onClick = () => {
setNum((num) => num + 1);
setNum((num) => num + 1);
setNum((num) => num + 1);
setNum((num) => num + 1);
setNum((num) => num + 1);
};
return (
<div className='App'>
<h1>{num}</h1>
<button onClick={onClick}>클릭 하면 1이 증가</button>
</div>
);
}
export default App;
확인해 보면 아무리 클릭을 해도 1씩 증가했던 것이 단 한 번의 클릭으로 5가 되었습니다. 그럼 도대체 이런 이유가 뭘까요? 분명 리액트는 성능을 위해서 배치 처리를 사용하여 상태가 한 번에 업데이트된다고 했는데 이번엔 이렇게 하니깐 최신상태를 바로 사용하게 되네요? 그렇죠? 짜증나니깐 한번 이유좀 봅시다. 이러는 이유가 있을거 아니에요. 그쵸?
리액트 배치 처리 과정
위에서 제가 “처리해야 할 일을 바로바로 끝내는 것 아니라 하나씩 모아두었다가 한꺼번에 처리하는 것입니다.”라고 언급했습니다. 이 한 번에 모아두었다가 한꺼번에 처리한다라고 했는데 이것 때문입니다.
아래 코드를 보시면 리액트 상태를 업데이트하는 코드가 있습니다.(공식문서에 나와있어요, 간략버전) 간략하게 배치는 저렇게 구현되었다고 예시가 나와있습니다.
export function getFinalState(
baseState: number,
queue: (number | ((n: number) => number))[]
) {
let finalState = baseState;
for (let update of queue) {
if (typeof update === 'function') {
// 큐의 인자 함수일경우
finalState = update(finalState);
} else {
// 큐의 인자가 숫자일 경우
finalState = update;
}
}
return finalState;
}
위의 코드를 설명하자면 getFinalState 함수는 인자로 초기 상태 값과 queue (업데이트 인자를 모아두는 저장소)를 받아서 상태를 업데이트하도록 되어있습니다. 여기서 눈여겨보셔야 할 점이 아래 코드입니다.
queue에 함수가 오면 가지고 있던 상태 값이 함수를 실행시킨 값으로 바꿔주네요.
if (typeof update === 'function') {
// 큐의 인자 함수일경우
finalState = update(finalState);
}
반대로 함수가 아닌 값이 setState(업데이트 함수)의 인자로 오면 그냥 기존 상태 값을 들어온 인자 값으로 덮어 씌웁니다. 한번 위의 코드가 적용된 리액트 앱으로 확인해 봅시다. 아래는 구현사항이고 밑에 결과 사진만 보시면 됩니다.
export default function QueueTest() {
return (
<>
<TestCase baseState={0} queue={[5, (n) => n + 1, 42]} expected={42} />
<TestCase baseState={0} queue={[1,1,1]} expected={1} />
<TestCase
baseState={0}
queue={[5, (n) => n + 1, (n) => n + 1]}
expected={7}
/>
</>
);
}
type TestCaseType = {
baseState: number;
queue: (number | ((n: number) => number))[];
expected: number;
};
function TestCase({ baseState, queue, expected }: TestCaseType) {
const actual = getFinalState(baseState, queue);
return (
<>
<p>
초기 상태 값: <b>{baseState}</b>
</p>
<p>
Queue: <b>[{queue.join(', ')}]</b>
</p>
<p>
예상 결과: <b>{expected}</b>
</p>
<p
style={{
color: actual === expected ? 'green' : 'red',
}}
>
처리된 결과: <b>{actual}</b> (
{actual === expected ? 'correct' : 'wrong'})
</p>
</>
);
}
export function getFinalState(
baseState: number,
queue: (number | ((n: number) => number))[]
) {
let finalState = baseState;
for (let update of queue) {
if (typeof update === 'function') {
// 큐의 인자 함수일경우
finalState = update(finalState);
} else {
// 큐의 인자가 숫자일 경우
finalState = update;
}
}
return finalState;
}
import { useState } from 'react';
import QueueTest from './QueueTest';
function App() {
return (
<div className='App'>
<QueueTest />
</div>
);
}
export default App;
위의 코드에 대한 결과
첫 번째 예제를 풀어보겠습니다.
Queue: [5, (*n*) => *n* + 1, 42]에 0인 초기값을 getFinalState(배치 과정 함수)에 넣어 실행시켜 보면
- 타입이 number 인 5를 만나 현재 내부의 상태가 5가 됩니다.
- (n) ⇒ n+1인 함수가 들어가 현재 내부의 상태가 6이 됩니다.
- 42인 number 값이 들어와 내부의 상태가 42가 되었으며, 최종적인 상태 42가 반환 값이 됩니다.
이런 식으로 리액트는 내부적으로 배치 작업이 일어나 상태를 바로바로 업데이트하여 리렌더링을 바로바로 해주는 것이 아니라 내부적인 상태를 가지고 있어 배치 작업으로 내부적인 상태를 변화시켜 최종적으로 변화된 상태를 내보내 리렌더링을 일으킵니다.
결론
세 가지만 기억하시면 리액트 상태 관련해서 아무런 문제없습니다.
- 리액트는 상태가 변경이 되면 리렌더링이 일어난다.
- 상태를 업데이트해줄 때는 이벤트 핸들러 함수가 끝날 때까지 기다리며 끝나고 나면 배치작업으로 상태를 업데이트시킨다.
- 상태를 업데이트시킨 값을 사용하고 싶으면 업데이트 함수를 사용하면 된다.
감사합니다. 지금까지 리액트 상태 업데이트의 비밀이었습니다.
'프론트엔드 > React' 카테고리의 다른 글
[형을 위한 리액트] 1. 리액트를 왜 쓰는지 (0) | 2024.10.07 |
---|---|
setState 함수는 Dependency list에 넣어줘야 할까? (0) | 2024.01.17 |
CRA없이 리액트(타입스크립트) 프로젝트 생성하기 (0) | 2023.03.12 |
Portals (0) | 2023.03.11 |
연속된 커스텀 훅(훅인마) (0) | 2023.03.09 |