teklog

리액트 상태와 불변성 2

2023/07/26

n°39

category : React

 state와 상태 불변성에 대한 궁금증에서 시작된 리액트 상태 메모. 지난 글을 쓰다보니, 자연스럽게 이해되는 부분이 있어 먼저 정리하고 넘어간다. 


리액트 상태 State

state는 렌더링 사이에서 값을 저장 (데이터를 보관)

setter는 리렌더링을 트리거 


함수형 컴포넌트 기준으로 더 이해가 쉽다. 리액트 컴포넌트는 결국 (render()를 반환하는) 함수이다. 이 함수(컴포넌트)는 setter가 렌더링을 트리거 하고 난 뒤 다시 실행된다. 리렌더링이 시작되는 것은 결국 컴포넌트 함수가 재실행되는 것이다. 이 때 지역변수로 있던 데이터는 이 컴포넌트가 실행될 때마다 매번 다시 초기화와 할당이 이루어질 것이다. 하지만 함수가 재실행되기 이전의 데이터에 접근할 필요가 자주 생긴다. 버튼 클릭 여부에 따라 다른 ui를 보여주는 등 수많은 사용자 이벤트의 경우를 떠올릴 수 있다. 즉 일반적인 변수로는 어려운, life cycle이 끝난 함수 내부의 지역변수에 접근이 필요하다. 바로 클로저가 떠오른다. 이 변수와 변수의 할당을 useState라는 훅을 이용해 제공하고 있는 것이다.


언뜻 당연한 내용을 한번 이해하고, docs의 이어지는 내용을 보면 이해가 더 깊어진다.


State as a Snapshot


랜더링은 시간의 스냅샷을 촬영합니다.


"랜더링(Rendering)"은 React가 컴포넌트를 호출하는 것을 의미합니다. 그 컴포넌트는 함수입니다. 해당 함수에서 반환하는 JSX는 한 순간의 UI 스냅샷과 같습니다. 해당 스냅샷의 속성(props), 이벤트 핸들러(event handlers), 그리고 지역 변수들은 랜더링(render)할 때의 상태(state)를 사용하여 모두 계산되었습니다.


사진이나 영화 프레임과 달리 반환하는 UI "스냅샷"은 상호작용적입니다. 이는 입력에 대한 응답으로 어떤 작업이 수행되는 이벤트 핸들러와 같은 로직을 포함합니다. React는 화면을 이 스냅샷과 일치하도록 업데이트하고, 이벤트 핸들러를 연결합니다. 따라서 버튼을 누르면 JSX에서 지정한 클릭 핸들러가 실행됩니다.


React가 컴포넌트를 다시 랜더링할 때:

React는 다시 함수를 호출합니다.

함수는 새로운 JSX 스냅샷을 반환합니다.

React는 반환된 스냅샷과 일치하도록 화면을 업데이트합니다.


컴포넌트의 상태(State)는 함수가 반환된 후 사라지는 일반적인 변수와는 다릅니다. 상태(State)는 실제로 React 자체에 존재하며, 마치 함수 바깥에 있는 선반에 놓여 있는 것처럼 작동합니다. React가 컴포넌트를 호출할 때 해당 랜더(render)를 위한 상태(State)의 스냅샷을 제공합니다. 컴포넌트는 JSX에서 이 스냅샷과 함께 새로운 속성(props)과 이벤트 핸들러를 반환합니다. 이 모든 값들은 해당 랜더(render)의 상태(State)에서 계산되어진 것입니다!



자바스크립트의 클로저라는 개념없이도 쉽고 간결하게 설명하고 있다. 스냅샷이라는 개념이 흥미롭게 다가온다. 단순한 토글 버튼을 생각해볼 수 있다. 버튼을 누르면 내용이 나오고, 다시 누르면 내용이 감춰진다. 상태라는 스냅샷은 버튼이 눌린 상태가 true이냐 false이냐에 따라 다른 내용을 보여준다. 만약 state가 일반적인 변수처럼 함수가 재실행될 때마다 모두 초기값으로 할당된다면, 버튼을 아무리 눌러도 토글은 열리지 않을 것이다. 즉 상태를 업데이트할 수 없을 것이다. 이를 문서에선 마치 함수(컴포넌트) 바깥에 있는 선반에 놓여있다-고 표현한다.



1 : no rerender
const isClicked = false;
const setIsClicked = (val:boolean) => !val

2 : react state
const [isClicked , setIsClicked] = useState(false)
..
return (<>
<button onClick={ () => 
1 : setIsClicked(isClicked) }
2 : setIsClicked((prev) => !prev) }
> 
{isClicked ? “접기” : “열기”}
 </button>
{isClicked ? <List /> : null}
<>)


1번의 방식으로 지역변수에 할당된 값은 함수의 초기 렌더링이 끝난 후, 리렌더링을 트리거링 하지 않기 때문에 아무일도 일어나지 않을 것이다. 설령 같은 컴포넌트에 다른 상태가 리렌더링을 트리거하여도, 다시 실행된 컴포넌트 함수는 isClicked는 영원히 false일 것이다. 


따라서 state를 스냅샷으로 표현하는 이유가 납득된다. 마치 시간과 함께 기록된 사진처럼, 리렌더링이 발생할 때 상태를 기점으로 렌더링 이전과 이후 상태를 비교하게 된다. 하지만 state의 시간은 동기적 프로그래밍을 떠올리게 하는 순차적인 시간이 아니다. 다음 흥미로운 예제가 이어진다.



export default function Counter() {
 const [number, setNumber] = useState(0);

 return (
  <>
   <h1>{number}</h1>
   <button onClick={() => {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
   }}>+3</button>
  </>
 )
}


클릭 당 숫자가 한 번만 증가하는 것을 주목하세요!

상태(State)를 설정하면 다음 랜더(render)를 위해 변경됩니다. 첫 번째 랜더(render)에서 숫자(number)는 0이었습니다. 이것이 첫 번째 랜더(render)의 onClick 핸들러에서 setNumber(number + 1)이 호출되었음에도 불구하고, 숫자(number)의 값이 여전히 0인 이유입니다:



<button onClick={() => {
 setNumber(number + 1);
 setNumber(number + 1);
 setNumber(number + 1);
}}>+3</button>


클릭 당 숫자가 한 번만 증가하는 것을 확인하세요!

상태(State)를 설정하면 다음 랜더(render)를 위해 변경됩니다. 첫 번째 랜더(render)에서 숫자(number)는 0이었습니다. 따라서 그 랜더(render)의 onClick 핸들러에서 setNumber(number + 1)이 호출되더라도 number의 값은 여전히 0입니다. .. setNumber(number + 1)을 세 번 호출했지만, 이 랜더(render)의 이벤트 핸들러에서 number는 항상 0입니다. 따라서 상태(State)가 세 번 1로 설정됩니다. 이로 인해 이벤트 핸들러가 완료된 후 React는 number가 3이 아닌 1로 설정된 상태(State)로 컴포넌트를 다시 랜더(render)합니다.

또한 코드에서 상태 변수를 해당 값으로 대체하여 이를 시각적으로 확인할 수 있습니다. 이 랜더(render)에서 number 상태 변수가 0이기 때문에 해당 이벤트 핸들러는 다음과 같아 보입니다:


<button onClick={() => {
 setNumber(0 + 1);
 setNumber(0 + 1);
 setNumber(0 + 1);
}}>+3</button>


state를 기점으로 이전과 이후의 상태를 비교하므로, setNumber 내부의 number를 리액트는 모두 0으로 기억하게 되는 것이다. 이는 setTimeout같은 비동기 함수를 사용하여도 마찬가지이다. 



  <>
   <h1>{number}</h1>
   <button onClick={() => {
    setNumber(number + 5);
    setTimeout(() => {
     alert(number);
    }, 3000);
   }}>+5</button>
  </>


React에 저장된 상태(State)는 알림이 실행될 때 변경되었을 수 있지만, 알림은 사용자가 상호작용한 시점의 상태(State) 스냅샷을 사용하여 예약되었습니다! 랜더(render) 내부에서 비동기적으로 동작하는 이벤트 핸들러 코드라도, 상태 변수의 값은 랜더(render) 내에서는 변경되지 않습니다. 해당 랜더(render)의 onClick에서 setNumber(number + 5)가 호출된 후에도 숫자(number)의 값은 여전히 0입니다. React가 컴포넌트를 호출하여 UI의 "스냅샷"을 가져올 때, 해당 값은 "고정"되었습니다.


비동기 로직으로 컴포넌트 (리)렌더링이 끝난 이후에 실행되더라도, 비동기 함수 내부의 number는 리렌더링 이전의 상태 0을 기억하기 때문에 0을 alert한다.


랜더(render) 내부에서 비동기적으로 동작하는 이벤트 핸들러 코드라도, 상태 변수의 값은 랜더(render) 내에서는 변경되지 않습니다. 해당 랜더(render)의 onClick에서 setNumber(number + 5)가 호출된 후에도 숫자(number)의 값은 여전히 0입니다. React가 컴포넌트를 호출하여 UI의 "스냅샷"을 가져올 때, 해당 값은 "고정"되었습니다. .. React는 랜더(render) 내의 이벤트 핸들러에서 상태(State) 값을 "고정"시킵니다. 즉, (비동기)코드가 실행되는 동안 상태가 변경되었는지 여부를 걱정할 필요가 없습니다.


이벤트 핸들러의 값과 연결된 state 또한 렌더 시점에 고정된다. 쉽게 이해하면 이런 경우를 생각할 수 있다. 비밀번호 입력란에 다섯자리를 입력하고 버튼을 눌러 전송했다. 실수로 여섯일곱자리까지 추가로 입력하더라도 이미 버튼을 누른 시점에 클라이언트에 저장된 상태가 요청에 담겨 api를 호출한 이후일 것이다.


..읽다보니 리액트 문서가 재밌어서 더 이어집니다 (다음은 batching에 대한 내용)