마지막날에 자바스크립트의 타입으로 시작해서 상태관리 임시 로직으로 끝나는 강의안을 PDF로 주시고 떠나신 코치님..광주 내려가는 길에 버스에서 간단하게 정리하려고 했는데, 생각보다 클로저 이후의 useState 구현하는 곳에서 진도가 안나갔다.. 그대로 집까지 가져와서 마무리한 이야기..
불변성(Immutability)
불변성은 한번 생성된 데이터가 변경되지 않는 특성을 의미한다. 즉, 데이터의 상태를 변경할 수 없도록 하여 코드의 예측 가능성과 안전성을 높이는 것이다.
상수 사용
상수에 원시값을 할당하면, 값이 변경되지 않기 때문에 불변성이 지켜진다.
Object.freeze
객체의 경우 주소값이 저장되는 것이기 때문에 참조 값은 변경될 수 있다. 따라서 Object.freeze를 통해 불변성을 유지해야한다.
클로저
클로저를 통해 변수를 보호하고 외부에서 직접 접근하거나 변경하지 못하게 하여 불변성을 구현할 수 있다.
클로저로 리액트 상태 관리하기
리액트의 상태 관리에서 중요한 개념 중 하나는 불변성이다. useState hook을 사용하여 상태 관리를하는데, 이를 클로저를 통해서 구현할 수 있다. 상태는 원시타입 뿐만 아니라 참조 타입도 존재하고, 복잡한 형태의 값들도 존재한다. 이 상황에서 리액트는 상태가 변경되었음을 어떻게 감지하고 어떻게 변경할 수 있을까?
일단 간단하게 클로저를 통해 useState를 만들어보자.
const useState = (initValue) => {
let state = initValue
const setState = (newValue) => {
state = newValue
return state
}
return [state, setState]
}
여기선 useState가 날아갔기 때문에, state와 useState의 참조 관계가 사라져 state 상태가 기억되지 않는다. 즉, 함수가 호출될 때마다 state가 초기화되며(state 변수가 함수 스코프 내에서만 유효) 상태가 지속되지 않기 때문에 업데이트가 제대로 동작하지 않는다. 따라서 상태를 함수 외부에 저장하거나 클로저를 이용해 별도로 기억해주는 함수를 만들어주어야한다.
const useState = (initValue) => {
let state = initValue
const getState = () => state
const setState = (newValue) => {
state = newValue
}
return [getState, setState]
}
const [count, setCount] = useState(0)
이렇게 하면 getState 함수가 state 상태를 기억하고 있기 때문에 setState로 인해 변경된 state 값을 가지고 올 수 있다. useState 함수는 state 변수를 함수 내부에 선언하고, 이를 접근할 수 있는 클로저를 형성하는 getState와 setState 함수를 반환한다. 쉽게 말해, getState와 setState 함수가 선언될 당시의 환경인 useState 내에 state가 포함되기 때문에, 이 함수들이 반환되어 useState의 컨텍스트가 사라졌음에도 클로저로 인해 참조할 수 있는 것이다.
그러나 우리는 useState의 state를 함수로 호출하지 않는다. 그리고 함수가 아니라 state를 바로 반환하면 함수 호출 시마다 새로운 값이 생성되어 상태가 유지되지 않는다.
그러면 state 값이 유지될 수 있도록 useState 자체가 어떤 함수로부터 반환되는 형태로, useState 함수를 감싸는 외부 함수를 만들어야 한다.
React 모듈을 만들어보자
const React = () => {
let value
const useState = (initValue) => {
const state = value ?? initValue
const setState = (newValue) => {
value = newValue
return value
}
return [state, setState]
}
return { useState }
}
useState 안에 있는 변수는 React 내부에 있는 value 변수를 받게 되거나, useState의 초기값을 의미한다. React 모듈이 처음 쓰였을 때는 value가 초기화되지 않았기 때문에 useState의 initValue인 외부에서 전달받는 초기값이 되는 것이고, React 모듈의 동작이 일어나 value 값에 대한 변경이 일어나면 변경이 일어난 value 값을 기억하는 것이다.
const [ count, setCount ] = React().useState();
React()를 하는 순간, React 모듈은 이미 동작되고 난 뒤이기 때문에 상태가 휘발되어버림
따라서 React 모듈이 있음을 기억하는 객체가 필요하다.
const MyReact = React();
const [ count, setCount ] = MyReact.useState(0);
이 상황에서 콘솔을 찍어보자.
console.log(count); // 0
setCount(5)
console.log(count) // 0
setCount를 통해 value를 5로 변경했음에도 왜 count는 0으로 나올까?
바로 함수는 선언된 당시의 값을 기억하기 때문이다. useState가 호출될 당시 MyReact의 value는 undefined였기 때문에, 두번째 MyReact.useState를 만들어주어 useState가 MyReact의 value인 5를 받으면서 count를 출력해야한다.
const [newCount] = MyReact.useState(0);
console.log(newCount); // 5
이제 MyReact에 React 모듈을 할당하는 것이 아니라 즉시실행함수를 통해서 React.useState로 만들어보자.
// const React = function() { ... } () 익명 함수를 사용한 즉시 실행 함수 표현식
const React = (() => {
let value
const useState = (initValue) => {
const state = value ?? initValue
const setState = (newValue) => {
value = newValue
}
return [state, setState]
}
return { useState }
})()
const [ count, setCount ] = React.useState(0);
const [newCount] = React.useState(0);
아직까지도 setCount를 통해서 state를 업데이트하더라도, state를 다시 선언해주어야한다. 하지만 리액트의 컴포넌트에서는 useState값으로 상태를 여러번 초기화하지 않는다. 즉, 리액트의 컴포넌트도 React 모듈 내부의 어떠한 함수로 래핑되서, 해당 value인 자체적인 상태 컴포넌트 인스턴스를 가져오는 것이다.
컴포넌트 래핑 함수 만들기
리액트 모듈에 컴포넌트 래핑 함수를 추가해보자.
const React = (() => {
let value
const useState = (initValue) => {
const state = value ?? initValue
const setState = (newValue) => {
value = newValue
}
return [state, setState]
}
const render = () => {
const Component = () => {
const [count, setCount] = React.useState(0)
console.log(count)
return null
}
return Component
}
return { useState, render }
})()
React.render 함수를 호출하면 Component 함수를 정의하고 반환한다. Component 함수는 useState를 호출하여 count 상태를 초기값 0으로 설정한다.
const MyComponent = React.render();
MyComponent() // count는 0
const [, setCount] = React.useState();
setCount(5) // 5가 value 값이 되며, React의 전역 상태로 유지
MyComponent() // count는 5
전역 상태를 업데이트할 수 있으나, 현재 컴포넌트 내부 상태값을 전달하는 것이 아니라 외부에서 useState를 가져오고 있다. 따라서 컴포넌트에서 setCount 값을 반환해야 한다.
const React = (() => {
let value
const useState = (initValue) => {
const state = value ?? initValue
const setState = (newValue) => {
value = newValue
}
return [state, setState]
}
const render = () => {
const Component = () => {
const [count, setCount] = React.useState(0)
return {
render: () => console.log(count),
click: (value) => setCount(value),
}
}
return Component
}
return { useState, render }
})()
이렇게 하면 Component.click을 이용해 내부의 상태를 변경시킬 수 있다.
React.render()().render(); // 0
React.render()().click(10);
React.render()().render(); // 10
React.render를 활용해 컴포넌트를 만들고, 컴포넌트를 호출하여 render와 click을 만들고 render를 통해서 상태를 불러오고 click을 통해서 값을 업데이트할 수 있다.
이를 조금 정리하여 React 모듈 내에서 해결해보자.
const React = (() => {
let value
const useState = (initValue) => {
const state = value ?? initValue
const setState = (newValue) => {
value = newValue
}
return [state, setState]
}
const render = (Component) => {
const C = Component()
C.render()
return C
}
return { useState, render }
})()
const Component = () => {
const [count, setCount] = React.useState(0)
return {
render: () => {
console.log(count)
},
click: (value) => setCount(value)
}
}
컴포넌트는 React 모듈의 value를 0으로 초기화한 count를 가진다. C.render()를 통해 0을 출력하고, React.render를 통해 해당 컴포넌트를 반환한다.
const a = React.render(Component) // count는 0
a.click(20) // count를 20으로 업데이트
const b = React.render(Component) // count는 20
그러면 전역 변수가 20으로 변경되고, 다시 컴포넌트를 생성하여 count를 불러오면 20이 출력된다. 이를 변경되면 바로 리렌더링시키도록 변경해보자.
리렌더링 추가하기
const React = (() => {
let state
let component
const useState = (initValue) => {
state = state ?? initValue
const setState = (newValue) => {
state = newValue
renderComponent(component)
}
return [state, setState]
}
const renderComponent = (Component) => {
component = Component
const instance = Component()
instance.render()
return instance
}
const render = (Component) => {
return renderComponent(Component)
}
return { useState, render }
})()
const Component = () => {
const [count, setCount] = React.useState(0)
return {
render: () => {
console.log(count)
},
click: (value) => setCount(value)
}
}
setState가 실행되면 renderComponent가 실행되며, 해당 컴포넌트를 다시 생성하여 반환함으로써 업데이트된 count가 렌더된다.
불변성 추가하기
지금까지 내부적으로 클로저를 사용하여 상태를 관리한다는 점을 확인했다. 그러나 현재 코드에서는 전역변수인 value에 새로운 값을 할당하는 것으로, 상태가 직접 수정되므로 불변성이 지켜지지 않는다. 따라서 useState를 참조값으로 변경하여 새로운 값을 할당해 불변성을 지켜야 한다. 간단하게 작성하기 위해 불변성을 관리하는 key를 직접 따로 추가해주었지만, 실제로 리액트에서는 useState 호출이 자신만의 독립적인 state 변수를 가짐으로써 관리된다. 이 지점에서 관리를 따로 해주지 않기 때문에 React는 훅의 호출 순서가 항상 같다고 가정하며, 조건문이나 루프 안에서 훅을 사용하지 말라고 권장한다.
const React = (() => {
let state = {}
let component
const useState = (key, initValue) => {
state[key] = state[key] !== undefined ? state[key] : initValue
const setState = (newValue) => {
state[key] = { ...state[key], ...newValue }
renderComponent(component)
}
return [state[key], setState]
}
const renderComponent = (Component) => {
component = Component
const instance = Component()
instance.render()
return instance
}
const render = (Component) => {
return renderComponent(Component)
}
return { useState, render }
})()
const Component = () => {
const [count, setCount] = React.useState('count', 0)
return {
render: () => {
console.log(count)
},
click: (value) => setCount(value)
}
}
실제로는 각각의 상태와 컴포넌트는 인스턴스로 관리되며, 컴포넌트는 실제로 DOM 요소를 반환할 것이다. 그러나 이러한 방식으로 리액트의 상태관리가 클로저를 활용해 상태를 관리하며, 불변성을 지키면서 업데이트되는 것을 이해할 수 있다.
정리
리액트의 상태는 내부적으로 클로저를 사용하여 각 컴포넌트의 상태를 관리한다. 이를 통해 상태는 컴포넌트의 생명주기 동안만 유지되며, 외부에서 접근할 수 없다. 또 내부에서 관리하는 값이 변경될 때마다 해당 값을 새로 생성하여 반환하기 때문에, 결론적으로 상태값 자체는 변경하지 않는 것이므로 불변성은 지켜진다.