본문으로 건너뛰기

안동민 개발노트

본문 시작

프로필 카드 컴포넌트 만들기

전역 CSS·styled-components·CSS 모듈의 역할을 나누어 반응형 프로필 카드 목록을 만들고 스타일 격리를 확인합니다.

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

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

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

재사용 가능한 ProfileCard 경계

카드 데이터와 표현 책임을 컴포넌트 경계로 묶고 목록 배치는 부모에 남기면 같은 카드를 여러 화면에서 재사용할 수 있습니다.

  1. 계약
    Profile props

    id, name, role, imageUrl처럼 카드가 실제로 필요한 필드만 받습니다.

  2. 표현
    Card markup

    미지·이름·역할·소개를 의미 있는 요소로 묶고 빈 이미지 대체값을 둡니다.

  3. 목록
    ProfileList

    배열 map과 key, 반응형 grid는 여러 카드를 소유하는 부모가 담당합니다.

  4. 스타일
    CSS Module

    카드 내부 스타일을 지역화하고 선택·비활성처럼 값에 따른 상태만 조건부 클래스로 나눕니다.


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

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

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

다양한 스타일링 기법 적용
  • 인라인 스타일링: 동적인 조건부 스타일에 활용.
  • CSS 클래스: 전역적으로 필요한 기본 스타일 및 재사용 가능한 유틸리티 클래스에 활용.
  • CSS 모듈: 컴포넌트 스코프 스타일링으로 클래스 이름 충돌 방지.
  • Styled-components: 컴포넌트 자체를 스타일링하고 props를 통한 동적 스타일링에 활용.

미디어 쿼리를 이용한 반응형 디자인: 화면 크기에 따라 카드 레이아웃(컬럼 수)과 텍스트 크기 등이 유연하게 변하도록 구현.

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


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

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

동적 상태와 함께 움직이는 스타일은 Styled-components, 컴포넌트 단위 정적 규칙은 CSS 모듈, 전역 리셋/토큰은 index.css에 배치합니다.

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

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

App.js
index.css # 전역/기본 스타일
CardList.js
CardItem.js
CardItem.module.css
global.css # 선택적, App.css 역할

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

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

src/index.css
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;
}

Styled-components를 사용한 컨테이너

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

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

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

// Styled-components를 사용한 반응형 그리드 컨테이너
const CardsContainer = styled.div`
  display: grid;
  gap: 25px; /* 카드 사이의 간격 */
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* 기본 3열 (데스크톱) */
  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-fill, minmax(250px, 1fr)); /* 2열 */
    gap: 20px;
    padding: 15px;
  }

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

// 카테고리 필터링을 위한 버튼 스타일 (인라인 스타일 + CSS 클래스 조합 예시)
const FilterButton = styled.button`
  padding: 10px 20px;
  margin: 5px;
  border: 1px solid #ccc;
  border-radius: 5px;
  background-color: white;
  cursor: pointer;
  font-size: 1em;
  transition: all 0.2s ease;

  &:hover {
    background-color: #eee;
  }

  /* active prop에 따른 동적 스타일 (인라인 스타일링과 유사) */
  ${props => props.active && `
    background-color: #007bff;
    color: white;
    border-color: #007bff;
    font-weight: bold;
    box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  `}
`;

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);

  return (
    <>
      <h2 className="text-center">반응형 카드 목록</h2>
      <div className="text-center margin-top-20"> {/* 일반 CSS 클래스 활용 */}
        {categories.map(category => (
          <FilterButton
            key={category}
            onClick={() => setFilterCategory(category)}
            active={filterCategory === category} // active prop 전달
          >
            {category}
          </FilterButton>
        ))}
      </div>
      <CardsContainer>
        {filteredCards.map(card => (
          <CardItem key={card.id} card={card} />
        ))}
      </CardsContainer>
    </>
  );
}

export default CardList;

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

개별 카드 아이템의 스타일은 CSS 모듈을 사용하여 컴포넌트 스코프를 확보합니다.

또한, 내부적으로 인라인 스타일링도 사용합니다.

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;
}

.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: #7f8c8d;
  margin-bottom: 15px;
  flex-grow: 1; /* 설명이 길어져도 레이아웃 유지 */
}

.cardCategory {
  font-size: 0.85em;
  color: #9b59b6; /* 보라색 */
  background-color: #ecf0f1;
  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;
  }
}

CardItem.js

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

function CardItem({ card }) {
  // (예시) 카테고리에 따른 동적 인라인 스타일 (배경색 변경)
  const categoryColorMap = {
    '프론트엔드': '#e74c3c',
    '리액트 훅': '#2980b9',
    'UI/UX': '#f39c12',
    '심화': '#8e44ad',
    '아키텍처': '#16a085',
    'All': '#7f8c8d'
  };
  const categoryStyle = {
    backgroundColor: categoryColorMap[card.category] || '#7f8c8d',
    color: 'white'
  };

  return (
    <div className={styles.card}> {/* CSS 모듈 클래스 사용 */}
      <img src={card.imgUrl} alt={card.title} className={styles.cardImage} />
      <div className={styles.cardContent}>
        <h3 className={styles.cardTitle}>{card.title}</h3>
        <p className={styles.cardDescription}>{card.description}</p>
        <span className={styles.cardCategory} style={categoryStyle}> {/* CSS 모듈 + 인라인 스타일 조합 */}
          {card.category}
        </span>
      </div>
    </div>
  );
}

export default CardItem;
CardItem은 데이터, class, 동적 style의 경계를 나눠 렌더링한다

카드의 구조와 공통 스타일은 CSS 모듈이 맡고, 데이터 값과 정말 동적인 색상만 props에서 들어온다.

  1. title, desc, tone

    Data title, desc, tone props 입력

  2. card/layout

    CSS Module card/layout 공통 모양

  3. 동적 값

    Inline style 동적 값 좁은 범위

  4. CardItem UI

    Render CardItem UI 반복 가능한 카드


App.js (스타일 통합 최종)

모든 컴포넌트를 조합하여 렌더링합니다.

src/App.js
import React from 'react';
import './index.css'; // 전역 스타일
import CardList from './components/CardList';

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

export default App;

완성된 카드 목록은 데이터, 상태, props, 스타일 책임이 서로 물려 있습니다.

다음 지도처럼 어느 값이 어디에서 바뀌고 어떤 UI로 이어지는지 추적하면 디버깅이 쉬워집니다.

카드 목록 렌더링 책임 범위
  1. 카드 목록 렌더링 책임 범위 목록 화면

    데이터, 필터, props, 스타일 책임을 분리해 읽는다

  2. 버튼 클릭

    필터 상태를 바꾸고, 남은 데이터만 자식 컴포넌트로 내려갑니다.

  3. 스타일

    전역 기본값, 목록 레이아웃, 컴포넌트 내부, 작은 동적 색으로 책임을 나누면 추적이 쉬워집니다.


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

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

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

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

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

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

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

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

카테고리 필터링: 상단의 필터 버튼을 클릭하여 특정 카테고리의 카드만 표시되는지 확인합니다. (이때 버튼의 active 상태에 따라 배경색이 변하는 Styled-components의 동적 스타일링을 관찰하세요.)

반응형 테스트: 브라우저 창의 너비를 조절하거나 개발자 도구의 반응형 모드를 사용하여, 화면 크기에 따라 카드의 열(column) 수가 3열 -> 2열 -> 1열로 변하는지 확인합니다. (CardList.js의 Styled-components와 CardItem.module.css의 미디어 쿼리가 함께 작동)

클래스 이름 확인: 개발자 도구로 CardItem의 DOM 요소를 검사하여 CSS 모듈에 의해 클래스 이름이 고유하게 변환되었는지 (CardItem_card__abc123 형태) 확인합니다.

CardList에서 Styled-components가 생성한 클래스 이름도 확인합니다.

인라인 스타일 확인: CardItem 내부의 카테고리 스팬에 categoryColorMap에 따라 동적으로 적용된 background-color 인라인 스타일이 적용되었는지 확인합니다.


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

프로필 카드 실습 디버깅 지도

카드 실습에서 화면이 깨지면 한 번에 모두 고치려 하지 말고 값 이동 경로를 따라 확인합니다. 필터 상태와 CSS 모듈 키가 만나는 지점이 핵심입니다.

  1. source
    mockCards

    원본 데이터의 id, title, category, imgUrl이 완전한지 봅니다.

  2. filter
    filteredCards

    선택된 category와 비교 문자열이 같은 체계인지 확인합니다.

  3. props
    CardItem

    카드 컴포넌트에 필요한 값이 props로 빠짐없이 내려갑니다.

  4. style
    module key

    styles.card와 categoryColorMap이 렌더링 지점에서 함께 적용됩니다.


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

스타일링 방식은 범위 격리, 동적 스타일 필요성, 팀 규칙, 빌드 환경을 기준으로 선택합니다.


프로필 카드 컴포넌트를 만들 때는 다음 다이어그램처럼 데이터, 표시, 액션 영역을 나누면 재사용성이 좋아집니다.

프로필 카드 컴포넌트 분해도

프로필 카드 하나에도 props 계약, 조건부 표시, 사용자 액션의 경계가 들어 있습니다.

  1. data
    데이터 props

    름, 이미지, 직무, 소개처럼 화면에 필요한 최소 데이터만 받습니다.

  2. view
    표시 규칙

    미지가 없을 때 fallback, 긴 소개의 줄 수, 빈 필드 처리를 정합니다.

  3. action
    액션

    follow, message, profile link처럼 바깥에서 주입되는 이벤트를 연결합니다.

  4. variant
    변형

    compact, detailed, selected 상태는 같은 구조 안에서 옵션으로 관리합니다.


프로필 카드 실습은 스타일 도구를 섞는 일이므로 전역, 컨테이너, 카드 내부의 책임을 나눠 검수해야 합니다.

ProfileCard는 데이터·표현·행동 계약으로 조립된다

부모가 전달할 값과 카드 내부가 책임질 마크업을 컴포넌트 경계에서 나눈다.

  1. data
    profile prop

    name · role · avatarUrl

  2. slot
    children

    배지나 추가 설명을 선택적으로 삽입

  3. event
    onSelect

    사용자 선택 의도만 부모에 전달

  4. markup
    Card body

    미지 대체문·제목 계층·버튼 의미 보장