react에서 가장 많이 쓰는 hook중 하나인 useState는 사용하기 쉽지만 때때로 우리 예상과 다르게 동작하기도 합니다.
function App() {
const [count, setCount] = useState(0)
const incrementCount= () => {
setCount(count+ 1)
console.log(count)
setCount(count+ 1)
console.log(count)
}
return (
<div>
<h1>{count}</h1>
<button onClick={incrementCount}>count up!</button>
<div/>
)
}
위와 같이 버튼을 누르면 count가 2 증가하는 컴포넌트가 있다고 가정해보겠습니다. countup버튼을 딱 한번 누르면 어떤 결과가 나타날까요?
콘솔창에 순서대로 1,2가 찍힌 뒤 화면에 2가 나타날까요?
하지만 분명 저는 JS에서 const로 선언한 변수는 블록 스코프 내에서 절대 변하지 않는다고 배웠습니다. 그런데 0이 할당된 count를 같은 스코프에서 출력했는데 1,2가 출력될 수 있을까요?
또, setState를 호출하면 분명 리렌더링이 일어난다고 배웠습니다. 그렇다면 버튼을 누르는 순간 두번의 리렌더링이 일어나고 아주 짧은 사이에 화면에 나타나는 숫자가 0,1,2순으로 바뀌는 걸까요?
결과부터 말하자면 버튼을 한 번 누른 뒤 화면에는 1이 나타나고 콘솔에는 두 번의 0이 출력됩니다. 또한 리렌더링은 한번만 일어납니다.
하지만 이 결과는 제가 원했던 결과가 아닙니다. 버튼을 누르면 1만 증가하니까요. useState는 어떻게 동작하길래 이런 결과가 나온 걸까요?
객체지향 프로그래밍, 함수형 프로그래밍
갑작스러울 수 있지만, 우선 객체지향 프로그래밍과 함수형 프로그래밍에 대해 알아봅시다.
객체지향 프로그래밍은 객체를 다루는 프로그래밍 방식으로, 여기서 객체는 내부 상태들을 갖고 있으며 이 상태들을 수정할 수 있는 메서드의 호출 모음이 포함된 작은 캡슐입니다.
함수형 프로그래밍은 객체 지향 프로그래밍의 반대라고 할 수 있습니다. 함수형 프로그래밍은 가능한 한 상태 변경을 피하고자 하며 함수 간의 데이터 흐름을 사용합니다.
함수형 프로그래밍에서 가장 큰 관심사는 바로 프로그램의 정확성입니다. 프로그램이 정확하다는 건 어떤 프로그램을 실행했을 때 아무런 부수 효과(side effect)없이 모든 입력에 대해 정확한 결과를 반환한다는 것이고, 부수효과를 발생시키지 않는 방법은 프로그램은 실행 중간에 변경될 수 있는 자료구조를 사용하지 않는 것입니다.
그렇다면 결과가 정확하고 부수효과가 없는 것이 왜 좋은걸까요?
프로그램이 복잡해질수록 프로그램의 결과를 예측하는 점점 힘들어 집니다. 이는 결국 프로그램의 전체적인 안정성을 헤치고 디버깅을 어렵게 만듭니다.
이제 다시 리액트로 돌아와봅시다.
클래스형 컴포넌트, 함수형 컴포넌트
리액트에는 컴포넌트를 생성하는 두가지 방법이 존재합니다.
- 클래스형 컴포넌트
- 함수형 컴포넌트
클래스형 컴포넌트를 사용한다는 것은 위 설명을 적용해봤을 때 객체지향 프로그래밍을 한다는 것이라고 볼 수 있습니다.
//Class Component
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
};
}
componentDidMount() {
}
incrementCount = () => {
this.setState({ count: this.state.count + 1 });
}
render() {
<h1>Count: {this.state.count}</h1>
<button onClick={this.incrementCount}>Increase</button>
}
}
위는 간단한 클래스형 컴포넌트 예시입니다. 컴포넌트의 상태는 컴포넌트 객체에 등록되어 있습니다. 그리고 그 상태는 메서드에 의해 변화하고 변화된 상태에 따라 다른 결과를 보여주게 됩니다.
클래스형 컴포넌트는 어느 시점에 어떤 결과가 나오게 될지 예상할 수 없습니다. 자신의 상태 즉, 변경될 수 있는 자료구조를 가지고 있기 떄문입니다. 위에서 말한 표현을 빌리자면 정확하지 않은 프로그램이라고 볼 수 있습니다.
그렇다면 함수형 프로그래밍을 사용하면 이 문제를 해결할 수 있을까요?
//Functional Component
function MyComponent({count}) {
//컴포넌트 상태는 어디에 둘까요...?
return (
<div>
<h1>{count}</h1>
</div>
);
}
위는 간단한 함수형 컴포넌트의 예시입니다. 함수형 컴포넌트는 기본적으로 입력값을 받고 JSX를 반환하는 함수입니다. 위 함수의 결과값은 외부 환경에 전혀 영향을 주고 받지 않습니다. 즉 부수효과가 없습니다.
어떤 환경에서 실행하던, 이 컴포넌트는 title을 받고 그 title이 포함된 JSX를 반환하는 정확한 프로그램인 것 입니다. 반환된 JSX는 리액트에 의해 DOM tree에 반영됩니다. 함수실행 => 화면출력 까지의 동작이 모두 예측 가능하고 정확하게 이루어지는 겁니다.
하지만 컴포넌트를 만들기에는 함수형 프로그래밍은 치명적인 제약이 있습니다. 바로 상태를 가질 수 없다는 것입니다. 상태를 가지게 되는 순간 변경될 수 있는 자료구조를 갖게 되는 것이고, 그 순간 함수형 프로그래밍의 장점은 퇴색되는 것입니다.
그렇다면 컴포넌트의 상태를 컴포넌트가 갖지 않으면서 함수형 컴포넌트를 만드는 방법은 없을까요?
리액트 팀도 아마 저와 같은 고민을 하지 않았을까 생각합니다. 그리고 그 고민 끝에 나온 것이 Hook이 아닐까 합니다.
Hook
리액트 팀은 함수형 프로그래밍의 장점을 가져가면서 컴포넌트를 다룰수 있게 도와주는 함수인 Hook을 발표했습니다. 그 중에는 useState도 포함되어 있었습니다.
//Functional Component with hook
function MyComponent() {
const [count, setCount] = useState(0)
return (
<div>
<h1>{count}</h1>
</div>
);
}
useState는 함수형 컴포넌트에서도 상태를 관리할 수 있게 도와줍니다.
그렇다면 useState는 어떻게 이를 가능하게 하는 걸까요?
useState 동작 원리
useState를 통해 상태를 반환받는 순간 컴포넌트 함수 내부에 state가 등록되는 것 처럼 보이지만 사실 state는 컴포넌트 함수 외부에 존재합니다.
리액트는 별도 공간에 컴포넌트의 상태를 저장합니다. 컴포넌트가 렌더링 되는 순간 저장되어 있는 상태의 스냅샷을 컴포넌트에게 제공하는 것입니다.
function App() {
const [count, setCount] = useState(0)
const incrementCount= () => {
setCount(count + 1) // === setCount(0 + 1)
console.log(count) // 0
setCount(count + 1) // === setCount(0 + 1)
console.log(count) // 0
}
return (
<div>
<h1>{count}</h1>
<button onClick={incrementCount}>count up!</button>
<div/>
)
}
가장 먼저 봤던 문제의 코드를 다시 보면 이제 이해할 수 있을 것입니다. App컴포넌트가 렌더링 되면 useState는 count상태를 0으로 초기화함과 동시에 그 시점의 상태 스냅샷인 count : 0을 반환합니다. count는 함수 호출시점의 상태이기 때문에 함수 실행 중에 절대로 변하지 않습니다.
따라서 setCount(count + 1)를 두 번 호출 하는 것은 count를 두번 증가시키는게 아닌 setCount(0 + 1)을 두 번 호출한 것과 동일합니다. 따라서 결과적으로 화면에 1이 출력된 것입니다.
또 콘솔창에는 0이 두 번 나옵니다. 이 역시 count는 컴포넌트 함수 실행 시점 상태의 스냅샷이기 때문입니다.
리액트는 상태 뿐만이 아닌 props, event handlers 또한 호출시점의 스냅샷을 유지하며 이는 실행 도중에 변하지 않습니다.
따라서, Hook을 사용하는 함수형 컴포넌트는 변경될 수 있는 자료구조를 사용하지 않으면서 상태를 관리할 수 있게 되면서 기존 클래스형 컴포넌트의 한계점을 극복했습니다.
State는 어떻게 유지되는 걸까?
function App() {
const [score, setScore] = useState(0)
let height = 100
const handleClick = () => {
setScore(score + 1)
height = height + 10
}
return (
<>
<h1>{score}</h1>
<h1>{height}</h1>
<button onClick={handleClick}>score up!</button>
</>
)
}
위 코드에서 height은 아무리 버튼을 눌러도 100에서 변하지 않습니다. 렌더링 사이에 변수가 저장되지 않고 계속해서 100으로 초기화되기 때문입니다.
함수는 기본적으로 실행이 끝나는 순간 해당 지역변수들은 모두 가비지 컬렉터에 의해 사라지게 됩니다.
하지만 useState()를 통해 만든 컴포넌트 상태는 컴포넌트 함수가 종료되어도 유지되며 컴포넌트가 리렌더링 되어 다시 실행될 때도 저장해둔 state의 스냅샷을 제공합니다.
이런 현상이 가능한 이유는 setState가 클로저를 형성하기 때문입니다.
function useState(initialValue) {
let state = initialValue;
function setState(newState) {
state = newState;
}
return [state, setState];
}
// 사용 예
const [count, setCount] = useState(0);
setCount(1); // 1
setCount(2); // 2
위는 간단히 구현한 useState입니다. 클로저는 함수와 함수가 선언된 어휘적 환경의 조합입니다. setState가 선언된 위치는 useState의 지역변수 state를 가 유효범위 내에 있기 때문에 어휘적 환경에 등록됩니다.
setCount는 setState를 참조하고 setCount가 유지되는 동안 state는 메모리 상에서 유지될 수 있습니다.
이런 방식으로 컴포넌트는 컴포넌트 인스턴스가 유지되는 동안 렌더링 사이클과 무관하게 state를 유지할 수 있는 것입니다.
배칭(batching)
글의 처음으로 돌아가보면 setState를 두 번 호출하면 두번의 리렌더링이 일어날 것이라고 예측했습니다. 하지만 실제로 리렌더링은 한번 일어났습니다.
function App() {
const [height, setHeight] = useState(100)
const incrementHeight= () => {
setHeight(200)
setHeight(300)
}
return (
<div>
<h1>{height></h1>
<button onClick={incrementHeight}>count up!</button>
<div/>
)
}
위 코드에서 버튼을 한 번 누르면 리렌더링은 한 번만 일어납니다. 0 => 200 => 300 순으로 화면이 바뀌는게 아니라 0 => 300으로 한번에 바뀌게 됩니다. 하지만 setState는 분명 리렌더링을 유발한다고 배웠습니다. 어떻게 된걸까요?
리액트는 setState호출을 큐(queue)에 등록한 뒤 모든 setState호출이 끝난 뒤에 리렌더링을 실행합니다. 이 동작은 배칭(batching)이라고 많이 알려져 있습니다.
리액트는 배칭으로 많은 리렌더링을 방지해 성능을 향상 시키고, 완료되지 않은 중간 상태 (위 코드에서는 height이 200인 상태)를 화면에 노출시키지 않습니다.
따라서 리렌더링은 최종상태 300이 되었을 때 한번만 일어나게 됩니다.
함수형 프로그래밍과 객체지향 프로그래밍의 차이부터 시작해서 클래스형 컴포넌트의 한계, hook의 등장, 그리고 useState의 동작방식까지 알아봤습니다.
제가 처음 useState를 배웠을 때는 왜 이렇게 이상하게 동작하는 걸까 생각했습니다. 하지만 클래스형 컴포넌트에서 부터 마주한 문제들과 그것을 해결하는 과정과 방법들을 알고 난 뒤 그렇게 한 이유와 작동 방식도 좀 더 깔끔하게 이해하게 됐습니다.
References