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

React 클라이언트 상태와 서버 상태: useState에 있어도 원본은 서버일 수 있다

프로필 편집값을 저장된 서버 데이터처럼 표시했다가 다시 읽을 때 되돌아오는 실패를 통해, 서버 확인 사본과 로컬 편집 초안을 분리한다.

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

30초 요약

상태를 useState에 넣었는지, 전역 저장소에 넣었는지는 원본 소유자를 결정하지 않는다. 선택한 탭과 열린 창은 현재 앱이 기준을 소유한다. 서버에서 읽은 프로필은 클라이언트 메모리에 있어도 서버 원본의 사본이다. 저장 전 편집 초안과 마지막 서버 확인값을 구분해야 화면 변경을 서버 저장으로 오해하지 않는다.

이 글을 관통하는 상황: 화면에서는 저장됐는데 다시 읽으면 돌아온다

다음 코드는 서버를 흉내 내는 메모리 객체에서 프로필을 읽는다.

실제 네트워크 지연과 실패는 없으며 새로고침하면 이 가짜 서버도 초기화된다.

import { useState } from 'react'
 
let storedProfile = { id: 'user-1', name: '민수' }
 
async function readProfile() {
  return { id: storedProfile.id, name: storedProfile.name }
}
 
export default function ProfileEditor() {
  const [profile, setProfile] = useState(null)
 
  async function handleRead() {
    const nextProfile = await readProfile()
    setProfile(nextProfile)
  }
 
  if (profile === null) {
    return <button onClick={handleRead}>서버에서 프로필 읽기</button>
  }
 
  return (
    <section>
      <label>
        이름
        <input
          value={profile.name}
          onChange={event => {
            setProfile({ id: profile.id, name: event.target.value })
          }}
        />
      </label>
      <p>화면이 저장됐다고 믿는 이름: {profile.name}</p>
      <button type="button" onClick={handleRead}>
        서버에서 다시 읽기
      </button>
    </section>
  )
}

서버에서 프로필 읽기를 누른 뒤 입력을 지수로 바꾼다. 화면 문구도 바로 지수가 된다. 그러나 서버에 쓰는 함수는 호출한 적이 없다. 서버에서 다시 읽기를 누르면 민수로 돌아온다.

화면 갱신은 성공했지만 서버 저장은 일어나지 않았다. profile 하나에 “마지막 서버 응답”과 “현재 편집 초안”이라는 서로 다른 의미를 덮어쓴 것이 잘못된 판단이다.

저장 위치가 아니라 원본 소유자를 묻는다

두 상태를 다음 질문으로 구분한다.

질문클라이언트 상태서버 상태
현재 기준은 누가 정하는가?이 브라우저나 앱원격 서버
다른 곳에서 바뀔 수 있는가?보통 현재 UI 흐름이 바꿈다른 사용자·기기·작업도 바꿀 수 있음
대표 예열린 편집기, 선택한 탭, 저장 전 초안계정 프로필, 주문, 권한
클라이언트가 가진 값원본 또는 현재 작업특정 시점에 읽은 사본

따라서 “어느 Hook을 썼는가?”보다 “이 값의 정답을 누가 바꿀 수 있는가?”를 먼저 묻는다.

화면 사본을 바꿔도 서버 원본은 바뀌지 않는다

잘못된 예제의 setProfile은 React에 다음 화면을 요청한다. 서버와 연결된 객체를 수정하는 특별한 명령이 아니다. 서버를 바꾸려면 쓰기 요청을 보내고 그 결과를 확인해야 한다.

이 경계에서 화면 문구도 정확해야 한다.

  • 입력 직후: 사용자가 편집 중인 초안
  • 요청 중: 서버 확인을 기다리는 값
  • 성공 응답 뒤: 서버가 확인한 사본
  • 실패 뒤: 저장되지 않은 초안과 오류

한 객체를 곧바로 “저장된 프로필”이라고 부르면 이 네 상태를 구분할 자리가 사라진다.

서버 확인 사본과 편집 초안을 분리한다

다음 교정 예제는 서버 읽기와 쓰기를 흉내 낸다. saveProfile이 돌려준 값을 받은 뒤에만 서버 확인 사본을 바꾼다. 이 편에서는 한 요청이 끝날 때까지 입력과 다른 요청을 잠가 겹침을 막는다. 요청 실패와 여러 요청이 겹쳤을 때의 결과 선택은 15·16편의 범위로 남긴다.

import { useState } from 'react'
 
let storedProfile = { id: 'user-1', name: '민수' }
 
async function readProfile() {
  return { id: storedProfile.id, name: storedProfile.name }
}
 
async function saveProfile(name) {
  storedProfile = { id: storedProfile.id, name }
  return { id: storedProfile.id, name: storedProfile.name }
}
 
export default function ProfileEditor() {
  const [serverProfile, setServerProfile] = useState(null)
  const [draftName, setDraftName] = useState('')
  const [status, setStatus] = useState('idle')
 
  async function handleRead() {
    setStatus('reading')
    const nextProfile = await readProfile()
    setServerProfile(nextProfile)
    setDraftName(nextProfile.name)
    setStatus('idle')
  }
 
  async function handleSave() {
    setStatus('saving')
    const confirmedProfile = await saveProfile(draftName)
    setServerProfile(confirmedProfile)
    setDraftName(confirmedProfile.name)
    setStatus('idle')
  }
 
  if (serverProfile === null) {
    return (
      <button disabled={status === 'reading'} onClick={handleRead}>
        서버에서 프로필 읽기
      </button>
    )
  }
 
  const hasUnsavedChange = draftName !== serverProfile.name
 
  return (
    <section>
      <p>서버 확인값: {serverProfile.name}</p>
      <label>
        편집 초안
        <input
          value={draftName}
          disabled={status !== 'idle'}
          onChange={event => setDraftName(event.target.value)}
        />
      </label>
      <p>{hasUnsavedChange ? '저장 전 변경 있음' : '서버 확인값과 같음'}</p>
      <button
        type="button"
        disabled={!hasUnsavedChange || status !== 'idle'}
        onClick={handleSave}
      >
        {status === 'saving' ? '저장 중…' : '서버에 저장'}
      </button>
      <button type="button" disabled={status !== 'idle'} onClick={handleRead}>
        서버에서 다시 읽기
      </button>
    </section>
  )
}

입력 직후에는 draftName만 바뀌므로 저장 전 변경 있음이 보인다. 저장 성공 응답을 받은 뒤 serverProfile도 같은 값이 되고 문구가 서버 확인값과 같음으로 바뀐다. 다시 읽어도 서버가 승인한 이름이 유지된다.

status는 서버 원본 자체가 아니라 현재 클라이언트가 요청을 관찰하는 상태다. 서버 사본과 함께 관리할 수 있지만 원본 프로필과 같은 데이터는 아니다.

서버 상태에는 사본을 관리하는 책임이 붙는다

여기서 캐시는 다시 쓰기 위해 클라이언트에 보관한 서버 사본이다. 전용 도구를 쓰든 직접 만들든 서버 상태에는 다음 질문이 붙는다.

  • 요청 중과 실패를 어떻게 표시할 것인가?
  • 같은 데이터를 여러 곳이 요청하면 어떻게 공유할 것인가?
  • 사본을 언제 오래됐다고 판단하고 다시 읽을 것인가?
  • 화면에서 사라진 사본을 얼마 동안 보관할 것인가?
  • 서버 쓰기 성공 뒤 어떤 사본을 다시 맞출 것인가?

TanStack Query를 도입한다고 편집 초안의 소유자가 자동으로 정해지지는 않는다. 도구에는 서버 확인 사본을 맡기고, 저장 전 초안과 열린 패널 같은 클라이언트 상태는 별도로 두는 경계가 먼저다. 사본을 언제 다시 읽을지는 13편, 사용하지 않는 사본을 언제 버릴지는 14편에서 나눈다.

Web과 React Native에서도 소유 질문은 같다

Web의 열린 팝오버와 Native의 열린 바텀 시트는 클라이언트 상태다. 두 화면이 같은 서버 프로필을 보여 준다면 각각 받은 값은 서버 상태의 사본이다. 앱 생명주기, 오프라인 저장과 재연결 정책은 Native 플랫폼 목록에서 별도로 다루며 이 글의 React 공통 모델로 일반화하지 않는다.

같은 프로필 편집을 전후로 검증한다

  1. 최초 읽기 뒤 서버 확인값편집 초안이 모두 민수인지 확인한다.
  2. 지수를 입력하면 초안만 바뀌고 저장 전 변경 있음이 보이는지 확인한다.
  3. 저장 전에 다시 읽으면 서버 확인값 민수로 돌아오는지 확인한다.
  4. 다시 지수를 입력해 저장한 뒤 서버 확인값과 초안이 모두 지수인지 확인한다.
  5. 서버에서 다시 읽어도 지수가 유지되는지 확인한다.
문제       화면에서 이름을 바꿨지만 서버를 다시 읽으면 이전 이름으로 돌아옴
잘못된 판단 서버 응답을 담은 useState를 바꾸면 서버 원본도 저장됨
관찰       profile.name은 지수지만 readProfile 결과는 계속 민수
원인       서버 확인 사본과 아직 저장하지 않은 편집 초안을 한 필드에 덮어씀
행동       serverProfile과 draftName을 분리하고 성공 응답 뒤 사본 갱신
검증       저장 전 표시는 구분되고 저장 성공 뒤 다시 읽어도 이름 유지

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

  • 값의 저장 도구보다 원본을 누가 소유하는지 먼저 적었는가?
  • 서버에서 읽은 값을 클라이언트의 영구 원본처럼 취급하지 않는가?
  • 서버 확인 사본과 저장 전 편집 초안을 같은 필드에 덮어쓰지 않는가?
  • 화면 state 변경을 서버 쓰기 성공으로 잘못 표시하지 않는가?
  • 로딩·오류·최신성·보존·동시 요청 책임을 빠뜨리지 않았는가?
  • Web과 Native의 전송 차이를 React 상태 규칙으로 뭉개지 않는가?

기억할 질문은 하나다.

이 값의 정답을 지금 누가 바꿀 수 있는가?

다음 글에서는 서버 사본이 언제 오래됐다고 보고 다시 확인해야 하는지 다룬다.

Active recall

기억에서 꺼내 보기

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

  1. 01선택한 탭과 서버에서 읽은 프로필이 둘 다 useState에 있다면 각각 어떤 상태인가?

    정답

    탭은 현재 앱이 기준을 소유하는 클라이언트 상태이고, 프로필은 서버 원본의 특정 시점 사본인 서버 상태다.

    왜 그런가

    저장 도구가 아니라 누가 원본을 정하고 다른 곳에서 바뀔 수 있는지로 분류한다.

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

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

  2. 02서버에서 읽은 profile.name을 입력 onChange로 바로 바꿨을 때 왜 서버 저장을 증명하지 못하는가?

    정답

    바뀐 것은 클라이언트 메모리의 사본뿐이며 서버 쓰기 요청과 성공 응답이 없었기 때문이다.

    왜 그런가

    화면이 즉시 바뀌는 React state 갱신과 서버 원본 변경은 서로 다른 사건이다.

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

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

  3. 03프로필 편집 화면에서 serverProfile과 draftName은 각각 무엇을 뜻해야 하는가?

    정답

    serverProfile은 마지막으로 서버가 확인한 사본이고 draftName은 아직 서버가 승인하지 않은 현재 사용자의 편집 초안이다.

    왜 그런가

    둘을 분리하면 저장 전·저장 중·서버 확인 뒤 상태를 거짓 문구 없이 표현할 수 있다.

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

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

  4. 04서버 상태를 일반 React state처럼만 다룰 때 추가로 빠지기 쉬운 책임은 무엇인가?

    정답

    비동기 로딩과 오류, 중복 요청, 사본의 최신성 판단, 다시 확인할 시점과 서버 변경 뒤 조정이다.

    왜 그런가

    원격 공유 원본은 다른 곳에서 변하고 요청도 실패할 수 있어 UI 전용 상태와 다른 관리가 필요하다.

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

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

출처와 검증 범위

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

  1. Sharing State Between ComponentsReact · 공식 문서 · 확인 2026-08-09
  2. TanStack Query OverviewTanStack · 공식 문서 · 확인 2026-08-09
  3. useEffect - Fetching data with EffectsReact · 공식 문서 · 확인 2026-08-09
  4. NetworkingReact Native · 공식 문서 · 확인 2026-08-09
  5. async functionMDN Web Docs · 공식 문서 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.