Skip to content

TextField

이토 edited this page Jun 1, 2025 · 3 revisions

문제 상황

팀 내 디자인 시스템을 구현하며 input과 textarea를 하나의 TextField 컴포넌트로 통합해 사용하고자 했습니다. as: 'input' | 'textarea' props를 통해 조건부 렌더링을 구현하고, forwardRef와 타입 유틸(예: ExclusiveKeys, Strict)을 결합해 타입 안정성도 고려했습니다.

그러나 구현이 고도화될수록 타입 정의가 지나치게 복잡해졌고, 특히 다음과 같은 문제가 드러났습니다:

  • 타입 오류는 발생하지 않았지만, 컴포넌트 내부에서 forwardRef로 래핑할 때 as의 타입 단언이 불가피했고,
  • 이 단언은 타입스크립트의 타입 안전성 보장을 포기하게 되는 지점이었습니다.
  • 또한, 타입 유틸이 복잡하게 얽히며 팀원 입장에서 타입 흐름을 이해하기 어려워졌고, 재사용 시 높은 진입 장벽이 생겼습니다.

이 구조는 실제 오류가 없더라도 유지보수성과 확장성을 저해하며, 시간이 지날수록 팀 전체의 개발 속도와 안정성에 영향을 줄 수 있다고 판단했습니다.

/* ---------- Util Type ---------- */
type ExclusiveKeys<A, B> = Exclude<keyof A, keyof B>;
type Strict<A, K extends PropertyKey> = Omit<A, K> & { [P in K]?: never };

/* ---------- Own Type ---------- */
interface CustomProps { ... }

/* ---------- Native Attrs ---------- */
type InputAttrs = ComponentPropsWithoutRef<"input">;
type TextareaAttrs = ComponentPropsWithoutRef<"textarea">;

type InputOnly = ExclusiveKeys<InputAttrs, TextareaAttrs>;
type TextareaOnly = ExclusiveKeys<TextareaAttrs, InputAttrs>;

type StrictInputAttrs = Strict<InputAttrs, TextareaOnly>;
type StrictTextareaAttrs = Strict<TextareaAttrs, InputOnly>;

type InputProps = Omit<StrictInputAttrs, keyof CustomProps> &
  CustomProps & { as?: "input" };

type TextareaProps = Omit<StrictTextareaAttrs, keyof CustomProps> &
  CustomProps & { as: "textarea" };

type TextFieldProps = InputProps | TextareaProps;
type TextFieldRef<P extends TextFieldProps> = P extends { as: "textarea" }
  ? ComponentPropsWithRef<"textarea">["ref"]
  : ComponentPropsWithRef<"input">["ref"];

const sizeMap = {
  lg: "py-4 px-5 text-[1rem]",
  sm: "p-2.5 text-sm",
} as const;

function TextFieldInner<T extends TextFieldProps>(
  { as, ... }: T,
  ref: TextFieldRef<T>,
) {
  const Component = (as ?? "input") as ElementType;
  const wrapperClassNames = cn( ... );
  const textElementClassNames = cn( ... );

  return (
    <div>
      ...

      <div className={wrapperClassNames}>
        {prefix}
        <Component
          id={id}
          ref={ref}
          disabled={disabled}
          className={textElementClassNames}
          {...rest}
        />
        {postfix}
      </div>

      ...
    </div>
  );
}

TextFieldInner.displayName = "TextField";

export const TextField = forwardRef(TextFieldInner) as <
  P extends TextFieldProps,
>(
  props: P & { ref?: TextFieldRef<P> },
) => ReactNode;

export default TextField;

어떻게 해결했을까?

근본적인 해결을 위해 다음과 같은 리팩토링을 진행했습니다:

  1. 조건부 렌더링 제거 & 컴포넌트 분리 TextField를 하나의 컴포넌트로 묶는 대신, TextField.Input, TextField.TextArea로 명확히 분리하여 각각의 HTML 요소와 타입 시스템에 완전히 대응하도록 했습니다.
  2. 공통 로직은 별도 컴포넌트로 추출 label, validateMessage, wrapper 구조 등의 공통 UI는 Field라는 별도 컴포넌트로 분리해 중복 제거와 역할 분리를 동시에 달성했습니다.
  3. 타입 정의를 단순화하고 추론 가능하게 설계 복잡한 유틸 타입 없이도 InputHTMLAttributes / TextareaHTMLAttributes를 기반으로 props를 설계하여 IDE의 자동완성 및 타입 추론이 명확하게 작동하도록 했습니다.
  4. 타입 단언 제거 → 타입 안전성 강화 더 이상 as 기반의 조건부 타입/렌더링에 의존하지 않기 때문에 forwardRef 내부에서도 단언 없이 타입이 안전하게 흐르도록 개선했습니다.
interface TextElementProps { ... }

type InputProps = Omit<
  InputHTMLAttributes<HTMLInputElement>,
  keyof TextElementProps
> &
  TextElementProps;

type TextAreaProps = Omit<
  TextareaHTMLAttributes<HTMLTextAreaElement>,
  keyof TextElementProps
> &
  TextElementProps;

function Field({ ... }: PropsWithChildren<
  Pick<TextElementProps, "id" | "label" | "validateMessage">
>) {
  return (
    <div>
      {label && (
        <label htmlFor={id} className="inline-block mb-2 leading-[1.625rem]">
          {label}
        </label>
      )}

      {children}

      {validateMessage && (
        <p className="m-2 text-sm text-red-40">{validateMessage}</p>
      )}
    </div>
  );
}

const Input = forwardRef(
  (
    { ... }: InputProps,
    ref: Ref<HTMLInputElement>,
  ) => {
    const wrapperClassNames = cn( ... );
    const textElementClassNames = cn( ... );

    return (
      <Field id={id} label={label} validateMessage={validateMessage}>
        <div className={wrapperClassNames}>
          {prefix}
          <input
            id={id}
            ref={ref}
            disabled={disabled}
            className={textElementClassNames}
            {...rest}
          />
          {postfix}
        </div>
      </Field>
    );
  },
);

const TextArea = forwardRef(
  (
    { ... }: TextAreaProps,
    ref: Ref<HTMLTextAreaElement>,
  ) => {
    const wrapperClassNames = cn( ... );
    const textElementClassNames = cn( ... );

    return (
      <Field id={id} label={label} validateMessage={validateMessage}>
        <div className={wrapperClassNames}>
          {prefix}
          <textarea
            id={id}
            ref={ref}
            disabled={disabled}
            className={textElementClassNames}
            {...rest}
          />
          {postfix}
        </div>
      </Field>
    );
  },
);

const TextField = {
  Input,
  TextArea,
};

export default TextField;

결과

  • 타입 안정성 강화: 단언 없이도 타입스크립트가 안전하게 타입을 추론할 수 있는 구조로 개선했습니다.
  • 가독성과 진입장벽 개선: 팀원이 코드를 처음 접하더라도 타입 구조를 이해하고 사용할 수 있게 되었고, 복잡한 타입 유틸을 학습할 필요가 없어졌습니다.
  • 컴포넌트의 명확한 책임 분리: 각각의 TextField 변형은 더 이상 조건 분기로 흐르지 않고, 명확한 역할과 props 타입을 가진 독립된 컴포넌트로 유지보수성을 확보했습니다.
  • 공통 UI 추출을 통한 재사용성 증가: Field 컴포넌트를 통해 일관된 UI 구조를 유지하면서 중복 코드를 줄일 수 있었습니다.

트러블 슈팅

컴포넌트

커스텀 훅

Clone this wiki locally