
const info = {
name: 'jinju',
role: 'publisher'
};
const newInfo = info;
newInfo.role = 'frontend';
console.log(newInfo); // { name: 'jinju', role: 'frontend' }
console.log(info); // { name: 'jinju', role: 'frontend' }
처음 자바스크립트를 공부할 때 한 번쯤 마주치게 되는 당황스러운 순간이다.
분명 newInfo라는 새 변수를 만들었고, 복사해서 수정했다고 생각했는데, 복사본을 수정하니 원본까지 같이 바뀌어버렸다.

현실에서의 복사는 명확하다. 문서를 복사하면, 독립된 새 문서가 생긴다. 그런데 자바스크립트에서는 왜 이런 일이 벌어지는 걸까?
아마 자바스크립트를 처음 접하는 삐약 개발자들이 많이 당황하는 부분일 것이라고 생각한다. 이번 글에서는 자바스크립트에서 왜 복사가 오해를 부르는지, 그리고 얕은 복사/깊은 복사가 정확히 어떤 의미인지 파헤쳐보도록 하겠다.
우선 자바스크립트에서 데이터가 저장되는 원리를 이해해야 복사에 대해서도 연관지어 이해할 수 있을 것이다. 여기서 설명하고자 하는 모델은 자바스크립트 엔진의 구현을 그대로 복제한 것이 아닌, 참조와 복사의 개념을 이해하기 위한 설명용 단순화 모델임을 먼저 밝힌다.
let a = "abc";
let b = a;
b = 123;
console.log(a); // "abc"
console.log(b); // 123

원시값(숫자, 문자열, 불린 등)은 대입했을 때, 복사본을 바꿔도 원본에 영향이 없는 형태로 동작한다.
let obj1 = { count: 1, name: "jinju" };
let obj2 = obj1; // 같은 객체를 가리키는 참조가 복사됨
obj2.count = 2;
console.log(obj2.count); // 2
console.log(obj1.count); // 2 (같이 변경됨)

객체에서는 내용이 아닌 같은 대상을 가리키는 참조가 복사된다. obj2 = obj1은 객체의 내용이 복사되는 게 아니라, 같은 객체를 가리키는 연결이 복사되는 것에 더 가깝다. 그래서 한쪽을 수정하면 다른 쪽에서도 같은 결과를 보게 된다. 원본과 복사본이 같은 대상을 보고 있기 때문이다.
두 객체를 동등 연산자와 일치 연산자로 비교해보면 결과는 모두 true라는 걸 확인할 수 있다.
이제 본격적으로 복사에 대한 이야기를 해보도록 하겠다. 자바스크립트에서 복사는 무엇을 어떻게 복사하느냐에 따라 의미가 완전히 달라진다.
원시값에서 흔히 체감되는 복사 방식이다. 복사본을 바꿔도 원본에 영향을 주지 않는다.
객체에서 같은 대상을 가리키는 참조가 복사되는 경우이다. 복사본과 원본이 같은 참조를 공유하기 때문에 값이 함께 변경된다.
객체의 틀을 새로 만들고, 내부는 목적에 따라 공유하거나 더 깊게 복사하는 방식이다. 얕은 복사와 깊은 복사가 구조 복사의 하위 개념이다.
얕은 복사는 객체의 껍데기만 새로 만들고, 중첩된 내부 값은 참조를 공유하는 복사 방식이다.
대표적인 방법은 다음 두 가지이다.
const a = {
name: "jinju",
nested: { level: 0 },
};
const b = { ...a };
console.log(a === b); // false (겉 객체는 새로 생성)
console.log(a.nested === b.nested); // true (중첩은 같은 참조!)
b.nested.level = 1;
console.log(b.nested.level); // 1 (변경됨)
console.log(a.nested.level); // 1 (원본도 같이 변경됨)
일치 연산자로 a와 b를 비교해보았을 때, 위 코드에서 확인할 수 있는 포인트는 아래와 같다.
겉보기엔 독립된 객체처럼 보이지만, 중첩된 내부는 여전히 연결되어 있다.
얕은 복사는 편리하지만, 중첩을 수정하는 순간 원본 오염이 생길 수 있으니 주의해야 한다.
깊은 복사는 중첩된 구조까지 포함해 참조 공유를 끊고, 완전히 독립된 복사본을 만드는 것을 목표로 한다. 복사본과 원본이 독립된 객체로 존재하기 때문에, 그 값을 변경해도 서로 영향을 주지 않는다.
여기서 중요한 사실은, 깊은 복사가 무조건 좋은 정답이 아니라는 것이다.
깊은 복사는 얕은 복사보다 비용이 더 들고, 상황에 따라 어디까지 복사할지 요구사항에 맞게 잘 설계해야 한다.
깊은 복사의 대표적인 방법은 아래 네 가지가 있다.
const copy1 = JSON.parse(JSON.stringify(original));
const copy2 = structuredClone(original);
function deepCopy(obj) {
if (obj === null || typeof obj !== 'object') return obj;
const copy = Array.isArray(obj) ? [] : {};
for (let key in obj) {
copy[key] = deepCopy(obj[key]);
}
return copy;
}
const copy3 = deepCopy(original);
const copy4 = _.cloneDeep(original);
아래는 이중 2번째 예시인 structuredClone()을 통해 구현한 깊은 복사의 예시 코드이다.
const a = {
user: {
name: "원본",
hobbies: ["풋살", "수영"],
},
};
const b = structuredClone(a);
console.log(a === b); // false
console.log(a.user === b.user); // false
console.log(a.user.hobbies === b.user.hobbies); // false
b.user.name = "복사본";
b.user.hobbies.push("코딩");
console.log(b.user.name); // "복사본"
console.log(b.user.hobbies); // ["풋살", "수영", "코딩"]
console.log(a.user.name); // "원본"
console.log(a.user.hobbies); // ["풋살", "수영"]
복사한 데이터를 변경해도, 원본에는 전혀 영향을 주지 않는 걸 확인할 수 있다.
두 객체를 동등 연산자와 일치 연산자로 비교해봐도 결과는 모두 false가 나온다.
이렇게 보면 깊은 복사가 항상 안전해보이지만, 실무에서는 얕은 복사가 현실적인 선택이 되는 경우가 많다. 이유는 단순하다. 비용 때문이다.
실제 서비스에서 다루는 객체는 크고 복잡하다. 사용자 정보, 상품 목록, 댓글 트리처럼 중첩이 깊고 데이터가 많다.
그래서 실무에서는 보통 이렇게 생각한다.
이 전략을 흔히 구조 공유(Structural Sharing)라고 부른다.
상태 관리에서 중요한 전제 중 하나는 이전 상태를 오염시키지 않는 불변성이다.
얕게 복사한 뒤 중첩을 직접 수정하면, 복사본 뿐만 아니라 이전 상태까지 같이 망가질 수 있다. 이런 경우에는 상태 비교를 전제로 하는 최적화나 디버깅이 어려워진다.
그래서 중첩을 수정해야 한다면, 보통 다음 원칙을 따른다.
// 불변성이 깨지는 잘못된 예시
const [user, setUser] = useState({
profile: { name: "jinju", age: 25 }
});
const updateAge = () => {
const newUser = { ...user }; // 얕은 복사
newUser.profile.age = 26; // 중첩 객체 직접 수정 → 원본 오염!
setUser(newUser); // 상태 비교를 전제로 한 최적화/디버깅이 어려워짐
};
// 중첩도 새로 만드는 올바른 예시
const updateAgeCorrectly = () => {
setUser({
...user,
profile: { ...user.profile, age: 26 }
});
};
자바스크립트에서 복사는 생각보다 단순하지 않다.
복사는 단순해 보이지만, 자바스크립트의 참조 시스템을 이해하지 못하면 예상치 못한 버그의 원인이 될 수 있다. 상황에 맞는 복사 방법을 선택하는 것이 안정적인 코드를 작성하는 첫 걸음이 될 것이다.