📋 얕은 복사 vs 깊은 복사
📖 정의
**얕은 복사(Shallow Copy)**는 객체의 최상위 레벨만 복사하고 중첩된 객체는 참조를 공유합니다. **깊은 복사(Deep Copy)**는 중첩된 모든 객체를 재귀적으로 복사하여 완전히 독립적인 복사본을 만듭니다.
🎯 비유로 이해하기
아파트 비유
얕은 복사와 깊은 복사를 아파트에 비유하면:
원본 아파트
├─ 거실 (1층 데이터)
│ ├─ TV
│ └─ 소파
└─ 방 (중첩 객체)
├─ 침대
└─ 책상
얕은 복사 (Shallow Copy)
├─ 새 아파트 열쇠 만들기
├─ 거실은 새로 복사 (TV, 소파 복사)
└─ 방은 원본과 같은 방 사용 (참조 공유)
결과:
원본.방.침대 = "더블침대"
→ 복사본.방.침대도 "더블침대" (같은 방!)
깊은 복사 (Deep Copy)
├─ 완전히 새 아파트 짓기
├─ 거실도 새로 만들기
└─ 방도 새로 만들기 (가구도 모두 복제)
결과:
원본.방.침대 = "더블침대"
→ 복사본.방.침대는 변경 없음 (다른 방!)
문서 복사 비유
원본 문서 (중첩 구조)
├─ 1장: 제목 (원시값)
└─ 2장: 참고자료 (객체)
├─ 링크 목록
└─ 이미지
얕은 복사
├─ 새 문서 만들기
├─ 1장 복사 (텍스트 그대로 복사)
└─ 2장은 원본과 같은 폴더 참조
원본 참고자료 수정 시:
→ 복사본도 같은 변경 보임!
깊은 복사
├─ 새 문서 만들기
├─ 1장 복사
└─ 2장도 새 폴더에 모든 파일 복사
원본 참고자료 수정 시:
→ 복사본은 영향 없음!
연필 상자 비유
원본 상자
├─ 연필 3자루 (원시값)
└─ 작은 상자 (중첩 객체)
└─ 지우개 2개
얕은 복사
├─ 큰 상자 새로 만들기
├─ 연필 3자루 복사
└─ 작은 상자는 원본과 같은 상자
작은 상자에서 지우개 1개 꺼내면:
원본 작은 상자: 지우개 1개
복사본 작은 상자: 지우개 1개 (같은 상자!)
깊은 복사
├─ 큰 상자 새로 만들기
├─ 연필 3자루 복사
└─ 작은 상자도 새로 만들어서 지우개 복사
작은 상자에서 지우개 1개 꺼내면:
원본 작은 상자: 지우개 1개
복사본 작은 상자: 지우개 2개 (다른 상자!)
⚙️ 작동 원리
1. 참조 vs 값
// 원시 타입: 값 복사
let a = 10;
let b = a; // 값 복사
b = 20;
console.log(a); // 10 (변경 없음)
console.log(b); // 20
// 객체 타입: 참조 복사
let obj1 = { name: '철수' };
let obj2 = obj1; // 참조 복사 (같은 객체)
obj2.name = '영희';
console.log(obj1.name); // 영희 (변경됨!)
console.log(obj2.name); // 영희
// 메모리 구조
/*
원시값:
a: [10]
b: [20] ← 독립적인 메모리
객체:
obj1: [메모리 주소 0x001] ─┐
├─> { name: '영희' }
obj2: [메모리 주소 0x001] ─┘
└─ 같은 주소!
*/
// 참조 비교
console.log(obj1 === obj2); // true (같은 참조)
const obj3 = { name: '영희' };
console.log(obj1 === obj3); // false (다른 참조, 내용은 같아도)
2. 얕은 복사
// 1. Spread 연산자 (...)
const original = {
name: '철수',
age: 25,
address: {
city: '서울',
district: '강남'
}
};
const shallow1 = { ...original };
// 1단계는 복사됨
shallow1.name = '영희';
console.log(original.name); // 철수 (변경 없음)
// 2단계는 참조 공유
shallow1.address.city = '부산';
console.log(original.address.city); // 부산 (변경됨!)
// 2. Object.assign()
const shallow2 = Object.assign({}, original);
shallow2.age = 30;
console.log(original.age); // 25 (변경 없음)
shallow2.address.district = '서초';
console.log(original.address.district); // 서초 (변경됨!)
// 3. Array.slice()
const arr1 = [1, 2, [3, 4]];
const arr2 = arr1.slice();
arr2[0] = 10;
console.log(arr1[0]); // 1 (변경 없음)
arr2[2][0] = 30;
console.log(arr1[2][0]); // 30 (변경됨!)
// 4. Array.concat()
const arr3 = [1, 2, [3, 4]];
const arr4 = [].concat(arr3);
arr4[2][1] = 40;
console.log(arr3[2][1]); // 40 (변경됨!)
// 얕은 복사의 문제
const user = {
name: '철수',
hobbies: ['축구', '농구']
};
const copiedUser = { ...user };
copiedUser.hobbies.push('야구');
console.log(user.hobbies); // ['축구', '농구', '야구']
// 원본도 변경됨!
3. 깊은 복사
// 1. JSON 방법 (간단하지만 제한적)
const original = {
name: '철수',
age: 25,
address: {
city: '서울',
district: '강남'
},
hobbies: ['축구', ['농구', '야구']]
};
const deep1 = JSON.parse(JSON.stringify(original));
deep1.address.city = '부산';
deep1.hobbies[1][0] = '배구';
console.log(original.address.city); // 서울 (변경 없음!)
console.log(original.hobbies[1][0]); // 농구 (변경 없음!)
// ❌ JSON 방법의 한계
const problematic = {
date: new Date(),
regex: /test/g,
func: () => console.log('hi'),
undefined: undefined,
symbol: Symbol('id'),
circular: null
};
problematic.circular = problematic; // 순환 참조
// const copied = JSON.parse(JSON.stringify(problematic));
// - date: 문자열로 변환됨
// - regex: 빈 객체 {}
// - func: 사라짐
// - undefined: 사라짐
// - symbol: 사라짐
// - circular: 에러 발생
// 2. structuredClone() (최신, 권장!)
const deep2 = structuredClone(original);
deep2.address.city = '대구';
console.log(original.address.city); // 서울 (변경 없음!)
// structuredClone 장점:
// - Date, RegExp, Map, Set 지원
// - 순환 참조 지원
// - ArrayBuffer 등 복잡한 타입 지원
const complex = {
date: new Date(),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
buffer: new ArrayBuffer(8)
};
const cloned = structuredClone(complex);
// 모두 제대로 복사됨!
// ❌ structuredClone 한계
const withFunction = {
func: () => console.log('hi')
};
// const copied = structuredClone(withFunction); // 에러!
// 함수는 복사 불가
// 3. 재귀 함수 (완전 제어)
function deepCopy(obj, map = new WeakMap()) {
// null, undefined, 원시값
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 순환 참조 처리
if (map.has(obj)) {
return map.get(obj);
}
// Date
if (obj instanceof Date) {
return new Date(obj);
}
// RegExp
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// Map
if (obj instanceof Map) {
const mapCopy = new Map();
map.set(obj, mapCopy);
obj.forEach((value, key) => {
mapCopy.set(key, deepCopy(value, map));
});
return mapCopy;
}
// Set
if (obj instanceof Set) {
const setCopy = new Set();
map.set(obj, setCopy);
obj.forEach(value => {
setCopy.add(deepCopy(value, map));
});
return setCopy;
}
// 배열
if (Array.isArray(obj)) {
const arrCopy = [];
map.set(obj, arrCopy);
obj.forEach((item, index) => {
arrCopy[index] = deepCopy(item, map);
});
return arrCopy;
}
// 객체
const objCopy = {};
map.set(obj, objCopy);
Object.keys(obj).forEach(key => {
objCopy[key] = deepCopy(obj[key], map);
});
return objCopy;
}
// 사용
const test = {
a: 1,
b: { c: 2 },
d: [3, { e: 4 }]
};
const testCopy = deepCopy(test);
testCopy.b.c = 20;
testCopy.d[1].e = 40;
console.log(test.b.c); // 2 (변경 없음!)
console.log(test.d[1].e); // 4 (변경 없음!)
// 4. Lodash 라이브러리
// import _ from 'lodash';
// const deep3 = _.cloneDeep(original);
// 모든 경우 잘 처리됨
4. 중첩 레벨별 복사
// 1단계 중첩
const level1 = {
name: '철수',
age: 25
};
// 얕은 복사로 충분
const copy1 = { ...level1 };
copy1.name = '영희';
console.log(level1.name); // 철수 (OK!)
// 2단계 중첩
const level2 = {
name: '철수',
address: {
city: '서울'
}
};
// ❌ 얕은 복사
const copy2Shallow = { ...level2 };
copy2Shallow.address.city = '부산';
console.log(level2.address.city); // 부산 (문제!)
// ✅ 수동 2단계 복사
const copy2Deep = {
...level2,
address: { ...level2.address }
};
copy2Deep.address.city = '대구';
console.log(level2.address.city); // 부산 (OK!)
// 3단계 중첩
const level3 = {
name: '철수',
address: {
city: '서울',
detail: {
street: '강남대로'
}
}
};
// ✅ 수동 3단계 복사
const copy3 = {
...level3,
address: {
...level3.address,
detail: {
...level3.address.detail
}
}
};
// 복잡해짐! → 깊은 복사 필요
// 배열 중첩
const nestedArray = [
[1, 2],
[3, [4, 5]],
[6, [7, [8, 9]]]
];
// ❌ 얕은 복사
const shallowArr = [...nestedArray];
shallowArr[0][0] = 10;
console.log(nestedArray[0][0]); // 10 (문제!)
// ✅ 깊은 복사
const deepArr = structuredClone(nestedArray);
deepArr[2][1][1][0] = 80;
console.log(nestedArray[2][1][1][0]); // 8 (OK!)
5. 성능 비교
// 성능 테스트
const largeObject = {
a: Array(1000).fill({ b: { c: 1 } })
};
// 1. Spread (빠름, 얕은 복사)
console.time('Spread');
const spread = { ...largeObject };
console.timeEnd('Spread'); // ~0.1ms
// 2. Object.assign (빠름, 얕은 복사)
console.time('Object.assign');
const assigned = Object.assign({}, largeObject);
console.timeEnd('Object.assign'); // ~0.1ms
// 3. JSON (중간, 깊은 복사)
console.time('JSON');
const jsonCopy = JSON.parse(JSON.stringify(largeObject));
console.timeEnd('JSON'); // ~5ms
// 4. structuredClone (중간, 깊은 복사)
console.time('structuredClone');
const structured = structuredClone(largeObject);
console.timeEnd('structuredClone'); // ~3ms
// 5. 재귀 함수 (느림, 깊은 복사)
console.time('Recursive');
const recursive = deepCopy(largeObject);
console.timeEnd('Recursive'); // ~10ms
// 6. Lodash (중간, 깊은 복사)
// console.time('Lodash');
// const lodash = _.cloneDeep(largeObject);
// console.timeEnd('Lodash'); // ~7ms
/*
성능 순위:
1. Spread / Object.assign (가장 빠름)
2. structuredClone (빠름)
3. JSON (보통)
4. Lodash (보통)
5. 재귀 함수 (느림)
선택 기준:
- 1단계만: Spread
- 깊은 복사 필요 + 최신 브라우저: structuredClone
- 깊은 복사 필요 + 구형 브라우저: JSON or Lodash
- 특수 타입 포함: 재귀 함수 or Lodash
*/
💡 실제 예시
React State 불변성
import React, { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '공부하기', completed: false },
{ id: 2, text: '운동하기', completed: false }
]);
// ❌ 잘못된 방법: 직접 수정
const toggleWrong = (id) => {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
setTodos(todos); // React가 변화 감지 못함!
};
// ✅ 올바른 방법: 얕은 복사
const toggleCorrect = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // 새 객체
: todo
));
};
// 중첩 객체
const [user, setUser] = useState({
name: '철수',
profile: {
age: 25,
address: {
city: '서울'
}
}
});
// ❌ 잘못된 방법
const updateCityWrong = (city) => {
user.profile.address.city = city;
setUser(user); // 변화 감지 못함!
};
// ✅ 올바른 방법: 깊은 복사
const updateCityCorrect = (city) => {
setUser({
...user,
profile: {
...user.profile,
address: {
...user.profile.address,
city: city
}
}
});
};
// 또는 Immer 라이브러리
// import { produce } from 'immer';
//
// const updateCityImmer = (city) => {
// setUser(produce(user, draft => {
// draft.profile.address.city = city;
// }));
// };
return (
<div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleCorrect(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
<div>
<p>{user.name}</p>
<p>{user.profile.address.city}</p>
<button onClick={() => updateCityCorrect('부산')}>
부산으로 이사
</button>
</div>
</div>
);
}
Redux Reducer
// Redux: 항상 새 객체 반환
// ❌ 잘못된 Reducer
function badReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
state.todos.push(action.payload); // 직접 수정!
return state; // 같은 참조
case 'UPDATE_USER':
state.user.name = action.payload;
return state;
default:
return state;
}
}
// ✅ 올바른 Reducer
function goodReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, action.payload] // 새 배열
};
case 'UPDATE_USER':
return {
...state,
user: {
...state.user,
name: action.payload // 새 객체
}
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
default:
return state;
}
}
// Redux Toolkit (간편)
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: {
todos: [],
user: { name: '' }
},
reducers: {
addTodo: (state, action) => {
// Immer 내장: 직접 수정처럼 보이지만 불변성 유지
state.todos.push(action.payload);
},
updateUser: (state, action) => {
state.user.name = action.payload;
},
toggleTodo: (state, action) => {
const todo = state.todos.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});
배열 복사 패턴
// 배열 복사의 다양한 패턴
const numbers = [1, 2, 3, 4, 5];
// 1. 전체 복사
const copy1 = [...numbers];
const copy2 = numbers.slice();
const copy3 = Array.from(numbers);
const copy4 = numbers.map(n => n);
// 2. 요소 추가
const addStart = [0, ...numbers]; // [0,1,2,3,4,5]
const addEnd = [...numbers, 6]; // [1,2,3,4,5,6]
const addMiddle = [...numbers.slice(0, 2), 2.5, ...numbers.slice(2)];
// [1,2,2.5,3,4,5]
// 3. 요소 제거
const removeFirst = numbers.slice(1); // [2,3,4,5]
const removeLast = numbers.slice(0, -1); // [1,2,3,4]
const removeIndex = [
...numbers.slice(0, 2),
...numbers.slice(3)
]; // [1,2,4,5] (인덱스 2 제거)
// 4. 요소 수정
const updateIndex = numbers.map((n, i) =>
i === 2 ? 30 : n
); // [1,2,30,4,5]
// 5. 필터링
const evens = numbers.filter(n => n % 2 === 0); // [2,4]
// 중첩 배열
const nested = [[1, 2], [3, 4], [5, 6]];
// ❌ 얕은 복사
const shallowCopy = [...nested];
shallowCopy[0][0] = 10;
console.log(nested[0][0]); // 10 (변경됨!)
// ✅ 깊은 복사
const deepCopy = nested.map(arr => [...arr]);
deepCopy[0][0] = 100;
console.log(nested[0][0]); // 10 (변경 없음!)
// 객체 배열
const users = [
{ id: 1, name: '철수', age: 25 },
{ id: 2, name: '영희', age: 23 }
];
// ❌ 얕은 복사
const usersCopy = [...users];
usersCopy[0].age = 30;
console.log(users[0].age); // 30 (변경됨!)
// ✅ 깊은 복사
const usersDeepCopy = users.map(user => ({ ...user }));
usersDeepCopy[0].age = 35;
console.log(users[0].age); // 30 (변경 없음!)
// 객체 배열 수정
const updateUser = (users, id, updates) => {
return users.map(user =>
user.id === id
? { ...user, ...updates }
: user
);
};
const updated = updateUser(users, 1, { age: 26 });
console.log(users[0].age); // 25 (원본 유지)
console.log(updated[0].age); // 26 (복사본 수정)