forked from CodeitPart3/thejulge
-
Notifications
You must be signed in to change notification settings - Fork 0
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;근본적인 해결을 위해 다음과 같은 리팩토링을 진행했습니다:
- 조건부 렌더링 제거 & 컴포넌트 분리 TextField를 하나의 컴포넌트로 묶는 대신, TextField.Input, TextField.TextArea로 명확히 분리하여 각각의 HTML 요소와 타입 시스템에 완전히 대응하도록 했습니다.
- 공통 로직은 별도 컴포넌트로 추출 label, validateMessage, wrapper 구조 등의 공통 UI는 Field라는 별도 컴포넌트로 분리해 중복 제거와 역할 분리를 동시에 달성했습니다.
- 타입 정의를 단순화하고 추론 가능하게 설계 복잡한 유틸 타입 없이도 InputHTMLAttributes / TextareaHTMLAttributes를 기반으로 props를 설계하여 IDE의 자동완성 및 타입 추론이 명확하게 작동하도록 했습니다.
- 타입 단언 제거 → 타입 안전성 강화 더 이상 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 구조를 유지하면서 중복 코드를 줄일 수 있었습니다.