class 컴포넌트에서 함수형 컴포넌트로 넘어간 이유
Class Component
Class Component를 이용해 만들어낸 컴포넌트는 말그대로 하나의 객체처럼 동작합니다. `this` 를 통해 자기 자신을 칭하고, 뭔가 변화가 생기면 `render()` 메서드를 다시 호출해 리랜더링하게 됩니다.
그런데 객체를 사용할 때 매번 언급되는 문제점이 있습니다. 보통 객체를 이용한 프로그래밍 방식에서는 객체가 가지고 있는 상태와 메서드가 적절히 어우러지도록 설계합니다. 이런 구조에서 메서드의 결과물은 상태의 영향을 받게 되고, 장점도 분명히 있겠지만 이로 인해 생기는 불편한 점도 분명히 있습니다.
예를 들자면, 특정 메모리를 차지하고 있는 객체가, 지니고 있는 상태가 변경되어서 리랜더링되어야 하는 상황을 생각해보자, `name` 이라는 state 값이 원래 ‘iOS’였다가, ‘Web’으로 변경된 상황일 때, 이미 상태가 변경되기 직전에 어떤 비동기 작업이 시작되었고 이 비동기 작업은 끝나고 나면 `name` 이라는 state 값을 프린트하게 됩니다.
그러면 시작할 때는 분명 ‘iOS’였을텐데, 비동기 작업이 끝났을 때 콘솔에 찍히는 값은 ‘Web’이 됩니다.
이렇게 상태에 따라 그 결과 값이 의도치 않게 변한다는 점이 문제가 되었고, 그 외에도 보일러플레이트 코드가 많다는 점이나, props 들을 직관적으로 확인하기 어렵다거나 하는 등의 문제가 있습니다.
또, Class Component는 `this` 가 가리키는 것, 내부 상태값의 변화 등의 문제로 인해 이 메서드를 실행하는 시점에 어떤 값이 어떨거라고 확신할 수가 없다는 문제를 가지고 있습니다.
Functional Component
Functional Component의 불변성은 컴포넌트가 사용하는 값을 예측 가능하게 만들어줍니다. Functional Component는 컴포넌트가 함수의 반환 값으로 나타나고, 그 컴포넌트가 가지고 있는 값, 클로저 등의 정보는 기본적으로 변하지 않습니다.
만약 리랜더링이 필요한 경우, Functional Component는 한 번 더 호출됩니다. 컴포넌트는 그대로 있고 내부에 있는 값만 변경되는 것이 아니고, 그냥 컴포넌트를 만들어주는 함수를 다시 호출한다. 즉, 새로운 컴포넌트가 만들어집니다. Functional Component는 그 자체로 함수의 결과물이니, 그 함수 안에 적는 것들이 다시 처음부터 순서대로 진행된다. (엄밀히 따지면 첫 랜더링과 리랜더링은 조금 다릅니다.)
하지만 장점이었던 불변성은 단점이기도한데 상태가 변하지 않는 Pure한 컴포넌트로 모든 것을 만드는 것은 정말 어려운 일입니다. 그래서 과거의 React는 Class Component에서만 상태 관리하는 방법이 잘 제시되어있었기 때문에 Functional Component는 그리 복잡하지 않을 때나 사용되었습니다.
ReactHook 등장
그러다 등장한 React Hook, `useState` 덕분에 Functional Component에서도 상태 관리를 할 수 있게 되었습니다. 애초에 불변성이라는 장점을 가졌다는 Functional Component에서 어떻게 상태를 구현할 수 있는지 상상하기 쉽지않은데, 이를 `useState` 는 어떤 방법을 이용해 해결한 것인지 원리를 알아보도록 하겠습니다.
Closure
useEffect의 작동 원리를 알아보기 전에 간단히 javascript의 Closure를 짚고 넘어가보도록 하겠습니다. Closure는 여러 언어에 존재하며 비슷비슷한 느낌으로 쓰입니다. 다수의 명령 흐름을 하나의 Closure에 묶어 여기저기 전달할 수 있도록 해준다는 점이 첫번째 특징입니다. 이런의미에서는 함수도 Closure의 일환일 수 있습니다.
하지만, Javascript에서의 Closure에는 하나 더 주요한 부분이 있습니다.
바로 자신이 사용하는 변수를 기억하고 어딘가에 저장해두는 특성이 있다는 것입니다. 이것을 변수를 Capture한다고 하며, 일반적으로 살가져야 할 변수라도 어떤 Closure에서 사용한다면 사라지지 않고 잡아 붙들려 있다고 생각할 수 있습니다.
function outer() {
let outerVar = 1;
function inner() {
console.log(outerVar);
}
return inner;
}
const closure = outer();
closure(); _// 출력: 1_
단순히 보면 outer() 안의 outerVar는 outer()의 호출이 끝나면 없어져야 합니다. 하지만 outerVar는 살아남아 closure()를 호출했을 때 1이 출력됩니다.
outer를 호출하면 그 반환 값으로 명령 흐름을 담고 있는 inner 라는 클로저를 얻을 수 있는데, 이 inner를 실행하면 outerVar를 호출합니다.
이 inner가 정의되는 시점에 그 환경에는 outerVar가 존재하므로, inner는 이 outerVar를 사용하기도 합니다. 따라서 이 inner가 살아 있는한 outerVar를 사라지지 않고 붙들려 살아있게 됩니다.
따라서, outer()가 실행되는 scope가 없어지더라도 아직 inner가 살아있다면 outerVar도 사라지지 않습니다.
useState의 작동원리
useState는 바로 직전에 알아본 Closure의 특징을 이용하게 됩니다. 위의 예시에서 함수가 호출된 후에도 살아있는 변수를 확인할 수 있었는데, 이 점을 이용하여 Functional Component가 실행된 후에도 어떤 변수가 살아서 유지될 수 있도록 구현할 것입니다.
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 모듈의 단순화라고 생각하자. 이 모듈은 익명 함수로부터 render와 useState, 두 개의 Closure를 반환받아 저장하고 있습니다.
render는 Functional Component를 렌더링해주는 메소드이고
_val은 익명 함수 scope 안에서 정의되는 녀석으로, 우리가 원하는 상태를 저장합니다. 이는 익명 함수의 동작이 끝나고 나서도 유지되는데 이 _val를 바라보고 있는 useState가 MyReact에 저장되어 있으므로, _val은 메모리 어딘가에 살아있게 됩니다.
useState를 보면 _val은 처음에 undefined가 할당되어 있을 수 밖에 없는데, undefined일 경우엔 initialValue를 할당하게 됩니다. useState가 두번째로 불릴 때부터는 _val에 이미 할당 값이 있으므로 기존 값을 그대로 사용하게 됩니다.
useState 메서드 안에서도 setState 메서드가 있습니다. 컴포넌트가 useState를 사용하면 반환 받는 setter로 컴포넌트에서 값을 업데이트할 때는 이 setState를 이용하게 되고 이 setState는 모듈scope에 있는 _val를 변경하게 됩니다.
여러 개의 useState를 사용한다면..?
작업을 하다보면 한 컴포넌트에서 useState를 한 번만 쓰지 않고 여러 개를 사용할 때가 많습니다. 그렇다면 위처럼 _val 하나로는 여러개의 useState를 당연히 감당할 수 없습니다. 그러므로 React는 내부에 더 복잡한 무언가를 구현한 듯 합니다.
React 문서에 나와있는 내용으로 각 컴포넌트에 대한 정보를 가지고 있는 메모리 공간이 각각 존재하는데 여기서 여러 useState를 구분할 수 있도록 정보를 저장하고 있는 것으로 보입니다.
착각하는 점
보통 useState를 이용할 때 Functional Component의 불변성을 까먹고 사용할 때가 있습니다. 위에서 확인했듯이 한 번 랜더링된 컴포넌트가 가지고 있는 상태값은 그 중간에 변하지 않습니다.
예를 들어, [count, setCount]의 setCount를 이용할 때, 이 setCount는 메모리 어딘가에 있는 _val을 변경한 것이지, count가 변경된 것이 아닙니다.
이 count 값이 새로운 값이 되는건 리렌더링 이후입니다. 이 때 count는 바로 직전의 count와는 전혀 관계 없는 새로운 것 입니다. 리렌더링할 때 다시 useState를 부르면 그때 변경된 _val 값을 가져온다고 생각할 수 있습니다.
const [state, setState] = useState(0);useEffect(() => {
setState(state + 1); // 분명 state에 1을 더했는데?
console.log(state); // 호출: 0
}, []);
setState를 이용한 직후 state 값을 호출했는데, 업데이트가 되지 않은 상황이다. 비동적으로 발생해서 중간에 shadow 구간이 생기는걸로 오해할 수 있는데 state 값이 새로운 값이 되려면 리렌더링이 되어야 하는데, Javascript는 싱글 스레드이므로 useEffect에 들어있는 콜백이 마무리된 이후에 리렌더링이 진행됩니다. 그렇다면 아직 console.log를 실행하는 시점에는 리렌더링이 되지 않기 때문에 state값이 0인 것을 알수 있습니다. 따라서 setter를 사용하고 바로 그 값을 이용하려면 useEffect의 deps에 해당 state를 넣어 변경이 되었다는 점이 확인될 때 이용하거나 다른방법을 사용해야합니다.
마치며
이번 블로그 글을 작성하면서 한 번도 궁금함을 가지지 못했던 것을 학습한 느낌이라 배운 것이 많았습니다. 역시 모든건 원리를 알고 있는 것이 많은 도움이 되는구나라는 생각을 했습니다. 다음에도 당연하게 무의식적으로 사용하는 것들의 원리나 만들어진 이유를 궁금해하는 시간을 가져보면 좋을 것 같습니다.