해당 글은 깊은 복사 유틸 함수를 만들고 리팩토링하는 과정을 담은 글입니다. 그 과정 가운데 제가 마주친 3가지 문제점이 있었으므로, 3가지 문제점을 위주로 글을 풀어나가보겠습니다. (오른쪽편의 목차를 참고해주세요)
얕은 복사와 깊은 복사를 비교하는 글은 굉장히 많습니다. 때문에 여기선 간단히 핵심만 다루고, 바로 깊은 복사를 구현한 과정을 설명해보도록 하겠습니다.
얕은 복사란 객체를 복사할 때, 원래 값과 복사된 값이 메모리 상에서 같은 주소값을 가리키고있는 것을 말합니다. 만약 객체안에 객체가 있을 경우 한 개의 객체라도 원본 객체를 참조하고 있다면, 이는 얕은 복사입니다. 때문에 얕은 복사후에 복사한 객체에 수정하게되면, 원본 객체에도 변형이 생깁니다. 이는 '불변성'을 해친다하여 요즘에는 잘 사용하지 않는 방법이기도 합니다.
얕은 복사를 진행하는 방식에는 크게 3가지가 있습니다. (이 3가지 방법의 예제코드는 검색하면 무수히 많이 나옵니다.) 1)Spread Operator 2)Object.assign() 3)json.stringify
결론적으로 이런 방식으로 복사를 진행하고나면,
const obj = {
a : 1,
b : {
c : 2
}
}
obj === shallowCopied(obj) //false
obj.b.c === shallowCopied(obj).b.c //true
이렇게 중첩되어있는 객체의 경우에는 제대로된 복사가 이루어지지 않았다는 것을 알 수 있습니다. 원본 객체가 가지고 있던 객체를 그대로 참조하고 있습니다.
깊은 복사란 객체를 복사할 때, 원래의 값과 복사된 값이 메모리 상에서 다른 주소값을 가리키고 있는 것을 말합니다. 때문에 함수 내부의 모든 중첩된 객체들은 원본 객체와는 다른 참조값을 가지게 됩니다.
const obj = {
a : 1,
b : {
c : 2
}
}
obj === deepCopied(obj) //false
obj.b.c === deepCopied(obj).b.c //false
먼저 코어자바스크립트 책에서 소개한 깊은 복사코드에서부터 시작했습니다. 아래는 책에서 소개해준 코드입니다.
var copyObjectDeep = function(target){
var result = {};
if(typeof target === 'object' && target !== null){
for(var prop in target){
result[prop] = copyObjectDeep(target[prop]);
}
}else{
result = target;
}
return result;
}
그런데 이 코드에는 한계점이 있었습니다. 배열, Date, RegExp, Map, Set 등등의 타입들은 제대로 복사하지 못한다는 점이었습니다. (아마 책의 목적은 이런 데이터타입을 복사하는 것보다는 깊은 복사가 재귀적으로 이루어지는 것을 보여주는 것이었을 것 같습니다.) 테스트코드를 통해 확인해보았더니, 역시나 통과하지 못하는 타입들이 있었습니다.
때문에, 저는 이런 타입들도 복사할 수 있도록 코드를 다시 작성해나갈 필요가 있었습니다. 제가 테스트를 위해 사용한 예시 객체는 아래와 같습니다.
var obj = {
a: 1,
b: {
c: null,
d: [1, 2],
e: {
f: [1, 2, 3, 4],
},
g: new Function(),
h: new Date(),
i: new RegExp(),
j: Symbol("a"),
k: new Set([1, 2, 3, 4]),
l: new Map(),
m: 42 / +0, //Infinity
},
}
타입체크가 제가 처했던 첫 번째 문제상황이었습니다. 내장함수인 typeof 메서드만으로는 Map, Set, Date 등등의 타입들은 체크할 수가 없었습니다. 이런 타입들은 typeof로 체크를 하면 'object'를 보여줄 뿐이었습니다.
어떻게하면 타입체크를 제대로 할 수 있을까 고민하던 중 과연 lodash 라이브러리는 이런 타입들을 제대로 복사해내는지가 궁금해졌습니다. 그래서 확인해보았더니,
역시나 lodash는 제대로 복사해내고 있었습니다. 그렇단 말인즉 이들은 각각의 타입들을 구분해서 체크하고 복사하고 있다는 말일테니 한번 이들이 작성한 코드를 뜯어보기로 했습니다.
lodash 깃허브에서 파일을 클론받아 하나하나 코드를 확인해보던 중, 이런 단서를 발견했습니다.
뭔가 각각의 타입별로 구분해놓고 있는 것 같았습니다. 조금 더 추적을 해서 확인을 해보니, 이런 tag들을 통해서 분기처리를 하고 복사를 진행하는 것을 알 수 있었습니다. 이 지점에서 더 궁금해졌습니다. 그럼 이 tag들은 어떻게 뽑아내는 것일까? 조금 더 추적을 하다보니 이런 함수를 발견했습니다.
이런 방식으로 tag을 뽑아내고 있었습니다. 저로써는 참으로 신기하다고 느꼈습니다. (Phap Dinh 님께 감사...) toString을 자주 사용해본적 없던 저로써는 이렇게 타입을 구분할 수 있다는 사실을 처음알았습니다. 역시나 MDN에서도 소개하고 있는 방법이더군요.
이로써, 타입체크는 확실하게 가능해졌습니다. 이제 함수 내에서 if문을 통해 각각의 타입들을 확인하고, 그 타입들에 알맞도록 새로운 객체를 반환해주도록 하기만 하면될 것 같습니다.
각각의 타입들에 알맞게 복사해내는 것은 그렇게 어려운 일이 아니었습니다. 가만 생각해보니 오히려 밝혀지는 문제점은 테스트코드에 있었습니다. 아래는 단순하게 생각하고 작성했던 저의 테스트코드입니다.
it("value equality", () => {
expect(copyObjectDeep(obj)).toBe(obj)
})
it("reference equality - toEqual", () => {
expect(copyObjectDeep(obj)).toEqual(obj)
})
저의 코드가 문제가 되었던 점을 설명드리겠습니다. '깊은복사'라는 것은 복사된 객체와 원형 객체 내부의 모든 참조형 타입들이 서로 다른 참조값을 가지고 있어야 합니다.
let obj = {
a : 1,
map : new Map()
}
obj.a === deepCopied(obj).a //true
obj.map === deepCopied(obj).map //false
이런식으로 복사한 이후 객체 내부의 데이터영역의 값들은 같을지라도, 객체 값들은 서로 다른 참조값을 가지고 있어야 '깊은 복사'가 제대로 이루어집니다.
그런데 이런 테스트는 단순하게 jest에서 제공하는 메서드만으로는 해결되는 것이 아니었습니다.
jest를 사용해본 것이 처음인 저로써는 멋도모르고 '객체 비교하고 싶으면 toEqual 사용하세요' 라는 말만 듣고 toEqual만 사용했습니다. toEqual은 두 객체의 내용이 같은지 재귀적으로 비교해주는 메서드입니다. 때문에 이것으로는 두 객체 내부에 있는 객체가 다른 참조값을 가지고 있는지 확인할 수 없었습니다. 만약 두 객체가 다른 참조값을 가지는지 확인하고 싶다면 toBe 메서드를 사용해야합니다.
여기까지 생각에 이르니, 두개의 객체를 재귀적으로 순회하면서 도달한 property가 object 타입이라면 toBe 메서드로 비교해줘야겠다는 결론을 내리게 되었습니다.
객체를 순회하다보면 참조형이 아닌, 원시형 타입들도 같이 섞여있을 것이 다분합니다. 그런 상황에서도 not.toBe로 비교하게 된다면, 분명 테스트를 통과하지 못할 것입니다. 그렇기 때문에 원시형 타입일 경우에는 비교를 하지 않도록 통과시켰습니다.
모든 참조형 타입은 typeof로 확인하면 object 타입으로 나옵니다. 그렇다면 타입이 object일 때, 비교를 진행하면되는 것입니다. 두 객체가 다른 참조값을 가지고 있는지 확인합니다. 그리고 참조형이기때문에 재귀를 통해 다시 그 내부를 확인해줍니다.
주의해야 할 부분이 있었습니다. null 타입도 typeof로 확인하면 'object'타입이 나옵니다. (null의 타입이 object로 이유가 궁금하다면 여기를 확인해보세요. 이는 자바스크립트의 버그라고 합니다.) 때문에 null 타입이 아니라는 것도 같이 확인하면서 object를 확인해야했습니다.
아래는 결과 코드입니다.
function compareObject(target1, target2) {
// 객체인지 확인. null도 꼭 같이 체크합니다.
const isObject =
typeof target1 === "object" &&
typeof target2 === "object" &&
target1 !== null &&
target2 !== null
if (isObject) {
expect(target1).not.toBe(target2) // 서로 다른 참조값을 가지는지 비교
for (const prop in target1) {
compareObject(target1[prop], target2[prop]) // 재귀적 비교진행
}
} else {
return // 원시형이므로 그냥 return
}
}
이것을 이제 이 코드를 통해서 객체 내부의 참조형 타입들이 서로 다른 타입을 가지고 있는지 확인할 수 있게 되었고, 값들이 제대로 복사되었는지 테스트하기만한다면 깊은복사 테스트는 제대로 이루어졌다고 할 수 있을 것 같습니다.
it("should have different reference - my util", () => {
compareObject(obj, copyObjectDeep(obj))
})
it("should have value equality in object - toEqual", () => {
expect(copyObjectDeep(obj)).toEqual(obj)
})
이렇게 테스트를 통과한 것을 확인할 수 있습니다.
드디어 객체 내의 다양한 타입들을 복사해내고, 테스트코드까지 작성했습니다. 그런데, 막상 코드들을 작성해놓고보니 뭔가 함수가 방대하고, 지저분하다는 생각이 듭니다. 아래는 제가 작성하게 된 코드입니다.
function getTag(value) {
if (value == null) {
return value === undefined ? "[object Undefined]" : "[object Null]"
}
return toString.call(value)
}
const objTag = "[object Object]"
const arrTag = "[object Array]"
const dateTag = "[object Date]"
const mapTag = "[object Map]"
const regexpTag = "[object RegExp]"
const setTag = "[object Set]"
const copyObjectDeep = target => {
const tag = getTag(target)
if (tag === objTag) {
let result = {}
for (const prop in target) {
result[prop] = copyObjectDeep(target[prop])
}
return result
}
if (tag === arrTag) {
let result = []
for (const prop of target) {
result.push(copyObjectDeep(prop))
}
return result
}
if (tag === dateTag) {
let date = target.constructor
return new date(+target)
}
if (tag === setTag) {
let copiedSet = new Set()
for (const prop of target) {
copiedSet.add(copyObjectDeep(prop))
}
return copiedSet
}
if (tag === mapTag) {
let copiedMap = new Map()
for (const [key, value] of target) {
copiedMap.set(key, copyObjectDeep(value))
}
return copiedMap
}
if (tag === regexpTag) {
const reFlags = /\w*$/
const copiedRegExp = new target.constructor(
target.source,
reFlags.exec(target)
)
copiedRegExp.lastIndex = target.lastIndex
return copiedRegExp
}
return target
}
exports.copyObjectDeep = copyObjectDeep
뭔가 문제가 있어보이지만, 이 문제를 해결하려면 무엇이 문제인지 그 기준을 분명하게 세울 필요가 있습니다. 저는 이번에 이 코드에서 클린코드를 위해 관심사의 분리 SOC(Seperation Of Concern) 원칙을 적용해보려합니다.
관심사의 분리를 적용하고자 한다면, 이것이 무엇인지 알 필요가 있습니다. 몇가지 자료들을 찾아보면서 제가 정리하게 된 핵심 내용은 다음 2가지였습니다.
1)More cohesion 2)Less coupling Bonus)왜,무엇을?
cohesion이라는 단어는 '응집력'이라는 뜻을 가지고 있습니다. 더 많은 응집력을 가지도록 하라는 말은 하나의 함수, 클래스 내부에 최대한 관련 있는 것들 위주로 묶어두어야한다는 것입니다. 그 관련성이 깊으면 깊을수록 좋다는 의미가 되겠습니다. 이런 원칙을 지켜나가다보면, 자연스럽게 '하나의 함수에선 하나의 기능만 작성하도록한다'는 원칙도 지켜질 것 같습니다.
coupling이라는 단어는 '결합도' 혹은 '의존도'라는 뜻을 가지고 있습니다.그러니까 하나의 함수는 가능하면 다른 함수들과의 결합도 의존도를 가지지 않도록 한다는 것입니다. 하나의 함수에서 일어난 변화가 다른 함수에서의 변화에 영향을 끼쳐서는 안 됩니다. 이런 식으로 각각의 함수 사이에 의존도가 낮아지면 낮아질 수록 관심사의 분리가 잘 이루어진 코드라고 할 수 있을 것 같습니다.
이렇게 SOC(Seperation Of Concerns)의 두가지 큰 개념을 알아보았습니다. 실제로 이것을 적용시키려 할 때 도움이 되는 질문이 있습니다. 바로 이 함수를 '왜' 작성하는지, 이 함수는 '무엇'을 하는지 질문하는 것입니다. 이런 질문들을 거듭해나가면서 우리는 관심사의 분리를 이루어낼 수 있습니다. 저의 코드를 통해서 하나씩 적용해나가보겠습니다.
리팩토링을 시작하기전 질문을 해보았습니다.
우선 여기서 나누어져야할 것이 밝혀졌습니다. 제가 작성한 함수에는 타입이라는 관심사가 몰려있는 것을 확인할 수 있습니다. 때문에 저는 이것을 나누어야 합니다.
const cloneObjectDeep = target => {
const tag = getTag(target)
if (tag === objTag) {
let result = {}
for (const prop in target) {
result[prop] = copyObjectDeep(target[prop])
}
return result
}
if (tag === arrTag) {
let result = []
for (const prop of target) {
result.push(copyObjectDeep(prop))
}
return result
}
if (tag === dateTag) {
let date = target.constructor
return new date(+target)
}
if (tag === setTag) {
let copiedSet = new Set()
for (const prop of target) {
copiedSet.add(copyObjectDeep(prop))
}
return copiedSet
}
if (tag === mapTag) {
let copiedMap = new Map()
for (const [key, value] of target) {
copiedMap.set(key, copyObjectDeep(value))
}
return copiedMap
}
if (tag === regexpTag) {
const reFlags = /\w*$/
const copiedRegExp = new target.constructor(
target.source,
reFlags.exec(target)
)
copiedRegExp.lastIndex = target.lastIndex
return copiedRegExp
}
return target
}
이것을 위해서 저는 각각의 데이터 타입들을 복사하는 함수들을 따로따로 만들었습니다.
function cloneArray(target, recursiveFunc) {
let result = []
target.forEach(prop => {
result.push(recursiveFunc(prop))
})
return result
}
function cloneObject(target, recursiveFunc) {
let result = {}
for (const prop in target) {
result[prop] = recursiveFunc(target[prop])
}
return result
}
function cloneSet(target, recursiveFunc) {
let copiedSet = new Set()
for (const prop of target) {
copiedSet.add(recursiveFunc(prop))
}
return copiedSet
}
function cloneDate(target) {
let date = target.constructor
return new date(+target)
}
function cloneMap(target, recursiveFunc) {
let copiedMap = new Map()
for (const [key, value] of target) {
copiedMap.set(key, recursiveFunc(value))
}
return copiedMap
}
function cloneRegExp(target) {
const reFlags = /\w*$/
const copiedRegExp = new target.constructor(
target.source,
reFlags.exec(target)
)
copiedRegExp.lastIndex = target.lastIndex
return copiedRegExp
}
이제 이 각각의 함수들은 최대한 낮은 의존도와 응집성을 가지게 되었습니다.(제 기준..) 간단히 함수들을 설명해보자면, 그 안에서 타입에 알맞은 새로운 객체를 만들고 값을 넣어줍니다. 그 안에서 또 다른 객체타입을 가질 수 있는 타입의 경우에는 recursiveFunc 인자를 통해서 재귀적으로 복사할 수 있도록 만들어주었습니다. 이 함수의 why, what은 다음과 같습니다.
이제 위에서 각 타입별로 사용할 함수를 작성했으니, 이것을 적용할 함수가 필요합니다. switch문을 통해 각 타입들을 분리하는 함수를 작성합니다.
function cloneByTag(target, recursiveFunc) {
const tag = getTag(target)
switch (tag) {
case objTag:
return cloneObject(target, recursiveFunc)
case arrTag:
return cloneArray(target, recursiveFunc)
case dateTag:
return cloneDate(target)
case setTag:
return cloneSet(target, recursiveFunc)
case mapTag:
return cloneMap(target, recursiveFunc)
case regexpTag:
return cloneRegExp(target)
default:
return target
}
}
getTag라는 함수를 통해 객체가 가지고 있는 tag를 뽑아냅니다. 이 tag를 통해 객체 타입을 구분하고 각각에 알맞는 내용을 return 합니다. 이 함수의 why, what은 다음과 같습니다.
function getTag(value) {
return toString.call(value)
}
이 함수의 why, what은 다음과 같습니다.
const cloneObjectDeep = target => {
return cloneByTag(target, cloneObjectDeep)
}
이 함수의 why, what은 다음과 같습니다.
사실 이렇게 리팩토링을 하고나서도 남아있는 의문점이 있습니다. 우선 if문에 들어가있던 각각의 기능들을 함수단위로 구분한 것까지는 괜찮다는 생각이 들었습니다. 그런데 몇가지 의문점이 있었습니다.
cloneByTag 함수 같은 경우에는 이 하나의 함수 안에서 1)태그를 뽑아내고, 2)타입을 체크한다는 2가지 기능을 동시에 수행하는 것이 아쉽게 느껴졌습니다. 사실 그 이후로 어떻게 더 분리해내야 할지에 대한 부분은 의문점으로 남아 더 이상 진행하지 못했습니다. 현재 저의 수준에선 최대한의 cohesion을 남겨두었다고 생각했는데, 깊은 복사 수행을 위해서 더 나아간 분리가 가능할까? 그런 의문이 들었습니다.
cloneObjectDeep 함수 내부에는 cloneByTag만 덜렁 남아버렸습니다. 이것이 이 함수의 기능을 잘 표현해주는 것일까 의문이 듭니다. 1)타입을 구분한다 2)복사를 수행한다. 의 개념을 구분하고 싶었기에 이런 함수가 된 것 같습니다. '복사를 수행한다. 그때 필요한 동작에는 타입 구분이 있어서, 타입 구분 함수를 이 최상위 부모 함수 안에 가져왔다.' 정답이 없는 문제이겠지만, 계속해서 의문점은 남아있습니다. 내가 작성한 방식이 why와 what을 명료하게 남겨둔 방식일까??
깊은 복사 유틸을 작성하고,리팩토링을 진행하면서 배우게 된 것들이 있습니다. 이것들을 정리하며 글을 마무리하려 합니다.
관심사의 분리라는 개념을 어렴풋하게만 이해하고 있었습니다. 이 기회를 통해 다양한 자료를 찾아보았고, 그 결과 명쾌한 2가지 키워드를 얻어낼 수 있었습니다. more cohesion, less coupling. 물론 이런 키워드를 알고 이해했다 일 뿐이지, 이것이 제게 '훈련'된 것은 아님으로 계속해서 연습해나가야할 것 같습니다.
제가 수행하고자 하는 동작이 있을 때, 그것이 잘 안되면 잘 한 사람의 것을 살펴보면 됩니다. 검색하기가 애매해다고 느껴지거나 검색해도 잘 나오지 않는다면, 똑같은 기능을 수행하는 라이브러리를 찾아보고 그 라이브러리는 어떻게 구현했는지 내부 코드를 뜯어보면 됩니다. 이번에는 lodash 라이브러리를 살펴봄으로써, 어떻게 다양한 객체 타입을 구분할 수 있는지 배울 수 있었습니다.
프론트엔드 입장에서 테스트코드를 작성할 일이 많지는 않았습니다. 그러나 멘토님께 테스트코드를 작성할 줄 아는데 안하는것과 할 줄 모르는데 안하는 것은 다르다는 조언을 들었습니다. 이번 기회에 유틸 함수에 대한 테스트 코드를 작성해보았는데, 테스트 케이스를 작성해놓고 하나씩 통과시켜나가는 쾌감이 있다는 것을 알게 되었습니다.
전체 코드가 궁금하시다면 제 깃허브를 확인해주세요.