web-performance

포케코리아 웹 성능 최적화 1편 — Tailwind CSS 중복 500회를 @layer components로 정리하기

157개 파일에 걸쳐 500회 이상 반복되던 Tailwind CSS 클래스 조합을 @layer components로 추출하여 평균 70% 코드 감소를 달성한 리팩토링 과정을 정리합니다.

tailwind css refactoring performance

들어가며

이 글은 포케코리아 웹 성능 최적화 시리즈의 첫 번째 글입니다.

1편 CSS 리팩토링 → 2편 폰트 서브셋팅 → 3편 이미지 최적화 → 4편 Lighthouse 결과 비교

사이드 프로젝트로 운영 중인 포켓몬 도감 서비스 포케코리아는 Next.js + Tailwind CSS로 구성되어 있다. 기능이 점점 추가되면서 데스크톱/모바일 컴포넌트 전체에 걸쳐 동일한 유틸리티 클래스 조합이 반복적으로 사용되고 있었다.

한 예로, 상세 카드 스타일은 이렇게 생겼다.

className =
  'w-full h-full bg-primary-4 border-[3px] border-solid border-primary-1 rounded-2xl shadow-[0_0_0px_3px_var(--color-primary-4)] p-4';

이 157자짜리 클래스 문자열이 13개 파일에 반복되고 있었고, 비슷한 패턴이 프로젝트 전반에 걸쳐 500회 이상 발견되었다. CSS 리팩토링이 필요한 시점이었다.

@layer components란

Tailwind CSS는 커스텀 클래스를 정의할 수 있는 세 가지 레이어를 제공한다.

레이어용도우선순위
@layer baseHTML 요소 기본 스타일낮음
@layer components재사용 컴포넌트 클래스중간
@layer utilities유틸리티 클래스높음

@layer components 블록 안에 클래스를 선언하면 Tailwind의 유틸리티 클래스보다 낮은 우선순위를 갖는다. 즉, 기본 스타일을 정의해두고 필요한 곳에서 유틸리티 클래스로 오버라이드할 수 있다.

@layer components {
  .card-detail {
    @apply w-full h-full bg-primary-4 border-[3px] border-solid border-primary-1 rounded-2xl p-4;
    box-shadow: 0 0 0px 3px var(--color-primary-4);
  }
}
{
  /* 기본 카드 스타일 그대로 사용 */
}
<div className="card-detail">...</div>;

{
  /* 필요한 곳에서 유틸리티로 오버라이드 가능 */
}
<div className="card-detail p-6">...</div>;

@layer 없이 작성하면 항상 최종 출력에 포함되고 우선순위 제어가 어렵다. @layer components에 정의하면 Tailwind의 트리 셰이킹 대상에도 포함되어 미사용 시 자동 제거된다.

참고: CSS 네이티브의 @layer와 Tailwind의 @layer는 다르다. Tailwind v3에서는 빌드 타임에 처리되며, v4부터 CSS 네이티브 @layer를 직접 활용하는 방향으로 전환되었다.

@apply를 사용해도 괜찮을까

Tailwind 창시자 Adam Wathan은 @apply 남용에 대해 비판적 입장을 밝힌 적이 있다. 공식 문서에서도 스타일 재사용의 우선순위를 다음과 같이 안내한다.

  1. 컴포넌트 추출 — 가장 권장
  2. 에디터 기능 활용 — 멀티커서 편집 등
  3. @apply 사용 — 컴포넌트화가 어려운 경우에 한해

이번 리팩토링에서는 이미 컴포넌트 단위로 분리되어 있지만, 동일한 시각적 패턴이 서로 다른 컴포넌트에 걸쳐 반복되는 상황이었다. 카드 스타일, 배지 스타일, flex 레이아웃 패턴처럼 디자인 시스템 수준의 공통 스타일이 대상이었기 때문에 @layer components + @apply가 적절한 선택이었다.

발견한 9가지 중복 패턴

프로젝트 전체를 스캔하여 반복 사용되는 클래스 조합을 패턴별로 분류했다.

1. 카드 스타일 — 157자가 11자로 (93% 감소)

가장 극적인 개선이 이루어진 패턴이다.

// Before — 157자
className =
  'w-full h-full bg-primary-4 border-[3px] border-solid border-primary-1 rounded-2xl shadow-[0_0_0px_3px_var(--color-primary-4)] p-4';

// After — 11자
className = 'card-detail';

상세 카드, 리스트 카드, 작은 카드까지 3가지 변형을 정의했다. 13개 파일에 걸쳐 적용되었다.

2. Leading 정렬 — 72개 파일, 215회

프로젝트 전반에서 leading-[calc(Xrem+2px)] 패턴이 사용되고 있었다. 텍스트를 세로 중앙에 정렬하기 위한 line-height 계산인데, 매번 calc 표현식을 인라인으로 작성하고 있었다.

// Before
className = 'text-xl leading-[calc(2rem+2px)]';

// After
className = 'text-xl text-aligned-base';

6단계(xs, sm, md, base, lg, xl)의 정렬 클래스를 정의하여 72개 파일에 걸쳐 215회 적용했다.

3. Flex 레이아웃 — 63개 파일, 124회

flex items-center justify-between 같은 조합이 프로젝트에서 가장 높은 빈도로 반복되는 패턴 중 하나였다.

// Before
className = 'flex items-center justify-between';
className = 'flex items-center justify-center';
className = 'flex items-center gap-2';

// After
className = 'flex-between';
className = 'flex-center';
className = 'flex-items-gap-2';

4. 타입 태그 & 데미지 배지 — 10개 파일

포켓몬 타입 태그(110자)와 물리/특수/상태 데미지 배지(60~80자)를 각각 추출했다.

// Before — 타입 태그 (110자)
className={`w-[3.6rem] h-6 block px-2 rounded-[0.625rem] text-center font-semibold text-[0.85rem] leading-[calc(1.5rem+2px)] chip-type-${type.toLowerCase()}`}

// After — 17자
className={`type-tag chip-type-${type.toLowerCase()}`}

데미지 배지는 기본 스타일을 .badge-damage로 정의하고, 물리(.badge-damage-physical), 특수(.badge-damage-special), 상태(.badge-damage-status)를 확장하는 구조로 설계했다.

5. Description List — 2개 파일, 27회 (87% 감소)

포켓몬 상세 정보를 표시하는 <dt>, <dd> 패턴이 반복되고 있었다.

// Before
<dt className="w-48 h-10 text-xl leading-[calc(2.5rem+2px)]">특성</dt>
<dd className="h-10 text-xl font-semibold flex items-center gap-2 leading-[calc(2.5rem+2px)]">
  {ability}
</dd>

// After
<dt className="dl-term">특성</dt>
<dd className="dl-desc">{ability}</dd>

.dl-term::after로 자동 콜론(:)을 추가하여 마크업도 깔끔해졌다.

6. 퀴즈 답변 버튼 — 6개 파일 (91% 감소)

// Before — 160자
className =
  'h-[3rem] px-[1rem] text-[1rem] leading-[calc(3rem+2px)] text-left rounded-[20rem] bg-primary-3 text-primary-1 transition-colors hover:bg-primary-2 hover:text-primary-4';

// After — 15자
className = 'btn-quiz-answer';

hover 효과까지 포함된 복잡한 스타일이 하나의 클래스로 정리되었다.

7~9. 나머지 패턴

패턴적용 범위감소율
Height-Leading 매칭Leading 패턴과 함께 처리
데미지 배지9개 파일, 27회71%
카드 코너 접힌 효과4개 파일 (283자 → 16자)94%

카드 코너 접힌 효과는 ::before 의사 요소를 Tailwind 유틸리티로 작성한 것인데, 283자에 달하는 가장 긴 클래스 문자열이었다.

// Before — 283자
className =
  "... before:content-[''] before:absolute before:top-0 before:left-0 before:block before:border-t-[1.5rem] before:border-l-[1.5rem] before:border-r-[1.5rem] before:border-b-[1.5rem] before:border-t-[#334150] before:border-l-[#334150] before:border-r-transparent before:border-b-transparent";

// After — 16자
className = '... card-corner-fold';

추출한 @layer components 구조

최종적으로 globals.css@layer components 블록에 27개 클래스를 정의했다. 패턴별로 그룹을 나누고 주석으로 구분하여 유지보수성을 확보했다.

@layer components {
  /* 레이아웃 */
  .flex-between {
    @apply flex items-center justify-between;
  }
  .flex-center {
    @apply flex items-center justify-center;
  }
  .flex-items-gap-2 {
    @apply flex items-center gap-2;
  }
  .flex-items-gap-4 {
    @apply flex items-center gap-4;
  }

  /* 카드 */
  .card-detail {
    @apply w-full h-full bg-primary-4 border-[3px] border-solid border-primary-1 rounded-2xl p-4;
    box-shadow: 0 0 0px 3px var(--color-primary-4);
  }
  .card-list {
    /* ... */
  }
  .card-list-sm {
    /* ... */
  }

  /* 타입 태그 & 배지 */
  .type-tag {
    @apply w-[3.6rem] h-6 block px-2 rounded-[0.625rem] text-center font-semibold text-[0.85rem];
    line-height: calc(1.5rem + 2px);
  }
  .badge-damage {
    @apply w-14 h-7 text-[0.875rem] rounded-lg text-white text-center;
  }
  .badge-damage-physical {
    @apply badge-damage bg-[#fd8181];
  }
  .badge-damage-special {
    @apply badge-damage bg-[#9b9bfa];
  }
  .badge-damage-status {
    @apply badge-damage bg-[#72d372];
  }

  /* 설명 리스트 */
  .dl-term {
    /* ... */
  }
  .dl-term::after {
    content: ':';
    float: right;
  }
  .dl-desc {
    /* ... */
  }

  /* 퀴즈 UI */
  .btn-quiz-answer {
    /* ... */
  }
  .btn-quiz-answer:hover {
    @apply bg-primary-2 text-primary-4;
  }

  /* 카드 코너 접힌 효과 */
  .card-corner-fold::before {
    @apply content-[''] absolute top-0 left-0 block;
    border-width: 1.5rem;
    border-style: solid;
    border-color: #334150 transparent transparent #334150;
  }
}

결과

정량적 성과

패턴변경 전 (평균)변경 후 (평균)감소율적용 횟수
Card Corner283자16자94%4회
Detail Card157자11자93%13회
Quiz Button160자15자91%6회
Description List60자8자87%27회
Type Tag110자17자84%1회
Damage Badge75자22자71%27회
Flex Layout35자12자66%124회
Leading30자18자40%215회

부수 효과

코드 크기 감소 외에도 몇 가지 부수적인 개선이 있었다.

추출 기준 정리

이번 작업을 통해 정리한 @apply 추출 기준은 다음과 같다.

상황접근 방식
한 컴포넌트 안에서만 반복해당 컴포넌트의 props나 변수로 해결
여러 컴포넌트에 걸친 디자인 패턴@layer components로 추출
의미 있는 이름을 부여할 수 있는 경우추출 적합 (card-detail, badge-damage)
단순히 클래스가 길어서 줄이고 싶은 경우추출 지양

Tailwind 공식 문서에서도 강조하듯, 컴포넌트 추출이 1순위이고 @apply는 그 다음이다. 하지만 디자인 시스템 수준의 공통 스타일이라면 @layer components는 충분히 합리적인 선택이다.

다음 편에서는 한글 웹폰트 서브셋팅으로 폰트 번들 크기를 126KB 줄인 과정을 다룬다.


포케코리아 웹 성능 최적화 시리즈

참고 자료