abonglog logoabonglog

복잡한 상태관리, 함수형으로 생각하며 리팩토링하기 의 썸네일

복잡한 상태관리, 함수형으로 생각하며 리팩토링하기

함수형 자바스크립트
프로필 이미지
yonghyeun

최근 진행하고 있는 사이드 프로젝트에서 절차 지향적으로 작성된 코드들을 함수형으로 리팩토링 하며 느꼈던 점들을 기존 지식들을 회상하며 다시 작성한다.

함수형 패러다임이란

함수형 패러다임이란 모든 프로그램을 하나의 함수로 보고 그 안에서 다양한 순수 함수들을 합성하여 함수를 만들어내는 프로그래밍 패러다임을 의미한다.

순수함수란 동일한 입력에 대해 항상 동일한 출력값을 반환하는 함수를 의미한다. 순수 함수라는 점이 함수형 패러다임에서 중요한 이유 는 함수 합성 과정에서 부수효과가 존재하는 함수의 경우 매번 동일한 입출력을 보장 할 수 없어 안정성이 떨어지기 때문이다.

함수형 패러다임에서 가장 큰 목적으로 두는 부분들은 다양하지만 그 중 이번 리팩토링 단계에서 느꼈던 점은 함수 합성과 지연평가이다.

코드스멜이 났던 기존 구조

우선 백그라운드를 좀 살펴보자

현재 나는 블록 기반 텍스트 에디터를 만들며 블록 기반 에디터의 input 요소들을 관리하는 스토어를 생성하고 있다.

인풋 요소들을 Segment라 정의했다.인풋 요소들을 Segment라 정의했다.

현재 zustand 를 이용하고 있는 상태 관리 스토어에서 거대한 상태의 인터페이스는 이렇게 생겼다.

실제 Store에서 사용하는 State, 중첩된 Map 형태
interface Segment {
  /* ... 생략 */
  clientSegmentId : string;
  segmentText : string;
  segmentId : null | string;
}
 
export type ParagraphOrder = number;
export type SegmentOrder = number;
 
export type SegmentMap = Map<SegmentOrder, Segment>;
// 📌 실제 Store에서 사용하는 State, 중첩된 Map 형태
export type ParagraphMap = Map<ParagraphOrder, SegmentMap>;

이런 중첩되어있는 Map 형태에서 가장 말단에 존재하는 특정 Segment 를 수정하기 위한 상태 변경 메소드는 changeSegment 메소드는 이런 타입으로 구현되어 있었다.

변경하길 원하는 Segment의 식별자와 새로운 Segment를 인수로 받았음
  changeSegment: (segmentChange: {clientSegmentId : string; newSegment : Segment}) => void;

이후 스토어 내부에서 인수로 받은 식별자인 clientSegmentId 를 이용해 중첩되어 있는 Map 내부에서 변경하고자 하는 Segment 를 찾아내고 두 번째 인수로 준 newSegment 로 교체하는 과정을 거친다.

기존 구조의 코드 스멜을 알아보자

함수에서 인수를 원시값이나 객체로 받는 행위 자체는 꽤나 익숙한 구조일 것이다.

그럼 저 changeSegment 들을 사용하여 input 요소에 붙을 핸들러 중 segmentText 값을 수정하는 핸들러의 모습을 살펴보자

changeSegment를 이용한 핸들러 예시
const useCorrectionSegmentInput = ({
  paragraphOrder,
  segmentOrder,
}: DiaryWriteSegmentInputProps) => {
  // 1. 기존의 Segment 를 구독
  const segment = useDiaryEditorStore(
    (state) => state.paragraphs.get(paragraphOrder).get(segmentOrder));
 
  const changeSegment = useDiaryEditorStore((state) => state.changeSegment);
 
  // 2. 기존 Segment의 식별자와 새로운 값을 이용해 업데이트 
 const handleChangeSegmentText = (text: string) => {
    changeSegment({
      clientSegmentId: segment.clientSegmentId,
      nextSegment: {
        ...segmentState,
        segmentText: text,
      },
    });
  };
...

개인적으로 이 핸들러를 보며 느껴졌던 코드 스멜들을 회상해본다.

1. 상태 변경 자체는 사실 테스트 하기가 어렵다

우선 가장 크게 느껴졌던 부분은 리액트의 상태 관리 업데이트는 테스트 하기가 어렵다.

예를 들어 호출 될 때 마다 인수의 값을 하나씩 증가시키는 순수 함수가 있다고 가정해보자

이런 순수함수는 단순 호출 시 출력값이 입력값보다 1 큰지만 확인하면 된다.

하지만 useState 와 같은 상태 값으로 정의되어 있는 로직이 잘 동작하는지 확인하기 위해선 상태 관리가 영향을 미치는 Virtual DOM -> Actual DOM 의 변화를 테스트 해야 한다.

2. 상태 변경 로직이 인수의 인터페이스에 영향을 받는다.

현재의 changeSegment 함수 자체는 변경하고자 하는 상태의 식별자와 변경 할 새로운 상태를 인수로 받는다.

하지만 이런 경우를 생각해보자.

만약 changeSegment 함수를 호출하는 부분에서 기존 정의된 식별자가 아닌 다른 식별자요소를 통해 식별하고 싶다면 어떻게 해야 할까?

아마 다른 요소를 받는 핸들러를 또 생성해줘야 할 것이다.

아마 이런 모양일 것이다.
changeSegmentBySomething1 = ({something1 : ... , segment : Segment})=> void;
 
changeSegmentBySomething2 = ({something2 : ... , segment : Segment})=> void;
 
changeSegmentBySomething3 = ({something3 : ... , segment : Segment})=> void;

이러한 일이 발생하는 이유는 changeSegment 라는 상태 변경 메소드가 인수 자체의 인터페이스에 크게 종속 되어 있는 구조이기 때문일것이다.

그렇기에 받고자 하는 인수가 변경된다면 변경된 인수에 종속되는 다른 상태 변경 메소드를 또 만들어야 한다.

즉, 상태 변경을 유발하는 컴포넌트들에선 다양한 요구 사항이 존재 할 수 있는데 상태변경 메소드 자체는 여러 요구사항을 만족 할 수 없는 구조이다.

3. 현재 구조는 추상화 수준이 낮아 이해하기 어렵다.

다시 상태 변경 메소드인 changeSegment 를 호출하는 부분을 다시 살펴보자

가장 낮은 추상화 수준으로 정의된 핸들러
  const handleChangeSegmentText = (text: string) => {
    changeSegment({
      clientSegmentId: segmentState.clientSegmentId,
      nextSegment: {
        ...segmentState,
        segmentText: text,
      },
    });
  };

추상화 수준이 가장 낮은 단계는 어떠한 유명 함수도 호출하지 않는방식이다.

현재 changeSegment 의 인수에 들어가는 부분들은 단순 객체와 스프레드 문법으로만 작성 되어 있어 가장 낮은 추상화 수준으로 작성되어 있음을 알 수 있다.

처음에는 이 문제를 해결하기 위해 changeSegment 의 인수를 만드는 메소드를 만들어 해결하고자 했었으나 이는 올바른 접근 방식이 아녔음을 깨달았다.

처음 접근했던 잘못된 추상화 방식
changeSegment(createChangeSegment(...))

추상화 하고자 하는 요소는 어떤 요소를 찾아 어떤 형태로 수정 할 것인지 인데 현재의 방식은 단순 changeSegment 함수에서 기대하는 인수를 만드는 방식밖에 되지 않기 때문이다.

함수형으로 바꿔보자

구구절절 설명하기 전 변경된 형태를 먼저 보여주도록 한다.

변경된 상태변경 메소드의 타입
type Finder = (segment: Segment) => boolean;
type Updater = (segment: Segment) => Segment;
 
changeSegment: (finder: Finder,updater: Updater)

이전과 다르게 인수를 객체가 아닌 함수들로 받도록 상태 변경 메소드가 수정했다.

이를 통해 인수로 받을 인터페이스 구조에 종속되지 않고 해당 상태 변경 메소드를 호출하는 부분에서 제어 가능하게 되었다.

상태 변경 메소드를 호출하는 부분
import { hasSameClientId, updateSegmentId, updateSegmentText } from '../../lib';
 
...
 
const useCorrectionSegmentInput = ({
  paragraphOrder,
  segmentOrder,
}: DiaryWriteSegmentInputProps) => {
  const segment = useDiaryEditorStore(
    (state) => state.paragraphs.get(paragraphOrder).get(segmentOrder),
  );
  const changeSegment = useDiaryEditorStore((state) => state.changeSegment);
 
  const handleChangeSegmentText = (segmentText: string) =>
    changeSegment(hasSameClientId(segment), updateSegmentText(segmentText));

이런식으로 말이다.

이 리팩토링을 통해 무엇을 얻을 수 있었을까?

  1. 상태 변경 메소드를 호출하는 부분에서 상태 변경 로직을 제어 함으로서 더욱 유연한 구조를 가질 수 있다.
  2. 상태 변경 메소드를 호출하는 부분에서 인수를 유명 함수로 제공함으로서 추상화 수준을 높혀 어떤 로직들이 일어나는지 이해하기 쉬워졌다. (객체와 ... 로 범벅된 인수때와 비교해보자)
  3. 인수로 제공되는 함수들은 순수 함수로 구성되어 테스트 하기 매우 쉽다.
매우 단순한 구조들의 순수함수 1
import { Segment } from '@/shared/types/diary/shared/Editor';
 
export const hasSameClientId = (segmentA: Segment) => (segmentB: Segment) =>
  segmentA.clientSegmentId === segmentB.clientSegmentId;
매우 단순한 구조들의 순수함수 2
import { Segment } from '@/shared/types/diary/shared/Editor';
 
const updateSegmentWithPartial =
  (partialSegment: Partial<Segment>) =>
  (prevSegment: Segment): Segment => ({
    ...prevSegment,
    ...partialSegment,
  });
 
export const updateSegmentText = (segmentText: string) =>
  updateSegmentWithPartial({ segmentText });
 
export const updateSegmentId = (segmentId: string) =>
  updateSegmentWithPartial({ segmentId });

이 때 인수로 제공 될 함수들은 고계 함수 형태로 정의되어 있다.
함수를 값처럼 이용하는 함수형 패러다임 자체에서 커링을 통한 고계함수는 일반적인 패턴이다.

변경된 상태 변경 메소드의 구현체

타입만으로 직관적이지 않을 수 있어 zustand 내부에서 구현된 코드를 함께 첨부한다.

완전 완전 슈퍼 함수형으로 생각한다면 반복문을 재귀로 변경하고, mutableisUpdate 변수 자체도 재귀 함수의 인수로 집어넣고 그래야겠지만 적절히 섞어 쓰도록 했다.

changeSegment 구현체
      changeSegment: (finder, updater) => {
        set(({ paragraphs }) => {
          const newParagraphs = new Map(paragraphs);
          let isUpdated = false;
 
          for (const [paragraphOrder, segmentMap] of paragraphs) {
            if (isUpdated) {
              break;
            }
 
            for (const [segmentOrder, segment] of segmentMap) {
              if (isUpdated) {
                break;
              }
 
              if (finder(segment)) {
                const updatedSegmentMap = new Map(
                  newParagraphs.get(paragraphOrder),
                );
                updatedSegmentMap.set(segmentOrder, updater(segment));
                newParagraphs.set(paragraphOrder, updatedSegmentMap);
                isUpdated = true;
              }
            }
          }
 
          return { paragraphs: newParagraphs };
        });
      },