변경 가능한 데이터 구조를 가진 언어에서 불변성 유지하기
불변성을 지키면 데이터 변경으로 인한 예측 불가능한 동작을 피하고,
코드를 더 이해하기 쉽고 안전하게 만들 수 있습니다.
이를 위해 흔히 사용하는 기법이 카피-온-라이트(copy-on-write) 입니다.
스터디 회차: 5회차 (2025년 8월 21일)
카피-온-라이트 원칙 (copy-on-write)
- 복사본 만들기
- 복사본 변경하기
- 복사본 리턴하기
이 과정을 통해 원본은 그대로 두고, 새로운 데이터 구조를 만들어 불변성을 유지하면서 값을 바꿀 수 있습니다.
예제: 이름으로 아이템 제거하기
tsx
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);
}
문제점
splice
는 원본 배열을 변경하기 때문에 불변성이 깨집니다.- 이 함수는 호출 시점에 따라 다른 결과가 나오므로 액션이 됩니다.
개선하기
tsx
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
에 카피-온-라이트 원칙을 적용합니다.- cart를 복사하여 지역변수를 생성합니다.
- 복사본에 대해서 수정합니다.
- 수정된 복사본을 리턴합니다.
이렇게 하면 원본을 변경하지 않고, 불변성을 유지한 상태에서 데이터를 수정할 수 있습니다.
읽기도하고 쓰기도하는 함수 분리하기
예제: 자바스크립트의 shift()
메서드
tsx
var a = [1, 2, 3, 4];
var b = a.shift();
console.log(b); // 1
console.log(a); // [2,3,4] (원본이 변경됩니다.)
어떤 동작은 읽고 변경하는 일을 동시에 합니다. 이 경우 두가지 개선 방법이 있습니다.
1. 읽기와 쓰기 동작으로 분리하기
tsx
function first_element(array) {
return array[0]; // 읽기 -> 계산
}
function drop_first(array) {
var array_copy = array.slice();
array_copy.shift(); // 쓰기 -> 카피온라이트
return array_copy;
}
- 읽기 동작은 단순히 리턴하는 동작으로, 아무것도 바꾸지 않고 숨겨진 입력이나 출력이 없기 때문에 계산입니다.
shift
메서드가 하는 일 그대로 감싸주어 카피온 라이트를 적용합니다.- 읽기와 쓰기를 분리하는 접근 방법은 분리된 함수를 따로 쓸 수 있기 때문에 더 좋은 접근 방법입니다.
2. 값을 두 개 리턴하는 함수로 만들기
tsx
function shift(array) {
var array_copy = array.slice();
var first = array_copy.shift();
return {
first: first,
array: array_copy,
};
}
- 인자를 복사한 후에 첫번째 항목을 지우고, 변경된 배열과 함께 리턴합니다.
- 기존
shift
와 동일한 사용성(값과 배열을 함께 얻음)을 제공하면서도 불변성을 지킵니다.
불변 데이터 구조를 읽는건 계산입니다.
- 변경 가능한 데이터를 읽는건 액션입니다.
- 쓰기는 데이터를 바꾸기 때문에 데이터를 변경 가능한 구조로 만듭니다.
즉, 쓰기는 액션을 만듭니다.
- 쓰기를 모두 없앴다면 데이터는 바뀌지 않습니다. 불변 데이터입니다.
- 데이터를 불변형으로 만들었다면 그 데이터 읽기는 계산입니다.
따라서, 쓰기를 읽기로 만들수록 (데이터 구조를 불변형으로 만들수록) 더 많은 계산이 생기고 액션이 줄어듭니다.
중첩된 쓰기를 읽기로 바꾸기
예제: 장바구니에서 이름으로 가격 변경하기
tsx
function setPriceByName(cart, name, price) {
for (var i = 0; i < cart.length; i++) {
if (cart[i].name === name) cart[i].price = price;
}
}
문제점
cart
를 인자로 받지만, 주소값이 복사되므로 값을 변경시 원본이 변경됩니다.
개선하기
tsx
function setPrice(item, new_price) {
var item_copy = Object.assign({}, item); // 객체 복사
item_copy.price = new_price;
return item_copy;
}
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;
}
shopping_cart = setPriceByName(shopping_cart, "t-shirt", 13); // 복사된 배열 적용
- 가장 안쪽에 있는
setPrice
에서 발생하는 쓰기 동작부터 바꾸는 것이 쉽습니다. setPrice
에 먼저 카피-온-라이트를 적용하고setPriceByName
에 적용합니다.
원본을 수정하지 않고 복사하기
카피-온-라이트에서 원본을 수정하지 않고 복사하는 방법은 다음과 같습니다.
tsx
// 배열 복사하기
const copyArr = arr.slice();
// 객체 복사하기
const copyObj = Object.assign({}, obj);
얕은 복사 (shallow copy) 와 구조적 공유 (structural sharing)
예제: 장바구니에서 이름으로 가격 변경하기
tsx
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;
}
function setPrice(item, new_price) {
var item_copy = Object.assign({}, item);
item_copy.price = new_price;
return item_copy;
}
카피-온-라이트가 적용된 코드에서 실제로 복사된 것은 다음과 같습니다.
- 배열 하나 (
cartCopy
) - 조건에 맞는 객체 하나
나머지 객체들은 원본과 복사본이 같은 참조를 공유합니다. 이를 "구조적 공유"라고 합니다.
구조적 공유
- 복사된 배열도 원래 객체를 가리킵니다.
- "얕은 복사"를 통해 객체 모두를 복사하지 않고 참조값만 복사합니다.
- 변경할 객체만 복사하여 변경합니다.
- 변경된 객체는 복사된 배열에 적용합니다.
- 이러한 과정중에서도 원본 객체와 배열은 변경되지 않았습니다.
따라서, 최소한의 복사와 메모리 활용으로 불변성을 유지하면서 값을 변경할 수 있습니다.
구조적 공유
구조적 공유란 두 개의 중첩된 데이터 구조가 어떤 참조를 공유하는 것을 의미합니다.
- 데이터가 바뀌지 않는 불변 데이터 구조라면 구조적 공유는 안전합니다.
- 구조적 공유는 메모리를 적게 사용하고 모든 것을 복사하는 것보다 빠릅니다.
정리하기
- 카피-온-라이트를 적용하면 원본은 그대로 두고 복사본을 변경할 수 있습니다.
- 읽기와 쓰기를 분리하거나, 두 값을 리턴하는 방식으로 동작을 명확히 할 수 있습니다.
- 중첩된 구조에서는 안쪽부터 불변성 적용 → 구조적 공유를 통해 효율적으로 불변성을 지킬 수 있습니다.