Dev Thinking
32완료

객체 상태의 불변성 - 참조와 값의 차이 이해하기

2025-08-16
10분 읽기

리액트 공식문서 기반의 리액트 입문기

들어가며

자바스크립트로 웹 애플리케이션을 개발하면서 객체와 배열을 다루는 것은 익숙한 작업일 것입니다. 우리는 필요에 따라 객체의 속성을 직접 수정하거나, 배열에 요소를 추가하거나 제거하면서 데이터 상태를 관리해왔습니다. 하지만 리액트에서는 이러한 객체와 배열의 상태를 다룰 때 '불변성(Immutability)'이라는 중요한 원칙을 따르는 것이 좋습니다. 이번 편에서는 리액트에서 객체 상태를 어떻게 다루어야 하는지, 그리고 자바스크립트 방식과 리액트 방식이 어떻게 다른지 '참조와 값의 차이'라는 관점에서 자세히 살펴보려 합니다.

리액트가 왜 객체의 불변성을 강조하는지, 그리고 이 원칙이 예측 가능한 애플리케이션 상태 관리와 렌더링 성능에 어떤 도움을 주는지 함께 고민해보는 시간을 가졌으면 합니다. 단순히 문법을 익히는 것을 넘어, 그 배경에 깔린 리액트의 철학을 이해한다면 더욱 견고한 리액트 개발자로 성장할 수 있을 것이라는 기대를 가집니다.

리액트의 불변성 원칙 - 예측 가능한 상태 관리를 위한 선택

리액트에서 상태(State)는 곧 UI의 모습과 같습니다. 우리가 useState 훅을 통해 관리하는 모든 값은 컴포넌트가 렌더링될 때 사용되며, 이 상태가 변경되면 리액트는 해당 컴포넌트를 다시 렌더링하여 UI를 업데이트합니다. 이때 리액트가 상태 변경을 감지하는 방식은 매우 중요합니다. 리액트는 기본적으로 이전 상태와 현재 상태의 '참조(Reference)'가 달라졌는지 여부로 변화를 감지하는 것처럼 동작합니다.

만약 우리가 객체나 배열의 내용을 직접 수정한다면, 해당 객체나 배열의 '참조' 자체는 변하지 않으므로 리액트는 상태가 변경된 것을 인지하지 못할 수 있습니다. 이는 UI 업데이트가 누락되거나 예상치 못한 버그로 이어질 수 있습니다. 이러한 문제를 방지하고, 상태 변경이 일어날 때마다 새로운 객체나 배열의 참조를 생성하여 리액트가 변화를 명확히 감지하도록 하는 것이 바로 '불변성 원칙'입니다. 이 원칙을 지킴으로써 우리는 더 예측 가능하고 디버깅하기 쉬운 애플리케이션을 만들 수 있습니다.

자바스크립트로 객체 상태 변경하기

먼저 자바스크립트 방식으로 객체 상태를 변경하는 방법을 살펴보겠습니다. 간단한 사용자 정보를 담은 객체를 만들고, 나중에 이 객체의 특정 속성을 변경하는 예제를 통해 자바스크립트의 객체 처리 방식을 이해해 봅시다.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>JavaScript Object Update</title>
  </head>
  <body>
    <h1>JavaScript 객체 상태 변경 예제</h1>
    <div id="userInfo"></div>
    <button id="updateButton">이름 변경</button>
 
    <script>
      (function () {
        let user = {
          name: "김철수",
          age: 30,
          address: {
            city: "서울",
            zip: "12345",
          },
        };
 
        const userInfoDiv = document.getElementById("userInfo");
        const updateButton = document.getElementById("updateButton");
 
        function renderUserInfo() {
          // 기존 자식 노드 제거
          userInfoDiv.innerHTML = "";
 
          const fragment = document.createDocumentFragment();
 
          const nameP = document.createElement("p");
          nameP.textContent = `이름: ${user.name}`;
          fragment.appendChild(nameP);
 
          const ageP = document.createElement("p");
          ageP.textContent = `나이: ${user.age}`;
          fragment.appendChild(ageP);
 
          const cityP = document.createElement("p");
          cityP.textContent = `도시: ${user.address.city}`;
          fragment.appendChild(cityP);
 
          userInfoDiv.appendChild(fragment);
        }
 
        // 초기 렌더링
        renderUserInfo();
 
        // 버튼 클릭 시 객체 속성 직접 변경
        updateButton.addEventListener("click", () => {
          user.name = "이영희"; // 객체의 속성을 직접 수정
          user.address.city = "부산"; // 중첩된 객체의 속성 직접 수정
          console.log("객체 직접 변경 후 user:", user);
          renderUserInfo(); // UI 수동 업데이트
        });
      })();
    </script>
  </body>
</html>

위 자바스크립트 코드에서는 user 객체의 name 속성과 중첩된 address.city 속성을 직접 수정하고 있습니다. 이렇게 객체를 직접 수정하는 방식은 자바스크립트 개발자에게 매우 자연스러운 접근 방식입니다. 변경 후에는 renderUserInfo() 함수를 다시 호출하여 UI를 수동으로 업데이트해주고 있습니다.

자바스크립트 방식의 특징

  1. 객체 직접 수정 (Mutable Update): user.name = "이영희"와 같이 객체의 속성을 직접 할당하여 값을 변경합니다. 이는 원본 객체의 참조가 그대로 유지된 채 내부 값만 바뀌는 '가변적(mutable)'인 방식입니다.
  2. 참조 동일성 유지: 객체의 내용이 바뀌더라도 user 변수가 가리키는 메모리 주소(참조)는 동일합니다. user === user는 항상 true입니다.
  3. 수동적인 UI 업데이트: 객체 상태가 변경된 후에는 renderUserInfo()와 같은 함수를 명시적으로 호출하여 DOM을 직접 조작해 UI를 업데이트해야 합니다.
  4. 중첩 객체 처리: 중첩된 객체(user.address)의 속성(city)도 직접 접근하여 수정할 수 있습니다.
  5. 예측의 어려움: 객체가 여러 곳에서 공유되거나 사용될 경우, 한 곳에서의 직접적인 변경이 다른 곳에 어떤 영향을 미칠지 추적하기 어려워 예상치 못한 부작용(side effect)을 유발할 수 있습니다. 물론 Proxy와 같은 고급 기능을 활용하면 객체의 변경을 감지하고 제어하는 메커니즘을 구축하여 이러한 예측의 어려움을 부분적으로 완화할 가능성도 있지만, 이는 일반적인 자바스크립트의 직접 수정 방식이 기본적으로 가지는 특성은 아닙니다.

리액트로 동일한 객체 상태 만들기

이제 리액트 버전으로 넘어가 보겠습니다. 리액트에서는 useState 훅을 사용하여 상태를 관리하며, 객체와 같은 참조 타입의 상태를 업데이트할 때는 불변성 원칙을 지키는 것이 매우 중요합니다. 즉, 기존 객체를 직접 수정하는 대신, 새로운 객체를 만들고 변경된 속성만 새 객체에 반영하는 방식으로 상태를 업데이트해야 합니다.

import { useState } from 'react';
 
function UserProfile() {
  const [user, setUser] = useState({
    name: '김철수',
    age: 30,
    address: {
      city: '서울',
      zip: '12345',
    },
  });
 
  const updateUserName = () => {
    // 객체의 불변성을 유지하며 name 속성만 변경
    setUser({
      ...user, // 기존 user 객체의 모든 속성을 복사
      name: '이영희', // name 속성만 새로운 값으로 덮어씀
    });
  };
 
  const updateUserCity = () => {
    // 중첩 객체의 불변성 유지 (주의: 깊은 복사 필요)
    setUser(prevUser => ({
      ...prevUser,
      address: {
        ...prevUser.address, // 기존 address 객체의 모든 속성을 복사
        city: '부산', // city 속성만 새로운 값으로 덮어씀
      },
    }));
  };
 
  return (
    <div>
      <h1>React 객체 상태 변경 예제</h1>
      <p>이름: {user.name}</p>
      <p>나이: {user.age}</p>
      <p>도시: {user.address.city}</p>
      <button onClick={updateUserName}>이름 변경</button>
      <button onClick={updateUserCity}>도시 변경</button>
    </div>
  );
}
 
export default UserProfile;

위 리액트 코드에서 updateUserName 함수를 보면, setUser 함수를 호출할 때 ...user와 같이 스프레드(Spread) 연산자를 사용하여 기존 user 객체의 모든 속성을 복사하고, name 속성만 새로운 값으로 덮어쓰고 있습니다. 이로써 완전히 새로운 user 객체 인스턴스가 생성되고, 리액트는 이전 user 객체와 현재 user 객체의 참조가 달라졌음을 감지하여 UI를 업데이트합니다.

updateUserCity 함수에서는 중첩된 객체인 address를 변경하기 위해 한 번 더 스프레드 연산자를 사용하여 address 객체 자체를 새롭게 생성하는 것을 볼 수 있습니다. 이렇게 해야 address 객체의 참조도 변경되어 리액트가 중첩 객체의 변화도 올바르게 감지할 수 있습니다. prevUser를 사용하는 함수형 업데이트를 통해 이전 상태에 안전하게 접근하는 것도 중요합니다.

리액트 방식의 특징 (객체 상태의 불변성)

  • 1. 불변적 업데이트(Immutable Update)와 참조 동일성: 리액트에서 객체 상태를 업데이트할 때는 기존 객체를 직접 수정(Mutable Update)하는 대신, 항상 새로운 객체(New Object Reference)를 생성하여 변경된 속성만 새 객체에 반영하는 불변적 업데이트(Immutable Update) 방식을 사용해야 합니다. 이는 리액트가 이전 상태와 현재 상태의 '참조(Reference)'가 달라졌는지 여부로 변화를 감지하기 때문입니다. 새로운 객체 참조를 생성함으로써 리액트는 상태 변경을 명확히 인지하고 효율적으로 UI를 업데이트합니다. 이 원칙은 상태 변화를 예측 가능하게 하고, 불필요한 리렌더링을 방지하여 성능을 최적화하는 데 핵심적인 역할을 합니다.
  • 2. 자동적인 UI 동기화와 개발 편의성: setUser와 같은 setter 함수를 통해 상태를 업데이트하면, 리액트가 자동으로 컴포넌트를 다시 렌더링하고 변경된 부분만 효율적으로 Virtual DOM을 통해 실제 DOM에 반영합니다. 자바스크립트에서 renderUserInfo()와 같은 함수를 명시적으로 호출하여 UI를 수동으로 업데이트해야 했던 것과 달리, 리액트는 상태와 UI의 동기화 책임을 자동으로 관리합니다. 이는 개발자가 UI 업데이트 로직에 대한 부담을 덜고, 애플리케이션의 비즈니스 로직 구현에 더 집중할 수 있게 하여 개발 생산성을 크게 향상시킵니다.
  • 3. 중첩 객체 처리의 심층 이해와 함수형 업데이트: 객체 내부에 또 다른 객체가 중첩되어 있을 경우, 해당 중첩 객체를 변경할 때는 해당 중첩 객체까지도 새로운 객체로 복사해야 합니다. 단순히 최상위 객체만 복사하면 중첩 객체의 참조는 그대로 유지되어 리액트가 깊은 곳의 변화를 감지하지 못할 수 있습니다. 이때 setUser((prevUser) => ({ ...prevUser, address: { ...prevUser.address, city: "부산" } }))와 같이 함수형 업데이트를 사용하여 이전 상태(prevUser)에 안전하게 접근하고, 스프레드 연산자(...)를 통해 각 계층의 불변성을 유지하는 것이 중요합니다. 이는 복잡한 객체 상태 관리를 더욱 명확하고 예측 가능하게 만듭니다.
  • 4. 예측 가능한 상태 관리와 디버깅 용이성: 불변성 원칙을 유지하면 상태 변화가 언제, 어디서 일어났는지 추적하기 쉬워지고, 예기치 못한 **사이드 이펙트(Side Effect)**를 줄여 애플리케이션의 안정성을 높일 수 있습니다. 각 상태 변경이 새로운 객체 인스턴스를 생성하므로, propsstate의 변화를 추적하는 것이 훨씬 용이해집니다. 이는 특히 복잡한 애플리케이션에서 버그를 찾고 해결하는 디버깅 과정을 크게 단순화하며, 코드의 장기적인 유지보수성에도 긍정적인 영향을 미칩니다.
  • 5. Immer 라이브러리를 통한 생산성 향상: 스프레드 연산자를 사용한 불변성 업데이트는 중첩된 객체가 많아질수록 코드가 복잡하고 길어질 수 있습니다. 이때 Immer와 같은 불변성 관리 라이브러리를 활용하면, 마치 일반 자바스크립트 객체를 직접 수정하는 것처럼 직관적으로 코드를 작성하면서도 Immer가 내부적으로 불변성을 유지하는 새로운 상태를 생성해줍니다. 이는 개발자가 불변성 로직에 대한 고민을 줄이고, 생산성을 높이는 데 크게 기여합니다. (바로 아래 'Immer 라이브러리' 섹션에서 더 자세히 다룹니다.)

Immer 라이브러리: 불변성을 더 쉽게

지금까지 우리는 리액트에서 객체 상태의 불변성을 유지하기 위해 스프레드 연산자(...)를 사용하는 방법을 살펴보았습니다. 이는 매우 중요한 원칙이자 필수적인 패턴이지만, 객체의 중첩 깊이가 깊어지거나 변경해야 할 부분이 많아질수록 코드가 복잡해지고 가독성이 떨어질 수 있습니다. 이때 Immer와 같은 불변성 관리 라이브러리가 유용하게 사용될 수 있습니다.

Immer는 "copy-on-write"라는 전략을 사용하여 불변성 업데이트를 더욱 직관적이고 간결하게 만들어줍니다. 마치 일반 JavaScript 객체를 직접 수정하는 것처럼 코드를 작성해도, Immer는 내부적으로 변경 사항을 추적하여 새로운 불변 상태를 생성해줍니다.

import produce from 'immer';
 
const baseState = {
  name: '김철수',
  age: 30,
  address: {
    city: '서울',
    zip: '12345',
  },
};
 
const nextState = produce(baseState, draft => {
  draft.name = '이영희';
  draft.address.city = '부산';
});
 
console.log(baseState === nextState); // false (새로운 객체가 생성됨)
console.log(baseState.address === nextState.address); // false (변경이 일어난 address도 새로운 객체로)
console.log(baseState.age === nextState.age); // true (변경되지 않은 age는 참조가 유지됨)

위 예시처럼 produce 함수를 사용하면 draft 객체를 직접 수정하는 것처럼 코드를 작성할 수 있습니다. Immer는 이 draft에 대한 변경 사항만을 기반으로 불변성을 유지하는 nextState를 반환합니다.

Immer의 현재 사용 현황 및 고려사항

글을 작성하는 기준인 2025년 8월, Immer는 여전히 리액트를 포함한 다양한 JavaScript 프로젝트에서 활발하게 사용되고 있는 강력한 라이브러리입니다. 특히 Redux Toolkit과 같은 주요 상태 관리 라이브러리들이 내부적으로 Immer를 채택하면서 그 입지를 더욱 공고히 했습니다. 복잡하거나 깊이 중첩된 상태를 불변적으로 업데이트해야 할 때 코드의 가독성과 유지보수성을 크게 향상시켜 줍니다.

하지만 Immer를 사용할 때는 몇 가지 유의사항도 고려해야 합니다.

  1. 번들 크기 증가: Immer 라이브러리 자체가 애플리케이션 번들에 추가되므로, 아주 작은 규모의 프로젝트에서는 약간의 번들 크기 증가가 발생할 수 있습니다.
  2. 학습 곡선: produce 함수와 draft 객체와 같은 Immer의 개념에 익숙해지는 데 약간의 시간이 필요할 수 있습니다.
  3. 단순한 상태에는 과함: 숫자나 문자열과 같은 원시 타입 또는 아주 간단한 객체 상태를 업데이트하는 경우에는 Immer를 사용하는 것이 오히려 코드를 불필요하게 복잡하게 만들 수 있습니다.
  4. 디버깅 시 약간의 복잡성: JavaScript Proxy를 사용하기 때문에, produce 함수 내부에서 예외가 발생했을 때 스택 트레이스가 일반적인 JavaScript 코드보다 덜 직관적일 수 있습니다.

몇 가지 유의사항이 있긴 하나, Immer는 불변성 코드를 작성하는 데 드는 정신적 부하를 크게 줄여주어 개발 생산성을 높이는 데 기여하는 매우 유용한 도구입니다. 리액트 공식문서에서도 Immer의 사용을 언급하고 있기도 하므로, 복잡한 상태를 다루는 프로젝트에서는 충분히 도입을 고려해볼 만한 가치가 있다고 생각합니다.

자바스크립트 vs 리액트 차이

이제 자바스크립트 방식과 리액트 방식의 객체 상태 관리 차이를 명확하게 비교해 보겠습니다.

구분자바스크립트 (명령형)리액트 (선언형, 불변성 원칙)
상태 정의일반 자바스크립트 객체/변수useState 훅을 사용한 상태 객체 정의
상태 변경객체 속성을 직접 수정 (가변적)새 객체를 생성하여 변경된 값 반영 (불변적, Immer 사용 시 직관적 수정)
UI 업데이트innerHTML, textContent 등으로 DOM 직접 조작 (수동)setState 호출 시 리액트가 자동 렌더링 (자동)
변경 감지개발자가 직접 변경 여부 판단 및 UI 업데이트객체 참조의 동일성 여부로 변경 감지 (Immer는 불변성을 유지하며 새 참조 생성)
데이터 흐름양방향 데이터 흐름 가능성단방향 데이터 흐름 (상태 -> UI) 지향

요약

이번 편에서는 리액트에서 객체 상태를 다룰 때 매우 중요한 '불변성' 원칙과, 이 원칙이 자바스크립트의 전통적인 객체 처리 방식과 어떻게 다른지 함께 살펴보았습니다. 공부한 것을 정리해보면 다음과 같습니다.

  • 불변성 원칙: State 변경 시 기존 객체를 직접 수정하는 대신 항상 새로운 객체 인스턴스 생성.
  • 스프레드 연산자 활용: ... 스프레드 연산자를 사용하여 기존 속성을 복사하고 변경할 속성만 새 값으로 덮어쓰기.
  • 중첩 객체 처리: 중첩된 객체를 변경할 때는 해당 중첩 객체까지 새로운 객체로 복사하여 참조 변경 유도.
  • 함수형 업데이트: 이전 상태(prevUser)에 안전하게 접근하여 최신 상태를 기반으로 업데이트 수행.
  • Immer 라이브러리: 복잡한 객체 불변성 업데이트를 직관적이고 간결하게 만들어주는 유용한 도구.

객체 상태의 불변성을 이해하고 올바르게 적용하는 것은 리액트 개발에서 매우 중요한 기초 지식입니다. 처음에는 번거롭게 느껴질 수 있지만, 이 원칙을 따르면 애플리케이션의 상태 변화를 훨씬 더 쉽게 추적하고 관리할 수 있게 될 것입니다. 다음 편에서는 배열 상태를 불변적으로 업데이트하는 다양한 방법에 대해 알아보겠습니다.

참고문서

– [State에서 객체 업데이트하기 (Updating Objects in State)]