개발 기술/개발 이야기

Tailwind 없이, PostCSS+PurgeCSS로 유틸리티 클래스 구축하기

by GicoMomg 2025. 5. 26.

0. 들어가며…

  • 올해 사이드 프로젝트를 진행하며 자연스럽게 TailwindCSS의 유틸리티 클래스 방식을 자주 접하게 되었다.
  • Tailwind를 쓰면서 특히 마음에 들었던 점은, 마진(margin)이나 패딩(padding)처럼 반복되는 스타일을 단일 클래스 호출만으로 해결할 수 있다는 점이었다.
  • 덕분에 각 .tsx 파일 안에서 바로 스타일을 정의·수정할 수 있었고, 코드베이스가 한결 깔끔해졌으며 유지보수도 쉬워졌다.
  • 그래서 다른 프로젝트에도 TailwindCSS를 적용하면 어떨지 고민했으나, 해당 프로젝트와 전역 CSS와 비교해 보니, Tailwind가 제공하는 방대한 클래스 중 일부만 사용할 수 있었다.
  • 게다가 기존 전역 CSS를 전부 Tailwind 방식으로 바꾸려면 작업 규모 대비 리스크가 컸다.

 

그럼, 어떻게 해야 TailwindCSS의 유틸리티 클래스 방식의 장점을 유지하면서,
기존 전역 클래스 선언에 맞춰 커스텀 유틸리티 클래스를 확장할 수 있을까?

  • 이번 시간에는 TailwindCSS의 “유틸리티-퍼스트” 철학을 참고하여, 프로젝트에 꼭 필요한 전역 스타일 클래스만 정의하고,
  • 여기에 PurgeCSS를 더해 빌드 시점에 실제로 사용하지 않는 클래스를 제거하는 CSS 워크플로를 구현해 보았다.



1. 첫 번째, postCSS로 유틸리티 클래스 생성하기

1) postCSS란?

  • PostCSS는 자바스크립트를 이용해 CSS를 변형하고 자동화할 수 있는 도구이다.
  • PostCSS를 활용하면 CSS 코드 압축 및 최적화, 호환성 문제 해결(autoprefixer), 그리고 사용자 정의 유틸리티 클래스 생성할 수 있다.
  • 이번 시간에는 TailwindCSS 처럼 유틸리티 클래스를 만들기 위해, postCSS를 활용했다.

 

2) 구현해보기

해당 프로젝트는 vite, vue3, pnpm 환경에서 진행했다. (레포 링크)

(1) 필요한 의존성 설치하기

  • 설정에 앞서, postcss와 postcss-cli를 설치해준다.
  • postcss-cli은 postCSS 플러그인을 .css 파일에 적용해주는 커맨드라인 도구이다.
pnpm add -D postcss postcss-cli

 

(2) 폴더 구조 살펴보기

  • 폴더 구조는 다음과 같이 구성해보았다.
src
|-- plugins
|   |-- postcss             // 커스텀 PostCSS 플러그인 정의
|       |-- config.js                  
|       |-- index.js                   
|       |-- utils.js                   
|-- postcss.config.js       // PostCSS 설정 파일(사용할 플러그인 목록을 지정)
|-- vite.config.js          // Vite 설정 파일(빌드 시 커스텀 PostCSS 플러그인을 포함)
  • plugins/postcss 폴더에는 유틸리티 클래스를 동적으로 생성하는 커스텀 PostCSS 플러그인이 정의되어 있다.
  • 이 폴더에는 config.js, index,js, utils.js 파일이 존재하여 각각의 역할은 다음과 같다.
postcss/config.js 테마, 유틸리티 규칙 등 플러그인에서 사용할 설정값을 정의
postcss/index.js PostCSS 플러그인 엔트리 파일 (PostCSS API에 맞춘 플러그인 객체 생성 및 export)
postcss/utils.js 유틸리티 클래스를 생성하는 함수 및 헬퍼 함수 정의
  • 그럼 각 파일에는 어떤 구조로 구현되어 있는지 하나하나 살펴보자!

 

(3) postcss/config.js 살펴보기

  • config.js는 유틸리티 클래스 생성을 위한 설정을 담고 있다.
// config.js

export default {
  theme: { /* 디자인 토큰 */ },
  utilities: [ /* 클래스 생성 규칙 */ ]
}
  • config는 크게 두 부분으로 나뉜다.
    1. theme: 색상·간격·폰트 크기 등 디자인 토큰을 정의한다.
    2. utilities: theme 데이터를 참조하여 어떤 패턴(selectorPattern)과 속성(prop)으로 유틸리티 클래스를 생성할지 규칙을 작성한다.
  • theme는 선택 사항이지만, 실제로 어떤 CSS 클래스가 만들어질지는 오직 utilities 설정에 달려 있다.
  • config.js의 설정은 내부적으로 utils.js의 로직을 거쳐 실제 CSS 클래스로 변환된다. (utils.js의 자세한 구현 내용은 여기에서 확인해보자!)

 

이제 이 설정을 바탕으로 어떤 방식으로 CSS 클래스가 생성되는지, 예시를 통해 살펴보자.

A. 색상 유틸리티 생성 예시

export default {
  theme: {
    colors: {
      blue: { 100: '#F0F4FF', 200: '#E0E7FF' },
      red:  { 100: '#FEE2E2', 200: '#FCA5A5' },
    },
  },
  utilities: [
    {
      name: 'colors',
      tokenPath: 'colors',    // theme.colors를 참조
      rules: [
        { selectorPattern: '.bg-{key}', prop: ['background-color'] }
      ]
    }
  ]
}
속성값 설명
tokenPath theme 객체 내 어느 키값을 참조할지 지정한다. (theme.colors).
selectorPattern .bg-{key} 패턴으로 클래스명을 생성한다.
이 중 {key}blue.100, blue.200 등으로 치환된다.
prop ['background-color'] 속성에 대한 스타일을 선언하게 된다.

 

  • 결과적으로, selectorPattern 패턴에 따라 다음과 같은 클래스를 생성한다.
.bg-blue-100 { background-color: #F0F4FF }
.bg-blue-200 { background-color: #E0E7FF }
.bg-red-100 { background-color: #FEE2E2 }
.bg-red-200 { background-color: #FCA5A5 }

 
B. 간격(margin/padding) 유틸리티 생성 예시

export default {
  theme: {
    spacing: {
      1: '0.25rem', 2: '0.5rem',
      3: '0.75rem', 4: '1rem',
    },
  },
  utilities: [
    {
      name: 'spacing',
      tokenPath: 'spacing',  // theme.spacing 참조
      rules: [
        { selectorPattern: '.m-{key}',  prop: ['margin'] },
        { selectorPattern: '.mt-{key}', prop: ['margin-top'] },
      ]
    }
  ]
}
속성값 설명
theme spacing이라는 키에 간격 값(예: 0.25rem, 0.5rem 등)을 정의한다.
tokenPath spacing 토큰을 참조한다.
즉, PostCSS 플러그인은 theme.spacing 객체를 읽어서 유틸리티 클래스를 생성한다.
selectorPattern 생성할 CSS 클래스 이름의 패턴이다.
'.m-{key}'m-1, m-2 같은 클래스 이름으로 변환된다.
여기서 {key}에는 1, 2, 3, 4가 순서대로 대입된다.
prop 실제로 선언할 CSS 속성 이름을 나열한다.

 

  • 결과적으로, theme.spacing의 숫자 키(1~4)를 {key}로 치환해, 클래스를 만든다.
.m-1 { margin: 0.25rem }
.mt-1 { margin-top: 0.25rem }
.m-2 { margin: 0.5rem }
.mt-2 { margin-top: 0.5rem }
.m-3 { margin: 0.75rem }
.mt-3 { margin-top: 0.75rem }
.m-4 { margin: 1rem }
.mt-4 { margin-top: 1rem }

 
C. 정적 유틸리티 생성 예시

export default {
  theme: {},
  utilities: [
    {
      name: 'flex',
      rules: [
        {
          selectorPattern: '.flex-center',
          prop: ['display', 'align-items', 'justify-content'],
          value: ['flex', 'center', 'center']
        }
      ]
    }
  ]
}
속성 설명
selectorPattern 클래스 이름 패턴을 지정한다.
여기서 생성될 클래스 이름이 flex-center가 된다.
prop 선언할 CSS 속성들을 배열 형태로 나열한다.
['display', 'align-items', 'justify-content']형태로 선언하면 세 가지 속성이 한 번에 적용된다.
value prop 배열 순서에 대응하는 값들을 배열로 선언한다.
['flex', 'center', 'center']는 각각 display, align-items, justify-content 속성에 할당될 값이다.

 

  • 결과적으로 prop, value 두 배열을 ZIP 방식으로 매핑(zip)한, 클래스가 생성된다.
.flex-center {
    display: flex;
    align-items: center;
    justify-content: center;
}

 

(4) postcss/index.js 살펴보기

  • config.js에 정의된 설정은 utils.jsgetPostCssUtilities() 함수로 전달되어 실제 CSS 규칙으로 변환된다.
  • 변환된 규칙은 모두 @layer utilities { … } 블록 안에 삽입된다. 그럼, 유틸리티 클래스가 다른 레이어보다 뒤에 배치되어,
    필요할 때 더 높은 우선순위의 스타일로 덮어쓸 수 있다.
import postcss from 'postcss';
import config from './config.js';
import { getPostCssUtilities } from './utils.js';

export default {
  postcssPlugin: 'css-utility-generator',
  Once(root) {
    const rules = getPostCssUtilities({
      utilities: config.utilities,
      theme:     config.theme
    });
    if (!rules.length) return;

    // utilities 레이어를 생성하고 규칙을 추가
    const layer = postcss.atRule({ name: 'layer', params: 'utilities' });
    layer.append(...rules);

    // AST 최하단에 레이어 삽입
    root.append(layer);
  }
};

 

(5) 생성한 postcss 플러그인을 등록하기

  • 작성한 PostCSS 커스텀 플러그인을 Vite에서 사용하려면, vite.config.js 에 설정을 추가해야 한다.
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import postcssConfig from './postcss.config.js';

export default defineConfig({
  plugins: [vue()],
  css: {
    postcss: postcssConfig, // 여기서 PostCSS 설정 로드
  },
});

 

그럼 이제 런타임, 빌드타임에서 커스텀 유틸리티 클래스를 쓸 수 있는 건가!
아니다, 아직 한가지 처리가 더 필요하다.

  • Vite 설정에 플러그인을 등록만 한 경우, 빌드 단계에서만 유틸리티 클래스가 생성된다.
  • 하지만 개발 중에도 브라우저에서 해당 유틸리티를 사용하려면, 런타임 전에 별도의 CSS 파일을 생성해야 한다.
  • 핵심은 postcss CLI를 이용해 src/assets/utility.css 파일을 만들어 두는 것이다!
  • 먼저, src/assets/utility.css 파일이 이미 있다면 내용을 초기화하고, 없다면 생성하는 스크립트를 작성한다.
// scripts/clear-css.js

import fs from 'fs';
fs.writeFileSync('src/assets/utility.css', '');

 

  • 이후 package.jsonscriptsmake:css 명령을 추가한다.
// package.json

{
  "scripts": {
    "make:css": "node scripts/clear-css.js && postcss src/assets/utility.css -o src/assets/utility.css",
     ...
  },
}

 

  • 이렇게 설정해 두면, npm run make:css 명령어 만으로 최신 유틸리티 클래스utility.css에 추가할 수 있다.
@layer utilities {
  .bg-blue-100 { background-color: #F0F4FF; }
  .bg-blue-200 { background-color: #E0E7FF; }
  /* …기타 유틸리티 클래스… */
}
1. config.js를 기반으로 2. utility.css가 생성됨

 
 

2. 두 번째, PurgeCSS로 미사용 클래스 제거하기

  • 앞서 우리는 postCSS를 사용하여 커스텀 유틸리티 클래스를 생성하는 방법을 알아보았다.
  • 하지만 여기에는 한 가지 단점이 있었다. 실제 프로젝트에서 사용하지 않는 유틸리티 클래스까지 빌드 결과물에 포함된다는 점이다.

어떻게 해야 실제 사용하는 커스텀 유틸리티 클래스만 빌드에 포함할 수 있을까?
PurgeCSS를 사용하여 해결해 보자!

1) PurgeCSS란?

  • PurgeCSS는 프로젝트에서 실제로 사용 중인 CSS 클래스만 선택하여 최종 빌드 번들에 남기고,
  • 사용하지 않는 CSS 클래스는 자동으로 제거하는 도구이다.
  • 주로 Tailwind CSS와 같은 유틸리티 퍼스트(Utility-First) 스타일링 프레임워크와 함께 사용되지만, 일반적인 CSS 프로젝트에서도 활용할 수 있다.
  • PurgeCSS의 동작 원리는 다음과 같다. 빌드 시점에 소스 파일(.vue, .js, .html 등)을 정적 분석하여 실제로 사용된 클래스 이름을 찾아내고, CSS 파일에서 이 클래스 이름에 매칭되지 않는 규칙을 제거한다.
  • 이를 통해 CSS 파일의 크기를 줄여 번들 크기를 감소시키고, 초기 로딩 성능과 브라우저의 파싱 및 스타일 계산 비용을 크게 절감할 수 있다.

 

2) PurgeCSS 설정 방법

(1) 의존성 설치

  • 먼저 필요한 의존성을 설치한다.
pnpm add -D cross-env @fullhuman/postcss-purgecss

 

(2) postcss.config.js에 PurgeCSS 설정 추가

  • postcss.config.js 파일을 아래와 같이 수정하여 PurgeCSS를 설정한다.
import myUtilityGenerator from './src/plugins/postcss/index.js';
import purgecss from '@fullhuman/postcss-purgecss';
import autoprefixer from 'autoprefixer';

const plugins = [myUtilityGenerator, autoprefixer];

// 프로덕션 환경에서만 PurgeCSS를 적용!
if (process.env.NODE_ENV === 'production') {
  plugins.push(
    purgecss({
      content: ['./src/**/*.html', './src/**/*.vue'],
      defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
    })
  );
}

export default {
  plugins,
};

 

(3) 적용 전후 빌드 결과 비교

  • PurgeCSS를 적용하기 전에는 빌드된 CSS 파일 크기가 약 2.35KB였다.
  • 그러나 PurgeCSS 적용 이후, 실제 사용된 클래스만 유지되어 CSS 파일 크기가 0.18KB로 약 92% 감소한 것을 확인할 수 있었다.

적용 전

 
적용 후

 

(4) PurgeCSS 적용 시 주의할 점

  • PurgeCSS를 사용할 때, 동적으로 클래스 이름을 생성하는 경우 정적 분석에 의해 클래스가 누락될 수 있다.
  • 예를 들어 Vue 컴포넌트에서 다음과 같이 클래스를 바인딩하면…
<template>
  <button
    :class="[
      `bg-${color}-${shade}`,  // ex: bg-blue-100
      isActive ? 'active' : ''
    ]"
  >
    Click me
  </button>
</template>

<script setup>
import { ref } from 'vue';

const color = ref('blue');
const shade = ref('100');
const isActive = ref(false);
</script>
  • active는 소스 코드에 문자열로 명시되어 있어 정적 분석으로 잡히지만,
  • bg-${color}-${shade} 형태의 클래스(bg-blue-100 등)는 분석 시점에 알 수 없어 제거될 위험이 있다.
  • 이를 방지하려면 PurgeCSS 설정에 safelist: [/^bg-/] 을 추가해, bg- 로 시작하는 모든 클래스를 항상 유지하도록 해야 한다.
// postcss.config.js

if (process.env.NODE_ENV === 'production') {
  plugins.push(
    purgecss({
      content: ['./src/**/*.html', './src/**/*.vue'],
      defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
      safelist: [
        /^bg-/, /* [추가] "bg-"로 시작하는 모든 클래스를 항상 유지 */
      ]
    })
  );
}



3. 마치며

이번 시간에는 PostCSS와 PurgeCSS를 활용하여 커스텀 유틸리티 클래스를 관리하는 방법을 알아보았다.
 
방법은 크게 두 단계로 나눠졌는데 먼저 PostCSS로 프로젝트에 꼭 필요한 유틸리티 클래스를 선언했다.
그 다음 PurgeCSS를 적용해 빌드 단계에서 실제 사용되지 않는 클래스들을 자동으로 제거하도록 설정했다.
이 두 설정으로 인해 여러 커스텀 유틸리티 클래스를 선언할 수 있었으며, 또한 프로덕션 번들에는 정말로 쓰이는 클래스만 남게 되어,
코드 가독성은 물론 번들 크기와 초기 로딩 속도까지 최적화할 수 있었다.
 
물론 TailwindCSS를 사용하는 것도 좋은 선택이지만, 프로젝트 특성상 방대한 클래스 세트를 모두 활용하지 않거나 자체 디자인 시스템을 이미 구축하고 있는 상황에서는 커스텀 PostCSS 플러그인을 구성하는 방법도 대안이 될 수 있을 듯 하다.



반응형

댓글