본문으로 건너뛰기
2026. 7. 20
© WONKOOK LEE

무엇을 상태로 두지 말아야 할까요?

무엇을 상태로 두고, 무엇을 두지 말아야 하는가

어느 날 리뷰에서 useEffect가 네 개 연달아 붙은 컴포넌트를 만났습니다. 하나가 상태를 바꾸면 다음 이펙트가 깨어나고, 그게 또 다른 상태를 바꾸는 도미노였습니다. 동작은 했지만 "지금 무슨 순서로 흐르는가"를 읽으려면 파일을 위아래로 몇 번이나 오가야 했습니다. 비슷한 시기에 다른 버그도 봤습니다. 목록에서 고른 항목을 상태로 저장해 뒀는데, 목록이 갱신되자 그 항목만 과거에 갇혀 엉뚱한 값을 보여주고 있었습니다. 두 문제는 겉보기엔 달랐지만 뿌리가 같았습니다 — 굳이 상태로 두지 않아도 되는 것을 상태로 두고 있었습니다.

그렇다면 무엇을 상태로 두지 말아야 할까요?


말하자면 이런 모습이었습니다.

// 하나가 상태를 바꾸면 다음이 깨어나는 도미노.
// 지금 이 컴포넌트가 무슨 순서로 흐르는지 한눈에 들어오나요?
useEffect(() => { /* ① 시작되면 데이터를 부른다 */ }, [started]);
useEffect(() => { /* ② 데이터가 오면 계산한다 */ }, [data]);
useEffect(() => { /* ③ 결과가 나오면 검증한다 */ }, [result]);
useEffect(() => { /* ④ 검증되면 제출한다 */ }, [validated]);

이 글에서 다루는 내용

오늘 정리하려는 것은 서로 달라 보이는 세 가지 리액트 원칙입니다. 하지만 세 원칙 모두 결국 "이걸 정말 상태로 둬야 하는가" 라는 한 질문의 변주입니다. 상태는 공짜가 아니기 때문입니다 — 상태는 특정 시점에 갇히고(스냅샷), 서로 동기화가 어긋나고, 값이 변할 때마다 연쇄 반응을 부릅니다. 이 세 가지 대가를 각각 피하는 방법이 아래 세 원칙입니다. 리액트를 예로 들지만 상태를 다루는 어떤 시스템에도 그대로 적용됩니다.





1. 지나간 스냅샷과 최신 값 읽기

리액트에서 상태는 스냅샷(snapshot)입니다. 어떤 함수가 렌더링되는 순간, 그 안의 클로저는 그 시점의 상태 값을 사진처럼 복사해 품습니다. 이건 버그가 아니라 설계입니다. 덕분에 화면은 항상 "그 렌더 시점의 일관된 한 장면"을 그립니다.

문제는 과거에 만들어진 클로저가 한참 뒤에 실행될 때입니다. 구독 콜백, 타이머, 진행 중인 비동기 작업 — 이들은 과거의 스냅샷에 갇힌 채 살아 있다가, 정작 실행되는 순간에는 최신 값이 필요합니다.

function Chat({ socket }: { socket: Socket }) {
const [message, setMessage] = useState("");

// ❌ 구독은 마운트 시점에 딱 한 번 등록된다.
// 그때의 message("")를 캡처한 채로, 아무리 타이핑해도 이 값은 영원히 ""이다
useEffect(() => {
socket.on("data", () => {
console.log("현재 입력값:", message); // 항상 최초 렌더의 ""
});
}, []);

return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}

이 상황에서 흔한 대응은 의존성 배열에 message를 넣는 것입니다. 하지만 그러면 타이핑할 때마다 구독을 해지하고 다시 등록하게 됩니다. 화면을 다시 그리려는 것도 아닌데, 단지 최신 값을 읽으려고 연결을 통째로 재설정하는 셈입니다.

여기서 원칙이 나옵니다.

원칙

값을 미리 복사해 두지 말고, 실행되는 시점에 읽어라.

클로저가 과거에 갇히는 이유는 값을 그때 복사해 갖고 있기 때문입니다. 그러니 값 대신 객체를 넘겨주면 됩니다. 콜백은 객체만 붙들고 있다가 정작 실행되는 순간에 그 안을 읽으므로, 언제나 가장 최근에 쓰인 값을 봅니다. 리액트에서 이 객체가 useRef입니다. ref는 렌더가 반복돼도 같은 객체로 유지되고, .current를 바꿔도 리렌더를 유발하지 않습니다.

function Chat({ socket }: { socket: Socket }) {
const [message, setMessage] = useState("");

// 렌더가 끝날 때마다 최신값을 ref에 써 둔다
const messageRef = useRef(message);
useEffect(() => {
messageRef.current = message;
});

// ✅ 구독은 여전히 한 번만 등록되지만, 값을 복사해 두지 않고 실행 시점에 읽는다
useEffect(() => {
socket.on("data", () => {
console.log("현재 입력값:", messageRef.current);
});
}, []);

return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}

ref에 값을 써넣는 일을 렌더 도중이 아니라 이펙트 안에서 하는 이유가 있습니다. 렌더 함수는 순수해야 하고, 리액트는 렌더를 중단하거나 버렸다가 다시 시도할 수 있기 때문에 렌더 중의 ref 변경은 공식적으로 권장되지 않습니다. 커밋이 끝난 뒤에 쓰면 그런 걱정이 없습니다.

이 기법에는 이름이 있습니다. 커뮤니티에서는 latest ref 패턴(latest ref pattern)이라고 부릅니다. 콜백이 값을 캡처하는 대신 "항상 최신 값이 들어 있는 ref"를 캡처하게 만드는 것으로, useEventCallback, useLatest 같은 이름의 커스텀 훅으로 감싸 쓰는 경우가 많습니다.

핵심은 두 가지의 목적이 다르다는 것입니다. useState는 화면을 다시 그리기 위한 값(렌더용)이고, useRef는 로직이 실행되는 시점에 최신 값을 참조하기 위한 값(로직용)입니다. 같은 데이터라도 "화면에 보여줘야 하니까 상태로", "실행 시점에 최신값만 필요하니까 ref로" — 이렇게 목적에 따라 갈라서 취급해야 합니다.


useEffectEvent — 같은 문제의 공식 해법

이 패턴이 워낙 흔하다 보니 리액트는 아예 전용 API를 만들었습니다. 오랫동안 실험 채널에 머물러 있었지만 React 19.2(2025년 10월)부터 정식 API로 릴리스됐습니다. 이제 react에서 바로 가져다 쓸 수 있습니다.

import { useEffect, useEffectEvent, useState } from "react";

function Chat({ socket }: { socket: Socket }) {
const [message, setMessage] = useState("");

// 이펙트 이벤트는 언제나 최신 props와 state를 본다
const onData = useEffectEvent(() => {
console.log("현재 입력값:", message);
});

// ✅ message는 의존성이 아니다 — 구독은 여전히 한 번만 등록된다
useEffect(() => {
socket.on("data", onData);
}, [socket]);

return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}

리액트는 이렇게 이펙트에서 떼어낸 조각을 이펙트 이벤트(Effect Event)라고 부릅니다. DOM 이벤트 핸들러처럼 "실행되는 순간의 최신 값"을 보되, 이펙트를 다시 실행시키지는 않는 비반응형(non-reactive) 조각입니다. ref로 우회하던 일을 언어 차원에서 표현해 주는 셈입니다.

다만 쓰임에 제약이 뚜렷합니다.

  • 이펙트 안에서만 호출할 수 있습니다(useEffect, useLayoutEffect, 다른 이펙트 이벤트). 일반 이벤트 핸들러로는 쓸 수 없습니다.
  • 의존성 배열에 넣지 않습니다. 정체성이 매 렌더 의도적으로 바뀌기 때문에, 실수로 의존성에 넣으면 이펙트가 매 렌더 재실행되며 곧바로 티가 납니다.
  • 다른 컴포넌트나 훅으로 넘기지 않습니다. 반드시 선언한 곳과 같은 컴포넌트·훅 안에서 소비해야 합니다.

그래서 latest ref 패턴이 완전히 대체된 것은 아닙니다. 이펙트 바깥(이벤트 핸들러, 외부에 넘겨줄 콜백, 서드파티 위젯에 등록할 함수)에서 최신 값이 필요하거나, 19.2 미만 버전을 쓰고 있다면 여전히 ref가 답입니다. 정리하면 이펙트 안에서 최신 값을 읽는 문제는 useEffectEvent가, 그 밖의 경우는 latest ref 패턴이 맡습니다.


Recap

리액트의 상태는 렌더 시점을 사진처럼 담은 스냅샷이라, 과거에 만들어진 콜백은 그때의 값에 갇힙니다. 실행 시점의 최신 값이 필요하다면 값을 복사해 두지 말고 useRef가 주는 같은 객체를 넘겨, 실행되는 순간에 읽으면 됩니다(latest ref 패턴). 이펙트 안에서 최신 값을 읽는 경우라면 React 19.2에서 정식 릴리스된 useEffectEvent가 더 깔끔한 해법이고, 이펙트 바깥이나 구버전에서는 여전히 ref가 답입니다. 화면 갱신용(useState)과 로직 참조용(useRef)은 목적이 다르므로 같은 데이터라도 필요에 따라 나눠 담아야 합니다.




2. 저장하지 말고 계산하세요

두 번째 유혹은 이렇습니다. A라는 데이터가 바뀌면, A로부터 나오는 B도 이펙트로 따라 바꿔주는 것입니다. 언뜻 성실해 보이지만 이건 시스템을 복잡하게 만드는 지름길입니다.

// ❌ 파생되는 값을 굳이 상태로 두고, 이펙트로 동기화한다
function Profile() {
const [firstName, setFirstName] = useState("원");
const [lastName, setLastName] = useState("국");
const [fullName, setFullName] = useState("");

// firstName이나 lastName이 바뀌면 fullName을 따라 맞춘다
useEffect(() => {
setFullName(lastName + firstName);
}, [firstName, lastName]);

return <h1>{fullName}</h1>;
}

이 방식엔 두 가지 문제가 있습니다. 우선 불필요한 렌더가 한 번 더 돕니다. 이름을 바꾸면 리렌더가 일어나고, 이펙트가 fullName을 바꿔 리렌더가 또 일어납니다. 더 나쁜 것은 타이밍이 어긋난다는 점입니다. 이펙트는 렌더가 끝난 뒤에 실행되므로, 그 찰나에 fullName은 낡은 값을 들고 있습니다. A가 변했을 때 B를 뒤따라 변하게 만드는 방식은 중간에 흐름이 끊기거나 순서가 어긋날 위험을 항상 안고 있습니다.

원칙은 단순합니다.

원칙

원본 데이터만 유지하고, 결과값은 필요한 시점에 즉시 계산해서 쓴다.

같은 입력이면 같은 출력이라는 성질을 이용하면, 파생되는 값은 애초에 상태일 이유가 없습니다. 렌더 중에 계산하면 그 값은 언제나 원본과 일치합니다.

// ✅ 원본만 상태로. 결과는 렌더 중에 계산한다 — 항상 일치하고, 이펙트도 필요 없다
function Profile() {
const [firstName, setFirstName] = useState("원");
const [lastName, setLastName] = useState("국");

const fullName = lastName + firstName; // 파생 상태(derived state)를 저장하지 않는다

return <h1>{fullName}</h1>;
}

이 원칙이 진가를 발휘하는 순간은 stale(낡은) 상태 버그를 미리 막을 때입니다. 서두에서 말한 "목록이 갱신됐는데 고른 항목만 과거에 갇힌" 버그가 정확히 이 경우입니다.

// ❌ 선택된 항목 '자체'를 상태로 저장한다.
// items가 새로 불려오면 selectedItem은 과거 객체를 붙든 채 stale해진다
const [selectedItem, setSelectedItem] = useState<Item | null>(null);

// ✅ 최소한의 원본(id)만 상태로 두고, 항목은 계산한다.
// items가 바뀌어도 항상 최신 목록에서 다시 찾으므로 갇힐 여지가 없다
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedItem = items.find((item) => item.id === selectedId) ?? null;

무엇을 상태로 남길지 고를 때의 기준은 "이 값이 다른 값에서 계산되는가" 입니다. 계산된다면 그건 파생 값이고, 저장이 아니라 계산으로 두어야 합니다. 다만 계산이 정말 무거울 때만 useMemo로 감싸 캐싱하면 됩니다. 파생된다는 이유만으로 모든 계산을 메모이제이션할 필요는 없습니다 — 대부분의 계산은 렌더마다 다시 해도 부담이 되지 않습니다.


Recap

A가 변하면 B를 이펙트로 뒤따라 바꾸는 동기화는 불필요한 렌더와 타이밍 어긋남을 부릅니다. 파생되는 값은 상태로 저장하지 말고 원본만 유지한 뒤 렌더 중에 즉시 계산하면, 결과는 항상 원본과 일치하고 stale 버그의 여지가 사라집니다. 판별 질문은 "이 값이 다른 값에서 계산되는가"이고, 계산이 정말 무거울 때만 useMemo로 캐싱합니다.




3. 관찰하지 말고 직접 실행하세요

세 번째는 여러 단계의 비동기 작업을 이어 붙이는 방식에 관한 이야기입니다. 데이터를 수집하고, 계산하고, 확정하는 절차가 순서대로 일어나야 할 때, 각 단계의 결과를 이펙트로 관찰하다가 다음 단계를 실행하는 구조를 흔히 만납니다.

// ❌ 상태 변화를 관찰하며 다음 단계를 잇는 반응형 사슬
function Wizard() {
const [data, setData] = useState<Data | null>(null);
const [result, setResult] = useState<Result | null>(null);

// 1단계: 시작 신호가 오면 데이터를 수집한다
useEffect(() => {
if (started) fetchData().then(setData);
}, [started]);

// 2단계: 데이터가 도착하면 계산한다
useEffect(() => {
if (data) setResult(compute(data));
}, [data]);

// 3단계: 결과가 나오면 제출한다
useEffect(() => {
if (result) submit(result);
}, [result]);
}

이 반응형 구조는 전체 흐름을 파악하기 어렵게 만듭니다. 순서는 코드 여기저기에 흩어져 있고, 각 이펙트는 "언제 실행되는가"를 조건문(if)으로 방어해야 합니다. 무엇보다 에러가 어느 단계에서 났는지 한곳에서 통제하기가 어렵습니다. 마운트 시점의 의도치 않은 실행, 의존성 변화로 인한 중복 실행 같은 함정도 이 구조에 딸려옵니다.

게다가 이건 개발자만의 문제로 끝나지 않습니다. 각 단계가 상태를 건드릴 때마다 리렌더가 한 번씩 일어나므로, 중간에 불필요한 로딩 스피너가 스쳐 지나가거나 화면이 여러 번 깜빡이는(flickering) 형태로 사용자에게까지 새어 나갑니다. 상태 변화에 기대어 다음 작업을 잇는 방식은 개발자의 가독성뿐 아니라 사용자 경험까지 조금씩 갉아먹습니다.

관점을 바꿔야 합니다.

원칙

작업이 시작되는 최초의 진입점(버튼 클릭 등)에서 전체 절차를 순서대로 직접 실행하라.

"상태가 변하면 다음 행동을 한다"가 아니라 "순서대로 행동을 실행하며, 필요할 때만 상태를 기록한다"로 뒤집는 것입니다. 사용자의 행동에서 시작되는 절차는 이펙트가 아니라 이벤트 핸들러가 맡아야 할 일입니다.

// ✅ 진입점에서 전체 절차를 한눈에 보이게 풀어 쓴다
async function handleSubmit() {
try {
const data = await fetchData(); // 1. 수집
const result = compute(data); // 2. 계산
await submit(result); // 3. 확정
} catch (error) {
showError(error); // 어느 단계에서 실패하든 여기서 잡힌다
}
}

절차가 하나의 흐름 안에 모이면 순서가 코드 그대로 읽히고, 예외 처리가 한곳으로 모이며, 중간 결과를 굳이 상태로 남길 필요도 줄어듭니다. dataresult는 절차가 흐르는 동안만 필요한 지역 변수였을 뿐, 화면에 계속 붙들어 둘 상태가 아니었던 것입니다.

물론 이펙트가 틀린 도구인 것은 아닙니다. 이펙트가 있어야 할 자리는 외부 시스템과의 동기화 — 서버 연결을 열고 닫거나, 구독을 걸거나, 리액트 바깥의 위젯을 붙이는 일입니다. 특정 사용자 행동이 아니라 "이 컴포넌트가 화면에 있는 동안"이라는 조건에 묶인 일이라면 이펙트가 맞습니다. 반대로 "버튼을 누르면 일어나는 일"의 순서를 이펙트 사슬로 흉내 내고 있다면, 그건 핸들러로 옮겨야 한다는 신호입니다.


Recap

여러 단계의 비동기 절차를 이펙트로 관찰하며 잇는 반응형 구조는 순서를 흩어 놓고 에러 처리를 어렵게 만듭니다. 사용자 행동에서 시작되는 절차는 최초 진입점(이벤트 핸들러)에서 순서대로 실행하면 흐름이 코드 그대로 읽히고 예외가 한곳에 모이며, 중간 결과를 상태로 남길 필요도 줄어듭니다. 이펙트는 사용자 행동의 연쇄가 아니라 외부 시스템과의 동기화에 쓰는 도구입니다.




세 가지 질문으로 요약하면

세 원칙은 결국 상태를 두기 전에 던지는 세 가지 질문으로 압축됩니다. 상태가 치르는 세 가지 대가 — 시점에 갇히고, 동기화가 어긋나고, 변화가 연쇄를 부르는 것 — 을 각각 피하는 질문입니다.

마주친 상황던질 질문
값은 필요한데 화면은 다시 안 그려도 됨이 값이 렌더링을 유발해야 하는가?아니라면 상태 말고 ref에
어떤 값이 다른 값에서 나옴이 값이 계산되는 값인가?저장하지 말고 렌더 중에 계산
여러 단계가 순서대로 이어짐상태 변화를 관찰해서 잇고 있는가?이펙트 말고 진입점에서 직접 실행

정리하자면, 데이터의 보관 목적(화면용인가 로직용인가)을 분명히 구분하고, 굳이 상태로 관리하지 않아도 되는 파생 값은 즉시 계산하며, 복잡한 절차는 상태 변화에 기대어 연쇄로 일으키지 말고 하나의 흐름 안에서 처리한다 — 이것이 오늘 다룬 설계의 핵심입니다. 좋은 상태 설계는 상태를 많이 잘 관리하는 것이 아니라, 애초에 상태로 두지 않아도 되는 것을 알아보는 눈에 가깝습니다.




References

상태와 스냅샷, 그리고 ref

  1. React — State as a Snapshot
  2. React — Referencing Values with Refs
  3. React — Separating Events from Effects
  4. React — useEffectEvent API 레퍼런스
  5. React Blog — React 19.2 (useEffectEvent 정식 릴리스)

파생 상태와 상태 구조 설계

  1. React — You Might Not Need an Effect
  2. React — Choosing the State Structure

이펙트의 제자리

  1. React — Synchronizing with Effects
  2. React — Lifecycle of Reactive Effects


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자