icon

안동민 개발노트

5장 : 스타일링과 CSS

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


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

이번 실습에서는 이러한 지식을 총동원해 반응형 카드 목록(Responsive Card List) 컴포넌트를 만들어 봅니다. 이 실습을 통해 각 스타일링 기법이 어떤 상황에 적합한지 체감하고, 복잡한 UI를 여러 스타일링 방식을 혼합해 구현하는 방법을 경험할 수 있습니다.


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

이번 실습은 스타일링 전략 선택을 중심으로 진행합니다. 같은 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, Recoil, Zustand 비교 분석', 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;

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;

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

실습 검증은 화면 크기별 레이아웃과 스타일 스코프를 분리해서 봅니다. 먼저 반응형 깨짐을 확인하고, 다음으로 클래스 충돌/동적 스타일 반영 여부를 점검합니다.

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

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 클래스, CSS 모듈, Styled-components)을 목적에 맞게 혼용하고, 미디어 쿼리와 유연한 레이아웃(CSS Grid)을 사용하여 반응형 UI를 구현하는 능력을 갖추게 되셨습니다.

이 실습을 통해 각 스타일링 방식의 장단점을 명확히 이해하고, 실제 프로젝트에서 어떤 상황에 어떤 기법을 사용할지 판단하는 데 필요한 실용적인 지식을 얻으셨기를 바랍니다.

목차