Skip to main content
입문React 공통 모델 · 7

React와 오래된 클로저: 자동 저장이 최초 초안만 읽는 이유

Render마다 만들어진 callback이 어떤 상태 스냅샷을 기억하는지 확인하고, 입력마다 다시 예약할지 고정 주기로 최신 값을 읽을지 의도에 맞춰 선택한다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

React 함수 컴포넌트는 Render마다 그 시점의 props와 상태 스냅샷을 받는다. 그 Render 안에서 만든 타이머 callback도 자신이 만들어진 Render의 값을 읽는 클로저다. 나중에 상태가 바뀌어 새 Render가 생겨도 이미 등록한 callback이 자동으로 교체되지는 않는다.

해법부터 고르지 않는다. 입력이 바뀔 때 타이머를 다시 시작할지, 타이머 주기는 유지하고 실행 순간의 최신 값만 읽을지를 먼저 정한다.

이 글을 관통하는 상황: 화면은 최신인데 저장 로그는 비어 있다

다음 편집기는 3초마다 현재 초안을 자동 저장하려고 한다. saveDraft는 실제 저장 요청 대신 저장한 문자열을 콘솔에 기록한다.

import { useEffect, useState } from 'react'
 
function saveDraft(text) {
  console.log(`저장: ${text}`)
}
 
export default function Editor() {
  const [draft, setDraft] = useState('')
 
  useEffect(() => {
    const intervalId = setInterval(() => {
      saveDraft(draft)
    }, 3000)
 
    return () => clearInterval(intervalId)
  }, [])
 
  return (
    <label>
      초안
      <textarea value={draft} onChange={event => setDraft(event.target.value)} />
    </label>
  )
}

화면을 연 뒤 3초 안에 회의록을 입력한다. 화면의 textarea에는 회의록이 보이지만 첫 저장 로그는 저장: 처럼 빈 값이다. 그 뒤에도 interval은 최초의 빈 draft를 계속 저장한다.

잘못된 판단은 “callback이 실행될 때 같은 draft 변수의 최신 값을 읽을 것”이라는 생각이다. 변수 이름은 같아도 Render 실행마다 draft 값과 그 값을 읽는 callback이 새로 만들어진다. 빈 의존성 배열은 최초 callback을 등록한 뒤 새 Render의 callback으로 교체하지 않는다.

callback은 자신이 만들어진 Render의 값을 읽는다

실행 전에 callback 안의 값을 치환해 보면 원인이 선명하다.

function firstRender() {
  const draft = ''
  return () => saveDraft(draft)
}
 
function secondRender() {
  const draft = '회의록'
  return () => saveDraft(draft)
}
 
const tickFromFirstRender = firstRender()
const tickFromSecondRender = secondRender()

interval에 등록된 함수는 tickFromFirstRender다. 새 Render가 tickFromSecondRender를 만들었어도 외부 타이머는 그 존재를 알지 못한다.

최신 값보다 타이머를 다시 시작할 조건을 먼저 정한다

요구사항이 사용자가 편집한 뒤 3초 동안 추가 입력이 없으면 한 번 저장이라면 draft 변화 때 예약을 다시 시작하는 것이 맞다. 이 동작을 지연 저장, 또는 debounce라고 부른다. debounce는 짧은 시간에 연속된 입력이 오면 이전 예약을 취소하고 마지막 입력 뒤에 한 번 실행하는 방식이다.

Effect는 최초 화면 반영 뒤에도 실행된다. 빈 draft만 검사해 건너뛰면 사용자가 기존 내용을 모두 삭제한 변경도 저장하지 못한다. 따라서 “비어 있는가”가 아니라 실제로 편집했는지를 별도 상태로 기록한다.

const [hasEdited, setHasEdited] = useState(false)
 
useEffect(() => {
  if (!hasEdited) return
 
  const timeoutId = setTimeout(() => {
    saveDraft(draft)
  }, 3000)
 
  return () => clearTimeout(timeoutId)
}, [draft, hasEdited])
 
function handleChange(event) {
  setDraft(event.target.value)
  setHasEdited(true)
}
 
// textarea에는 onChange={handleChange}를 연결한다.

반면 요구사항이 입력 중에도 매 3초마다 최신 초안을 저장이라면 입력마다 interval을 정리하고 다시 시작하면 주기가 밀린다. 사용자가 3초보다 짧은 간격으로 계속 입력하면 저장 시점이 계속 뒤로 미뤄질 수 있다. 최신 값 문제는 사라져도 원래 고정 주기 요구사항은 바뀐다.

고정 주기라면 Effect Event에서 최신 값을 읽는다

고정 주기 자동 저장은 다음처럼 두 책임을 나눈다.

import { useEffect, useEffectEvent, useState } from 'react'
 
export default function Editor() {
  const [draft, setDraft] = useState('')
 
  const onSaveTick = useEffectEvent(() => {
    saveDraft(draft)
  })
 
  useEffect(() => {
    const intervalId = setInterval(() => {
      onSaveTick()
    }, 3000)
 
    return () => clearInterval(intervalId)
  }, [])
 
  return (
    <label>
      초안
      <textarea value={draft} onChange={event => setDraft(event.target.value)} />
    </label>
  )
}
  • Effect는 3초 interval을 시작하고 정리한다.
  • Effect Event는 각 tick에 실행되어 최신 커밋된 draft를 저장한다.
  • draft 변화는 저장할 내용은 바꾸지만 interval을 다시 시작하지 않는다.

useEffectEvent는 현재 React 19.2 API다. 사용하는 Web 프레임워크나 React Native 앱이 제공하는 React 버전과 Hook 린트 지원을 확인한 뒤 적용한다. 지원하지 않는 환경에서는 현재 환경이 지원하는 별도의 최신 값 전달 전략과 그 검증 방법을 먼저 설계해야 한다.

Effect Event는 의존성을 숨기는 도구가 아니다

예를 들어 채팅방 roomId가 바뀌면 연결 자체를 바꿔야 한다. 이를 Effect Event 안에 숨기고 연결 Effect의 의존성에서 빼면 이전 채팅방 연결이 남는다. 반면 고정 interval의 tick은 외부 타이머가 일으킨 사건이고, 최신 draft는 tick에서 저장할 내용일 뿐 interval 재시작 조건이 아니다.

Effect Event는 같은 컴포넌트의 Effect, 그 Effect가 시작한 callback 또는 다른 Effect Event 안에서 호출한다. 일반 클릭 처리 함수처럼 다른 곳에서 호출하거나 다른 컴포넌트·Hook으로 전달하지 않는다. Effect Event 함수 자체도 의존성 배열에 넣지 않는다.

예약 당시 값과 실행 순간 값을 나눠 검증한다

두 자동 저장 요구사항은 서로 다른 결과로 검증한다.

마지막 입력 뒤 저장

  1. 입력하지 않은 채 3초를 기다려 빈 초안이 저장되지 않는지 확인한다.
  2. 회의를 입력하고 2초 뒤 을 입력한다.
  3. 첫 입력 기준 3초에는 저장되지 않는지 확인한다.
  4. 마지막 입력 3초 뒤 회의록이 한 번 저장되는지 확인한다.

고정 주기 저장

  1. 타이머가 시작된 뒤 3초 안에 회의록을 입력한다.
  2. 첫 tick에서 회의록이 저장되는지 확인한다.
  3. 입력을 계속 바꿔도 tick 간격이 입력 시점부터 다시 시작되지 않는지 확인한다.
문제       화면은 최신 초안인데 자동 저장은 최초 빈 값을 반복 저장
잘못된 판단 callback은 실행 순간 같은 이름의 최신 state를 찾아 읽음
관찰       화면 draft는 회의록, interval callback의 draft는 빈 문자열
원인       최초 Render에서 등록한 closure가 그 Render의 상태 스냅샷을 읽음
행동       다시 예약할 값인지, 고정 주기에서 최신 값만 읽을지 요구사항 분리
검증       debounce는 마지막 입력 뒤 1회, 고정 interval은 각 tick에 최신 값

풀 리퀘스트에서는 다음을 확인한다.

  • 비동기 callback은 어느 Render에서 만들어졌고 어떤 props·상태를 읽는가?
  • 그 값이 바뀌면 외부 작업 자체를 다시 시작해야 하는가?
  • 의도는 마지막 변화 뒤 한 번 실행인가, 고정된 외부 사건마다 최신 값 읽기인가?
  • Effect Event를 의존성 경고를 숨기는 용도로 사용하지 않았는가?
  • 사용하는 React와 Hook 린트 버전이 선택한 API를 지원하는가?

기억할 질문은 하나다.

예약 당시 값이 필요한가, 실행 순간의 최신 값이 필요한가?

다음 글에서는 계산 결과를 재사용하는 useMemo가 실제 병목을 줄이는지 측정하고, 단순한 코드를 불필요하게 메모화하지 않는 판단 기준을 살펴본다.

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01빈 의존성 배열로 만든 자동 저장 interval이 입력 뒤에도 빈 초안을 저장하는 이유는 무엇인가?

    정답

    interval callback이 최초 Render에서 만들어져 그 Render의 빈 draft 상태 스냅샷에 접근하는 클로저이기 때문이다.

    왜 그런가

    새 Render가 생겨도 이미 등록한 callback 함수가 자동으로 새 callback으로 교체되지는 않는다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02draft를 Effect 의존성에 추가하면 오래된 값 문제는 없어지지만 왜 항상 같은 기능이라고 할 수 없는가?

    정답

    입력마다 Effect를 정리하고 다시 설정하므로 고정 주기 저장이 아니라 마지막 입력 뒤 시간을 다시 재는 동작이 되기 때문이다.

    왜 그런가

    의존성은 최신 값을 얻기 위한 형식이 아니라 어떤 값 변화가 외부 동기화를 다시 시작해야 하는지 표현한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 033초 고정 주기는 유지하면서 매 tick에 최신 draft를 저장하려면 React 19.2에서 어떤 경계를 사용할 수 있는가?

    정답

    저장 callback을 Effect Event로 만들고 interval은 한 번 설정해, tick이 Effect Event를 호출할 때 최신 커밋된 draft를 읽게 할 수 있다.

    왜 그런가

    draft 변화는 저장할 내용은 바꾸지만 interval 자체를 다시 시작할 원인은 아니라는 의도를 코드로 분리한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04Effect Event를 모든 누락 의존성을 없애는 방법으로 사용해도 될까?

    정답

    아니다. 값 변화가 외부 동기화를 다시 시작해야 한다면 의존성으로 남기고, Effect가 시작한 비반응형 사건에서 최신 값만 읽어야 할 때 Effect Event를 사용한다.

    왜 그런가

    Effect Event는 같은 컴포넌트의 Effect에서 호출하는 비반응형 로직이지 일반 callback이나 의존성 우회 수단이 아니다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. ClosuresMDN Web Docs · 공식 문서 · 확인 2026-08-09
  2. State as a SnapshotReact · 공식 문서 · 확인 2026-08-09
  3. Separating Events from EffectsReact · 공식 문서 · 확인 2026-08-09
  4. useEffectEventReact · 공식 문서 · 확인 2026-08-09
  5. Removing Effect DependenciesReact · 공식 문서 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.