자바스크립트 복사의 함정 - spread 하나로 3시간을 날린 이야기

작년에 우리 팀에서 운영하던 대시보드 서비스에서 이상한 버그가 올라왔다. 사용자가 필터 설정을 변경하면 다른 사용자의 필터도 같이 바뀐다는 것이었다. 로그를 뒤져보고, 네트워크 요청을 추적하고, 혹시 웹소켓에서 상태가 공유되는 건 아닌지까지 살펴봤다. 3시간쯤 지나서야 원인을 찾았다. 범인은 딱 한 줄이었다.
const userFilters = { ...defaultFilters };
defaultFilters 안에 중첩된 객체가 있었고, spread 연산자는 내부에 중첩된 객체까지는 복사하지 않고 참조만 공유하고 있었다. 한 사용자가 중첩된 필터 옵션을 변경하면, 같은 참조를 공유하는 다른 모든 사용자의 필터도 함께 바뀌었던 것이다.
자바스크립트에서 객체를 복사한다는 것은 보이는 것보다 훨씬 복잡하다. 내가 지금까지 프로덕션에서 겪어본 버그 중 상당수가 이 "복사가 진짜 복사가 아닌" 문제에서 비롯되었다. 여기서는 얕은 복사와 깊은 복사의 차이를 바닥부터 파고들어, 실무에서 어떤 방법을 언제 써야 하는지까지 다룬다.
값 타입과 참조 타입 - 복사 문제의 근본 원인
다음 그림은 자바스크립트가 원시 타입과 참조 타입을 메모리에 어떻게 저장하는지 보여준다.

원시 타입은 스택에 값 자체가 저장되어 복사하면 독립적인 사본이 만들어진다. 참조 타입은 힙의 주소만 복사되므로 두 변수가 같은 객체를 가리키게 된다. 이 차이가 모든 복사 문제의 출발점이다.
자바스크립트의 복사 문제를 이해하려면 먼저 언어가 데이터를 메모리에 어떻게 저장하는지부터 알아야 한다.
자바스크립트의 데이터 타입은 크게 두 종류로 나뉜다. 원시 타입과 참조 타입이다. 원시 타입에는 string, number, boolean, null, undefined, Symbol, BigInt가 있고, 참조 타입에는 Object, Array, Function 등이 있다.
원시 타입은 값 자체가 변수에 저장된다. 메모리의 스택 영역에 고정 크기로 들어간다. 반면 참조 타입은 실제 데이터가 힙 영역에 저장되고, 변수에는 그 힙 메모리의 주소만 들어간다. 여기서 복사 동작이 갈린다.
코드로 확인해보자.
// 원시 타입: 값이 복사된다 let a = 42; let b = a; b = 100; console.log(a); // 42 - a는 변하지 않는다 console.log(b); // 100 // 참조 타입: 참조(주소)가 복사된다 let obj1 = { name: "김개발", score: 95 }; let obj2 = obj1; obj2.score = 50; console.log(obj1.score); // 50 - obj1도 바뀌었다! console.log(obj2.score); // 50
let obj2 = obj1은 객체를 복사하는 것이 아니다. obj1이 가리키고 있는 힙 메모리의 주소를 obj2에 복사하는 것이다. 그래서 obj1과 obj2는 같은 객체를 바라본다. 한쪽을 수정하면 다른 쪽에서도 변경이 보인다.
여기서 한 가지 더 알아둘 것이 있다. const로 선언한 객체도 프로퍼티 변경이 가능하다는 점이다.
const config = { debug: false, verbose: true }; config.debug = true; // 에러가 나지 않는다! console.log(config.debug); // true // const가 막는 것은 참조의 재할당이다 // config = {}; // TypeError: Assignment to constant variable.
const는 변수가 가리키는 메모리 주소의 재할당만 막는다. 그 주소에 있는 객체의 내용물을 바꾸는 것은 막지 않는다. 이것 때문에 "const로 선언했으니까 안전하겠지"라고 생각하다 버그를 만드는 경우를 많이 봤다.
그리고 참조 타입의 동등 비교도 직관과 다르게 동작한다.
const arr1 = [1, 2, 3]; const arr2 = [1, 2, 3]; console.log(arr1 === arr2); // false - 내용이 같아도 다른 객체다 const arr3 = arr1; console.log(arr1 === arr3); // true - 같은 참조를 가리킨다
내용이 완전히 동일해도 별도로 생성된 객체는 서로 다른 메모리 주소를 가지므로 === 비교에서 false가 나온다. React에서 상태를 비교할 때 이 참조 동등성을 사용하기 때문에, 상태를 업데이트하려면 반드시 새로운 객체를 만들어야 한다. 이 내용은 뒤에 나오는 프레임워크 섹션에서 자세히 다룬다.
얕은 복사의 모든 것 - 그리고 함정들
참조 타입의 동작을 이해했으니, 이제 실제로 객체를 복사하는 방법을 알아보자. 가장 먼저 등장하는 것이 얕은 복사다.
다음 그림은 얕은 복사와 깊은 복사의 핵심 차이를 보여준다.

얕은 복사(spread)는 최상위 프로퍼티만 새로 만들고 중첩된 객체는 원본과 같은 주소를 공유한다. 깊은 복사(structuredClone)는 모든 레벨을 독립적으로 복제하여 원본과 완전히 분리된다.
얕은 복사는 객체의 최상위 프로퍼티만 새로 복사하고, 중첩된 객체는 원본과 같은 참조를 공유하는 복사 방식이다. 자바스크립트에서 얕은 복사를 수행하는 방법은 여러 가지가 있다.
spread 연산자
가장 많이 쓰이는 방법이다.
const original = { name: "김개발", age: 32, skills: ["JavaScript", "React", "Node.js"], address: { city: "서울", district: "강남구" } }; const copy = { ...original }; // 최상위 프로퍼티 변경: 원본에 영향 없음 copy.name = "이개발"; console.log(original.name); // "김개발" - OK // 중첩 객체 변경: 원본도 바뀜! copy.address.city = "부산"; console.log(original.address.city); // "부산" - 문제 발생! // 배열도 마찬가지 copy.skills.push("TypeScript"); console.log(original.skills); // ["JavaScript", "React", "Node.js", "TypeScript"]
spread 연산자는 1단계 깊이만 복사한다. copy.name을 바꿔도 원본은 안전하지만, copy.address.city를 바꾸면 원본의 address도 바뀐다. copy.address와 original.address가 같은 객체를 가리키고 있기 때문이다.
배열에서도 동일하게 동작한다.
const matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; const shallowCopy = [...matrix]; shallowCopy[0][0] = 999; console.log(matrix[0][0]); // 999 - 원본도 바뀐다
2차원 배열은 "배열 안의 배열"이므로, spread로 복사해도 내부 배열은 참조가 공유된다. 행렬 데이터를 다루는 코드에서 이 함정에 빠지는 경우가 많다.
Object.assign()
spread 이전에 주로 쓰이던 방법이다.
const target = { a: 1 }; const source1 = { b: 2 }; const source2 = { c: 3 }; const result = Object.assign(target, source1, source2); console.log(result); // { a: 1, b: 2, c: 3 } console.log(target); // { a: 1, b: 2, c: 3 } - target이 변경된다! console.log(result === target); // true
Object.assign()은 첫 번째 인자(target)를 직접 변경한다는 점에서 spread와 다르다. 원본을 변경하지 않으려면 빈 객체를 target으로 써야 한다.
// 원본을 보존하려면 빈 객체를 타겟으로 사용 const safeCopy = Object.assign({}, original);
spread와 Object.assign()에는 또 한 가지 중요한 차이가 있다. Object.assign()은 대상 객체의 setter를 호출하지만, spread는 호출하지 않는다.
const withSetter = { _value: 0, set value(v) { console.log(`setter 호출: ${v}`); this._value = v; } }; // Object.assign은 setter를 호출한다 Object.assign(withSetter, { value: 42 }); // 출력: "setter 호출: 42" // spread는 setter를 무시하고 프로퍼티를 덮어쓴다 const spreadCopy = { ...withSetter, value: 42 }; // setter가 호출되지 않음
이 차이는 대부분의 코드에서 문제가 되지 않는다. 다만 Vue 3의 reactive() 객체처럼 Proxy로 감싼 객체를 다룰 때는 주의가 필요하다. Object.assign(reactiveObj, source)는 Proxy의 setter를 통해 반응성 추적이 유지되지만, { ...reactiveObj, ...source }는 Proxy를 벗겨내면서 반응성이 끊어진다. Vue 공식 문서에서도 반응형 객체를 병합할 때 Object.assign()을 권장하는 이유가 여기에 있다.
Array.from()과 Array.prototype.slice()
배열 전용 얕은 복사 방법이다.
const numbers = [1, 2, 3, 4, 5]; const copy1 = Array.from(numbers); const copy2 = numbers.slice(); const copy3 = [...numbers]; // 세 가지 모두 얕은 복사 // 원시 타입 원소만 있는 1차원 배열에서는 사실상 깊은 복사처럼 동작한다 copy1[0] = 999; console.log(numbers[0]); // 1 - 원본 안전 // 하지만 객체 원소가 있으면 상황이 달라진다 const users = [ { name: "김개발", active: true }, { name: "이개발", active: false } ]; const usersCopy = [...users]; usersCopy[0].active = false; console.log(users[0].active); // false - 원본도 바뀌었다
원시 타입만 담긴 1차원 배열이라면 얕은 복사만으로도 충분하다. 하지만 배열 원소가 객체인 순간, 다시 같은 문제가 발생한다.
실무에서 흔한 얕은 복사 버그
내가 코드 리뷰에서 가장 많이 지적하는 패턴을 정리하면 다음과 같다.
첫 번째는 변수 할당을 복사로 착각하는 경우다.
function processOrder(order) { // 이건 복사가 아니다. 같은 객체를 가리킬 뿐이다. let orderItems = order.items; // 원본 order의 items도 정렬되어 버린다 orderItems.sort((a, b) => a.price - b.price); return orderItems; }
두 번째는 최상위만 복사하고 중첩 객체를 수정하는 경우다.
function updateUserProfile(state, action) { // 최상위만 복사했다 const newState = { ...state }; // 중첩 객체를 직접 수정 - 원본 state도 변경된다! newState.profile.address.city = action.payload.city; return newState; }
올바른 방법은 변경이 필요한 중첩 경로를 전부 복사하는 것이다.
function updateUserProfile(state, action) { return { ...state, profile: { ...state.profile, address: { ...state.profile.address, city: action.payload.city } } }; }
코드가 꽤 장황해진다. 중첩이 3단계만 되어도 이런 모습이 된다. 이것이 Immer 같은 라이브러리가 등장한 배경이다.
세 번째는 배열의 파괴적 메서드를 간과하는 경우다.
// sort(), reverse(), splice()는 원본 배열을 변경한다 const scores = [85, 92, 78, 95, 88]; const sorted = scores.sort((a, b) => b - a); console.log(scores); // [95, 92, 88, 85, 78] - 원본이 바뀌었다 console.log(sorted); // [95, 92, 88, 85, 78] console.log(scores === sorted); // true - 같은 배열이다! // 안전한 방법 1: 복사 후 정렬 const safeSorted = [...scores].sort((a, b) => b - a); // 안전한 방법 2: ES2023의 비파괴적 메서드 사용 const safeSorted2 = scores.toSorted((a, b) => b - a); const safeReversed = scores.toReversed(); const safeSpliced = scores.toSpliced(0, 1);
ES2023에서 추가된 toSorted(), toReversed(), toSpliced() 메서드는 원본을 변경하지 않고 새 배열을 반환한다. 아직 이 메서드를 모르는 개발자가 많은데, 쓸 수 있으면 쓰자.
깊은 복사의 진화 - JSON 해킹에서 structuredClone까지
얕은 복사의 한계를 넘어 객체를 완전히 독립적으로 복제하는 것이 깊은 복사다. 자바스크립트에서 깊은 복사의 역사를 돌아보면, 삽질의 역사였다.
JSON.parse(JSON.stringify()) - 원조 꼼수
가장 오래된 깊은 복사 방법이다. 내가 처음 자바스크립트를 배울 때도 이 방법이 "깊은 복사의 정석"처럼 퍼져 있었다.
const original = { name: "김개발", scores: [95, 88, 92], metadata: { created: "2024-01-15", tags: ["frontend", "react"] } }; const deepCopy = JSON.parse(JSON.stringify(original)); deepCopy.metadata.tags.push("typescript"); console.log(original.metadata.tags); // ["frontend", "react"] - 원본 안전!
단순한 데이터 구조에서는 잘 동작한다. 하지만 이 방법에는 한계가 꽤 있다.
const problematic = { date: new Date("2024-01-15"), regex: /hello/gi, undef: undefined, func: function() { return 42; }, map: new Map([["key", "value"]]), set: new Set([1, 2, 3]), nan: NaN, infinity: Infinity }; const cloned = JSON.parse(JSON.stringify(problematic)); console.log(typeof cloned.date); // "string" - Date 객체가 문자열이 되었다 console.log(cloned.regex); // {} - RegExp가 빈 객체가 되었다 console.log(cloned.undef); // undefined가 아니라 프로퍼티 자체가 사라졌다 console.log(cloned.func); // undefined - 함수가 사라졌다 console.log(cloned.map); // {} - Map이 빈 객체가 되었다 console.log(cloned.set); // {} - Set이 빈 객체가 되었다 console.log(cloned.nan); // null - NaN이 null이 되었다 console.log(cloned.infinity); // null - Infinity가 null이 되었다
Date가 문자열이 되고, RegExp, Map, Set은 빈 객체가 되고, undefined와 함수는 아예 사라진다. NaN과 Infinity는 null로 바뀐다. 데이터 타입의 정확성이 중요한 상황에서는 절대 쓰면 안 되는 방법이다.
순환 참조가 있으면 아예 에러가 발생한다.
const circular = { name: "순환" }; circular.self = circular; JSON.stringify(circular); // TypeError: Converting circular structure to JSON
lodash cloneDeep - 믿을 만하지만 무거운
JSON 방식의 한계를 해결하기 위해 많은 프로젝트가 lodash의 cloneDeep을 사용했다.
import cloneDeep from 'lodash/cloneDeep'; const original = { date: new Date(), regex: /test/gi, nested: { deep: { value: 42 } }, set: new Set([1, 2, 3]) }; const cloned = cloneDeep(original); cloned.nested.deep.value = 999; console.log(original.nested.deep.value); // 42 - 원본 안전 console.log(cloned.date instanceof Date); // true - 타입 보존 console.log(cloned.regex instanceof RegExp); // true
cloneDeep은 대부분의 자바스크립트 타입을 제대로 처리하고, 순환 참조도 다룬다. 문제는 번들 사이즈다. cloneDeep만 import해도 약 17.4KB(gzip 시 5.3KB)가 추가된다. lodash 전체를 import하면 71.5KB(gzip 시 25.2KB)다. 깊은 복사 하나를 위해 이 정도 사이즈를 감수할 것인지는 프로젝트마다 판단이 다르겠지만, 번들 사이즈에 민감한 프론트엔드 프로젝트에서는 부담이 된다.
structuredClone - 네이티브 깊은 복사의 표준
2022년에 모든 주요 브라우저와 Node.js에서 지원되기 시작한 structuredClone()으로 드디어 라이브러리 없이 네이티브로 깊은 복사를 할 수 있게 됐다.
const original = { name: "김개발", scores: [95, 88, 92], metadata: { created: new Date("2024-01-15"), tags: new Set(["frontend", "react"]), config: new Map([["theme", "dark"], ["lang", "ko"]]) } }; const cloned = structuredClone(original); // 중첩 객체 수정 - 원본에 영향 없음 cloned.metadata.tags.add("typescript"); cloned.metadata.config.set("theme", "light"); cloned.scores.push(100); console.log(original.metadata.tags); // Set(2) {"frontend", "react"} - 안전 console.log(original.metadata.config.get("theme")); // "dark" - 안전 console.log(original.scores); // [95, 88, 92] - 안전 // 타입도 보존된다 console.log(cloned.metadata.created instanceof Date); // true console.log(cloned.metadata.tags instanceof Set); // true console.log(cloned.metadata.config instanceof Map); // true
structuredClone은 꽤 많은 타입을 처리한다. Array, Object, Map, Set, Date, RegExp, ArrayBuffer, TypedArray, Error(및 하위 타입), Blob, File, ImageData 등을 지원하고, 순환 참조도 제대로 처리한다.
// 순환 참조도 처리한다 const circular = { name: "순환" }; circular.self = circular; const cloned = structuredClone(circular); console.log(cloned.self === cloned); // true - 순환 구조가 보존됨 console.log(cloned !== circular); // true - 독립적인 복제본
structuredClone의 한계
하지만 이것도 만능은 아니다. 분명한 제약이 있다.
// 1. 함수는 복제할 수 없다 - DataCloneError 발생 const withFunction = { name: "test", greet: function() { return "hello"; } }; try { structuredClone(withFunction); } catch (e) { console.log(e.name); // "DataCloneError" console.log(e.message); // "function() { return \"hello\"; } could not be cloned." } // 2. DOM 노드도 복제할 수 없다 try { const div = document.createElement('div'); structuredClone(div); } catch (e) { console.log(e.name); // "DataCloneError" } // 3. 프로토타입 체인이 보존되지 않는다 class User { constructor(name) { this.name = name; } greet() { return `안녕, ${this.name}`; } } const user = new User("김개발"); const clonedUser = structuredClone(user); console.log(user instanceof User); // true console.log(clonedUser instanceof User); // false - 일반 Object가 됨 // clonedUser.greet(); // TypeError: clonedUser.greet is not a function // 4. Symbol 프로퍼티는 무시된다 const withSymbol = { [Symbol("id")]: 12345, name: "test" }; const clonedSymbol = structuredClone(withSymbol); console.log(Object.getOwnPropertySymbols(clonedSymbol).length); // 0 // 5. 프로퍼티 디스크립터가 보존되지 않는다 const withDescriptor = {}; Object.defineProperty(withDescriptor, 'readOnly', { value: 42, writable: false, enumerable: true }); const clonedDesc = structuredClone(withDescriptor); clonedDesc.readOnly = 999; // 에러 없이 변경 가능 - writable:false가 사라졌다 console.log(clonedDesc.readOnly); // 999
정리하면, structuredClone은 함수, DOM 노드, Symbol 프로퍼티, 프로토타입 체인, 프로퍼티 디스크립터를 처리하지 못한다. 특히 클래스 인스턴스를 복제하면 일반 Object로 바뀌어서 메서드를 쓸 수 없게 되니 주의하자.
성능 벤치마크 - 어떤 방법이 가장 빠른가
Node.js 환경에서의 벤치마크 결과를 보면 의외의 사실이 드러난다. Node.js GitHub 이슈 #50320에 보고된 데이터(Node.js 20.x, V8 11.x 기준)에 따르면, structuredClone은 JSON.parse/stringify보다 느리다.
| 메서드 | 100회 | 10,000회 | 1,000,000회 |
|---|---|---|---|
| structuredClone | 5.9ms | 131ms | 약 10초 |
| JSON.parse/stringify | 1.4ms | 88ms | 약 8초 |
| 커스텀 deepClone | 1.1ms | 46ms | 약 3초 |
여기서 "커스텀 deepClone"은 재귀적으로 프로퍼티를 순회하며 복사하는 단순 구현이다. 속도는 가장 빠르지만, Date, Map, Set 같은 특수 타입을 처리하지 못하고 순환 참조에서 무한 루프에 빠진다. 속도만 보고 커스텀 구현을 선택하면 안 되는 이유다.
structuredClone이 JSON 방식보다 느린 이유는 구현 방식에 있다. V8의 값 직렬화/역직렬화 API를 내부적으로 사용하는데, 이 과정에서 MessageChannel을 거치면서 오버헤드가 발생한다. Node.js 메인테이너 Ben Noordhuis의 설명에 따르면, 핵심 작업이 V8 내부에서 돌아가기 때문에 Node.js 쪽에서 손댈 수 있는 부분이 별로 없다.
그렇다면 무조건 JSON 방식이 낫다는 뜻인가. 그건 아니다. 성능은 JSON 방식이 빠르지만, 정확성은 structuredClone이 훨씬 낫다. Date, Map, Set, 순환 참조를 올바르게 처리해야 하는 상황에서는 structuredClone이 유일한 선택지다.
실무에서의 판단 기준은 이렇다. 단순한 JSON 호환 데이터(API 응답 등)를 복사할 때는 JSON 방식이 빠르고 충분하다. 하지만 복잡한 자바스크립트 객체를 다루거나, 데이터 타입의 정확한 보존이 필요할 때는 structuredClone을 써야 한다. 성능 차이가 실제로 문제가 되는 경우는 수십만 회 반복하는 상황이지, 일반적인 애플리케이션 코드에서는 체감하기 어렵다.
프레임워크에서의 실전 복사 패턴
얕은 복사와 깊은 복사의 개념이 실전에서 가장 치열하게 부딪히는 곳은 프론트엔드 프레임워크의 상태 관리다. React, Vue, Redux 각각이 복사와 관련하여 서로 다른 패턴을 요구한다.
React - 불변성이 핵심
React의 상태 업데이트는 불변성을 기반으로 동작한다. React는 이전 상태와 새 상태의 참조를 비교하여 리렌더링 여부를 결정한다. 같은 참조면 변경이 없다고 판단하고, 다른 참조면 변경이 있다고 판단한다.
import { useState } from 'react'; function ProfileEditor() { const [user, setUser] = useState({ name: "김개발", age: 32, address: { city: "서울", district: "강남구", detail: "테헤란로 123" }, skills: ["JavaScript", "React"] }); // 잘못된 방법: 직접 변경하면 리렌더링이 발생하지 않는다 const handleBadUpdate = () => { user.address.city = "부산"; // React가 변경을 감지하지 못한다 setUser(user); // 같은 참조이므로 무시됨 }; // 올바른 방법: 변경 경로를 전부 새로 만든다 const handleGoodUpdate = () => { setUser({ ...user, address: { ...user.address, city: "부산" } }); }; // 배열 업데이트도 새 배열을 만들어야 한다 const handleAddSkill = (newSkill) => { setUser({ ...user, skills: [...user.skills, newSkill] }); }; // 배열에서 특정 항목 제거 const handleRemoveSkill = (index) => { setUser({ ...user, skills: user.skills.filter((_, i) => i !== index) }); }; return ( <div> <p>{user.name} - {user.address.city}</p> <button onClick={handleGoodUpdate}>도시 변경</button> <button onClick={() => handleAddSkill("TypeScript")}>스킬 추가</button> </div> ); }
중첩이 깊어지면 코드가 매우 장황해진다. 이때 Immer가 빛을 발한다.
import { useImmer } from 'use-immer'; function ProfileEditor() { const [user, updateUser] = useImmer({ name: "김개발", age: 32, address: { city: "서울", district: "강남구", detail: "테헤란로 123" }, skills: ["JavaScript", "React"] }); // Immer를 사용하면 직접 변경하는 것처럼 쓸 수 있다 const handleUpdate = () => { updateUser(draft => { draft.address.city = "부산"; // 직접 변경하는 것처럼 보이지만 안전하다 draft.skills.push("TypeScript"); }); }; return ( <div> <p>{user.name} - {user.address.city}</p> <button onClick={handleUpdate}>업데이트</button> </div> ); }
Immer의 draft는 Proxy 객체다. draft에 대한 변경을 기록한 뒤, 변경된 부분만 새로 생성하여 불변 업데이트를 수행한다. structuredClone이 전체 객체를 통째로 복제하는 것과 달리, Immer는 변경이 필요한 경로만 새로 만들기 때문에 메모리 효율이 좋다.
Vue - 반응성과 복사의 충돌
Vue는 Proxy 기반의 반응형 시스템을 사용한다. 이 반응형 객체를 복사할 때 특유의 문제가 발생한다.
import { reactive, toRaw } from 'vue'; const state = reactive({ user: { name: "김개발", scores: [95, 88, 92] } }); // 잘못된 복사: spread로 복사하면 Proxy 래퍼가 벗겨진다 const badCopy = { ...state.user }; // badCopy의 변경은 UI에 반영되지 않음 (반응성 상실) // 올바른 복사: 3단계 워크플로 // 1) toRaw()로 반응성 제거 (순수 객체 추출) const rawData = toRaw(state.user); // 2) structuredClone()으로 깊은 복사 const deepCopy = structuredClone(rawData); // 3) 필요하면 reactive()로 다시 래핑 const reactiveCopy = reactive(deepCopy);
Vue에서 파괴적 배열 메서드를 사용할 때도 주의가 필요하다.
import { ref } from 'vue'; const items = ref([3, 1, 4, 1, 5, 9]); // 잘못된 방법: sort()가 원본 반응형 배열을 변경한다 // items.value.sort(); // 원본이 변경됨 // 올바른 방법: 복사본을 정렬한다 const sorted = [...items.value].sort((a, b) => a - b); // 또는 ES2023의 비파괴 메서드 사용 const sorted2 = items.value.toSorted((a, b) => a - b);
Redux - 중첩의 고통
Redux에서는 리듀서가 반드시 새로운 상태 객체를 반환해야 한다. 중첩 상태를 다룰 때 이 규칙을 지키는 것이 매우 번거롭다.
// 전통적인 Redux 리듀서 - 중첩 상태 업데이트 function todoReducer(state, action) { switch (action.type) { case 'UPDATE_TODO_TEXT': return { ...state, todos: { ...state.todos, [action.id]: { ...state.todos[action.id], text: action.text } } }; default: return state; } }
이 코드에서 흔히 저지르는 실수가 두 가지 있다.
// 실수 1: 변수 할당이 복사라고 착각 function buggyReducer1(state, action) { let todos = state.todos; // 복사가 아니라 같은 참조! todos[action.id].text = action.text; // 원본 state도 변경됨 return { ...state, todos }; } // 실수 2: 최상위만 spread function buggyReducer2(state, action) { let newState = { ...state }; // 최상위만 복사 newState.todos[action.id].text = action.text; // 중첩 객체는 원본과 공유! return newState; }
Redux Toolkit은 이 문제를 Immer를 내장하여 해결했다.
import { createSlice } from '@reduxjs/toolkit'; const todoSlice = createSlice({ name: 'todos', initialState: { todos: {}, filter: 'all' }, reducers: { updateTodoText(state, action) { // 직접 변경하는 것처럼 쓸 수 있다 state.todos[action.payload.id].text = action.payload.text; }, addTodo(state, action) { state.todos[action.payload.id] = action.payload; } } });
Redux Toolkit의 createSlice 내부에서 Immer가 자동으로 불변 업데이트를 처리한다. 지금 새로 Redux 프로젝트를 시작한다면, 전통적인 spread 패턴 대신 Redux Toolkit을 사용하는 것이 맞다.
V8 엔진 내부 - 복사와 성능의 비밀
여기서 한 단계 더 깊이 들어가보자. V8 엔진이 객체를 내부적으로 어떻게 다루는지를 이해하면, 왜 특정 복사 패턴이 더 빠른지까지 설명할 수 있다.
히든 클래스와 인라인 캐싱
자바스크립트는 동적 타입 언어다. 객체에 언제든지 프로퍼티를 추가하거나 삭제할 수 있다. 이런 유연성은 편리하지만, 엔진 입장에서는 프로퍼티 접근을 최적화하기 어렵게 만든다.
V8은 이 문제를 히든 클래스로 해결한다. 객체가 생성되면 빈 히든 클래스가 할당되고, 프로퍼티가 추가될 때마다 새로운 히든 클래스로 전환된다.
// 이 코드를 V8이 처리하는 과정 const point = {}; // 히든 클래스 C0 (빈 객체) point.x = 10; // 히든 클래스 C0 -> C1 (x 프로퍼티 추가) point.y = 20; // 히든 클래스 C1 -> C2 (y 프로퍼티 추가)
중요한 점은, 같은 순서로 같은 프로퍼티를 추가한 객체는 히든 클래스를 공유한다는 것이다.
const p1 = {}; p1.x = 1; p1.y = 2; const p2 = {}; p2.x = 3; p2.y = 4; // p1과 p2는 같은 히든 클래스를 공유한다 // V8은 이를 이용하여 프로퍼티 접근을 최적화한다
V8은 인라인 캐싱이라는 기법을 사용하여, 같은 히든 클래스를 가진 객체의 프로퍼티에 접근할 때 메모리 오프셋을 캐시해둔다. 한 번 오프셋을 알면 이후에는 딕셔너리 탐색 없이 바로 값을 읽을 수 있다.
인라인 캐시의 상태는 세 가지로 나뉜다. 하나의 히든 클래스만 관찰된 경우를 모노모픽이라 하고, 이때 성능이 가장 좋다. 2~4개의 히든 클래스가 관찰되면 폴리모픽으로, 약간의 성능 저하가 있다. 4개를 초과하면 메가모픽 상태가 되어 캐시가 비활성화되고, 느린 경로로 동작한다.
복사 패턴이 성능에 미치는 영향
이 히든 클래스 시스템이 객체 복사와 어떤 관련이 있을까. spread 연산자나 Object.assign()으로 객체를 복사하면 새 객체가 생성되는데, 프로퍼티 추가 순서에 따라 히든 클래스가 결정된다.
// 일관된 구조 = 히든 클래스 공유 = 인라인 캐싱 최적화 function createPoint(x, y) { return { x, y }; // 항상 같은 순서로 프로퍼티 생성 } const points = []; for (let i = 0; i < 10000; i++) { points.push(createPoint(i, i * 2)); } // 이 루프는 인라인 캐싱 덕분에 매우 빠르다 let sum = 0; for (const p of points) { sum += p.x + p.y; // 모노모픽 접근 }
반면 아래처럼 동적으로 프로퍼티를 추가하거나, 조건에 따라 다른 구조의 객체를 만들면 성능이 떨어진다.
// 나쁜 패턴: 조건부 프로퍼티 추가 function createUser(data) { const user = { name: data.name }; if (data.email) user.email = data.email; // 있을 때만 추가 if (data.phone) user.phone = data.phone; // 있을 때만 추가 if (data.age) user.age = data.age; // 있을 때만 추가 return user; } // 이렇게 만들면 email/phone/age의 조합에 따라 // 서로 다른 히든 클래스가 생성된다 // 개선된 패턴: 항상 같은 구조 function createUserOptimized(data) { return { name: data.name, email: data.email || null, phone: data.phone || null, age: data.age || null }; } // 모든 객체가 같은 히든 클래스를 공유하여 인라인 캐싱 효율이 높다
이것은 객체를 복사할 때도 적용된다. spread로 복사하면 열거 가능한 프로퍼티가 같은 순서로 추가되므로 히든 클래스가 보존된다. 하지만 복사 후에 프로퍼티를 동적으로 추가하거나 삭제하면 히든 클래스가 달라지고, 인라인 캐싱의 효율이 떨어진다.
Chrome DevTools의 Memory 탭에서 힙 스냅샷을 찍으면 각 객체의 "map" 필드로 히든 클래스를 확인할 수 있다. 성능 튜닝이 필요한 상황이라면 같은 종류의 객체가 히든 클래스를 공유하고 있는지 점검해볼 만하다.
상황별 복사 방법 선택 가이드
지금까지 다룬 내용을 종합하여, 실무에서 어떤 복사 방법을 선택해야 하는지 정리한다.
원시 타입만 담긴 단순한 객체나 배열이라면 spread 연산자면 충분하다. 가장 빠르고, 가장 읽기 쉽고, 대부분의 상황에서 올바르게 동작한다.
중첩 객체가 있지만 JSON 호환 데이터(API 응답, 설정 파일 등)라면 JSON.parse(JSON.stringify())가 빠르고 간편하다. Date, Map, Set 같은 특수 타입이 없다는 전제 하에서다.
중첩 객체에 Date, Map, Set, Error 등이 포함되어 있거나, 순환 참조가 있는 경우에는 structuredClone()을 사용한다. 번들 사이즈 추가 없이 정확한 깊은 복사를 수행할 수 있다.
함수나 클래스 인스턴스를 포함한 객체를 복사해야 한다면 커스텀 복제 로직을 작성하거나 lodash cloneDeep을 사용한다. structuredClone은 함수와 프로토타입 체인을 처리하지 못하기 때문이다.
React, Vue 같은 프레임워크에서 상태를 업데이트할 때는 얕은 복사(spread) + 변경 경로 복사 패턴이 기본이고, 중첩이 깊다면 Immer를 도입하는 것이 가장 실용적이다.
빠르게 참조할 수 있도록 정리하면 다음과 같다.
| 상황 | 추천 방법 | 이유 |
|---|---|---|
| 원시 타입만 있는 단순 객체/배열 | { ...obj } / [...arr] | 가장 빠르고 간결하다 |
| JSON 호환 중첩 데이터 (API 응답 등) | JSON.parse(JSON.stringify()) | 빠르고, 특수 타입이 없으면 충분하다 |
| Date, Map, Set 포함 또는 순환 참조 | structuredClone() | 네이티브, 번들 추가 없이 정확하다 |
| 함수, 클래스 인스턴스 포함 | lodash cloneDeep 또는 커스텀 | structuredClone이 처리하지 못한다 |
| React/Vue 상태 업데이트 (깊은 중첩) | Immer (useImmer, Redux Toolkit) | 가독성과 안전성을 동시에 잡는다 |
내가 실무에서 가장 강조하고 싶은 것은, "복사가 필요한가"를 먼저 생각하라는 것이다. 불변성이 필요한 곳에서만 복사를 하고, 단순히 데이터를 읽기만 하는 곳에서는 원본 참조를 그대로 쓰는 것이 성능과 가독성 모두에서 유리하다. 모든 곳에 깊은 복사를 적용하는 것은 불필요한 메모리 낭비이고, 그렇다고 모든 곳에 얕은 복사만 쓰면 유령 버그에 시달린다. 상황에 맞는 도구를 골라야 한다.
마무리
내가 도입부에서 이야기한 그 대시보드 버그는 결국 structuredClone(defaultFilters)로 한 줄을 바꿔서 해결했다. 코드를 고치는 데는 10초면 충분했지만, 원인을 찾는 데는 꼬박 3시간이 걸렸다.
자바스크립트의 복사 문제는 언어 설계 자체에서 비롯된다. 원시 타입과 참조 타입의 메모리 저장 방식이 다르고, 대부분의 내장 복사 연산이 얕은 복사만 수행하기 때문이다. 이 동작을 정확히 이해하고 있으면 버그를 예방할 수 있고, 이해하지 못하면 3시간을 날린다.
한 가지만 기억하자. 코드에서 ...이나 Object.assign()을 쓸 때, 그 객체 안에 중첩된 객체나 배열이 있는지를 확인하는 습관을 들이자. 그 한 번의 확인이 디버깅 시간을 줄여준다.
참고 자료
- web.dev, "Deep-copying in JavaScript using structuredClone", https://web.dev/articles/structured-clone
- MDN, "Structured clone algorithm", https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
- Node.js GitHub Issue #50320, "structuredClone Performance", https://github.com/nodejs/node/issues/50320
- javascript.info, "Object copy, references", https://javascript.info/object-copy
- React 공식 문서, "Updating Objects in State", https://react.dev/learn/updating-objects-in-state
- Redux 공식 문서, "Immutable Update Patterns", https://redux.js.org/usage/structuring-reducers/immutable-update-patterns
- LINE Engineering, "V8 Hidden Class", https://engineering.linecorp.com/en/blog/v8-hidden-class
- Mathias Bynens, "JavaScript engine fundamentals: Shapes and Inline Caches", https://mathiasbynens.be/notes/shapes-ics
- Immer 공식 문서, https://immerjs.github.io/immer/






댓글
댓글을 작성하려면 이 필요합니다.