State as a Snapshot

김동현·2026년 3월 15일

title: 스냅샷으로서의 State (State as a Snapshot)

안녕하세요! 프론트엔드 개발의 세계로 오신 것을 환영합니다. 리액트(React)를 공부하시다 보면 'State(상태)'라는 개념이 정말 자주 등장하죠. 오늘은 공식 문서의 "스냅샷으로서의 State" 파트를 함께 읽어보며, 여러분이 이 개념을 완벽하게 이해할 수 있도록 도와드릴게요. 자바스크립트 변수와 어떻게 다른지 차근차근 살펴봅시다!

이 단원의 핵심 소개

state 변수들은 얼핏 보면 여러분이 값을 마음대로 읽고 쓸 수 있는 일반적인 자바스크립트 변수처럼 보일 수 있어요. 하지만, 리액트에서 state는 변수라기보다는 하나의 '스냅샷(사진)'처럼 동작한답니다. state의 값을 설정(업데이트)한다고 해서 여러분이 이미 가지고 있는 state 변수 값이 그 즉시 바뀌는 게 아니에요. 대신, 리액트에게 "이 컴포넌트를 다시 그려줘(리렌더링해줘)!" 라고 신호를 보내는(trigger) 역할을 하죠.

🌱 이 페이지에서 배울 내용

  • state 설정이 어떻게 리렌더링을 유발하는지
  • state가 언제, 그리고 어떻게 업데이트되는지
  • state를 설정한 직후에 state 값이 즉시 업데이트되지 않는 이유 (초보자분들이 정말 많이 헷갈려하시는 부분이에요!)
  • 이벤트 핸들러가 어떻게 state의 "스냅샷"에 접근하여 사용하는지

State를 설정하면 렌더링이 촉발됩니다 {/setting-state-triggers-renders/}

여러분은 아마 사용자가 클릭 같은 이벤트를 발생시키면, 그에 반응해서 사용자 인터페이스(UI)가 화면상에서 '직접적으로' 즉각 변경된다고 생각하실 수 있어요. 하지만 리액트는 이런 멘탈 모델(사고방식)과는 조금 다르게 작동합니다.

이전 페이지에서 state를 설정하는 것은 리액트에게 리렌더링을 요청하는 것이라는 걸 보셨을 거예요. 즉, 인터페이스가 사용자의 이벤트에 반응하여 화면을 바꾸려면, 반드시 state를 업데이트해야 한다는 뜻이죠. (DOM을 직접 수정하는 것이 아니라 상태를 바꿔서 리액트가 화면을 다시 그리게 하는 것이 리액트의 핵심 철학입니다!)

다음 예제에서 "Send(전송)" 버튼을 누르면, setIsSent(true)라는 코드가 리액트에게 UI를 다시 렌더링하라고 지시합니다.

import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}
label, textarea { margin-bottom: 10px; display: block; }

버튼을 클릭했을 때 내부적으로 어떤 일이 일어나는지 순서대로 살펴볼까요?

  1. onSubmit 이벤트 핸들러가 실행됩니다.
  2. setIsSent(true)isSent 값을 true로 설정하고, 새로운 렌더링을 대기열(queue)에 추가합니다.
  3. 리액트는 새롭게 바뀐 isSent 값에 따라 컴포넌트를 리렌더링합니다.

그렇다면 state와 렌더링 사이에는 구체적으로 어떤 관계가 있는 걸까요? 조금 더 자세히 파헤쳐 보겠습니다.


렌더링은 그 시점의 스냅샷을 찍습니다 {/rendering-takes-a-snapshot-in-time/}

"렌더링"이라는 것은 리액트가 여러분이 작성한 컴포넌트(즉, 함수죠!)를 호출해서 실행한다는 것을 의미해요. 그 함수가 실행을 마치고 반환(return)하는 JSX 코드는 마치 특정 시간대의 UI 모습을 찰칵 찍어놓은 스냅샷과 같습니다.

그 안의 props, 이벤트 핸들러, 그리고 로컬 변수들은 모두 해당 렌더링이 일어날 당시의 state를 사용해서 계산된 결과물입니다.

우리가 흔히 아는 멈춰있는 사진이나 영화의 한 프레임과는 다르게, 여러분이 반환하는 이 UI "스냅샷"은 상호작용이 가능합니다. 이 스냅샷 안에는 사용자의 입력에 반응해서 어떤 동작을 할지 지정해 둔 이벤트 핸들러 같은 로직이 포함되어 있거든요.

리액트는 여러분이 반환한 이 스냅샷에 맞춰 실제 화면(DOM)을 업데이트하고 이벤트 핸들러들을 연결해 줍니다. 결과적으로 화면의 버튼을 누르면, 여러분이 JSX에 작성해 둔 클릭 핸들러가 제대로 실행되는 것이죠.

리액트가 컴포넌트를 리렌더링할 때의 과정을 정리해보면 이렇습니다:
1. 리액트가 여러분의 함수(컴포넌트)를 다시 호출합니다.
2. 여러분의 함수는 새로운 JSX 스냅샷을 반환합니다.
3. 리액트는 여러분의 함수가 반환한 스냅샷 모양과 똑같아지도록 화면을 업데이트합니다.

React executing the function
(리액트가 함수를 실행하는 모습)

Calculating the snapshot
(스냅샷을 계산하는 모습)

Updating the DOM tree
(실제 DOM 트리를 업데이트하는 모습)

컴포넌트의 '기억' 역할을 하는 state는, 함수가 실행을 마치고 반환되면 훅 사라져 버리는 일반적인 로컬 변수와는 다릅니다. state는 실제로 여러분의 함수 외부에 있는 리액트 내부 자체에 (마치 선반 위에 안전하게 보관된 것처럼!) "살아있답니다". 리액트가 여러분의 컴포넌트를 호출할 때, 리액트는 그 특정 렌더링 차례에 맞는 state 스냅샷을 여러분의 컴포넌트에게 제공합니다. 그러면 여러분의 컴포넌트는 JSX 안에 새로운 props와 이벤트 핸들러들이 포함된 UI 스냅샷을 반환하는데요, 이 모든 것들은 해당 렌더링에서 받은 state 값들을 사용해서 계산된 것이에요!

You tell React to update the state
(리액트에게 state를 업데이트하라고 지시)

React updates the state value
(리액트가 내부적으로 state 값을 업데이트)

React passes a snapshot of the state value into the component
(리액트가 그 state 값의 스냅샷을 다시 컴포넌트로 전달)

이게 어떻게 작동하는지 보여드리기 위해 재미있는 실험을 하나 해보죠. 아래 예제를 보면, setNumber(number + 1) 코드를 세 번 연달아 호출했기 때문에 "+3" 버튼을 누르면 카운터가 한 번에 3씩 증가할 거라고 기대하실 수 있어요.

"+3" 버튼을 누르면 어떤 일이 일어나는지 직접 확인해보세요:

import { useState } from 'react';

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>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

어떠신가요? 버튼을 눌러보시면 number가 클릭 한 번당 1씩만 증가하는 것을 알 수 있습니다!

왜 그럴까요? State를 설정하는 것은 오직 다음 렌더링에 대한 state 값만 변경하기 때문입니다. 첫 번째 렌더링이 진행될 때 number0이었습니다. 그렇기 때문에 그 첫 번째 렌더링에서 만들어진 onClick 핸들러 안에서는, setNumber(number + 1)이 아무리 호출되더라도 number의 값은 계속해서 0으로 고정되어 있는 것이죠. (이해를 돕자면, 함수가 생성될 때 자신이 속한 환경의 변수 값을 기억하는 자바스크립트의 클로저(Closure) 특성 때문이라고 생각하시면 됩니다.)

<button onClick={() => {
  setNumber(number + 1); // 여기서 number는 0입니다.
  setNumber(number + 1); // 여기서도 number는 0입니다.
  setNumber(number + 1); // 여기서도 역시 number는 0입니다.
}}>+3</button>

이 버튼의 클릭 핸들러가 리액트에게 어떤 지시를 내리는지 하나씩 풀어보겠습니다:

  1. setNumber(number + 1): number0이므로 setNumber(0 + 1)이 됩니다.
    • 리액트는 다음 렌더링에서 number1로 바꾸기 위해 준비합니다.
  2. setNumber(number + 1): number는 여전히 0이므로 또 setNumber(0 + 1)이 됩니다.
    • 리액트는 다음 렌더링에서 number1로 바꾸기 위해 다시 준비합니다.
  3. setNumber(number + 1): number는 계속 0이므로 마지막으로 setNumber(0 + 1)이 됩니다.
    • 리액트는 다음 렌더링에서 number1로 바꾸기 위해 최종적으로 준비합니다.

비록 여러분이 setNumber(number + 1)을 세 번이나 호출했지만, 현재 렌더링의 이벤트 핸들러 안에서 number는 언제나 0입니다. 그래서 여러분은 결과적으로 state를 1로 세 번 설정한 것과 같죠. 이것이 바로 이벤트 핸들러가 모두 종료된 후 리액트가 컴포넌트를 리렌더링할 때 number가 3이 아니라 1이 되는 이유입니다.

코드를 읽을 때, 머릿속에서 state 변수 자리에 그 값을 직접 대입해보면 상황을 시각화하기가 아주 좋습니다. 현재 렌더링에서 number state 변수가 0이기 때문에, 이벤트 핸들러는 실제로는 이렇게 보이는 것과 마찬가지예요:

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

그리고 그 다음 번 렌더링이 일어나면 이번엔 number1이 되겠죠? 그럼 그 두 번째 렌더링의 클릭 핸들러는 다음과 같은 모습이 됩니다:

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

이 때문에 버튼을 다시 클릭하면 카운터가 2로 설정되고, 그 다음 클릭에서는 3으로 설정되는 식입니다.


시간에 따른 State 흐름 {/state-over-time/}

자, 재미있는 원리를 배웠으니 퀴즈를 하나 내볼게요. 아래 버튼을 클릭하면 경고창(alert)에 어떤 값이 뜰지 맞춰보세요:

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        alert(number);
      }}>+5</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

방금 전처럼 코드를 값으로 치환해보는 방법을 사용하셨다면, 경고창에 "0"이 표시될 것이라고 훌륭하게 예측하셨을 겁니다:

setNumber(0 + 5);
alert(0);

그렇다면, 타이머를 둬서 컴포넌트가 다시 렌더링된 이후에 경고창이 뜨도록 만들면 어떨까요? "0"이 뜰까요, 아니면 업데이트된 "5"가 뜰까요? 한번 맞춰보세요!

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

결과가 놀라우신가요? 앞서 배운 치환 방법을 사용하면 alert에 전달되는 state의 "스냅샷"이 무엇인지 명확하게 보일 겁니다.

setNumber(0 + 5);
setTimeout(() => {
  alert(0);
}, 3000);

경고창이 실행되는 시점(3초 뒤)에는 리액트 안에 저장된 최신 state 값은 이미 5로 변경되어 있을지도 모릅니다. 하지만, 경고창 띄우기를 예약할 당시에 사용된 state는 사용자가 상호작용했던 그 시점의 스냅샷(즉 0) 이었어요!

결론적으로, 하나의 렌더링 내부에서 state 변수의 값은 절대 변하지 않습니다. 이벤트 핸들러의 코드가 비동기적(예: setTimeout 사용)이라 할지라도 말이죠. 해당 렌더링의 onClick 안에서는 setNumber(number + 5)가 호출된 후라도 number의 값은 여전히 0입니다. 리액트가 컴포넌트를 호출하여 UI의 "스냅샷을 찍을 때" 이미 그 값이 0으로 "고정(fixed)"되었기 때문입니다.

이러한 특성 덕분에 이벤트 핸들러가 타이밍에 따른 오류를 덜 일으키게 됩니다. 아래는 메시지를 5초 지연시켜 전송하는 폼 예제입니다. 다음 시나리오를 상상해 보세요:

  1. 여러분이 "Send(전송)" 버튼을 눌러, Alice에게 "Hello"라는 메시지를 보냅니다.
  2. 5초의 지연 시간이 끝나기 전에, 수신자(To) 필드의 값을 "Bob"으로 재빨리 바꿉니다.

자, alert 경고창에는 어떤 내용이 나타날까요? "You said Hello to Alice"일까요? 아니면 "You said Hello to Bob"일까요? 지금까지 배운 내용을 바탕으로 추측해보고 직접 시도해 보세요:

import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('Hello');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}
label, textarea { margin-bottom: 10px; display: block; }

정답은 Alice입니다! 리액트는 단일 렌더링의 이벤트 핸들러 내에서 state 값들을 "고정(fixed)" 시켜 둡니다. 그렇기 때문에 코드가 실행되는 도중에 화면의 값이 변경되었는지 여부를 걱정할 필요가 없는 것이죠. (이 덕분에 비동기 작업 중에도 데이터의 무결성이 보장됩니다!)

하지만, 만약 리렌더링이 일어나기 직전의 최신 state 값을 꼭 읽어야만 한다면 어떻게 해야 할까요? 그럴 때는 다음 페이지에서 배울 상태 업데이트 함수(state updater function)를 사용하시면 됩니다!


📝 핵심 요약 (Recap)

  • State를 설정(업데이트)하면 새로운 렌더링을 요청하게 됩니다.
  • 리액트는 컴포넌트 외부에, 마치 선반 위에 물건을 두듯 state를 안전하게 보관합니다.
  • useState를 호출할 때 리액트는 해당 렌더링을 위한 state 스냅샷을 여러분에게 제공합니다.
  • 변수들과 이벤트 핸들러들은 리렌더링 과정에서 "살아남지" 않습니다. 렌더링이 일어날 때마다 자신만의 완전히 새로운 이벤트 핸들러들을 가집니다.
  • 모든 렌더링(그리고 그 내부의 함수들)은 리액트가 해당 렌더링에 제공한 state 스냅샷만을 항상 "보게" 됩니다.
  • 이벤트 핸들러 안의 state 변수를, 렌더링된 JSX를 상상하는 것과 같은 방식으로 머릿속에서 값으로 치환해 볼 수 있습니다.
  • 과거에 생성된 이벤트 핸들러는 그것이 생성되었던 렌더링 당시의 state 값을 그대로 유지합니다.

🏋️ 도전 과제 (Challenges)

신호등 구현하기 {/implement-a-traffic-light/}

버튼을 누르면 신호가 토글(전환)되는 보행자 신호등 컴포넌트가 있습니다:

import { useState } from 'react';

export default function TrafficLight() {
  const [walk, setWalk] = useState(true);

  function handleClick() {
    setWalk(!walk);
  }

  return (
    <>
      <button onClick={handleClick}>
        Change to {walk ? 'Stop' : 'Walk'}
      </button>
      <h1 style={{
        color: walk ? 'darkgreen' : 'darkred'
      }}>
        {walk ? 'Walk' : 'Stop'}
      </h1>
    </>
  );
}
h1 { margin-top: 20px; }

클릭 핸들러에 alert을 추가해 보세요. 신호등이 초록색이고 "Walk"라고 표시될 때 버튼을 클릭하면 경고창에 "Stop is next(다음은 정지입니다)"라고 표시되어야 합니다. 신호등이 빨간색이고 "Stop"이라고 표시될 때 버튼을 클릭하면 경고창에 "Walk is next(다음은 보행입니다)"라고 표시되어야 합니다.

alertsetWalk 호출 이전과 이후 중 어디에 넣느냐가 결과에 영향을 미칠까요?

💡 해답 (Solution)

여러분이 작성한 alert은 아마 이와 같은 모습일 것입니다:

import { useState } from 'react';

export default function TrafficLight() {
  const [walk, setWalk] = useState(true);

  function handleClick() {
    setWalk(!walk);
    alert(walk ? 'Stop is next' : 'Walk is next');
  }

  return (
    <>
      <button onClick={handleClick}>
        Change to {walk ? 'Stop' : 'Walk'}
      </button>
      <h1 style={{
        color: walk ? 'darkgreen' : 'darkred'
      }}>
        {walk ? 'Walk' : 'Stop'}
      </h1>
    </>
  );
}
h1 { margin-top: 20px; }

alertsetWalk 호출 전에 넣든 뒤에 넣든 결과에는 아무런 차이가 없습니다. 해당 렌더링 안에서의 walk 값은 고정되어 있기 때문이죠. setWalk를 호출하는 것은 오직 다음번 렌더링의 값만 변경할 뿐, 이전 렌더링에서 이미 만들어진 이벤트 핸들러에는 영향을 미치지 않습니다.

처음에는 다음 코드가 직관적이지 않게 보일 수 있습니다:

alert(walk ? 'Stop is next' : 'Walk is next');

하지만 이 코드를 "현재 신호등이 'Walk(보행)'를 보여주고 있다면, 메시지는 'Stop is next(다음은 정지)'라고 알려주어야 한다" 라고 읽어보시면 이치에 맞을 겁니다. 이벤트 핸들러 내부의 walk 변수는 해당 렌더링이 가졌던 walk 값을 그대로 따르며, 결코 실행 도중 변하지 않습니다.

앞서 배운 '치환 방식'을 적용해보면 이것이 올바르게 동작한다는 걸 확인할 수 있습니다. walktrue일 때, 코드는 다음과 같이 치환됩니다:

<button onClick={() => {
  setWalk(false);
  alert('Stop is next'); // walk가 true였으니까 이 문장이 실행되죠!
}}>
  Change to Stop
</button>
<h1 style={{color: 'darkgreen'}}>
  Walk
</h1>

따라서 "Change to Stop" 버튼을 클릭하면 walkfalse로 설정하는 다음 렌더링이 예약(queue)되고, 화면에는 "Stop is next"라는 경고창이 올바르게 나타나게 됩니다.


사이트맵 (Sitemap)

모든 문서 페이지 개요 (Overview of all docs pages)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글