tags:
- TIL
- React
created: 2025-06-24
📘 2025-06-24 TIL
React로 컴포넌트를 개발할 때, UI 일관성과 유지보수성을 높이기 위해 타입 안정성, 스타일 분리, 테스트 자동화가 중요합니다. 이번 글에서는 vanilla-extract를 사용해 스타일을 분리하고, React Testing Library로 테스트를 작성한 사례를 소개합니다.
// HeadingSize: 'sm' | 'md' | 'lg'
// ColorKey: 'primary' | 'secondary' | ...
interface Props {
children: React.ReactNode;
size: HeadingSize;
color: ColorKey;
}
import { titleColorStyle, titleSizeStyle } from './title.css';
import type { HeadingSize, ColorKey } from '@/styles/theme.css';
function Title({ children, size, color }: Props) {
return (
<h1 className={`${titleSizeStyle[size]} ${titleColorStyle[color]}`}>
{children}
</h1>
);
}
export default Title;
여기서 titleSizeStyle[size]
과 titleColorStyle[color]
은 vanilla-extract에서 정의한 스타일 객체이며, 디자인 시스템의 토큰을 기반으로 동작합니다.
import { styleVariants } from '@vanilla-extract/css';
import { vars } from '@/styles/theme.css';
export const titleSizeStyle = styleVariants({
sm: { fontSize: vars.heading.sm.fontSize },
md: { fontSize: vars.heading.md.fontSize },
lg: { fontSize: vars.heading.lg.fontSize },
});
export const titleColorStyle = styleVariants({
primary: { color: vars.color.primary },
secondary: { color: vars.color.secondary },
third: { color: vars.color.third },
});
styleVariants를 사용하면 key별로 분기되는 className을 깔끔하게 만들 수 있어요. 유지보수성이 매우 좋습니다.
import { render, screen } from '@testing-library/react';
import Title from './Title';
describe('Title 컴포넌트', () => {
it('제목이 잘 렌더링되는지 확인', () => {
render(<Title size="lg" color="primary">제목</Title>);
const heading = screen.getByText('제목');
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H1');
});
it('올바른 클래스 이름이 적용되는지 확인', () => {
render(<Title size="md" color="secondary">타이틀</Title>);
const heading = screen.getByText('타이틀');
expect(heading.className).toMatch(/md/);
expect(heading.className).toMatch(/secondary/);
});
});
여기서 className 전체를 정확히 알 필요는 없고, 특정 키워드가 포함되었는지만 확인하는 방식입니다.
<ThemeProvider>
로 감싸야 합니다.import { ThemeProvider } from '@/providers/ThemeProvider';
render(
<ThemeProvider>
<Title size="lg" color="primary">제목</Title>
</ThemeProvider>
);
Omit<Type, Keys>는 TypeScript 유틸리티 타입으로, Type에서 Keys에 해당하는 키를 제외한 새로운 타입을 만들어줍니다.
type User = {
id: number;
name: string;
password: string;
};
type PublicUser = Omit<User, 'password'>;
// 결과: { id: number; name: string }
즉, “이 키는 타입에서 제거하고 나머지만 써줘” 라는 뜻입니다.
type CardVariantProps = VariantProps<typeof card>;
// => 여기엔 variant, padding, shadow, rounded, interactive 등이 있음
type CardProps =
Omit<React.HTMLAttributes<HTMLDivElement>, keyof CardVariantProps>
& CardVariantProps;
이건 다음과 같은 뜻이에요:
div에 쓸 수 있는 모든 HTML 속성 중에서 variant, padding, shadow, rounded, interactive를 제외한 나머지 +
우리가 직접 만든 스타일 속성(CardVariantProps)을 props로 받을 거야!
👉 이렇게 하면 div에 전달될 props에 불필요한 커스텀 속성이 들어가지 않게 안전하게 필터링할 수 있어요.
export const Card = ({
variant,
padding,
shadow,
rounded,
interactive,
className,
...rest // 👉 DOM에 들어갈 나머지 props만 안전하게
}: CardProps) => {
return (
<div
className={clsx(
cardStyle,
card({ variant, padding, shadow, rounded, interactive }),
className
)}
{...rest} // ✅ HTML 속성만 안전하게 전달
/>
);
};
개념 | 설명 |
---|---|
Omit<T, K> | 타입 T에서 키 K를 제외한 타입을 생성 |
목적 | 컴포넌트에서 DOM으로 전달되면 안 되는 커스텀 props를 안전하게 필터링 |
왜 필요? | React는 DOM에 모르는 속성이 들어가면 경고를 냄 (interactive 같은 거) |
실제 효과 | 깔끔한 타입 구성 + React 경고 방지 + 더 안전한 컴포넌트 |
타입 구성 ─────────────────────────────┐
│
tailwind-variants 정의 ▼
---------------------- CardVariantProps
const card = tv({ = {
variants: { variant?: ...,
variant: { ... }, padding?: ...,
padding: { ... }, shadow?: ...,
shadow: { ... }, rounded?: ...,
rounded: { ... }, interactive?: boolean
interactive: { ... } }
}
})
▼
타입 구성 ──────────────────────────────────────────────────────┐
│
CardProps 정의 ▼
-------------------- ┌────────────────────────────────────┐
type CardProps = │ React.HTMLAttributes<HTMLDivElement> │
Omit< └────────────────────────────────────┘
HTMLAttributes<HTMLDivElement>, (DOM에 전달 가능한 모든 기본 속성)
keyof CardVariantProps ▲
> │
& CardVariantProps ▼
Omit: 'interactive', 'variant' 등 제외
➤ `rest`에는 className, id 등만 남음
▼
실제 컴포넌트 사용 ─────────────────────────────────────────────┐
▼
export const Card = ({ variant, interactive, ...rest }: CardProps) => {
return (
<div
className={clsx(
cardStyle, // 기본 스타일
card({ variant, interactive }), // variant를 스타일로만 사용
className
)}
{...rest} // 여기에는 interactive 안 들어감!!
/>
);
};
스크롤 위치에 따라 스타일이 동적으로 변경되는 반응형 Sticky Header입니다. Vanilla Extract + tailwind-variants를 조합하여 구현되었으며, 다크모드와 접근성(시각적 숨김)도 고려했습니다.
• 상단 고정(sticky)
• 스크롤 시 자동 축소 (height, padding, box-shadow 변화)
• BOOKSTORE 로고 및 카테고리 네비게이션, 로그인/회원가입 버튼
• 반응형 레이아웃 (최대 너비: 1020px)
• body.scrolled 클래스를 활용한 스타일 분기
body.scrolled 클래스가 추가되면 headerContainer에 아래 스타일이 적용됨:
selectors: {
'body.scrolled &': {
padding: vars.spacing.sm,
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.05)',
borderRadius: '12px',
},
},
<header className={clsx(...)}> // sticky + transition
<div className={headerContainer}> // max-width: 1020px
<img src="..." alt="BOOKSTORE 로고" />
<h1><span className="sr-only">BOOKSTORE</span></h1>
<nav>전체, 동화, 소설, 사회</nav>
<div>로그인 / 회원가입</div>
</div>
</header>
💡 className="flex gap-2" 같은 tailwind는 vanilla-extract 스타일로 일관되게 옮기는 게 유지보수에 좋음.
import { FadeInSection } from '@/components/motion/FadeInSection';
// 한 번만 등장 애니메이션
<FadeInSection>
<Card>최초 진입 시 fade-in</Card>
</FadeInSection>
// 스크롤 진입할 때마다 fade-in
<FadeInSection once={false}>
<Card>계속 재등장하는 카드</Card>
</FadeInSection>
• useInView로 요소가 viewport에 들어올 때 감지
• framer-motion의 useAnimation으로 애니메이션 제어
• motion.section 태그로 감싸서 y: 20 → 0, opacity: 0 → 1 전환
• 새롭게 알게 된 점
• 어렵게 느껴졌던 부분
• 다음에 학습할 주제