React 상태의 불변 업데이트: Undo가 이전 값으로 돌아가게 하려면
바깥 객체만 복사하고 중첩 배열 항목을 직접 바꿀 때 과거 상태까지 훼손되는 이유를 확인하고, 변경 경로만 새로 만드는 업데이트를 설계한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
React 상태의 객체와 배열은 이미 저장된 값을 직접 바꾸지 않고 다음 값을 새로 만들어 전달한다. 중첩 항목 하나가 바뀌면 항목 → 배열 → 최상위 상태 경로를 새로 만든다. 바뀌지 않은 다른 항목은 기존 객체를 재사용할 수 있다.
현재 화면이 새 값으로 보인다고 끝내지 않는다. 직전 상태로 되돌리는 실행 취소(Undo), 이전 로그처럼 과거 상태를 다시 읽는 기능에서 이전 값이 보존되는지도 확인해야 한다.
이 글을 관통하는 상황: 수량은 2가 됐는데 Undo도 2다
장바구니 변경 이력을 배열에 저장하고 마지막 항목을 현재 상태로 사용한다고 하자.
import { useState } from 'react'
const initialCart = {
items: [
{ id: 'coffee', name: '커피', quantity: 1 },
{ id: 'tea', name: '차', quantity: 1 },
],
}
export default function CartHistory() {
const [history, setHistory] = useState([initialCart])
const current = history[history.length - 1]
function handleIncrease(itemId) {
const next = { ...current }
const target = next.items.find(item => item.id === itemId)
if (!target) return
target.quantity += 1
setHistory([...history, next])
}
function handleUndo() {
if (history.length > 1) {
setHistory(history.slice(0, -1))
}
}
return (
<section>
<p>
{current.items
.map(item => `${item.name}: ${item.quantity}`)
.join(', ')}
</p>
<button type="button" onClick={() => handleIncrease('coffee')}>
커피 수량 추가
</button>
<button
type="button"
onClick={handleUndo}
disabled={history.length <= 1}
>
실행 취소
</button>
</section>
)
}slice(0, -1)은 원본 history 배열을 바꾸지 않고 마지막 이력만 제외한 새 배열을 만든다. 이력이
하나뿐일 때는 실행 취소 버튼을 비활성화해 더 이전 값이 없는 경계도 드러낸다.
처음에는 커피: 1이 보인다. 수량 추가 버튼을 누르면 새 history 배열을 상태 설정 함수에 전달해
재렌더가 요청되고, 마지막 원소인 next를 현재 값으로 읽으므로 화면은 커피: 2가 된다. 그러나
실행 취소 버튼을 누르면 기대한 커피: 1이 아니라 여전히 커피: 2가 보인다. next.items와 그
안의 항목은 current가 가리키던 과거 상태와 같은 객체이기 때문이다.
잘못된 판단은 “최상위 객체가 새 객체이므로 불변 업데이트가 끝났다”는 것이다. 현재 화면만 보면 버그가 숨고, 이전 이력으로 돌아갈 때 비로소 드러난다.
바깥 객체만 복사하면 과거 항목을 공유한다
실행 전에 동일성을 예측하고 확인한다.
next !== current // true
next.items === current.items // true
next.items[0] === current.items[0] // true최상위 객체인 next와 current만 서로 다르다. items 배열과 수량이 있는 항목 객체는 여전히
서로 같고 공유돼 있다.
바뀐 항목에서 최상위까지 새로 만든다
교정할 때는 위 컴포넌트의 handleIncrease만 다음 함수로 교체한다. 잘못된 함수와 교정 함수는 같은
화면에서 사용하는 두 대안이며 동시에 실행하지 않는다.
function handleIncrease(itemId) {
const next = {
...current,
items: current.items.map(item => {
if (item.id !== itemId) return item
return {
...item,
quantity: item.quantity + 1,
}
}),
}
setHistory([...history, next])
}변경한 coffee 항목에서 시작하면 새 값의 경로는 다음과 같다.
quantity 숫자
↑ 새 coffee 항목 객체
↑ 새 items 배열
↑ 새 cart 객체
↑ 새 history 배열각 화살표에서 그 값을 감싸는 항목 객체·배열·최상위 객체가 새 값이므로 과거 이력은 변경한 항목과 분리된다.
바뀌지 않은 가지는 재사용할 수 있다
교정 뒤의 동일성 경계를 예측하면 다음과 같다.
next !== current // true
next.items !== current.items // true
next.items[0] !== current.items[0] // true: coffee 변경
next.items[1] === current.items[1] // true: tea 재사용불변 업데이트는 상태 안의 모든 객체와 배열을 깊게 복제하는 일이 아니다. 바뀐 경로를 새로 만들고 바뀌지 않은 가지를 안전하게 재사용하는 규칙이다.
깊은 상태는 구조부터 다시 본다
다만 “평평하게 만들기”가 모든 중첩을 없애라는 뜻은 아니다. 변경이 항상 함께 일어나는 값은 한 객체로 묶는 편이 명확할 수 있다. 반복되는 복사 코드와 실수 빈도, 조회 방식과 변경 단위를 보고 상태 구조를 선택한다.
현재 화면과 과거 상태를 함께 검증한다
교정한 함수로 바꾸고 화면을 새로 시작한다. 이 사례의 검증은 다음 세 결과를 함께 본다.
- 수량 추가 뒤 현재 커피 수량은
2다. - Undo 뒤 커피 수량은
1로 돌아간다. - 변경하지 않은 차 항목은 이전과 같은 객체로 재사용된다.
문제 수량 추가 화면은 2지만 Undo 뒤에도 2임
잘못된 판단 최상위 cart 객체만 새로 만들면 중첩 항목도 독립됨
관찰 next !== current지만 next.items[0] === current.items[0]
원인 새 바깥 객체 안에서 과거와 같은 배열·항목을 직접 변경
행동 변경 항목, items 배열, cart와 history까지 경로를 새로 만듦
검증 현재 수량 2, Undo 수량 1, 변경하지 않은 항목 동일성 유지풀 리퀘스트에서는 다음을 확인한다.
- 상태 객체·배열의 기존 속성이나 원소를 직접 바꾸지 않는가?
- 변경 값에서 최상위 상태까지 이어지는 항목 객체·배열·최상위 객체를 새로 만들었는가?
- 바뀌지 않은 가지는 재사용하되 이후에도 직접 변경하지 않는가?
- 현재 화면뿐 아니라 취소·Undo·이전 로그에서 과거 값이 보존되는가?
- 복사 경로가 지나치게 깊다면 상태 중복과 구조를 줄일 수 있는가?
기억할 축은 짧다.
변경 경로는 새로 만들고, 바뀌지 않은 가지는 재사용한다.
다음 글에서는 렌더링 계산과 채팅 연결·타이머·브라우저 같은 외부 시스템 동기화를 왜 Effect로 분리하는지 살펴본다.
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01`const next = { ...current }` 뒤에 `next.items[0].quantity += 1`을 실행하면 과거 상태가 바뀔 수 있는 이유는 무엇인가?
정답
새 바깥 객체를 만들었어도 items 배열과 그 안의 항목 객체는 current와 같은 값을 공유할 수 있기 때문이다.
관련 설명 다시 읽기왜 그런가
바깥 객체 동일성이 달라진 것과 변경 경로의 모든 객체가 새로 만들어진 것은 다른 보장이다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02장바구니 항목 하나의 수량을 바꿀 때 어떤 값들을 새로 만들어야 하는가?
정답
변경한 항목 객체, 그 항목을 담는 items 배열, items를 담는 장바구니 객체를 새로 만든다.
관련 설명 다시 읽기왜 그런가
바뀐 값에서 최상위 상태까지 이어지는 경로마다 새 객체가 있어야 이전 상태와 다음 상태를 분리할 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03수량이 바뀌지 않은 다른 항목 객체까지 모두 새로 만들 필요가 있을까?
정답
기존 객체를 앞으로도 변경하지 않는다는 규칙을 지킨다면 바뀌지 않은 항목은 이전 객체를 재사용할 수 있다.
관련 설명 다시 읽기왜 그런가
변경 경로만 새로 만들고 다른 가지를 재사용하는 구조적 공유는 이전 상태를 보존하면서 불필요한 복사를 줄인다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04불변 업데이트가 지켜졌는지 현재 화면 변경만으로 충분히 검증할 수 있을까?
정답
아니다. 이전 이력으로 Undo했을 때 과거 수량이 보존되는지와 이전·다음 객체의 동일성 경계도 함께 확인해야 한다.
관련 설명 다시 읽기왜 그런가
바깥 객체만 새로 만들면 현재 화면은 바뀌어도 공유한 중첩 객체 때문에 과거 상태가 훼손될 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- Updating Objects in StateReact · 공식 문서 · 확인 2026-08-08
- Updating Arrays in StateReact · 공식 문서 · 확인 2026-08-08
- Choosing the State StructureReact · 공식 문서 · 확인 2026-08-08
- Array.prototype.map()MDN Web Docs · 공식 문서 · 확인 2026-08-08
- Array.prototype.slice()MDN Web Docs · 공식 문서 · 확인 2026-08-08