Back to list

#03. React의 Rerender 규칙 + 깊은 복사(Deep Copy) vs 얕은 복사(Shallow Copy) vs 참조 복사(Reference Copy)

목차14

TL;DR


개요

본래 이전 글을 작성하다가, 나도 잘못알고 있던 내용이 있어서 추가로 작성하게 되었다.
정적 컴파일 언어에서는 의외로 마주할 일이 적지만, JS나 Python에서는 꽤 치명적인 “복사”와 관련된 내용이다.

그리고, 이 내용이 React의 Rerender 규칙과 어떻게 맞물리는지도 정리해보고자 한다.


값과 복사

아마, 학과나 여러 자료를 통해서 객체지향 언어를 공부할 때 반드시 배우는 2가지 개념이 있다.

1. 값에는 두 종류가 있다:

  • Stack에 직접 저장되는 타입

    • C#에서는 “값 형식(Value Type)”, Java에서는 “원시 타입(Primitive Type)“이라 부르는 그것
    • 크기가 고정되어있고()ex: int = 4 byte), 변수가 그 값 자체를 들고 있음.
    • C#의 경우 enum 역시 Value type로 취급됨
    • 개발자가 struct로 선언한 구조체 역시 값 형식
  • Heap에 저장되고, 변수는 메모리 주소만 들고있음 (참조)

    • 흔히 말하는 Collection들: 배열, 리스트, 문자열, …
    • 객체지향언어에서 개발자가 정의하는 Class
    • Java에서는 enum이 Ref type임
    • Dynamic, alloc/malloc, 포인터, 가변(mutable) 등의 용어랑 항상 엮임

2. 근데 정수값이 항상 Stack에만 저장되는 것은 아니다:

  • 아래 코드처럼 Value Type이지만 Class 등의 객체 내부에서 선언된 필드일 경우가 대표적.
class Address
{
    public string name;
    public Address(string cityName) { name = cityName; }
}

class Person
{
    public int Age;          // 원시 타입 (값)
    public Address Addr;     // 참조 타입 (내부 객체)

    public Person(int age, Address addr)
    {
        Age = age;
        Addr = addr;
    }

    // Ctor
    ...
}

그림으로 표현해보자면 아래와 같다:

스크린샷 2026-07-22 015405

실제로는 Person 인스턴스는 클래스의 메타데이터와 내부 필드들이 하나의 연속된 메모리 블록으로 할당되므로,
위 그림도 엄밀하게 말하자면 올바른 그림은 아니다.
(‘20’ 위에 Person의 헤더 등이 쭉 있겠지만, 중요한건 이게 아니다)

복사의 종류

자바스크립트의 특수성을 살펴보기 전에, 먼저 3종류의 복사에 대해 정리해보자.
예시는 C#으로 통일하긴 했는데,

참조 복사(Reference Copy)

참조 복사는 말 그대로 ‘참조하는 값만’ 복사한다.

// [Case 1] 참조 복사 (Reference Copy)
Person p1 = new Person(20, new Address("Seoul"));  // 원본 객체 생성
Person p2 = p1;  // 참조 복사

p2.Age = 99; // p2의 나이를 바꿈
// 결과: p1.Age도 99로 바뀜

스크린샷 2026-07-22 015118

이미지로 나타내면 위와 같다. 편의상 Person.addr가 가리키는 Address의 인스턴스 객체가 위치한 주소를 address_loc이라 이름붙였다.
단순하게 보자면 둘이 같은 인스턴스를 가리키고 있으니, 둘 중 한놈이 필드 값을 수정하면 모두에게 영향이 간다.
예를들어 p2age 값을 99로 바꿀 경우, 둘이 공유하는 인스턴스의 age는 더이상 20이 아닌 99라는 값을 담게 된다.
따라서, p1.age를 호출해도 99가 튀어나올 것이다.

얕은 복사 (Shallow Copy)

얕은 복사는 참조 복사보다는 한단계 더 들어가서 ‘최상위 객체’는 복사하지만,
객체 내부에서 가리키는 또다른 객체까지는 복사하지 않는다.
즉, 원본 객체 p1이 저장된 일련의 메모리 블록을 그대로 복사해서 어딘가에 붙여넣고, p2한테 그걸 가리키게 시키는 것이다.

// [Case 2] 얕은 복사 (Shallow Copy)
Person p1 = new Person(20, new Address("Seoul"));  // 원본 객체 생성
Person p2 = p1.ShallowCopy();   // ShallowCopy는 가상의 메서드

p1.Age = 30;              // 원시 타입 변경 -> p2.Age는 20 그대로
p1.Addr.name = "Busan";   // 내부 참조 타입 변경 -> p2.Addr.name도 "Busan"으로 바뀜

(실제로는 MemberwiseClone()이라는 내장 메서드가 별도로 존재하지만, 의미 전달을 위해 ShallowCopy라 하겠다.)
결과만 놓고보자면, 값 형식의 값은 보존이 되지만 내부 참조 타입은 여전히 공유하고 있음을 확인할 수 있다.

스크린샷 2026-07-22 015124

위 이미지에서, ageaddr의 값들이 그대로 복사되었음이 보인다.
그러나 얕은 복사는 어디까지나 가장 최상위 1 Depth만 복사하기 때문에,
p1, p2가 각각 참조하는 Person의 인스턴스는 다를지언정 내부에서 참조중인 addr_loc는 여전히 동일한 객체이다.

스크린샷 2026-07-22 015128

따라서 p1.age = 30으로의 변경은 p2에게 영향을 주지 않지만 (완전 독립된 메모리 공간이기 때문에),
p1.addr.name"Busan"으로 변경할 시 p2.addr.name 역시 "Busan"을 뱉을 것이다(여전히 같은 Address 인스턴스를 참조하기 때문에).

깊은 복사 (Deep Copy)

깊은 복사는 모든 내부 참조까지 그대로 복사한 케이스이다.
얕은 복사에서 ‘내부 참조들을 끝까지 추적해서 조지지 못했던’ 이유로 여러 문제가 비롯되었다면,
깊은 복사는 모든 참조 객체를 연쇄적으로 재할당한다.

실제로 제공되는 메서드나 API가 있는지는 모르겠지만, JSON으로 통째로 직렬/역직렬화하거나 리플렉션을 이용한다고 한다.

// [Case 3] 깊은 복사 (Deep Copy)
Person p1 = new Person(20, new Address("Seoul"));  // 원본 객체 생성
Person p2 = p1.DeepCopy();   // 어떻게든 구현한 Deep Copy 메서드

p1.Age = 40;              // 원시 타입 변경 -> p2.Age는 20 그대로 
p1.Addr.name = "Busan";    // 내부 참조 타입 변경 -> p2.Addr.name은 "Seoul" 그대로

스크린샷 2026-07-22 015142

얕은 복사에 비해, Address가 하나 더 추가되었다.
여기서 하나 잡다한 내용을 곁들이자면, ‘끝까지 따라내려가서 복사한다’고 했는데 왜 같은 "Seoul"을 가리키는지 의아할 수 있다.
이는 두가지 측면에서 설명이 필요한데,

  • Deep Copy는 더 쪼갤 수 없는 마지막 잎(leaf)에 도달하면, 굳이 값을 복제하지 않고 참조를 공유함 (불변성)
  • 문자열의 경우, 메모리 절약을 위해 이미 존재하는 문자열은 새로 할당하지 않고 기존 주소를 공유한다 (언어 레벨의 설계, 문자열 풀링)

스크린샷 2026-07-22 015148

아무튼 위 상황에서 만약 p1.Addr.City = "Busan"과 같이 참조하는 문자열 값이 바뀔 경우,
이때 비로소 메모리 공간에 새로운 String "Busan"이 등록되고 객체의 참조 주소가 바뀌게 된다.

Partial-Deep Copy

그러면 극단적으로, 만약 이 예시보다 더 깊은 내부 참조를 가지는 경우에는 뭐라고 부를까?
나도 궁금해서 Gemini한테 물어봤는데, “명확한 명칭은 없음”이라는 답변을 얻었다.
다만, 실무적으로 “부분 깊은 복사(Partial Deep Copy)“라는 용어를 사용하는 경우가 있다는데, Gemini 폼이 요새 좀 떨어져서 어디까지 믿어야할지 잘 모르겠다.


다시 JavaScript로 돌아와서…

서론이 길었고, 드디어 본론이다. 본론이 서론보다 압도적으로 짧을 것 같기는 하다.

Mutation 함정 (+ React의 Rerender 트리거 조건)

이전 게시글에서, Array의 유틸 함수들로 map, filter, find, reduce, forEach를 소개하였다.
위 5개 외에도 JS의 Array에는 다른 언어들처럼 sort, reverse, splice, push와 같은 여러 유틸 함수들이 있다.

그러나 위 연산들의 대부분은 대상 배열의 값을 조작하는, 즉 “원본 배열”을 변경하는 함수들이다.
그리고 이 부분이, 나중에 State와 Setter를 다룰 때 이 부분이 문제가 될 수 있다.

예시를 보기에 앞서, React의 Rerender가 발동되는 조건을 한줄로 정리하자면:

React는 Setter의 호출 + State의 값 변경 시 Rerender가 트리거된다.

1. reverse로 정렬 순서를 뒤집으면 Rerender가 일어날까?

function App() {
  let [arr, setArr] = useState([1, 2, 3]);

  <button onClick = { () => arr.reverse() }>Reverse!</button>
  ...
}
  1. React는 Setter의 호출 + State의 값 변경 시 Rerender가 트리거된다.
  2. State에 배열/객체가 할당될 경우, State에는 배열의 요소들이 아닌 배열의 ‘주소값’이 할당된다.
  3. Setter에서 push를 통해 배열에 요소가 추가되어도, State가 의미하는 배열의 주소값은 변경되지 않는다.
  4. 따라서 Rerender가 발생하지 않는다.

2. 아하, 그렇다면 copy 후 그걸 reverse해서 Setter를 호출하면?

function App() {
  let [arr, setArr] = useState([1, 2, 3]);

  <button onClick = { () => {
    let copy = arr;   // arr를 복사하고?
    copy.reverse() ;    // reverse() 호출했으니 copy = [3, 2, 1]?
    setArr(copy);     // setArr([3, 2, 1])?
  }}>Reverse!</button>
  ...
}

이 경우에도, 어차피 copy에 할당되는 값은 arr의 주소값이지, 배열 요소들이 아니다.
Reference copy가 일어났기 때문에 arr = copy = [3, 2, 1]로 뒤집히긴 하지만, React 입장에서는
“아까랑 똑같은 주소 가리키고 있는데, 뭐 바뀐거 없어보이는데?” 라고 생각할 것이다.
이를 피하기 위해서는, 적어도 아래와 같이 작성해야한다:

function App() {
  let [arr, setArr] = useState([1, 2, 3]);

  <button onClick = { () => {
    let copy = [...arr]; // arr를 Shallow Copy. copy에는 새로운 배열 [1, 2, 3] 할당
    copy.reverse() ;  // copy = [3, 2, 1]
    setArr(copy);     // copy는 arr와 관련 없는 배열
  }}>Reverse!</button>
  ...
}

이러면 우리가 원하는대로 Rerender가 일어나, 변경된 [3, 2, 1]이 UI에 반영된다!

여담으로 Deep Copy는 모든 내부 참조들의 끝까지 내려가서 값을 복사하는거라 했는데,
위 상황에서는 array의 모든 값들이 정수이므로 Shallow Copy = Deep Copy와 같은 상황이다.

만약 array에 담긴 값들이 또다른 객체였다면 Shallow Copy ≠ Deep Copy였겠지만,
React 입장에서는 어쨌건 State(= 가리키는 주소값)에 변경이 생겼으니 Rerender가 트리거되고, 따라서 UI에도 반영될 것이다.

그런데 단순히 값을 뒤집는데 저 세 함수가 필요할까?
저정도면 차라리 유틸 함수를 하나 만들어서 쓰는게 깔끔할지도 모르겠다;;
물론 이미 구현되어있다 (2023년부터인가 추가된걸로 알고있음).

function App() {
  let [arr, setArr] = useState([1, 2, 3]);

  <button onClick = { () => setArr(arr.toReversed()) }>Reverse!</button>
  ...
}

이럴수가, 코드가 20%로 줄어들었다!
이처럼 자동으로 해당 연산이 적용된 새 배열을 return하도록 되어있는 Syntax sugar들이 몇몇 있다:

  • arr.toSorted(): 정렬된 arr와 같은 새 배열을 반환한다.
  • arr.toReversed(): 역순으로 정렬된 arr와 같은 새 배열을 반환한다.
  • arr.with(n, 'value'): n번째 요소가 ‘value’로 치환된 arr와 같은 새 배열을 반환한다.

아마 찾아보면 더 나올테지만, 아무튼 사본 배열을 return하는 유틸 함수들은 알아두면 좋을 것 같다.

JavaScript에서의 타입?

조사하면서 뭔가 어긋나는 것 같아서 찾아보니, JS는 C 계열 언어나 Java 계열 언어와는 조금 다른 방식으로 값들을 관리한다.
(JavaScript는 Java와 아무 관련 없는거 다들 알 것이라 생각한다)

우선, JS 명세에서는 명시적으로 “Stack과 Heap”에 대한 언급을 하지 않는다.
적어도 JS 언어의 타입을 기술한 Section 6. ECMAScript Data Types and Values 부분에서는 단 한번도 언급되지 않았다.

여기서는 “아몰라 메모리에서 어떻게 관리할건지는 엔진이 알아서 할거고, 나는 의미만 정의할거야”라고 한다.
여러 AI한테 여러 맥락에서 정리시킨 결과, 아래와 같이 정리되었다:

  • 타입 시스템

    • 정수 a, b가 같은 5라는 값을 가지고 있다고 해서 a, b가 동일한 메모리 주소를 가리키지 않았음
    • 참조형 변수 str1, str2가 같은 “abc”라는 값을 참조할 때, str1, str2는 동일한 메모리 주소를 참조함
    • 전자의 경우를 “값 형식”, 후자의 경우를 “참조 형식”으로 구분하고, 두 형식을 취급하는 방식이 엄밀히 구분됨 (Stack & Heap)
  • JavaScript

    • 문서는 타입 유형을 Immutable(=Primitive) vs Mutable(=Object) 객체로만 구분함
    • 가변적(Mutable): 어떤 변수가 참조중인 메모리 공간에 올라가 있는 ‘값’ 자체를 바꿀 수 있음
      • 대표적으로 배열의 특정 요소의 값을 변경. 참조중인 메모리 주소는 동일하지만, 내부 값은 변경 가능
    • 불변(Immutable): 어떤 변수가 참조중인 메모리 공간에 올라가 있는 ‘값’ 자체를 바꾸지 않음
      • 쉽게말해, 타입시스템의 String과 유사한 방식을 취함
      • 정수 a, b가 같은 5라는 값을 가지고 있으면, a, b는 동일한 메모리 주소를 가리킴
      • 반대로 a, b가 서로 다른 값을 가지고 있으면, a, b는 서로 다른 메모리 주소를 가리킴
let a = 5;  // 1) 메모리의 어느 영역에 5가 할당, a는 5를 가리킴
let b = a;  // 2) a와 b 모두 동일한 영역을 가리킴

a = 7;      // 3) 메모리의 어느 영역에 7이 할당, a는 7을 가리킴.
console.log(b);  // 4) b의 참조는 바뀌지 않으므로 출력: 5
  • 원시값(Primitive): Undefined, Null, Boolean, String, Symbol, Number, BigInt — 7개.
    • 불변(Immutable), 원자적(Atomic, 정수는 정수값 하나만 가짐), 내부 메서드/프로퍼티를 들고있지 않음
  • 객체(Object): 그 외 나머지들
    • 가변(Mutable), 내부 메서드와 프로퍼티(arr.length가 대표적인 예시) 포함

이로부터 유추할 수 있는 내용은 아래와 같다:

  • JS의 Primitive에서는 Reference / Shallow / Deep Copy 모두 일어나지 않는다.

Reference Copy의 정의가 ‘두 변수 a, b가 공유하는 대상을, a가 바꿨을 때, b가 영향을 받는것’ 인데,
a의 값이 재할당될 때 공유중인 값을 바꾸는 것이 아닌, a가 새로운 공간을 참조하게 되므로 위 상황이 발생하지 않는다.
또한 원시값은 정말 값 그 자체이므로, 당연히 Shallow/Deep copy의 원흉이 되는 참조와 관련이 없다.

  • 이러면 useState의 동작 원리를 생각해볼 필요가 있다.

React는 내부적으로 State의 변경 여부를 판단할 때, Object.is를 이용하여 검사한다.
여기서 Object.is는 참조 비교로, ‘두 매개변수가 같은 주소값을 가리키는가’를 검사한다.

let a = "abc";
let b = "abc";
let c = "xyz";

Object.is(a, b)   // true  = 값이 같음 = 동일한 주소를 가리킴 = rerender 없음
Object.is(a, c)   // false = 값이 다름 = 다른 주소를 가리킴 = rerender

Java의 경우 new를 통해 여러 개의 “abc”를 만들어낼 수 있으므로,
만약 String a = new String("abc"); String b = new String("abc);와 같이 선언될 경우 a ≠ b일 것이다.

그러나 JS에서는 모든 “abc”가 반드시 같은 메모리 주소를 가리키게 되므로, 사실상 ‘값으로 비교’하는 것과 동일한 효과이다.
그리고 이것이 바로 JS에서 ‘가변성’을 중심으로 타입을 구분한 이유가 아닐까?


결론

어쩌다보니 toReverse를 살펴보다가 React의 Rerender 트리거 방식, 그리고 그 뒤에 있던 본질적인 타입 처리 방식까지 확장되었다.
정말 예상치 못한 곳에서 시간을 많이 쓴 기분이지만, 어찌되었든 하나 얻어가는게 있으니 좋은거 아닐까?

링크가 복사되었습니다