자바스크립트의 복사

자바스크립트에는 2가지 복사가 있다. 깊은 복사와 얕은 복사다. 이 개념을 이해하려면 자바스크립트의 원시 값(number, string, boolean, undefined, null, symbol)은 변수에 '값'이 저장되고 객체 값은 변수에 '참조 값'이 저장된다는 것을 알아야 한다. 원시 값 변수를 다른 변수에 대입할 경우 다른 변수는 동일한 값으로 저장되지만 객체 값 변수를 다른 변수에 대입할 경우 동일한 참조 값이 저장된다.

기본적으로 원시값은 메모리의 크기가 고정적이며 불변성을 띈다. 따라서 변수의 값을 변경할 경우 새로운 메모리 공간에 값을 할당하여 변수에 대입한다. 반면에 자바스크립트의 객체는 메모리의 크기를 미리 알 수 없으므로 고정할 수 없으며 객체를 수정한 경우 메모리에서 값을 직접 수정한다.

따라서 객체 값 변수를 다른 변수에 대입한 상태에서 다른 변수에서 객체의 값을 수정할 경우 이 메모리를 참조하고 있었던 원본 객체 값 변수 역시 영향을 받는다. 이러한 복사가 '얕은 복사'다. 함수의 매개 변수로 객체를 받는 경우 얕은 복사가 발생한다는 것을 유의해야 한다.

function patchUserName(user, name) {
    user.name = name
}

const user = { name: 'Jino Bae' }

console.log(user) // { name: 'Jino Bae' }

patchUserName(user, 'Aram Kim')

console.log(user) // { name: 'Aram Kim' }

위 함수에서는 의도적으로 기존 객체를 수정하기 위해서 만들었지만, 의도하지 않은 경우 객체 상태의 예상이 어려워져 동작을 파악하거나 코드를 읽기가 어려워 진다. 함수형 프로그래밍에서는 이러한 함수를 비순수 함수라고 부른다. 비순수 함수란 함수의 동작으로 인해서 외부에 영향을 주거나 받는 함수를 지칭한다.

깊은 복사의 구현

자바스크립트에서는 다양한 깊은 복사를 구현할 수 있다. 아래 함수들은 기존 객체에 영향을 미치지 않는다.

function patchUserName(user, name) {
    const copyUser = Object.assign({}, user)
    copyUser.name = name
    return copyUser
}
function patchUserName(user, name) {
    const copyUser = {...user}
    copyUser.name = name
    return copyUser
}

배열인 경우 아래와 같이 깊은 복사를 구현할 수 있다.

function appendUser(users, user) {
    const copyUsers = users.slice()
    copyUsers.push(user)
    return copyUsers
}
function appendUser(users, user) {
    const copyUsers = [...users]
    copyUsers.push(user)
    return copyUsers
}
function appendUser(users, user) {
    const copyUsers = users.concat([user]) // 기존 객체에 영향주지 않음
    return copyUsers
}

깊은 복사의 함정

지금까지는 필자도 깊은 복사를 위 개념 정도로 이해하고 있었다. 그러다 최근 취업을 위해 면접을 진행하다가 관련된 질문으로 나왔는데 아차 싶었다. 객체의 내부의 객체까지는 고려해 본 적이 없었는데 객체 안에 객체는 여전히 얕은복사가 이뤄진다는 것을 알게 되었다. 생각해보면 당연한 것 같기도 한데...

const user = {
    name: 'Jino Bae',
    favorite: {
        game: 'The Sims 4',
        languages: ['Korean', 'English']
    }
}

const copyUser = {...user}
copyUser.favorite.game = 'GTA V' // 복사한 객체의 객체를 수정

console.log(user)
// {
//     name: 'Jino Bae',
//     favorite: {
//         game: 'GTA V', // 기존 객체 역시 변경됨
//         languages: [ 'Korean', 'English' ]
//     }
// }

여하지간 객체 내의 객체를 비롯해 객체를 깊은 복사 하는 방법으로 2가지 방법을 생각했다.

방법 1.

복사 진행중 객체 내에서 객체를 만나면 재귀 함수를 호출하여 내부 객체가 새로운 메모리에 생성되도록 하여 반환해 주는 것이다.

function deepCopy(props) {
    if (typeof props !== 'object') {
        return props;
    }

    return Object.keys(props).reduce((acc, key) => {
        const curProp = props[key];
     
        if (typeof props === 'object') {
            const copiedProp = deepCopy(curProp);
            return {...acc, [key]: copiedProp };
        }

        return {...acc, [key]: curProp };
    }, {})
}

단, 이 경우 문제가 한가지 있었는데 배열은 근본적으로 오브젝트 타입이므로, 오브젝트 형태로 복사되었다. 혹여 후에 배열 타입인지 비교가 필요한 경우 문제가 생긴다.

user => {
    name: 'Jino Bae',
    favorite: {
        game: 'The Sims 4',
        languages: [ 'Korean', 'English' ]
    }
}
copyUser  => {
    name: 'Jino Bae',
    favorite: {
        game: 'GTA V',
        languages: {
            '0': 'Korean',
            '1': 'English'
        }
    }
}

문제를 해결하기 위해서 프로토타입의 생성자 이름을 확인하여 의도한 타입으로 복사될 수 있도록 하였다.

function getTypes(prop) {
    return Object.getPrototypeOf(prop).constructor.name
}

function deepCopy(props) {
    if (typeof props !== 'object') {
        return props;
    }

    return Object.keys(props).reduce((acc, key) => {
        const curProp = props[key];

        if (typeof props === 'object') {
            if (getTypes(curProp) === 'Array') {
                return {...acc, [key]: [...curProp] };
            }

            if (getTypes(curProp) === 'Object') {
                const copiedProp = deepCopy(curProp);
                return {...acc, [key]: copiedProp };
            }
        }

        return {...acc, [key]: curProp };
    }, {})
}
방법 2.

다른 방법은 없는지 찾아봤는데 매우 간단하게 깊은 복사를 하는 방법이 있었다. JSON 빌트인 객체의 stringify와 parse를 연속적으로 사용하는 방법이다.

function deepCopy(props) {
    return JSON.parse(JSON.stringify(props))
}

하지만 위 메서드들이 내부적으로 어떻게 구현된지 모르기에 왠지 사용이 꺼림직해 보인다. 그래서 내부 로직을 찾아보고자 하였으나 원하는 결과를 찾기가 어려웠다. 우선 성능 테스트를 통해 어느 정도의 퍼포먼스를 보여주는지 확인해 보았다.

성능 테스트

우선 위에서 나열한 객체를 바탕으로 테스트를 진행했을 땐 방법2의 성능이 매우 좋았다.

const user = {
    name: 'Jino Bae',
    favorite: {
        game: 'The Sims 4',
        languages: ['Korean', 'English'],
    }
}
deepCopy1 : 100000회 반복까지 걸린 시간 : 1311
deepCopy2 : 100000회 반복까지 걸린 시간 : 760

이렇게 일반화를 할 뻔 했으나 객체의 양을 늘렸더니 방법2의 성능이 비약적으로 떨어졌다.

const user = {
    name: 'Jino Bae',
    favorite: {
        game: 'The Sims 4',
        languages: ['Korean', 'English'],
        friends: [
            {
                name: 'Aram',
                favorite: {}
            },
            {
                name: 'Yudai',
                favorite: {}
            },
            {
                name: 'Aram',
                favorite: {}
            },
            {
                name: 'Yudai',
                favorite: {}
            },
            {
                name: 'Aram',
                favorite: {}
            },
            {
                name: 'Yudai',
                favorite: {}
            }
        ]
    }
}
deepCopy1 : 100000회 반복까지 걸린 시간 : 1378
deepCopy2 : 100000회 반복까지 걸린 시간 : 2207

객체의 양이 많아져 문자열을 메모리에 넣는 과정이 오래걸리는 걸까? 정확한 내부 로직을 알 수 없으니 확답을 할 수 없겠지만 무턱대고 방법2를 사용했다간 피를 볼 수 있다는 점은 확실하니 유의가 필요하다. 이번엔 재귀 함수를 사용하는 방법1을 염두에 두고 객체의 깊이를 늘려보았다.

const user = {
    name: 'Jino Bae',
    favorite: {
        game: 'The Sims 4',
        languages: ['Korean', 'English'],
        friends: {
            friends: {
                friends: {
                    friends: {
                        friends: {
                            friends: {
                                friends: {
                                    friends: {
                                        friends: {
                                            friends: {
                                                friends: {
                                                  
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
deepCopy1 : 100000회 반복까지 걸린 시간 : 2163
deepCopy2 : 100000회 반복까지 걸린 시간 : 1889

확실히 방법1의 성능이 다소 떨어지는 경향을 보여준다. 복사하는 객체의 형태에 따라서 적절한 방법을 사용해야 할 것으로 예상된다. 아래 링크에서 직접 객체를 변경하며 성능 테스트를 진행해 볼 수 있다.

  • deepcopy-test.js · Mymy Dev @baealex #

이 글이 도움이 되었나요?

신고하기
0분 전
작성된 댓글이 없습니다. 첫 댓글을 달아보세요!
    댓글을 작성하려면 로그인이 필요합니다.