76에서 100까지: 완벽한 Lighthouse 점수 달성하기
요약: 개인 포트폴리오 사이트가 모바일 Lighthouse 성능 점수 76점과 CLS 0.493에서 모든 카테고리에서 완벽한 100/100/100/100을 달성했습니다. 이 여정에서 미묘한 CSS 로딩 문제, 정규식 버그, 그리고 모바일에서 레이아웃 이동을 유발한 특히 교묘한 CSS 변수 오버라이드 문제를 발견했습니다.
시작점
이 사이트는 HTMX와 Alpine.js를 사용한 FastAPI + Jinja2 포트폴리오였습니다. 초기 Lighthouse 모바일 감사 결과:
| 지표 | 점수 |
|---|---|
| 성능 | 76 |
| 접근성 | 91 |
| 모범 사례 | 100 |
| SEO | 100 |
| CLS | 0.493 |
CLS 수치가 매우 심각합니다. Google은 0.1 이상을 “불량”으로 간주합니다. 0.493이면 페이지 로드 중에 눈에 띄게 화면이 흔들립니다.
1단계: 접근성 빠른 개선
성능을 다루기 전에, 기준선을 확립하기 위해 접근성 문제를 정리했습니다:
폼 레이블
장식용 span을 적절한 <label> 요소로 변경:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
대비율
푸터 제목과 폼 레이블이 --color-text-tertiary(40% 불투명도 흰색)를 사용하고 있었습니다. WCAG AA 4.5:1 대비 요구사항을 충족하기 위해 --color-text-secondary(65% 불투명도)로 상향 조정했습니다.
중복 대체 텍스트
소셜 미디어 아이콘에 “LinkedIn icon”과 같은 대체 텍스트가 있었는데, 이미 aria-label이 있는 링크 안에 있었습니다. 스크린 리더 반복을 방지하기 위해 alt=""와 aria-hidden="true"로 변경했습니다.
동일한 링크 텍스트
여러 “View Case Study” 링크가 스크린 리더로는 구분이 불가능했습니다. 프로젝트 이름이 포함된 aria-label 속성을 추가했습니다:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
결과: 접근성이 91에서 100으로 상승했습니다.
2단계: 렌더 차단 CSS 문제
Lighthouse에서 LCP 분석에 2,460ms의 “Element render delay”가 표시되었습니다. 원인: 첫 페인트를 차단하는 동기 CSS 파일.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
브라우저가 무엇이든 그리기 전에 전체 25KB 스타일시트를 다운로드하고 파싱해야 했습니다.
해결책: Critical CSS + 비동기 로딩
1단계: 스크롤 없이 보이는 영역의 critical CSS를 별도 파일로 추출하여 <head>에 인라인으로 삽입.
2단계: media="print" 트릭을 사용하여 전체 스타일시트를 비동기로 로드:
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
media="print" 속성은 브라우저에게 “이 스타일시트는 화면 렌더링에 필요하지 않다”고 알려주어 차단하지 않습니다. 로드가 완료되면 onload 핸들러가 media="all"로 전환합니다.
Critical CSS 추출 스크립트
스크롤 없이 보이는 컴포넌트의 critical CSS를 자동으로 추출하는 Python 스크립트를 작성했습니다:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
결과: LCP 요소 렌더 지연이 2,460ms에서 ~300ms로 감소했습니다.
3단계: CLS 악몽의 시작
비동기 CSS를 구현한 후, CLS가 실제로 더 나빠졌습니다—0.119로 상승. Lighthouse에서 <main>이 이동하는 요소로 표시되었습니다.
버그 #1: 정규식을 오염시키는 CSS 주석
추출 스크립트가 선택자를 매칭하기 위해 정규식을 사용했습니다:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
문제: 추출 전에 CSS 주석이 제거되지 않았습니다. 다음과 같은 주석:
/* Hero Section - Editorial */
.hero { ... }
정규식이 “Hero Section”부터 여는 중괄호까지 매칭하게 되어, 접두사 검사에 실패하는 잘못된 선택자가 생성되었습니다. Critical .hero 스타일이 조용히 누락되고 있었습니다.
수정: 정규식 작업 전에 주석 제거:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
버그 #2: 미디어 쿼리 규칙이 독립 규칙으로 추출됨
정규식이 미디어 쿼리 내부의 규칙을 매칭하여 독립 규칙으로 추출하고 있었습니다:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
스크립트가 두 규칙을 최상위 레벨에서 추출했습니다:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
모바일에서 제목이 critical CSS의 모바일 크기로 렌더링된 후… 전체 CSS에 동일한 규칙이 있어서 그대로 유지되었습니다. 하지만 데스크톱에서는 모바일 오버라이드가 잘못 적용되고 있었습니다.
수정: 미디어 쿼리 위치를 추적하고 그 내부 규칙을 건너뛰기:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
4단계: 100vh 가설
CLS가 여전히 0.116이었습니다. 이론: 초기 페인트에서 스크롤 없이 보이는 영역 아래에 아무것도 보이지 않으면 CLS에 기여할 수 없습니다.
히어로를 85vh에서 100vh로 변경:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
결과: 변화 없음. CLS 여전히 0.116. 이동이 뷰포트 내에서 발생하고 있었습니다.
5단계: 결정적 단서
Lighthouse 필름스트립에서 히어로 텍스트가 프레임 사이에서 눈에 띄게 위치를 이동하고 있었습니다. 페이드가 아니라—수평으로 이동하고 있었습니다.
수평 위치에 영향을 미치는 스타일을 심층 조사:
- .hero__content가 padding: 0 var(--gutter) 사용
- --gutter가 :root에서 48px로 정의됨
그리고 찾았습니다:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
모바일에서의 순서:
- Critical CSS 로드:
--gutter: 48px - 히어로가 48px 측면 패딩으로 렌더링
- 전체 CSS가 비동기로 로드
- 미디어 쿼리가
--gutter: 24px설정 - 히어로 패딩이 48px에서 24px로 축소
- 텍스트 리플로우 및 이동 = CLS 0.116
수정
:root 미디어 쿼리를 critical CSS의 일부로 추출:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
이제 critical CSS에 포함됩니다:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
모바일이 처음부터 올바른 24px 거터로 렌더링됩니다. 전체 CSS가 로드될 때 --gutter는 이미 24px—변경 없음, 이동 없음.
6단계: CSP와 Alpine.js
한 가지 더 문제: Alpine.js가 Content Security Policy 위반에 대한 콘솔 오류를 발생시키고 있었습니다. Alpine은 내부적으로 동적 표현식 평가를 사용합니다.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
'unsafe-eval' 지시어는 Alpine.js의 표현식 파싱에 필요합니다. 대안은 Alpine의 CSP 호환 빌드를 사용하는 것인데, 일부 제한이 있습니다.
최종 결과
| 지표 | 이전 | 이후 |
|---|---|---|
| 성능 | 76 | 100 |
| 접근성 | 91 | 100 |
| 모범 사례 | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0.493 | 0.033 |
| LCP | 2.6s | 0.8s |
증거
모든 네 가지 카테고리에서 완벽한 점수를 보여주는 데스크톱 감사:

가장 중요한 모바일 감사도 완벽한 점수 달성:

핵심 교훈
1. CSS 변수가 CLS를 유발할 수 있다
CSS 사용자 정의 속성의 미디어 쿼리 오버라이드는 DevTools의 계산된 스타일 패널에서 보이지 않습니다—최종 값만 보입니다. Critical CSS에 변수 오버라이드가 포함되지 않으면 전체 스타일시트가 로드될 때 레이아웃이 이동합니다.
2. Critical CSS 추출은 까다롭다
단순한 정규식 접근 방식은 다음에서 실패합니다:
- CSS 주석 (선택자 매칭을 오염시킬 수 있음)
- 미디어 쿼리 내부의 규칙 (해당 블록 내부에 유지되어야 함)
- :root 미디어 쿼리 (변수 변경이 모든 것에 영향)
3. media="print" 트릭이 작동한다
media="print" onload="this.media='all'"로 비필수 CSS를 로드하는 것은 JavaScript 복잡성 없이 스타일시트 로딩을 지연시키는 합법적인 방법입니다.
4. CLS 디버깅에는 필름스트립이 필요하다
Lighthouse의 필름스트립 뷰는 정확히 언제 이동이 발생하는지 보여줍니다. 없으면 추측할 수밖에 없습니다. “이동 중”으로 강조 표시된 요소는 단지 증상일 수 있습니다—무엇이 이동을 유발하는지 찾으세요.
5. 100vh 히어로 섹션은 미학을 넘어서는 이점이 있다
히어로가 뷰포트를 채우면, 스크롤 없이 보이는 영역 아래 콘텐츠는 사용자가 스크롤할 때까지 측정되지 않으므로 CLS에 기여할 수 없습니다.
기술 스택
- 백엔드: FastAPI + Jinja2
- 프론트엔드: HTMX + Alpine.js (자체 호스팅, CDN 없음)
- CSS: 사용자 정의 속성을 사용한 순수 CSS, 전처리기 없음
- 최적화: Critical CSS 추출을 위한 커스텀 Python 스크립트
- 호스팅: GZip 압축과 불변 캐싱이 적용된 Railway
사용한 도구
- Lighthouse (Chrome DevTools)
- CLS 디버깅을 위한 Lighthouse 필름스트립 뷰
- CSS 분석을 위한
grep과regex101.com - Critical CSS 추출 스크립트용 Python
- iTerm 2
- Opus 4.5와 많은 ultrathink가 적용된 Claude Code CLI