sungyup's.

Web_Miscellaneous / 기초 개념 / 1.11 자바스크립트의 얕은 복사와 깊은 복사

1.11자바스크립트의 얕은 복사와 깊은 복사

얕은 복사와 깊은 복사의 개념과 방법

TL;DR

추억의 쪽지 시험

들어가며

얕은 복사와 깊은 복사에 대한 이해는 예기치 않은 사이드 이펙트를 방지하기 위해 반드시 필요하다. 기본적인 개념이지만 종종 (내가) 헷갈리는 것은, '얕다' 또는 '깊다'라는 개념이 해석하기에 따라 정반대의 의미로 느껴질 수 있기 때문이라고 생각한다.

예를 들어, '얕은' 복사는 말 그대로 얕게, 겉으로 보이는 부분만 복사하는 것이니 메모리 깊숙한 곳에 있는 참조는 복사하지 않고 새로운 객체를 만드는 것인가? 아니면, 오히려 복잡하게 내부 구조까지 신경 쓰지 않고 그냥 참조만 복사해서 더 단순하게 처리하는 건가?

반대로 '깊은' 복사는 원본 데이터를 너무나도 '깊이' 복사한 나머지 자신이 바뀌면 원본까지 바뀌는 복사인가? 아니면 정말 깊이 있게 완전히 분리된 새로운 복사본을 만들어서 그 안에서 무슨 일이 일어나도 원본과는 별개인 복사인가?

이 헷갈림은 실제로 구현한다고 생각해보면 바로 풀린다. 즉, '보이는 부분만 복사'하려고 해보면 결국엔 내부까지 완전히 새로 만들고 깊숙한 구조 전체를 복제하게 된다. 즉, '얕은' 복사야말로 사실은 가장 깊숙한 곳에 있는 참조를 복사해오는 것이고 '깊은' 복사는 가장 깊숙한 곳에 있는 참조가 아니라 구조 전체를 다른 공간에 복제한다.

개념

얕은 복사

얕은 복사(Shallow Copy)는 객체의 직접 속성(1단계 속성)만 복사한다. 이때 속성의 값이 원시 값(string, number, boolean 등)이면 그대로 복사되지만, 객체나 배열 같은 참조형 값이면 참조 주소만 복사된다. 즉, 복사된 객체에서 내부 객체를 수정하면 원본 객체도 영향을 받는다.

javascript
const original = { a: 1, b: {c: 2} }; const copy = {...original}; copy.b.c = 42; console.log(original.b.c); // 42

얕은 복사 방법

  • 객체
javascript
const copy1 = Object.assign({}, obj); // 또는, const copy2 = {...obj}

Object.assign(target, ...sources)sources 객체의 열거 가능한 자신만의 속성(상속 제외)을 target객체에 복사한다. 동일한 키가 있다면 덮어쓰기(overwrite)한다. 따라서 빈 객체에 원본 객체를 복사하게 된다.

  • 배열
javascript
const copy1 = arr.slice(); // 또는, const copy = [...arr];

원본이 바뀌는 불완전한 복사인데 왜 얕은 복사를 쓰는걸까? 기본적으로는 다음에 알아볼 깊은 복사가 복잡하고 비용이 크기 때문이고, 원본과 완전히 똑같이 복사하지 않고 복사본의 일부만 다르게 쓰고 싶을 때 ...와 함께 사용해 일부만 다른 객체를 사용하고 싶은 경우에 쓰기 적합하기 때문이다.

React 컴포넌트에서 상태를 업데이트하는 경우를 예로 들면, React에선 상태가 변화했는지 비교하기 위해선 원본을 직접 변경하면 안되고 새로운 객체를 만들어야 한다. 이럴 때 React는 얕은 비교를 하게 되는데, 깊은 복사를 하여도 물론 같은 결과가 나오지만 성능면에서 훨씬 떨어진다. 따라서 1단계만 복사하는 얕은 복사로 복사하고 일부만 수정하는 방식이 쓰인다.

javascript
const [user, setUser] = useState({ name: 'sungyup', settings: {theme: 'dark', fontSize: 14} }); // 테마만 바꾸는 경우 setUser(prev => ({ ...prev, settings: {...prev.settings, theme: 'light'} }));

비슷한 방식으로, 많은 라이브러리나 함수에서 기본값 객체를 정의하고 사용자 입력을 얕게 복사하여 덮어쓰는 패턴을 쓴다.

javascript
const defaultOptions = { retries: 3, timeout: 1000, headers: {'X-Token': 'abc'} }; const userOptions = { timeout: 2000 } const finalOptions = {...defaultOptions, ...userOptions};

이런 경우 headers 같은 중첩된 객체는 공유된다.

깊은 복사

깊은 복사(Deep Copy)는 단순히 1단계 속성뿐 아니라 중첩된 모든 객체나 배열까지 재귀적으로 복사한다. 복사된 객체는 원본과 완전히 독립적으로, 복사본의 값을 변경하더라도 원본은 영향을 받지 않는다.

javascript
const original = { a: 1, b: {c: 2} }; const deepCopy = JSON.parse(JSON.stringify(original)); deepCopy.b.c = 99; console.log(original.b.c); // 2

깊은 복사 방법 (스포일러: Lodash)

  • 가장 쉬운 방법
javascript
const deepCopy = JSON.parse(JSON.stringify(obj));

JSON.stringify(obj)는 객체를 JSON 문자열로 변환한다. 즉,

javascript
const obj = { name: 'sungyup', age: 33}; const json = JSON.stringify(obj); // '{"name":"sungyup","age":33}'
참고로, 만약 브라우저의 개발자 도구에서 위의 코드를 실행하고 console.log(json)을 실행했다면 그냥 객체만 보인다. 이것은 개발자 도구가 JSON 문자열을 파싱해서 보여주기 때문이다.

JSON.parse()는 JSON 문자열을 JavaScript 객체로 복원한다.

  1. 문자열을 한 글자씩 읽으며 JSON 문법에 맞게 tokenize한다.(예: {, "name", :, 'sungyup`')
  2. 구조적으로 구문을 분석(parsing)한다. 재귀 하강 파서 방식을 사용한다.
  3. 각 토큰을 기반으로 객체나 배열, 숫자, 문자열 등을 생성한다.
  4. 최종적으로 완전한 JS 객체를 생성해서 반환한다.

JSON.parse(JSON.stringify(obj))는 간단한 방법이지만, undefined, function, symbol, Date, Map, Set등이 손실된다. 이 타입들은 JSON에 없거나(undefined, Map, Set) 문자열이 아닌데 문자열로 변환(Date)되기 때문이다.

  • 알고리즘(재귀 함수)으로 구현
javascript
function deepClone(obj){ if(obj === null || typeof obj !== 'object') return obj; if(Array.isArray(obj)) return obj.map(deepClone); const clone = {}; for(let key in obj){ clone[key] = deepClone(obj[key]); } return clone; }
  • 라이브러리 사용 우리에겐 Lodash가 있다!
javascript
const deep = structuredClone(obj);