본문으로 건너뛰기
2025. 10. 20·약 7분

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

옅은 도미노들이 차례로 쓰러지고 빨간 도미노가 기울기 시작하며, 파란 도미노 셋은 아직 서 있는 그림

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

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


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

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

이 글에서 다루는 내용​

리액트에서 상태를 다루다 만나는 세 가지 문제를 살펴봅니다. 오래 살아 있는 콜백에서는 최신 값을 어떻게 읽을지, 다른 값에서 계산할 수 있는 결과를 따로 저장해야 할지, 여러 단계의 작업을 이펙트로 이어야 할지입니다. 첫 문제는 상태를 유지하면서 읽는 방법을 바꾸고, 나머지 둘은 불필요한 상태와 동기화를 줄이는 방향으로 풀어봅니다.





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

리액트에서 상태는 스냅샷(snapshot)입니다. 컴포넌트 함수가 실행될 때 만든 클로저는 해당 렌더의 변수 바인딩을 참조합니다. 상태 객체를 깊게 복사한다는 뜻은 아닙니다. 이건 버그가 아니라 설계입니다. 덕분에 화면은 항상 "그 렌더 시점의 일관된 한 장면"을 그립니다.

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

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

// ❌ 구독 시점의 message를 캡처해 이후 변경을 보지 못한다.
useEffect(() => {
const onData = () => {
console.log("현재 입력값:", message); // 항상 최초 렌더의 ""
};
socket.on("data", onData);
return () => socket.off("data", onData);
}, [socket]);

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(() => {
const onData = () => {
console.log("현재 입력값:", messageRef.current);
};
socket.on("data", onData);
return () => socket.off("data", onData);
}, [socket]);

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

ref에 값을 써넣는 일을 렌더 도중이 아니라 이펙트 안에서 하는 이유가 있습니다. 렌더 함수는 순수해야 하고, React는 렌더를 중단하거나 버렸다가 다시 시도할 수 있기 때문에 렌더 중 ref 변경은 권장되지 않습니다. 커밋과 일반 이펙트 사이의 짧은 구간에도 외부 이벤트가 올 수 있는 코드라면 useLayoutEffect를 고려하되, 브라우저 페인트를 막는 비용까지 함께 봐야 합니다.

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

두 도구는 목적이 다릅니다. useState는 값의 변경을 렌더링에 반영하고, useRef는 렌더링과 무관한 값을 커밋 사이에 보존합니다. 화면의 원본 상태를 ref로 옮기라는 뜻은 아닙니다. 상태가 원본이고 오래 살아 있는 콜백에서 최신 커밋 값을 읽어야 할 때만 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);
return () => socket.off("data", onData);
}, [socket]);

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

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

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

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

그래서 latest ref 패턴이 완전히 대체된 것은 아닙니다. 이펙트 바깥에서 최신 값이 필요하거나 React 19.2 미만을 지원한다면 ref가 여전히 필요할 수 있습니다. 다만 useEffectEvent는 의존성을 숨기는 범용 도구가 아닙니다. 값의 변화가 이펙트를 다시 연결해야 하는 이유라면 그 값은 그대로 의존성에 남겨야 합니다.


Recap​

React의 상태는 렌더 시점의 스냅샷이라 과거에 만들어진 콜백은 그때의 값을 봅니다. 오래 살아 있는 콜백에서 최신 커밋 값을 읽어야 할 때는 상태를 원본으로 유지한 채 ref를 거울로 둘 수 있습니다(latest ref 패턴). 이펙트 안의 비반응형 로직이라면 React 19.2의 useEffectEvent가 의도를 더 정확히 표현합니다. 다만 변화에 반응해야 하는 값까지 이 API로 숨겨서는 안 됩니다.




저장하지 말고 계산하세요​

이번에는 이름과 성을 합친 fullName을 별도 상태로 저장한 코드를 보겠습니다. 이름이나 성이 바뀔 때마다 이펙트에서 fullName도 갱신합니다.

// ❌ 파생되는 값을 굳이 상태로 두고, 이펙트로 동기화한다
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을 갱신해 다시 렌더링합니다. 계산 한 번이면 얻을 값을 맞추기 위해 렌더가 한 번 더 필요하고, 중간에는 두 값이 어긋납니다.

원칙은 단순합니다.

원칙

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

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

// ✅ 원본만 상태로. 결과는 렌더 중에 계산한다: 항상 일치하고, 이펙트도 필요 없다
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로 결과를 재사용할 수 있습니다. 파생 값이라는 이유만으로 모든 계산에 메모이제이션을 붙일 필요는 없습니다.




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

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

// ❌ 상태 변화를 관찰하며 다음 단계를 잇는 반응형 사슬
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)으로 방어해야 합니다. 무엇보다 에러가 어느 단계에서 났는지 한곳에서 통제하기가 어렵습니다. 마운트 시점의 의도치 않은 실행, 의존성 변화로 인한 중복 실행 같은 문제도 이 구조에서 생깁니다.

이 예제의 data와 result는 화면에 보여주려는 값도 아닙니다. 다음 작업을 시작하는 신호로만 쓰이므로, 상태로 저장하지 않고 함수 안에서 전달할 수 있습니다.

원칙

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

사용자가 제출 버튼을 눌러 시작하는 절차라면 이벤트 핸들러 안에 실행 순서를 모을 수 있습니다.

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

절차가 하나의 흐름 안에 모이면 순서가 코드 그대로 읽히고, 예외 처리가 한곳으로 모이며, 중간 결과를 굳이 상태로 남길 필요도 줄어듭니다. data와 result는 절차가 흐르는 동안만 필요한 지역 변수였을 뿐입니다. 다만 버튼 비활성화나 진행 표시처럼 UI가 알아야 하는 status는 여전히 상태로 두고, 중복 제출 방지와 취소 정책도 핸들러 경계에서 함께 설계해야 합니다.

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




세 가지 질문으로 요약하면​

리뷰할 때는 아래처럼 문제의 종류부터 구분하면 됩니다.

마주친 상황던질 질문답
오래 살아 있는 콜백이 최신 값을 읽어야 함값 변화가 구독을 다시 연결해야 하는가?아니라면 state는 원본으로 두고 Effect Event나 ref로 최신 값을 읽기
어떤 값이 다른 값에서 나옴이 값이 계산되는 값인가?저장하지 말고 렌더 중에 계산
여러 단계가 순서대로 이어짐상태 변화를 관찰해서 잇고 있는가?이펙트 말고 진입점에서 직접 실행

서두의 코드를 다시 본다면 선택한 항목은 ID만 저장하고, 연달아 붙은 이펙트는 제출 핸들러로 모을 것입니다. 화면에 필요한 입력값과 진행 상태는 그대로 남깁니다.




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


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