얕은 복사와 깊은 복사를 구분해야 하는 이유
얕은 복사와 깊은 복사를 구분해야 하는 이유
스프레드 문법으로 객체를 복사했는데 원본까지 바뀌는 문제는 복사가 실패해서가 아니다. 최상위 객체만 새로 만들고 내부 객체의 참조는 공유하는 얕은 복사의 특성 때문이다.
- 얕은 복사는 한 단계의 프로퍼티만 새 컨테이너에 담는다.
- 중첩 객체와 배열은 원본과 같은 참조를 가질 수 있다.
- 깊은 복사보다 필요한 경로만 복사하는 불변 업데이트가 더 적합한 경우가 많다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #얕은 복사의 참조 공유
- #깊은 복사의 선택지
- #필요한 경로만 복사하기
- #배열을 복사해도 배열 안의 객체는 공유한다
- #JSON을 이용한 깊은 복사가 위험한 이유
- #상태 변경에서는 필요한 경로만 새로 만든다
- #복사보다 도메인 생성 함수가 나은 경우
- #결론
- #관련 노트
얕은 복사의 참조 공유
const original = {
name: "Chanho",
settings: { theme: "dark" },
};
const copied = { ...original };
copied.settings.theme = "light";
console.log(original.settings.theme); // light
copied 자체는 새 객체지만 settings는 같은 객체를 가리킨다.
깊은 복사의 선택지
JSON stringify와 parse는 Date, Map, undefined, 순환 참조를 제대로 보존하지 못한다. 지원되는 환경에서는 structuredClone이 더 정확하지만 함수나 일부 플랫폼 객체는 복사할 수 없다.
const copied = structuredClone(original);
copied.settings.theme = "light";
큰 상태 트리를 매번 통째로 복사하면 비용이 커지고 어떤 부분이 변경되었는지도 드러나지 않는다.
필요한 경로만 복사하기
const next = {
...original,
settings: {
...original.settings,
theme: "light",
},
};
변경 경로의 객체만 새로 만들면 나머지 참조는 안전하게 재사용할 수 있다. React 상태나 reducer에서 자주 쓰는 방식이다.
배열을 복사해도 배열 안의 객체는 공유한다
객체뿐 아니라 배열 스프레드와 slice()도 얕은 복사다.
const original = [
{ id: 1, tags: ["javascript"] },
{ id: 2, tags: ["database"] },
];
const copied = [...original];
copied[0].tags.push("copy");
console.log(original[0].tags);
// ["javascript", "copy"]
새로 만들어진 것은 배열 컨테이너 하나다. 첫 번째 원소가 가리키는 객체와 그 안의 tags 배열은 원본과 같다.
original ──> Array A ──┬──> Object 1 ──> Tags Array
└──> Object 2
copied ──> Array B ──┬──> Object 1 ──> Tags Array
└──> Object 2
“복사했다”라는 말만으로는 충분하지 않다. 어느 레벨의 컨테이너가 새로 만들어졌고 어느 참조가 공유되는지 표시해야 한다.
JSON을 이용한 깊은 복사가 위험한 이유
JSON.parse(JSON.stringify(value))는 간단해 보이지만 JSON이 표현할 수 없는 값의 의미를 잃는다.
const original = {
createdAt: new Date("2026-01-01T00:00:00Z"),
missing: undefined,
pattern: /record-/i,
ids: new Set([1, 2, 3]),
};
const copied = JSON.parse(JSON.stringify(original));
console.log(copied);
결과에서는 createdAt이 문자열이 되고 missing은 사라진다. 정규표현식과 Set도 원래 의미를 보존하지 못한다. BigInt와 순환 참조는 직렬화 단계에서 오류가 난다.
| 값 | JSON 왕복 결과 | structuredClone |
|---|---|---|
Date |
문자열 | 새 Date 객체 |
Map, Set |
일반적으로 정보 손실 | 복제 지원 |
undefined 프로퍼티 |
제거 | 보존 |
| 함수 | 제거 또는 변환 불가 | 복제 불가 |
| 순환 참조 | 오류 | 복제 지원 |
| 클래스 인스턴스 | prototype 손실 | 일반 객체처럼 의미가 달라질 수 있음 |
structuredClone도 모든 값을 그대로 복제하는 만능 함수는 아니다. 함수와 일부 플랫폼 객체를 복제할 수 없고, 도메인 클래스의 메서드와 불변 조건까지 대신 복원해 주지는 않는다.
상태 변경에서는 필요한 경로만 새로 만든다
중첩 상태에서 하나의 필드만 바꿀 때 전체 깊은 복사는 과하다. 변경된 경로의 객체만 새로 만들면 나머지 참조를 재사용하면서 변경 지점을 표현할 수 있다.
const state = {
profile: {
name: "Mina",
preferences: {
theme: "dark",
notifications: true,
},
},
cachedPosts: largePostList,
};
const nextState = {
...state,
profile: {
...state.profile,
preferences: {
...state.profile.preferences,
theme: "light",
},
},
};
참조를 비교하면 어떤 부분이 바뀌었는지 확인할 수 있다.
console.log(state === nextState); // false
console.log(state.profile === nextState.profile); // false
console.log(state.cachedPosts === nextState.cachedPosts); // true
React와 Redux 같은 도구는 이 참조 변화로 업데이트 여부를 판단할 수 있다.
복사보다 도메인 생성 함수가 나은 경우
금액, 기간, 권한처럼 불변 조건을 가진 객체는 범용 깊은 복사보다 생성 함수로 다시 만드는 편이 안전하다.
function createReservation({ startAt, endAt, attendeeIds }) {
if (endAt <= startAt) {
throw new Error("endAt must be after startAt");
}
return Object.freeze({
startAt: new Date(startAt),
endAt: new Date(endAt),
attendeeIds: Object.freeze([...attendeeIds]),
});
}
const copied = createReservation(existingReservation);
이 방식은 복사 과정에서도 데이터 규칙을 다시 확인한다. 클래스 인스턴스, DB entity, 네트워크 응답을 무조건 clone하는 것보다 의미가 분명하다.
독립적인 스냅샷이 필요한가, 일부 경로만 변경할 것인가, 직렬화 가능한 데이터인가, 도메인 규칙을 다시 적용해야 하는가? 답에 따라 복사 방법이 달라진다.
결론
복사의 깊이는 문법이 아니라 데이터 구조와 변경하려는 경로로 결정한다. 참조 공유가 의도인지 버그인지 명확히 해야 한다.