Web: useState의 동작 원리와 함정

Heechan
HcleeDev
Published in
13 min readMay 26, 2022
Photo by AbsolutVision on Unsplash

React Hooks에서 단연 많이 사용되는 것을 꼽자면 useState 가 아닐까 싶다.

수없이 많이 사용함에도 불구하고, useState 는 그냥 변화하는 상태값 정도로 생각했지 이게 제대로 어떻게 동작하는지는 알지 못했다. 상태는 변화하는데 왜 이게 const 로 선언되는지도 잘 몰랐고, 왜 바로바로 업데이트가 되지 않는지도 잘 몰랐다.

이번 주는 useState 의 동작 원리와 그로부터 발생할 수 있는 주의점을 알아보자.

Functional Component에서 상태를?

지난주에 올린 React Hook에 관련된 글에서도 말했지만, Functional Component는 기본적으로 불변성이라는 특징을 가진다.

Class Component는 this 가 가리키는 것, 내부 상태값의 변화 등의 문제로 인해 이 메서드를 실행하는 시점에 어떤 값이 어떨거라고 확신할 수가 없다는 문제를 가지고 있었다.

Functional Component의 불변성은 컴포넌트가 사용하는 값을 예측 가능하게 만들어준다. Functional Component는 컴포넌트가 함수의 반환 값으로 나타나고, 그 컴포넌트가 가지고 있는 값, 클로저 등의 정보는 기본적으로 변하지 않는다.

만약 리랜더링이 필요한 경우, Functional Component는 한 번 더 호출된다. 컴포넌트는 그대로 있고 내부에 있는 값만 변경되는 것이 아니고, 그냥 컴포넌트를 만들어주는 함수를 다시 호출한다. 즉, 새로운 컴포넌트가 만들어지는거다. Functional Component는 그 자체로 함수의 결과물이니, 그 함수 안에 적는 것들이 다시 처음부터 순서대로 진행된다. (엄밀히 따지면 첫 랜더링과 리랜더링은 조금 다르긴 하다)

하지만 Functional Component의 장점이었던 불변성은 단점이기도 했다. 상태가 변하지 않는 Pure한 컴포넌트로 모든 것을 만드는 것은 정말 어려운 일이다. 비슷한 이유로 학부 시절에 함수형 프로그래밍을 처음 배울 때 혼미했던 기억이 난다. 상태는 어쩔 수 없이 도입해야 한다. 과거의 React는 Class Component에서만 상태 관리하는 방법이 잘 제시되어있었기 때문에 Functional Component는 그리 복잡하지 않을 때나 사용되었다.

그러다 등장한 React Hook, useState 덕분에 Functional Component에서도 상태 관리를 할 수 있게 되었다.

const App = () => {
const [num, setNum] = useState(0);
return (<div onClick={() => setNum(num + 1)}>{num}</div>);
};

이렇게 하면 해당 <div> 를 클릭할 때마다 setNum 을 통해 num 에 1씩 더해진 값이 할당될 것이고, 리랜더링이 발생할 것이다. 리랜더링이 발생하면, 이 App 이라는 이름의 Functional Component는 다시 실행될 것이다.

그런데 그러면 어떻게 상태를 유지할 수 있는 것일까? 다시 App 이 실행되면, useState 는 다시 처음부터 실행되어 0을 넣는 것은 아닐까? 값을 어떻게 기억하고 있는걸까?

랜더링될 때마다 새롭게 불리는 것으로 보이는 Functional Component에서 값을 유지한다는 것은 쉬운 일이 아닌 것처럼 보인다. 애초에 불변성이라는 장점을 가졌다는 Functional Component에서 어떻게 상태를 구현할 수 있는지 상상하기 쉽지 않다. 이를 useState 는 어떤 방법을 이용해 해결한 것인지 원리를 알아보자.

간단히 알아보는 Closure

그 전에 이를 이해하기 위한 최소한의 JavaScript 개념을 짚고 넘어가야 한다. 바로 Closure다.

Closure는 여러 언어에 존재하고, 다들 비슷비슷한 느낌이다. 일단 다수의 명령 흐름을 하나의 Closure로 묶어서 여기저기 전달할 수 있도록 해준다는 점이 첫번째로 보이는 특징일 것이다.

다수의 명령 흐름을 묶는다는 의미로 생각해보면, 함수도 당연히 Closure의 일환이다. 함수 안에 여러 명령어들을 집어넣으니까 말이다.

하지만 JavaScript의 Closure에서는 주요하게 생각해야 하는 부분이 또 하나 있다.

바로 자신이 사용하는 변수를 기억하고 어딘가에 저장해두는 특성이 있다는 점이다. 이걸 변수를 Capture한다고 하는데, 일반적으로 사라져야 할 변수라도 어떤 클로저에서 사용된다면 사라지지 않고 잡아 붙들린 것이라고 생각할 수 있겠다.

가장 보편적으로 사용되는 예시를 하나 보자.

function outer() {
let outerVar = 1;
function inner() {
console.log(outerVar);
}
return inner;
}
const closure = outer();
closure(); // 출력: 1

단순히 생각해봤을 때 outer() 안의 outerVarouter() 의 호출이 끝나면 없어져야 한다. outer() 가 실행되는 scope의 Environment는 실행이 완료되면 분명 없어지긴 할테니까 말이다.

하지만 outerVar 는 당당히 살아남아 closure() 를 호출했을 때 1이 출력된다. 어떻게 된 일일까.

outer를 호출하면 그 반환 값으로 명령 흐름을 담고 있는 inner 라는 클로저를 얻을 수 있다. 이 inner 는 실행하면 outerVar 를 호출한다.

inner 가 정의되는 시점에 그 Environment에는 outerVar 가 존재한다. 심지어 inner 는 이 outerVar 를 사용하기도 한다. 따라서 이 inner 가 살아있는한 outerVar 도 죽지 못하고 붙들려 살아남게 된다.

따라서 outer() 가 실행되는 scope가 없어지더라도 아직 inner 가 살아있다면 outerVar 도 사라지지 않는다.

이것이 JavaScript Closure가 가지고 있는 중요한 특징이다.

useState의 동작 원리

useState 의 동작 원리도 위에서 설명한 Closure의 특징을 이용한다. 위의 예시에서도 ‘함수가 호출된 후에도 살아있는 변수’를 확인할 수 있었을 것이다.

이 개념을 잘 적용하면 Functional Component가 실행된 후에도 어떤 변수가 살아서 유지될 수 있도록 만들 수 있을 것 같은 느낌이 든다.

이를 이용해 useState 라는 메서드를 적절히 구현하면 아래와 같은 모습이 된다. 이 코드는 이 글에 있는 예제를 가져왔다.

const MyReact = (function() {
let _val // hold our state in module scope
return {
render(Component) {
const Comp = Component()
Comp.render()
return Comp
},
useState(initialValue) {
_val = _val || initialValue
function setState(newVal) {
_val = newVal
}
return [_val, setState]
}
}
})()

처음 봤을 때 딱 느낌이 안올 수 있겠다. 이 MyReact는 우리가 사용하는 React 모듈의 단순화라고 생각하면 된다.

MyReact 모듈은 익명 함수로부터 두 개의 Closure를 반환받아 저장하고 있는 모듈이다. renderuseState , 이 두 가지를 제공해주고 있다.

render 는 읽어보면 알 수 있듯 Functional Component를 랜더링해주는 메서드다.

_val 은 익명 함수 scope 안에서 정의되는 녀석으로, 우리가 원하는 상태를 저장해줄 것이다. 이는 익명 함수의 동작이 끝나고 나서도 유지된다. 이 _val 을 바라보고 있는 useState 가 MyReact에 저장되어있기 때문에, _val 는 이 메모리 세상 어딘가에 살아 숨쉴 것이다.

useState 를 살펴보자. _val 은 처음에는 undefined가 할당되어있을 수 밖에 없는데, undefined일 경우에는 initialValue 를 할당한다. useState 가 두 번째로 불릴 때부터는 _val 에 이미 값이 할당되었을 것이므로, 기존 값을 그대로 사용한다.

useState 메서드 안에도 setState 메서드가 있다. 컴포넌트가 useState를 사용하면 반환 받는 setter로, 컴포넌트에서 값을 업데이트할 때는 이 setState 를 이용하게 된다. 이 setState 는 모듈 scope에 정의되어있는 _val 을 변경한다.

그러면 위에서 만든 MyReact를 이용했을 때 생기는 상황을 생각해보자.

function Counter() {
const [count, setCount] = MyReact.useState(0)
return {
click: () => setCount(count + 1),
render: () => console.log('render:', { count })
}
}
let App
App = MyReact.render(Counter) // render: { count: 0 }
App.click()
App = MyReact.render(Counter) // render: { count: 1 }

Counter가 <div> 같은걸 가지고 있지는 않지만, 일종의 Functional Component라고 생각하고 보자.

MyReact의 render 를 이용해 첫 Counter를 랜더링한다. 랜더링하면 MyReact의 useState 가 실행된다. 이때는 _val 에 아무것도 없기 때문에 초기 값으로 들어간 0이 할당된다.

그러면 useState 는 그 반환값으로 count 를 0으로, setCount_val 에 접근하는 setter를 돌려줄 것이다. 그 후 Counter는 clickrender 가 들어있는 객체를 반환해 App에 저장된다. 일단 이 객체 입장에서 count 는 무조건 0이라는 값이다. (Functional Component의 불변성)

App.click()() => setCount(count + 1) 을 실행시킨다. 이러면 모듈 scope에 있는 _val 이 1으로 변경될 것이다.

아, 근데 setter를 실행시키면 리랜더링이 된다. 이거는 이 MyReact에는 딱히 구현되어있지 않지만 실제 React에서는 setter가 실행이 될 경우 컴포넌트를 리랜더링한다. 여기서도 그 과정을 보여주기 위해 App.click()render 를 한 번 더 실행한다.

다시 랜더링하면 마찬가지로 Counter는 const [count, setCount] = MyReact.useState(0) 부터 시작한다. 아까랑 같은 결과가 나와야 하지 않나? 라고 생각할 수 있지만, 아까와는 다르다. 다시 useState 를 불러와도, 모듈 scope에 정의되어있는 동일한 _val 을 보게 된다. 좀 전에 App.click() 을 하면서 이 _val 값은 1으로 변경되었다. 따라서 초기값 0이 아닌 1이 유지된다.

이러면 useState 가 반환하는 [count, setCount]count 값은 1이 된다. 이를 기반으로 다시 만들어지는 새로운 객체 입장에서는 count 값이 1인 상태로 굴러간다고 볼 수 있겠다.

이런 식으로 Closure의 특징을 이용해 Functional Component에서도 상태가 유지되는 useState 가 돌아간다고 볼 수 있다.

그런데, 한 컴포넌트에서 useState 를 한 번만 쓰지는 않는다. 여러 개의 useState 를 사용하는 경우가 대부분이다.

위처럼 _val 하나로는 여러 개의 useState 를 감당할 수 없다. 공부하면서 이 점이 상당히 궁금했는데, 당연하게도 실제 React는 내부에서 좀 더 복잡한 구현을 해둔 듯 하다.

출처: https://ko.reactjs.org/docs/hooks-faq.html#under-the-hood

React 문서를 보면 그 의문을 확인할 수 있었다. 각 컴포넌트에 대한 정보를 가지고 있는 메모리 공간이 각각 있는데, 여기서 여러 useState 를 구분할 수 있도록 정보를 저장하고 있는 것으로 보인다. 위에서 우리가 _val 로 만들었던 것을 여기선 보다 메모리를 잘 활용해 구현하고 있다고 생각할 수 있다.

자주 속는 함정

당장 위에서도 살짝 언급했지만, 우리는 useState 를 이용할 때 Functional Component의 불변성이라는 특징을 까먹고 이용하기도 한다.

이 불변성이라는 이름이 아직도 거부감이 들 수도 있는데, 위에서 확인했듯이 한 번 랜더링된 컴포넌트가 가지고 있는 상태값은 그 중간에 변하지 않는다.

[count, setCount]setCount 를 이용할 때, 이 setCount 는 메모리 어딘가에 있는 _val 을 변경한 것이지, 지금 옆에 가지고 있는 count 가 변경된 것이 아니다.

count 값이 새로운 값이 되는건 리랜더링이 된 이후다. (이때 count 는 바로 직전의 count 와는 전혀 관계가 없는 새로운 녀석이다.) 리랜더링할 때(= Functional Component가 다시 호출될 때) 다시 useState 를 부르면 그때 변경된 _val 값을 가져온다고 생각할 수 있다.

그래서 아래와 같은 경우에 당황한 적이 많았다. 여러분들도 분명히 있을거라 생각한다.

const [state, setState] = useState(0);useEffect(() => {
setState(state + 1); // 분명 state에 1을 더했는데?
console.log(state); // 호출: 0
}, []);

setState 를 이용한 직후에 state 값을 불렀는데, 업데이트가 되지 않는 상황이었다.

처음에는 뭔가 비동기적으로 발생해서 중간에 shadow 구간이 생기는건가… 라고 생각했는데 그게 아니었다.

state 값이 새로운 값이 되려면 리랜더링이 되어야 하는데, JS는 싱글 스레드로 돌아가기 때문에 useEffect 에 들어있는 콜백이 마무리된 이후에 리랜더링이 진행될 것이다. 그러면 아직 console.log(state) 를 실행하는 시점에는 리랜더링이 되기 전이라는거니까, state 값은 0인 것이 당연하다.

따라서 setter를 사용하고 바로 그 값을 이용하려고 한다면 useEffect 의 deps에 해당 state를 넣어 변경이 되었다는 점이 확인될 때 이용하거나 다른 방법을 찾아야 할 것이다.

결론

이번에 이 내용을 공부하면서 Functional Component와 useState 에 대해 한 층 더 깊게 알게되었다. 기존에는 그냥 아무 생각 없이 state 가 변한다고 생각하고 있었는데, 실제로는 변하지 않는다는 점..

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science