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

··설명 약 7분·작성: 해비·블로그 소개

JavaScript 배열 안의 중첩 객체를 원본 변경 없이 어떻게 갱신하나요?

배열을 복사해도 안쪽 객체는 원본과 연결돼 있습니다. 원본을 유지하며 한 항목을 바꾸려면 배열과 변경할 객체·중첩 객체를 새로 만듭니다. 바꾸지 않은 항목은 재사용할 수 있습니다.

적용 조건·범위: 대상 작업과 meta는 새 객체이며 다른 작업은 같은 객체를 재사용합니다. 문자열 id를 숫자와 비교하면 대상이 변경되지 않으므로 타입까지 일치시킵니다. Node.js에서 위 출력과 일치함을 확인했습니다. 브라우저 Console 실행은 미확인입니다.

공식 문서 참고: MDN: spread 얕은 복사와 중첩 참조

혼자 실행해 보는 객체 참조와 중첩 갱신

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

새 배열을 만든 뒤 원본이 유지되는 결과를 비교하세요.

예제 크게 보기 (새 탭)

독자 질문: 배열 안 한 작업을 바꿀 때 원본과 다른 작업의 참조는 어떻게 되는가?

js-05-example.mjs로 저장한 뒤 node js-05-example.mjs을 실행하세요. 외부 패키지는 필요하지 않습니다.

const tasks = [
  { id: '1', title: 'JavaScript 학습', completed: false, meta: { priority: 1 } },
  { id: '2', title: '화면 확인', completed: true, meta: { priority: 2 } },
];
const nextTasks = tasks.map((task) => task.id === '1'
  ? { ...task, completed: !task.completed, meta: { ...task.meta, priority: 3 } }
  : task);
console.log('원본:', tasks[0].completed, tasks[0].meta.priority);
console.log('변경:', nextTasks[0].completed, nextTasks[0].meta.priority);
console.log('대상 참조:', tasks[0] === nextTasks[0]);
console.log('중첩 참조:', tasks[0].meta === nextTasks[0].meta);
console.log('그 외 참조:', tasks[1] === nextTasks[1]);

예상 출력:

원본: false 1
변경: true 3
대상 참조: false
중첩 참조: false
그 외 참조: true

대상 작업과 meta는 새 객체이며 다른 작업은 같은 객체를 재사용합니다. 문자열 id를 숫자와 비교하면 대상이 변경되지 않으므로 타입까지 일치시킵니다. Node.js에서 위 출력과 일치함을 확인했습니다. 브라우저 Console 실행은 미확인입니다.

직접 풀어볼 문제

id가 ‘2’인 작업의 title만 ‘화면 재확인’으로 바꾸고 원본과 변경 결과를 비교하세요. 예상 결과는 원본 title ‘화면 확인’, 새 title ‘화면 재확인’입니다.

원시값과 객체 복사는 다르게 동작합니다

let first = 10;
let second = first;


second = 20;


console.log(first);

first는 여전히 10입니다.

객체는 다릅니다.

const firstTask = {
  id: 1,
  completed: false,
};


const secondTask = firstTask;
secondTask.completed = true;


console.log(firstTask.completed);

두 변수는 같은 객체를 가리키므로 firstTask.completed도 true가 됩니다.

객체의 ===는 내용이 아니라 참조를 비교합니다

const a = { title: '공부' };
const b = { title: '공부' };


console.log(a === b);

값이 같아 보여도 서로 다른 객체이므로 false입니다.

spread로 새 객체 만들기

const task = {
  id: 1,
  title: 'JavaScript 공부',
  completed: false,
};


const nextTask = {
  ...task,
  completed: true,
};


console.log(task.completed);
console.log(nextTask.completed);
console.log(task === nextTask);

원본 객체를 유지하면서 변경된 새 객체를 만들 수 있습니다.

얕은 복사는 중첩 객체를 공유할 수 있습니다

const task = {
  title: '공부',
  meta: {
    priority: 1,
  },
};


const copied = { ...task };


console.log(task.meta === copied.meta);

결과는 true입니다. 바깥 객체만 새로 만들었고 meta는 같은 객체를 가리킵니다.

중첩 값까지 바꾸려면 해당 단계도 새 객체로 만들어야 합니다.

const updated = {
  ...task,
  meta: {
    ...task.meta,
    priority: 2,
  },
};

배열에서 특정 객체만 새로 만들기

const tasks = [
  { id: '1', title: '공부', completed: false },
  { id: '2', title: '운동', completed: false },
];


const nextTasks = tasks.map((task) => {
  if (task.id !== '2') {
    return task;
  }


  return {
    ...task,
    completed: true,
  };
});

배열은 새로 만들어지고 변경 대상 객체도 새 객체가 됩니다. 변경하지 않은 객체는 그대로 재사용합니다.

왜 불변 갱신을 사용하는가

Vanilla JavaScript에서도 항상 불변 갱신만 해야 한다는 규칙은 없습니다. 하지만 상태의 이전값과 다음값을 비교하기 쉬워지고, React 같은 UI 라이브러리로 넘어갔을 때 같은 사고방식을 그대로 사용할 수 있습니다.

이 과정에서는 TaskFlow 상태 변경 함수를 예측 가능하게 만들기 위해 불변 갱신을 사용합니다.

불변 갱신은 변경할 객체를 새로 만들고 원본 객체를 유지하는 방식입니다.
개념 설명용 일러스트. 불변 갱신은 변경할 객체를 새로 만들고 원본 객체를 유지하는 방식입니다. 이미지 원본 열기

자주 발생하는 오류

배열만 복사하고 객체를 직접 바꿉니다

const nextTasks = [...tasks];
nextTasks[0].completed = true;

배열만 새롭고 nextTasks[0]은 원본의 첫 객체와 같습니다.

JSON 직렬화를 일반 복사 방법으로 사용합니다

JSON.parse(JSON.stringify(value))는 Date, undefined, 함수 등 여러 값을 잃거나 바꿀 수 있습니다. 모든 데이터의 범용 복사 해법으로 사용하지 않습니다.

학습 완료 체크리스트

  • 원시값과 객체 참조 차이를 설명할 수 있습니다.
  • spread가 얕은 복사라는 점을 이해했습니다.
  • 중첩 객체를 필요한 깊이만큼 새로 만들 수 있습니다.
  • 배열 안 특정 객체를 불변 방식으로 갱신할 수 있습니다.

독립 실습은 여기서 마칠 수 있습니다. 아래 선택 실습은 TaskFlow 프로젝트 파일로 진행합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript로 화면에 동작을 붙입니다.

HTML/CSS는 이전 체크포인트를 유지하고, app.js 에는 참조 확인을 연결합니다. 실제 교체 범위는 아래 추가·교체 파일 목록과 전체 파일을 기준으로 합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

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

TaskFlow JavaScript 5단계입니다. 기존 서비스 화면과 실제 콘솔 출력을 확인하세요.

예제 크게 보기 (새 탭)

변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.

시작본과 이번 변경

JavaScript 배열 메서드: map filter forEach reduce 차이의 완료 체크포인트를 시작본으로 사용합니다.

js/task-store.js의 addTask·toggleTask·getTasks를 아래 파일 기준으로 바꿉니다. HTML/CSS는 시작본을 유지하고, app.js는 아래 변경 파일 전체본에 맞춰 변경 전후 참조를 관찰하도록 갱신하세요. 토글 전후 첫 객체 비교가 false이고 변경하지 않은 객체가 재사용되는지 확인한 다음 reduce 요약으로 이어갑니다.

연속 프로젝트 선수 조건

  • 배열과 객체의 기본 구조
  • 4회차의 map()과 filter()
  • javascript-step-04-array-methods 체크포인트

이전 체크포인트

javascript-step-04-array-methods

기존 toggleTask()는 대상 객체를 직접 수정했습니다.

Before

export function toggleTask(id) {
  const task = tasks.find((item) => item.id === id);


  if (!task) {
    return null;
  }


  task.completed = !task.completed;
  return task;
}

After

export function toggleTask(id) {
  let updatedTask = null;


  tasks = tasks.map((task) => {
    if (task.id !== id) {
      return task;
    }


    updatedTask = {
      ...task,
      completed: !task.completed,
    };


    return updatedTask;
  });


  return updatedTask;
}

이번 회차 변경 코드와 전체본 연결

HTML/CSS는 이전 체크포인트를 유지하고, app.js에는 참조 확인을 연결합니다. 실제 교체 범위는 아래 추가·교체 파일 목록과 전체 파일을 기준으로 합니다.

전체 파일·코드 보기

js/task-store.js 전체 코드

let tasks = [
  { id: '1', title: 'JavaScript 과정 정리', completed: false },
  { id: '2', title: 'TaskFlow 화면 확인', completed: true },
  { id: '3', title: '다음 글 읽기', completed: false },
];


export function getTasks() {
  return [...tasks];
}


export function addTask(title) {
  const task = {
    id: String(Date.now()),
    title,
    completed: false,
  };


  tasks = [...tasks, task];
  return task;
}


export function toggleTask(id) {
  let updatedTask = null;


  tasks = tasks.map((task) => {
    if (task.id !== id) {
      return task;
    }


    updatedTask = {
      ...task,
      completed: !task.completed,
    };


    return updatedTask;
  });


  return updatedTask;
}


export function deleteTask(id) {
  tasks = tasks.filter((task) => task.id !== id);
}


export function filterTasks(status = 'all') {
  if (status === 'completed') {
    return tasks.filter((task) => task.completed);
  }


  if (status === 'pending') {
    return tasks.filter((task) => !task.completed);
  }


  return [...tasks];
}

참조 확인

const before = getTasks();
const firstBefore = before[0];


toggleTask('1');


const after = getTasks();
const firstAfter = after[0];


console.log(firstBefore === firstAfter);

토글된 첫 작업은 새 객체이므로 false가 출력되어야 합니다.

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 javascript-step-04-array-methods의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. store 전체 파일을 불변 갱신 구현으로 교체하고 app.js에서 변경 전후 참조를 비교합니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • js/app.js — 전체 교체
  • js/task-store.js — 전체 교체

실행과 확인

npm start
npm test

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: 변경 전후 배열과 변경 대상 객체 참조 비교가 모두 false입니다.

실제 검증: JavaScript 모듈·DOM·경계값 실행 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

js/app.js — 전체 파일

import { getTasks, toggleTask } from './task-store.js';
const before = getTasks();
const firstBefore = before[0];


toggleTask('1');


const after = getTasks();
const firstAfter = after[0];


console.log(firstBefore === firstAfter);

js/task-store.js — 전체 파일

let tasks = [
  { id: '1', title: 'JavaScript 과정 정리', completed: false },
  { id: '2', title: 'TaskFlow 화면 확인', completed: true },
  { id: '3', title: '다음 글 읽기', completed: false },
];


export function getTasks() {
  return [...tasks];
}


export function addTask(title) {
  const task = {
    id: crypto.randomUUID(),
    title,
    completed: false,
  };


  tasks = [...tasks, task];
  return task;
}


export function toggleTask(id) {
  let updatedTask = null;


  tasks = tasks.map((task) => {
    if (task.id !== id) {
      return task;
    }


    updatedTask = {
      ...task,
      completed: !task.completed,
    };


    return updatedTask;
  });


  return updatedTask;
}


export function deleteTask(id) {
  tasks = tasks.filter((task) => task.id !== id);
}


export function filterTasks(status = 'all') {
  if (status === 'completed') {
    return tasks.filter((task) => task.completed);
  }


  if (status === 'pending') {
    return tasks.filter((task) => !task.completed);
  }


  return [...tasks];
}

완료 체크포인트

javascript-step-05-immutable-update

다음 학습

다음 글에서는 reduce()를 이용해 전체·완료·미완료 개수를 하나의 요약 객체로 계산합니다.

다음 글: JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준

글 소개·학습 준비·전체 목차
JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기 표지

JavaScript · 5 / 15 · 필수 학습 · 객체 참조와 불변 갱신

중첩 객체를 원본 수정 없이 바꾸려면 바깥 배열뿐 아니라 변경되는 객체와 그 안의 중첩 객체도 새로 만듭니다. 바꾸지 않은 객체는 재사용할 수 있습니다.

이번 글에서는 JavaScript 객체가 참조로 연결되는 방식을 확인하고, TaskFlow의 작업 상태 변경을 원본을 직접 수정하지 않는 방식으로 바꿉니다.

이번 글에서 배우는 내용

  • 원시값과 객체 참조 차이
  • ===로 객체를 비교할 때의 의미
  • spread 문법
  • 얕은 복사
  • 중첩 객체 복사
  • 배열 안 특정 객체 불변 갱신

먼저 알아야 할 내용

배열·객체와 map·filter의 반환값을 알면 됩니다. 상단 예제의 tasks는 모두 예제 안에서 선언합니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기