본문 바로가기

인라인 스타일링과 CSS 클래스 사용

React에서 className과 style prop이 DOM에 전달하는 값, 동적 스타일과 재사용 규칙의 경계, 일반 CSS의 전역 범위를 비교합니다.

4장까지는 React 컴포넌트가 상태와 데이터를 어떻게 다루는지 살펴봤습니다. 5장에서는 그렇게 만든 화면에 스타일을 입히는 방법을 다룹니다. React의 내장 DOM 컴포넌트는 CSS 클래스 이름을 받는 className과, 스타일 선언을 담은 객체를 받는 style prop을 지원합니다. 두 방식은 둘 중 하나만 골라야 하는 스타일링 체계가 아닙니다. 미리 정의할 수 있는 규칙은 CSS 클래스에 두고, 렌더할 때마다 계산되는 몇 가지 값만 style로 넘기는 것이 기본적인 나눔입니다.

이 글에서는 일반 CSS를 기준으로 두 prop의 역할을 비교합니다. CSS 모듈과 CSS-in-JS는 뒤의 글에서 따로 다룹니다.


두 prop이 받는 값

className은 DOM 요소의 CSS 클래스 이름을 나타내는 문자열이고, style은 CSS 선언을 나타내는 자바스크립트 객체입니다. 두 prop은 같은 요소에 함께 쓸 수 있습니다.

<div
  className="progress"
  style={{ width: `${percent}%` }}
/>

React는 CSS 파일을 프로젝트에 넣는 방법을 정해 두지 않습니다. 아래 예제의 CSS import는 이 구문을 처리하는 빌드 도구나 프레임워크를 쓰는 프로젝트를 전제로 하며, 이 교재의 Vite 프로젝트는 CSS import를 기본으로 처리합니다.


style prop으로 값 전달하기

style에는 문자열이 아니라 객체를 넘깁니다. CSS 속성의 이름은 DOM의 style 프로퍼티처럼 대부분 camelCase로 씁니다.

src/components/InlineStylingExample.jsx
export default function InlineStylingExample({ percent }) {
  const value = Number(percent);
  const safePercent = Number.isFinite(value)
    ? Math.min(100, Math.max(0, value))
    : 0;

  return (
    <div
      className="meter"
      role="progressbar"
      aria-label="진행률"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={safePercent}
    >
      <span
        className="meter__fill"
        style={{ width: `${safePercent}%` }}
      />
    </div>
  );
}

이 예제는 입력을 숫자로 바꾼 뒤, 유한한 값만 0에서 100 사이로 제한합니다. NaN이나 무한대처럼 진행률로 나타낼 수 없는 값은 0으로 처리해, CSS의 너비와 aria-valuenow가 같은 유효한 수치를 쓰게 합니다.

속성 이름은 background-color를 backgroundColor로, font-size를 fontSize로 씁니다. 단위를 분명히 적어야 한다면 '1.25rem', '50%'처럼 문자열로 넘깁니다. 숫자 값을 넘기면 React가 보통 px를 붙이지만, lineHeight, opacity, flex처럼 단위가 없는 CSS 속성에는 붙이지 않습니다.

<section
  style={{
    marginTop: 16,   // 16px
    lineHeight: 1.5, // 단위 없음
    opacity: 0.8,    // 단위 없음
  }}
>
  내용
</section>

style은 데이터에서 계산한 너비나 좌표, 색상처럼 값을 미리 알 수 없을 때, 한 요소에만 필요한 작은 동적 선언일 때, 그리고 클래스 규칙과 함께 쓸 CSS 사용자 지정 속성(custom property, 흔히 CSS 변수라고 부릅니다)의 값을 넘길 때 잘 맞습니다. 사용자 지정 속성을 선언하고 var()로 읽는 방법은 웹 입문 교재의 CSS 변수와 계산식에서 다룹니다.

반면 style 객체는 그 요소의 인라인 선언만 나타내므로, :hover, ::before, @media, @keyframes처럼 선택자나 at-rule이 필요한 CSS 문법을 객체 안에 직접 쓸 수 없습니다. 이런 동작은 자바스크립트의 마우스 이벤트로 흉내 내기보다 CSS 규칙으로 나타내는 편이 키보드와 포인터, 미디어 조건을 함께 다루기 쉽습니다. 또 style이 한 요소에 붙는다고 해서 컴포넌트만의 범위가 생기는 것은 아닙니다. 상속되는 속성은 자손에게 영향을 주고, 바깥 스타일시트의 다른 규칙도 같은 요소와 자손에 계속 적용됩니다. 클래스 이름이 충돌하는 문제는 피하지만 CSS 전체를 격리하지는 않습니다.

큰 정적 스타일 객체를 JSX마다 되풀이하면, 규칙을 재사용하기 어렵고 어디를 고쳐야 하는지 파악하기도 힘들어집니다. React 공식 문서도 style은 값을 미리 알 수 없는 동적 스타일에만 쓰고, 그 밖의 경우에는 일반 CSS 클래스를 쓰라고 권합니다(React 문서의 CSS 스타일 적용).

더 깊이: React가 style 객체를 DOM에 반영하는 방식

브라우저에서 React DOM은 style 객체를 문자열 하나로 만들어 style 속성에 쓰지 않고, 속성마다 DOM 요소의 style 객체에 따로 설정합니다. React 저장소의 CSSPropertyOperations.js를 보면, --card-progress처럼 --로 시작하는 사용자 지정 속성은 style.setProperty로, 나머지는 style.backgroundColor처럼 프로퍼티에 값을 대입해 설정합니다. 다시 렌더링할 때는 이전 객체와 비교해 값이 바뀐 속성만 다시 쓰고, 다음 객체에서 빠진 속성은 빈 문자열을 넣어 지웁니다. 값이 null, undefined, 불리언, 빈 문자열인 속성도 같은 방식으로 지워지므로, 조건에 따라 선언을 빼고 싶다면 color: isError ? 'crimson' : undefined처럼 쓸 수 있습니다.

이렇게 속성 단위로 갱신하기 때문에 font와 fontVariant처럼 축약 속성(shorthand)과 개별 속성(longhand)을 한 객체에 섞으면 문제가 생깁니다. 예를 들어 { font: '16px serif', fontVariant: 'small-caps' }에서 font만 바꾸면 style.font를 다시 쓰면서 fontVariant도 덮이고, font를 빼면 fontVariant까지 함께 사라집니다. React는 개발 모드에서 이런 갱신을 발견하면 축약 속성과 개별 속성을 섞지 말라는 경고를 Console에 남깁니다. 같은 값을 다루는 속성은 개별 속성으로만 나눠 쓰는 편이 안전합니다.


className으로 CSS 규칙 적용하기

React에서는 HTML의 class에 해당하는 prop으로 className을 씁니다. class는 자바스크립트의 예약어이고, React DOM은 DOM 프로퍼티 이름인 className을 따르기 때문입니다. 아래 예제는 컴포넌트 파일과 같은 이름의 CSS 파일을 한 쌍으로 둡니다. 컴포넌트 맨 위의 import './CssClassesExample.css'가 그 규칙을 문서에 불러오고, JSX는 그 안에 정의된 클래스 이름을 문자열로 고르기만 합니다.

src/components/CssClassesExample.jsx
import './CssClassesExample.css';

export default function CssClassesExample({ isPrimary, disabled }) {
  const buttonClassName = [
    'button',
    isPrimary && 'button--primary',
  ].filter(Boolean).join(' ');

  return (
    <button className={buttonClassName} disabled={disabled}>
      저장
    </button>
  );
}
src/components/CssClassesExample.css
.button {
  border: 1px solid #d9d9d9;
  border-radius: 0.375rem;
  padding: 0.625rem 1rem;
  transition: background-color 160ms ease;
}

.button:hover:not(:disabled) {
  background-color: #e6e6e6;
}

.button--primary {
  background-color: #087ea4;
  color: white;
}

@media (width <= 40rem) {
  .button {
    width: 100%;
  }
}

조건에 따라 스타일을 바꾸는 일이 style만의 기능은 아닙니다. 자바스크립트로 className 문자열을 만들면 상태에 따라 미리 정의한 규칙을 고를 수 있습니다. 위 코드에서 isPrimary가 참이면 클래스 문자열은 button button--primary가 되고, 거짓이면 filter(Boolean)이 false를 걸러 내 button만 남습니다. 클래스 조합이 복잡해지면 프로젝트에서 이미 쓰고 있는 clsx나 classnames 같은 도구를 검토할 수 있습니다.

CSS 클래스는 여러 요소와 컴포넌트에서 되풀이되는 시각 규칙, :hover나 :focus-visible, 가상 요소, 미디어 쿼리, 키프레임처럼 선택자와 at-rule이 필요한 규칙, 그리고 primary, selected, compact처럼 화면의 상태에 이름을 붙여 나타내는 경우에 잘 맞습니다.

다만 일반 스타일시트의 클래스 선택자는 React 컴포넌트가 어디서 나뉘는지 알지 못합니다. 같은 문서에 불러온 .button 규칙은 일치하는 모든 요소에 적용되므로, 큰 프로젝트에서는 이름이 겹치거나 의도하지 않게 덮어쓰는 일을 관리해야 합니다. 웹 입문 교재의 CSS 아키텍처와 방법론이 소개하는 BEM 같은 이름 규칙은 충돌할 가능성을 줄여 주지만, 범위를 기술적으로 강제하지는 않습니다. 클래스 이름을 그 파일 안으로 한정하고 싶다면, 다음 글에서 다룰 CSS 모듈처럼 빌드할 때 고유한 클래스 이름을 만드는 방식을 씁니다.


선택 기준

className은 규칙을 선택하고 style은 계산한 값을 전달합니다

REACT DOM · RULE & VALUE

className은 규칙을 선택하고 style은 계산한 값을 전달합니다

한 요소에서 두 prop을 함께 씁니다. 재사용·상태·반응형 규칙은 CSS에 두고, 데이터에서 계산되는 개별 선언만 style 객체로 좁힙니다.

본문 진행률 예제 · 같은 유효한 수치

percent=72 → safePercent=72
meter: aria-valuenow=72
meter__fill: style.width="72%"

유한한 입력은 0~100으로 제한하고 NaN·무한대는 0으로 처리합니다. CSS 너비와 접근성 값이 같은 safePercent를 사용합니다.

className과 style이 DOM에 전달하는 값과 표현 범위
판단 기준 className + CSS 규칙 style 객체
전달하는 값 클래스 이름 문자열 카멜 케이스 CSS 선언 객체
우선 쓰는 때 규칙과 UI 상태를 미리 정의하고 여러 곳에서 재사용할 때 데이터에서 계산되어 렌더 전에는 알 수 없는 개별 값을 전달할 때
표현 범위 선택자, 가상 상태·요소, 미디어 쿼리, 키프레임 해당 요소의 인라인 선언만 표현하며 선택자와 at-rule은 포함하지 못함
조건부 변경 JavaScript로 클래스 문자열을 조합해 이름 붙은 상태를 선택 JavaScript 값으로 속성값을 직접 계산
범위와 재사용 규칙을 재사용하지만 일반 CSS 클래스는 문서 전역에서 충돌할 수 있음 클래스 이름 충돌은 없지만 컴포넌트 스코프나 CSS 격리를 만들지는 않음
className + CSS
값 클래스 이름 문자열
선택 알려진 규칙과 이름 붙은 UI 상태
기능 선택자, 가상 상태, 미디어 쿼리, 키프레임
조건 JavaScript로 클래스 문자열을 조합해 미리 정의한 UI 상태를 선택합니다.
경계 일반 CSS 클래스는 문서 전역에서 충돌할 수 있다.
style 객체
값 카멜 케이스 CSS 선언 객체
선택 데이터에서 계산한 개별 값
조건 JavaScript로 속성값을 직접 계산합니다.
제약 선택자와 at-rule은 직접 표현하지 못한다.
경계 클래스 충돌은 피하지만 컴포넌트 스코프를 만들지는 않는다.

한 요소에서 두 prop을 함께 사용할 수 있다. 구조·상태·반응형 규칙은 클래스에 두고, 계산된 너비·좌표·색상 같은 값만 style로 좁히면 책임이 선명하다.

위 그림은 className이 규칙을 고르고 style이 계산한 값을 넘긴다는 기준을 표로 정리합니다. className은 클래스 이름 문자열과 CSS 규칙을, style은 camelCase로 쓴 CSS 선언 객체를 넘깁니다. 규칙과 UI 상태를 미리 정의해 여러 곳에서 재사용할 때는 className을, 데이터에서 계산되어 렌더하기 전에는 알 수 없는 개별 값을 넘길 때는 style을 먼저 씁니다. className은 선택자와 가상 상태·요소, 미디어 쿼리, 키프레임까지 나타낼 수 있지만, style은 그 요소의 인라인 선언만 나타내고 선택자와 at-rule은 담지 못합니다.

조건에 따라 바꿀 때 className은 자바스크립트로 클래스 문자열을 조합해 이름 붙은 상태를 고르고, style은 자바스크립트 값으로 속성 값을 바로 계산합니다. 재사용과 범위의 면에서 일반 CSS 클래스는 규칙을 재사용할 수 있지만 문서 전체에서 충돌할 수 있고, style은 클래스 이름 충돌은 없지만 컴포넌트만의 범위나 CSS 격리를 만들지는 않습니다.

그림 위쪽의 예는 이 글의 진행률 막대입니다. percent=72이면 safePercent도 72가 되어 막대의 aria-valuenow는 72, 채움 요소의 style.width는 "72%"가 됩니다. 그림의 설명처럼 CSS의 너비와 접근성 값이 같은 safePercent를 쓰도록 하는 것이 핵심입니다.

실제로는 둘을 함께 쓰는 경우가 많습니다. 구조와 상호작용 상태, 반응형 규칙은 클래스에 두고 계산된 값만 인라인으로 넘기면, CSS의 기능과 데이터에 따른 표현을 모두 살릴 수 있습니다.

<div
  className={isSelected ? 'card card--selected' : 'card'}
  style={{ '--card-progress': `${progress}%` }}
>
  {children}
</div>

이 코드에서 className은 알려진 규칙과 상태의 이름을 고르고, style은 실행하면서 계산한 값을 CSS 사용자 지정 속성 --card-progress로 넘깁니다. 이 값은 별도의 CSS가 var(--card-progress)로 읽어야 화면에 영향을 줍니다. 어느 쪽을 쓰든, 먼저 무엇이 바뀌는지와 그 규칙을 어디서 재사용하는지를 구분하세요.


예제 컴포넌트 함께 렌더링하기

두 예제 컴포넌트를 한 화면에 렌더링합니다. 진행률 막대에는 percent={72}를 넘겨 앞 그림의 예와 같은 값을 쓰고, 버튼에는 isPrimary를 값 없이 적어 true를 넘깁니다. JSX에서 속성 이름만 적으면 그 값은 true가 되므로, 버튼은 button button--primary 클래스를 받습니다.

src/App.jsx
import './App.css';
import InlineStylingExample from './components/InlineStylingExample.jsx';
import CssClassesExample from './components/CssClassesExample.jsx';

export default function App() {
  return (
    <main className="app">
      <h1>React 스타일링 기초</h1>
      <InlineStylingExample percent={72} />
      <CssClassesExample isPrimary disabled={false} />
    </main>
  );
}

첫 번째 React 앱 만들기의 Vite 프로젝트에 앞의 두 컴포넌트 파일을 만들고 이 App.jsx를 적용합니다. 막대가 실제로 보이려면 App.css에 최소한의 규칙도 필요합니다. 너비만 가진 span을 두면 막대의 높이와 색이 정해지지 않기 때문입니다.

src/App.css
.app {
  max-inline-size: 40rem;
  margin-inline: auto;
  padding: 1rem;
}

.meter {
  block-size: 1rem;
  background-color: #e6e6e6;
  border-radius: 0.5rem;
  overflow: hidden;
}

.meter__fill {
  display: block;
  block-size: 100%;
  background-color: #087ea4;
}

.button.button--primary:hover:not(:disabled) {
  background-color: #065b77;
}

.button:focus-visible {
  outline: 3px solid #111;
  outline-offset: 3px;
}

높이와 배경, 모양은 재사용할 클래스 규칙이고, 채워지는 너비만 입력에서 계산하므로 style에 남깁니다. 강조 버튼에 마우스를 올렸을 때도 흰 글자와 읽을 수 있는 어두운 배경을 함께 유지합니다.

percent={72}를 렌더링하면 채움 요소의 CSS 너비는 부모 막대의 72%이고, 접근성 값은 aria-valuenow="72"입니다. 부모 막대의 실제 폭이 300 CSS px라면 채움 폭은 300 × 0.72 = 216 CSS px입니다. 접근성 값은 픽셀 폭이 아니라 0에서 100 사이의 진행률이라는 점에 주의하세요. 개발자 도구에서 채움 요소의 style에는 너비 하나만 있고, 높이와 배경은 .meter__fill 규칙에서 오는지도 확인해 보세요.


정리

  • className은 클래스 이름 문자열을, style은 camelCase로 쓴 CSS 선언 객체를 받으며, 한 요소에 함께 쓸 수 있습니다.
  • style에 넘긴 숫자에는 보통 px가 붙지만, lineHeight, opacity, flex처럼 단위가 없는 속성에는 붙지 않습니다.
  • style은 선택자나 @media 같은 at-rule을 담지 못하고 컴포넌트만의 범위를 만들지도 않으므로, 계산된 값을 넘길 때만 씁니다.
  • 되풀이되는 규칙과 상태, 반응형 규칙은 CSS 클래스에 두고, 자바스크립트로 클래스 문자열을 조합해 상태를 고릅니다.
  • 일반 CSS 클래스는 문서 전체에 적용되어 이름이 충돌할 수 있으며, 범위를 한정하려면 CSS 모듈 같은 방식을 씁니다.

연습 문제

1. 범위를 벗어난 진행률

다른 조건은 그대로 두고 percent를 각각 -4, 130, '알 수 없음'으로 바꾸면, 채움 요소의 CSS 너비와 aria-valuenow는 어떻게 될까요?

해설 보기

-4는 최솟값으로 제한되어 0%와 0, 130은 최댓값으로 제한되어 100%와 100이 됩니다. '알 수 없음'은 숫자로 바꾼 결과가 NaN이므로 0%와 0입니다. CSS의 표현과 접근성 수치가 모두 같은 safePercent를 읽기 때문에 두 값이 어긋나지 않습니다.

2. 강조하지 않은 비활성 버튼

CssClassesExample을 isPrimary={false}, disabled={true}로 렌더링하면 버튼의 클래스 문자열은 무엇이 되고, 마우스를 올렸을 때의 배경 규칙은 적용될까요?

해설 보기

클래스 문자열은 button 하나입니다. disabled는 버튼의 실제 비활성 속성일 뿐, 클래스 이름을 저절로 더하지 않습니다.

비활성 버튼은 .button:hover:not(:disabled)의 :not(:disabled) 조건을 만족하지 않으므로, 마우스를 올려도 그 배경 규칙은 적용되지 않습니다. 클래스 문자열을 만드는 자바스크립트와, 조건을 맞춰 보는 CSS를 구별해 읽어야 합니다.

다음 글 CSS 모듈 소개에서는 클래스 이름을 파일 안으로 한정해 전역 충돌을 막는 CSS 모듈을 살펴봅니다.