Skip to main content
핵심

자바스크립트 불변성: const, 복사, Object.freeze, React 상태

const가 이름의 재대입을 막는 범위, 복사가 중첩 객체를 다루는 범위, Object.freeze가 속성을 제한하는 범위, React에서 새 상태를 전달하는 규칙을 구분한다.

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

30초 요약

같은 문제처럼 보이지만 각 도구의 책임은 다르다.

| 도구 또는 규칙 | 직접 보장하는 것 | 직접 보장하지 않는 것 | | --- | --- | --- | | const | 이름을 다른 값에 재대입하지 못하게 함 | 객체 속성의 변경 방지 | | 한 단계 복사 | 바깥 객체나 배열을 새로 만듦 | 모든 중첩 객체의 복제 | | structuredClone() | 지원되는 중첩 데이터를 복제 규칙에 따라 새 구조로 만듦 | 모든 자바스크립트 값의 복제 | | Object.freeze() | 전달한 일반 객체의 속성 추가·삭제·데이터 값 변경을 제한 | 중첩 객체의 자동 동결 | | React에서 상태 갱신 | 기존 상태를 보존하고 새 상태를 설정 함수로 전달하는 규칙 | 객체 내부 변경의 자동 탐지 |

이 차이를 알면 “불변으로 만들려면 무엇을 써야 하나?”라는 질문을 목적에 맞게 더 작은 질문으로 나눌 수 있다.

읽기 전에 알아둘 코드 표기

앞의 두 글에서 다음 내용을 익혔다고 가정한다.

  • 변수 선언은 이름과 값을 연결한다.
  • 여러 이름이 같은 객체 값에 연결될 수 있다.
  • ===Object.is는 객체의 중첩 내용을 비교하지 않고 같은 객체인지 판단한다.

React 예제에 필요한 최소 실행 모델과 새 문법은 해당 절에서 코드 바로 앞에 설명한다.

이 글은 자바스크립트 실행 동작을 다룬다. 런타임(runtime)은 코드가 실제로 실행되는 시점과 환경을 뜻한다. TypeScript의 readonly처럼 타입 검사 단계에서 쓰기 코드를 제한하는 기능은 별도 주제이며, 런타임의 Object.freeze와 같은 것으로 취급하지 않는다.

불변성은 기존 것을 바꾸지 않는 업데이트 규칙이다

숫자나 문자열은 값 자체의 내부를 바꿀 수 없다.

let count = 1
count = 2

이 코드는 숫자 1을 수정한 것이 아니라 count를 다른 숫자 값에 다시 연결한다.

객체는 다르다.

const profile = { name: '민지' }
profile.name = '서준'

이 코드는 이미 존재하는 객체의 속성을 바꾼다. 객체를 불변으로 다룬다는 것은 보통 기존 객체를 그대로 보존하고 필요한 결과를 새 객체로 표현한다는 뜻이다.

const previousProfile = { name: '민지' }
const nextProfile = { name: '서준' }
 
console.log(previousProfile.name) // 민지
console.log(nextProfile.name)     // 서준

이 규칙의 장점은 이전과 다음을 동시에 관찰할 수 있고, 어느 경로가 새 객체가 되었는지 비교로 알아낼 수 있다는 점이다.

const는 이름의 재대입을 막는다

const settings = { theme: 'dark' }
 
settings.theme = 'light' // 가능
settings = { theme: 'light' } // TypeError

const를 우선 사용하는 코딩 규칙은 이름의 뜻이 실행 중 갑자기 다른 값으로 바뀌는 일을 줄여 준다. 그러나 이것을 “객체가 절대 바뀌지 않는다”는 보장으로 읽으면 안 된다.

스프레드는 한 단계만 복사한다

먼저 속성이 중첩되지 않은 객체를 복사해 보자.

const original = { name: '민지', age: 20 }
const copy = { ...original }
 
copy.name = '서준'
 
console.log(original.name) // 민지
console.log(copy.name)     // 서준
console.log(copy === original) // false

copy는 새 바깥 객체다. 이제 중첩 객체를 넣는다.

const original = {
  name: '민지',
  preferences: { theme: 'dark' },
}
const copy = { ...original }
 
copy.name = '서준'
copy.preferences.theme = 'light'
 
console.log(original.name) // 민지
console.log(original.preferences.theme) // light
console.log(copy.preferences === original.preferences) // true

배열 스프레드도 같은 원리다.

const original = [{ done: false }]
const copy = [...original]
 
copy[0].done = true
console.log(original[0].done) // true

새 배열을 만들었지만 첫 번째 원소의 객체는 공유한다.

structuredClone은 지원하는 값 그래프를 복제한다

여기서 객체 그래프는 한 객체의 속성에서 다른 객체로 이어지는 연결 전체를 뜻한다. 먼저 중첩 객체 하나만 있는 예제를 보자.

const original = {
  preferences: { theme: 'dark' },
}
 
const copy = structuredClone(original)
 
console.log(copy === original) // false
console.log(copy.preferences === original.preferences) // false

다음 보충 예제에서 self는 원본 객체 자신으로 다시 이어지는 순환 관계다. instanceof Date는 복제된 값도 Date인지 확인한다.

const original = {
  createdAt: new Date('2026-07-27T00:00:00.000Z'),
}
original.self = original
 
const copy = structuredClone(original)
 
console.log(copy.createdAt instanceof Date) // true
console.log(copy.self === copy) // true

따라서 사용 전에는 두 가지를 확인한다.

  1. 데이터 안의 모든 값이 구조화 복제 대상인가?
  2. 전체 그래프를 독립시켜야 하는가, 아니면 바뀐 경로만 새로 만들면 되는가?

두 번째 질문의 답이 “바뀐 경로만”이라면 전체 복제보다 바뀐 경로만 새로 만드는 방식이 의도에 더 잘 맞는다.

정확한 적용 범위

일반 객체와 배열의 핵심 예측을 익힌 뒤 다음 예외를 확인한다. 지원되는 값이라고 해서 내부 데이터까지 언제나 원본과 완전히 독립되는 것은 아니다. SharedArrayBuffer처럼 여러 실행 흐름이 같은 메모리를 공유하도록 만든 값은 복제된 객체도 원본과 같은 공유 메모리를 사용할 수 있다. 다른 실행 흐름으로 소유권을 옮길 수 있는 전송 가능 값도 별도 규칙을 따른다.

Object.freeze는 전달받은 객체만 동결한다

const profile = {
  name: '민지',
  preferences: { theme: 'dark' },
}
 
const result = Object.freeze(profile)
 
console.log(result === profile) // true
console.log(Object.isFrozen(profile)) // true
console.log(Object.isFrozen(profile.preferences)) // false

Object.isFrozen(value)는 전달한 객체 자체가 동결되었는지를 truefalse로 알려 준다.

동결된 바깥 객체에는 새 속성을 추가하거나 기존 데이터 속성 값을 바꾸거나 속성을 삭제할 수 없다. 실패한 쓰기가 TypeError를 던지는지는 실행 코드가 엄격 모드인지에 따라 달라진다. 엄격 모드는 실패한 대입을 예외로 알리는 실행 규칙이다. 이 모드에서는 예외를 던지고, 비엄격 모드에서는 실패가 조용히 무시될 수 있지만 어느 경우든 동결된 데이터 속성 값은 바뀌지 않는다.

중첩 객체는 별도 객체 값이다.

profile.preferences.theme = 'light'
console.log(profile.preferences.theme) // light

동결은 복사도 아니다. 같은 객체를 반환하므로 이전 값과 다음 값을 별도로 보관하는 업데이트를 만들어 주지 않는다. React에서 상태를 갱신할 때 Object.freeze가 상태 설정 함수를 대신할 수 없는 이유다.

정확한 적용 범위

일반 객체의 핵심 동작을 익힌 뒤 다음 예외를 확인한다. Object.freeze()가 모든 객체에서 성공하거나 모든 종류의 쓰기를 없애는 것은 아니다. 예를 들어 요소가 있는 TypedArray(숫자 데이터를 고정된 형식으로 담는 배열형 객체)는 동결하려 하면 TypeError가 발생할 수 있다. 접근자 속성의 setter(값을 대입할 때 실행되는 함수)도 호출될 수 있으므로, 동결은 일반 객체의 자체 데이터 속성과 구성을 고정하는 기능으로 이해한다.

바뀐 경로만 새로 만든다

중첩된 설정 하나를 바꾸기 위해 전체 상태 그래프를 깊게 복제할 필요는 없다.

const previous = {
  profile: {
    name: '민지',
    preferences: { theme: 'dark' },
  },
  permissions: ['read'],
}
 
const next = {
  ...previous,
  profile: {
    ...previous.profile,
    preferences: {
      ...previous.profile.preferences,
      theme: 'light',
    },
  },
}

각 비교 결과를 따라가 보자.

next === previous // false
next.profile === previous.profile // false
next.profile.preferences === previous.profile.preferences // false
next.permissions === previous.permissions // true

이 예제에서 permissions는 바뀌지 않았으므로 공유해도 된다. 이후 next.permissions를 직접 변경한다면 이전 상태에도 영향을 주므로, 공유하는 가지도 앞으로 변경하지 않는다는 불변 업데이트 규칙을 계속 지켜야 한다.

중첩 단계가 너무 깊어 스프레드가 반복된다면 먼저 상태 구조를 더 작은 객체로 나눌 수 있는지 검토한다. 어떤 도구를 쓰더라도 바뀐 경로가 새 객체가 되어야 한다는 원리는 같다.

React에는 새 상태 객체를 전달한다

이 글에서 사용하는 함수 컴포넌트는 입력을 바탕으로 화면 조각을 계산하는 함수다. 렌더링(rendering)은 React가 이 함수를 실행해 다음 화면을 계산하는 과정이다. 상태 설정 함수는 지금 실행 중인 변수의 값을 즉시 바꾸는 명령이 아니라, 다음 상태를 저장하고 다음 렌더링을 요청하는 함수다.

useState(initialValue)는 상태 값과 상태 설정 함수를 한 쌍으로 제공한다. setProfile(nextProfile)은 다음 상태를 전달한다. JSX(JavaScript XML)는 자바스크립트 안에서 화면 구조를 표현하는 문법이지만, 이 글에서는 화면 표시보다 상태 업데이트에 집중한다.

const [profile, setProfile] = useState(...)의 왼쪽 대괄호는 한 쌍의 첫 번째 값을 profile, 두 번째 값을 setProfile이라는 이름에 각각 연결하는 배열 구조 분해 문법이다.

이제 React에서 객체 상태를 직접 바꾸는 코드를 보자.

const [profile, setProfile] = useState({
  name: '민지',
  preferences: { theme: 'dark' },
})
 
function handleRename() {
  profile.name = '서준'
  setProfile(profile)
}

두 문제가 겹친다. 현재 렌더링이 읽고 있는 객체를 직접 바꾸고, 상태 설정 함수에는 이전과 같은 객체를 전달한다.

바깥 속성 하나를 바꿀 때는 새 객체를 전달한다.

current => ({ ... })current라는 이름으로 현재 상태를 받아 새 객체를 반환하는 짧은 함수 표기다. 객체 중괄호를 괄호로 감싸야 함수의 본문이 아니라 반환할 객체라는 뜻이 된다. 이런 함수를 업데이터 함수(updater function)라고 부른다.

한 컴포넌트에서 전체 업데이트 흐름을 확인한다

다음은 바깥의 name과 중첩된 preferences.theme을 각각 갱신하는 완전한 함수 컴포넌트다. import는 React 패키지에서 useState를 가져오고, export default는 다른 파일이 이 컴포넌트를 기본 결과로 가져올 수 있게 한다.

import { useState } from 'react'
 
export default function ProfileEditor() {
  const [profile, setProfile] = useState({
    name: '민지',
    preferences: { theme: 'dark' },
  })
 
  function handleRename() {
    setProfile(current => ({
      ...current,
      name: '서준',
    }))
  }
 
  function handleThemeChange() {
    setProfile(current => ({
      ...current,
      preferences: {
        ...current.preferences,
        theme: 'light',
      },
    }))
  }
 
  return (
    <section>
      <p>이름: {profile.name}</p>
      <p>테마: {profile.preferences.theme}</p>
      <button type="button" onClick={handleRename}>
        이름 바꾸기
      </button>
      <button type="button" onClick={handleThemeChange}>
        테마 바꾸기
      </button>
    </section>
  )
}

JSX 안의 {profile.name}{profile.preferences.theme}은 중괄호 안의 자바스크립트 표현식을 평가해 화면에 표시한다. onClick={handleRename}은 버튼을 클릭했을 때 handleRename 함수를 호출하도록 연결한다. 중첩된 테마를 바꾸는 handleThemeChange는 바뀐 값에서 최상위 상태까지 이어지는 객체를 모두 새로 만든다.

업데이터 함수의 current는 React가 앞선 요청을 반영해 처리 중인 최신 상태를 받는다. 이전 상태를 바탕으로 다음 상태를 계산할 때 사용하면 한 번의 클릭이나 입력 처리 안에서 여러 업데이트가 쌓이는 경우에도 각 업데이트의 입력을 분명히 할 수 있다.

React가 불변 객체만 허용하는 자바스크립트 런타임을 만드는 것은 아니다. 일반 자바스크립트 객체는 여전히 변경 가능하다. React에서 상태를 불변으로 다루는 것은 스냅샷을 보존하고 이전과 구별되는 새 객체로 업데이트를 전달하기 위한 프로그래밍 규칙이다.

목적별로 도구를 선택한다

마지막으로 문제를 도구에 연결해 보자.

  1. 이 이름을 다른 값으로 바꾸지 못하게 하고 싶은가? const를 사용한다.
  2. 바깥 객체나 배열만 새로 만들면 되는가? 객체·배열 스프레드 같은 얕은 복사를 사용한다.
  3. 구조화 복제 규칙에 따라 새 객체 그래프를 만들어야 하는가? structuredClone()의 지원 범위와 공유 메모리·전송 가능 값의 별도 규칙을 확인한다.
  4. 일반 객체의 자체 데이터 속성 값 변경과 속성 추가·삭제·재정의를 막아야 하는가? Object.freeze()의 지원 범위를 확인하고 중첩 객체는 별도로 판단한다.
  5. React의 이전 상태를 보존하며 일부만 갱신하는가? 변경 경로를 새로 만들고 상태 설정 함수에 새 최상위 객체를 전달한다.

불변성은 특정 메서드 하나의 이름이 아니다. 기존 상태를 보존해야 하는 이유를 먼저 정하고, 각 도구가 어느 층까지 보장하는지 확인하는 업데이트 설계다.

Active recall

기억에서 꺼내 보기

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

  1. 01`const profile = { name: "민지" }`에서 무엇을 바꿀 수 없고 무엇은 별도 조치 없이 바꿀 수 있을까?

    정답

    `profile` 바인딩을 다른 값에 재대입할 수 없다. 그러나 기존 객체의 `name` 속성은 바꿀 수 있다.

    왜 그런가

    const는 이름과 값의 연결을 제한하며 객체를 자동으로 동결하지 않는다.

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

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

  2. 02`const copy = { ...original }` 뒤에 `copy.nested.value`를 바꾸면 원본의 중첩 값도 바뀔 수 있는 이유는 무엇일까?

    정답

    스프레드는 새 바깥 객체를 만들지만 `nested`에는 원본과 같은 중첩 객체 값이 들어갈 수 있기 때문이다.

    왜 그런가

    바깥 객체의 동일성이 달라진 것과 모든 중첩 객체가 새로 만들어진 것은 다른 보장이다.

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

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

  3. 03Object.freeze로 바깥 객체를 동결하면 중첩 객체도 자동으로 동결될까?

    정답

    아니다. Object.freeze는 전달받은 객체 자체에 적용되며 중첩 객체는 별도로 동결하지 않는 한 바뀔 수 있다.

    왜 그런가

    깊은 동결이 필요하면 객체 그래프를 순회하며 각 객체를 동결하는 별도 절차가 필요하다.

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

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

  4. 04중첩 상태의 theme만 바꿀 때 어느 객체를 새로 만들며, 왜 전체를 structuredClone으로 복제할 필요가 없을까?

    정답

    theme이 들어 있는 객체부터 최상위 상태까지 이어지는 변경 경로만 새로 만들고, 바뀌지 않은 다른 가지는 이전 객체를 재사용할 수 있기 때문이다.

    왜 그런가

    structuredClone은 지원되는 객체 그래프 전체를 새 구조로 만들지만, 한 경로만 바뀌는 업데이트에서는 변경 경로만 복사해야 이전 상태를 보존하면서 바뀌지 않은 가지도 공유할 수 있다.

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

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

  5. 05React 객체 상태를 직접 바꾼 뒤 같은 객체를 상태 설정 함수에 전달하면 왜 문제가 될까?

    정답

    이전 렌더링의 상태 스냅샷을 변경하고, React의 Object.is 비교에서도 이전 상태와 같은 객체로 판단될 수 있기 때문이다.

    왜 그런가

    기존 상태를 바꾸지 않고 새 객체를 만들어 설정 함수에 전달해야 다음 상태가 분명해진다.

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

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

출처와 검증 범위

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

  1. Let and Const DeclarationsEcma International · 표준 명세 · 확인 2026-07-27
  2. constMDN Web Docs · 공식 문서 · 확인 2026-07-27
  3. Spread syntaxMDN Web Docs · 공식 문서 · 확인 2026-07-27
  4. Safe passing of structured dataWHATWG · 표준 명세 · 확인 2026-07-27
  5. Window structuredClone()MDN Web Docs · 공식 문서 · 확인 2026-07-27
  6. Object.freezeEcma International · 표준 명세 · 확인 2026-07-27
  7. Object.freeze()MDN Web Docs · 공식 문서 · 확인 2026-07-27
  8. Updating Objects in StateReact · 공식 문서 · 확인 2026-07-27
  9. State as a SnapshotReact · 공식 문서 · 확인 2026-07-27
  10. useStateReact · 공식 문서 · 확인 2026-07-27
이 문서의 마지막까지 읽었습니다.