반응형 카드 목록 컴포넌트 만들기
전역 CSS·styled-components·CSS 모듈·인라인 스타일의 책임을 나누어 반응형 카드 목록을 만들고 cascade와 접근성을 검증합니다.
우리는 인라인 스타일링부터 일반 CSS 클래스, CSS 모듈, 그리고 styled-components까지 다양한 스타일링 방법과 반응형 디자인의 기초를 다졌습니다.
이번 실습에서는 이러한 지식을 총동원해 반응형 카드 목록(Responsive Card List) 컴포넌트를 만들어 봅니다.
이 실습을 통해 각 스타일링 기법이 어떤 상황에 적합한지 체감하고, 복잡한 UI를 여러 스타일링 방식을 혼합해 구현하는 방법을 경험할 수 있습니다.
CARD LIST · STYLE RESPONSIBILITY
카드 목록의 스타일은 범위와 값의 출처로 나눕니다
전역 기본값, 목록·필터 규칙, 카드 내부 정적 규칙, 데이터 기반 배경색을 분리합니다. 모든 결과 CSS는 같은 문서의 cascade에 참여합니다.
본문 필터 버튼 · 스타일과 접근성 상태
filterCategory="프론트엔드"
$active=true → 선택 CSS 규칙
aria-pressed=true → 실제 버튼 상태
$active는 DOM에 전달하지 않음전역 CSS는 기본값, styled-components는 목록과 필터, CSS 모듈은 카드 내부, inline style은 배지 배경색 한 값을 맡습니다.
| 경계 · 이 예제의 책임 | 확인할 계약 |
|---|---|
| 전역 CSS
|
요소 선택자와 전역 클래스는 모든 일치 요소에 닿으므로 카드 내부 세부 규칙을 넣지 않습니다. |
| styled-components
|
스타일 전용 값은 $active 같은 transient prop으로 두고 실제 선택 상태는 aria-pressed로 노출합니다. |
| CSS 모듈
|
지역화되는 것은 클래스 이름입니다. 생성 문자열은 빌드 설정에 따라 달라지고 규칙은 여전히 cascade에 참여합니다. |
| 인라인 style 카테고리 데이터에서 계산한 |
동일 중요도에서는 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 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.
레이아웃 기반 전역 스타일 설정 (index.css)
전체 애플리케이션에 적용될 기본적인 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로 만들고, 반응형 레이아웃을 구현합니다.
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
.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
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 · INPUT & DOM
CardItem은 데이터·지역 클래스·동적 값을 한 li로 결합합니다
card prop은 제목·설명·카테고리를, CSS 모듈은 정적 규칙을 제공합니다. 카테고리에서 계산한 배경색 한 값만 style로 전달하며 부모의 key는 별도 식별 계약입니다.
본문 첫 카드 · 값 하나만 계산
card.title: 리액트 기초
card.category: 프론트엔드
backgroundColor: #b42318
결과: ul 안 li / h3 / p / span같은 장식 placeholder는 alt=""로 중복 낭독을 피합니다. 이미지가 새 정보를 전달한다면 그 목적에 맞는 대체 텍스트를 함께 제공해야 합니다.
card 데이터, 지역 클래스, 카테고리 배경색이라는 세 입력은 CardItem으로 각각 들어갑니다. 결합 결과는 ul 안의 li이며 장식 img와 h3,p,span을 갖습니다. 부모의 key는 자식 prop으로 자동 전달되지 않습니다.
CSS 모듈은 클래스 이름 충돌을 줄이지만 cascade를 격리하지 않습니다. 같은 속성의 소유자를 하나로 정해 최종 계산 스타일을 예측 가능하게 유지합니다.
App.jsx와 main.jsx 연결
App.jsx에서 컴포넌트를 조합하고, Vite의 진입점인 main.jsx에서 전역 CSS와 App을 연결합니다.
import CardList from './components/CardList';
function App() {
return (
<div className="App">
<CardList />
</div>
);
}
export default App;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 목록으로 노출되는지, 장식 이미지가 빈 대체 텍스트를 갖는지 확인합니다. 동작 줄이기 환경에서는 카드 이동 효과도 꺼져야 합니다.
아래 다이어그램은 카드 목록 실습에서 문제가 생겼을 때 원인 위치를 좁히는 순서를 정리한 것입니다.
반응형 카드 목록 검증 추적표 — 상태 추적
실제 All 6개에서 프론트엔드 id 1/5 두 개로 바뀌며 filterCategory/배열/개수 문구/aria-pressed가 같은 새 상태를 읽는다. 스타일 진단 표는 전체 관계도와 표와 인쇄에 보존한다.
CARD LIST · VERIFICATION TRACE
카드 오류는 데이터에서 DOM·계산 스타일로 추적합니다
필터 결과·개수와 목록을 먼저 대조하고, 최종 DOM 속성과 Computed를 확인합니다. 반응형 배치와 키보드·대체 텍스트·사용자 모션 선호도 함께 점검합니다.
같은 필터 결과에서 개수와 DOM이 나온다
선택한 필터는 aria-pressed=true입니다. key는 부모의 형제 식별이며 자식 prop이 아닙니다. $active는 DOM에 전달하지 않습니다. 생성 hash를 고정하지 않고 실제 mapping·DOM·계산 스타일을 확인합니다.
원래 설명과 상세 기준
본문 프론트엔드 필터 · 검증 시작점
All: 카드 6개
프론트엔드: id 1, 5 → 카드 2개
status: "프론트엔드 카테고리: 2개"
선택 버튼: aria-pressed=true안정 id는 부모 key이며 자식이 key prop을 받는 것은 아닙니다. 생성 hash를 고정하기보다 mapping·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 이하에서는 한 열입니다. 초점이 보이고 중복 이미지 설명이 없으며 상태·개수·모션이 사용자 설정에 맞습니다. |
-
데이터와 필터
mockCards의 안정적인id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. -
prop과 DOM
key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가ul/li목록과 live 상태 문구로 노출되는지 확인합니다. -
스타일 계산
전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다.
$active는 DOM에서 빠지고aria-pressed가 실제 선택 상태와 일치해야 합니다. -
반응형과 접근성
가로 넘침, 576px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다.
CSS 모듈의 생성 클래스 문자열은 빌드 설정에 따라 달라질 수 있습니다. 특정 해시보다 import된 mapping과 최종 DOM·Computed 결과를 검증합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 표 행 | 순서 | 확인 위치 | 통과 기준 |
| 원도표 표 행 | 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 | 가로 넘침 없이 폭에 맞춰 열이 재배치되고 576 px 이하에서는 한 열입니다. 초점이 보이고 중복 이미지 설명이 없으며 상태·개수·모션이 사용자 설정에 맞습니다. |
| 원도표 설명 | mockCards의 안정적인 id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. |
| 원도표 조건·주석 | 데이터와 필터 mockCards의 안정적인 id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. |
| 원도표 설명 | key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가 ul/li 목록과 live 상태 문구로 노출되는지 확인합니다. |
| 원도표 조건·주석 | prop과 DOM key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가 ul/li 목록과 live 상태 문구로 노출되는지 확인합니다. |
| 원도표 설명 | 전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다. $active는 DOM에서 빠지고 aria-pressed가 실제 선택 상태와 일치해야 합니다. |
| 원도표 조건·주석 | 스타일 계산 전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다. $active는 DOM에서 빠지고 aria-pressed가 실제 선택 상태와 일치해야 합니다. |
| 원도표 설명 | 가로 넘침, 576 px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다. |
| 원도표 조건·주석 | 반응형과 접근성 가로 넘침, 576 px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| All 목록 | filter: All / count: 6 | 전체 6개 카드를 표시한다 | UI는 같은 필터 원천을 읽는다 |
| 프론트엔드 선택 | 대상: id 1,5 / button: aria-pressed=true | filterCategory가 프론트엔드로 바뀐다 | transient $active는 DOM 속성이 아니다 |
| 일치한 출력 | count: 2 / 목록: id 1,5 | 카드 두 개와 프론트엔드 카테고리 2개 문구를 표시한다 | key는 부모 추적용이며 자식 prop이 아니다 |
반응형 카드 목록 검증 추적표 — 상태 추적
실제 All 6개에서 프론트엔드 id 1/5 두 개로 바뀌며 filterCategory/배열/개수 문구/aria-pressed가 같은 새 상태를 읽는다. 스타일 진단 표는 전체 관계도와 표와 인쇄에 보존한다.
CARD LIST · VERIFICATION TRACE
카드 오류는 데이터에서 DOM·계산 스타일로 추적합니다
필터 결과·개수와 목록을 먼저 대조하고, 최종 DOM 속성과 Computed를 확인합니다. 반응형 배치와 키보드·대체 텍스트·사용자 모션 선호도 함께 점검합니다.
같은 필터 결과에서 개수와 DOM이 나온다
선택한 필터는 aria-pressed=true입니다. key는 부모의 형제 식별이며 자식 prop이 아닙니다. $active는 DOM에 전달하지 않습니다. 생성 hash를 고정하지 않고 실제 mapping·DOM·계산 스타일을 확인합니다.
원래 설명과 상세 기준
본문 프론트엔드 필터 · 검증 시작점
All: 카드 6개
프론트엔드: id 1, 5 → 카드 2개
status: "프론트엔드 카테고리: 2개"
선택 버튼: aria-pressed=true안정 id는 부모 key이며 자식이 key prop을 받는 것은 아닙니다. 생성 hash를 고정하기보다 mapping·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 이하에서는 한 열입니다. 초점이 보이고 중복 이미지 설명이 없으며 상태·개수·모션이 사용자 설정에 맞습니다. |
-
데이터와 필터
mockCards의 안정적인id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. -
prop과 DOM
key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가ul/li목록과 live 상태 문구로 노출되는지 확인합니다. -
스타일 계산
전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다.
$active는 DOM에서 빠지고aria-pressed가 실제 선택 상태와 일치해야 합니다. -
반응형과 접근성
가로 넘침, 576px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다.
CSS 모듈의 생성 클래스 문자열은 빌드 설정에 따라 달라질 수 있습니다. 특정 해시보다 import된 mapping과 최종 DOM·Computed 결과를 검증합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 표 행 | 순서 | 확인 위치 | 통과 기준 |
| 원도표 표 행 | 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 | 가로 넘침 없이 폭에 맞춰 열이 재배치되고 576 px 이하에서는 한 열입니다. 초점이 보이고 중복 이미지 설명이 없으며 상태·개수·모션이 사용자 설정에 맞습니다. |
| 원도표 설명 | mockCards의 안정적인 id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. |
| 원도표 조건·주석 | 데이터와 필터 mockCards의 안정적인 id와 필드를 확인하고, 선택 카테고리·결과 목록·개수가 같은 상태를 가리키는지 비교합니다. |
| 원도표 설명 | key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가 ul/li 목록과 live 상태 문구로 노출되는지 확인합니다. |
| 원도표 조건·주석 | prop과 DOM key는 부모의 형제 목록에서만 식별에 쓰입니다. 결과가 ul/li 목록과 live 상태 문구로 노출되는지 확인합니다. |
| 원도표 설명 | 전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다. $active는 DOM에서 빠지고 aria-pressed가 실제 선택 상태와 일치해야 합니다. |
| 원도표 조건·주석 | 스타일 계산 전역·styled-components·CSS 모듈·인라인 선언을 Computed에서 추적합니다. $active는 DOM에서 빠지고 aria-pressed가 실제 선택 상태와 일치해야 합니다. |
| 원도표 설명 | 가로 넘침, 576 px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다. |
| 원도표 조건·주석 | 반응형과 접근성 가로 넘침, 576 px 이하 한 열, 키보드 초점, 중복 낭독 없는 이미지, 텍스트 명암, reduced motion 대응을 함께 시험합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| All 목록 | filter: All / count: 6 | 전체 6개 카드를 표시한다 | UI는 같은 필터 원천을 읽는다 |
| 프론트엔드 선택 | 대상: id 1,5 / button: aria-pressed=true | filterCategory가 프론트엔드로 바뀐다 | transient $active는 DOM 속성이 아니다 |
| 일치한 출력 | count: 2 / 목록: id 1,5 | 카드 두 개와 프론트엔드 카테고리 2개 문구를 표시한다 | key는 부모 추적용이며 자식 prop이 아니다 |
이 실습에서는 인라인 스타일, 전역 CSS 클래스, CSS 모듈, styled-components, 미디어 쿼리, CSS Grid를 같은 예제 안에서 비교했습니다.
스타일링 방식은 클래스 이름의 범위, 동적 값의 출처, cascade, 접근성, 팀 규칙과 빌드 환경을 기준으로 선택합니다. 컴포넌트 API에는 실제로 구현한 데이터와 이벤트만 노출하고, 페이지 이동이나 상태 저장 같은 행동을 암묵적으로 숨기지 않습니다.
첫 실행에서 값과 요소 대조하기
앞의 Vite 프로젝트의 React 진입점과 루트 index.html을 유지하고 여기의 소스 파일들을 연결합니다. 개발 서버에 접속할 때는 터미널에 출력된 주소를 사용합니다. 기존 http://localhost:5173 안내는 그 포트로 시작한 경우의 예시이며, 포트가 이미 사용 중이면 실제 출력이 달라질 수 있습니다.
처음에는 All이 선택되어 카드 6개와 전체 카테고리: 6개 문구가 보입니다. 프론트엔드를 선택하면 원 데이터의 id 1과 5만 남아 두 카드와 프론트엔드 카테고리: 2개가 나타납니다. 리액트 훅은 id 2 하나이며, 다시 All을 선택하면 원 데이터 여섯 카드가 돌아옵니다. 배열에서 원 데이터를 삭제하는 대신 현재 필터에 맞는 결과를 계산하기 때문입니다.
선택 버튼의 aria-pressed는 한 개만 true인지, 배경색과 결과 개수도 그 선택을 가리키는지 확인합니다. 이미지가 모두 같은 장식 placeholder라는 조건도 유지합니다. 아직 구현하지 않은 상세 페이지 이동이나 필터 저장을 버튼에 기대하지 않습니다.
Grid 열 수를 숫자로 확인하기
예제의 border-box 전역 규칙과 실제 짧은 카드 문구를 사용합니다. 뷰포트가 1200px이면 .App의 폭 1200px에서 좌우 padding 40px을 빼고, 목록 padding 40px도 빼어 Grid 내용 폭은 1120px입니다. 이 구간의 열 최소 폭은 280px, gap은 25px입니다. 세 열의 최소 요구 폭 280 × 3 + 25 × 2 = 890px은 들어가지만 네 열의 280 × 4 + 25 × 3 = 1195px은 들어가지 않습니다. 여섯 카드의 초기 목록은 세 열입니다.
375px에서는 576px 이하 규칙이 적용됩니다. .App padding 40px과 목록 padding 20px을 빼면 카드가 사용하는 폭은 315px이며 열은 명시적으로 하나입니다. 필터 뒤 항목 수가 줄어들면 auto-fit은 빈 트랙을 접을 수 있으므로 같은 뷰포트에서도 카드의 실제 폭은 달라집니다.
긴 단어를 넣는 확장
원 예제의 짧은 문구를 확인한 뒤 끊기 어려운 URL을 설명에 넣어 봅니다. 다음 규칙을 CardItem.module.css 끝에 더하면 카드가 Grid 트랙 안에서 줄어들고 긴 문자열도 줄바꿈할 수 있습니다. 기존 글자 크기를 축소하거나 정보를 숨겨 해결하지 않습니다.
.card {
min-inline-size: 0;
}
.cardTitle,
.cardDescription {
overflow-wrap: anywhere;
}새 카테고리의 결과 예측하기
기존 여섯 데이터를 유지하고 고유한 id 7, category '미분류'인 카드 하나를 추가한다고 가정합니다. 다른 필드는 기존 카드 형식과 같은 유효한 값을 제공합니다. All의 개수, 새 필터 버튼의 개수와 선택 결과, 새 카드 배지의 배경색을 먼저 적습니다. 아직 색상 매핑은 수정하지 않았습니다.
데이터에서 최종 스타일까지의 해설
All은 7개입니다. 카테고리 Set에 새 값이 더해져 기존 다섯 카테고리와 All에 '미분류'가 추가되므로 필터 버튼은 총 7개입니다. 새 필터 결과는 id 7 한 카드입니다. 색상 매핑에 값이 없어서 인라인 배경색은 fallback #475467이 됩니다.
새 id는 형제 목록의 key를 고유하게 유지합니다. CSS 클래스가 새 카테고리 이름으로 자동 생성된다고 가정하지 않습니다. 목록 필터, 자식 데이터, 색상 매핑, DOM 상태를 각각 따라가면 추가 데이터의 영향을 설명할 수 있습니다.