나의 Clickable 연대기 : Higher-Order Component
앞서 작성한 input 연대기 초반에 잠깐 언급했었는데, 내가 프론트엔드 공부를 처음 시작했을때부터 지금까지 꾸준히 고민하고 있는 문제 중 하나가 'Button과 Link가 같은 스타일을 가질 때 어떻게 처리할 것인가'이다. 이 두 개의 컴포넌트는 받는 props의 타입도 조금 다르고, 클릭했을 때의 동작도 다른 편이지만, 결국은 '클릭할 수 있는 영역'이기 때문에 UI 적으로 비슷하거나 동일한 디자인을 가져가는 경우가 종종 있다.
처음에는 하나의 스타일시트를 만들고, Button과 Link 컴포넌트에 className으로 동일한 css를 넘겨줄 생각이었다. 그러나 이 방법은 곧 한계를 드러냈다. 내가 간과한 사실 중 하나는 '스타일은 결국 여러 옵션의 내용(size, color etc)을 중첩하여 사용하게 되므로 어디선가는 이를 처리하는 로직을 가지고 있어야 하며', 간과한 다른 사실로는 Link 컴포넌트가 리액트 라우터 혹은 NextJS가 제공해주므로 이를 다시 감싸는 게 참 번거로운 일이라는 점이 있겠다.
이런 문제를 해결하기 위해 개발 뉴비였던 내가 떠올린 컴포넌트가 바로 Clickable이다. 이 포스트를 통해 나의 귀여운 Clickable 컴포넌트가 어떻게 성장해왔는지 살펴보고자 한다.
Clickable 데뷔
초창기 Clickable은 아주 형편없었다. button 태그와 Link 컴포넌트를 직접 들고 있으며, as 프로퍼티 값에 따라 조건문 분기를 타고 ReactElement를 리턴하고 있다.
export default function Clickable({ className, as, color, size, children, onClick, disabled, type, href }) {
const style = clsx(styles.root, styles[color], styles[size], className);
if (as === "button") {
return (
<button className={style} type={href} disabled={disabled} onClick={onClick}>
{children}
</button>
)
} else {
return (
<Link className={style} href={href}>
{children}
</Link>
)
}
}
이걸 처음 만들때는 타입스크립트를 안 써서 몰랐지만, 리액트 전체를 타입스크립트로 마이그레이션 한 이후로 문제점이 곧장 드러났다. 사실 그 전에도 알 수 있는 문제이기는 했는데, href prop을 제공해도 as prop의 값이 "button"이면 아무짝에도 쓸모가 없다는 점이었다. 크리티컬한 에러까지는 아니지만 어떤 식으로든 해결해야했다.
type ClickableStyle = { color?: "primary" | "white" | "like"; size?: "small" | "medium" | "large" };
type ClickableProps<
T extends "Button" | "Link",
> = { as: T } & ClickableStyle & (T extends "Button" ? ButtonHTMLAttributes<HTMLButtonElement> : Parameters<typeof Link>[number]);
타입 추론 자체가 아주 복잡하기는 하지만, 그래도 어떻게든 되기는 한다. 하지만 근본적인 문제가 심각해서 도저히 이 구조를 유지할 수가 없었다. 대대적인 수정이 필요했다.
스타일 처리기로서의 Clickable
생각해보면 나는 '공통의 UI 스타일'을 처리하기 위해 Clickable 컴포넌트를 구상해냈다. 그렇다면 이 컴포넌트는 사실 button과 Link를 직접 들고 있을 필요가 없는 것이다. 그리하여 Clickable은 span을 사용해서 button과 Link 내부로 숨어드는 방식으로 진화하였다.
function Clickable({ children, disabled, color = "primary", size = "medium" }: ClickableProps) {
return (
<span className={clsx(styles.root, disabled && styles.disabled, styles[color], styles[size])}>{children}</span>
);
}
export const Click = {
Button: (buttonProps: ButtonProps) => {
return (
<button {...buttonProps}>
<Clickable disabled={buttonProps.disabled} color={buttonProps.color} size={buttonProps.size}>
{buttonProps.children}
</Clickable>
</button>
);
},
Link: (linkProps: LinkProps) => {
return (
<Link {...linkProps}>
<Clickable color={linkProps.color} size={linkProps.size}>
{linkProps.children}
</Clickable>
</Link>
);
},
};
이제 Click.Link와 Click.Button 컴포넌트를 사용하면서 각 컴포넌트에 알맞은 props을 사용할 수 있고, Clickable은 온전히 스타일만 처리하는 컴포넌트가 되었다. 그런데 여전히 문제는 있었다. 만약 Link도 Button도 아닌 제3의 컴포넌트를 추가해야 한다면? 10개의 컴포넌트를 추가해야 한다면 일일이 하드코딩하는 수 밖에 없었다.
그리고 이때쯤 나는 공통 로직을 재사용하는 방식의 하나로 고차 컴포넌트라는 개념을 접하게 되었다.
Higher Order Clickable
당시 유인동 선생님의 함수형 프로그래밍 강의를 들었던 덕분에 고차 함수에 대해서는 완벽하게 이해를 하고 있던 상황이었다. 그리고 고차 컴포넌트란 그저 인자로 컴포넌트를 받아서, 컴포넌트를 리턴하는 조금 특이한 종류의 고차 함수에 지나지 않는다.
withClickable은 Button 혹은 Link 컴포넌트를 받아서 스타일 로직이 포함된 Clickable 컴포넌트를 리턴한다. ComponentType이 intersection type이라 해결할 수 없는 타입 오류가 발생하여 @ts-expect-error 주석처리를 해두었다.
import clsx from "clsx";
import { ComponentPropsWithoutRef } from "react";
import styles from "./Clickable.module.css";
import Button from "./Button";
import Link from "next/link";
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 withClickable<T extends ComponentType = typeof Button>(Component?: T) {
function Clickable(props: ClickableProps<T>) {
const { color = "primary", size = "medium", className, ...restProps } = props;
const style = clsx(styles.root, styles[color], styles[size], className);
const Render = Component ?? Button;
// @ts-expect-error
return <Render className={style} {...restProps} />;
}
return Clickable;
}
이 방식의 장점은 어떤 컴포넌트를 추가하더라도 ComponentType를 수정하면 문제 없이 동작한다는 것이다. 단점이 있다면 실제로 다른 컴포넌트에서 withClickable을 호출할 때 코드가 좀 더럽다는 것이다.
export default function Comp() {
const handleClick = () => {}
return (
<>
{withClickable()({ children: "확인", onClick: handleClick })}
{withClickable()({ children: "이동", href: "/" })}
</>
)
}
이전에 유틸리티 컴포넌트 관련 포스트에서도 이야기했지만 나는 컴포넌트의 리턴부에는 가능하면 컴포넌트처럼 생긴 놈들이 있었으면 하는 작은 소망이 있다. 따라서 컴포넌트를 받아서 컴포넌트를 리턴한다는 아이디어는 유지하되, HOC이 아닌 일반적인 컴포넌트처럼 사용할 수 있게 리팩토링을 진행했다.
the Clickable
withClickable의 기본적인 아이디어 및 구조는 유지한 채 고차 컴포넌트가 아닌 일반 컴포넌트로 Clickable를 새로이 구현하였다. Component props를 넘겨주지 않으면 기본 값은 Button 컴포넌트처럼 동작하도록 작성되어있다.
import { ComponentPropsWithoutRef } from "react";
import styles from "./Clickable.module.css";
import clsx from "clsx";
import Button from "./Button";
import Link from "next/link";
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 { color = "primary", size = "medium", className, ...restProps } = props;
const style = clsx(styles.root, styles[color], styles[size], className);
const Render: ElementType = Component ?? Button;
return <Render className={style} {...restProps} />;
}
withClickable도 그렇지만, Clickable 컴포넌트 역시 HOC의 컨셉을 계승하고 있다보니 한 가지 치명적인 약점이 존재한다. 그것은 단순 button 태그나 a 태그는 사용할 수 없고 오로지 '컴포넌트'만 받을 수 있다는 것이다. button 태그 대신 Button 컴포넌트를 쓰는 것도 그런 이유이다.
ClickablePropsFormatter
그다지 치명적이지 않은 약점 중에서는 이런 게 있을 수 있겠다. 지금 Clickable 컴포넌트는 혼자서 스타일을 처리하고 있다. 그런데 만약 구현해야 할 버튼의 종류가 50개 정도 되고, 각 종류별로 수많은 속성을 튜닝할 수 있다면 어떻게 될까. 그런 게 가능할지는 모르겠지만 아마도 Clickable 컴포넌트는 과로사할 것이다. 게다가 새로운 종류의 버튼이 추가될 때마다 Clickable 컴포넌트를 수정해야 하기 때문에 SOLID 원칙에도 ─ 정확히는 개방 폐쇄 원칙에 ─ 위배된다.
이 문제를 해결하기 위해 나는 Clickable을 종류에 따라 생성할 수 있도록 ClickablePropsFormatter 클래스를 만들었다. 최근에 디자인 패턴을 공부하고 있는 만큼 이 클래스를 설계하며 여러 패턴을 활용해보았다. 싱글턴으로 동작하는 만큼 Clickable이 많아져도 메모리 걱정이 없으며, ClickableFormatter를 부모 인터페이스로 두고 서로 다른 doFormat() 전략을 적용하였고, formatterMap을 통해 객체를 동적으로 생성 및 제공한다.
또한 덕 타이핑 덕분에 추가적인 스타일이 필요한 경우에도 Clickable 컴포넌트나 ClickablePropsFormatter 클래스를 수정하지 않고 확장할 수 있다.
interface ClickableFormatter {
doFormat<T extends ElementType>(props: ClickableFactoryProps & ComponentPropsWithoutRef<T>): { className: string, restProps: object }
}
class ClickablePropsFormatter {
private static _instance: ClickablePropsFormatter;
private static formatterMap = new Map<string, ClickableFormatter>([
["round", new RoundClickableFormatter()],
["square", new SquareClickableFormatter()],
["underline", new UnderlineClickableFormatter()],
])
// private 생성자
private constructor() {}
// 싱글턴 인스턴스 반환
static get instance(): ClickablePropsFormatter {
if (!this._instance) {
this._instance = new ClickablePropsFormatter();
}
return this._instance;
}
// props 내용물에 따라 적절한 포맷팅 함수를 호출
// 반환값은 { className: string, restProps: object } 형태
formatProps<T extends ElementType>(props: ClickableFactoryProps & ComponentPropsWithoutRef<T>) {
const formatter = ClickablePropsFormatter.formatterMap.get(props.shape);
if (!formatter) {
throw new Error("Invalid shape");
}
return formatter.doFormat(props)
}
}
export function Clickable<T extends ElementType = "button">({ children, Component, ...props }: ClickableFactoryProps & ComponentPropsWithoutRef<T> & { Component?: T }) {
const Render = Component ?? "button";
const { className, restProps } = ClickablePropsFormatter.instance.formatProps<T>(props as ClickableFactoryProps & ComponentPropsWithoutRef<T>);
return <Render className={className} {...restProps}>{children}</Render>
}
ClickablePropsFormatter를 도입하는 과정에서 타입적인 개선도 이루어졌다. 게다가 객체지향적으로 구조를 바꾸다보니 코드 변경이 용이해져서, 이제 Clickable은 컴포넌트 뿐만 아니라 일반 태그까지도 처리할 수 있게 되었다. 지금의 Clickable은 내가 개발을 시작했을 때 머리속으로 그려왔던 바로 그 Clickable에 가장 가까운 것 같다. 이후로는 어떻게 이걸 바꾸게 될지 잘 모르겠다. Slot 패턴을 써먹어볼까 싶기도 한데, 내가 이 패턴을 아주 좋아하지는 않아서 확신은 없다.
블로그의 정보
Ayden's journal
Beard Weard Ayden