중첩된 구조의 불변 객체를 부분 변경해서 새 객체를 만드는 코드를 쉽게 만들자.

하스켈 Lens 라이브러리를 타입스크립트로 포팅한 lens.ts 패키지를 소개합니다.

대상 독자

타입스크립트를 사용하면서, 불변성을 유지한 채 객체를 업데이트(객체 복사 + 부분 변경) 기능이 필요하신 분

  • 타입스크립트 (필수)
  • 불변 데이터를 숭상하시는 분
  • 특히, Redux를 사용하시는 분에게 유용합니다.

서론: 불변성을 유지하면서 객체를 부분 업데이트 할 때 JavaScript에서, 혹은 TypeScript에서 불편했던 점

JavaScript의 Spread 문법에만 의존해서 Redux 스테이트 업데이트 하다가 멘탈 붕괴하는 사례

예를 들어, 아래와 같은 클래스 구조가 있다고 합시다.

class RootState {
  constructor (
    public readonly nested1: Nested1,
    ...
  ) {}
}

class Nested1 {
  constructor (
    public readonly nested2: Nested2,
    ...
  ) {}
}

class Nested2 {
  constructor (
    public readonly nested3: Nested3,
    ...
  ) {}
}

class Nested3 {
  constructor(
    public readonly someValue: string,
    ...
  ) {}
}

보시면 생성자(constructor)에 프로퍼티를 선언하고 readonly 키워드를 먹였습니다. 이렇게 하면 타입스크립트에서 불변 클래스가 됩니다. 객체가 한 번 만들어지고 나면 변경되지 않습니다. (주의: any로 강제 타입 캐스팅을 하고 객체를 업데이트하면 불변성이 깨집니다.)

이 클래스를 바탕으로 인스턴스를 생성했을 때 객체 표현으로 바꿔보면 아래와 같습니다.

const state = {
  ...,
  nested1: {
    ...,
    nested2: {
      ...,
      nested3: {
        ...,
        someValue: 'someValue'
      }
    }
  }
}

이를 Redux의 어느 리듀서의 상태라고 보고, 액션에 의해서 state.nested1.nested2.nested3.someValue의 값을 바꿔야 한다고 생각해보십시오.

Q. Redux 리듀서가 뭐에요?
Redux 리듀서는 함수형 프로그래밍에서 State 모나드라고 불리는 일종의 함수인데,
간단히 말해서 (S, A) => S 형식의 함수입니다.
이것이 Redux에서는 (state, action) => new_state입니다.
다른 설명은 여기서 필요없고, 지금 맥락에서 중요한 것은 인자로 들어온 state는 절대 변경하면 안 되고, 새 객체를 만들어서 return해야 한다는 것입니다. 즉 state는 불변(immutable)입니다. (다 아시죠?)

(state: RootState, action: SomeAction): RootState => {
  switch (action.type) {
    case 'updateSomeValue':
      return {
        ...state,
        nested1: {
          ...state.nested1,
          nested2: {
            ...state.nested1.nested2,
            nested3: {
              ...state.nested1.nested2.nested3,
              someValue: action.value
            }
          }
        }
      }
    default:
      return state
  }
}

이때는 대략 정신이 멍해진다…

중첩구조 객체 state를 업데이트 하는 전형적인 reducer 코드입니다. JavaScript의 Spread 문법을 이용하면, 불변적으로 객체를 업데이트할 수 있습니다. 사실상 뼈와 살을 분리한 뒤(?) 재조립하는 모양입니다.

눈에도 잘 안 들어오고, 상당히 정신사납게 만드는 코드가 되었습니다.

이게 제대로 한 게 맞는가 싶은 느낌이 들지만, 아쉽게도 진짜로 이렇게 작성하는게 보통(…)입니다.

자바스크립트가 함수형 프로그래밍과 불변성을 제대로 활용하기에는 문법적 지원이 아직 미비하기 때문입니다.

리듀서 함수를 처음 한 두 개 만들 때는 할 만하지만, 자꾸 저런 코드를 쳐다보면 정말 질리게 됩니다.

서론: 하지만 Immutable.js가 출동하면 어떨까? 이! 뮤! 터! 블!

중첩된 객체 구조를 가진 상태 값의 업데이트를 위해 Immutable.js를 사용하는 방법에 대해서는, 유명한 velopert님의 글을 링크해두겠습니다.

React ❤️ Immutable.js – 리액트의 불변함, 그리고 컴포넌트에서 Immutable.js 사용하기

나쁘진 않습니다만, 결론적으로 여전히 사용하기에 불편합니다.

가장 치명적인 단점은 타입스크립트를 사용하면서 여러분이 열심히 정의해놓은 타입 정보를 활용하지 못한다는 것입니다.

이런 불편한 점을 경험해보신 rokt33r님의 글을 링크해놓겠습니다.

Immutable.js로도 부족한 이유

타입 정보를 활용하지 못한다는 것은 결국 타입 안전성을 상실하는 것이고, 타입스크립트를 이용하는 장점이 사라지는 것을 의미합니다.

본론: 타입스크립트 렌즈 라이브러리(lens.ts)

하스켈과 같은 전형적인 함수형 프로그래밍 언어에는 다음과 같은 특징이 있습니다.

  • 한 번 정해진 참조는 항상 같은 대상을 가리킨다.
  • 값을 제자리(in-place)에서 변경할 수 없다.

첫번째 특징은, JavaScript에서도 var, let를 사용하는 대신 항상 const만 사용해서 달성할 수 있습니다.

const something = {
  someValue: 'someValue'
}

// 불가능!!!
something = {
  someValue: 'someOtherValue'
}

// 가능(...)
something.someValue = 'someOtherValue'

두번째 특징은, 위와 같이, 사실 JavaScript에서는 강제되지 않고, TypeScript에서는 어느 정도 문법적으로 제약할 수 있지만, any로 강제 캐스팅 하는 등의 트릭을 사용하면 쉽게 파훼됩니다.

그렇더라도 항상 불변성을 지키려고 노력해야 합니다. 불변성을 추구하는 것은 안전한 프로그램을 작성하는 좋은 습관입니다. 함수형 프로그래밍뿐만 아니라 객체지향 프로그래밍에서도 그렇습니다.

하지만 사용하는 방법이 쉽지 않으면, 쉽게 불변성을 포기하는 타협을 하게 됩니다.

그러나 다행스럽게도 타입스크립트 세계에 불변 객체의 구세주와 같은 라이브러리가 있었으니, 바로 lens.ts 패키지입니다.

본론: 타입 안전성도 살리고, 코드도 간결하고 명확하게

기존 state객체는 보존하고, $.nested1.nested2.nested3.someValue의 값만 'someOtherValue'로 업데이트한 새 객체를 반환하는 코드를 생각해봅시다. (구조는 위에서 제시한 RootState 클래스 구조와 같습니다.)

  • JavaScript Spread 문법을 이용한 방법
const new_state = {
  ...state,
  nested1: {
    ...state.nested1,
    nested2: {
      ...state.nested1.nested2,
      nested3: {
        ...state.nested1.nested2.nested3,
        someValue: 'someOtherValue'
      }
    }
  }
}
  • Immutable.js를 이용한 방법
import { Map } from 'immutable'

// immutable.js의 컨테이너로 감싸서 만들어야 함 (전체 애플리케이션이 immutable.js와 강하게 엮이게 됩니다.)
const state = Map({
  ...,
  nested1: Map({
    ...,
    nested2: Map({
      ...,
      nested3: Map({
        ...,
        someValue: 'someValue'
      })
    })
  })
})

const new_state = state.setIn(['nested1', 'nested2', 'nested3', 'someValue'], 'someOtherValue')
  • Lens를 이용한 방법
import { lens } from 'lens.ts'

const new_state = lens<RootState>().nested1.nested2.nested3.someValue('someOtherValue')(state)

assert(new_state instanceof RootState)

참 쉽죠?

다른 방법과 비교해서 lens.ts의 장점은 아래와 같습니다,

  • 타입 안전성(Type-Safety)
    • (그럴 리가 없지만) 혹시 프로퍼티를 잘못 타이핑 했으면 tsc 컴파일러가 타입 오류를 잡아줍니다.
    • (아마도) IDE 코드 편집기에서 .을 타이핑할 때마다 탐색가능한 프로퍼티 팝업 및 자동 완성 지원
  • 코드량이 현저히 줄어든다.
  • 코드의 의미가 명확하다.

다른 기능도 많지만 이것만 알고 계셔도 유용하게 사용하실 수 있습니다.

결론

함수형 프로그래밍 진영에서는 이와 같은 라이브러리를 Optics Library(광학 라이브러리?)라고 부릅니다.

Lens는 Haskell에서 출발하였고, 다양한 언어 버전의 variations가 있습니다.

그러고 보니 라이브러리 이름이 렌즈, 모노클(단안경)이고 데이터 타입에도 Prism(프리즘?) 같은 게 있군요.

불변성이 본질인 함수형 프로그래밍 언어에서는 이미 이와 같은 라이브러리를 잘(그리고 아마 거의 유일한 수단으로) 활용하고 있었습니다.

저는 사실 전에 Scala로 개발을 하다가 Node.js 월드로 유배(?)를 온 경험이 있어서, 가끔 Scala에는 있는데 Node에는 없어서 아쉬운 것이 있을 때마다 구글에 XXX equivalent in Node/JavaScript/TypeScript를 검색해봅니다.

그러다보니 이런 것들을 발견하기도 했습니다.

자, 그러면 결론입니다.

TypeScript를 쓰시면서, 불변성을 추구하고, Redux 리듀서에서와 같이 객체 업데이트를 편리하게 하고 싶으시다면

lens.ts를 잘 활용해보시기 바랍니다.

감사합니다.

도움이 되셨다면, 좋아요와 구독을 눌러주시고 하트를 찍고 댓글을 남겨주시고, SNS에 공유해주세요!

References