useState
와 useEffect
는 리액트 개발에서 밥 먹듯이 사용되는 훅이다. useState
는 '상태와 상태 업데이트 함수를 정의하는 훅'이고, useEffect
는 '리렌더링 후 부수 효과를 처리해 주는 훅'인데, 각각에 대한 정의와 사용법은 알고 있지만 정확한 동작에 대해선 이해하는 데 어려움이 많았다. 이번 포스트에선 해당 내용에 대해 최대한 상세하게 정리해두려 한다.
useState
상태 업데이트 과정
아래와 같이 상태를 정의해뒀다.input
태그에서 onChange
이벤트가 발생할 때마다 상태를 변경해 준다. 실제로 상태가 잘 변경되는지 확인하기 위해 useEffect
도 함께 사용해 줬다.
import { useEffect, useState } from 'react';
const App = () => {
const [text, setText] = useState('');
useEffect(() => {
console.log(text);
}, [text]);
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value);
};
return (
<div>
<input type='text' value={text} onChange={handleChange} />
</div>
);
};
export default App;
최초의 text
상태는 빈 문자열로 정의했고, text
를 input
창에 표시하기 위해 value
속성의 값으로 전달했다. 해당 코드를 실행해 보면 input
창에 입력되는 값이 잘 표시되며, useEffect
로 실행한 console.log
결과를 봐도 입력에 따라 값이 잘 실행되는 게 확인된다.
상태 업데이트와 리렌더링 절차를 나열해 보면 아래와 같다.
input
창에 값을 입력하면 등록해 둔handleChange
이벤트 핸들러가 동작한다.handleChange
핸들러에선input
창에 입력된 값(e.target.value
)을setText
의 인자로 전달해 업데이트한다.setText
로 호출한 상태 업데이트 작업이 리액트에서 자체적으로 관리하는 업데이트 큐에 등록된다. 업데이트 큐에 등록된 작업은 즉시 반영되지 않고 배치 단위로 실행되는 다음 렌더링 사이클에 반영된다(비동기로 작업을 처리하는 태스크 큐와 유사하다).- 업데이트 큐에 특정 배치 규모의 상태 변경 작업을 합쳐서 연산한 후 한 번에 상태를 변경해 준다(예시에선 하나의 작업이지만, 실제 애플리케이션에선 여러 상태 업데이트를 묶어서 처리한다).
- Virtual DOM이 기존의 상태와 변경된 상태를 비교해 리렌더링이 필요한 부분을 마크한다.
- 상태 업데이트와 함께 상태 변경이 있던 컴포넌트(함수)가 리렌더링(재실행)된다.
중요한 건 업데이트 큐에 상태 업데이트 작업을 등록해 뒀다가 리액트 내부에서 정의해 둔 특정 배치 사이즈를 기준으로 하여 한 번에 업데이트를 반영한다는 것이다. 그래서, 아래의 코드를 실행해 보면 count
가 setCount
호출 시점마다 값을 증가시키지 않는 것을 볼 수 있다.
import { useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 가장 먼저 호출됐지만 바로 count 값을 바꾸진 않기 때문에 count는 아직 0이다
setCount(count + 2); // 위에서 count가 변경되지 않았기 때문에 setCount(0 + 2)가 업데이트 큐에 등록된다.
setCount(count + 3); // 마찬가지로 setCount(0 + 3)이 업데이트 큐에 등록된다.
};
return (
<div>
{count}
<button onClick={handleClick}>increase++</button>
</div>
);
};
export default App;
업데이트 큐에는 count
를 업데이트하는 setCount
함수가 세 번 등록되어 있지만, 리액트 내부의 동작 방식에 따라 같은 배치에서 같은 상태 값을 변경하기 위한 업데이트 함수가 2회 이상 호출될 시 차례대로 실행되어, 결국 마지막 호출된 값으로 덮어 씌워진다(count
는 차례대로 1, 2, 3으로 변하지만 리렌더링 시에는 마지막 상태 변화 결과인 3
으로 반영되어 화면에 그려진다. 즉, 값이 3씩 증가되는 것으로 화면에 보인다).
상태 업데이트가 비동기와 유사하게 동작하며, 한 번의 리렌더링 사이클에 같은 상태를 업데이트하는 함수가 중복으로 호출되면 덮어씌워진다는 사실이 중요하다. 이러한 원리를 제대로 이해하지 못하면 개발 시 의도치 않은 동작과 마주하게 될 것이다.
상태 업데이트 방식이 문제가 되는 예시
input
에 사용자가 비밀번호를 입력하면, 입력 결과를 Validation 해서 8자리 이하일 경우 Warning을 보여주려고 한다. 구현 과정에서, 아래와 같이 상태 업데이트와 Validation 체크를 실행하는 코드를 작성했다고 하자.
import { useState } from 'react';
const App = () => {
const [text, setText] = useState('');
const [isValid, setIsValid] = useState(false);
const validator = (text: string) => {
if (text.length >= 8) setIsValid(true); // text의 길이가 8보다 커지면 validation 통과
};
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value); // 상태 업데이트 함수
validator(text);
};
return (
<div>
<input type='text' value={text} onChange={handleChange} />
</div>
);
};
export default App;
해당 코드는 의도한 대로 동작하지 않고, 길이가 9일 때 validation을 통과하게 된다.
input
에 값이 입력되면onChange
이벤트 핸들러에 등록된handleChange
콜백 함수가 실행된다.- 콜백 함수 내부에서
setText
가 실행되지만 바로 값을 업데이트하지 않고 업데이트 큐에 등록만 한다. text
의 값이 아직 업데이트되지 않은 상황에서validator
가 실행된다.- 일련의 절차를 거쳐서 업데이트 큐에 등록된 배치 작업이 실행돼 상태가 업데이트되고, 리렌더링(함수가 재실행)된다. 함수가 재실행될 땐 이벤트가 발생한 시점은 아니기 때문에
handleChange
핸들러 내부의validator
가 다시 실행되진 않는다. - 결국,
validator
는 언제나text
의 이전 상태를 가지고 유효성 검증을 하게 된다.
문제를 해결하는 잘못된 방식
문제를 해결하기 위해 validator
함수가 이벤트 핸들러 내부가 아닌, 컴포넌트 함수 본문에서 바로 실행되게 코드를 다음과 같이 바꿔봤다. 이렇게 바꾸면 setText
로 변경된 상태가 업데이트 큐를 거쳤다가 상태를 실제로 변경하고 함수 컴포넌트가 재실행되는 시점에 호출되기 때문에 잘 동작할 거라고 생각된다.
import { useState } from 'react';
const App = () => {
const [text, setText] = useState('');
const [isValid, setIsValid] = useState(false);
const validator = (text: string) => {
if (text.length >= 8) setIsValid(true); // text의 길이가 8보다 커지면 validation 통과
};
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value); // 상태 업데이트 함수
};
validator(text); // App 컴포넌트 재실행 시 호출되도록 변경
return (
<div>
<input type='text' value={text} onChange={handleChange} />
{isValid ? null : <span>비밀번호는 8자리 이상이어야 합니다</span>}
</div>
);
};
export default App;
하지만, 이 코드도 제대로 동작하지 않는다. 해당 코드는 8자리 문자가 입력되는 순간 아래와 같은 에러 메시지를 뱉어버린다.
Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
원인은 validator
에서 if
절의 조건을 통과했을 때 setIsValid
가 실행되기 때문이다. 리액트는 상태가 변경되면 컴포넌트를 리렌더링 한다. 때문에 setIsValid
라는 함수가 호출되고, 업데이트 큐에 등록되었다가 상태가 변경되면 App
컴포넌트가 다시 실행된다. 그리고, 다시 실행될 때 함수 본문에 있는 validator
가 다시 호출되고, validator
본문에 있는 if
절 조건이 통과해서 setIsValid
가 다시 호출된다. 무한 반복이다.
text
의 유효성을 검증하는 것은 text
의 상태가 바뀐 이후에 이뤄져야 동작이지, isValid
의 업데이트와는 상관이 없다. 이런 상황일 때 useEffect
를 사용해 동작을 제어할 수 있다.
useEffect
실행 시점
useEffect
는 의존성 배열에 있는 값의 상태가 변경될 시 실행될 '사이드 이펙트'를 관리하는 훅이다. 즉, 상태가 변경될 시 어떠한 값이 함께 변경되어야 할 때, 위의 예시처럼 '리렌더링이 진행되는 중간에' 의존된 값의 변경이 될 경우 문제가 될 수 있는 경우에 사용하는 훅이다. 위 예시 코드를 useEffect
를 사용해 바꿔보면 아래와 같다.
import { useEffect, useState } from 'react';
const App = () => {
const [text, setText] = useState('');
const [isValid, setIsValid] = useState(false);
const validator = (text: string) => {
if (text.length >= 8) setIsValid(true);
};
// text가 바뀌고 나면 validator 실행
useEffect(() => {
validator(text);
}, [text]);
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value);
};
return (
<div>
<input type='text' value={text} onChange={handleChange} />
{isValid ? null : <span>비밀번호는 8자리 이상이어야 합니다</span>}
</div>
);
};
export default App;
해당 코드의 실행 순서는 다음과 같다.
- 처음 컴포넌트가 렌더링 된다.
input
창에 값이 입력되면handleChange
내부에 있는setText
함수가 호출되어 리액트 내부에서 관리하는 업데이트 큐에 작업이 등록된다.- 일정 배치 사이즈의 업데이트를 한 번에 업데이트하여 상태를 변경한다.
- Diffing을 통해 상태 변경으로 인해 리렌더링해야 할 컴포넌트를 표시한다.
- 리렌더링이 필요한 컴포넌트 함수를 재실행한다. 재실행할 때 변경된 상태의 값이 반영된다.
- 리렌더링 시 변경된 상태가
useEffect
의 의존성 배열에 존재한다면useEffect
의 첫 번째 인자로 넘긴 콜백 함수가 실행된다. 위 예시에선text
가 렌더링 사이클에 의해 변경됐기 때문에useEffect
의 콜백 함수가 호출된다. useEffect
의 콜백 함수에서text
의 유효성 검증 함수가 호출된다. 유효성 검증 결과에 따라setIsValid
가 호출되면 해당 호출이 다시 업데이트 큐에 등록된다.- 상태 업데이트와 Diffing 후 리렌더링 컴포넌트 마크, 컴포넌트 리렌더링이 진행된다.
- 마지막 리렌더링에선
isValid
만 값이 변경됐고,text
는 바로 직전 리렌더링 시점의 값에서 변경이 없기 때문에(handleChange
가 다시 실행되진 않았음)useEffect
콜백 함수가 재호출 되지 않고 실행이 종료된다.
useEffect
의 콜백 함수는 렌더링 사이클이 돌아서 리렌더링이 완료된 이후에 실행된다(의존성 배열의 상태가 변경됐음을 전제). 따라서, useEffect
콜백에 있는 구문들은 리렌더링 과정 중간에는 영향을 미치지 않는다.
useEffect
의 클린업 함수 실행 시점
useEffect
의 '클린업(Clean-up) 함수'란, useEffect
로 인해 발생한 사이드 이펙트의 부작용을 정리해 주는 함수이다. 주로 useEffect
의 콜백 함수 마지막에 return () => void
형태로 실행된다.
아래는 input
창의 onChange
이벤트 결과를 일정 시간이 결과한 후 debounceText
에 반영해 주는 디바운스 코드다. 예를 들어, input
에 등록된 값을 Context로 등록해서 다른 컴포넌트에 전달한다고 했을 때 text
를 바로 전달하면 사용자가 입력을 할 때마다 컴포넌트가 계속 리렌더링 될 것이다. 하지만, 디바운스로 구현하여 debounceText
를 전달하면 사용자가 연속해서 입력할 땐 Context를 받아서 쓰는 컴포넌트는 리렌더링 되지 않다가 입력이 종료될 때 한 번만 리렌더링을 시킬 수 있다.
import { useEffect, useState } from 'react';
const App = () => {
const [text, setText] = useState('');
const [debounceText, setDebounceText] = useState('');
// text가 바뀌고 나면 validator 실행
useEffect(() => {
const timerId = setTimeout(() => {
setDebounceText(text);
}, 1000);
return () => {
clearTimeout(timerId);
};
}, [text]);
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value);
};
return (
<div>
<input type='text' value={text} onChange={handleChange} />
</div>
);
};
export default App;
useEffect
내부를 보면 return () => { clearTimeout(timer); }
이라는 구문이 보인다. 이 부분이 클린업 함수다. 만약에 클린업 함수가 없다면 setTimeout
으로 걸어둔 타이머가 취소되지 않아서 등록 후 1000ms가 된 시점부터 setDebounceText(text)
가 연속해서 실행될 것이다. 이전 타이머를 clearTimeout
으로 제거해 준 후 새로운 타이머를 등록해야 디바운스의 의도대로 입력이 멈춘 후 1000ms 이후에 setDebounceText
실행이 가능하며, 이를 클린업 함수에 등록하면 깔끔하게 처리할 수 있다.
위의 코드가 디바운스로 제대로 동작하는 이유는, 클린업 함수가 다음 리렌더링이 될 시점에 가장 먼저 실행되기 때문이다. 아래 코드를 실행해 보자.
import { useEffect, useState } from 'react';
const App = () => {
const [text, setText] = useState('');
useEffect(() => {
console.log('useEffect 실행', text);
return () => {
console.log('useEffect 클린업 함수 실행', text);
};
}, [text]);
const handleChange = (e: React.ChangeEvent<HTMLElement>) => {
const target = e.target as HTMLInputElement;
setText(target.value);
};
return (
<div>
<input type='text' value={text} onChange={handleChange} />
</div>
);
};
export default App;
실행 결과를 보면 클린업 함수가 useEffect
콜백 함수보다 먼저 실행된 것 같이 보이지만, 함께 출력한 text
를 보면 클린업 함수는 이전 렌더링 사이클의 text
결과를 기억하고 있다가 다음 리렌더링 사이클이 됐을 때 실행되었다는 것을 알 수 있다. 즉, 클린업 함수는 다음 리렌더링 사이클 시작 직전에 실행된다.
위에서 디바운스를 구현한 예제도 아래와 같은 순서로 실행된다. 클린업 함수의 실행 시점은 다음 렌더링 사이클이 된다는 것에 주목하자.
input
창에 입력이 들어오면handleChange
함수의setText
가 호출됨.- 업데이트 큐 등록, 배치 상태 변경, Virtual DOM Diffing 후 변경사항 표시, 상태 변경된 컴포넌트 DOM에 반영이 순차적으로 일어남
- 상태 변경된 후
useEffect
실행되고, 내부의setTimeout
타이머 등록 - 클린업 함수는 다음 리렌더링 시 실행될 수 있게
timerId
정보를 가지고 대기 - 다음
input
이벤트 들어와서 리렌더링이 필요할 때 클린업 함수가 호출되어서 이전에useEffect
에서 등록한 타이머가 해제됨 - 다시 상태 변경 - 컴포넌트 업데이트 후
useEffect
에서 타이머 등록 - 해당 과정 반복
결론
알아본 내용의 결론이다.
setState
로의 상태 업데이트는 업데이트 큐에 등록되었다가 배치로 처리되며, 한 번의 리렌더링 사이클에서 같은 상태에 대한 중복 업데이트가 발생하면 마지막 업데이트 요청으로 덮어 씌워진다(useState
로 정의한 상태뿐만 아니라useReducer
,Context
,Props
등 '상태 변경'은 전부 마찬가지다).useEffect
는 리렌더링 사이클이 완료된 직후에 의존성 배열에 있는 상태가 변경됐는지 확인 후 실행된다.useEffect
의 클린업 함수는 해당 리렌더링 사이클이 아니라, 다음 리렌더링 사이클에서 실행된다.
리액트는 비싼 비용을 지불해야 하는 리렌더링 과정을 최적화하기 위해 독특한 상태 업데이트 알고리즘을 가지고 있다. 이에 따라 내부적으로 독특한 실행 순서를 갖게 되는데, 이에 대한 정확한 이해가 있어야 사이드 이펙트를 최소화한 리액트 애플리케이션 개발을 할 수 있다. 잘 기억해 두자.
'React' 카테고리의 다른 글
React createElement 직접 구현하기: 동작 구현 (0) | 2024.12.16 |
---|---|
React createElement 직접 구현하기: 환경 설정 (1) | 2024.12.14 |
BrowserRouter와 createBrowserRouter (1) | 2024.12.03 |
제어 컴포넌트와 비제어 컴포넌트 (1) | 2024.11.25 |
JSX란? (3) | 2024.11.21 |