중첩된 불변 객체의 필드 여러 군데를 한꺼번에 업데이트 하려면?

지난 번에는 타입스크립트에서 중첩된 불변 객체의 필드 업데이트를 편리하게 해주는 렌즈 라이브러리를 소개했습니다.

lens.ts: 하스켈 렌즈가 타입스크립트로

이번 시간에는 필드 한 군데만 아니라 불변 객체의 여러 필드를 한꺼번에 업데이트하는 방법을 알려드립니다.

사전 지식

이 글을 보시기 전에 아래 내용에 대해서 알고 있는지 확인하십시오.

  • 타입스크립트
  • 불변 객체(Immutable Object)
  • 렌즈(Lens, of Optics)

복습: 필드 하나 업데이트 하기

지난 시간에 배운 것을 복습해보겠습니다.

const prev_state: ST = {
  level1: {
    level2: {
      level3: {
        value: 'value'
      }
    }
  }
}

참고: 실습 예제에서 다루는 최상위 불변 객체의 타입을 ST 라고 하겠습니다.

위와 같이 타입 ST의 객체가 있을 때,

const new_state = {
  ...prev_state,
  level1: {
    ...prev_state.level1,
    level2: {
      ...prev_state.level1.level2,
      level3: {
        ...prev_state.level1.level2.level3,
        value: 'other_value'
      }
    }
  }
}

이렇게 불편하게 안 해도 되고,

import { lens } from 'lens.ts'

const new_state = lens<ST>().level1.level2.level3.value.set('other_value')(prev_state)

이렇게 하면 된다고 배웠지요?

렌즈 라이브러리가 있으면 필드 하나 업데이트 하는 것은 이렇게 쉬웠습니다.

그러면 한꺼번에 서로 다른 여러 필드를 업데이트 하려면 어떻게 해야 할까요?

도전: 여러 군데 필드 변경하기

어떤 객체에서 다양한 위치의 여러 군데 필드를 업데이트 해보겠습니다.

const prev_state: ST = {
  target1: 'target1',
  target2: false,
  level1: {
    level2: {
      target3: 123
    }
  }
}

target1, target2, target3의 값을 임의의 다른 값으로 바꿔보겠습니다. 어떻게 해야할까요?

일단 렌즈 라이브러리 자체에는 그런 기능이 없습니다.

그래도 일단 해보겠습니다.

const target1_updated = lens<ST>().target1.set('another_target1')(prev_state)
const target2_updated = lens<ST>().target2.set(true)(target1_updated)
const target3_updated = lens<ST>().level1.level2.target3.set(456)(target2_updated)

// target3_updated
const new_state: ST = {
  target1: 'another_target1',
  target2: true,
  level1: {
    level2: {
      target3: 456
    }
  }
}

자, 이렇게 하면 될까요?

문제점

원하는 결과는 얻을 수 있지만…

  • 한꺼번에 하라고 했는데, 여러 번 끊어서 업데이트 했다.
  • 중간 참조(target1_updated, target2_updated, …)를 만들어야 한다.
  • 안 예쁘다

그렇습니다. 안 예쁜 게 가장 큰 문제입니다.

개선: reduce()를 응용해봅시다.

위에서 prev_state와 중간 결과 target1_updated, target2_updated, …의 타입은 모두 ST로 같습니다.

뭔가 패턴이 보이죠? 어떤 타입의 출력을 다시 입력으로 집어넣어서 같은 타입의 새 출력을 만듭니다.

접기(fold) 혹은 축소(reduce) 연산을 적용할 수 있습니다.

const new_state = [
  lens<ST>().target1.set('another_target1'),
  lens<ST>().target2.set(true),
  lens<ST>().level1.level2.target3.set(456)
].reduce((state, apply) => apply(state), prev_state)

위에서 세 번에 걸쳐서 수행한 업데이트를 합성해서 하나로 만든 것입니다.

자세히 보시면, 배열에 넣은 lens<ST>(). ... .set(xxx) 부분에서 마지막에 stateapply하지 않은 것을 알 수 있습니다.

apply라 함은 함수(xxx)와 같이 매개변수를 넘겼다는 것입니다. 그냥 마 함수 호출

즉 배열에 있는 값들은 모두 ST => ST 타입의 함수들입니다. 업데이트를 수행한 후에 ST 타입의 결과를 반환하는 함수들입니다.

reduce()를 통해 이 함수들을 여러 군데의 필드를 업데이트하고 새 객체를 반환하는 하나의 함수로 합성했습니다.

네, 정확히 말하자면 함수로 합성한 게 아니라 바로 결과를 뽑아낸 것이고, 진짜로 합성된 함수를 얻으려면 아래와 같이 하면 됩니다. (따질까봐 다시 설명-_-)

function update_target1_target2_target3(
  state: ST,
  target1: string,
  target2: boolean,
  target3: number
): ST {
  return [
    lens<ST>().target1.set(target1),
    lens<ST>().target2.set(target2),
    lens<ST>().level1.level2.target3.set(target3)
  ].reduce((prev_state, apply) => apply(prev_state), state)
}

심화: 성능 고려

위에서 lens<ST>(). ... 이런 식으로 인라인으로 렌즈 함수를 사용했지만, 사실 아래와 같이 하는 게 권장됩니다.

const stLens = lens<ST>()
const target1Lens = stLens.target1
const target2Lens = stLens.target2
const target3Lens = stLens.level1.level2.target3

...

function update_target1_target2_target3(
  state: ST,
  target1: string,
  target2: boolean,
  target3: number
): ST {
  return [
    target1Lens.set(target1),
    target2Lens.set(target2),
    target3Lens.set(target3)
  ].reduce((prev_state, apply) => apply(prev_state), state)
}

성능 때문에 그렇습니다만(매번 함수 생성하지 않도록), 무시할 만하면 그냥 인라인으로 써도 될 것 같습니다.

결론

필드 여러 군데 업데이트도 문제가 없습니다.

써보세요.

감사합니다.

내용이 유익했다면 추천과 공유하기 부탁드립니다.

References