← 모든 글

76점에서 100점으로: 완벽한 Lighthouse 점수 달성기

요약: 개인 포트폴리오 사이트의 모바일 Lighthouse 성능 점수가 76점(CLS 0.493)에서 모든 카테고리 완벽한 100/100/100/100으로 개선되었습니다. 이 과정에서 미묘한 CSS 로딩 문제, 정규식 버그, 그리고 모바일에서 레이아웃 시프트를 유발하는 특히 교묘한 CSS 변수 오버라이드 문제를 발견했습니다.


출발점

이 사이트는 FastAPI + Jinja2 기반 포트폴리오에 HTMX와 Alpine.js를 인터랙티비티에 활용하고 있었습니다. 초기 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”과 같은 alt 텍스트가 있었는데, 이미 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 전체 스타일시트를 다운로드하고 파싱한 후에야 화면에 그릴 수 있었습니다.

해결책: 크리티컬 CSS + 비동기 로딩

1단계: 크리티컬(스크롤 없이 보이는 영역) 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"로 전환합니다.

크리티컬 CSS 추출 스크립트

스크롤 없이 보이는 영역의 컴포넌트에 대한 크리티컬 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”부터 여는 중괄호까지 매칭하여 잘못된 형식의 셀렉터를 생성했고, 이것이 프리픽스 검사를 통과하지 못했습니다. 크리티컬 .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! */

모바일에서는 크리티컬 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__contentpadding: 0 var(--gutter) 사용 - --gutter:root에서 48px로 정의됨

그리고 원인을 찾았습니다:

/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 24px */
  }
}

모바일에서의 순서:

  1. 크리티컬 CSS 로드: --gutter: 48px
  2. 48px 사이드 패딩으로 히어로 렌더링
  3. 전체 CSS 비동기 로드
  4. 미디어 쿼리가 --gutter: 24px로 설정
  5. 히어로 패딩이 48px에서 24px로 축소
  6. 텍스트 리플로우 및 시프트 발생 = CLS 0.116

수정

:root 미디어 쿼리를 크리티컬 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())

이제 크리티컬 CSS에 다음이 포함됩니다:

:root { --gutter: 48px; /* ... */ }

@media (max-width: 768px) {
  :root { --gutter: var(--spacing-md); }
}

모바일에서 처음부터 올바른 24px gutter로 렌더링됩니다. 전체 CSS가 로드될 때 --gutter는 이미 24px이므로 변화 없음, 시프트 없음.


6단계: CSP와 Alpine.js

한 가지 문제가 더 있었습니다: Alpine.js가 Content Security Policy 위반에 대한 콘솔 오류를 발생시키고 있었습니다. Alpine.js는 내부적으로 동적 표현식 평가를 사용합니다.

# In security headers middleware
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

'unsafe-eval' 디렉티브는 Alpine.js의 표현식 파싱에 필요합니다. 대안은 Alpine.js의 CSP 호환 빌드를 사용하는 것이지만, 일부 제한 사항이 있습니다.


최종 결과

지표 이전 이후
성능 76 100
접근성 91 100
권장사항 100 100
SEO 100 100
CLS 0.493 0.033
LCP 2.6s 0.8s

증거

데스크톱 감사에서 모든 네 가지 카테고리에서 만점을 기록한 결과:

Lighthouse desktop audit showing 100/100/100/100 scores

가장 중요한 모바일 감사에서도 만점을 달성했습니다:

Lighthouse mobile audit showing 100/100/100/100 scores


핵심 교훈

1. CSS 변수가 CLS를 유발할 수 있습니다

CSS 커스텀 프로퍼티의 미디어 쿼리 오버라이드는 DevTools의 계산된 스타일 패널에서 보이지 않습니다—최종 값만 표시됩니다. 크리티컬 CSS에 변수 오버라이드가 포함되지 않으면, 전체 스타일시트가 로드될 때 레이아웃 시프트가 발생합니다.

2. 크리티컬 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, 전처리기 없음
  • 최적화: 크리티컬 CSS 추출을 위한 커스텀 Python 스크립트
  • 호스팅: Railway (GZip 압축 및 불변 캐싱 적용)

사용된 도구

  • Lighthouse (Chrome DevTools)
  • CLS 디버깅을 위한 Lighthouse 필름스트립 뷰
  • CSS 분석을 위한 grepregex101.com
  • 크리티컬 CSS 추출 스크립트를 위한 Python
  • iTerm 2
  • Claude Code CLI (Opus 4.5 모델과 수많은 ultrathink 활용)

관련 게시물

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 분 소요

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 분 소요

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 분 소요