JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기

2026.09.12·수정 2026.09.14·약 16분·작성: 해비·블로그 소개

이 글에서 완성하는 것

얕은 복사가 중첩 객체를 공유하는 이유를 확인하고, 변경 경로만 새로 만드는 상태 갱신 함수를 완성합니다. structuredClone의 복제 범위와 React 상태로 이어지는 원칙도 실습합니다.

얕은 복사만 하면 중첩된 profile 객체는 원본과 같은 참조를 가집니다. profile도 새 객체로 복사해야 원본을 유지하면서 값을 바꿀 수 있습니다.
얕은 복사만 하면 중첩된 profile 객체는 원본과 같은 참조를 가집니다. profile도 새 객체로 복사해야 원본을 유지하면서 값을 바꿀 수 있습니다.

학습 수준은 중급입니다. 함수·객체 글과 배열 메서드 글에서 객체 접근, map, find를 먼저 익히세요. Node.js 22 이상에서 실습합니다. 이 글은 배열 메서드의 목록을 반복하지 않고, 어떤 객체가 새로 생기고 어떤 객체를 계속 공유하는지 확인하는 데 집중합니다.

값이 같다는 말과 같은 객체라는 말

두 객체의 속성 내용이 같아도 서로 다른 객체라면 === 비교는 false입니다. 반면 const alias = original은 객체를 하나 더 만드는 코드가 아닙니다. 두 변수가 같은 객체를 가리키므로 한쪽에서 속성을 바꾸면 다른 쪽에서도 바뀐 값을 읽습니다. 함수의 인수로 객체를 넘겨도 이 문제가 나타납니다. 매개변수에 담긴 객체를 수정하면 호출자가 보던 객체도 바뀔 수 있습니다.

const는 변수에 다른 값을 다시 대입하지 못하게 합니다. 객체 내부를 읽기 전용으로 만드는 선언은 아닙니다. 그래서 const로 만든 장바구니도 items.push를 호출하면 변합니다. 코드 리뷰에서는 const 여부와 객체 변경 여부를 따로 확인해야 합니다. 객체를 출력한 화면만 보면 값의 변화와 참조의 관계를 구분하기 어려우므로 이 실습은 ===와 assert를 함께 사용합니다.

얕은 복사는 어느 경계까지 새 객체인가

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

clone.mjs

import assert from 'node:assert/strict';
const shared = { score: 10 };
const source = {
  left: shared,
  right: shared,
  created: new Date('2024-01-01T00:00:00Z'),
};
source.self = source;
const copy = structuredClone(source);
assert.notEqual(copy.left, source.left);
assert.equal(copy.left, copy.right);
assert.equal(copy.self, copy);
assert.ok(copy.created instanceof Date);
copy.left.score = 20;
assert.equal(source.left.score, 10);
assert.equal(copy.right.score, 20);
assert.throws(() => structuredClone({ run() {} }), { name: 'DataCloneError' });
console.log('structured clone: all checks passed');

reference.mjs

import assert from 'node:assert/strict';
const original = { profile: { name: '민지', city: '서울' }, tags: ['JS'] };
const alias = original;
const shallow = { ...original };
assert.equal(alias, original);
assert.notEqual(shallow, original);
assert.equal(shallow.profile, original.profile);
shallow.profile.city = '부산';
assert.equal(original.profile.city, '부산');
console.log('same root:', alias === original);
console.log('same nested:', shallow.profile === original.profile);
console.log('original city:', original.profile.city);

solution.mjs

import assert from 'node:assert/strict';
export function renameItem(state, id, name) {
  const target = state.items.find((item) => item.id === id);
  if (!target || target.name === name) return state;
  return {
    ...state,
    items: state.items.map((item) => (item.id === id ? { ...item, name } : item)),
  };
}
const before = {
  items: [
    { id: 'a', name: '펜' },
    { id: 'b', name: '책' },
  ],
};
const next = renameItem(before, 'a', '파란 펜');
assert.equal(before.items[0].name, '펜');
assert.equal(next.items[0].name, '파란 펜');
assert.notEqual(next, before);
assert.notEqual(next.items, before.items);
assert.notEqual(next.items[0], before.items[0]);
assert.equal(next.items[1], before.items[1]);
assert.equal(renameItem(before, 'x', '새 이름'), before);
assert.equal(renameItem(before, 'a', '펜'), before);
console.log('rename exercise: all checks passed');

update.mjs

export function changeCity(state, city) {
  if (state.profile.city === city) return state;
  return { ...state, profile: { ...state.profile, city } };
}
export function changeQuantity(state, id, quantity) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new RangeError('quantity must be a positive integer');
  }
  const target = state.items.find((item) => item.id === id);
  if (!target || target.quantity === quantity) return state;
  return {
    ...state,
    items: state.items.map((item) => (item.id === id ? { ...item, quantity } : item)),
  };
}

update.test.mjs

import assert from 'node:assert/strict';
import { changeCity, changeQuantity } from './update.mjs';
const before = {
  profile: { name: '민지', city: '서울' },
  items: [
    { id: 'a', quantity: 1 },
    { id: 'b', quantity: 2 },
  ],
};
const next = changeQuantity(before, 'a', 3);
assert.equal(before.items[0].quantity, 1);
assert.equal(next.items[0].quantity, 3);
assert.notEqual(next, before);
assert.notEqual(next.items, before.items);
assert.notEqual(next.items[0], before.items[0]);
assert.equal(next.items[1], before.items[1]);
assert.equal(next.profile, before.profile);
assert.equal(changeQuantity(before, 'missing', 4), before);
assert.equal(changeQuantity(before, 'a', 1), before);
assert.throws(() => changeQuantity(before, 'a', 0), RangeError);
assert.throws(() => changeQuantity(before, 'a', 1.5), RangeError);
const moved = changeCity(before, '제주');
assert.equal(before.profile.city, '서울');
assert.equal(moved.profile.city, '제주');
assert.equal(moved.items, before.items);
assert.equal(changeCity(before, '서울'), before);
console.log('immutable update: all checks passed');

reference.mjs 전체 코드 보기

reference.mjs로 저장하고 node reference.mjs를 실행하면 same root: true, same nested: true, original city: 부산 순서로 출력됩니다. shallow 자체는 original과 다르지만 profile 속성 값은 같은 객체입니다. 바깥 포장만 바꿨다고 안쪽 데이터까지 독립한 것은 아닙니다. MDN의 전개 구문 설명도 배열 원소의 동일성이 유지되는 얕은 복사임을 설명합니다.

배열도 같습니다. […items]는 새 배열을 만들지만 그 안의 객체들은 그대로 공유합니다. 복사한 배열에서 pop을 하면 원본 배열의 길이는 유지되지만, copy[0].quantity를 바꾸면 원본 배열의 첫 객체도 변합니다. 어느 연산이 배열 자체를 바꾸는지, 어느 연산이 배열 안 객체를 바꾸는지 한 단계씩 구별하세요. 여기서는 이 차이를 이해하기 위해 의도적으로 원본이 변하는 예제를 먼저 실행했습니다.

변경 지점에서 루트까지 새 객체 만들기

중첩 값을 원본 보존 방식으로 바꾸려면 변경된 값의 직접 부모부터 최상위 상태까지 새 객체를 만듭니다. 도시를 바꾸면 profile과 state가 새로 필요합니다. 상품 수량을 바꾸면 해당 상품, items 배열, state가 새로 필요합니다. 바뀌지 않은 상품과 profile은 공유해도 됩니다. 다만 이후에도 공유된 객체를 직접 수정하지 않는 규칙을 함께 지켜야 합니다.

update.mjs 전체 코드 보기

update.mjs의 함수는 입력 상태를 읽고 다음 상태를 반환합니다. 반환값을 변수에 저장하지 않으면 변경 결과를 잃습니다. find로 대상을 먼저 찾는 이유는 없는 id나 같은 수량에 대해 원래 상태를 그대로 반환하려는 것입니다. 이 예제의 items는 id가 유일한 배열이라는 전제를 가집니다. 중복 id를 허용해야 하는 서비스라면 먼저 데이터 모델에서 식별 규칙을 정해야 합니다.

전개 순서도 확인하세요. {…item, quantity}는 기존 속성을 복사한 뒤 새 quantity로 덮습니다. {quantity, …item}이라고 쓰면 기존 quantity가 다시 덮어써 의도한 변경이 사라집니다. 입력 오류는 예외, 대상 없음과 실질적 변화 없음은 기존 상태 반환으로 정했습니다. 실제 제품에서는 대상 없음도 오류로 다룰 수 있지만 함수의 계약과 호출자의 처리가 서로 일치해야 합니다.

값과 참조를 각각 검증하기

update.test.mjs 전체 코드 보기

node update.test.mjs가 성공하면 immutable update: all checks passed가 나옵니다. 값 검증만 하면 원본을 같이 바꾼 버그를 놓칠 수 있고, 참조 검증만 하면 잘못된 수량을 저장한 버그를 놓칠 수 있습니다. 이 테스트는 원본 값, 변경 값, 변경 경로의 새 참조, 유지 경로의 동일 참조를 모두 확인합니다. 비교 대상이 숫자인지 객체인지에 따라 assert.equal이 무엇을 검증하는지도 구별하세요.

비교 예상 이유
next === before false 루트 변경
next.items === before.items false 배열 변경
next.items[0] === before.items[0] false 수량 변경
next.items[1] === before.items[1] true 변경 없음
next.profile === before.profile true 변경 없음

structuredClone은 전체 복사가 필요할 때

독립된 데이터 스냅샷이 필요하다면 structuredClone을 검토할 수 있습니다. 지원하는 값의 그래프를 복제하므로 중첩 객체를 원본과 분리하고 순환 참조도 처리합니다. 복제본 내부에서 원래 하나였던 공유 객체는 복제본에서도 하나로 공유됩니다. “깊은 복사니까 모든 경로가 서로 다른 객체”라고 이해하면 잘못입니다. 아래 left와 right는 복제 후에도 같은 객체입니다.

clone.mjs 전체 코드 보기

clone.mjs를 실행하면 Date 보존, 순환 관계, 내부 공유 관계, 함수 복제 실패를 확인합니다. 함수와 DOM 노드는 복제할 수 없고, 사용자 정의 클래스의 프로토타입이나 속성 기술자가 그대로 보존되는 것도 아닙니다. 자세한 범위는 structured clone 알고리즘 문서에서 확인하세요. Node에는 DOM이 없으므로 DOM 노드 실패는 이 파일의 검증 대상이 아닙니다.

JSON.stringify와 JSON.parse를 이어 쓰는 방식은 직렬화 가능한 JSON 데이터로 바꾸는 작업입니다. Date를 객체 형태로 보존하지 못하고 순환 참조는 처리하지 못하므로 범용 복제 함수로 쓰지 마세요. 반대로 단일 상품의 수량 변경에 항상 전체 structuredClone을 적용하면 바뀌지 않은 객체도 새 참조가 됩니다. 필요한 변경 경로만 복사하는 방식과 전체 스냅샷의 목적을 구별하면 선택이 쉬워집니다.

React 상태 갱신으로 연결하기

React를 아직 배우지 않았다면 여기서는 이전 상태를 입력으로 받아 다음 상태를 반환하는 함수 형태만 기억하면 됩니다. React에서 이 함수를 사용할 때는 setState(prev => changeQuantity(prev, id, quantity))처럼 이전 상태를 받아 갱신할 수 있습니다. 상태를 직접 수정하면 이전 렌더가 바라보던 값까지 바뀔 수 있습니다. 위 함수처럼 이전 상태를 보존하면 변경 전후를 비교하기 쉬워집니다.

React 공식 객체 상태 갱신 가이드는 중첩 객체의 변경 경로를 복사하는 방식과 이전 상태를 변경하지 않는 이유를 설명합니다. 참조가 달라지면 언제나 특정 자식이 렌더된다고 일반화하지는 마세요. 렌더링과 최적화는 컴포넌트 구조 등 추가 조건에 영향을 받습니다. 이 글의 완료 기준은 프레임워크 동작 예측보다 원본을 보존하는 데이터 변환입니다.

연습: 이름만 바꾸는 함수와 정답

renameItem(state, id, name)을 직접 작성하세요. 지정한 상품 이름만 바꾸고 다른 상품은 같은 참조를 유지해야 합니다. 없는 id와 같은 이름에는 state를 그대로 반환하세요. 정답을 열기 전에 어떤 참조가 false여야 하는지 종이에 적고, 원본 이름이 유지되는지도 확인하세요. 입력 name은 이미 유효한 문자열이라는 계약으로 제한합니다.

정답과 검증 펼치기

solution.mjs 전체 코드 보기

solution.mjs의 검증까지 성공했다면 “얕은 복사면 안전하다” 대신 “변경 경로의 각 객체를 새로 만들고 나머지는 공유한다”라고 설명할 수 있어야 합니다. 이 원칙은 설정 편집, 장바구니, 목록 이름 수정처럼 이전 상태와 다음 상태를 함께 다루는 작업에서 바로 적용할 수 있습니다.

실습 파일 다운로드

실행 예제와 연습 정답 ZIP 다운로드. 압축을 푼 폴더에서 README의 명령을 실행하세요. 외부 패키지는 설치하지 않습니다.

이 글이 도움이 되었나요?

조회 중

JavaScript 학습 순서

필수 13개 · 전체 14개

읽음 기록 관리

전체 과정 목차 (14개)
  1. 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
  2. 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
  3. 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
  4. 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기 현재 글
  5. 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
  6. 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
  7. 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
  8. 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
  9. 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
  10. 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
  11. 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
  12. 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
  13. 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
  14. 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기