Ayden's journal

나의 input 연대기 : rander props 패턴

내가 프론트엔드 공부를 처음 시작했을때부터 지금까지 꾸준히 고민하고 있는 문제가 둘 있다. 하나는 다음 포스트에서 풀어볼 'Button과 Link가 같은 스타일을 가질 때 어떻게 처리할 것인가'이고, 다른 하나는 input의 type을 text와 password로 바꿔주는 기능을 어떻게 처리할 것인지에 대한 것이다.

 

회원가입/로그인 폼을 생각해보면 거기에는 늘 비밀번호 입력을 감출 수 있는 눈동자 버튼이 있다. 이 눈동자 버튼을 누르면 input의 type이 password에서 text로 바뀌고, 다시 누르면 그 반대로 바뀌게 된다. 리액트를 배우고 한 시간 정도면 아마 누구라도 구현할 수 있을 지 모른다.

그런데 이 눈동자가 토글된 상태는 어느 컴포넌트에 위치시키고, 눈동자 버튼은 어디에 배치해야 하며, 컴포넌트 사이에서 상태 공유는 어떻게 처리해야 하는 걸까? 어떻게 해야 코드의 재사용성을 끌어올리면서도 군더더기 없이 깔끔하게 처리할 수 있을까? 아래의 내용은 내가 프론트엔드 공부를 처음 시작해서 input 컴포넌트를 만들고, 그 이후로 쉬지 않고 고민해온 10개월간의 결론이다.

 

거대한 Input

리액트를 배우고 첫 프로젝트에서 내 input 컴포넌트는 아래와 같았다. 필요한 모든 것을 직접 들고 있으며, 재사용성을 높여보겠답시고 여러 부분에 조건부 랜더링을 걸어두었다. 첫 술에 배부를 수야 없겠다만, 그래도 이건 좀 심하지 않았나 생각한다.

function Input({ id, name, type, value, label, errorText, onBlur, onFocus, onChange, eyeButton, placeholder }: Props) {
  const [eyesValue, setEyesValue] = useState(false);

  function handleEyesClick() {
    setEyesValue((current) => !current);
  }

  if (eyesValue) {
    type = "text";
  }

  const inputConfig = {
    name,
    type,
    value,
    onChange,
    placeholder,
  };

  const eyesStyle = clsx(styles.eyes, eyesValue ? styles.eyesOn : styles.eyesOff);
  const borderControl = clsx(styles.container, errorText && styles.errorBorder);

  return (
    <div className={styles.root}>
      {label && (
        <label className={styles.label} htmlFor={id}>
          {label}
        </label>
      )}
      <div className={borderControl} onBlur={onBlur} onFocus={onFocus}>
        <input autoComplete="off" className={styles.input} {...inputConfig} />
        {eyeButton && <button className={eyesStyle} onClick={handleEyesClick} type="button"></button>}
      </div>
      {errorText && <div className={styles.errorMessage}>{errorText}</div>}
    </div>
  );
}

export default Input;

 

InputWrapper 데뷔

그 다음 프로젝트에서는 코드가 아주 조금 나아진다. Input 컴포넌트에서 모든 것을 처리하는 대신, 특정 스타일 등의 로직을 처리하는 InputWrapper 컴포넌트를 도입하였기 때문이다. 그러나 실제로는 하나의 컴포넌트를 분리 가능한 수준에서 두 개로 쪼개어놨을 뿐, 개선이라고 볼 수 있는 상황은 아니다.

function InputWrapper({ labelName, htmlFor, star, mobile, errorText, children, onBlur, onFocus }: InputWrapperProp) {
  const wrapperStyle = clsx(
    styles.wrapper,
    htmlFor === "comment" && styles.commentWrapper,
    errorText && styles.errorBorder
  );

  return (
    <div className={styles.root}>
      {labelName && (
        <Label htmlFor={htmlFor} star={star} mobile={mobile}>
          {labelName}
        </Label>
      )}
      <div className={wrapperStyle} onBlur={onBlur} onFocus={onFocus}>
        {children}
      </div>
      {errorText && <div className={styles.errorMessage}>{errorText}</div>}
    </div>
  );
}
function Input({
  placeholder, id, name, value, onChange, eyeButton, eyesValue, type = "text", onEyesClick,
}: InputProp) {
  return (
    <>
      <input
        className={styles.input}
        type={type}
        id={id}
        name={name}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
      />
      {eyeButton && (
        <button className={styles.eyes} onClick={onEyesClick} type="button">
          <Image src={`/icons/icon-eyes${eyesValue ? "On" : "Off"}.svg`} width={18} height={18} alt="" />
        </button>
      )}
    </>
  );
}

이때 토글된 타입을 외부에서 관리하기 위해 커스텀 훅을 만들어 사용하였는데, 덕분에 비밀번호 및 비밀번호 확인 등 타입 변환이 필요한 input마다 커스텀 훅을 하나씩 호출해야하는 지경에 이르게 되었다.

 

Context API 활용

InputWrapper 데뷔 이후 내 input 컴포넌트는 조금씩 개선되었지만, 큰 틀에서는 여전히 비슷한 형태를 유지하고 있었다. input과 eyeButton을 분리해야하는데, 컴포넌트 내부에 있는 상태를 다른 컴포넌트에 전달하는 좋은 방법이 쉽게 떠오르지 않았다. 그러다가 Caro-Kann이라는 라이브러리를 개발하면서 예전에는 몰랐던 Context API의 새로운 활용 가능성을 알게 되었다. 덕분에 내 개발 인생 처음으로 eyeButton이 input 컴포넌트로 분리될 수 있었다.

export const EyeButtonContext = createContext("text")

export default function EyeButton({ children }: { children: ReactElement }) {
  // 비밀번호 타입 토글
  const [toggleType, handleToggleType] = useToggle("password", "text");

  return (
    <>
      <EyeButtonContext.Provider value={toggleType}>
        {children}
      </EyeButtonContext.Provider>

      <button className={styles.button} type="button" onClick={handleToggleType}>
        <Show when={toggleType === "text"} fallback={<IoEyeOutline />}>
          <IoEyeOffOutline />
        </Show>
      </button>
    </>
  );
}
export default function Input({ className, name, ...inputProps }: InputProps) {
  const toggleType = useContext(EyeButtonContext)

  return <input type={toggleType} className={clsx(styles.input, className)} name={name} id={name} {...inputProps} />;
}

사람에 따라서는 이 방법을 끝으로 더 이상 input에 대한 고민을 접어둘지도 모르겠다. 하지만 나는 그런 사람이 아니다.

생각해보면 input의 타입을 바꿔주는 건 서비스 전체에서 고작해봐야 회원가입/로그인 두 곳 뿐이다. 심지어 회원가입/로그인 페이지의 모든 input 타입을 바꿔야 하는 것도 아니다. 그런데도 불구하고 input 내부에서 컨텍스트를 사용하는 것이 마음에 들지 않았다. 이것은 순전한 취향의 영역일지도 모르겠지만 아무튼 나는 그랬다.

 

Render Props Pattern

함수형 프로그래밍을 공부한 이후로 나는 코드 전반에 고차 함수를 응용하고 있다. 그러다가 문득 이런 생각이 들었다. 컴포넌트라고 부르고 있기는 하지만 이것도 결국은 함수 아닌가? 몇 가지 테스트를 거친 끝에 나는 컴포넌트를 리턴하는 함수를 컴포넌트의 props로 제공할 수 있다는 사실을 알게 되었다.

import { ReactNode } from "react";

function TestCompo({ func, children }: { func: (bar: ReactNode) => ReactNode; children: ReactNode }) {
  return <div>{func(children)}</div>;
}

export default function Foo() {
  return <TestCompo func={(bar) => <p>{bar}</p>}>A</TestCompo>;
}

처음에 나는 이 방식을 익명 고차 컴포넌트 패턴이라고 불렀다. 나 말고는 이런 방법을 사용하는 사람을 주변에서 본 적이 없기도 하거니와, 1) 컴포넌트의 props로 익명 함수를 전달하고 2) 함수가 컴포넌트를 리턴하는 꼴이 꼭 고차 컴포넌트 같으니 나름 괜찮은 작명이라고 생각했다. 긴 고민 끝에 이런 패턴을 스스로 발견했다는 사실에 큰 성취감을 느꼈다.

그러나 똑똑한 개발자들이 이런 고민을 먼저 안 했을리 없고, 나는 이걸 랜더 프롭스 패턴이라고 부른다는 사실을 어제 저녁에 책 ─ 제목이 궁금한 사람이 있을지 몰라 남겨두자면 "자바스크립트 + 리액트 디자인 패턴" ─ 을 읽다가 알게 되었다. EyeButton과 Input 컴포넌트 사이에 계층 구조가 복잡하다면 컨텍스트를 쓰는 게 맞겠지만, 단순 부모자식 사이라면 랜더 프롭스 패턴을 사용하는 편이 더 나을 것 같았다.

export default function EyeButton({ Input }: { Input: (type: string) => ReactElement }) {
  // 비밀번호 타입 토글
  const [toggleType, handleToggleType] = useToggle("password", "text");

  return (
    <>
      {Input(type)}

      <button className={styles.button} type="button" onClick={handleToggleType}>
        <Show when={toggleType === "text"} fallback={<IoEyeOutline />}>
          <IoEyeOffOutline />
        </Show>
      </button>
    </>
  );
}
export default function Input({ className, name, ...inputProps }: InputProps) {
  return <input className={clsx(styles.input, className)} name={name} id={name} {...inputProps} />;
}

 

그리고 이를 실제로 사용할 때는 아래와 같은 모습이 될 것이다. 랜더 프롭스는 children으로 내려보내도 문제 없지만, Input 컴포넌트가 사용되어야 한다는 의미를 드러내기 위해 일부러 Input 이라는 이름의 props를 사용했다.

// 타입 토글 기능이 필요한 경우
<EyeButton
  Input={(toggleType) => (
    <Input {...register(htmlFor, validator[htmlFor])} type={toggleType} placeholder={placeholder} />
  )}
/>

// 타입 토글 기능이 필요 없는 경우
<Input {...register(htmlFor, validator[htmlFor])} type="text" placeholder={placeholder} />

 

이대로 끝?

방법을 찾았고 마침내 해냈다! (??? : 3부 끝!) 하고 끝내기에는 아직 미진한 부분이 많다. 가장 거슬리는 부분은 EyeButtonProps의 Input 타입이 (type: string) => ReactElement 라는 점이다. 따라서 실제로는 Input 컴포넌트 뿐만 아니라 div나 span을 넣어도 타입 에러 없이 작동한다.

이 타입을 좁혀서 Input 컴포넌트만 받을 수 있으면 좋겠는데, 현재로는 createElement 함수의 리턴 타입이 모두 ReactElement인 탓에 쉽지는 않다. Input(toggleType).type과 같은 방식으로 리턴되는 태그의 타입을 알아낼 수 있지만, 아쉽게도 이 값은 런타임에만 존재하는 듯하다.

 

따라서 아래와 같은 방법을 사용하면 div나 span을 넣었을 때 런타임 에러를 발생시키고, 이를 통해 코드 수정을 유도할 수 있겠다. 다만 런타임 시점이 되어야 문제를 발견하게 되는 건 썩 좋은 상황은 아닌지라 여전히 나는 타입 에러를 통해 이 문제를 해결하고 싶은 마음이 강하다. 해결만 된다면 랜더 프롭스 패턴에서 뿐만 아니라 프로젝트 전반에 걸쳐 아주 견고한 타입 안정성을 구축할 수 있으리라 기대하고 있다.

import { ReactNode, ReactElement, isValidElement } from "react";

export default function EyeButton({ Input }: { Input: (type: string) => ReactElement }) {

  // 비밀번호 타입 토글
  const [toggleType, handleToggleType] = useToggle("password", "text");
  
  const result = Input(toggleType);

  if (!isValidElement(result) || result.type !== "input") {
    throw new Error("Input must return a input element");
  }
  
  return (
    <>
      {result}

      <button className={styles.button} type="button" onClick={handleToggleType}>
        <Show when={toggleType === "text"} fallback={<IoEyeOutline />}>
          <IoEyeOffOutline />
        </Show>
      </button>
    </>
  );
}

 

그런 까닭에 input 컴포넌트에 대한 나의 고민은 현재진행형이라 해야겠다. 다음에 이 포스트를 수정하는 날이 온다면, 그때는 위의 문제를 타입스크립트 수준에서 해결했기를 바란다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기