본문으로 건너뛰기

06 변경 가능한 데이터 구조를 가진 언어에서 불변성 유지하기

이번 장에서 살펴볼 내용
  • 데이터가 바뀌지 않도록 하기 위해 카피-온-라이트를 적용합니다.
  • 배열과 객체를 데이터에 쓸 수 있는 카피-온-라이트 동작을 만듭니다.
  • 깊이 중첩된 데이터도 카피-온-라이트가 잘 동작하게 만듭니다.

모든 동작을 불변형으로 만들 수 있나요?

아래는 앞으로 카피-온-라이트를 적용해야 하거나 적용할지도 모르는 장바구니와 제품에 대한 동작입니다.

장바구니에 대한 동작

  1. 제품 개수 가져오기 (읽기)
  2. 제품 이름으로 제품 가져오기 (읽기)
  3. 제품 추가하기 (쓰기)
  4. 제품 이름으로 제품 빼기 (쓰기)
  5. 제품 이름으로 제품 구매 수량 바꾸기 (중첩된 데이터에 대한 동작)(쓰기)

다섯 번째 동작은 장바구니 안에 제품의 정보를 바꿔야해서 어려울 것 같습니다. 이런 것을 중첩된 데이터라고 합니다. 어떻게 하면 중첩된 데이터에 대한 불변 동작을 구현할 수 있을까요?

동작을 읽기, 쓰기 또는 둘 다로 분류하기

노트

읽기

  • 데이터에서 정보를 가져옵니다.
  • 데이터를 바꾸지 않습니다.

쓰기

  • 데이터를 바꿉니다.

장바구니 동작 중 세 개는 쓰기 동작입니다. 쓰기 동작은 불변성 원칙에 따라 구현해야 합니다. 앞에서 본 것처럼 불변성 원칙은 카피-온-라이트라고 합니다. 하스켈이나 클로저 같은 언어도 이 원칙을 쓰고 있습니다. 이 언어들은 불변성이 언어에 이미 구현되어 있다는 것이 다릅니다.

자바스크립트는 기본적으로 변경 가능한 데이터 구조를 사용하기 때문에 불변성 원칙을 적용하려면 직접 구현해야 합니다.

읽으면서 쓰는 동작은 어떨까요? 어떤 경우에는 데이터를 바꾸면서 동시에 정보를 가져오는 경우도 있습니다.

카피-온-라이트 원칙 세 단계

카피-온-라이트는 세 단계로 되어 있습니다. 각 단계를 구현하면 카피-온-라이트로 동작합니다.

  1. 복사본 만들기
  2. 복사본 변경하기
  3. 복사본 리턴하기

지난장에서 구현한 add_element_last() 함수를 다시 봅시다.

function add_element_last(array, elem) {
var new_array = array.slice();
new_array.push(elem);
return enw_array;
}

add_element_last() 함수는 읽기일까요, 쓰기일까요?

데이터를 바꾸지 않았고 정보를 리턴했기 때문에 읽기입니다! 우리는 쓰기를 읽기로 바꿨습니다.

카피-온-라이트로 쓰기를 읽기로 바꾸기

아래는 제품 이름으로 장바구니에서 제품을 빼는 함수입니다.

function remove_item_by_name(cart, name) {
var idx = null;
for (var i = 0; i < cart.length; i++) {
if (cart[i].name === name) {
idx = i;
}
}
if (idx !== null) {
cart.splice(idx, 1);
}
}

이 함수는 장바구니를 변경합니다. 만약 remove_item_by_name() 함수에 전역변수 shopping_cart를 넘기면 전역변수인 장바구니가 변경됩니다.

카피-온-라이트를 적용해 봅시다. 가장 먼저 할 일은 장바구니를 복사하는 일입니다. 다음으로 복사본을 리턴해보겠습니다.

function remove_item_by_name(cart, name) {
var new_cart = cart.slice();
var idx = null;
for (var i = 0; i < new_cart.length; i++) {
if (new_cart[i].name === name) {
idx = i;
}
}
if (idx !== null) {
new_cart.splice(idx, 1);
}
return new_cart;
}

remove_item_by_name을 카피-온-라이트 버전으로 바꿨습니다. 이제 이 함수를 사용하던 곳을 바꾸는 일만 남았습니다.

function delete_handler(name) {
shopping_cart = remove_item_by_name(shopping_cart, name);
// ...
}

앞에서 만든 카피-온-라이트 동작은 일반적입니다.

앞으로 적용할 카피-온-라이트 동작도 앞에서 만든 동작과 비슷합니다. 그래서 add_element_last() 함수처럼 재사용하기 쉽도록 일반화할 수 있습니다.

배열에 .splice() 메서드를 일반화해 봅시다.

원래 코드
function removeItems(array, idx, count) {
array.splice(idx, count);
}
카피-온-라이트를 적용한 코드
function removeItems(array, idx, count) {
var copy = array.splice();
copy.splice(idx, count);
return copy;
}

이런 작업은 많이 사용하기 때문에 재사용할 수 있도록 만들면 나중에 고생을 하지 않아도 됩니다.

쓰기를 하면서 읽기도 하는 동작은 어떻게 해야 할까요?

어떤 동작은 읽고 변경하는 일을 동시에 합니다. 이런 동작은 값을 변경하고 리턴합니다. .shift() 메서드가 좋은 예제인데 한 번 살펴봅시다.

shift
var a = [1,2,3,4];
var b = a.shift();
console.log(b); // 1
console.log(a); // [2,3,4]

카피-온-라이트로 어떻게 바꿀 수 있을까요? 두 가지 접근 방법이 있을까요?

  1. 읽기와 쓰기 함수로 각각 분리한다.
  2. 함수에서 값을 두 개 리턴한다.

선택할 수 있다면 첫 번째 접근 방법이 더 좋은 방법입니다. 책임이 확실히 분리되기 때문입니다.

쓰면서 읽기도 하는 함수를 분리하기

먼저 쓰기에서 읽기를 분리합니다. 다음으로 쓰기에 카피-온-라이트를 적용해 읽기로 바꿉니다.

읽기와 쓰기 동작으로 분리하기

.shift() 메서드가 리턴하는 값은 배열에 첫 번째 항목입니다. 따라서 배열에 첫 번째 항목을 리턴하는 계산 함수를 만들면 됩니다. 이렇게 만든 함수는 읽기 동작만 할 뿐, 아무것도 바꾸지 않습니다.

function first_element(array) {
return array[0];
}

first_element() 함수는 배열을 바꾸지 않는 읽기 함수이기 때문에 카피-온-라이트를 적용할 필요가 없습니다.

.shift() 메서드의 쓰기 동작은 새로 만들 필요가 없습니다. .shift() 메서드가 하는 일을 그대로 감싸기만 하면 됩니다.

function drop_first(array) {
array.shift();
}

쓰기 동작을 카피-온-라이트로 바꾸기

function drop_first(array) {
var array_copy = array.slice();
array_copy.shift();
return array_copy;
}

읽기와 쓰기를 분리하는 접근 방법은 분리된 함수를 따로 쓸 수 있기 때문에 더 좋은 접근 방법입니다. 물론 함께 쓸 수도 있습니다. 원래는 무조껀 함께 쓸 수밖에 없었지만 이제 선택해서 쓸 수 있습니다.

값을 두 개 리턴하는 함수로 만들기

첫 번째 접근 방법처럼 두 번째 접근 방법도 두 단계로 나눌 수 있습니다. 먼저 .shift() 메서드를 바꿀 수 있도록 새로운 함수로 감쌉니다. 다음으로 읽기와 쓰기를 함께 하는 함수를 읽기만 하는 함수로 바꿉니다.

동작을 감싸기

첫 번째 단계는 .shift() 메서드를 바꿀 수 있도록 새로운 함수로 감싸는 것입니다.

function shift(array) {
return array.shift();
}

읽으면서 쓰기도 하는 함수를 읽기 함수로 바꾸기

인자를 복사한 후에 복사한 값의 첫 번째 항목을 지우고, 지운 첫 번째 항목과 변경된 배열을 함게 리턴하도록 바꿉니다.

function shift(array) {
var array_copy = array.slice();
var first = array_copy.shift();
return {
first,
array: array_copy
}
}

다른 방법

다른 방법으로는 첫 번째 접근 방식을 사용해 두 값을 객체로 조합하는 방법입니다.

function shift(array) {
return {
first: first_element(array),
array: drop_first(array)
}
}

첫 번째 접근 방법으로 만든 두 함수는 모두 계산이기 때문에 쉽게 조합할 수 있습니다. 조합해도 이 함수는 계산입니다.

불변 데이터 구조를 읽는 것은 계산입니다.

변경 가능한 데이터를 읽는 것은 액션입니다.

변경 가능한 값을 읽을 때마다 다른 값을 읽을 수도 있습니다. 따라서 변경 가능한 데이터를 읽는 것은 액션입니다.

쓰기는 데이터를 변경 가능한 구조로 만듭니다.

쓰기는 데이터를 바꾸기 때문에 데이터를 변경 가능한 구조로 만듭니다.

어떤 데이터에 쓰기가 없다면 데이터는 변경 불가능한 데이터입니다.

쓰기를 모두 없앴다면 데이터는 생성 이후 바뀌지 않습니다. 따라서 불변 데이터입니다.

불변 데이터 구조를 읽는 것은 계산입니다.

어떤 데이터를 불변형으로 만들었다면 그 데이터에 모든 읽기는 계산입니다.

쓰기를 읽기로 바꾸면 코드에 계산이 많아집니다.

데이터 구조를 불변형으로 만들수록 코드에 더 많은 계산이 생기고 액션은 줄어듭니다.

불변 데이터 구조는 충분히 빠릅니다.

일반적으로 불변 데이터 구조는 변경 가능한 데이터 구조보다 메모리를 더 많이 쓰고 느립니다. 하지만 불변 데이터 구조를 사용하면서 대용량의 고성능 시스템을 구현하는 사례는 많이 있습니다.

언제든 최적화할 수 있습니다.

애플리케이션을 개발할 때 예상하기 힘든 병목 지점이 항상 있습니다. 그래서 성능 개선을 할 때는 모통 미리 최적화하지 말라고 합니다.

불변 데이터 구조를 사용하고 속도가 느린 부분이 있다면 그때 최적화하세요.

가비지 콜렉터는 매우 빠릅니다.

대부분의 언어는 가비지 콜렉터 성능 개선을 위해 꾸준히 연구해 왔습니다. 어떤 가비지 콜렉터는 한두 개의 시스템 명령어로 메모리를 비울 수 있을 만큼 최적화되었습니다.

생각보다 많이 복사하지 않습니다.

데이터 구조의 최상위 단계만 복사하는 것을 얕은 복사라고 합니다. 얕은 복사는 같은 메모리를 가리키는 참조에 대한 복사본을 만듭니다. 이것을 구조적 공유라고 합니다.

함수형 프로그래밍 언어에는 빠른 구현체가 있습니다.

앞에서는 직접 불변 데이터 구조를 만들었습니다. 하지만 어떤 함수형 프로그래밍 언어에서 불변 데이터 구조를 지원합니다. 그리고 직접 만든 것보다 더 효율적으로 동작합니다.

데이터 구조를 복사를 할 때 최대한 많은 구조를 공유합니다. 그래서 더 적은 메모리를 사용하고 결국 가비지 콜렉터의 부담을 줄여줍니다. 구현은 우리가 한 것과 같은 카피-온-라이트를 기반으로 하고 있습니다.

객체에 대한 카피-온-라이트

  1. 복사본 만들기
  2. 복사본 변경하기
  3. 복사본 리턴하기

배열은 .slice() 메서드로 복사본을 만들 수 있었습니다. 자바스크립트 객체는 Object.assign()을 사용하면 됩니다.

용어 설명

**얕은 복사(shallow copy)**는 중첩된 데이터 구조에 최상위 데이터만 복사합니다. 예를 들어 객체가 들어 있는 배열이 있다면 얕은 복사는 배열만 복사하고 안에 있는 객체는 참조로 공유합니다.

두 개의 중첩된 데이터 구조가 어떤 참조를 공유한다면 **구조적 공유(structural sharing)**라고 합니다. 데이터가 바뀌지 않는 불변 데이터 구조라면 구조적 공유는 안전합니다. 구조적 공유는 메모리를 적게 사용하고, 모든 것을 복사하는 것보다 빠릅니다.

중첩된 쓰기를 읽기로 바꾸기

제품 이름으로 해당 제품의 가격을 바꾸는 쓰기 동작을 읽기 동작으로 바꿔보겠습니다.

function setPriceByName(cart, name, price) {
var cartCopy = cart.slice();
for(var i = 0; i < cartCopy.length; i++) {
if (cartCopy[i].name === name) {
cartCopy[i] = setPrice(cartCopy[i], price);
}

return cartCopy;
}
}

중첩된 쓰기도 중첩되지 않은 쓰기와 같은 패턴을 사용합니다. 복사본을 만들고 변경한 다음 복사본을 리턴합니다. 중첩된 항목에 또 다른 카피-온-라이트를 사용하는 부분만 다릅니다.

중첩된 모든 데이터 구조가 바뀌지 않아야 불변 데이터라고 할 수 있습니다.

최하위부터 최상위까지 중첩된 데이터 구조의 모든 부분이 불변형이어야 합니다. 중첩된 데이터의 일부를 바꾸려면 변경하려는 값과 상위의 모든 값을 복사해야 합니다.

구조적 공유에서 공유된 복사본이 변경되지 않는 한 안전합니다. 값을 바꿀 때는 복사본을 만들기 때문에 공유된 값은 변경되지 않는다고 확신할 수 있습니다.

결론

클로저나 하스켈 같은 언어에는 기본적으로 카피-온-라이트를 지원하지만, 자바스크립트에서는 카피-온-라이트 원칙을 직접 구현해줘야 했습니다. 그리고 유틸리티 함수로 만들어 나중에 편리하게 쓸 수 있도록 했습니다. 앞으로 카피-온-라이트를 적용해야 할 때 이 함수를 계속 사용하면 문제없을 것입니다. 다른 고민 없이 이 함수들을 사용하기 때문에 원칙이라고 부릅니다.

요점 정리

  • 함수형 프로그래밍에서 불변 데이터가 필요합니다. 계산에서는 변경 가능한 데이터에 쓰기를 할 수 없습니다.
  • 카피-온-라이트는 데이터를 불변형으로 유지할 수 있는 원칙 입니다. 복사본을 만들고 원본 대신 복사본을 변경하는 것을 말합니다.
  • 카피-온-라이트는 값을 변경하기 전에 얕은 복사를 합니다. 그리고 리턴합니다. 이렇게 하면 통제 할 수 있는 범위에서 불변성을 구현할 수 있습니다.
  • 보일러 플레이트 코드를 줄이기 위해 기본적인 배열과 객체 동작에 대한 카피-온-라이트 버전을 만들어 두는 것이 좋습니다.