마켓컬리 클론코딩을 진행하다가 장바구니 페이지에서 한 가지 문제점이 생겼었다.
간략하게 설명하자면, 유저가 + 버튼이나 - 버튼을 통해상품의 수량을 변경 시 setState를 통해 상품의 갯수가 원래 갯수에서 +1 되거나 -1 이 되어 변경된 수량을 서버로 보내는 로직을 구현하였다.
하지만 여기서 문제가 발생했다.
setState를 통해 변경되어야 할 state의 값이 항상 마지막 값을 못 가져오는 사태가 발생했다.
ex) 7번의 + 버튼 클릭을 통해 화면상에 나타나는 상품의 수량은 7이지만 함수를 통해 보내지는 상품의 수량은 6
이 문제를 해결하기 위해 함수형 업데이트, 수량 증가와 감소 시 비동기 처리 등등 정말 다양한 방법을 시도해 보았지만 문제의 원인은 바로 useState의 작동 원리에 있다는 것을 알게되었다.
아래의 간단한 예시 코드를 살펴보자
import React, { useState } from 'react';
const Example = () => {
const [state, setState] = useState(0);
const addQuantityHandler = () => {
console.log('클릭 이벤트 발생');
setState(state + 1);
if(state === 1){
console.log('과연 찍힐까?')
}
};
return (
<button onClick={addQuantityHandler}>수량 증가 버튼</button>
);
};
export default Example;
장바구니 페이지에서 발생한 문제와 동일하지만 비교적 매우 간단한 예시코드이다.
만약 여기서 버튼을 클릭하게 되면 과연 '과연 찍힐까?' 라는 부분이 콘솔에 출력될까?
정답은 '찍히지 않는다' 이다.
그렇다면 바로 콘솔을 확인해보자.
버튼을 처음 클릭했을 시 '클릭 이벤트 발생' 은 출력되지만 '과연 찍힐까?' 부분은 출력되지 않는다.
하지만 두번째 클릭 시 '클릭 이벤트 발생' 과 함께 '과연 찍힐까?' 가 찍히는 것을 알 수 있다.
이 글을 쓰게 된 이유가 바로 이 지점이다.
useState는 배열을 반환하며 배열의 첫 번째 요소는 <상태 값 저장 변수>(이하 state),
두 번째 요소는 <상태 값 갱신 함수>(이하 setState)이다.
우리는 이 배열을 구조분해할당을 통해 뽑아 사용한다 바로 아래와 같이 말이다.
const [state, setState] = useState(initiailState)
나는 그동안 이 setState가 state를 변경시킨다고 생각했지만 아니었다.
setState 함수를 실행하면 state 값을 재할당하는 것이 아닌
리렌더링을 시켜 state 자체를 새 값으로 선언해버리면서 동작한다.
아주 조금만 생각해보면 const로 선언했기 때문에 값이 재할당 되는 것이 아니라는 것을 금방 알 수 있었을텐데
setState로 state를 변경시키는 것이 너무나 당연하게 여긴 것이 이렇게 발목을 잡지 않았나 생각한다.
그럼 useState가 내부적으로 어떻게 동작하는지, 우리는 어떻게 변경된 상태값을 컨트롤하며 컴포넌트를 리렌더링 하는지 알아보자. (눈물 쓰-윽)
// ReactHooks.js
export function useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
위 사진은 node_modules/react/cjs/react.development.js 내부에 각종 hooks 함수가 선언된 곳인데 그 중 useState가 선언된 부분만 뽑아왔다.
useState는 dispatcher라는 인스턴스를 생성하고, 인자로 초기값을 받아 dispatcher.useState에 전달후 반환값을 return한다.
즉, dispatcher의 메소드 useState에 initialState를 전달하면 배열을 반환하고 그 안에 우리가 사용할 state와 setState가 있다는 말이다.
그렇다면 resolveDispatcher라는 친구는 어떤 친구일까?
더 거슬러 올라가서 dispatcher를 반환하는 resolveDispatcher 함수를 찾아보자.
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
if (__DEV__) {
if (dispatcher === null) {
console.error('Some error msg...');
}
}
return ((dispatcher: any): Dispatcher);
}
이 함수는 어디선가 dispatcher를 가져오고 에러처리를 하고 있다.
근데 자세히 살펴보면 새로운 키워드로 ReactCurrentDispatcher가 나왔다.
좀 더 거슬러 올라가보자.
const ReactCurrentDispatcher = {
/**
* @internal
* @type {ReactComponent}
*/
current: (null: null | Dispatcher),
};
거슬러 올라와보니 ReactCurrentDispatcher라는 친구는 전역에 선언된 객체의 프로퍼티이고, 속성으로 current를 가지고 있다.
이 current가 우리가 찾던 dispatcher가 담길 곳이라고 할 수 있다.
함수가 선언부보다 상위에 있는 값에 접근하는 것... 바로 Closure다.
(솔님 왈 얘네 클로져 겁나 좋아하네 ㅋㅋㅋㅋ)
핵심은 useState의 리턴 값의 출처가 전역에서 온다는 점이다.
이를 통해 우리는 리액트가 실제로 클로저를 활용해 함수 외부의 값에 접근하는 사실을 알 수 있다.
다시 처음에 작성한 예시와, 아주아주 간단하게 작성한 react 모듈 코드를 예로 들어보겠다.
Example 컴포넌트 함수는 실행되면 먼저 useState를 호출해서 반환값을 구조분해 할당으로 추출해 변수에 저장한다.
여기서 중요한건, Example도 jsx를 반환하는 함수일뿐이라는 점이다. 렌더링이 시작되면 이 함수가 호출되어 새로운 jsx을 반환한다.
아래의 react 모듈 코드를 보면, useState 밖에 전역으로 선언된 _value가 있다.
우리가 useState를 통해 관리하는 '상태'는 바로 이녀석이다.
setState는 Example함수에 선언된 state가 아니라, 자신이 선언된 위치에서 접근할 수 있는 _value를 변경하는 것이다
이는 closure의 개념을 알면 바로 이해할 수 있다.
setState가 리렌더링을 트리거하며 Example함수가 두 번째로 실행되었을 때
즉, setState 함수는 자신과 함께 반환된 변수를 변경시키는게 아니라, 다음 useState가 반환할 react 모듈의 _value를 변경시키고, 컴포넌트를 리렌더링 시키는 역할을 한다. 변경된 값은 useState가 가져온다.