[생각을 곁들인 리액트] 2-2. 함수형 프로그래밍 개념 도입하기

seungrodotlog·2024년 3월 13일
post-thumbnail

이번 편은 사실 어떻게 보면 메인 주제와는 크게 관련이 없을 수도 있습니다. 또한, 함수형 프로그래밍의 개념은 호불호가 갈리는 편이기도 하고, 상황에 따라 맞지 않을 수도 있습니다.

다만 개인적으로 함수형 프로그래밍에 관심이 많은 입장에서, 즐겨 쓰는 기술 및 방법들을 설명하고자 일종의 번외 느낌으로, 본 글을 작성해 봅니다.

함수형 프로그래밍이란?

우리에게 함수라는 단어는 익숙합니다.

const sum = (a, b) => a + b;

input을 받아서, 특정 로직을 실행하고 output을 반환하는 것이 함수입니다. 우리에겐 위 코드와 같은 함수가 익숙하지만, 사실 이러한 코드를 알기 전에도 우리는 함수라는 것을 알고 있었습니다.

f(x,y)=x+yf(x, y) = x + y

자, 그런데 수학에서 이러한 함수의 중요한 특징에 대해 기억하고 계신가요?

함수의 중요한 조건 중 하나는, 하나의 input에 대해 하나의 output이 결정된다는 것입니다.

예를 들어, y=x2y = x^2라는 식이 있다고 할 때, yy(output)은 xx(input)에 의해 결정되고, 각 yy 값은 하나의 xx 값에 대응됩니다. 때문에 yy를 종속변수, xx를 독립변수라고도 부릅니다.

다음으로 변수에 대해 살펴보겠습니다. 변수란 무엇인가요?

let a = 10;
a = 15;

우리가 생각하는 변수는 이러한 모습입니다. 변하는 값이라고도 흔히 부릅니다. 그런데 개발을 배우기 전의 변수도 이러한 모습이었나요?

수학에서의 변수는 중간에 변하지 않습니다. 다만 특정 식에 대해 사전에 정해지지 않은 값으로 작용하여, 어떤 값이 주어지냐에 따라 결과를 달리 할 수 있는 역할을 합니다. 변하는 값은 맞지만, 이것이 중간에 변하는 값이라는 뜻을 의미하지는 않습니다.

우리가 흔히 작성하는 함수는 수학의 그것에서 개념을 따왔지만, 그 원칙을 지키지는 않고 있습니다. 그리고 이것은 가끔 혼란을 야기합니다.

let a = 10;

const sumA = (value) => {
  a += value;
  
  return a;
}

sumA(5);
sumA(5);

위 코드의 실행 결과, 첫 sumA와 두 번째 sumA는 서로 다른 결과를 출력하게 됩니다. 이는 앞서 언급했던 '하나의 input에 대해 하나의 output이 결정된다'라는 사실에 위배됩니다. 이를 달리 말하면 '함수의 실행 결과가 일관되지 않는다'라는 것을 의미합니다.

함수형 프로그래밍의 개념은 이러한 문제점에 집중하고, 보다 더 수학에서의 그것과 가까운 함수를 구현하고자 합니다. 이를 위해 함수형 프로그래밍에서는 다음과 같은 원칙들을 중요시 합니다.

  • 함수형 프로그래밍의 개념에서, 변수는 '불변성'을 가집니다.
  • 함수형 프로그래밍의 개념에서, 함수 실행은 외부 변수에 의존하지 않습니다.
  • 함수형 프로그래밍의 개념에서, 지역 변수는 최소한으로 사용합니다.

흔히들 함수형 프로그래밍을 사용하면 세부적인 과정은 함수 내에 숨기고, 함수들을 연쇄적으로 호출하면서 그 흐름을 쉽게 파악할 수 있다고 합니다. 개인적인 생각에 이는 지역변수와 가변성 변수의 사용을 최소화 함에 따라 자연스럽게 함수의 분리가 일어나기 때문에 발생하는 자연스러운 효과라고 생각합니다.

서론이 길었습니다만, 이제 이전 글에서 만들었던 Modal 컴포넌트를 리팩토링 해보며 함수형 프로그래밍 개념에서 자주 사용되는 기술들을 간단하게 소개해보겠습니다.

커링 사용하여 불필요한 익명 함수 제거하기

먼저 지난 번 작성했던 함수 중 useMemo 부분을 살펴보겠습니다.

const { header, body, footer } = useMemo(() => {
  if (!Array.isArray(children)) {
    throw Error('Modal 컴포넌트의 자식 요소는 단일 요소일 수 없습니다.');
  }

  const partsMap = new Map(
    (['Header', 'Body', 'Footer'] as const).map(
      (name) =>
        [Modal[name], name.toLowerCase()] as [
          string | JSXElementConstructor<any>,
          string
        ]
    )
  );

  return children
    .filter((child): child is ReactElement =>
      isTypeOfElement([...partsMap.keys()], child)
    )
    .reduce(
      (result, child) => ({
        ...result,
        [partsMap.get(child.type)!]: child,
      }),
      {} as Record<'header' | 'body' | 'footer', ReactElement>
    );
}, []);

가장 먼저 보이는 부분은 partsMap을 선언하는 부분이 너무 길다는 것입니다. 부품과 부품의 키 엔트리로 매핑하는 부분을 분리해보겠습니다.

const mapEntriesOfPartsWithKey = (): [
  string | JSXElementConstructor<any>,
  string
][] => {
  return (['Header', 'Body', 'Footer'] as const).map((name) => [
    Modal[name],
    name.toLowerCase(),
  ]);
};

//...

const partsMap = new Map(mapEntriesOfPartsWithKey());

여기서, 함수를 어느정도로 분리할 것이냐는 개인의 취향에 따라 결정할 수 있을 것 같습니다. 예를 들어 누군가는 new Map(...) 전체를 분리하고 싶을 수도 있고, 누군가는 map 메소드에 전달되는 mapper 콜백만 분리하고 싶을 수도 있습니다. 개인적으로는 위와 같이 분리 하였을 때 mapPartsWithKeyEntries 함수 자체도 이해하기 쉽고, 호출되는 부분 역시 보기에 불편함이 없다고 생각되어 이렇게 나누었습니다.

다음으로는 필터링 하는 부분이 아쉽습니다.

return children
  .filter((child): child is ReactElement =>
    isTypeOfElement([...partsMap.keys()], child)
  )
  .reduce(
    (result, child) => ({
      ...result,
      [partsMap.get(child.type)!]: child,
    }),
    {} as Record<'header' | 'body' | 'footer', ReactElement>
  );

이 부분 역시 filter 메소드에 전달되는 predicate를 별도 함수로 선언하여 분리할 수 있습니다. 그러나 잘 보면, predicate에 해당하는 함수가 내부적으로 단 하나의 함수만 호출하는 역할을 하고 있기 때문에, 뭔가 굳이 이것을 따로 별도의 함수로 분리하기는 아까울 수 있습니다.

개발 작업을 진행하면서 이런 상황은 생각보다 자주 발생하는데요, 이 때 사용할 수 있는 좋은 기술이 있습니다. 바로 커링이라는 기술입니다.

커링은 쉽게 말해, 함수를 여러번에 걸쳐 호출할 수 있도록 하는 개념입니다.

const sum = (a, b) => a + b;

const curriedSum = (a) => (b) => a + b;

여기 sumcurriedSum 두 함수가 있습니다. 당연하게도 sum은 일반 함수, curriedSum은 커링 된 sum 함수입니다. curriedSum 함수는 다소 특이하게 생겼죠. 바로 함수를 리턴하는 함수입니다. curriedSum을 사용하면 a의 값이 고정된 상태에서, 즉, a의 상태를 보존한 상태에서, b의 값을 추후에 입력받아 함수를 지연 호출할 수 있습니다.

const sumTen = curriedSum(10);

sumTen(5); // sum(10, 5)와 같다.
sumTen(20); // sum(10, 20)과 같다.

이 점을 잘 이용하면, 우리가 작성한 코드도 아래와 같이 개선할 수 있을 것 같습니다.

return children
  // .filter(child => isTypeOfElement([...partsMap.keys()])(child))와 같습니다.
  .filter(isTypeOfElement([...partsMap.keys()]))
  .reduce(
    (result, child) => ({
      ...result,
      [partsMap.get(child.type)!]: child,
    }),
    {} as Record<'header' | 'body' | 'footer', ReactElement>
  );

큰 차이는 아니지만 익명함수가 제거되면서 코드가 좀 더 직관적으로 개선되었습니다.

그럼 이제 위와 같이 코드를 구현하기 위해 isTypeOfElement 함수를 수정해보겠습니다.

const isTypeOfElement =
  (types: (string | JSXElementConstructor<any>)[]) =>
  (child?: ReactNode): child is ReactElement => {
    return isValidElement(child) && types.includes(child.type);
  };

커링 함수 개선하기

자, 그런데 만약 isTypeOfElement 함수를 지연 호출하지 않고 바로 호출하고 싶으면 아래와 같은 형태로 호출해야 합니다.

isTypeOfElement(types)(child);

음... 개인적으로 괄호가 불필요하게 하나 더 추가되는 느낌이라 그리 매력적인 코드는 아닌 것 같습니다. 지연 호출을 하고 싶지 않은 경우를 고려하여, 조건에 따라 클로저를 반환하거나, 바로 함수 실행 결과를 호출하도록 수정해보겠습니다.

const isTypeOfElement = (
  types: (string | JSXElementConstructor<any>)[],
  child?: ReactNode
) => {
  if (!child) return (child: ReactNode) => isTypeOfElement(types, child);

  return isValidElement(child) && types.includes(child.type);
};

함수 오버로드 활용하기

이렇게 작성하면 기능상 문제는 없지만, 타입스크립트 오류가 발생됩니다. 조건문에 의해 isTypeOfElementboolean | (child: ReactNode) => boolean 타입을 반환하게 되고, 이로 인해 타입 가드의 역할도 할 수 없게 될 뿐더러, 유니온 타입으로 추론됨에 따라 호출 시마다 타입스크립트 오류와 마주칠 수 있습니다. 이를 해결하기 위해서는 함수 오버로드를 사용해야 합니다.

함수 오버로드를 사용하면 함수 호출 시 입력받은 매개변수의 형태에 따라 서로 다른 리턴 타입을 가질 수 있습니다. 말만 들어보면 유니온 타입과 크게 다르지 않아 보이지만, 유니온 타입과 다른 점은 매개변수 형태, 즉, 매개변수의 타입 및 구성에 따라 반환 타입이 '정확하게' 추론된다는 점입니다.

// isTypeOfElement(types, child)는 child is ReactElement 타입으로 추론됩니다.
function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[],
  child: ReactNode
): child is ReactElement;

// isTypeOfElement(types)는 (child: ReactNode) => child is ReactElement 타입으로 추론됩니다.
function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[],
): (child: ReactNode) => child is ReactElement;

function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[],
  child?: ReactNode
) {
  if (!child) return (child: ReactNode) => isTypeOfElement(types, child);

  return isValidElement(child) && types.includes(child.type);
}

최종

마지막으로, reduce 함수에 전달하는 accumulator도 별도의 함수로 분리하고 나면

const partsAccumulator =
  (partsMap: Map<string | JSXElementConstructor<any>, string>) =>
  (
    result: Record<'header' | 'body' | 'footer', ReactElement>,
    child: ReactNode
  ) => {
    return {
      ...result,
      [partsMap.get(child.type)!]: child,
    };
  };

전체 코드는 다음과 같이 리팩토링 됩니다.

function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[],
  child: ReactNode
): child is ReactElement;

function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[]
): (child: ReactNode) => child is ReactElement;

function isTypeOfElement(
  types: (string | JSXElementConstructor<any>)[],
  child?: ReactNode
) {
  if (!child) return (child: ReactNode) => isTypeOfElement(types, child);

  return isValidElement(child) && types.includes(child.type);
}

const mapEntriesOfPartsWithKey = (): [
  string | JSXElementConstructor<any>,
  string
][] => {
  return (['Header', 'Body', 'Footer'] as const).map((name) => [
    Modal[name],
    name.toLowerCase(),
  ]);
};

const partsAccumulator =
  (partsMap: Map<string | JSXElementConstructor<any>, string>) =>
  (
    result: Record<'header' | 'body' | 'footer', ReactElement>,
    child: ReactElement
  ) => {
    return {
      ...result,
      [partsMap.get(child.type)!]: child,
    };
  };

const _Modal = forwardRef<HTMLDivElement, ModalProps>(
  ({ visible, css, children, ...props }, ref) => {
    const { header, body, footer } = useMemo(() => {
      if (!Array.isArray(children)) {
        throw Error('Modal 컴포넌트의 자식 요소는 단일 요소일 수 없습니다.');
      }

      const partsMap = new Map(mapEntriesOfPartsWithKey());

      return children
        .filter(isTypeOfElement([...partsMap.keys()]))
        .reduce(
          partsAccumulator(partsMap),
          {} as Record<'header' | 'body' | 'footer', ReactElement>
        );
    }, []);

    return (
      <>
        {visible && (
          <div css={backdropStyle}>
            <div css={[css, modalStyle]} ref={ref} {...props}>
              <header>{header}</header>
              <main>{body}</main>
              <footer>{footer}</footer>
            </div>
          </div>
        )}
      </>
    );
  }
);

Modal의 핵심 로직을 담당하는 useMemo를 읽어보면 다음과 같습니다.

if (!Array.isArray(children)) { // 만약 children이 배열이 아니면 오류를 반환하고
   throw Error('Modal 컴포넌트의 자식 요소는 단일 요소일 수 없습니다.');
}

const partsMap = new Map(mapEntriesOfPartsWithKey()); // 부품과 키 쌍의 엔트리로 'partsMap' 맵을 만들고

return children
  .filter(isTypeOfElement([...partsMap.keys()])) // 부품들의 타입에 해당하는 children들을 필터링 하여
  .reduce( // 'parts' 객체를 누산한다.
    partsAccumulator(partsMap),
    {} as Record<'header' | 'body' | 'footer', ReactElement>
  );

개인적으로는, 실제 작업 간 소스코드를 작성할 때에는 FxTS, ts-pattern 등을 사용하여 좀 더 함수형 프로그래밍의 개념을 적용시키는 편입니다. 다만, 위 코드 정도만 되어도 함수형 프로그래밍의 특징을 살펴보기에는 충분한 것 같습니다.

마무리

개인적으로는 몇 달 정도 전부터 함수형 프로그래밍의 매력에 빠져 있습니다. 함수형 프로그래밍은 분명 처음 접했을 때 생소하고 어려운 부분도 있고, 상황에 따라서는 좋은 선택지가 아닐 수도 있습니다. 특히 자바스크립트에서는 객체와 함께 사용하다보면 pipe와 같은 문법을 사용하다보면 this와 관련된 문제가 발생할 수도 있습니다. (물론 이를 해결할 수 있는 방법은 존재합니다)

그러나 함수형 프로그래밍 진영에서 주로 활용되는 문법들을 잘 적용하면 꼭 함수형 프로그래밍의 문법, 개념으로 개발 작업을 진행하지 않더라도, 좀 더 깔끔한 코드를 작성하는데에 도움이 될 수 있습니다.

profile
주니어 승로의 개발승록.

0개의 댓글