patch-package로 npm 패키지 수정하기 (feat. rmc-picker)

unhyif·2023년 7월 2일
2

React

목록 보기
3/4
post-thumbnail

회사에서 rmc-picker라는 라이브러리를 사용하여 wheel picker를 구현하고 있었는데, 어느 순간부터 제대로 동작하지 않는 상황이 발생했다 😨 원인을 파악하고 나니 패키지 커스텀 밖에 답이 없어서 patch-package를 시도하게 되었고, 다행히 성공했다!

먼저 원인을 어떻게 찾게 되었는지를 회상해 보면서 이후의 patch 과정을 담아보려 한다.


문제 원인 파악

wheel picker가 스크롤될 때 실행되어야 하는 callback이 갑자기 실행되지 않았다. 그리고 wheel picker가 렌더링될 때 아래의 에러 메시지들이 뜨기 시작했다.

  • Warning: React does not recognize the selectedValue prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase selectedvalue instead. If you accidentally passed it from a parent component, remove it from the DOM element.
  • Warning: Unknown event handler property onScrollChange. It will be ignored.

매우 당황스러웠다. selectedValueonScrollChange는 라이브러리 docs에 명시된 prop들인데, 왜 ignore 된다는 거지...? 깃허브 issue를 확인해 보았지만 이에 대한 변경 사항은 없는 듯 했다.

2~30분을 이것저것 만져 보다가, 일단 에러 메시지부터 해결해 봐야겠다 싶어서 selectedValue가 DOM element에 넘겨졌는지 보려고 개발자 도구를 켜봤다.

확인해 보니, UI 커스텀을 위해 만들었던 side div에 selectedValue가 넘겨져 있었다. 이게 왜 여기로 넘어가지?


    <MultiPicker
        selectedValue={wheelPickerHook.selectedValues}
        onScrollChange={wheelPickerHook.handleScroll}
      >
        <div className="rmc-picker-indicator indicator side" />

위 코드의 div가 저 side div에 해당하는데, 난 selectedValue를 전혀 넘겨주지 않고 있다. 이건 라이브러리를 뜯어 봐야겠구나 싶어서 소스코드를 확인해 보았다.

확인해 보니, children의 각 child를 cloneElement하면서 3가지 prop을 넘겨주고 있었다.

나처럼 커스텀하는 경우는 고려되지 않은 설계였다. 아래의 라이브러리 example처럼 MultiPicker 하위에 Picker만 오면 괜찮은데,

나는 아래처럼 커스텀을 했기 때문이다... 🙃

      <MultiPicker
        selectedValue={wheelPickerHook.selectedValues}
        onScrollChange={wheelPickerHook.handleScroll}
      >
        <div className="rmc-picker-indicator indicator side" />
        {wheelPickerHook.itemLists.map((itemList, listIndex) => {
          const hasLeftGap = !!listIndex;
          return (
            <React.Fragment key={listIndex}>
              {hasLeftGap && (
                <div className="rmc-picker-indicator indicator gap" />
              )}
              <Picker indicatorClassName="indicator">
                {itemList.map(item => (
                  <Picker.Item
                    key={item.value}
                    className="item"
                    value={item.value}
                  >
                    <span>{item.text}</span>
                  </Picker.Item>
                ))}
              </Picker>
            </React.Fragment>
          );
        })}
        <div className="rmc-picker-indicator indicator side" />
      </MultiPicker>

라이브러리 설계상 Picker만 사용해서는 UI 커스텀이 불가능한 상황이었다. 아무튼, 소스코드에 따르면 내 코드에선 Picker가 아닌 side div와 Fragment가 cloneElement 되고 있었기 때문에 warning이 뜬 거였다. 그리고 Fragment 내에 있는 Picker는 clone 되지 못해서 props를 받지 못했으므로 스크롤될 때의 callback인 onScrollChange가 실행되지 않았던 것이었다. 갈 길은 멀어 보이는데 원인을 찾아서 기뻤다... 🎉

따라서, 난 다음의 action을 취해야 했다.

  1. 패키지를 수정해야 한다.
  2. Fragment 내의 PickercloneElement 되도록 해야 한다.

patch-package

patch-package는 node_modules의 변경 사항을 git으로 관리하여 어떤 실행 환경에서도 node_modules에 변경 사항이 적용될 수 있게 해주는 라이브러리이다.

방법

yarn add -D patch-package
  1. package.json에 postinstall 스크립트를 아래와 같이 추가한다.

  1. node_modules를 변경한다.

yarn patch-package 패키지명

을 하면, patch 파일이 생성된다.

이 patch 파일을 통해, yarn을 했을 때 변경 사항이 반영된 패키지를 설치할 수 있게 된다.

단점

그런데 package가 업데이트 되면 patch한 것과 호환 문제가 발생할 수 있다는 사실을 주의해야 한다.


문제 해결 과정

그럼 rmc-picker를 어떻게 patch 했는가?

우선 크게 4가지 질문에 대해 Chat GPT로부터 힌트를 얻어 로직의 틀을 작성했다.

  1. React element를 식별하는 방법
  2. 단순 html element를 식별하는 방법
  3. Fragment를 식별하는 방법
  4. children에 대해 map을 실행하는 방법

1. React element를 식별하는 방법

const hasLeftGap = !!listIndex;
          return (
            <React.Fragment key={listIndex}>
              {hasLeftGap && (
                <div className="rmc-picker-indicator indicator gap" />
              )}

위 코드에서, boolean인 hasLeftGap 또한 child로 여겨진다는 것을 알게 되었다. 이러한 것들을 제외시키기 위해, React.isValidElement()를 활용해 React element를 식별할 수 있었다.

https://react.dev/reference/react/isValidElement

2. 단순 html element를 식별하는 방법

오직 PickercloneElement를 해야 했고, 커스텀을 위해 넣은 단순 div들은 제외시켜야 했기에 단순 html element를 식별하고자 했다.
이때 알게된 사실은 React에서 렌더링하는 html element는 HTMLElement의 직접적인 instance가 아니라는 사실이다. 모든 React element는 createElement를 통해 생성되는 JS 객체이기 때문에, instanceof HTMLElement를 이용할 수 없었다. 따라서 단순 div들을 걸러내기 위해서는 React element의 type property를 이용해야 했다.

나는 div로 커스텀을 했으므로 element의 type === div인 경우를 상정했는데, 일반적인 html element를 타겟팅 한다면 type의 typeof === string인 경우를 상정하면 될 것 같다.

3. Fragment를 식별하는 방법

Fragment가 아닌 Fragment 내의 Picker를 clone해야 했기에 일단 Fragment를 먼저 식별해야 했고, 이때 React element의 type === React.Fragment인지 확인함으로써 가능했다.

4. children에 대해 map을 실행하는 방법

React.Children.map()을 사용하면 children에 대해 map을 실행할 수 있다. 난 child 중에서도 function component의 경우에만 cloneElement를 하는 로직을 작성했다.


틀을 작성한 이후엔 JS와의 싸움이었다. 끝없는 console.log()를 통해 디버깅할 수 있었다 🥹


결과

에러 메시지 없이 잘 동작하게 되었다!

  • 기존 코드
var MultiPicker = function MultiPicker(props) {
  var prefixCls = props.prefixCls,
      className = props.className,
      rootNativeProps = props.rootNativeProps,
      children = props.children,
      style = props.style;
  var selectedValue = props.getValue();
  var colElements = React.Children.map(children, function (col, i) {
    return React.cloneElement(col, {
      selectedValue: selectedValue[i],
      onValueChange: function onValueChange() {
        for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
          args[_key] = arguments[_key];
        }

        return props.onValueChange.apply(props, [i].concat(args));
      },
      onScrollChange: props.onScrollChange && function () {
        for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
          args[_key2] = arguments[_key2];
        }

        return props.onScrollChange.apply(props, [i].concat(args));
      }
    });
  });
  return React.createElement("div", _extends({}, rootNativeProps, {
    style: style,
    className: (0, _classnames["default"])(className, prefixCls)
  }), colElements);
};
  • 새로운 코드
var MultiPicker = function MultiPicker(props) {
  var prefixCls = props.prefixCls,
    className = props.className,
    rootNativeProps = props.rootNativeProps,
    children = props.children,
    style = props.style;
  var selectedValue = props.getValue();

  // 현재 clone해야 하는 picker의 index
  let pickerIndex = 0;
  // clone할 때 필요한 props를 구하는 함수를 추가했다.
  const getProps = () => {
    const index = pickerIndex;
    const result = {
      selectedValue: selectedValue[index],
      onValueChange: function onValueChange() {
        for (
          var _len = arguments.length, args = new Array(_len), _key = 0;
          _key < _len;
          _key++
        ) {
          args[_key] = arguments[_key];
        }

        return props.onValueChange.apply(props, [index].concat(args));
      },
      onScrollChange:
        props.onScrollChange &&
        function () {
          for (
            var _len2 = arguments.length, args = new Array(_len2), _key2 = 0;
            _key2 < _len2;
            _key2++
          ) {
            args[_key2] = arguments[_key2];
          }
          return props.onScrollChange.apply(props, [index].concat(args));
        },
    };

    pickerIndex++;
    return result;
  };

  // MultiPicker의 children에 대해
  var colElements = React.Children.map(children, function (col) {
    // 1-1. React element인 경우
    if (React.isValidElement(col)) {
      switch (col.type) {
          // 2-1. Fragment인 경우 clone된 Picker들을 가진 새로운 Fragment를 반환한다.
        case React.Fragment: {
          // Fragment의 children에 대해
          const children = React.Children.map(col.props.children, child => {
            // 3-1. React element인 경우
            if (React.isValidElement(child)) {
              // 4-1. div인 경우 연산을 가하지 않는다.
              if (child.type === 'div') {
                return child;
                // 4-2. function component(Picker)인 경우 clone한다. (Fragment인 경우는 생각하지 않음)
              } else {
                return React.cloneElement(child, getProps());
              }
              // 3-2. React element가 아닌 경우 연산을 가하지 않는다.
            } else {
              return child;
            }
          });
          return React.createElement(React.Fragment, null, ...children);
        }
          // 2-2. div인 경우 연산을 가하지 않는다.
        case 'div':
          return col;
          // 2-3. function component(Picker)인 경우 clone한다. (현재는 존재하지 않는 케이스)
        default:
          return React.cloneElement(col, getProps());
      }
      // 1-2. React element가 아닌 경우 연산을 가하지 않는다. (현재는 존재하지 않는 케이스)
    } else {
      return col;
    }
  });
  return React.createElement(
    'div',
    _extends({}, rootNativeProps, {
      style: style,
      className: (0, _classnames['default'])(className, prefixCls),
    }),
    colElements,
  );
};

배운 점

  1. 에러 메시지에 답이 있다. 정확히는 에러도 아니었고 warning이었지만, 파헤친 덕분에 문제의 원인을 파악할 수 있었다. warning도 무시하지 말자!
  2. 라이브러리의 UI 커스텀도 기능에 영향을 줄 수 있다. 퍼블리싱만 바꿨다고 생각해서 기능 테스트를 안 했었는데, 이 사실을 더 늦게 알았으면 큰일날 뻔 했다...
  3. Chat GPT는 정말 유용하다. 기존 코드를 더 빠르게 파악할 수 있었고, 필요한 힌트들도 빠르게 얻을 수 있었다. 그런데 내가 질문을 올바르게 했는지, GPT의 답변이 정말 정확한지 의심하고 확인해야 한다. 나의 경우 GPT의 답변이 들어맞지 않아서 삽질했는데 되돌아보니 나의 질문이 명확하지 않았다.
  4. 기초를 중시하자. 이전에 공부했었던 React element의 개념이 이번의 patch 과정에서 중요하게 쓰였고, closure 개념 또한 더 확신 있는 코드를 짜는 데에 도움이 되었다.
  5. 코드는 모두 연결되어 있다. cloneElement에만 정신이 팔려 있었는데, 그에 쓰이는 props 계산 로직도 변경해야 한다는 것을 나중에야 깨달았다. 한 부분에만 집중하지 말고 전체적으로 살펴보자.

글로만 보면 간단하게 보일 수 있지만 9~10시간이 걸렸던 대장정이었다... 그래도 라이브러리 사용에 자신감을 붙여준 유익한 경험이었고 wheel picker를 생각했던 대로 구현할 수 있어서 다행이었다. 다음에 또 patch를 하게 된다면 오늘보다 삽질을 훨씬 더 줄일 수 있을 것 같다. 👏

그리고 내가 짠 patch 코드로는 커버할 수 없는 edge case가 많아서 소스코드에 PR은 못 하겠지만, 내가 커스텀한 wheel picker를 패키지화 해보는 것도 괜찮지 않을까 싶어서 시간 될 때 해보고 싶다!

0개의 댓글