고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

고차 컴포넌트 구현하기

고윤태 2023. 10. 27. 17:36
안녕하세요 이번에 작성하게 될 내용은 이번 프로젝트에서 제가 고차 컴포넌트를 사용하게 된 계기와 방법에 대해 글을 작성해 보겠습니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

저희 프로젝트는 일정 또는 QA 수정사항을 현재는 Jira를 사용하여 관리하지만 기존에는 Notion을 사용하여 관리했었습니다.

그러던 와중 PM님에 의해서 하나의 수정사항 요청이 생겼습니다.

 

 

 

지금 프로젝트를 진행하고 계시는 PM님이 친절하시고 꼼꼼하신 편이어서

 

 

상세 내역에 테스트 케이스를 작성해 주셨습니다.

 

 

즉 현재 화면에서 사용자가 설정할 수 있는 펫의 종류는 강아지 또는 고양이입니다.

강아지와 고양이는 각각 선택할 수 있는 필터의 옵션이 다르기 때문에 사용자가 선택한 펫의 종류가 바뀐다면 "전체"라는 값으로 초기화를 시켜주고 있었습니다.

하지만 오른쪽 맨 끝에 있는 옵션을 선택한 후 펫의 종류를 변경하게 된다면 선택된 값은 "전체"로 변경이 되지만 스크롤의 위치는 그대로 유지가 되어 있습니다.

저도 직접 확인을 하면서 유저의 입장에서 불편함을 느꼈고 PM 분이 한 발 빠르게 먼저 캐치해 주신 게 아닌가 싶습니다.

 

 

그래서 이번에 새로운 기능 개발을 위해 백엔드 분이 작업을 해주시는 동안 시간 여유가 생겨서 기능 개선에 들어가게 됐습니다.

🤔 기능 산출 및 개발 계획

현재 개발 상황을 가장 먼저 체크해야 합니다.

          <ChipContainer
            containerStyle={{
              flexDirection: 'row',
              height: 40,
            }}
            labels={isDog ? DOG_CATEGORY : CAT_CATEGORY}
            selectedLabel={category}
            type="single"
            chipStyle={{ marginRight: 8 }}
            onSelectedHandler={(label: string) => {
              if (label) {
                setCategory(label as SelectedCategory);
              }
            }}
          />

ChipContainer라는 컴포넌트를 사용해서 필터 목록을 구현하고 있는 상태였습니다.

https://yun-tech-diary.tistory.com/entry/React-Native-Chip%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90

 

React Native Chip을 구현해보자

개요 안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 제가 흔히 Chip이라 불리는 컴포넌트를 어떤 식으로 구현했고 어떤 식으로 사용하고 있는지 전달하고자 이렇게 글을 작성하게 됐

yun-tech-diary.tistory.com

ChipContainer 컴포넌트 같은 경우 제가 예전에 작성했던 Chip 구현 글 하단에 작성이 되어 있습니다.

여기서 발생한 문제는 현재 ChipContainer Wrap Tag는 View로 되어 있습니다.

View 태그는 scrollTo 같은 메서드가 없기 때문에 스크롤 위치를 스크립트로 컨트롤을 하려면 ScrollView를 사용해야 하죠

그렇다면 View 태그를 ScrollView로 변경 후 forwardRef를 사용하여 부모 컴포넌트에서 ref를 받아 부모에서 scrollTo를 이용하면 끝이 나겠구나 싶었습니다.

 

 

하지만 제 개인적인 욕심 때문에 돌아오게 생겼습니다.

 

 

위의 GIF를 보시면 아실 수 있지만 스크롤이 되지 않아도 괜찮은 부분이 스크롤이 가능해져 통통 튀는 모습이 

다른 분들은 어떠실지 모르겠지만 제 입장에서는 예쁘지 않았습니다.

(지극히 개인적인 견해입니다.)

스크롤이 가능해야 콘텐츠를 다 확인할 수 있는 부분에서는 저런 부분이 당연하게 느껴지지만 그러지 않은 곳에서 스크롤이 된다는 점은 미스라고 생각했습니다.

 

그렇다면 Wrap이 View인 것과 ScrollView인 것을 만들어야 한다는 말이 됩니다.

 

 

고민하다 가장 간단하게 해결을 할 수 있는 방법이 떠올랐습니다.

너무 간단한 방법인 Props의 parentType이라는 값을 추가하여 View를 사용할지 ScrollView를 사용할지 결정하는 것이었습니다.

간단하게 예시 코드를 준비했습니다.

import { View, ScrollView } from 'react-native';
import Chip from './Chip';

interface Props {
  parentType: 'View' | 'ScrollView';
  items: string[];
}

const Component = ({ parentType, items }: Props) => {
  const parent = (children: React.ReactNode) => {
    if (parentType === 'View') {
      return <View>{children}</View>;
    } else {
      return <ScrollView>{children}</ScrollView>;
    }
  };

  const child = items.map((v) => <Chip label={v} />);

  return <>{parent(child)}</>;
};

export default Component;

단순한 생각은 단순한 코드로 이어집니다.

import React from 'react';
import { View, ScrollView } from 'react-native';
import Chip from './Chip';

interface Props {
  parentType: 'View' | 'ScrollView';
  items: string[];
}

const Component = React.forwardRef<ScrollView | View | null, Props>(
  ({ parentType, items }, ref) => {
    const parent = (children: React.ReactNode) => {
      if (parentType === 'View') {
        return <View ref={ref}>{children}</View>;
      } else {
        return <ScrollView ref={ref}>{children}</ScrollView>;
      }
    };

    const child = items.map((v) => <Chip label={v} />);

    return <>{parent(child)}</>;
  }
);

export default Component;

이런 식으로 forwardRef를 적용하니 TypeScript에서 타입 에러를 발생했습니다.

각각 ref를 할당하는 곳에서 아래와 같은 에러 메시지를 노출했습니다.

'ForwardedRef <ScrollView | View | null>' 형식은 'LegacyRef <View> | undefined' 형식에 할당할 수 없습니다.
'ForwardedRef <ScrollView | View | null>' 형식은 'LegacyRef <ScrollView> | undefined' 형식에 할당할 수 없습니다.

 

저런 타입 에러는 즉 쉽게 설명하자면

A와 B라는 사람이 있다는 가정하에 A가 원하는 것은 딸기와 포도이고 B가 원하는 것은 사과와 포도인데
저는 A에게는 딸기, 포도, 사과를 B에게도 딸기, 포도, 사과를 줘서 자기들이 원하는 것만 주지 않아서 거절당한 상황입니다.

 

 

즉 현재 제 상황은 두 마리 토끼를 잡으려다가 다 놓친 것이 딱 맞는 비유인 것 같습니다.

이 문제를 해결하기 위해 또 한 번의 딜레마의 빠졌습니다. 사실상 ref를 이용하여 엘리먼트의 메서드를 사용할 곳은 ScrollView만 사용하는 곳에서만 할 것인데 

import React from 'react';
import { View, ScrollView } from 'react-native';
import Chip from './Chip';

interface Props {
  parentType: 'View' | 'ScrollView';
  items: string[];
}

const Component = React.forwardRef<ScrollView | null, Props>(
  ({ parentType, items }, ref) => {
    const parent = (children: React.ReactNode) => {
      if (parentType === 'View') {
        return <View>{children}</View>;
      } else {
        return <ScrollView ref={ref}>{children}</ScrollView>;
      }
    };

    const child = items.map((v) => <Chip label={v} />);

    return <>{parent(child)}</>;
  }
);

export default Component;

이런 식으로 ScrollView에만 ref를 할당하여 아래 코드와 같이 사용을 하고 끝을 낼까 고민을 했었습니다.

import React, { useRef } from 'react';
import { ScrollView } from 'react-native-gesture-handler';
import Component from '../components/ui/Component';

const Screen = () => {
  const ref = useRef<ScrollView | null>(null);

  return (
    <>
      <Component items={['test1', 'test2']} parentType='View' />
      <Component items={['test1', 'test2']} parentType='ScrollView' ref={ref} />
    </>
  );
};

export default Screen;

하지만 코드를 이렇게 마무리하게 된다면?

React의 컴포넌트 설계 원칙에 어긋나게 돼버립니다.

단일 책임 원칙(Single Responsibility Principle)을 어긋나 버리죠 이유는 'View'와 'ScrollView' 두 가지 역할을 모두 수행하도록 설계되었습니다.

이 뜻은 확정성도 떨어트린다는 말이 됩니다.
parentType prop에 따라 다른 동작을 수행하는 로직이 포함되어 있습니다. 만약 다른 종류의 parentType이 추가된다면 해당 로직 부분은 계속 수정되어야 합니다.

 

만약 저와 같은 상황에서는 고차 컴포넌트를 활용하는 게 좋습니다.

고차 컴포넌트를 제 생각에 쉽게 비유하자면 (물론 다른 분들의 생각과 차이가 있을 수도 있습니다.)

 

 

반죽 틀이라고 생각하면 좋을 것 같습니다. 같은 내용물을 갖고 있지만 틀만 다르기 때문에 적절한 비유가 아닐까 싶습니다.

 

근데 왜 고차 컴포넌트로 해요? custom hook은 왜 안 써요?

custom hook은 리액트 관련 로직(상태 관리, 사이드 이펙트 처리 등)만을 추상화하고 재사용하는 데 사용됩니다. 따라서 custom hook으로 parentType prop에 따라 다른 종류의 요소를 렌더링 하는 것은 불가능합니다.

결론적으로, 여기서 요구되는 것은 "컴포넌트 로직"의 재사용이 아니라 "컴포넌트 자체"의 변형과 장식(decoration)입니다. 이런 경우엔 고차 컴포넌트 패턴이 가장 적합하다고 할 수 있습니다.

 

고차 컴포넌트 예시를 보여드리겠습니다,

간단하면서 실용적인 예시를 준비했습니다.

유저의 프로필을 보여주려고 합니다. 하지만 로그인이 되어 있지 않다면 로그인을 유도하는 화면을 보이게 하겠습니다.

 

 

구조는 사진과 같이 간단하게 준비했습니다.

 

App.tsx

import React from 'react';
import { View, Text } from 'react-native';

const isLoggedIn = false;

const LoggedInComponent = () => {
  return (
    <View style={{ margin: 16 }}>
      <Text style={{ fontSize: 18 }}>환영합니다.</Text>
      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>고윤태님</Text>
    </View>
  );
};

const LoggedOutComponent = () => {
  return (
    <View style={{ margin: 16 }}>
      <Text style={{ fontSize: 18 }}>아직 로그인을 안하셨네요.</Text>
      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>
        로그인을 해주세요!
      </Text>
    </View>
  );
};

const withAuth = (Component: React.FC) => {
  const AuthComponent = (props: {}) => {
    if (isLoggedIn) {
      return <Component />;
    } else {
      return <LoggedOutComponent />;
    }
  };
  return AuthComponent;
};

const AuthComponent = withAuth(LoggedInComponent);

const App = () => {
  return <AuthComponent />;
};

export default App;

코드 설명


isLoggedIn : 변수는 현재 사용자의 로그인 상태를 나타내는 부울(Boolean) 값입니다. 코드의 시작 부분에서 false로 초기화되어 있어 사용자가 로그인하지 않은 상태를 나타냅니다.

LoggedInComponent : 사용자가 로그인한 경우에 표시되는 컴포넌트입니다. 환영 메시지와 사용자 이름을 포함한 UI를 렌더링 합니다.

LoggedOutComponent : 사용자가 로그인하지 않은 경우에 표시되는 컴포넌트입니다.

withAuth : 함수는 고차 컴포넌트(Higher-Order Component, HOC)를 생성하는 함수입니다. 이 함수는 Component라는 매개변수로 전달된 컴포넌트를 감싸고, isLoggedIn 변수의 상태에 따라 LoggedInComponent 또는 LoggedOutComponent 중 하나를 렌더링 하는 새로운 컴포넌트를 반환합니다.

AuthComponent : withAuth 함수를 사용하여 생성된 고차 컴포넌트입니다. 이 컴포넌트는 isLoggedIn 변수를 기반으로 사용자의 로그인 상태를 확인하고 Component 또는 LoggedOutComponent 중 하나를 렌더링 합니다.

 

withAuth 함수를 사용하여 컴포넌트 감싸기(HOC) 패턴을 활용한 코드입니다.

 

결과 화면

isLoggedIn false로 설정이 되어 있을 때

isLoggedIn true로 설정이 되어 있을 때

이런 예제처럼 고차 컴포넌트를 활용할 수 있습니다.

👀 컴포넌트 개발(CODE)

import React, { forwardRef } from 'react';
import { ViewStyle, ScrollView, View } from 'react-native';
import Chip from './Chip';

type SingleSelectedHandler = (label: string) => void;
type MultiSelectedHandler = (labels: string[]) => void;

interface Props {
  labels: string[];
  type: 'single' | 'multi';
  selectedLabel?: string | string[];
  onSelectedHandler?: SingleSelectedHandler | MultiSelectedHandler;
  containerStyle?: ViewStyle;
  chipStyle?: ViewStyle;
}

interface ScrollViewProps
  extends Props,
    React.ComponentProps<typeof ScrollView> {}

const ChipContainer =
  (Component: React.ComponentType<any>, forwardedRef?: React.Ref<ScrollView>) =>
  ({
    labels,
    type,
    selectedLabel,
    onSelectedHandler,
    containerStyle,
    chipStyle,
    ...restProps
  }: ScrollViewProps) => {
    const onPressHandler = (isActive: boolean, label: string) => {
      if (type === 'single') {
        if (isActive) {
          (onSelectedHandler as SingleSelectedHandler)?.('');
        } else {
          (onSelectedHandler as SingleSelectedHandler)?.(label);
        }
      } else if (type === 'multi') {
        const selectedLabels = Array.isArray(selectedLabel)
          ? [...selectedLabel]
          : [];

        if (isActive) {
          const index = selectedLabels.indexOf(label);
          if (index !== -1) {
            selectedLabels.splice(index, 1);
          }
        } else {
          selectedLabels.push(label);
        }

        (onSelectedHandler as MultiSelectedHandler)?.(selectedLabels);
      }
    };

    return (
      <Component style={containerStyle} ref={forwardedRef} {...restProps}>
        {labels.map((label) => {
          const isActive = Array.isArray(selectedLabel)
            ? selectedLabel.includes(label)
            : selectedLabel === label;

          return (
            <Chip
              key={label}
              label={label}
              style={chipStyle}
              isActive={isActive}
              onPressHandler={() => onPressHandler(isActive, label)}
            />
          );
        })}
      </Component>
    );
  };

export const ChipContainerView = ChipContainer(View);

export const ChipContainerScrollView = forwardRef<ScrollView, ScrollViewProps>(
  (props, forwardedRef) => ChipContainer(ScrollView, forwardedRef)(props)
);

코드 설명

 

ScrollViewProps는 Props와 ScrollView 컴포넌트에 대한 속성(props)을 함께 가지는 인터페이스입니다
이렇게 ScrollViewProps를 정의함으로써 ChipContainer 컴포넌트는 ScrollView 컴포넌트를 렌더링 할 때 ScrollView에 대한 속성(props)을 받아올 수 있게 됩니다.

 

forwardedRef (선택적): ScrollView 컴포넌트의 참조를 전달합니다. 이렇게 함으로써 ScrollView 컴포넌트에 대한 참조를 유지할 수 있습니다.

 

ChipContainer의 주된 장점은 여러 종류의 컴포넌트에 동일한 로직을 적용할 수 있다는 것입니다. 예를 들어, View와 ScrollView 컴포넌트에 동일한 Chip 로직을 적용하려면 ChipContainer 함수에 원하는 컴포넌트를 인자로 전달하면 됩니다.

 완성된 ChipContainer 활용하기

컴포넌트

          <ChipContainerScrollView
            containerStyle={{
              height: 40,
              width: fullWidth - 32,
            }}
            labels={isDog ? DOG_CATEGORY : CAT_CATEGORY}
            selectedLabel={category}
            type="single"
            chipStyle={{ marginRight: 8 }}
            onSelectedHandler={(label: string) => {
              if (label) {
                setCategory(label as SelectedCategory);
              }
            }}
            horizontal={true}
            showsHorizontalScrollIndicator={false}
            ref={scrollViewRef}
          />

기존에 목표했던 기능 추가

  useDidUpdate(() => {
    setCategory('전체');
    requestProduct({ isPageResetting: true, initCategory: '전체' });

    if (scrollViewRef.current) {
      scrollViewRef.current?.scrollTo({ x: 0 });
    }
  }, [petType]);

ref를 사용하여 스크롤 위치를 변경하면 드디어 처음에 목표했던 petType이 바뀌면 스크롤 위치를 초기화할 수 있습니다.

 

실행화면

GIF를 보시면 처음에 의도했던 기능대로 잘 작동하는 것을 확인할 수 있습니다.

또한 기존의 View 태그를 사용했던 곳은 기존과 똑같이 동작을 합니다.

😁 글 작성 후기

React를 사용하면서 고차 컴포넌트를 알고 있는 상태였지만 워낙 custom Hook과 재사용 컴포넌트 조합을 많이 사용하기에 고차 컴포넌트를 사용해 볼 기회가 없었습니다. (제가 필요한 곳에서도 사용하지 않았을 수도 있습니다🤫)

고차 컴포넌트 글조차 Class 형으로 작성된 공식 문서에서만 봤었습니다.

이번 기회를 통해 함수형으로 작성된 고차컴포넌트 글도 읽어보고 직접 내 상황에 맞게 코드로 옮긴 좋은 경험인 거 같습니다.

생각보다 앞으로 은근 활용할 곳이 많을 것 같다고 느껴졌습니다.

 

누군가 제 글을 읽었을 때 처음 글에 만족스럽지 못한 부분이다라고 작성된 것처럼 통통(?) 튕기는 부분을 포기할 수도 있었던 거였고

기존 코드를 컨트롤 C + V를 하여 새로운 컴포넌트를 만들 거나, ChipContainer를 포기하고 이곳에서만 ScollView 태그 안에 Chip을 만들어서 사용하고 넘어갈 수도 있는 부분이었을 수도 있지만

개인적으로 제가 Chip을 사용할 때 편리하게 사용하기 위해서 만든 ChipContainer를 개선해서 사용하고 싶었습니다.

그렇게 했을 때 제가 생각하는 장점은 다른 분들이 제 프로젝트에 투입이 되었을 때 Chip을 사용할 때는 ChipContainer를 이용하여 사용하면 된다는 것을 인지할 수 있습니다.

특정 상황에서는 ScrollView를 이용해서 Chip을 사용한다면 어떤 예외 케이스에서는 이런 식으로 사용하는 거지 하고 띠용? 할 수도 있습니다.

 

 

또 다른 이유로는 제 개발자 지조가 컨트롤 C + V를 하는 것을 좋아하지 않습니다. 유지보수를 해야 할 곳이 늘어나는 것이라고 생각하기 때문입니다.

혹시나 기존 코드에서 View 태그와 ScrollView를 활용하도록 변경한 지금의 코드를 녹여내지 못했더라면 전 망설임 없이 다시 구현했을 것 같습니다.

그 뜻은 기존의 제 코드라는 나무가 썩어있었다는 뜻이라고 생각합니다.

하지만 다행스럽게도 제 코드에 잘 녹일 수 있었습니다.

 

이번 작업은 간단한 기능에 비해 소요 시간은 꽤 있는 편이었지만 재미있는 개선 작업이었습니다.

 

'개발' 카테고리의 다른 글

React Native Tabs 구현하기  (1) 2023.11.24
React Native accordion(아코디언) 만들기  (1) 2023.11.07
채팅방 목록에 새로운 기능을 추가해보자  (0) 2023.10.19
React Native 채팅방 만들기  (2) 2023.10.12
React Native Picker  (2) 2023.09.19