Javascript

[JS] 자바스크립트에서 불변성을 이용하는 경우

SeanK 2022. 5. 31. 11:23

The case for Immutability in JavaScript

불변성 데이터 구조의 이점과 사용법

Photo by Lydia Torrey on Unsolash

불변성은 아무것도 변하지 않는 불변의 상태를 뜻하고 이러한 의미를 우리는 코딩에서 변하지 않는 데이터 구조의 개념으로 이용하고 있다. 

 

어떻게 이런 게 가능하며 왜 불변성을 이용하는 것일까? 이는 아주 좋은 질문이며 이번 포스팅에서 다룰 주제이다. 이 글이 끝날 때쯤 언제 불변성을 이용하고 언제 이용하지 말아야 하는지 이해할 수 있기를 바란다. 


우선, 불변성 데이터 구조란 정확히 무엇일까?

위에서 언급했듯, 이는 절대 변하지 않는 구조이다. 설사 변하는 것 처럼 느껴진대도 내부적으로 새로운 구조가 만들어진다. 

 

자바스크립트에서 한가지 좋은 예는 스트링 데이터 타입이 있다. 스트링은 불변성을 가진다. 

 

예를 들어 아래와 같이 작성해 보자:

let myStr = "Hello "
myStr += " World"

하나의 스트링 데이터만을 다룬다고 생각될 지도 있지만, 사실은 두 개의 데이터를 이용하고 있다. 그리고 스트링의 불변성을 입증할 가장 쉬운 방법은 스퀘어 괄호를 이용해 문자열을 대체해보는 것이다. 

let myStr = "Hello"
myStr[0] = "F"
console.log(myStr) // will still print Hello

이는 모든 스트링 메소드들이 기존의 스트링을 대체하는 것이 아닌 새로운 스트링을 반환하는지에 대한 이유이다. 예를 들어:

let myStr = "Hello"
let newStr = myStr.replace("H", "F")
console.log(myStr) // still prints Hello
console.log(newStr) // will print Fello

예를 들어 배열과 같은 다른 데이터 구조의 경우 불변하지 않기 때문에 차이를 아는 것이 중요하다. 

 

불변성의 데이터 구조를 가지는게 왜 중요한가?

한 가지 명확히 알아야 할 점은 불변성의 데이터 구조는 어떤 상황에서는 좋지만 모든 경우에 이상적인 구조는 아니라는 점이다. 따라서 중요한 점은 불변성의 데이터 구조를 이해하는 것은 활용할 수 있는 방안이지 당신이 지금까지 일해오던 방식을 바꾸어야 하는 것이 아니라는 것이다. 

 

위 사항을 숙지하며, 불변성의 데이터 구조의 주된 장점은 일관성을 제공한다는 것이다. 다르게 설명하자면, 다른 함수들 사이에서 공유되는 값을 의도치않게 변경해 잠재적인 사이드 이펙트를 일으 킬 수 있는 가능성을 없앤다. 

 

예를 들어, 아래와 같은 코드를 한 번 보자:

let mutable = []

function change1(list) {
    list.push(2)
    console.log(list)
}

function change2(list) {
    list.push(3)
    list.push(3)
    console.log(list)
}

setTimeout(() => change1(mutable), Math.random() * 1000)
setTimeout(() => change2(mutable), Math.random() * 1000)


setTimeout(() => console.log(mutable), Math.random() + 4000)

예제에서, 변환가능한 리스트가 두 개의 다른 함수의 파라미터로 전달되고 있다. 이 함수들은 랜덤 한 순서로 실행된다 (어떤 순서로 실행될지 아무도 모른다). 이 함수들은 다른 함수를 고려하지 않은 채 각자 자신의 일을 실행할 것이다. 그리고 마침내 변환 가능한 원형 데이터 구조가 프린트된다. 

 

눈치챌 수 있듯이, 랜덤 요소는 스크립트가 여러 번 실행될 때마다 다른 결과를 프린트하도록 할 것이다. 이는 불길한 징조다. 왜냐하면 코드는 이상적으로 결정론적이어야 한다. 즉, 같은 조건하에 실행된 코드는 항상 같은 결과를 내어야 한다. 

 

또 다른 변환가능한 데이터 구조의 위험성의 예는 아래와 같다:

let mutable = [1, 2, 3]

function change1(list) {
    for(let i = 0; i < list.length; i++) {
        list[i] = list[i] * 2
    }
    console.log("The sum of the doubles is: ", sumValues(list))
}

function sumValues(list) {
    let sum = list.reduce((prev, curr) => prev + curr, 0)
    return sum
}

setTimeout(() => change1(mutable), Math.random() * 1000)
setTimeout(() => console.log("The sum of the original numbers is: ", sumValues(mutable)), Math.random() * 1000)


setTimeout(() => console.log(mutable), Math.random() + 4000)

아까와 같은 구조이지만 변환 가능한 데이터를 바꾸는 함수와 그 데이터에 의존하고 있는 함수가 들어가 있다. 이는 사이트 이펙트를 분명히 보여준다. 

 

생각해보면 이는 실생활에서 정말 큰 절망감을 줄 수 있다. 왜냐하면 당신은 함수 안에서 전역 변수를 사용하지 않았기 때문이다. 모든 것이 완벽하게 동작해야 하나, change1 함수가 먼저 실행 되면 sumValues는 12가 될 것이고 두 번째로 실행된다면 결과는 6이 될 것이다. 

 

따라서 모두 잘못된 결과를 얻게 된다:

The sum of the doubles is:  12
The sum of the original numbers is:  12
[ 2, 4, 6 ]

혹은:

The sum of the original numbers is:  6
The sum of the doubles is:  12
[ 2, 4, 6 ]

마지막 줄에서 리스트가 변형되었음을 확인할 수 있지만 첫 줄에서는 옳게 나온다. 

 

이러한 문제를 해결할 수 있는 방법들이 여러가지 있는데, 중요한 점은 변환 가능한 데이터를 다룰 때 신경을 쓰지 않던 혹은 이것에 대해 잘 모르는 상태에서 당신의 시스템에 잠재적으로 나타날 수 있는 버그를 넣어버릴 수 있다는 것이다. 뿐만 아니라, 랜덤 하게 발생되는 버그의 경우 디버깅이 기하급수적으로 어려워진다. 

 

따라서 사용가능한 경우 불변성의 데이터를 이용하는 이유는 당신의 코드에 의도치 않게 사이드 이펙트를 일으키는 코드를 배제하고, 시스템 상에 나타날 버그의 수를 줄이는 데에 있다. 


자바스크립트에서 불변성의 데이터 구조를 이용하기 위한 ImmutableJS

만일 엄격하게 금지 되어 있지 않다면, ImmutableJS와 같은 외부 라이브러리를 활용하면 간단하게 불변성 구조를 이용할 수 있다.

 

이 라이브러리의 최대 장점은 자바스크립트에서 사용 가능한 모든 변환 가능 데이터 구조를 불변환 구조로 대체할 수 있다는 것이다. 따라서 이전의 예제에 아래와 같이 코드를 작성한다면:

const {List} = require("immutable")

let mutable = List([1, 2, 3])

function change1(list) {
    for(let i = 0; i < list.length; i++) {
        list[i] = list[i] * 2
    }
    console.log("The sum of the doubles is: ", sumValues(list))
}

function sumValues(list) {
    let sum = list.reduce((prev, curr) => prev + curr, 0)
    return sum
}

setTimeout(() => change1(mutable), Math.random() * 1000)
setTimeout(() => console.log("The sum of the original numbers is: ", sumValues(mutable)), Math.random() * 1000)

변경된 코드는 세번째 줄에 원형의 리스트에 List를 사용한 것뿐인데 아래와 같은 결과가 나온다.

The sum of the original numbers is:  6
The sum of the doubles is:  6

예상했던 결과는 아니지만 틀린 결과도 아니다! 사실 완벽하게 의도했던 대로 작동했다. change1 함수에서 원형 데이터를 변경하지 못했다. 선택의 여지없이 다른 별도의 배열이 생성되었다. map 메서드를 이용하면 원형과 불변의 데이터가 새로운 리스트 결과를 만들어내는 것을 확인할 수 있다. 따라서 change1 함수를 아래와 같이 변경해 보자:

function change1(list) {
    list = list.map(x => x * 2)
    console.log("The sum of the doubles is: ", sumValues(list))
}

이러면 모든게 끝이다. 다행히, ImmutableJS 없이 할 뻔했지만 라이브러리 덕분에 방법을 찾을 수 있었다. 

 

chang1 vs change2 예제로 다시 돌아가 보자. 우리는 해당 문제를 ImmutableJS를 활용해 원형 배열을 List로 선언해 해결할 수 있다:

const {List} = require("immutable")


let mutable = List([])

function change1(list) {
    list.push(2)
    console.log(list)
}

function change2(list) {
    list.push(3)
    list.push(3)
    console.log(list)
}

setTimeout(() => change1(mutable), Math.random() * 1000)
setTimeout(() => change2(mutable), Math.random() * 1000)


setTimeout(() => console.log(mutable), Math.random() + 4000)

 당연히, 아웃풋은 예상치 못한 결과가 나올 것이다. 왜일까?

 

여기 로그를 한번 보자:

List {
  size: 0,  <-------
  _origin: 0,
  _capacity: 0,
  _level: 5,
  _root: undefined,
  _tail: undefined,
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}
List {
  size: 0,  <-------
  _origin: 0,
  _capacity: 0,
  _level: 5,
  _root: undefined,
  _tail: undefined,
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}
List {
  size: 0,  <-------
  _origin: 0,
  _capacity: 0,
  _level: 5,
  _root: undefined,
  _tail: undefined,
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}

스크립트에 총 3개의 콘솔 로그가 있다. 더 복잡한 객체를 다루어야 하니 일단 구조는 무시하고, 세 개의 하이라이트 된 부분을 살펴보자. 모두 비어있다. 

 

어떻게 된 일일까?

 

불변성 리스트는 변형될 수 없기에 push 메소드는 새로운 배열과 추가된 값을 반환한다. 실재로는 원형의 배열에 값을 추가하는 게 아니다. 따라서 아래와 같이 수정을 해보자:

function change1(list) {
    let myList = list.push(2)
    console.log(myList)
}

function change2(list) {
    let myList = list.push(3)
    myList = myList.push(3)
    console.log(myList)
}

특히 9번째 줄에 list.push(3)이 아닌 myList.push(3)을 사용한 점에 주목하라. 메소드가 새로운 구조 배열을 리턴하기 때문에 8번째 줄의 3은 9번째 줄에서 list에 들어있지 않기 때문이다. 

 

결과는 아래와 같다:

List {
  size: 1, <--------
  _origin: 0,
  _capacity: 1,
  _level: 5,
  _root: null,
  _tail: VNode { array: [ 2 ], ownerID: OwnerID {} },
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}
List {
  size: 2,  <--------
  _origin: 0,
  _capacity: 2,
  _level: 5,
  _root: null,
  _tail: VNode { array: [ 3, 3 ], ownerID: OwnerID {} },
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}
List {
  size: 0,  <--------
  _origin: 0,
  _capacity: 0,
  _level: 5,
  _root: undefined,
  _tail: undefined,
  __ownerID: undefined,
  __hash: undefined,
  __altered: false
}

처음 두 리스트는 각각 변형되었고 마지막은 훼손되지 않은 채 남아있다. 


왜 모든 경우에 불변성 구조를 사용하지 않는가?

위에서 말했듯, 불변성은 모든 경우에 이상적인 구조는 아니다. 예상할 수 있듯이, 배열을 변형할 때마다 새로운 데이터 배열을 생성하거나 모든 변환 가능 데이터 구조를 분변성으로 변경하는 것은 퍼포먼스에 영향을 미칠 수 있다.

 

그렇다고 이를 오해하진 말라. 불변성이 퍼포먼스에 좋지 않다는 말을 하는게 아니다. 사실 많은 ImmutableJS와 같은 라이브러리를 허용하는 다양한 알고리즘들이 빠르게 동작한다. 

 

즉 게임 개발과 같이 마이크로 세컨드 단위가 극히 중요한 케이스에서는 사이드 이펙트를 줄이기 위해 가변 데이터를 어떻게 다루어야 할지 다른 방법을 강구해 볼 수 있다. 

 

하지만 프론트엔드 개발과 같은 영역에서는 불변성 구조가 이상적이다. 사실 리엑트와 같은 프레임워크에서는 이 점을 컴포넌트의 변경이 이루어졌는지와 리렌더링을 올바로 실행하기 위한 이해하고 쉽게 가변성 상태 컴포넌트를 다루기 위해 이용한다.

 

따라서 불변성 구조의 힘들 무시하지 말고 적절한 곳에서 적절한 컨텍스트로 활용하라.