포케코리아 웹 성능 최적화 1편 — Tailwind CSS 중복 500회를 @layer components로 정리하기
157개 파일에 걸쳐 500회 이상 반복되던 Tailwind CSS 클래스 조합을 @layer components로 추출하여 평균 70% 코드 감소를 달성한 리팩토링 과정을 정리합니다.
들어가며
이 글은 포케코리아 웹 성능 최적화 시리즈의 첫 번째 글입니다.
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 base | HTML 요소 기본 스타일 | 낮음 |
@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 남용에 대해 비판적 입장을 밝힌 적이 있다. 공식 문서에서도 스타일 재사용의 우선순위를 다음과 같이 안내한다.
- 컴포넌트 추출 — 가장 권장
- 에디터 기능 활용 — 멀티커서 편집 등
- @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 Corner | 283자 | 16자 | 94% | 4회 |
| Detail Card | 157자 | 11자 | 93% | 13회 |
| Quiz Button | 160자 | 15자 | 91% | 6회 |
| Description List | 60자 | 8자 | 87% | 27회 |
| Type Tag | 110자 | 17자 | 84% | 1회 |
| Damage Badge | 75자 | 22자 | 71% | 27회 |
| Flex Layout | 35자 | 12자 | 66% | 124회 |
| Leading | 30자 | 18자 | 40% | 215회 |
- 총 변경 파일: 157개
- 총 변경 횟수: 500회 이상
- 추정 코드 감소량: 약 20,000자
- 평균 감소율: 약 70%
부수 효과
코드 크기 감소 외에도 몇 가지 부수적인 개선이 있었다.
- 유지보수성 — 카드 스타일을 변경할 때
globals.css한 곳만 수정하면 프로젝트 전체에 반영된다 - 일관성 — 동일한 패턴은 반드시 동일한 클래스를 사용하게 되어 시각적 불일치를 방지한다
- 가독성 —
card-detail이라는 이름만으로 코드의 의도가 명확해진다 - 번들 크기 — 반복되는 클래스 문자열이 줄어들며 CSS/JSX 번들이 작아진다
추출 기준 정리
이번 작업을 통해 정리한 @apply 추출 기준은 다음과 같다.
| 상황 | 접근 방식 |
|---|---|
| 한 컴포넌트 안에서만 반복 | 해당 컴포넌트의 props나 변수로 해결 |
| 여러 컴포넌트에 걸친 디자인 패턴 | @layer components로 추출 |
| 의미 있는 이름을 부여할 수 있는 경우 | 추출 적합 (card-detail, badge-damage) |
| 단순히 클래스가 길어서 줄이고 싶은 경우 | 추출 지양 |
Tailwind 공식 문서에서도 강조하듯, 컴포넌트 추출이 1순위이고 @apply는 그 다음이다. 하지만 디자인 시스템 수준의 공통 스타일이라면 @layer components는 충분히 합리적인 선택이다.
다음 편에서는 한글 웹폰트 서브셋팅으로 폰트 번들 크기를 126KB 줄인 과정을 다룬다.
포케코리아 웹 성능 최적화 시리즈
- 1편: Tailwind CSS 중복 500회를 @layer components로 정리하기 (현재 글)
- 2편: 한글 웹폰트 서브셋팅으로 126KB 줄이기
- 3편: srcset과 DPR로 이미지 대역폭 44% 절감하기
- 4편: Lighthouse로 검증한 최적화 전후 비교