본문으로 건너뛰기

안동민 개발노트

본문 시작

반응형 카드 목록 컴포넌트 만들기

전역 CSS·styled-components·CSS 모듈·인라인 스타일의 책임을 나누어 반응형 카드 목록을 만들고 cascade와 접근성을 검증합니다.

우리는 인라인 스타일링부터 일반 CSS 클래스, CSS 모듈, 그리고 styled-components까지 다양한 스타일링 방법과 반응형 디자인의 기초를 다졌습니다.

이번 실습에서는 이러한 지식을 총동원해 반응형 카드 목록(Responsive Card List) 컴포넌트를 만들어 봅니다.

이 실습을 통해 각 스타일링 기법이 어떤 상황에 적합한지 체감하고, 복잡한 UI를 여러 스타일링 방식을 혼합해 구현하는 방법을 경험할 수 있습니다.

반응형 카드 목록에서 전역 CSS, styled-components, CSS 모듈, 인라인 style이 맡는 범위와 cascade 주의점을 비교한 표

React · styling responsibility

도구 이름보다 영향 범위와 값의 출처를 먼저 정하면 스타일을 섞어도 책임이 겹치지 않습니다.

카드 목록 예제의 스타일 경계와 cascade 계약
경계 이 예제의 책임 확인할 계약
전역 CSS body, 제목, .App, 공유 유틸리티의 기본값을 한 번 적용합니다. 요소 선택자와 전역 클래스는 모든 일치 요소에 닿으므로 카드 내부 세부 규칙을 넣지 않습니다.
styled-components CardsContainer의 Grid·media query와 FilterButton의 prop 기반 상태를 맡습니다. 스타일 전용 값은 $active 같은 transient prop으로 두고 실제 선택 상태는 aria-pressed로 노출합니다.
CSS 모듈 CardItem.module.css가 카드 내부의 지역 클래스와 정적 규칙을 제공합니다. 지역화되는 것은 클래스 이름입니다. 생성 문자열은 빌드 설정에 따라 달라지고 규칙은 여전히 cascade에 참여합니다.
인라인 style 카테고리 데이터에서 계산한 backgroundColor 한 값만 카드 배지에 적용합니다. 동일 중요도에서는 style attribute 선언이 선택자 규칙보다 우선하므로 값 범위를 좁게 유지합니다.
전역 CSS · 공유 기본값
body, 제목, .App, 유틸리티처럼 모든 화면이 공유할 규칙만 둡니다. 전역 선택자는 모든 일치 요소에 영향을 줍니다.
styled-components · 레이아웃과 상태
Grid와 media query, 필터 버튼의 prop 기반 CSS를 맡습니다. $active는 DOM에 전달하지 않고 aria-pressed가 실제 상태를 설명합니다.
CSS 모듈 · 지역 클래스
카드 내부 정적 규칙을 지역 이름으로 import합니다. 클래스 이름 충돌은 줄지만 CSS cascade 자체가 사라지는 것은 아닙니다.
인라인 style · 계산된 한 값
카테고리 배경색처럼 JavaScript 데이터에서 정해지는 값만 둡니다. 같은 중요도에서는 선택자 규칙보다 우선합니다.

가장 넓은 전역 경계에서 가장 좁은 값 경계로 내려갈수록 책임을 줄이고, 같은 속성을 여러 도구가 동시에 소유하지 않게 합니다.


실습 목표: 스타일링 전략 비교 적용

이번 실습은 스타일링 전략 선택을 중심으로 진행합니다.

같은 UI를 만들더라도 동적 스타일, 재사용성, 클래스 충돌 방지 요구에 따라 도구 선택이 달라지는 지점을 비교해 봅니다.

다양한 스타일링 기법 적용
  • 인라인 스타일링: JavaScript 데이터에서 계산한 한두 개의 값에 제한적으로 활용.
  • CSS 클래스: 전역 기본 스타일과 명시적으로 공유할 유틸리티 클래스에 활용.
  • CSS 모듈: 컴포넌트의 지역 클래스 이름을 만들고 정적 내부 스타일에 활용.
  • styled-components: 목록 레이아웃과 컨트롤을 스타일링하고 props에 따라 CSS 규칙을 선택할 때 활용.

미디어 쿼리를 이용한 반응형 디자인: 사용 가능한 너비에 따라 카드 열 수가 유동적으로 정해지고, 좁은 화면에서는 한 열과 작은 글자 크기로 전환되도록 구현.

Flexbox 또는 CSS Grid 활용: 카드 목록을 유연하게 배치하는 레이아웃 구현.


준비 단계: 스타일 역할 분리

준비 단계의 선택 기준은 단순합니다.

전역 리셋·기본값은 index.css, 목록과 필터 컨트롤의 prop 기반 규칙은 styled-components, 카드 내부의 정적 규칙은 CSS 모듈에 배치합니다. 카테고리처럼 데이터에서 계산되는 한 개의 색상 값만 인라인 스타일로 남깁니다.

CSS 모듈과 styled-components가 만든 클래스도 결국 같은 문서의 CSS cascade에 참여합니다. 클래스 이름 지역화는 선택자 충돌을 줄이지만 Shadow DOM 같은 완전한 스타일 격리는 아닙니다.

Vite로 생성된 프로젝트가 있다고 가정합니다.

src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

main.jsx
App.jsx
index.css # 전역/기본 스타일
CardList.jsx
CardItem.jsx
CardItem.module.css

레이아웃 기반 전역 스타일 설정 (index.css)

전체 애플리케이션에 적용될 기본적인 CSS 리셋 및 폰트 설정을 포함합니다.

src/index.css
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f0f2f5;
  color: #333;
  line-height: 1.6;
}

.App {
  padding: 20px;
  max-width: 1200px;
  margin: 20px auto;
  background-color: #ffffff;
  border-radius: 10px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}

h1, h2 {
  color: #2c3e50;
  text-align: center;
  margin-bottom: 30px;
}

/* 유틸리티 클래스 (일반 CSS 클래스 예시) */
.text-center {
  text-align: center;
}

.margin-top-20 {
  margin-top: 20px;
}

body, h1, h2 같은 요소 선택자와 .App, 유틸리티 클래스는 문서 전체에 영향을 줍니다. 전역 규칙에는 모든 화면이 공유해야 하는 기본값만 두고, 카드 내부 규칙은 뒤에서 CSS 모듈로 옮깁니다.


Styled-components를 사용한 컨테이너

CardList.jsx에서 카드 목록 전체를 감싸는 컨테이너를 styled-components로 만들고, 반응형 레이아웃을 구현합니다.

src/components/CardList.jsx
import { useState } from 'react';
import styled from 'styled-components';
import CardItem from './CardItem'; // 다음 단계에서 만들 CardItem 컴포넌트

// 예제가 별도 이미지 파일 없이도 실행되도록 만든 장식용 placeholder
const placeholderImage = `data:image/svg+xml,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
    <rect width="640" height="360" fill="#e4e7ec" />
    <circle cx="320" cy="140" r="56" fill="#98a2b3" />
    <path d="M176 304c28-72 86-108 144-108s116 36 144 108" fill="#667085" />
  </svg>
`)}`;

// (가상 데이터) 카드 목록에 표시될 데이터
const mockCards = [
  { id: 1, title: '리액트 기초', description: 'JSX, 컴포넌트, props의 이해', category: '프론트엔드', imgUrl: placeholderImage },
  { id: 2, title: '훅 심화', description: 'useState, useEffect, useContext 심화 학습', category: '리액트 훅', imgUrl: placeholderImage },
  { id: 3, title: '스타일링 마스터', description: 'CSS-in-JS, CSS 모듈, 반응형 디자인', category: 'UI/UX', imgUrl: placeholderImage },
  { id: 4, title: '성능 최적화', description: '메모이제이션, 코드 스플리팅 기법', category: '심화', imgUrl: placeholderImage },
  { id: 5, title: '라우팅 가이드', description: 'React Router를 이용한 페이지 전환', category: '프론트엔드', imgUrl: placeholderImage },
  { id: 6, title: '상태 관리 패턴', description: 'Redux Toolkit, Zustand, Jotai 비교 분석', category: '아키텍처', imgUrl: placeholderImage },
];

// styled-components를 사용한 반응형 목록 컨테이너
const CardsContainer = styled.ul`
  margin: 0;
  list-style: none;
  display: grid;
  gap: 25px; /* 카드 사이의 간격 */
  /* 사용 가능한 너비에 280px 이상인 열을 가능한 만큼 배치 */
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  padding: 20px;
  background-color: #fdfdfd;
  border-radius: 10px;
  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);

  /* 992px 이하에서는 최소 열 너비와 간격을 줄임 */
  @media (max-width: 992px) {
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 20px;
    padding: 15px;
  }

  /* 미디어 쿼리: 모바일 (최대 576px) */
  @media (max-width: 576px) {
    grid-template-columns: 1fr; /* 1열 */
    gap: 15px;
    padding: 10px;
  }
`;

// 카테고리 필터링을 위한 prop 기반 버튼 스타일
const FilterButton = styled.button`
  padding: 10px 20px;
  margin: 5px;
  border: 1px solid #ccc;
  border-radius: 5px;
  background-color: white;
  color: #1d2939;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;

  &:focus-visible {
    outline: 3px solid #111;
    outline-offset: 2px;
  }

  /* $active는 DOM으로 전달되지 않는 transient prop */
  ${({ $active }) => $active && `
    background-color: #005fcc;
    color: white;
    border-color: #005fcc;
    font-weight: bold;
  `}

  &:hover {
    background-color: ${({ $active }) => $active ? '#004a99' : '#eee'};
  }

  @media (prefers-reduced-motion: reduce) {
    transition: none;
  }
`;

function CardList() {
  const [filterCategory, setFilterCategory] = useState('All'); // 필터링 상태

  const categories = ['All', ...new Set(mockCards.map(card => card.category))];

  const filteredCards = filterCategory === 'All'
    ? mockCards
    : mockCards.filter(card => card.category === filterCategory);
  const selectedLabel = filterCategory === 'All' ? '전체' : filterCategory;

  return (
    <>
      <h1 className="text-center">반응형 카드 목록</h1>
      <div
        className="text-center margin-top-20"
        role="group"
        aria-label="카테고리 필터"
      >
        {categories.map(category => (
          <FilterButton
            key={category}
            type="button"
            onClick={() => setFilterCategory(category)}
            $active={filterCategory === category}
            aria-pressed={filterCategory === category}
          >
            {category}
          </FilterButton>
        ))}
      </div>
      <p className="text-center" role="status" aria-live="polite">
        {selectedLabel} 카테고리: {filteredCards.length}
      </p>
      <CardsContainer aria-label="강의 카드 목록">
        {filteredCards.map(card => (
          <CardItem key={card.id} card={card} />
        ))}
      </CardsContainer>
    </>
  );
}

export default CardList;

repeat(auto-fit, minmax(...))는 컨테이너에 들어갈 수 있는 열 수를 계산하므로 주석만으로 3열이나 2열을 보장하지 않습니다. 992px 경계에서는 최소 열 너비와 간격을 줄이고, 576px 이하에서만 한 열을 명시적으로 고정합니다.

$active는 styled-components가 스타일 계산에만 사용하고 기본 DOM 버튼에는 전달하지 않는 transient prop입니다. 실제 선택 상태의 접근성 계약은 별도의 aria-pressed로 표현합니다.


CSS 모듈을 사용한 카드 아이템

개별 카드 아이템의 정적 스타일은 CSS 모듈의 지역 클래스 이름으로 관리합니다.

카테고리 데이터에서 계산되는 배경색 한 값만 인라인 스타일로 적용합니다. 두 방식이 같은 요소에 적용되더라도 CSS 모듈이 cascade 밖으로 격리되는 것은 아닙니다.

CardItem.module.css

src/components/CardItem.module.css
.card {
  background-color: white;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
  display: flex; /* 내부 요소 정렬을 위한 Flexbox */
  flex-direction: column;
}

@media (hover: hover) and (pointer: fine) {
  .card:hover {
    transform: translateY(-5px);
    box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
  }
}

.cardImage {
  width: 100%;
  height: 180px; /* 고정 높이 */
  object-fit: cover; /* 비율을 유지해 박스를 채우며 일부가 잘릴 수 있음 */
  background-color: #eee; /* 이미지 없을 때 대비 */
}

.cardContent {
  padding: 15px;
  flex-grow: 1; /* 남은 공간 차지 */
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.cardTitle {
  font-size: 1.4em;
  color: #34495e;
  margin-top: 0;
  margin-bottom: 10px;
}

.cardDescription {
  font-size: 0.95em;
  color: #475467;
  margin-bottom: 15px;
  flex-grow: 1; /* 설명이 길어져도 레이아웃 유지 */
}

.cardCategory {
  font-size: 0.85em;
  color: white;
  background-color: #475467; /* 알 수 없는 category의 fallback */
  padding: 5px 10px;
  border-radius: 15px;
  display: inline-block;
  margin-top: 10px;
  align-self: flex-start; /* 좌측 정렬 */
}

/* 반응형을 위한 미디어 쿼리 (CSS 모듈 내에서도 사용 가능) */
@media (max-width: 576px) {
  .cardTitle {
    font-size: 1.2em;
  }
  .cardDescription {
    font-size: 0.9em;
  }
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }

  .card:hover {
    transform: none;
  }
}

CardItem.jsx

src/components/CardItem.jsx
import styles from './CardItem.module.css'; // CSS 모듈 임포트

function CardItem({ card }) {
  // 흰 글자와 충분한 대비를 갖는 카테고리 배경색
  const categoryColorMap = {
    '프론트엔드': '#b42318',
    '리액트 훅': '#175cd3',
    'UI/UX': '#8a4b08',
    '심화': '#6941c6',
    '아키텍처': '#107569'
  };
  const categoryStyle = {
    backgroundColor: categoryColorMap[card.category] || '#475467'
  };

  return (
    <li className={styles.card}> {/* CSS 모듈 클래스 사용 */}
      <img
        src={card.imgUrl}
        alt=""
        width={640}
        height={360}
        className={styles.cardImage}
        loading="lazy"
      />
      <div className={styles.cardContent}>
        <h3 className={styles.cardTitle}>{card.title}</h3>
        <p className={styles.cardDescription}>{card.description}</p>
        <span className={styles.cardCategory} style={categoryStyle}>
          {card.category}
        </span>
      </div>
    </li>
  );
}

export default CardItem;

CardItem.module.css를 import하면 지역 이름과 실제 클래스 이름의 매핑 객체가 만들어집니다. 생성되는 문자열 형식은 Vite 설정과 개발·프로덕션 모드에 따라 달라질 수 있으므로 특정 접두사나 해시 모양에 의존하지 않습니다.

부모의 map()에 둔 key={card.id}는 React가 형제 항목을 추적하는 힌트이며 CardItem의 prop으로 전달되지 않습니다. 카드가 id 자체를 사용해야 한다면 card.id나 별도 prop으로 명시해야 합니다.

이 예제의 이미지는 모든 카드에 같은 fallback을 사용하고 바로 아래 제목을 반복하므로 alt=""로 장식 이미지임을 표시했습니다. 이미지가 제목에 없는 정보를 전달하도록 바뀐다면 그 목적에 맞는 대체 텍스트도 함께 전달해야 합니다.

CardItem 컴포넌트가 카드 prop, CSS 모듈, 계산된 인라인 배경색을 결합해 의미 있는 목록 항목을 렌더링하는 구조도

React · component styling boundary

구조·정적 규칙·데이터 값을 분리해 전달하고, CardItem에서 하나의 목록 항목으로 결합합니다.

CardItem 입력과 렌더링 구조 카드 prop, CSS 모듈, 인라인 배경색이 CardItem에 들어가고 CardItem은 시맨틱 li 요소와 하위 이미지, 제목, 설명, 카테고리를 렌더링한다. card prop title · description category · imgUrl · id CSS 모듈 styles.card · styles.cardImage 지역 클래스 · 정적 카드 규칙 인라인 style backgroundColor category에서 계산한 한 값 CardItem prop을 마크업에 연결 className + style key는 부모 목록에서만 사용 렌더된 목록 항목 <li> · <img alt=""> <h3> · <p> · <span> ul 안의 의미 구조 · 접근 가능한 텍스트
1 · card prop
제목·설명·카테고리·이미지 URL을 구조에 연결합니다. 안정적인 id는 부모가 목록의 key로 쓰며 자식 prop으로 자동 전달되지는 않습니다.
2 · CSS 모듈
styles.card 같은 지역 클래스가 카드의 정적 규칙을 제공합니다. 실제 생성 문자열은 빌드 설정에 따라 달라집니다.
3 · 인라인 style
카테고리 데이터에서 계산한 backgroundColor만 전달합니다. 동일 중요도에서는 style attribute 선언이 선택자 규칙보다 우선합니다.
4 · 의미 있는 결과
CardItem은 입력을 ul 안의 li로 결합합니다. 제목이 바로 뒤에 반복되므로 예제의 일반 이미지는 alt=""로 중복 낭독을 피합니다.

CSS 모듈은 클래스 이름 충돌을 줄이지만 cascade를 격리하지 않습니다. 같은 속성의 소유자를 하나로 정해 최종 계산 스타일을 예측 가능하게 유지합니다.


App.jsxmain.jsx 연결

App.jsx에서 컴포넌트를 조합하고, Vite의 진입점인 main.jsx에서 전역 CSS와 App을 연결합니다.

src/App.jsx
import CardList from './components/CardList';

function App() {
  return (
    <div className="App">
      <CardList />
    </div>
  );
}

export default App;
src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

완성된 카드 목록은 데이터, 필터 상태, props, 스타일 책임이 서로 물려 있습니다. 문제가 생기면 원본 데이터에서 시작해 필터 결과, 자식 prop, 최종 DOM과 계산된 스타일 순서로 추적합니다.


검증 순서: 반응형·스코프 점검

실습 검증은 화면 크기별 레이아웃과 스타일 스코프를 분리해서 봅니다.

먼저 반응형 깨짐을 확인하고, 다음으로 클래스 충돌/동적 스타일 반영 여부를 점검합니다.

위의 모든 코드 파일들을 해당 경로에 맞게 생성하고 내용을 복사하여 붙여넣으세요.

styled-components가 설치되어 있는지 확인합니다 (npm install styled-components 또는 yarn add styled-components).

npm run dev (또는 yarn dev) 명령어를 실행하여 개발 서버를 시작합니다.

브라우저에서 http://localhost:5173에 접속합니다.

카드 목록 확인: 다양한 카드들이 그리드 형태로 배열되어 있는지 확인합니다.

카테고리 필터링: 상단의 필터 버튼을 클릭하여 특정 카테고리의 카드만 표시되는지 확인합니다. $active에 따른 styled-components 규칙과 DOM의 aria-pressed 값이 함께 바뀌는지도 확인하세요.

반응형 테스트: 브라우저 창의 너비를 조절하거나 개발자 도구의 반응형 모드를 사용합니다. auto-fit은 사용 가능한 공간과 최소 열 너비에 따라 열 수를 정하므로 특정 폭에서 항상 3열·2열이 된다고 단정하지 않습니다. 576px 이하에서는 한 열로 전환되고 카드 제목·설명 크기도 바뀌는지 확인하세요.

스코프와 cascade 확인: 개발자 도구에서 styles.card가 실제 지역 클래스 이름으로 매핑되고 styled-components 규칙도 생성되었는지 확인합니다. 정확한 클래스 문자열 대신 Styles/Computed 패널에서 어떤 규칙이 적용·재정의됐는지를 확인하세요.

인라인 스타일 확인: 카테고리 스팬에는 background-color 한 값만 인라인으로 적용되고, 글자색·여백·모양은 CSS 모듈 규칙에서 오는지 확인합니다. 같은 중요도라면 style attribute의 선언이 선택자 규칙보다 우선합니다.

접근성 확인: 키보드로 모든 필터 버튼에 도달하고 활성화할 수 있는지, focus 표시와 aria-pressed가 보이는 상태와 일치하는지, 결과가 ul/li 목록으로 노출되는지, 장식 이미지가 빈 대체 텍스트를 갖는지 확인합니다. 동작 줄이기 환경에서는 카드 이동 효과도 꺼져야 합니다.


아래 다이어그램은 카드 목록 실습에서 문제가 생겼을 때 원인 위치를 좁히는 순서를 정리한 것입니다.

반응형 카드 목록의 데이터, 렌더링, 스타일 계산, 접근성을 소스와 브라우저 결과에서 차례로 검증하는 표

React · verification trace

소스 모양만 보지 말고 데이터 → DOM → 계산된 스타일 → 사용자 경험 순서로 결과를 추적합니다.

반응형 카드 목록 구현의 검증 순서와 통과 기준
순서 확인 위치 통과 기준
01 · 데이터와 필터 mockCards, filterCategory, filteredCards 각 카드에 안정적인 id와 필요한 필드가 있고, 선택한 카테고리와 결과 목록·개수가 일치합니다.
02 · prop과 DOM 부모의 key, CardItem card, 렌더된 ul/li와 live 결과 문구 key는 형제 사이에서 안정적이고 고유하며 자식 prop으로 기대하지 않습니다. 목록과 결과 개수는 의미 있는 요소로 노출됩니다.
03 · 스타일 계산 전역 CSS, styled-components, CSS 모듈, 배지의 style, DevTools의 Computed $active는 DOM에 전달되지 않고 aria-pressed와 상태가 일치합니다. 인라인 선언은 계산된 배경색 한 값뿐입니다.
04 · 반응형과 접근성 Grid/media query, 키보드 초점, 이미지 대체 텍스트, 명암, reduced motion 가로 넘침 없이 폭에 맞춰 열이 재배치되고 576px 이하에서는 한 열입니다. 초점이 보이고 중복 이미지 설명이 없으며 상태·개수·모션이 사용자 설정에 맞습니다.
  1. 데이터와 필터

    mockCards의 안정적인 id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다.

  2. prop과 DOM

    key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가 ul/li 목록과 live 상태 문구로 노출되는지 확인합니다.

  3. 스타일 계산

    전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다. $active는 DOM에서 빠지고 aria-pressed가 실제 선택 상태와 일치해야 합니다.

  4. 반응형과 접근성

    가로 넘침, 576px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다.

CSS 모듈의 생성 클래스 문자열은 빌드 설정에 따라 달라질 수 있습니다. 특정 해시보다 import된 mapping과 최종 DOM·Computed 결과를 검증합니다.


이 실습에서는 인라인 스타일, 전역 CSS 클래스, CSS 모듈, styled-components, 미디어 쿼리, CSS Grid를 같은 예제 안에서 비교했습니다.

스타일링 방식은 클래스 이름의 범위, 동적 값의 출처, cascade, 접근성, 팀 규칙과 빌드 환경을 기준으로 선택합니다. 컴포넌트 API에는 실제로 구현한 데이터와 이벤트만 노출하고, 페이지 이동이나 상태 저장 같은 행동을 암묵적으로 숨기지 않습니다.