filter 와 backdrop-filter 의 경우, Lightning CSS 는 접두사가 붙은 선언과 붙지 않은 선언을 하나로 병합하면서 마지막에 쓴 쪽의 접두사 플래그만 남긴다. 그래서 backdrop-filter 다음에 -webkit-backdrop-filter 를 쓰면 WebKit 전용으로 컴파일된다. Chrome 은 -webkit-backdrop-filter 를 아예 지원하지 않기 때문에, 소스는 멀쩡해 보이는데 프로덕션에서 blur 만 조용히 사라진다. 접두사가 붙는 다른 속성 대부분은 똑같이 해도 멀쩡한데, 바로 그 점 때문에 이 문제는 놓치기 쉽다.
증상
내 템플릿 중 하나의 sticky 헤더에 프로스티드 글래스 효과를 넣어뒀다. 반투명 배경과 그 뒤의 blur. 내 컴퓨터에서는 몇 주 동안 잘 동작했다. 그런데 배포된 사이트를 Chrome 으로 열어보니 헤더가 그냥 밋밋한 반투명이었다. blur 가 없었다. 그 아래로 콘텐츠가 선명한 채로 스크롤됐다.
소스는 그렇지 않다고 말하고 있었다. 규칙은 globals.css 에 그대로 있었고, 요소에 클래스도 붙어 있었고, 미디어 쿼리가 가리고 있지도 않았고, @supports 게이트도 없었다. DevTools 에서도 요소가 그 규칙에 매칭되는 것으로 나왔다.
내가 짐작한 것
CSS 명시도 문제이거나 스태킹 컨텍스트 문제라고 짐작했다. backdrop-filter 는 뒤에 무엇이 있는지, 그리고 컨테이닝 블록을 만드는 조상 요소가 있는지에 민감한 것으로 유명하니까. 부모 요소의 transform 과 filter 를 뒤지는 데 부끄러울 만큼 많은 시간을 썼다.
틀린 접근이었고, 틀린 이유는 내가 소스를 읽고 있었지 브라우저가 내려받은 스타일시트를 읽고 있지 않았기 때문이다.
실제 동작 원리
템플릿은 Next.js 16.2.10 으로 빌드한다(package.json 에 고정해 뒀다). tailwindcss 와 @tailwindcss/postcss 는 ^4 로 선언돼 있고 내 설치 환경에서는 4.3.2 로 해석된다. PostCSS 설정은 이게 전부다:
// launch-template/postcss.config.mjs
const config = { plugins: { "@tailwindcss/postcss": {} } };
export default config;Tailwind v4 는 Lightning CSS 를 통해 CSS 를 컴파일한다 — 내 설치 환경에서는 lightningcss@1.32.0. Lightning CSS 는 -webkit-backdrop-filter 와 backdrop-filter 를 서로 무관한 두 속성으로 다루지 않는다. 둘 다 벤더 접두사 플래그 집합을 가진 하나의 내부 속성으로 파싱한 다음, 살아남은 플래그만 다시 출력한다.
같은 속성에 선언이 두 개면 캐스케이드 규칙대로 나중 것이 이긴다. 그런데 "이기는" 대상은 접두사 플래그까지 포함한 선언 전체다. 표준 속성을 먼저 쓰고 WebKit 쪽을 나중에 쓰면 표준 플래그는 사라진다.
확인하려고 컴파일러를 직접 돌려봤다. 빌드가 쓰는 것과 같은 lightningcss@1.32.0 이다:
### Tailwind v4 default targets (safari 16.4, chrome 111, firefox 128)
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
standard alone => .c { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }마지막 줄을 잘 읽어보자. 표준 속성만 작성하면 둘 다 나온다. Safari 에 접두사가 필요하다는 걸 컴파일러가 이미 알고 알아서 붙여준다. 안전장치랍시고 덧붙인 두 번째 선언은 안전을 더한 게 아니라 표준 속성을 없애버렸다.
WebKit 을 선호해서가 아니라, 정말로 마지막 플래그가 이기는 구조다. 타깃을 아예 설정하지 않으면 병합이 반대 방향으로 일어난다:
### no targets at all
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { backdrop-filter: blur(16px); }문제는 filter 계열이지 모든 속성이 아니다
처음엔 코드베이스에서 손으로 쓴 접두사를 전부 걷어낼 생각이었다. 그 전에 표준 다음 webkit 이라는 같은 조합을 여러 속성에 대해 컴파일러에 통과시켜 봤는데, 결과를 보고 멈췄다:
filter => -webkit-filter: blur(4px) ← standard GONE
backdrop-filter => -webkit-backdrop-filter: blur(16px) ← standard GONE
mask-image => both emitted
user-select => both emitted
background-clip => both emitted
hyphens => both emitted
box-decoration-break => both emitted
appearance => appearance: none (collapses to standard)
clip-path, transform => standard only, prefix dropped표준 속성을 잃는 건 filter 계열뿐이다. 나머지는 두 형태를 모두 출력하거나 표준 쪽으로 병합되기 때문에, 거기서의 중복 접두사는 정말로 무해하다.
둘 중에서는 filter 가 더 고약하다고 볼 수도 있다. 이 타깃 설정에서 filter: blur(4px) 만 쓰면 filter: blur(4px) 로 컴파일된다 — 어떤 타깃도 접두사를 필요로 하지 않으니까 — 따라서 그 뒤에 -webkit-filter 를 쓰면 잘 동작하던 선언을, 지원 대상 브라우저 중 아무도 요구하지 않은 선언으로 바꿔치기하는 셈이다.
이 일관성 없음이 진짜 함정이다. user-select 를 확인하고, 출력에 두 형태가 다 있는 걸 보고, 방어적으로 쓴 접두사는 괜찮다고 결론 내리고는, 정작 그렇지 않은 두 속성을 확인해 볼 생각은 하지 않게 된다.
왜 하필 Chrome 에서 아무것도 안 그려지는가
Blink 가 WebKit 계열이니 Chrome 도 -webkit-backdrop-filter 를 레거시 별칭으로 받아주겠거니 반쯤 짐작하고 있었다. 아니었다. Chrome 151 에서는:
CSS.supports('backdrop-filter', 'blur(16px)') // true
CSS.supports('-webkit-backdrop-filter', 'blur(16px)') // false인라인으로 지정해도 그냥 버려진다 — 표준 속성으로 매핑조차 되지 않는다. 그래서 실제 요소에 직접 찔러봤다:
const mk = (css) => { /* build a div, apply css, read computed style */ };
mk('-webkit-backdrop-filter:blur(16px)') // "none"
mk('-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)') // "blur(16px)"
mk('backdrop-filter:blur(16px)') // "blur(16px)""none". 컴파일러가 내보낸 선언은 Chrome 이 완전히 무시하는 선언이었다. Safari 에서는 계속 잘 동작했고, 그래서 이렇게 오래 리뷰를 통과해 버린 것이다.
확실하게 판정하는 진단법
소스 그만 읽자. 브라우저가 실제로 받은 바이트를 읽자:
# find the stylesheet the deployed page links
curl -sL https://launch.violettadev.com/en | grep -oE '[^"]+\.css' | sort -u
# then count the two forms in it
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '-webkit-backdrop-filter' | wc -l
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '[^-]backdrop-filter' | wc -l두 숫자가 맞지 않으면 선언이 병합돼 사라진 것이다. 현재 빌드에서는 맞는다 — 11 과 11. 배포된 스타일시트에서 그대로 가져온, 실제로 나가고 있는 nav 규칙은 이렇다:
.lv-nav{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}그리고 이걸 얻으려고 내가 쓴 코드는 launch-template/src/app/globals.css 의 이것이다:
.lv-nav {
position: sticky; top: 0; z-index: 100;
height: 64px; display: flex; align-items: center;
border-bottom: 1px solid transparent; transition: all .2s;
background: color-mix(in srgb, var(--bg) 80%, transparent);
backdrop-filter: blur(16px);
}들어간 건 선언 하나, 나온 건 둘. 오버레이 변형에서는 transition 속성 목록에도 컴파일러가 알아서 접두사를 붙여줬다: transition:...,-webkit-backdrop-filter .4s,backdrop-filter .4s.
이미 깨진 페이지에서 인과관계를 확인하고 싶다면 DevTools 에서 반대로 해보면 된다. 요소를 선택하고 Styles 패널에 backdrop-filter: blur(16px) 를 손으로 추가한다. blur 가 바로 나타나면 컴파일러가 표준 속성을 먹은 것이다.
다시 안 겪으려고 한 조치
손으로 쓴 backdrop-filter 쌍을 제거하고, 유혹이 생기는 자리에 주석을 남겼다. 템플릿 다섯 개 중 네 개의 globals.css 에 넣었다(문구는 동일하고, 각 파일 폭에 맞춰 줄바꿈만 조정했다):
/* Do NOT hand-write `-webkit-backdrop-filter` here — or anywhere else in this
file. The build (Tailwind v4 → Lightning CSS) treats the prefixed and
unprefixed property as ONE declaration and keeps only the prefix it saw LAST,
so an authored `backdrop-filter` + `-webkit-backdrop-filter` pair silently
ships as webkit-only and Chrome renders no blur at all. Author the standard
property alone; the build re-adds `-webkit-` when targets need it. */주석은 알림이지 가드가 아니다. 실제로 이걸 잡아내는 건, 같은 블록 안에서 접두사가 붙은 형태와 붙지 않은 형태로 동시에 등장하는 속성을 규칙 본문에서 스캔하는 스크립트다 — 지금은 CI 에 물려두지 않고 필요할 때 수동으로 돌린다. 템플릿 다섯 개, CSS/TSX/TS 파일 933 개 전체에서 filter 나 backdrop-filter 쌍은 0 개다. 무는 건 그 둘이다.
다른 속성의 쌍은 여전히 발견된다. 한 템플릿에는 mask-image 쌍 세 개와 background-clip 쌍 두 개, 다른 템플릿에는 user-select 쌍 두 개와 appearance 쌍 하나가 있다. 결정하기 전에 각각 컴파일러에 통과시켜 보고 그대로 뒀다 — 전부 두 형태를 다 출력한다. 중복일 뿐 위험하지는 않다. 남아 있는 -webkit- 선언들은 애초에 표준 대응물이 없는 속성들이다: -webkit-font-smoothing, ::-webkit-scrollbar, -webkit-text-size-adjust, ::-webkit-details-marker, -webkit-overflow-scrolling, -webkit-user-drag.
같은 가드를 만든다면 모든 쌍에서 실패하게 만들지는 말자 — 진짜 문제인 두 경우가 무해한 것들 더미에 파묻힌다. filter 와 backdrop-filter 에서는 빌드를 깨뜨리고, 나머지는 경고만 내자.
일반화한 규칙, 그리고 남겨둘 만한 부분은 이거다: 요즘 CSS 파이프라인은 알아서 접두사를 붙여주며, 손으로 쓴 접두사는 중복 보험이 아니라 진짜 선언을 덮어쓸 수 있는 여분의 선언이다. Tailwind v4 로 마이그레이션하면서 2019년식 방어용 접두사로 가득한 스타일시트를 물려받았다면, 그중 대부분은 그냥 짐일 뿐이지만 filter 와 backdrop-filter 짜리는 살아 있는 버그다 — 그리고 그것들은 소스에서도 안 보이고, 마침 테스트 중인 브라우저에서도 안 보이고, 테스트 스위트에서도 안 보인다. 드러나는 곳은 브라우저가 내려받은 스타일시트뿐이다.
이건 내가 launch 에 적용해 배포하는 패턴이다 — 라이브 데모는 launch.violettadev.com.