변하지 않는 상태를 유지하는 방법, 불변성(Immutable)

변하지 않는 상태를 유지하는 방법, 불변성(Immutable)


이번 포스팅에서는 순수 함수에 이어 함수형 프로그래밍에서 중요하게 여기는 개념인 불변성(Immutable)에 대한 이야기를 해보려고 한다.

사실 순수 함수를 설명하다보면 불변성에 대한 이야기가 꼭 한번은 나오게 되는데, 대부분 “상태를 변경하지 않는 것”이라는 짧은 정의로 설명하거나, 혹은 불변성을 해치는 행위들을 예시로 들고 이런 행위들을 금지 행위로 규정하며 설명을 진행하게된다.

그러나 개인적으로 이런 설명 방식은 상태와 메모리에 대한 개념이 확실하게 정립되지 않은 사람에게 별로 와닿지 않는 방식일 수도 있다고 생각한다. 그래서 이번 포스팅에서는 정확히 불변이라는 것이 무엇을 의미하는지에 대한 이야기를 해보려고 한다.

순수 함수와 불변성은 무슨 관계인가요?

저번에 작성했던 수학에서 기원한 프로그래밍 패러다임, 순수 함수에서 한 번 이야기 했듯이, 순수 함수는 수학의 함수를 프로그래밍의 세계로 가져온 모델이다.

프로그래밍의 세계에는 무언가를 저장하고 변경하고 불러올 수 있는 상태라는 개념이 존재하지만, 수학의 세계에는 그런 개념이 없기 때문에 모든 함수는 함수 외부의 무언가에 절대 영향을 받지 않고 독립적으로 존재한다.

그렇기 때문에 상태라는 개념 자체가 존재하지 않는 수학의 함수를 프로그래밍으로 구현한 모델인 순수 함수 또한 함수 외부의 상태에 영향을 받지 않아야한다는 규칙을 가질 수 밖에 없는 것이다.

또한 수학의 세계에는 상태라는 개념이 없기에 당연히 상태를 변경한다는 개념도 없을 수 밖에 없고, 우리는 이를 불변성(Immutable)이라고 부른다.

하지만 프로그래밍의 세계에서 상태를 변경하지 않는다는 것은 꽤나 신경을 많이 써줘야 하는 일이다. 그래서 우리는 “변수에 값을 재할당하지 않는다”와 같은 몇 가지 규칙들을 정해놓고 프로그래밍을 하면서 불변성을 유지한다.

하지만 프로그램에서 변이(Mutation)가 발생하는 근본적인 원인을 파악하고 불변성을 스스로 지켜나간다면, 이러한 규칙들이 커버할 수 없는 변태같은 상황을 마주치더라도 대응할 수 있기 때문에 우리는 불변(Immutation)이 정확히 무엇을 의미하는 지 알아야 할 필요가 있다.

불변성이란?

보통 불변성의 의미는 상태를 변경하지 않는 것이라는 간단한 정의로 설명된다.

그러나 대부분 불변성에 대한 설명을 할 때, “함수 외부의 변수에 접근, 재할당해서는 안된다”, “함수의 인자를 변경하면 안 된다”와 같이 상태를 변경하는 행위를 금지하는 예시 정도만 설명하고, 상태를 변경한다는 것이 정확히 무엇을 의미하는지는 자세히 설명하지 않는다.

그래서 이런 설명 방식은 상태를 변경한다는 것이 정확히 어떤 의미인지 모르는 사람에게는 잘 와닿지 않을 수 있다고 생각한다.

그렇다면 불변성이 이야기하고 있는 상태의 변경이라는 것이 정확히 어떤 행위를 의미하는 것일까? 단순히 프로그램의 변수를 변경하거나 재할당 하지 않는 것을 이야기하는 것일까?

사실 불변성이 이야기하는 상태의 변경이라는 것은 단순한 변수의 재할당을 이야기하는 것이 아니다. 정확히 말하면 메모리에 저장된 값을 변경하는 모든 행위를 의미하며, 여기에 변수의 재할당과 같은 행위도 포함되는 것이다.

즉, 상태의 변경이라는 행위를 제대로 이해하기 위해서는 컴퓨터가 값을 어떤 방식으로 메모리에 저장하고 접근하는지에 대한 간단한 지식이 필요하다.

우리는 변수를 통해 메모리에 접근한다

대부분의 프로그래밍 언어에서는 메모리의 특정 공간에 저장된 값에 조금 더 쉽게 접근할 수 있도록 도와주는 변수라는 기능을 제공하고 있다.

변수라는 개념은 프로그래밍을 배울 때 가장 처음 배우는 것이기 때문에, 개발자라면 누구나 다 알고 있는 개념일 것이다. 한번 간단한 변수를 선언해보도록 하자.

1
2
3
4
let a;
a = 1;

console.log(a);
1
1

필자는 a라는 변수를 “선언”하고, 그 다음 라인에서 a 변수에 1이라는 값을 “할당”했다.

일반적으로는 let a = 1;와 같이 선언과 동시에 할당을 진행하지만, 엄밀히 말해서 선언과 할당은 다른 행위이기에 조금 더 편한 이해를 위해 코드를 나눠서 작성했다.

let a;라는 명령을 사용하여 변수를 선언하면 자바스크립트는 메모리에 a라는 변수를 통해 접근할 수 있는 메모리 공간을 마련한다. 필자는 변수를 선언만 하고 값을 할당하지 않았으니 이때 a 변수에 접근하려 한다면, “아무것도 정의되지 않았다”라는 의미의 undefined를 뱉어낼 것이다.

그 후 필자는 a = 1이라는 명령을 사용하여 마련된 메모리 공간에 1이라는 값을 저장했고, 그 이후부터 필자가 a라는 변수를 통해 해당 메모리 공간에 접근하면 저장되어 있던 1이라는 값을 얻어낼 수 있는 것이다.





즉, 변수라는 것은 메모리에 저장되어 있는 어떠한 값에 접근하는 일종의 단축어같은 개념이며, 만약 변수가 없다면 우리는 일일히 0x0018fa와 같은 메모리 주소를 사용하여 메모리에 값을 저장할 공간을 마련하고 값을 저장하거나 접근해야한다는 것이다.

만약 필자가 a = 2처럼 해당 변수의 값을 다시 할당한다면, 0x0018fa라는 주소를 가진 메모리 공간에 저장되어 있는 값을 변경하는 것이며, 상태를 변경하는 행위라고 말할 수 있는 것이다.

자바스크립트는 재할당 할 수 있는 변수를 선언하는 let 키워드와 재할당 할 수 없는 const 키워드를 구분하여 제공함으로써 개발자가 실수로 메모리 공간에 저장되어있는 값을 변경하는 행위를 방어할 수 있는 기능을 제공한다.

그렇다면 우리가 변수를 재할당하지만 않는다면 불변이라는 개념을 지킬 수 있는 것일까?





슬프게도 그렇지 않다. 프로그램이 변수가 가리키고 있는 메모리 공간에 있는 값을 불러오고 사용하는 방법은 그렇게 단순하지 않기 때문이다.

바로 여기서 그 유명한 값에 의한 호출(Call by value)참조에 의한 호출(Call by reference)이 등장한다.

값에 의한 호출과 참조에 의한 호출

값에 의한 호출과 참조에 의한 호출은 특정 컨텍스트에서 다른 컨텍스트에게 변수를 넘길 때 어떤 방식으로 값을 넘겨줄 것인지에 대한 방법들이다.

이렇게 컨텍스트 간 변수를 넘기는 상황은 함수 외부의 스코프에서 함수에게 인자를 넘겨주는 상황으로 많이 표현되며, 또 실제로도 그런 상황이 대부분이다.

이에 대해서 조금 더 쉽게 알아보기 위해 간단한 함수를 선언해보도록 하겠다.

1
2
3
function foo (s) {
return str.substring(0, 2);
};

foo 함수는 문자열을 인자로 받아서 가장 앞의 두 글자만 잘라내어 반환하는 순수 함수이다. 즉, foo 함수는 자신의 인자로 받은 값을 재료로 하여 자신의 반환 값을 만들어내는 셈이다.

그럼 foo 함수를 한번 사용해보도록 하자.

1
2
const str = 'Hello, World!';
foo(str);
1
He

foo 함수는 자신의 인자로 받은 문자열을 잘라서 반환하기 때문에, 마치 인자로 받은 str 변수를 직접 수정하는 것처럼 보인다.

하지만 foo 함수의 인자로 사용했던 str 변수를 콘솔에 출력해보면 처음 필자가 할당했던 값인 Hello, World!가 그대로 저장되어 있는 것을 확인할 수 있다.

1
2
console.log(foo(str));
console.log(str);
1
2
He
Hello, World!

이게 어떻게 된 것일까? 정답은 str 변수의 자료형인 string형의 호출 방식이 값에 의한 호출 방식을 사용하기 때문이다.

자바스크립트에서 string, number, boolean과 같이 원시 자료형을 사용하는 변수들은 모두 값에 의한 호출 방식을 사용한다.

값에 의한 호출 방식은 함수의 인자로 어떤 변수를 넘길 때 해당 변수가 가지고 있는 값을 그대로 복사하여 함수에게 넘겨주는 방식을 의미하기 때문에, 기존에 str 변수가 가리키고 있는 메모리 공간에 있는 값을 함수에 인자로 넘기는 것이 아니라 그 값을 복사하여 새로운 메모리 공간에 저장하고나서 넘겨준다는 뜻이다.





결국 foo(str)라는 코드로 함수를 호출하며 인자로 넘긴 str이라는 변수가 가지고 있는 값과, foo 함수 내부에서 s라는 변수를 통해 접근하는 값은 전혀 다른 메모리 공간에 저장되어 있는 새로운 값이다.

그렇기 때문에 foo 함수가 아무리 자신의 인자로 받은 변수를 지지고 볶아도 원본 변수는 절대로 영향을 받지 않는다. 심지어 foo 함수 내부에서 s 변수를 재할당하더라도 원본 변수에 담겨져 있는 값은 변하지 않는다.

1
2
3
4
5
6
7
8
9
const str = 'Hello, World!';

function foo (s) {
s = '재할당합니다';
return s.substring(0, 2);
}

foo(str);
console.log(str);
1
Hello, World!

foo 함수는 인자로 넘어온 변수에 값을 재할당했음에도 함수 외부에 있는 str 변수의 값은 변하지 않았다.

즉, 불변성을 유지한다는 것은 단순히 “함수의 인자를 변경하지 않는다”라던가 “변수를 재할당하지 않는다”는 개념이 아닌 것이다. 포인트는 메모리에 이미 담겨있는 값을 변경하지 않는 것이다.

반면 값에 의한 호출 방식을 사용하지 않는 Array, Object와 같은 객체들은 조금 상황이 다르다.

이번에는 인자로 배열을 받은 후 그 배열에 hi라는 문자열 원소를 추가하는 간단한 함수를 선언하고, 어떤 결과가 나오는지 살펴보도록 하겠다.

1
2
3
4
function bar (a) {
a.push('hi');
return arr;
};

원시 자료형이었던 str 변수는 값에 의한 호출 방식을 사용하지만, 객체인 Array는 참조에 의한 호출 방식을 사용한다. 그럼 함수를 사용해보고 원본 변수가 어떻게 되는지 확인해보자.

1
2
3
4
const array = [];
bar(array);

console.log(array);
1
['hi']

함수 내부에서 인자를 지지고 볶아도 원본 변수에는 전혀 영향이 없었던 foo 함수와 다르게, 이번에는 bar 함수의 인자로 넘겼던 array 변수의 값이 변경된 것을 확인해볼 수 있다.

함수의 인자로 변수를 넘길 때 값을 복사하여 새로운 공간에 저장한 후 넘겨주는 값에 의한 호출 방식과 다르게, 참조에 의한 호출 방식은 “변수가 가리키고 있는 메모리 공간의 주소”를 넘기는 방식이다.

즉, array 변수가 가리키고 있는 메모리 공간에 저장된 배열과 bar 함수가 인자로 받은 배열은 정확히 같은 메모리 공간에 저장되어 있는, “같은 배열” 이라는 것이다.





즉, bar 함수의 인자로 받은 배열은 참조에 의한 호출 방식을 사용하는 객체이기 때문에, 함수 내에서 이 배열을 지지고 볶아 버린다면 원본 배열 자체가 지지고 볶아지는 것이다.

이 경우에는 메모리 공간에 저장되어있던 배열을 직접 변경해버리는 것이므로, 상태가 변경되었다고 말할 수 있고, 불변성이 깨져버린 것이다.

똑같이 함수의 내부에서 인자를 수정하는 행위지만 인자가 값에 의한 호출 방식을 사용하는 자료형인지 참조에 의한 호출 방식을 사용하는 자료형인지에 따라 결과는 큰 차이가 나기 때문에, 불변성을 지키고 싶다면 항상 이 점을 염두에 두고 코드를 작성해야한다.

불변성을 지키면 어떤 점이 좋은가요?

프로그래밍을 하면서 상태의 불변성을 지키려면 자연스럽게 이것저것 신경써줘야하는 것들이 늘어날 수 밖에 없다. 그럼에도 불구하고 불변이라는 개념은 현재 많은 개발자들에게 환영받고 있는 개념이라는 것이 사실이다.

도대체 상태가 변경되지 않게 함으로써 얻을 수 있는 것이 무엇이길래, 다들 이렇게 불변불변하는 것일까?

무분별한 상태의 변경을 막는다

상태는 프로그램의 현재 상황을 보여주는 좋은 역할도 하지만, 여기저기서 무분별하게 이 상태를 참조하거나 변경하는 경우, 개발자조차 현재 프로그램이 어떻게 돌아가는지 파악하기 힘든 슬픈 상황이 발생할 수도 있다.

그래서 개발자들은 상태를 변경하는 행위에 특정한 규칙과 제약을 정해서 무분별한 상태 변화를 최대한 피하고, 이런 변화를 추적할 수 있는 상황을 선호할 수 밖에 없다.

무분별한 상태 변경 때문에 프로그램이 터지게 되는 가장 대표적인 상황은 바로 “전역 변수의 남용”이다. 자바스크립트에서 전역 변수의 사용을 아예 금지하는 컨벤션을 추천하는 것도 바로 이 이유이다.

1
2
3
4
5
6
7
8
9
10
11
12
let greeting = 'Hi';

function setName () {
name = 'Evan';
}

setTimeout(() => {
greeting = 'Hello';
}, 0);

setName();
console.log(`${greeting}, ${name}`);

greeting 변수는 전역 스코프에서 선언된 전역 변수이고, setName 함수 내부에서도 암묵적으로 전역 변수를 선언하고 있으며, setTimeout의 콜백 함수 내에서도 전역 변수인 greeting의 값을 재할당하고 있다.

이런 상황에서는 어디서 어떤 놈이 greeting이라는 전역 변수의 상태를 변경했는지 추적이 거의 불가능하며, 갑자기 콘솔에 Hi, Evan이 아닌 Get out, Evan이라고 출력된다고 해도 전혀 이상할 것이 없다.

개발자가 이런 상황을 만났을 때 야근을 하는 이유는, 슬프게도 이게 버그가 아니기 때문이다. 이 코드들은 콘솔에는 어떠한 에러도 출력되지 않는 지극히 정상적인 로직이다. (차라리 에러라도 나는 것이 디버깅은 더 쉽다)

불변성을 유지하며 순수 함수를 사용한다는 것은 함수 외부의 상태에 접근하여 이미 메모리에 할당되어 있는 값을 변경하지 않는다는 의미이므로, 이렇게 예측하지 못한 상태의 변경을 방어할 수 있다.

상태의 변경을 추적하기가 쉽다

일반적으로 자바스크립트의 객체의 프로퍼티나 배열의 원소를 변경해야하는 경우, 필연적으로 불변성이 깨질 수 밖에 없다.

애초에 배열이나 객체가 처음 나왔을 때, 어딘가에 구조화된 데이터를 저장해놓고 상태를 유지하고 변경해가며 사용하고자 하는 목적을 가지고 있었기 때문이다.

1
2
const evan = { name: 'Evan' };
evan.name = 'Not Evan'; // 상태 변화!

하지만 이렇게 기존에 메모리에 저장되어있는 값을 변경하는 행위는 불변의 법칙을 정면으로 위반하는 것이기 때문에, 불변성을 유지하고 싶은 개발자는 이런 식으로 객체의 프로퍼티나 배열의 원소를 변경할 수 없다.

게다가 방금 보았던 무분별한 전역 변수의 사용과 마찬가지로 객체나 배열의 상태 변화 또한 추적할 수 없는 문제이기 때문에, 어디서 이상한 놈이 엄한 객체나 배열의 상태를 변경하여 버그가 발생하더라도 개발자가 이를 디버깅하기란 쉽지 않은 문제이다.

그렇다고 객체의 프로퍼티나 배열의 원소를 변경하지 못하도록 할 수도 없는 노릇이다. 그럼 어떻게 이 문제를 해결해야할까?

한번 객체의 프로퍼티를 변경하는 간단한 함수를 통해 객체의 프로퍼티를 변경할 때 발생하는 상태 변화의 재현과 해결 방법을 알아보도록 하자.

1
2
3
4
function convertToJohn(person, name) {
person.name = 'John';
return person;
}

convertToJohn 함수는 객체를 인자로 받아, 해당 객체의 name 프로퍼티에 John이라는 문자열을 할당하는 역할을 하는 함수이다. 즉, 이 함수는 객체의 상태를 변경하는 역할을 하고 있다.

일단 결론부터 이야기하자면 이 함수는 순수 함수가 아닌데, 그 이유는 함수가 참조에 의한 호출 방식을 사용하는 객체의 프로퍼티를 직접 변경하면 함수 외부에 있는 원본 객체의 상태도 변경되기 때문이다.

1
2
3
4
5
const evan = { name: 'Evan' };
const john = convertToJohn(evan);

console.log(evan);
console.log(john);
1
2
{ name: 'John' } // ?
{ name: 'John' }

convertToJohn 함수를 사용하는 사람은 함수의 이름만 보고 “오호, 이 함수는 어떤 객체를 존 객체로 바꿔주는 함수로군?”이라고 생각하겠지만, 이 함수는 개발자 몰래 자신의 인자로 받은 객체까지 변경해버리는 나쁜 함수였다.

이렇게 의도하지않은 객체의 프로퍼티가 변경되는 것도 문제지만, 사실 더 큰 문제는 이런 상태의 변화를 전혀 추적할 수 없다는 것이다. 당장 위 예시의 evan 객체와 john 객체를 비교해보면 자바스크립트는 두 객체가 같은 객체라고 평가해버린다.

두 객체는 메모리 공간에 접근할 수 있는 변수명만 다를 뿐, 실제로는 같은 메모리 공간에 저장되어 있는 같은 객체이기 때문이다.

1
console.log(evan === john);
1
true

이런 상황에서 개발자는 “의도하지 않은 객체의 상태 변화”와 “상태의 변화를 추적할 수 없다”는 고약한 문제를 떠안게 된다. 그렇다면 이 문제를 어떻게 해결할 수 있을까?

이 문제는 생각보다 간단하게 해결할 수 있는데, nameJohn으로 가지는 객체를 그냥 새로 생성해버리면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
function convertToJohn (person) {
const newPerson = Object.assign({}, person);
newPerson.name = 'John';

return newPerson;
}

const evan = { name: 'Evan' };
const john = convertToJohn(evan);

console.log(evan);
console.log(john);
1
2
{ name: 'Evan' }
{ name: 'John' }

변경된 convertToJohn 함수는 더 이상 인자로 받은 person 객체에 직접 접근해서 값을 수정하지 않는다. 다만 Object.assgin 메소드를 사용하여 person 객체와 동일한 구조를 가진 새로운 객체를 생성하고 name 프로퍼티를 John으로 변경한 후 반환할 뿐이다.

이런 과정이 너무 불편하게 느껴진다면 ES6의 spread 연산자를 사용하면 더 간단한 문법으로 변경할 수도 있다.

1
2
3
4
5
6
function convertToJohn (person) {
return {
...person,
name: 'John',
};
}

이렇게 새로운 객체를 생성하게 되면 의도하지 않은 객체의 상태 변화도 방어할 수 있고 상태 변화를 추적할 수도 있게 된다. 왜냐하면 convertToJohn 함수가 뱉어낸 객체는 evan 객체와는 전혀 다른, 새로운 객체이기 때문이다.

1
console.log(evan === john);
1
false

객체의 상태를 변화시킬때, “상태가 변화된 객체”를 새로 생성한다면 우리는 이전 상태를 가진 객체와 다음 상태를 가진 객체를 비교하며 false가 나온다는 사실을 이용하며 객체의 상태가 변화되었음을 알 수 있는 것이다.

이런 원리는 웹 프론트엔드의 UI 라이브러리인 리액트(React)에서 상태의 변화를 감지하는 데에도 사용되고 있는데, 리액트는 개발자가 setState와 같은 메소드를 사용하여 상태를 변경했을 때 Object.is 메소드를 사용하여 이전 상태와 다음 상태를 비교하고 두 객체가 같지 않다고 평가되면 상태가 변이되었다고 판단하고 컴포넌트를 다시 렌더한다.

또한 상태 관리 라이브러리인 리덕스(Redux) 또한 동일한 원리로 상태의 변화를 판단하기 때문에, 리듀서를 작성할 때는 기존 state 객체의 프로퍼티를 직접 변경하지 않고 새로운 객체를 생성해서 반환해야한다.

1
2
3
4
5
6
7
8
9
10
function reducer (state, action) {
switch (action.type) {
case SET_NAME:
return {
...state,
name: action.payload,
};
// ...
}
}

이러한 불변성의 특징들은 참조에 의한 호출을 사용하는 자료형들의 상태 변화를 쉽게 감지할 수 있도록 만들어주기 때문에 개발자가 예상하지 못하는 방향으로 버그가 발생하는 것을 어느 정도 막을 수 있다.

또한 불변성은 멀티 쓰레딩을 사용할 때도 매우 유용한데, 여러 개의 쓰레드가 한 개의 상태를 정신없이 수정하고 참조하게되면 어느 순간부터는 도대체 쓰레드가 참조한 게 어떤 값인지 파악하기가 힘들기 때문이다.

이건 마치 하나의 종이에 여러 명의 화가가 물감을 칠하면서 그림을 완성해가는 느낌이라고 할 수도 있을 것 같다. 그러나 불변성이 제대로 지켜진다면 각자 쓰레드마다 종이를 주고 그림을 그려서 제출하라는 상황과 비슷하다.

개발자는 각 쓰레드가 그림을 제출할 때마다 상태가 변경되었음을 감지할 수도 있고, 이를 이용하여 그림의 상태가 변경되는 로그를 쌓을 수도 있다. 이후 그 그림들을 어떻게 취합하던, 필요없는 그림은 버리던 그건 그 후의 문제로 분리하면 되는 것이다.

현실적인 불변성의 상황

이렇게 불변성을 지키면서 프로그래밍을 하면 상태 변화를 쉽게 추적할 수 있고 관리할 수 있다는 점에서 더할 나위 없이 좋지만, 그렇다고 해서 장점만 있는 것은 아니다.

불변성의 가장 큰 문제는 기존의 객체지향 프로그래밍과의 접점을 만들기 어렵다는 것이다.

불변성을 지향한다는 함수형 프로그래밍의 특징은 기존에 우리가 익숙하게 사용하는 객체지향 프로그래밍과는 많이 다르다.

객체지향 프로그래밍은 private과 같은 접근 제한자로 상태를 외부에 노출시키지 않음으로써 사용자가 단순한 인터페이스를 접할 수 있도록 하지만, 함수형 프로그래밍은 아예 프로그램의 상태를 변경하지 않는 불변성을 지향하면서 프로그램의 동작을 예측하기 쉽고 단순하게 만든다.





OOP는 변경 가능한 상태를 감추며 단순함을 만들어내지만,
FP는 아예 변경 가능한 상태를 없앰으로써 단순함을 만들어낸다


즉, 객체지향 프로그래밍과 함수형 프로그래밍은 상태를 바라보는 관점 자체가 다르다는 것이다. 애초에 객체지향 프로그래밍은 상태를 “잘 변경하는 것”에 초점을 맞추는 패러다임이기 때문에 불변성과는 약간 거리가 있다.

또한 아직까지 우리가 사용하는 대부분의 API나 라이브러리들은 객체지향 프로그래밍을 기반으로 설계되고 있기 때문에 우리는 객체지향 프로그래밍에서 완벽하게 독립할 수 없는 상황이다.

문제는 이렇게 객체지향 프로그래밍을 기반으로 설계된 것들을 불변의 법칙으로 관리하려면 꽤 많은 비용이 들 수도 있다는 것이다.

쉬운 이해를 위해 웹 오디오 API를 사용하여 객체를 하나 생성하고, 이 객체의 상태를 변경해야하는 상황을 살펴보도록 하자.

1
2
3
4
const context = new AudioContext();
let state = {
node: context.createGain(),
};

상태를 표현하기 위해 간단한 객체를 생성하고 그 안에 게인 노드(GainNode)를 할당했다. 게인 노드는 오디오 신호의 크기를 키웠다 줄였다 할 수 있는 값인 gain 프로퍼티를 가지고 있고, 개발자는 이 프로퍼티의 값을 변경함으로써 간단하게 오디오 신호를 조작할 수 있다.

1
state.node.gain = 1.2;

그러나 이렇게 객체에 직접 접근하여 프로퍼티를 변경하는 행위는 불변성을 위배한다. 이 상황에서 불변성을 만족하기 위해서는 gain 프로퍼티의 값을 변경할 때마다 새로운 게인 노드 객체를 생성해줘야 하는 것이다.

1
2
3
4
5
6
7
8
9
10
const setGain = (value) => {
const newGain = context.createGain();
newGain.gain = value;
return newGain;
};

state = {
...state,
node: setGain(value),
};

물론 이렇게 불변성을 지켜주면 게인 노드의 상태 변화를 추적할 수 있다는 장점을 가지지만, 객체를 생성하는 비용이 클 경우에는 문제는 발생할 수 있다.

웹 오디오 API가 제공하는 게인 노드 객체는 멤버 변수와 메소드를 가지고 있는 엄연한 인스턴스이며, { gain: 1 }처럼 간단한 프로퍼티만을 가지고 있는 객체가 아니다.

만약 인스턴스가 가지고 있는 멤버 변수와 메소드가 많거나 객체를 생성할 때 무거운 작업이 동반되어야 한다면, 프로퍼티를 변경할 때마다 객체를 생성하는 것은 퍼포먼스에 상당한 부담이 될 수도 있다.

이미 생성된 객체를 복사하는 방법도 있겠지만, 저렇게 생성자를 통해 생성된 객체는 복사한 후에 프로토타입 링크도 전부 다시 연결해줘야하기 때문에, 일반 객체를 복사하는 것에 비해 그리 가벼운 작업은 아니다.(실제로 몇 번 해봤는데, 퍼포먼스가 생각보다 안 나온다)

이런 상황에서 섣불리 불변성을 유지한답시고 저런 코드를 작성하면 프로그램 전체의 퍼포먼스가 크게 저하될 수도 있기 때문에 각 상황에 맞는 현명한 판단이 필요하다.

마치며

사실 이번 포스팅에서는 불변성에 대한 설명과 더불어, 일급 시민이라는 개념을 사용한 커링과 같이 기술적인 부분에 대한 이야기도 함께 하려고 했는데, 또 분량 조절에 실패해버렸다.

아무래도 요즘 관심을 많이 가지고 있는 내용이다보니 자꾸 내용이 길어지는 듯 하다.

최근 불변성이라는 키워드가 프론트엔드 쪽에서 많이 주목받고 있기는 하지만 사실 불변성이 프론트엔드에서만 주목받는 키워드는 아니다. 본래 불변성이 주목받기 시작한 이유는 변경 가능한 상태를 여러 곳에서 공유하게 됨으로써 발생하는 여러가지 문제를 해결하기 위함이었기 때문이다.

일반적으로 이런 문제는 멀티 쓰레딩과 같은 동시성 프로그래밍을 사용할 때 많이 발생했는데, 기존에는 상태에 접근할 수 있는 권한을 의미하는 일종의 락(Lock)을 걸어놓고 락이 풀린 상태에만 쓰레드가 상태에 접근할 수 있도록 허가하는 방식을 주로 사용했었다.

그러나 이러한 상태가 한두개도 아닐 뿐더러, 실수로 락을 잘못 걸어서 상태가 꼬여버려도 개발자가 알아차리기 힘든 것은 매한가지이기 때문에, 변경 가능한 상태를 아예 없애버리는 불변의 개념이 각광받기 시작한 것이다.

오히려 Erlang이나 Rust 같은 언어들은 자바스크립트보다 더 빡빡한 방법으로 불변성을 지원하고 있기 때문에, 이 키워드에 관심이 많으신 분들은 해당 언어를 한 번 체험해보는 것도 좋겠다는 생각이 든다. (필자는 Rust를 한번 해볼까 생각 중이다)

하지만 자바스크립트 또한 특유의 자유로운 언어의 성격 때문에 ES5 시절부터 무분별한 상태 관리에 많은 개발자들이 고통받았었고, 점점 더 웹 프론트엔드 어플리케이션이 고도화되고 복잡한 상태 관리를 요구하게 되면서 이런 개념을 사용하게 되었다.

실제로 필자 또한 상태의 변화를 추적할 수 없는 상황에서 발생한 버그를 디버깅하느라 고생한 적이 너무나도 많았기 때문에, 이러한 불변의 개념을 처음 알았을 때 꽤나 관심있게 지켜봤던 기억이 있다.

하지만 앞서 이야기했듯이 불변성을 유지하며 프로그래밍을 한다는 것이 모든 상황에서의 정답이 될 수는 없다.

필자도 현재 작업 중인 토이 프로젝트인 Web Audio 에디터에 아무 생각없이 리덕스를 붙혔다가 위에서 이야기했던 객체 생성 비용 문제때문에 퍼포먼스가 안 나와서 고생 중이다.(위에서 예로 든 상황은 필자의 경험담이었다)

늘 이야기하는 것이지만 모든 상황에 맞아떨어지는 절대적인 기술이라는 것은 없기 때문에, 불변성이 무조건 좋다고 이야기하기보다 그저 각 상황에 맞는 현명한 의사결정을 통해 불변성을 이용하면 된다고 생각한다.

이상으로 변하지 않는 상태를 유지하는 방법, 불변성 포스팅을 마친다.

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×