
상황
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도 너무 맹신해버리면 안 되겠다.
더 읽어보기
2026.03.01
렌더링 전략 정리
리액트와 Next.js에서 렌더링 전략은 단순한 옵션 선택이 아니다. 이는 서비스의 초기 로딩 속도, 서버 비용, 캐싱 전략, SEO 노출, 개발 복잡도까지 동시에 좌우하는 아키텍처 결정이다. 프로젝트 규모가 커질수록 “어디에서 HTML을 생성하는가”, “언제 자바스크립트를 실행하는가”…
2026.02.22
View Transition API
웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…
2025.10.09
Activity 컴포넌트
리액트로 UI를 구성하다 보면, 특정 컴포넌트를 조건부로 렌더링해야 하는 상황이 자주 발생한다. 일반적으로는 삼항 연산자 등을 활용해서 컴포넌트를 렌더링하는데, 내 경우에는 이런 조건 렌더링을 반복적으로 작성하는 게 싫어 라는 특별한 컴포넌트를 만들어 사용하고 있다. 하지만 어떤 방식으…
2025.10.08
useEffectEvent
React를 사용하면서 useEffect 안에서 상태를 참조할 때 의외로 자주 겪는 문제가 있다. 바로 stale closure 문제다. 예를 들어 어떤 값이 변경되었는데, useEffect 내부의 콜백에서는 여전히 이전 값을 읽고 있는 현상이다. 이는 React의 클로저 구조상 자연스…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
댓글
댓글을 불러오는 중...