designpaca/research/svg/03-performance-and-pitfalls.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

493 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 03. 성능 · 브라우저 함정 · 폴백
> "SVG 필터는 예쁘다"와 "SVG 필터를 프로덕션에 넣어도 된다"는 다른 문제다. 이 문서는 후자를 위한 것이다.
---
## 1. 비용 모델 — 무엇이 비싼가
### 1.1 기본 공식
```
프레임당 비용 ≈ (필터 영역 픽셀 수) × (프리미티브 수 × 프리미티브별 픽셀당 연산) × (재계산 빈도)
```
세 항 모두가 곱해진다. 하나만 줄여도 효과가 크고, 셋 다 방치하면 폭발한다.
### 1.2 프리미티브별 비용 등급
| 등급 | 프리미티브 | 픽셀당 연산 | 비고 |
|---|---|---|---|
| **매우 저렴** | `feOffset`, `feFlood`, `feMerge`, `feTile` | 복사/상수 | 사실상 공짜 |
| **저렴** | `feColorMatrix`, `feComponentTransfer`, `feBlend`, `feComposite` | 산술 수 회 | 마음껏 써도 됨 |
| **보통** | `feGaussianBlur`, `feDropShadow` | 분리형 컨볼루션 (반경 비례) | 반경이 커지면 급증 |
| **비쌈** | `feMorphology`, `feDisplacementMap` | 이웃 탐색 / 랜덤 액세스 | 캐시 미스 다발 |
| **매우 비쌈** | `feTurbulence`, `feConvolveMatrix`, `feDiffuseLighting`, `feSpecularLighting` | Perlin 반복 / N×N 커널 / 노멀 계산 | **애니메이션 금지 후보** |
`feTurbulence`는 별도로 언급할 가치가 있다. Firefox 버그 트래커에 "far too slow"로 15년 넘게 올라와 있고, 최신 노트북에서도 큰 면적에 걸면 CPU 코어를 즉시 포화시킨다는 보고가 있다. **`numOctaves`가 1 늘어날 때마다 노이즈 계산이 한 층씩 더 쌓인다.** 5 이상은 시각적 이득 대비 손해다.
### 1.3 필터 영역이 곱해지는 방식
`<filter>``width`/`height`는 **처리할 픽셀 수를 직접 결정**한다.
| 필터 영역 | 원본 대비 픽셀 수 |
|---|---|
| `100% × 100%` | 1.00× |
| `120% × 120%` (기본값) | 1.44× |
| `160% × 160%` | 2.56× |
| `180% × 180%` | 3.24× |
| `200% × 200%` | 4.00× |
> 노이즈 오버레이처럼 영역 확장이 필요 없는 필터는 **반드시 `x="0%" y="0%" width="100%" height="100%"`** 로 줄여라. 그것만으로 30% 절약이다.
### 1.4 재계산이 일어나는 시점
필터 결과는 요소가 변하지 않는 한 캐시된다. 아래 상황에서 캐시가 무효화된다.
| 상황 | 재계산 |
|---|---|
| 필터 어트리뷰트 변경 (`baseFrequency`, `scale`, `seed`, `stdDeviation`) | ✅ 전체 |
| `flood-color` / `lighting-color` 변경 | ✅ 전체 (단, 형태 계산은 재사용 가능) |
| 필터 걸린 요소의 **자식이 변함** (텍스트, 애니메이션) | ✅ 전체 |
| 필터 걸린 요소의 **크기 변경** | ✅ 전체 |
| 필터 걸린 요소의 `transform` / `opacity` 변경 | ⚠️ 엔진·상황에 따라 다름. 합성 레이어로 승격되면 재사용되지만, 그렇지 않으면 매 프레임 재계산 |
| `backdrop-filter`에서 **배경이 스크롤/변화** | ✅ 매 프레임 |
> **가장 흔한 성능 사고**: 필터 걸린 요소를 `transform`으로 움직이는 것. 브라우저가 캐시된 레이어를 합성하는 대신 매 프레임 필터 전체를 다시 돌릴 수 있다. **필터와 모션은 서로 다른 요소에 분리하라.**
```html
<!-- ❌ 같은 요소에 필터 + 모션 -->
<div class="blob" style="filter:url(#goo); animation: move 3s infinite"></div>
<!-- ✅ 바깥이 움직이고, 안쪽이 필터를 가진다 (혹은 그 반대) -->
<div class="mover" style="animation: move 3s infinite">
<div class="blob" style="filter:url(#goo)"></div>
</div>
```
### 1.5 `will-change: filter`를 쓰지 마라 (대부분의 경우)
- `will-change: filter`는 요소를 별도 합성 레이어로 승격시키고 **메모리를 상시 점유**한다.
- 필터 자체는 여전히 CPU/GPU에서 재계산된다 — 승격이 필터 연산을 없애주지 않는다.
- 다수 요소에 걸면 레이어 폭발로 오히려 느려진다.
- **예외**: 짧은 인터랙션 직전에 JS로 붙였다가 끝나면 제거하는 패턴은 유효하다.
```js
el.addEventListener('pointerenter', () => { el.style.willChange = 'filter'; });
el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });
```
### 1.6 하드웨어 가속의 현실
- 엔진별로 가속하는 필터가 다르다. Chrome이 가속하는 것, Firefox가 가속하는 것이 서로 다르고, 일부 안드로이드 GPU는 아무것도 가속하지 못한다.
- `feTurbulence`는 하드웨어 가속에 친화적이지 않은 것으로 알려져 있다.
- **Safari는 CSS 단축 필터(`blur()`, `drop-shadow()` 등)에서는 잘 동작하지만 SVG `<filter>`에서는 문제가 잦다.**
- Firefox에는 "하드웨어 가속을 켜면 대부분의 CSS/SVG 필터가 동작하지 않는다"는 장기 미해결 버그가 보고되어 있다.
**결론**: 가속을 전제로 설계하지 마라. **CPU에서 돌아도 부드러운 수준**으로 설계하고, 가속은 보너스로 취급하라.
---
## 2. CSS 필터 vs SVG 필터 — 선택 기준
| 판단 | 선택 |
|---|---|
| blur / brightness / contrast / saturate / hue-rotate / drop-shadow 만 필요 | **CSS 단축 필터** — 더 빠르고, 더 잘 가속되고, Safari에서 안전하며, 값 보간 애니메이션이 가능 |
| 노이즈, 변위, 채널 분리, 알파 임계, 조명, 커스텀 톤커브 | **SVG 필터** — 대안이 없다 |
| 애니메이션이 필요한 값 | CSS 단축 필터는 **보간된다**. SVG 필터 어트리뷰트는 SMIL/JS만 가능 |
**혼합 사용이 가능하다** — 무거운 SVG 필터는 정적으로 두고, 값이 변하는 부분만 CSS로 처리하는 것이 최적이다.
```css
.card {
/* SVG 필터는 고정, CSS 필터만 트랜지션 */
filter: url(#texture) brightness(1);
transition: filter 240ms ease;
}
.card:hover {
filter: url(#texture) brightness(1.15) saturate(1.2);
}
```
> `filter` 목록에서 `url()`은 그대로 두고 뒤의 CSS 함수만 바뀌면 브라우저가 그 부분만 보간한다.
**CSS 필터로 대체 가능한지 먼저 확인하라.**
| SVG 필터 조합 | CSS 대체 |
|---|---|
| `feColorMatrix type="saturate" values="0"` | `filter: grayscale(1)` (sRGB로 동작) |
| `feGaussianBlur` + `feOffset` + `feFlood` + `feMerge` | `filter: drop-shadow(x y blur color)` |
| `feColorMatrix type="hueRotate"` | `filter: hue-rotate(deg)` |
| 큰 소프트 글로우 | **`radialGradient` 또는 `box-shadow`가 압도적으로 싸다** |
---
## 3. `backdrop-filter` 함정
### 3.1 지원 현황
| 기능 | Chromium | Safari | Firefox |
|---|---|---|---|
| `backdrop-filter: blur()` 등 CSS 함수 | ✅ | ✅ (`-webkit-` 접두사 필요했던 시기 있음) | ✅ |
| **`backdrop-filter: url(#svgFilter)`** | ✅ | ❌ | ❌ |
`backdrop-filter`에 SVG 필터를 넣는 것은 **CSS 스펙에 없는 Chromium 확장**이다. Liquid Glass류 효과가 Chrome에서만 보이는 이유가 이것이다. Firefox에는 기능 요청이 접수되어 있고(2025), `mdn/browser-compat-data`에도 관련 이슈가 열려 있다.
### 3.2 기능 감지가 신뢰할 수 없다 ⚠️
```js
CSS.supports('backdrop-filter', 'url(#x)') // Chromium: true (검증됨)
CSS.supports('backdrop-filter', 'url(#x) brightness(1.1)') // Chromium: true (검증됨)
```
`CSS.supports``@supports`는 **값을 파싱할 수 있는지**만 검사한다. Firefox/Safari도 `url()`을 유효한 `<filter-function>` 문법으로 파싱하므로 `true`를 반환할 가능성이 높다 — 그런데 렌더링은 하지 않는다.
**권장 전략: 감지하지 말고, 계단식으로 쌓아라.**
```css
/* 1단계: 모든 브라우저에서 그럴듯한 기본값 */
.glass {
backdrop-filter: blur(14px) saturate(1.4);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow: inset 0 1px 1px rgba(255,255,255,.6), 0 10px 30px rgba(0,0,0,.4);
}
/* 2단계: 굴절을 지원하면 덮어쓴다.
지원 안 하는 엔진에서는 이 선언이 무효 처리되어 1단계가 남는다. */
@supports (backdrop-filter: url(#lens)) {
.glass { backdrop-filter: url(#lens) brightness(1.06) saturate(1.25); }
}
```
> `@supports`가 통과해버리는 엔진에서는 `backdrop-filter`가 통째로 무시되어 유리 배경만 남는다. 그래서 **`background`와 `box-shadow`만으로도 유리처럼 보이게** 설계해두는 것이 핵심이다.
**확실히 가르고 싶다면 렌더 프로브를 써라** (미검증 — 로직 참고용):
```js
async function supportsBackdropSVGFilter() {
if (!('CSS' in window) || !CSS.supports('backdrop-filter', 'blur(1px)')) return false;
// Chromium 계열만 확실히 지원한다는 사실에 기대는 실용적 판단
const ua = navigator.userAgent;
const isChromium = /Chrome|Chromium|Edg/.test(ua) && !/OPR|Firefox/.test(ua);
const isAppleWebKit = /Safari/.test(ua) && !/Chrome|Chromium|Edg/.test(ua);
return isChromium && !isAppleWebKit;
}
```
> UA 스니핑은 최후의 수단이다. 실패 시 폴백이 충분히 좋다면 감지 자체를 하지 않는 편이 낫다.
### 3.3 크기 불일치
`backdrop-filter`의 필터 영역은 요소 크기에 **자동으로 맞춰지지 않는다.** `feImage` 변위맵의 `width/height`가 요소와 다르면 굴절이 어긋나거나 잘린다.
- 크기별로 별도 맵을 만들어라.
- 반응형으로 크기가 변하는 요소에는 이 기법을 쓰지 마라.
- 크기 변경 애니메이션은 매 프레임 맵 재계산 = 최악의 시나리오.
### 3.4 `backdrop-filter`는 배경 합성을 강제한다
배경이 스크롤하거나 변하면 **매 프레임 배경을 다시 캡처해 필터를 돌린다.** 뷰포트 전체를 덮는 `backdrop-filter`는 스크롤 성능을 파괴한다. 작은 칩/툴바/모달 헤더에만 써라.
---
## 4. 좌표계·색공간 함정 (요약과 진단)
§01에 상세히 있고, 여기서는 **증상 → 원인** 진단표로 정리한다.
| 증상 | 원인 | 처방 |
|---|---|---|
| 효과가 사각형으로 잘림 | 필터 영역이 좁음 | `x/y/width/height` 확대 |
| 색이 물빠진 듯 밝고 채도가 낮음 | linearRGB 기본값 | `color-interpolation-filters="sRGB"` |
| 듀오톤 색이 지정한 hex와 다름 | 위와 동일 | 위와 동일 |
| `stdDeviation="10"`인데 화면이 사라짐 | `primitiveUnits="objectBoundingBox"` | 값을 0~1 스케일로 다시 계산 |
| 변위가 이상한 방향으로 감 | `xChannelSelector`/`yChannelSelector` 기본값이 `A` | `R`/`G` 명시 |
| 색이 전부 흰색으로 날아감 | `feColorMatrix` 값에 0~255를 씀 | 255로 나눠 0~1로 |
| 필터 영역 전체가 단색 사각형 | `feFlood`/조명 결과를 클리핑 안 함 | `feComposite operator="in"` + `SourceAlpha` |
| 분기 배선이 무시됨 | `in`을 명시하지 않아 직전 출력이 들어감 | 양쪽 모두 `in` 명시 |
| 노이즈가 컬러 스노우로 보임 | feTurbulence의 알파도 노이즈 | `feColorMatrix`로 A를 1 상수화 |
| 노이즈에 격자 사각형이 보임 | `seed`가 514/1977/2337/4777/8032/9615 등 | seed 변경 |
| 요소가 통째로 사라짐 | `url(#id)` 오타 (레거시 엔진) | ID 확인 |
---
## 5. CSS 레이아웃 부작용 (놓치기 쉬운 것)
`filter``none`이 아닌 값을 가지면 그 요소는:
1. **새 스태킹 컨텍스트**를 만든다 → `z-index` 계층이 갇힌다.
2. **`position: fixed` / `absolute` 자손의 컨테이닝 블록이 된다** → 하위의 `position: fixed` 모달이 뷰포트가 아니라 그 요소 기준으로 배치된다.
`backdrop-filter`, `perspective`, `will-change: transform`, `contain: layout|paint|strict|content`, `content-visibility: auto`도 동일한 부작용을 갖는다.
> 이 동작은 호환성 때문에 표준화된 것이다. 모바일 WebKit이 먼저 하고 Chrome 22에서 따라갔으며, 2015년 워킹그룹 결의를 거쳐 Firefox가 마지막으로 합류했다.
**실무 영향**
```html
<!-- 카드에 질감 필터를 걸었더니, 카드 안의 드롭다운이 화면 밖으로 못 나간다 -->
<div class="card" style="filter: url(#texture)">
<div class="dropdown" style="position: fixed">...</div> <!-- 카드 기준으로 배치됨 -->
</div>
```
**회피**
- 필터를 콘텐츠 컨테이너가 아니라 **별도의 표면(surface) 레이어**에만 건다 (레시피 부록 B의 패턴).
- 오버레이/모달은 Portal로 `<body>` 직속에 렌더한다.
- DevTools는 "이 요소가 컨테이닝 블록을 만든다"를 시각적으로 표시해주지 않는다. 증상(모달 위치가 이상함)에서 역추적하는 수밖에 없다.
---
## 6. 브라우저별 알려진 이슈
### 6.1 Safari / WebKit
| 이슈 | 영향 |
|---|---|
| `backdrop-filter: url()` 미지원 | Liquid Glass 계열 전부 폴백 필요 |
| `feImage`**외부 SVG 파일** 참조 미지원 | data URI로 우회 |
| `feImage` **fragment 참조**(`href="#id"`)는 Safari **만** 지원 | 크로스브라우저에서는 쓸 수 없음 |
| `feImage`가 체인에 있으면 **HTML 요소에 필터 적용 실패** 보고 | SVG 콘텐츠에만 적용하거나 폴백 |
| 대형 요소에 필터를 걸면 고해상도에서 **아예 렌더되지 않는** 경우 보고 | 필터 적용 면적 제한 |
| SVG `<filter>`보다 CSS 단축 필터가 훨씬 안정적 | 가능하면 CSS 단축 사용 |
| `feGaussianBlur``edgeMode`**Safari만 지원** | 크로스브라우저에서는 사용 불가 |
### 6.2 Firefox
| 이슈 | 영향 |
|---|---|
| `backdrop-filter: url()` 미지원 (기능 요청 계류 중) | 폴백 필요 |
| `feTurbulence` 성능 이슈 장기 보고 | 큰 면적 금지 |
| 하드웨어 가속 활성 시 필터 미동작 버그 보고 | 사용자 환경에 따라 효과가 안 보일 수 있음 |
| `feComposite operator="arithmetic"` 시각 아티팩트 버그 보고 | 대안 연산자 고려 |
| 블러 반경 상한(≈100px) | 큰 블러는 다른 방법으로 |
| `feGaussianBlur edgeMode` 미지원 | — |
### 6.3 모바일 전반
- 큰 면적 필터 + 애니메이션 = **배터리 소모 + 열 스로틀링**. 스로틀링이 걸리면 페이지 전체가 느려진다.
- 일부 안드로이드 GPU는 필터 가속을 전혀 못 한다.
- 저가 기기에서 `feTurbulence` 애니메이션은 사실상 사용 불가.
- GSAP 포럼 등에서 "데스크톱은 멀쩡한데 모바일에서 feTurbulence가 죽는다"는 사례가 반복 보고된다.
### 6.4 feTurbulence seed 아티팩트
특정 `seed` 값에서 노이즈에 **격자 사각형**이 생긴다. 보고된 값: `514, 1977, 2337, 4777, 8032, 9615` 등.
- 원인은 SVG 스펙의 참조 구현에 있는 정수 나눗셈 버그다. gradient 테이블의 두 값이 모두 0이 되는 경우가 생긴다.
- Chrome, Firefox, Batik, Inkscape 등 독립 구현들이 스펙을 그대로 따랐기 때문에 **모두 동일하게 재현**된다.
- **처방**: seed는 무작위로 생성하지 말고, 1~50 정도의 작은 값 중 눈으로 확인한 것을 하드코딩하라.
---
## 7. 접근성
### 7.1 `prefers-reduced-motion`
애니메이션되는 필터는 전정계 장애가 있는 사용자에게 실제로 불편을 준다. **모든 애니메이션 필터에 감소 모션 처리를 넣어라.**
```css
/* 방법 1: 필터 자체를 끈다 */
@media (prefers-reduced-motion: reduce) {
.wave, .squiggle-art, .glitch-box { filter: none; }
}
/* 방법 2: 정적 버전으로 대체 */
@media (prefers-reduced-motion: reduce) {
.pulse-card { filter: none; box-shadow: 0 0 0 3px #22d3ee; }
}
```
**SMIL은 CSS 미디어쿼리로 끌 수 없다.** JS로 처리해야 한다.
```js
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
function applyMotionPreference() {
const svgRoot = document.querySelector('#filterDefs');
if (!svgRoot) return;
if (mq.matches) {
// SMIL 타임라인 정지
if (typeof svgRoot.pauseAnimations === 'function') svgRoot.pauseAnimations();
} else {
if (typeof svgRoot.unpauseAnimations === 'function') svgRoot.unpauseAnimations();
}
}
applyMotionPreference();
mq.addEventListener('change', applyMotionPreference);
```
> **미검증**: `pauseAnimations()`/`unpauseAnimations()`는 `SVGSVGElement`의 표준 메서드지만 이 세션에서 실측하지 않았다. SMIL을 아예 쓰지 않고 JS 타이머로 어트리뷰트를 바꾸는 방식(레시피 11)이 제어가 확실하다.
> ⚠️ SVG를 `<img>`나 CSS `background-image`로 넣으면 브라우저가 샌드박스 처리해 **내부의 `prefers-reduced-motion` 미디어쿼리가 평가되지 않는다.** 인라인 SVG를 쓰거나, 애니메이션을 바깥 CSS에서 제어하라.
### 7.2 텍스트 가독성
- 왜곡 필터를 텍스트에 걸면 **판독성이 떨어진다.** `feDisplacementMap scale`은 텍스트에서 4~8을 넘기지 마라.
- 필터는 **DOM 텍스트를 그대로 유지**한다 — 선택·복사·검색·스크린리더 접근은 살아있다. 이것이 이미지로 굽는 것보다 나은 결정적 이유다.
- 듀오톤/포스터화로 **대비가 낮아질 수 있다.** 텍스트 위에 적용한다면 WCAG 대비비를 실제 렌더 결과로 다시 측정하라.
- 색수차(RGB split)는 난독증·시각 피로가 있는 사용자에게 특히 읽기 어렵다. 본문 텍스트에는 쓰지 마라.
### 7.3 필터 정의 SVG의 접근성
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<defs><filter id="...">...</filter></defs>
</svg>
```
- `aria-hidden="true"`: 스크린리더가 빈 그래픽으로 읽지 않게.
- `focusable="false"`: 구형 IE/Edge에서 탭 순서에 들어가지 않게.
- `width="0" height="0"` + `position:absolute`: 레이아웃에 영향 없음.
---
## 8. 폴백 · 저사양 대응 전략
### 8.1 계층적 설계 (권장)
```
Layer 0 — 필터 없이도 완성된 디자인 (색, 그림자, 그라디언트)
Layer 1 — CSS 단축 필터로 얹는 개선 (blur, saturate, drop-shadow)
Layer 2 — SVG 필터로 얹는 개선 (질감, 굴절, 색수차)
```
각 레이어가 없어도 아래 레이어가 완결되어야 한다. **필터를 지웠을 때 깨지는 디자인은 잘못 설계된 것이다.**
### 8.2 저사양 기기 감지
```js
function shouldDisableHeavyFilters() {
// 1) 사용자 선호
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
// 2) 하드웨어 힌트 (모든 브라우저가 제공하지는 않음)
const cores = navigator.hardwareConcurrency; // Chromium/Firefox
const memory = navigator.deviceMemory; // Chromium only, GB 단위
if (typeof cores === 'number' && cores <= 4) return true;
if (typeof memory === 'number' && memory <= 4) return true;
// 3) 네트워크 절약 모드 (Chromium)
const conn = navigator.connection;
if (conn && conn.saveData) return true;
// 4) 좁은 화면 = 대체로 모바일
if (window.matchMedia('(max-width: 640px)').matches) return true;
return false;
}
if (shouldDisableHeavyFilters()) {
document.documentElement.classList.add('reduce-effects');
}
```
```css
/* 무거운 필터를 값싼 대체물로 교체 */
.reduce-effects .paper-surface { filter: none; background: #e8e0d0; }
.reduce-effects .squiggle-art { filter: none; }
.reduce-effects .prism-pill { backdrop-filter: blur(10px) saturate(1.4); }
.reduce-effects .grainy::after { opacity: 0.25; } /* 그레인은 싸니 약하게 유지 */
```
> `navigator.hardwareConcurrency`/`deviceMemory`는 Chromium에서 실측 확인했다(각각 24, 32 반환). Safari는 `deviceMemory`를 제공하지 않으므로 `typeof` 가드가 필수다.
### 8.3 뷰포트 밖 / 백그라운드 탭에서 정지
애니메이션 필터에는 필수다.
```js
function gateAnimation(targetEl, start, stop) {
let visible = false;
let focused = !document.hidden;
function sync() { (visible && focused) ? start() : stop(); }
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => {
visible = entries[0].isIntersecting;
sync();
}, { threshold: 0 }).observe(targetEl);
} else {
visible = true;
}
document.addEventListener('visibilitychange', () => {
focused = !document.hidden;
sync();
});
sync();
}
```
### 8.4 "구워서 쓰기" — 가장 확실한 최적화
정적 텍스처(종이 질감, 그레인 패턴, 리소 그레인)는 **한 번 렌더해서 PNG/WebP로 저장**하고 `background-image`로 쓰는 것이 압도적으로 싸다.
- 필터 체인이 아무리 길어도 런타임 비용은 0이 된다.
- 해상도 독립성을 잃지만, 노이즈/텍스처는 애초에 해상도 독립성이 필요 없다.
- 512×512 타일을 만들어 `background-repeat`로 깔면 파일도 작다.
- CSS-Tricks의 패턴 생성 글에서도 복잡한 필터 패턴은 "JPEG/PNG로 서빙해 사용자 CPU를 아끼라"고 권한다.
**data URI vs 요소 필터** — 이미 §02 레시피 02/03에서 쓴 방식이다. `background-image: url("data:image/svg+xml,...")`**한 번만 래스터화되어 캐시**되므로, 같은 노이즈를 `filter: url(#noise)`로 거는 것보다 훨씬 싸다. **노이즈는 언제나 data URI 배경으로.**
---
## 9. 측정과 디버깅
### 9.1 Chrome DevTools
1. **Performance 패널** → 녹화 → "Rendering" 트랙에서 Paint/Composite 시간 확인. 필터가 문제면 Paint가 길어진다.
2. **Rendering 탭** → "Paint flashing" 켜기 → 필터 영역이 매 프레임 초록색으로 깜빡이면 재계산 중이다.
3. **Rendering 탭** → "Layer borders"로 합성 레이어 승격 여부 확인.
4. **CPU throttling 4×/6×** 로 저사양 기기 시뮬레이션. 이것이 가장 현실적인 테스트다.
### 9.2 A/B 비교 방법
필터 하나가 얼마나 비싼지 알고 싶으면 DevTools 콘솔에서 껐다 켜본다.
```js
document.querySelectorAll('.effect').forEach(el => { el.style.filter = 'none'; });
```
FPS가 확 오르면 그 필터가 범인이다.
### 9.3 오프라인 도구
| 도구 | 용도 |
|---|---|
| [yoksel/svg-filters](https://yoksel.github.io/svg-filters/) | 필터 체인을 GUI로 만들고 코드 추출 |
| [yoksel/svg-gradient-map](https://yoksel.github.io/svg-gradient-map/) | 듀오톤/트라이톤 `tableValues` 자동 생성 |
| [fffuel nnnoise](https://www.fffuel.co/nnnoise/) | 노이즈 텍스처 SVG 생성 |
---
## 10. 프로덕션 체크리스트
배포 전에 이 목록을 통과시켜라.
**성능**
- [ ] 필터 영역(`x/y/width/height`)이 필요한 최소 크기인가
- [ ] `numOctaves`가 4를 넘지 않는가
- [ ] 애니메이션되는 필터의 적용 면적이 400×400px 이하인가
- [ ] 애니메이션 필터에 IntersectionObserver / visibilitychange 게이팅이 있는가
- [ ] 필터와 `transform` 애니메이션이 같은 요소에 걸려 있지 않은가
- [ ] `will-change: filter`를 남발하지 않았는가
- [ ] 정적 텍스처를 런타임 필터로 매번 계산하고 있지 않은가 (→ data URI 배경 또는 PNG)
- [ ] CPU throttling 4×에서 스크롤이 부드러운가
**호환성**
- [ ] `color-interpolation-filters`를 의도적으로 설정했는가
- [ ] `feDisplacementMap`의 채널 셀렉터를 명시했는가
- [ ] `feImage`를 쓴다면 data URI인가 (외부 파일/fragment 아님)
- [ ] `backdrop-filter: url()`에 의존한다면 폴백만으로도 완성도가 있는가
- [ ] `edgeMode`, `kernelUnitLength`, `BackgroundImage`, `FillPaint`를 쓰지 않았는가
- [ ] `feTurbulence` seed가 알려진 문제 값이 아닌가
- [ ] Safari / Firefox / 모바일 실기기에서 눈으로 확인했는가
**접근성**
- [ ] 애니메이션 필터에 `prefers-reduced-motion` 처리가 있는가
- [ ] 필터 정의 SVG에 `aria-hidden="true"`가 있는가
- [ ] 텍스트에 필터를 걸었다면 판독성과 대비비를 확인했는가
- [ ] 필터가 전부 꺼져도 디자인이 성립하는가
**레이아웃**
- [ ] 필터를 건 요소 안에 `position: fixed` 자손이 없는가
- [ ] 필터로 인한 스태킹 컨텍스트가 `z-index` 계층을 깨지 않는가
- [ ] 필터 컨테이너 안에 본문 텍스트가 들어가 있지 않은가