6장과 7장에서는 본격적으로 불변성을 지키기 위해 데이터를 복사할 때 필요한 Copy on Write방어적 복사를 다룬다. 자바스크립트의 내장 메소드는 예전 문법일수록 복사본을 만드는 형태가 아닌, 원본을 조작하는 형태인 경우가 많다.

예를 들어, 배열 메소드 중 push()를 보자.

let arr1 = [1,2,3,4];
let arr2 = arr1;

arr2.push(5); // arr1과 arr2 모두에 5가 추가된다.
console.log(arr1 === arr2); // true

예시처럼 분명 복사한 배열인 arr2에 5를 추가하려고 했음에도 arr1까지 5가 추가된다. 이처럼 특히 원본을 조작할 가능성이 있는 자바스크립트 언어에서는 이 두 장이 매우 중요한 부분이었다.

불변성 유지하기


1. 불변성


1-1. 불변 데이터 구조

데이터는 외부의 작용이 없다면 변하지 않는다. 그렇지만 프로그래밍 작업을 할 때 영원히 데이터를 변경하지 않을 수는 없다. 그러므로 쓰기가 적용되는 데이터는 변경이 가능한 구조가 된다. 그리고 변경 가능한 데이터를 읽는 게 또 하나의 액션이다.

그럼 반대로 쓰기가 적용된 데이터가 없다면 그 데이터는 변경 불가능한 "불변 데이터 구조" 라고 할 수 있다. 불변 데이터 구조에서는 읽기만 가능하며, 모든 읽기는 계산이다.

1-2. 시간에 따라 변화하는 데이터

이미 언급했듯이 영원히 모든 데이터를 변경하지 않고 읽기만 할 수는 없다. 시간이 흘러 사용자가 데이터를 변경하고 싶을 때는 어떻게 해야할까?

교체하기

당연하다고 생각할 지도 모르겠다. 이미 있는 데이터를 최신 데이터로 교체하면 된다. 교체하는 방법은 함수형 프로그래밍에서 일반적으로 쓰는 방법이라고 하는데, 이에 관해서는 파트 2에서 더 자세히 논해보도록 하자.



2. Copy on Write, 안전한 복사


2-1. 3단계 원칙

카피-온-라이트는 세 단계로 된 원칙만 구현하면 동작한다고 한다. 대체 어떤 원칙이길래 그것만 지키면 카피-온-라이트 방식으로 동작한다고 단언할 수 있을까?

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

생각보다 정말 별 거 없다. 복사본을 만들고, 필요한 만큼 복사본을 조작하고, 그걸 리턴하면 끝이다. '물은 물이요, 산은 산이로다' 같은 원칙이라고밖에는 할 말이 없다. 그럼에도 이 세 단계 원칙이 있다는 건 이걸 지키기가 그만큼 어렵다는 의미로 다가온다.

카피-온-라이트를 해야하는 첫번째 이유는 "쓰기(데이터 변경 有)를 읽기(데이터 변경 無)로 바꾼다"는 사실이다. 즉, 데이터를 변경하는 함수를 내부적으로 복사본을 만들어 처리함으로써 실질적으로는 데이터를 읽어오고 원본을 변경하지 않는 형태로 관리할 수 있게 된다는 것이다.

위에서 든 push()의 예시를 생각해보자.

let arr1 = [1,2,3,4];
let arr2 = arr1.slice(); // slice는 복사본을 만드는 메소드다.

arr2.push(5);
console.log(arr1 === arr2); // false

바뀐 건 단 하나, let arr2 = arr1;let arr2 = arr1.slice();로 메소드 하나만 추가했을 뿐인데 두 배열이 같냐는 판단에서 전혀 다른 결과가 나오게 됐다.

이처럼 정말 중요하지 않은 작은 차이지만, 만약 다른 모듈에서 이 함수를 사용했다고 가정한다면 문제를 일으켜 버그가 될 지도 모른다.

객체도 동일한 단계를 지켜 복사본을 만들 수 있다. Object.assign() 함수를 활용하여 객체에 있는 모든 키와 값을 복사한 뒤에 똑같이 복사본을 반환하면 된다.


2-2. 읽고 쓰기가 동시에 이뤄지는 동작에 카피-온-라이트 적용하기

늘 그렇듯 하나의 동작이 두 가지 동작을 하는 건 함수형 프로그래밍에서 바람직하지 않다. 그렇기에 이를 위한 규칙 두 개가 있다.

  1. 읽기와 쓰기 함수로 각각 분리하기
  2. 함수에서 값을 두 개 반환하기

책에서는 shift() 내장 메소드를 예시로 들어 설명했다. shift() 메소드는 배열의 0번째 요소를 꺼내 반환하는 기능이다. 이 기능을 쪼개보면 0번째 원소를 찾는 것, 그리고 그 값을 반환하는 것으로 나눠진다. 사실, 이 메소드까지 쪼개서 쓰는 것보다는 배열을 복사하는 편이 낫다고 생각한다.

그래서 예시를 간략하게 정리해보면 아래와 같다.

const arr = [1,2,3,4,5];

// shift 사용 시
const firstElem = arr.shift(); // 1
console.log(arr); // [2,3,4,5]

// shift 쪼개기
function find_first_element(array) {
  return array[0];
}

function shiftArr(array) {
  const arrCopy = [...array];
  arrCopy.shift();
  return arrCopy;
}

불변성을 유지한다는 건 때로 언어가 가진 한계조차도 극복해야하는 일이었다. 다만, 책에서도 이렇게 카피-온-라이트를 적용하기 위한 품이 많이 들기 때문에 필요한 시점에 이를 적용한 함수를 만들도록 추천하고 있다.

2-3. 중첩 데이터

쓰기가 중첩되어있는 경우, 안쪽에 있는 쓰기 동작부터 읽기 동작으로 하나하나 변경하여 불변성을 지키면 된다.

// original code
function originalFunc(arr, str, num) {
  for(let i = 0; i < arr.length; i++) {
    if(arr[i].str === str) arr[i].num = num;
  }
}

// modified code
function modifiedFunc(arr, str, num) {
  let arrCopy = [...arr]; // 데이터를 변경할 복사본 만들기
  for(let i = 0; i < arrCopy.length; i++) {
    if(arrCopy[i].str = str)
      // 중첩된 항목을 바꾸기 위해 카피-온-라이트 동작을 호출
      arrCopy[i] = copyOnWrite(arrCopy[i], num);
  }
  return arrCopy;
}

function copyOnWrite(elem, new_num) {
  let elemCopy = Object.assign({}, elem);
  elemCopy.num = new_num;
  return elemCopy;
}

얕은 복사

복사에 깊고 얕음이 어디 있어? 라고 묻는다면 바로 여기 있다.

얕은 복사
중첩된 데이터 구조에서 최상위 데이터만 복사한다. 즉, 수박 겉핥기처럼 껍데기만 복사한 뒤, 내부는 이미 있는 데이터 구조를 참조로 공유하게 된다.
다르게 말하자면 얕은 복사는 변수의 이름만 바뀌고 실제로 메모리에서 참조하는 주소값은 같다.

이 개념이 여기서 갑자기 튀어나온 이유는 중첩된 데이터에서 구조적 공유가 발생하기 때문이다.

그럼 '구조적 공유는 또 뭔데?' 싶다.

구조적 공유
"Structural sharing provides an efficient way to share data between multiple versions of it, instead of copying the whole data."
– Yehonathan Sharvit

두 중첩된 데이터 구조에서 안쪽 데이터가 같은 데이터를 참조하는 것으로 관점에 따라 여러 버전의 데이터를 공유할 때 전체를 복사하지 않고 구조를 공유해 효율성을 높이는 방법이다.

중첩 데이터 구조의 예시 코드에서 copyOnWrite(elem, new_num) 함수의 인자인 elemmodifiedFunc(arr, str, num)에서 일치 여부를 찾는 str 외에도 다른 key가 여러 개가 있었다고 가정해보자.

const elem = {
  str: 'string',
  symbol: 'symbol',
  primitive: 'primitive'
}

그렇다면 변경하고 싶은 str 외 나머지 값들은 Object.assign() 메소드를 통해 복사되었을까? 답은 아니오다. 해당 메소드는 얕은 복사를 하기 때문에 변경되는 데이터 이외에는 복사를 하지 않고 참조하여 구조적 공유가 이뤄졌다.

좀더 구체적으로 생각해보면 얕은 복사를 한다고 해서 상식적으로 생각하는 것처럼 메모리에 값이 중복으로 저장되는 것이 아니라, 이름만 다른 변수에 같은 주소 값을 참조하게 된다. 그리고 복사본에서 값을 수정하고 싶어서 새로운 값으로 변경해 할당하면 메모리에는 새로 변경한 값만을 저장하고, 변수는 그 주소 값을 참조한다. 그러니 여전히 변경한 값 외에 요소들은 기존과 같은 주소 값을 유지하고 있는 것이다. 이로 인해 발생하는 현상이 구조적 공유다.

즉, 기존 값들은 여전히 메모리에 유지되고 있기 때문에 불변성을 유지하고 있으므로 카피-온-라이트 원칙이 적용되는 것이다.



3. 방어적 복사


3-1. 레거시 코드

레거시 코드: 오래전에 작성한 코드로, 당장 수정하지는 못해 그대로 사용해야하는 코드

레거시 코드는 실무 현장 어디서든 무조건 발견하게 되는 코드다. 초기에 작성된 레거시 코드는 그에 기반해 이미 많은 곳에서 결합되어있기 때문에 이걸 수정하느니 새로 짜고 말지 하는 생각이 들도록 만든다.

카피 온 라이트를 활용해 안전하게 불변성을 보장해둔 코드에 레거시 코드를 호출해야 할 경우에는 어떻게 해야할까? 책에서는 이를 해결하기 위한 원칙으로 방어적 복사(defensive copy)를 제안한다.

방어적 복사

생성자를 통해 초기화 할 때, 새로운 객체로 감싸서 복사해주는 방법이다. 외부와 내부에서 주소값을 공유하는 인스턴스의 관계를 끊어주기 위함이다.

레거시 코드처럼 신뢰하기 힘든 코드와 데이터를 주고받아야 하는 상황에서 이 문제를 해결하려면 이미 언급했던 카피-온-라이트 방식을 생각날 수 있다. 하지만 카피-온-라이트 방식은 얕은 복사이므로 참조하는 주소값을 끊지 못한다. 그렇기에 방어적 복사 원칙을 활용한다.

신뢰하기 어려운 코드와 데이터를 주고 받는 방어적 복사 동작은 다음과 같다.

  1. 신뢰할 수 없는 코드 영역에 속한 데이터
  2. 해당 데이터 → 안전지대
  3. 깊은 복사로 만든 복사본을 안전지대에 두고, 원본은 무시한다.

안전지대에서 신뢰하기 힘든 영역으로 나가는 경우도 동일하며 위 단계의 역순으로 내보낸다.

이에 더해 방어적 복사를 하는 코드를 새로운 함수로 감싸서 활용하는 것도 좋은 방법이다.


3-2. 얕은 복사와 깊은 복사

얕은 복사에 대해서는 이미 위에서 상세하게 다뤘으므로 여기서는 가볍게 요약해보자. 복사하여 변경한 코드 외에 변경되지 않은 값에 대해서는 주소값을 참조하고 구조를 공유해 메모리 부담을 줄이는 복사 방식이라고 할 수 있다.

깊은 복사는 원본과 데이터 구조를 절대로 공유하지 않는다. 만약 중첩된 객체라 할 지라도 그 모든 것을 복사한다. 그렇기 때문에 깊은 복사는 메모리 부담이 크고 큰 비용을 치뤄야한다. 이런 단점으로 인해 모든 곳에 깊은 복사를 사용하지는 않고, 카피-온-라이트를 활용하지 못하는 곳에서만 사용한다.

깊은 복사 방법 in JS

깊은 복사를 하기 위해 Lodash 라이브러리를 활용하기도 한다. 이 라이브러리에는 cloneDeep()이라는 함수가 구현되어있어서 중첩된 데이터에도 깊은 복사를 한다.

아니면 JSON.parse()JSON.stringify()를 같이 쓰는 방법도 있다. 하단에 활용 파트에서 나오겠지만 JSON 형식은 깊은 복사를 통해 모듈끼리 통신하기 위해 깊은 복사를 사용한다. 그래서 굳이 통신을 하기 위한 코드가 아니라 하더라도 깊은 복사가 필요할 경우 이런 방법을 사용해볼 수 있다.

마지막으로 structuredClone()이라는 함수를 쓰는 방법도 있으나, Node.js에서는 사용이 불가능하다는 것이 단점이다. 자세한 방법은 MDN을 통해 확인할 수 있다.



4. Copy-on-Write와 방어적 복사 활용


4-1. 카피-온-라이트 활용

통제할 수 있는 데이터로 주소값이 참조되어 공유되어도 상관이 없는 경우에 사용한다. 안전지대 내에서라면 언제나 사용 가능하다.


4-2. 방어적 복사 활용

결국 방어적 복사는 변할 가능성이 있는 데이터를 받아오거나, 레거시 코드에서 데이터를 가져오는 등 신뢰하기 힘든 코드와 데이터를 주고 받는 경우에 사용한다. 아래는 일부 예시이며, 상황에 따라 더 다양하게 활용할 수 있다.

웹 API 속에 방어적 복사

대부분 웹 기반 API는 암묵적으로 방어적 복사가 이뤄지는데, 데이터 교환이 이뤄질 때 사용하는 JSON 형식의 데이터가 직렬화되면서 깊은 복사본이 되기 때문이다.

비공유 아키텍처(shared nothing architecture)
모듈이 서로 통신하기 위해 방어적 복사를 구현한 경우를 일컫는다.

함수형 프로그래밍 언어에서의 방어적 복사

얼랭(Erlang), 엘릭서(Elixir)는 언어 자체에 방어적 복사가 구현되어있다. 따라서 이런 언어를 활용하는 것도 방어적 복사를 활용하는 것이다.




결론

굳이 함수형 프로그래밍이 아니라고 하더라도 데이터의 불변성을 확보하는 건 동적 타입 언어인 자바스크립트에서는 매우 중요한 일이다. 단순히 데이터의 값이 변경되는 것뿐만 아니라 타입이 변경되었을 경우, 런타임에서야 오류를 알게 되기 때문에 뒤늦게 버그를 발견하게 되기 십상이다.

이번 두 챕터를 통해 얕은 복사와 깊은 복사를 언제 활용해야하는지, 어떻게 활용해야하는지 조금은 감을 잡을 수 있었다. 이제야 함수형 프로그래밍을 향한 첫 발을 뗀 기분이다.

profile
나도 재밌고, 남들도 재밌는 서비스 만들어보고 싶다😎

0개의 댓글

Powered by GraphCDN, the GraphQL CDN