JavaScript에서 객체나 배열을 복사할 때 스프레드 문법을 자주 사용한다.
const copy = { ...original }
코드가 간단하고 대부분의 상황에서 정상적으로 동작하는 것처럼 보인다.
하지만 스프레드 문법은 객체 내부까지 모두 새롭게 복사하는 깊은 복사(Deep Copy)가 아니다. 최상위 값만 복사하는 얕은 복사(Shallow Copy)이다.
이 차이를 모르고 객체를 수정하면 원본 데이터까지 예상하지 못하게 변경되는 문제가 발생할 수 있다.
이번 글에서는 JavaScript의 얕은 복사와 깊은 복사의 차이와 실무에서 어떤 방법을 사용하면 되는지 정리한다.

객체를 대입하면 복사가 아니다
다음 코드를 살펴본다.
const user = {
name: 'Kim',
age: 30
}
const copiedUser = user
copiedUser.age = 31
console.log(user.age)
결과는 31이다.
31
copiedUser에 user를 대입했지만 새로운 객체가 생성된 것이 아니기 때문이다.
두 변수는 동일한 객체를 참조한다.
개념적으로 다음과 같은 상태이다.
user ──────────┐
├──> { name: 'Kim', age: 30 }
copiedUser ────┘
따라서 copiedUser를 통해 값을 변경하면 user에서도 변경된 값이 확인된다.
스프레드 문법{ ...obj }을 사용하면 새로운 객체가 생성된다
객체를 복사할 때 다음과 같이 스프레드 문법을 사용할 수 있다.
const user = {
name: 'Kim',
age: 30
}
const copiedUser = {
...user
}
copiedUser.age = 31
console.log(user.age)
console.log(copiedUser.age)
결과는 다음과 같다.
30
31
이번에는 user와 copiedUser가 서로 다른 객체이다.
따라서 copiedUser.age를 변경해도 원본 객체에는 영향을 주지 않는다.
문제는 객체 내부에 또 다른 객체가 존재할 때 발생한다.
스프레드 문법은 얕은 복사이다
다음과 같이 중첩된 객체가 있다고 가정한다.
const user = {
name: 'Kim',
address: {
city: 'Seoul',
district: 'Yongsan'
}
}
const copiedUser = {
...user
}
이 상태에서 복사한 객체의 address를 수정한다.
copiedUser.address.city = 'Busan'
console.log(user.address.city)
예상과 다르게 결과는 Busan이다.
Busan
최상위 user 객체 자체는 새롭게 생성됐지만 내부의 address 객체는 동일한 객체를 참조하고 있기 때문이다.
개념적으로 다음과 같다.
user ---------> 객체 A
│
└── address ──┐
│
copiedUser ---> 객체 B │
│ │
└── address ──┘
이러한 복사를 얕은 복사라고 한다.
배열에서도 동일한 문제가 발생한다
배열 역시 스프레드 문법으로 복사할 수 있다.
const numbers = [1, 2, 3]
const copiedNumbers = [...numbers]
copiedNumbers.push(4)
console.log(numbers)
console.log(copiedNumbers)
결과는 다음과 같다.
[1, 2, 3]
[1, 2, 3, 4]
숫자처럼 원시값(Primitive Value)으로 구성된 배열에서는 별다른 문제가 없다.
하지만 객체 배열에서는 상황이 달라진다.
const users = [
{
id: 1,
name: 'Kim'
},
{
id: 2,
name: 'Lee'
}
]
const copiedUsers = [...users]
copiedUsers[0].name = 'Park'
console.log(users[0].name)
결과는 Park이다.
배열 자체는 새롭게 생성됐지만 배열 안에 들어 있는 객체는 그대로 공유하기 때문이다.
React에서는 특히 주의해야 한다
React에서 상태를 변경할 때도 이러한 차이가 중요하다.
다음과 같은 상태가 있다고 가정한다.
const [user, setUser] = useState({
name: 'Kim',
address: {
city: 'Seoul'
}
})
다음과 같이 작성하면 문제가 발생할 수 있다.
const changeCity = () => {
const newUser = { ...user }
newUser.address.city = 'Busan'
setUser(newUser)
}
newUser 자체는 새로운 객체이지만 newUser.address와 user.address는 같은 객체이다.
기존 상태를 직접 변경하는 결과가 발생한다.
중첩된 값을 변경해야 한다면 변경되는 단계도 새롭게 만들어야 한다.
const changeCity = () => {
setUser(prev => ({
...prev,
address: {
...prev.address,
city: 'Busan'
}
}))
}
이 방식에서는 user와 address 모두 새로운 객체가 생성된다.
React 상태를 다룰 때 모든 데이터를 무조건 깊은 복사해야 하는 것은 아니다.
변경되는 경로에 있는 객체만 새롭게 만드는 것이 일반적인 방법이다.
JSON.stringify를 이용한 깊은 복사는 권장하기 어렵다
과거에는 다음과 같은 방법으로 깊은 복사를 구현하는 경우가 많았다.
const copied = JSON.parse(
JSON.stringify(original)
)
단순한 객체에서는 동작한다.
하지만 모든 JavaScript 값을 안전하게 복사할 수 있는 방법은 아니다.
예를 들어 undefined 값이 있는 객체를 확인한다.
const original = {
name: 'Kim',
value: undefined
}
const copied = JSON.parse(
JSON.stringify(original)
)
console.log(copied)
value가 결과에서 사라질 수 있다.
Date 역시 원래의 객체 형태가 유지되지 않는다.
const original = {
createdAt: new Date()
}
const copied = JSON.parse(
JSON.stringify(original)
)
console.log(typeof copied.createdAt)
Date 객체가 문자열로 변환된다.
이외에도 JavaScript 객체의 종류에 따라 원본 정보를 그대로 유지하지 못할 수 있다.
따라서 JSON.parse(JSON.stringify())를 범용적인 깊은 복사 방법으로 사용하는 것은 피하는 것이 좋다.
깊은 복사가 필요하다면 structuredClone을 사용할 수 있다
현대적인 JavaScript 환경에서는 structuredClone()을 사용할 수 있다.
const user = {
name: 'Kim',
address: {
city: 'Seoul'
}
}
const copiedUser = structuredClone(user)
copiedUser.address.city = 'Busan'
console.log(user.address.city)
console.log(copiedUser.address.city)
결과는 다음과 같다.
Seoul
Busan
내부 객체까지 별도로 복사됐기 때문이다.
Date, Map, Set 등의 데이터도 복사할 수 있다는 장점이 있다.
const original = {
createdAt: new Date(),
options: new Map([
['pageSize', 20]
])
}
const copied = structuredClone(original)
다만 structuredClone()도 모든 JavaScript 값을 복사할 수 있는 것은 아니다.
대표적으로 함수는 복사할 수 없다.
const original = {
handler() {
console.log('click')
}
}
structuredClone(original)
이러한 데이터에서는 DataCloneError가 발생한다.
따라서 structuredClone() 역시 객체를 무조건 복사해주는 도구라고 생각해서는 안 된다.
깊은 복사를 무조건 사용하는 것도 좋지 않다
객체 복사 문제가 발생한다고 해서 모든 데이터를 structuredClone()으로 복사할 필요는 없다.
예를 들어 다음 객체에서 이름만 변경한다고 가정한다.
const user = {
name: 'Kim',
profile: {
age: 30,
city: 'Seoul'
}
}
다음 정도면 충분하다.
const newUser = {
...user,
name: 'Lee'
}
profile을 변경하지 않기 때문에 profile 객체를 새롭게 만들 이유가 없다.
특히 데이터가 크다면 전체 객체를 깊은 복사하는 작업 자체가 추가적인 비용이 된다.
React 상태에서도 마찬가지이다.
변경되는 데이터의 경로만 새롭게 생성하는 것이 일반적으로 적절하다.
setUser(prev => ({
...prev,
profile: {
...prev.profile,
city: 'Busan'
}
}))
객체의 크기가 매우 크거나 중첩 구조가 지나치게 복잡하다면 복사 방법보다 상태 구조 자체를 단순화할 수 있는지도 검토하는 것이 좋다.
얕은 복사와 깊은 복사의 사용 기준
실무에서는 다음 정도로 구분할 수 있다.
상황방법
| 최상위 속성만 변경 | 스프레드 문법 |
| 배열 자체만 변경 | 배열 스프레드 문법 |
| 중첩 객체 일부 변경 | 변경 경로를 단계별로 복사 |
| React 상태 변경 | 변경되는 경로만 새 객체 생성 |
| 전체 데이터의 독립적인 복사 필요 | structuredClone() 고려 |
| JSON 직렬화 가능한 단순 데이터 | JSON 방식도 가능하지만 복사 용도로는 비권장 |
| 함수가 포함된 객체 | 별도의 복사 로직 필요 |
중요한 것은 얕은 복사가 나쁜 방법이고 깊은 복사가 좋은 방법이라는 식으로 구분하지 않는 것이다.
필요한 범위만 복사하는 것이 대부분의 상황에서 더 단순하고 효율적이다.
정리
JavaScript에서 객체를 다른 변수에 대입하는 것은 복사가 아니라 동일한 객체에 대한 참조를 하나 더 만드는 것이다.
스프레드 문법을 사용하면 새로운 객체를 만들 수 있지만 최상위 단계만 복사하는 얕은 복사가 이루어진다.
중첩된 객체까지 완전히 독립적으로 복사해야 한다면 structuredClone()과 같은 방법을 사용할 수 있다.
React 상태를 관리할 때는 전체 객체를 무조건 깊은 복사하기보다 실제로 변경되는 경로의 객체만 새롭게 만드는 방식이 적절하다.
객체를 복사할 때는 어떤 문법을 사용할지보다 현재 데이터에서 어느 단계까지 새로운 참조가 필요한지를 먼저 판단하는 것이 중요하다.
참고사항
structuredClone()은 ECMAScript 문법이 아니라 Web API이며,
주요 브라우저에서는 2022년경부터 널리 지원되고 있다.
'FrontEnd > React.js' 카테고리의 다른 글
| React에서 useMemo, useCallback을 무조건 쓰면 안 되는 이유 (2) | 2026.08.31 |
|---|---|
| [React / scss] import 오류 해결하기 > use 쓰기 (0) | 2025.03.17 |
| [React-Query] 리액트 쿼리 사용해보기 - 캐싱을 통한 최적화 (0) | 2025.03.12 |
| [React.js] useContext / 테마색 바꾸기 (0) | 2025.03.11 |
| [React.js] useTransition & useDeferredValue 함께 사용하자 (0) | 2025.03.10 |
