티스토리 뷰
State: A Component's Memory
프론트엔드 개발에서 상태(state)는 사용자 인터페이스를 이루는 특정 시점의 데이터를 말한다. 따라서 상태는 동적으로 변화하는 데이터다. 사용자와의 상호작용이나 네트워크 응답과 같은 이벤트에 따라 변경되므로 UI의 렌더링에 직접적인 영향을 미친다. 상태에 따라 UI는 표시되거나 숨겨질 수 있다. 또 사용자와의 상호작용 중 현재 시점의 맥락을 의미하기도 한다. 입력값, 목록에서 선택된 항목, 여러 단계의 프로세스가 있는 경우 현재 위치한 페이지를 기억할 필요가 있다. 이는 모두 '상태'다.
React 컴포넌트의 상태란 일종의 컴포넌트 별 메모리다. 각각의 컴포넌트가 기억해야 하는 정보를 의미한다.
function App(): React.ReactElement {
let index: number = 0;
function handleClick(): void {
index = index + 1;
}
let sculpture: SculptureType = sculptureList[index];
return (
<>
<button onClick={handleClick}>Next</button>
<h2>
<i>{sculpture.name} </i>
by {sculpture.artist}
</h2>
<h3>
({index + 1} of {sculptureList.length})
</h3>
<img src={sculpture.url} alt={sculpture.alt} />
<p>{sculpture.description}</p>
</>
);
}
export default App;
Next 버튼을 클릭했을 때 `index` 값을 증가시켜 `sculptureList` 배열의 각 인덱스에 접근하려 한다. 그러나 이 코드는 정상동작 하지 않는다. 그 이유는 다음과 같다.
- 지역 변수는 렌더링 간에 유지되지 않는다. React가 이 컴포넌트를 재렌더링 할 때 지역변수의 변경 사항은 고려하지 않고 처음부터 렌더링 한다.
- 지역 변수를 변경해도 렌더링이 트리거되지 않는다. 새로운 데이터로 컴포넌트를 다시 렌더링해야 한다고 인식하지 않는다.
`button` 요소에 `onClick`으로 함수가 적용되어 있기 때문에 `index`의 값은 버튼 클릭 시마다 업데이트 된다. 그러나 이 작업이 컴포넌트를 다시 렌더링하는 트리거 역할을 하지는 않는다. React가 감지하는 것은 상태(state)나 props다. 즉 `index`는 state나 props로 관리되고 있지 않기 때문에 React는 이를 변경된 것으로 감지하지 못한다.
`index`를 props로 받지 않고, 컴포넌트 내부의 상태(state)로 관리하려면 `useState`로 렌더링 간 데이터를 유지하고, 변수를 업데이트해야 한다. `useState`는 함수형 컴포넌트에서 상태를 추가하고 관리하는 매커니즘이다.
function App(): React.ReactElement {
const [index, setIndex] = useState<number>(0);
function handleClick(): void {
setIndex(index + 1);
}
// ...
}
`useState` 훅을 가진 컴포넌트를 두 번 렌더링한다면 각 컴포넌트는 완전히 격리된 state를 가진다. 따라서 한 컴포넌트의 변경(리렌더링)이 다른 컴포넌드에 영향을 미치지 않는다. props와 달리 상위 컴포넌트는 하위 컴포넌트의 state를 알 수 없다. 즉 상위 컴포넌트는 하위 컴포넌트의 state를 변경할 수 없다.
Render and Commit
컴포넌트를 화면에 표시하려면 React에서 렌더링 과정을 거쳐야 한다.
컴포넌트 렌더링이 일어나는 데에는 두 가지 이유가 있다.
- 컴포넌트의 초기 렌더링인 경우
- 컴포넌트의 state가 업데이트 된 경우
초기 렌더링의 경우 앱 시작 시 다음 코드가 호출되는 것을 의미한다.
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'))
root.render(<Image />);
초기 렌더링 이후에는 컴포넌트의 state가 업데이트 될 때 렌더링이 트리거 된다. 렌더링은 결국 React에서 컴포넌트를 호출하는 것이다. 초기 렌더링을 할 때 React는 필요한 DOM 노드를 생성하고, 리렌더링을 할 때에는 이전 렌더링 이후 변경된 속성을 계산한다. 커밋 전까지는 아무런 작업도 수행하지 않는다. 렌더링 간 차이가 있는 경우에만 DOM 노드를 변경한다(커밋). 렌더링 결과가 이전과 같으면 React는 DOM을 건드리지 않는다.
State as a Snapshot
컴포넌트 state는 스냅샷처럼 동작한다. state는 컴포넌트의 메모리로 함수가 반환된 후 사라지는 일반 변수와는 다르게 동작한다. React가 컴포넌트를 호출하면(렌더링하면) 그 렌더링에 대한 state 스냅샷을 제공한다. 따라서 컴포넌트는 해당 렌더링의 state값을 사용해 계산된 새로운 props와 이벤트 핸들러가 포함된 UI 스냅샷을 JSX에 반환한다.
아래 코드는 버튼을 한 번 클릭하면 `setNumber()` 함수가 3번 호출된다.
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>
</>
);
}
`setNumber()` 함수가 3번 호출되기 때문에 버튼을 한 번 클릭할 때 `number`가 3씩 증가할 것 같지만 클릭 당 한 번만 증가한다. React는 상태 업데이트를 비동기적으로 처리한다. `setNumber()` 함수 자체는 여러 번 호출되지만 이전에 호출된 `setNumber()`의 결과가 즉시 반영(동기적으로 처리)되지 않고 모든 상태 업데이트는 한 번의 렌더링 주기에서 처리된다. 따라서 `number` 상태는 한 번만 증가한다. 다르게 이야기하면 이전 상태를 기반으로 새로운 상태를 계산하는 것이다.
최초 렌더링 시 `number`의 값은 0이다. 함수가 3번 호출되더라도 각 함수 호출 시 `number`의 값은 0이므로 `number`는 1로 3번 설정된다.
만약 아래와 같이 버튼 클릭 시 `number`에 5를 더하고, 동시에 `alert()`을 실행한다면 경고창에 뜨는 `number`의 값은 0일 것이다.
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
alert(number);
}}
>
+5
</button>
</>
);
}
`alert()` 함수에 타이머를 설정해 컴포넌트가 렌더링 된 다음에 실행되도록 하더라도 경고창에 표시되는 `number`의 값은 여전히 0이다. 실제로는 화면에 5가 먼저 렌더링 되더라도, 시간차를 두고 동작한 `alert()`은 여전히 기존의 상태값을 기반으로 동작한다. 즉 스냅샷을 찍을 때 고정된 값이다.
렌더링 도중 state가 변경되더라도 렌더링 시점의 state 값을 기반으로 동작한다. 그렇다면 다시 렌더링하기 전에 최신 state를 반영하려면 어떻게 해야 할까?
Queueing a Series of State Updates
state를 설정하면 다음 렌더링이 큐에 들어간다. 그러나 다음 렌더링을 큐에 넣기 전에 state 값에 대해 여러 작업을 수행하고 싶은 경우 어떻게 할 수 있을까?
React는 기본적으로 state를 업데이트 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 대기한다. 따라서 컴포넌트 리렌더링은 `setNumber()` 함수 호출이 완료된 이후에 일어난다. 그러면 너무 많은 리렌더링이 발생하는 것을 막을 수 있다. 하지만 다르게 이야기하면 이는 이벤트 핸들러와 그 안의 모든 함수가 실행 완료되기 전까지 UI가 업데이트 되지 않는다는 것이기도 하다.
버튼 클릭 시 `onClick` 이벤트로 `setNumber(state + 1)`을 3번 호출하는 대신 `setNumber(n => n + 1)`을 3번 호출하도록 변경하면 `number`는 3씩 증가한다. 이는 단순히 state 값을 전달하는 것이 아니라 함수를 큐에 추가하는 것이다. 이벤트 핸들러의 다른 코드가 실행된 이후 `n => n + 1` 함수가 실행된다. 렌더링 중 React는 큐를 순회하고 최종적으로 업데이트 된 state를 반환한다.
아래와 같이 버튼 클릭 시 `setNumber(number + 1)`, `setNumber(n => n + 1)`이 호출되는 경우를 생각해볼 수 있다.
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
}}
>
+5
</button>
</>
);
}
`setNumber(state + 5)`가 실행될 때 `number`는 0이다. 큐에 state를 5로 변경한다. `setNumber(n => n + 1)` 함수가 큐에 추가되고, 렌더링을 하는 동안 큐를 순회한다. state는 5로 변경되고, 이후 `n => n + 1`이 실행되며 최종적으로 `number`는 6이 된다.
렌더링 중에 큐를 순회하므로 업데이터 함수는 렌더링 중에 실행된다. 따라서 업데이터 함수는 항상 순수해야 한다. 업데이터 함수 내에서 state를 변경하는 것은 권장되지 않는다.
아래 컴포넌트는 Buy 버튼을 누를 때마다 Pending 카운터가 증가한다. 3초가 지나면 Pending 카운터가 감소하고 Completed 카운터가 증가해야 한다. 그러나 Buy 버튼을 누르면 Pending 카운터가 -1이 되고, 빠르게 두 번 누르면 두 카운터 모두 예측 불가능한 값을 반환한다.
export default function RequestTracker() {
const [pending, setPending] = useState<number>(0);
const [completed, setCompleted] = useState<number>(0);
async function handleClick() {
setPending(pending + 1);
await delay(3000);
setPending(pending - 1);
setCompleted(completed + 1);
}
return (
<>
<h3>Pending: {pending}</h3>
<h3>Completed: {completed}</h3>
<button onClick={handleClick}>Buy</button>
</>
);
}
function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
`handleClick` 이벤트 핸들러 내부에서 `setPending(pending + 1)` 이후 `setPending(pending - 1)` 함수로 `pending`의 값을 변경한다. 이 때 `pending`은 0이므로 최종적으로 `pending`은 `0 - 1`이 된다.
async function handleClick() {
setPending((p) => p + 1);
await delay(3000);
setPending((p) => p - 1);
setCompleted((c) => c + 1);
}
따라서 위와 같이 업데이터 함수를 전달해 클릭 당시의 state를 기반으로 연산하지 않고, 최신 state를 기반으로 동작하도록 변경해야 의도대로 동작한다.
state 큐를 직접 구현한다면 다음과 같이 해볼 수 있다.
function increment(n) {
return n + 1;
}
increment.toString = () => 'n => n+1';
export default function App() {
return (
<>
<TestCase
baseState={0}
queue={[1, 1, 1]}
expected={1}
/>
<hr />
<TestCase
baseState={0}
queue={[
increment,
increment,
increment
]}
expected={3}
/>
<hr />
<TestCase
baseState={0}
queue={[
5,
increment,
]}
expected={6}
/>
<hr />
<TestCase
baseState={0}
queue={[
5,
increment,
42,
]}
expected={42}
/>
</>
);
}
function TestCase({
baseState,
queue,
expected
}) {
const actual = getFinalState(baseState, queue);
return (
<>
<p>Base state: <b>{baseState}</b></p>
<p>Queue: <b>[{queue.join(', ')}]</b></p>
<p>Expected result: <b>{expected}</b></p>
<p style={{
color: actual === expected ?
'green' :
'red'
}}>
Your result: <b>{actual}</b>
{' '}
({actual === expected ?
'correct' :
'wrong'
})
</p>
</>
);
}
`TestCase` 컴포넌트에 props로 `baseState`, `queue`, `expected`가 전달된다.

`queue`를 순회해 타입이 `function`인 경우 해당 함수를 실행한다. 그 외에는 `finalState`를 덮어씌우면 된다.
export function getFinalState(baseState, queue) {
let finalState = baseState;
for (let update of queue) {
if (typeof update === "function") {
finalState = update(finalState);
} else {
finalState = update;
}
}
return finalState;
}
Updating Objects in State
컴포넌트 state는 객체를 포함한 모든 종류의 값을 가질 수 있다. 그러나 객체의 경우 이를 직접적으로 변경해서는 안 된다. 객체를 업데이트 하려면 기존 객체의 복사본을 만들어 state가 복사본으로 업데이트 되도록 해야 한다. 객체가 아닌 숫자나 문자, 불리언 값의 경우 원시 값이기 때문에 불변성을 갖는다. 반면 객체나 배열의 경우 참조형이기 때문에 참조가 변경되지 않는다면 해당 객체나 배열의 변경을 감지할 수 없다.
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0,
});
return (
<div
onPointerMove={(e) => {
position.x = e.clientX;
position.y = e.clientY;
}}
style={{
position: "relative",
width: "100vw",
height: "100vh",
}}
>
<div
style={{
position: "absolute",
backgroundColor: "red",
borderRadius: "50%",
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}}
/>
</div>
);
}
`position`에 할당된 객체를 `onPointerMove` 이벤트 핸들러에서 수정하고 있지만 React는 값의 변경을 감지하지 못한다.
setPosition({
x: e.clientX,
y: e.clientY,
});
그러나 위와 같이 새로운 객체를 만들어 전달하면 state를 새로운 객체로 교체하게 되고, 변경을 감지할 수 있다.
배열도 마찬가지다.
'Note' 카테고리의 다른 글
| 순수한 컴포넌트 (0) | 2024.05.31 |
|---|---|
| Spring Security OAuth 2.0 인증 과정 자세히 살펴보기 (0) | 2024.05.24 |
| Spring Data JPA 기본 구현체 분석 (0) | 2024.05.24 |
| Spring은 DB Transaction을 어떻게 알아서 처리할까? (0) | 2024.05.24 |
| Java Optional (0) | 2024.05.24 |
- Total
- Today
- Yesterday
- JPA
- Misc
- Transaction
- askers
- Spring Security
- Bandit
- sql injection
- webgoat
- sqli
- XSS
- DP
- CSRF
- test
- WEB
- Dreamhack
- oauth2
- React
- WarGame
- math
- Spring
- opengraph
- java
- linux
- Database
- SEO
- PS
- Framework
- 회고
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |