Ayden's journal

에러도 테크닉이 될 수 있다

상황

Button과 Link 및 여러 컴포넌트를 인자로 받아서 통일된 스타일을 처리해주는 Clickable 컴포넌트를 만들고 난 직후의 일이다. 로그인 페이지에서 Clickable 컴포넌트가 문제 없이 동작하는 걸 확인한 뒤 코드를 커밋했고, github action이 동작하며 라이브 서비스 서버로 배포가 완료되었다.

그런데 잘 적용되었는지 살펴보던 중 한 가지 문제점을 발견하게 되었다. 어떤 Clickable은 스타일이 제대로 적용되어있는 반면에, 어떤 Clickable은 일부 스타일만 적용되어있는 것이 아닌가! 추리를 위해 당시 Clickable 컴포넌트가 어떤 모습이었는지 그 코드를 아래에 첨부한다.

type ComponentType = typeof Button | typeof Link;
type ClickableStyle = { color?: "primary" | "white" | "like"; size?: "small" | "medium" | "large" };
type ClickableProps<T extends ComponentType> = ClickableStyle & ComponentPropsWithoutRef<T>;

export default function Clickable<T extends ComponentType = typeof Button>({
  Component,
  ...props
}: {
  Component?: T;
} & ClickableProps<T>) {
  const Render = Component ?? Button;
  const { color = "primary", size = "medium", className } = props;
  const style = clsx(styles.root, styles[color], styles[size], className);

  // @ts-expect-error
  return <Render className={style} {...props} />;
}

 

문제

// 문제 없이 기본 스타일 적용됨
<Clickable>검색</Clickable>

// 문제 없이 color 스타일 적용됨
<Clickable type="button" color="white" onClick={() => setBook({} as Item)}>
  취소
</Clickable>

// 문제 없이 disabled 스타일 적용됨
<Clickable disabled={isPending}>회원가입</Clickable>

// ??
<Clickable
  color="like"
  size="small"
  type="button"
  disabled={isPending}
  onClick={() => {
    mutate(isLiked ? "delete" : "post");
  }}
  className={styles.likeButton}
>
  {report.likeCount} {isMyReport || isLiked ? <IoHeart /> : <IoHeartOutline />}
</Clickable>

이 문제의 원인을 찾는 것은 어렵지 않았고, 사실 Clickable 컴포넌트가 실제로 사용된 곳들을 돌아다니다보니 자연히 뭐가 문제였는지 알아차릴 수 있었다. 아무 props도 없을 때, color∙size∙disabled props가 있을 때를 나누어서 생각해보면 답은 간단하다.

 

최초에 나는 문제의 원인으로 두 가지 정도가 가능성 있다고 보았다. 하나는 여러 props를 복합적으로 사용할 때 Clickable 내부에서 이를 처리하는 과정에 문제가 있을 수 있다는 것. 다른 하나는  이쪽이 훨씬 더 가능성이 높았는데 ─ className prop의 존재 유무가 Clickable 내부에 어떤 영향을 끼치고 있을 수 있다는 것이었다.

후자의 가능성을 높게 보았던 까닭은 위에서 살펴본 코드 중 마지막 예시 때문이었다. 다른 props 들은 스타일 선택에 아무런 영향을 끼치지 못했음에도 styles.likeButton만은 살아남아서 적용되고 있었다.

 

해결

const { color = "primary", size = "medium", className } = props;
const style = clsx(styles.root, styles[color], styles[size], className);

// @ts-expect-error
return <Render className={style} {...props} />;

원인을 쉽게 알 수 있도록 Clickable에서 문제가 된 부분만 잘라서 가져왔다. 이 문제의 원인은 "className prop의 중복 제공"에 있었다. 더 정확히는 중복 제공하는 방식이 잘못되었다고 해야할까?

나는 props로부터 className을 구조 분해 할당하고, 이를 clsx 라이브러리의 도움으로 처리하여 style 클래스 문자열을 얻어낼 수 있었다. 그리고 이 style을 Render의 className 프로퍼티에 제공하였다. 문제는 그 뒤에 있는 { …props } 안에도 className이 있다는 점이다.

return (
  <Render
    className={style}
    color={color}
    size={size}
    children={children}
    disabled={disabled}
    onClick={onClick}
    className={className}
  />
);

예시로 들었던 코드에서 props spread를 전개하면 실제로는 위와 같은 형태가 된다. className가 두 개 있으며, 이렇게 중복되는 프로퍼티가 제공된 경우 리액트는 ─ 동일한 변수명으로 정의된 var나 function의 경우처럼 ─ 가장 마지막에 제공된 프로퍼티의 값을 채택한다. 따라서 다른 경우들과 달리 오직 className 프로퍼티를 제공했던 Clickable 컴포넌트만 문제를 일으켰던 것이다.

// 간단한 해결책
return <Render {...props} className={style}  />;

// 제대로 된 해결책
const { color = "primary", size = "medium", className, ...restProps } = props;
const style = clsx(styles.root, styles[color], styles[size], className);

return <Render className={style} {...restProps} />;

 

응용

아래와 같은 컴포넌트 구조가 있다고 해보자. Show 컴포넌트의 fallback prop으로 제공된 Input 컴포넌트와 EyeButton 컴포넌트의 Input prop에 제공된 함수에서 사용된 Input 컴포넌트는 둘 모두 동일한 props를 필요로 한다. 다만, EyeButton 컴포넌트 쪽의 경우 부모 컴포넌트가 처리하는 조작된 타입 toggleType을 넣어주어야 한다.

+ Map과 Show에 대해서는 유틸리티 컴포넌트 : Map & Show 포스트에서 확인해볼 수 있다.

<Map each={signUpArray}>
  {({ inputName, htmlFor, type, placeholder }) => (
    <InputWrapper
      inputName={inputName}
      errorMessage={errorState[htmlFor]}
      htmlFor={htmlFor}
      key={htmlFor}
      Input={
        <Show
          when={type === "password"}
          fallback={
            <Input {...register(htmlFor, validator[htmlFor])} placeholder={placeholder} type={type} />
          }
        >
          <EyeButton Input={(toggleType) => <Input {...register(htmlFor, validator[htmlFor])} placeholder={placeholder} type={toggleType} />} />
        </Show>
      }
    />
  )}}
</Map>

 

이런 경우 리액트가 동일한 이름의 프로퍼티가 제공될 경우 언제나 마지막으로 제공된 프로퍼티 값을 채택한다는 점을 이용하면 훨씬 코드를 간결하게 짤 수 있다. 공통으로 사용하는 프로퍼티를 하나의 객체로 묶어두고, 필요한 경우에만 그 뒤에 중복 프로퍼티를 제공하는 식으로 처리하는 것이다.

<Map each={signUpArray}>
  {({ inputName, htmlFor, type, placeholder }) => {
    const inputProps = { ...register(htmlFor, validator[htmlFor]), placeholder, type };

    return (
      <InputWrapper
        inputName={inputName}
        errorMessage={errorState[htmlFor]}
        htmlFor={htmlFor}
        key={htmlFor}
      >
        <Show when={type === "password"} fallback={<Input {...inputProps} />}>
          <EyeButton Input={(toggleType) => <Input {...inputProps} type={toggleType} />} />
        </Show>
      </InputWrapper>
    );
  }}
</Map>

이러한 방식이 교과서적으로 옳지야 않겠지만, 적절하게 잘 사용하면 나름 괜찮지 않을까 하는 생각이 있다.

 

부록. ESLint는 뭐하냐?

원래 같은 프로퍼티를 중복하여 사용하면 eslint가 득달같이 달려와 아래의 메세지처럼 에러가 있다고 알려준다.

그런데 이번 경우에 나는 eslint의 도움을 받지 못했다. 추측하건데 eslint가 구문을 파싱하는 과정에서 전개되는 프로퍼티들을 제대로 확인하지 못하는 게 아닐까 싶다. 뭐든 그렇겠지만 eslint도 너무 맹신해버리면 안 되겠다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기