CSS 및 레이아웃애니메이션

CSS Houdini 활용: 일반 CSS에서는 불가능한 커스텀 효과 만들기

CSS Houdini 활용: 일반 CSS에서는 불가능한 커스텀 효과 만들기

겉보기에는 간단해 보이는 것을 하나 시도해 봅시다. hover 시 conic-gradient()의 각도를 부드럽게 애니메이션시키는 일입니다. 커스텀 프로퍼티 --kat: 0deg를 정의하고, transition: --kat 0.4s를 추가한 뒤 마우스를 올려봅니다. 그런데 아무 일도 일어나지 않습니다. 그라디언트는 마치 transition이 아예 존재하지 않는 것처럼 순간적으로 바뀌어 버립니다. 이건 코드의 버그가 아닙니다. 브라우저 입장에서 --kat은 그저 불투명한 문자열일 뿐입니다. 그것이 각도라는 사실을 알지 못하니 0deg180deg 사이를 보간할 방법이 없는 것입니다. 브라우저는 숫자, 색상, 길이는 보간할 수 있습니다. 그 타입을 알고 있기 때문입니다. 커스텀 프로퍼티는 내장 CSS 프로퍼티와 달리 아무런 타입도 갖지 않습니다. 바로 이 빈틈을 CSS Houdini가 채워줍니다.

Houdini란 실제로 무엇인가

흔한 오해가 하나 있습니다. Houdini는 단일 기술도, 심지어 단일 명세도 아닙니다. W3C의 여러 독립적인 제안들을 하나로 묶은 우산 같은 개념으로, 그 밑에 깔린 아이디어는 하나입니다. 렌더링 엔진의 일부(값 파싱, 레이아웃, 페인팅, 애니메이션)를 개발자에게 노출하는 것입니다. 예전에는 이 부분들이 개발자에게 완전히 감춰져 있었습니다. 필요한 기능을 정확히 수행하는 완성된 프로퍼티가 CSS에 추가되기를 기다리는 대신, 그 과정의 특정 단계에 접근할 수 있는 저수준 후크를 얻는 셈입니다.

이러한 후크는 worklet이라는 형태로 구현됩니다. Web Worker와 개념적으로 가깝지만 렌더링 파이프라인 전용으로 설계된 작은 JavaScript 모듈입니다. Worklet은 메인 스레드 밖에서 동작하며 window, document, DOM에 접근할 수 없고, 엄격하게 정의된 계약(예: paint(ctx, size, properties))을 통해서만 통신합니다. 이는 명세 작성자들이 게을러서 생긴 제약이 아니라 의도된 아키텍처 결정입니다. 이러한 격리 덕분에 엔진은 worklet을 별도의 스레드에서 병렬로 실행하고 결과를 캐시할 수 있으며, 여러분의 코드가 건드려서는 안 될 것을 건드릴 위험도 없습니다. worklet 안에서 Date, performance.now(), fetch를 찾을 수 없는 이유도 바로 이 같은 격리 때문입니다. 정밀한 시간 정보와 네트워크 접근을 제한하는 것은 페이지를 렌더링하는 코드 수준에서 타이밍 공격이나 핑거프린팅을 의도적으로 방지하기 위한 조치입니다.

이 우산 아래 있는 개별 부분들은 오늘날 전혀 다른 단계에 놓여 있습니다. 어떤 것은 오래전부터 Baseline의 일부이고, 어떤 것은 Chrome에서만 동작하며, 또 어떤 것은 10년이 지나도록 여전히 실험 단계입니다. 이 구분은 매우 중요한데, Houdini를 소개하는 대부분의 글은 이를 생략한 채 전체를 마치 하나의 완성된, 바로 쓸 수 있는 기술처럼 다룹니다.

오늘날 실제로 쓸 가치가 있는 유일한 Houdini: @property

CSS Properties and Values API는 이 글 서두에서 언급한 문제를 정확히 해결합니다. 구체적인 타입(syntax), 초기값, 상속 여부를 지정해 커스텀 프로퍼티를 등록하면, 그 순간부터 브라우저는 이를 문자열이 아니라 완전한 애니메이션 가능한 값으로 취급합니다.

html

<div class="ring"></div>

css

@property --kat {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.ring {
width: 200px;
height: 200px;
border-radius: 50%;
background: conic-gradient(from var(--kat), #ff4d4d, #4d79ff, #4dff88, #ff4d4d);
transition: --kat 0.6s ease;
}
.ring:hover {
--kat: 180deg;
}

이 예제는 일반 CSS로는 할 수 없는 일을 실제로 해냅니다. @property가 없다면 위의 transition은 이 글 서두의 시나리오와 똑같이 그냥 동작하지 않았을 것입니다. syntax: '<angle>'은 브라우저에게 --kat이 각도임을 알려주어 부드럽게 보간할 수 있게 하고, inherits: false는 자식 요소로 의도치 않게 상속되는 것을 막아줍니다. 이는 커스텀 프로퍼티에서 찾아내기 어려운 버그의 원인이 되곤 합니다.

같은 효과는 JavaScript 수준에서도 얻을 수 있습니다. 이는 스타일시트에 고정적으로 작성하는 대신 프로퍼티를 동적으로(예: 데이터를 기반으로 생성하여) 등록할 때 의미가 있습니다.

javascript

if ('registerProperty' in CSS) {
CSS.registerProperty({
name: '--kat',
syntax: '<angle>',
inherits: false,
initialValue: '0deg',
});
}

이는 오늘날 Baseline 상태를 갖춘 Houdini의 유일한 부분입니다. Chrome, Firefox(128 버전부터), Safari(16.4부터)에서 폴리필이나 @supports 없이 네이티브로 동작합니다. Houdini 전체에서 딱 하나만 기억하고 적용해야 한다면, 바로 이것입니다.

CSS Paint API: 실제로 그림을 그리는 worklet

Paint API를 사용하면 backgroundpaint() 함수로 대체할 수 있습니다. 이 함수는 Canvas 2D와 비슷하지만 기능이 축소된 인터페이스(PaintRenderingContext2D)를 사용해 요소 위에 직접 그림을 그립니다. fillText, drawImage, 픽셀 읽기는 사용할 수 없습니다. 이 방식을 일반적인 <canvas> 그리기와 구별 짓는 것은 inputProperties입니다. worklet은 자신이 관찰하고 싶은 커스텀 프로퍼티를 선언하고, 그중 하나라도 변경될 때만 자동으로 다시 그려집니다. 이벤트를 리스닝하는 JavaScript 코드는 단 한 줄도 필요 없습니다.

아래는 경고용 대각선 줄무늬 패턴 예제로, 그 각도는 앞서 등록했던 것과 같은 종류의 프로퍼티(정확히는 그 지역 변형인 --stripe-angle)로 제어됩니다. 이는 @property와 Paint API가 자연스럽게 서로를 보완하는 방식을 보여줍니다.

html

<div class="hazard"></div>

css

@property --stripe-angle {
syntax: '<angle>';
inherits: false;
initial-value: 45deg;
}
.hazard {
width: 100%;
height: 120px;
--stripe-angle: 45deg;
background: paint(hazardStripes);
transition: --stripe-angle 0.4s ease;
}
.hazard:hover {
--stripe-angle: 135deg;
}

javascript

// main.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('hazard-stripes.js');
}

javascript

// hazard-stripes.js
class HazardStripesPainter {
static get inputProperties() {
return ['--stripe-angle'];
}
paint(ctx, size, properties) {
const angle = properties.get('--stripe-angle').to('deg').value;
const stripeWidth = 24;
const { width, height } = size;
const diagonal = Math.sqrt(width ** 2 + height ** 2) * 2;
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((angle * Math.PI) / 180);
ctx.translate(-diagonal, -diagonal);
let stripeIndex = 0;
for (let x = 0; x < diagonal * 2; x += stripeWidth) {
ctx.fillStyle = stripeIndex % 2 === 0 ? '#111111' : '#f5c400';
ctx.fillRect(x, 0, stripeWidth, diagonal * 2);
stripeIndex++;
}
ctx.restore();
}
}
registerPaint('hazardStripes', HazardStripesPainter);

주목할 부분은 properties.get('--stripe-angle').to('deg').value입니다. 이것이 바로 같은 API 계열의 또 다른 구성 요소인 CSS Typed OM입니다. 문자열을 직접 파싱하는(parseFloat으로 "deg"를 잘라내는) 대신, 필요한 단위로 명시적으로 변환할 수 있는 타입이 지정된 CSSUnitValue 값을 얻게 됩니다. 그려지는 줄무늬의 각도가 오직 inputProperties에 선언된 커스텀 프로퍼티에만 의존하기 때문에, 엔진은 언제 요소를 다시 그려야 하는지 정확히 알 수 있습니다. 페이지의 다른 곳에서 관련 없는 CSS 프로퍼티가 바뀌어도 paint()가 다시 실행되지는 않습니다.

Layout API와 Animation Worklet: 2026년의 현실

여기서부터는 Houdini를 소개하는 대부분의 글이 침묵하는 영역입니다. 같은 명세의 세 번째 축인 CSS Layout API는 자신만의 레이아웃 알고리즘(자체적인 display: grid에 해당하는 것)을 정의할 수 있게 해주는데, 2015년부터 기획안으로 존재해 왔음에도 10년이 지난 지금까지 여전히 실험 단계에 머물러 있습니다. 어떤 안정 버전 브라우저도 이를 네이티브로 구현하지 않았습니다. 플래그를 켜거나 Chrome의 테스트용 origin trial을 통해서만 사용할 수 있습니다. display: layout(이름)을 바로 사용할 수 있는 해법처럼 소개하는 글을 만난다면 게시일을 확인해 보세요. 아마도 몇 년 전 origin trial 데모일 가능성이 높으며, 오늘날 사용자의 브라우저에서 실제로 돌아가는 것은 아닙니다.

Animation Worklet은 메인 스레드 밖에서 실행되는, 스크롤에 의해 구동되는 애니메이션을 완전히 제어할 수 있게 해줄 예정이었습니다. 실제로는 Chromium만 이를 지원합니다. 더 중요한 점은, 이 API의 핵심 사용 사례가 더 이상 Houdini를 써야 할 이유가 되지 않는다는 것입니다. CSS가 같은 문제를 해결하는 네이티브 방법을 갖게 되었기 때문입니다. animation-timeline: scroll()을 사용하면 이제 JavaScript 코드 한 줄, worklet 하나 없이도 애니메이션을 스크롤과 연결할 수 있으며, 브라우저 지원 범위도 계속 늘어나고 있습니다. 이는 더 큰 패턴을 잘 보여주는 사례입니다. Houdini가 애초에 만들어진 이유 중 일부는 시간이 지나면서 개발자를 위한 저수준 API로 남는 대신 CSS 명세 자체로 곧바로 흡수되었습니다.

실무에서 언제 의미가 있는가

  • @property는 안전장치 없이 과감하게 사용하세요 – Baseline 상태이므로 @supports도 폴리필도 필요 없습니다.
  • Paint API는 점진적 향상 기법으로 다루세요@supports(background: paint(x))로 감싸고 Firefox를 위한 합리적인 순수 CSS 폴백(예: 일반 그라디언트)을 마련하거나, 효과가 모든 곳에서 반드시 동작해야 한다면 css-paint-polyfill 폴리필을 사용하세요.
  • Layout API를 기반으로 한 프로덕션 기능은 계획하지 마세요 – 실질적인 브라우저 지원이 없는 여전히 실험적인 기술로, 가지고 놀기에는 좋지만 로드맵에 올릴 만한 것은 아닙니다.
  • paint()가 "공짜" CSS가 아니라는 점을 기억하세요 – 이는 선언된 inputProperties가 바뀔 때마다 다시 실행되는 여러분의 JavaScript 코드입니다. 비용이 큰 연산(노이즈 생성, 복잡한 절차적 패턴)은 <canvas>의 렌더링 루프를 최적화할 때와 똑같이 신경 써야 합니다. 자칫하면 레이아웃 메인 스레드에서 매 프레임마다 요소 전체를 다시 그리는 애니메이션을 실수로 만들어버리기 쉽습니다.

마무리

Houdini는 "쓸 것인가 말 것인가"라는 하나의 결정이 아니라, 각기 위험도가 다른 서너 개의 개별적인 결정입니다. 첫째, @property는 실제로 구체적인 문제(커스텀 프로퍼티 애니메이션)를 해결하며, 오늘날 다른 모든 CSS 프로퍼티와 똑같이 안전하게 사용할 수 있습니다. 이는 이 생태계에서 망설임 없이 도입할 가치가 있는 유일한 부분입니다. 둘째, Paint API는 실질적인 힘을 제공합니다(선언된 의존성이 바뀔 때만 다시 그려지는 worklet, 수동 이벤트 리스닝이 필요 없는 방식). 하지만 지원하지 않는 브라우저에 대한 명확한 대비책이 필요합니다. 이는 점진적 향상을 위한 도구이지 핵심 기능을 위한 도구는 아닙니다. 셋째, Layout API와 Animation Worklet은 명세가 존재한다는 사실만으로는 채택이 보장되지 않는다는 것을 잘 보여주는 사례입니다. 몇 년이 지나도 하나는 여전히 구현을 기다리고 있고, 다른 하나는 더 단순한 네이티브 CSS로 부분적으로 대체되었습니다. Houdini에 손을 대기 전에, 여러분에게 필요한 바로 그 부분이 이 세 바구니 중 어디에 속하는지 먼저 확인해 보세요.