📘 2025-06-24 TIL

📌 오늘 배운 핵심 요약

  • Title, Header, Footer 컴포넌트를 디자인 시스템 기반으로 제작
  • vanilla-extract로 스타일을 모듈화하고 tailwind-variants와 혼합 사용
  • Omit<T, K>로 타입 안정성과 DOM 전달 안전성을 확보
  • React Testing Library로 컴포넌트 동작과 스타일을 테스트

🧠 상세 학습 내용

📍 주제 1: Title 컴포넌트와 테스트

React로 컴포넌트를 개발할 때, UI 일관성과 유지보수성을 높이기 위해 타입 안정성, 스타일 분리, 테스트 자동화가 중요합니다. 이번 글에서는 vanilla-extract를 사용해 스타일을 분리하고, React Testing Library로 테스트를 작성한 사례를 소개합니다.

🧱 1. Title 컴포넌트 만들기

✅ 목적

  • 다양한 제목 스타일(size, color)을 prop으로 지정 가능
  • 디자인 시스템에 정의된 토큰(vars)을 활용

✅ props 타입 정의

// 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에서 정의한 스타일 객체이며, 디자인 시스템의 토큰을 기반으로 동작합니다.


🎨 2. 관련 스타일 정의 (title.css.ts)

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을 깔끔하게 만들 수 있어요. 유지보수성이 매우 좋습니다.


🧪 3. 테스트 코드 작성 (Jest + React Testing Library)

✅ 기본 렌더링 테스트

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 전체를 정확히 알 필요는 없고, 특정 키워드가 포함되었는지만 확인하는 방식입니다.


🧩 4. 테스트 시 고려 사항

ThemeProvider가 있는 경우

  • useContext로 다크모드 상태를 사용하는 컴포넌트는 테스트 시 반드시 <ThemeProvider>로 감싸야 합니다.
import { ThemeProvider } from '@/providers/ThemeProvider';

render(
  <ThemeProvider>
    <Title size="lg" color="primary">제목</Title>
  </ThemeProvider>
);

jest


📍 주제 2: Omit<T, K>란?

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에 불필요한 커스텀 속성이 들어가지 않게 안전하게 필터링할 수 있어요.


✅ 그리고 나서 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 안 들어감!!
      />
    );
  };

🔒 요점 요약

  • interactive는 card()에 스타일로만 사용됨
  • Omit으로 <div {...rest}>에는 절대 전달되지 않음
  • 👉 interactive="true" 같은 잘못된 DOM 속성 경고를 방지함

🧭 Header – 반응형 상단 고정 헤더

  • sticky + transition으로 상단 고정 + 스크롤 시 축소
  • body.scrolled 클래스로 스타일 전환 (padding, box-shadow 등)
  • 모바일 반응형 대응

스크롤 위치에 따라 스타일이 동적으로 변경되는 반응형 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>

headerSticy


  • vanilla-extract로 footerContainer, copyrightStyle 등 구성
  • 전체 레이아웃의 마지막 고정 위치에 배치

💡 className="flex gap-2" 같은 tailwind는 vanilla-extract 스타일로 일관되게 옮기는 게 유지보수에 좋음.


📍 주제 4: FadeInSection – Scroll 애니메이션 유틸

  • framer-motion과 useInView 조합
  • 한 번만 등장 vs. 스크롤 진입마다 반복 여부 설정 가능
  • motion.section + opacity, y 트랜지션 제공

✅ 사용법


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 전환


💭 회고

• 새롭게 알게 된 점

  • styleVariants를 통해 prop과 스타일을 직접 매핑하면 일관성과 타입 추론이 좋아짐
  • Omit<T, K>로 불필요한 DOM 경고를 방지하고 타입 안전성 확보

• 어렵게 느껴졌던 부분

  • vanilla-extract 테마 확장 시 TypeScript 오류 대응
  • createThemeContract와 createTheme의 strict한 타입 구조

다음에 학습할 주제

  • 회원가입