JavaScript에서 CSS 값을 숫자로 안전하게 다루는 방법 — CSS Typed OM과 attributeStyleMap 실전 가이드
애니메이션 로직에서 getComputedStyle(el).width로 읽어온 값에 숫자를 더했더니, 콘솔에 "200px5" 같은 기묘한 문자열이 찍힌 경험 있으신가요? 저도 처음엔 그냥 parseFloat()로 감싸면 되겠지 하고 넘어갔는데, 그게 팀 코드베이스 여기저기에 퍼지다 보니 결국 문제가 생겼습니다. 디자인 시스템 마이그레이션 중에 누군가 parseFloat를 빠뜨린 채로 머지했고, 그 컴포넌트가 특정 레이아웃에서만 소수점 단위로 크기가 어긋나는 버그가 됐습니다. QA에서 재현이 쉽지 않아서 원인 파악까지 꽤 오래 걸렸죠.
이 문제의 뿌리는 기존 CSSOM이 모든 CSS 값을 문자열로 반환한다는 설계에 있습니다. CSS 엔진 내부에서는 숫자와 단위가 분리된 구조체로 관리하면서도, JavaScript에 넘겨줄 때는 "200px" 같은 문자열로 직렬화해서 줍니다. TypeScript를 써도 getComputedStyle의 반환 타입이 string이라 정적 분석만으로는 이 실수를 잡기 어렵습니다.
CSS Typed OM을 사용하면 CSS 값이 단위와 숫자가 분리된 타입 있는 객체로 반환되어, parseFloat 없이 안전한 숫자 연산이 가능합니다. 이 글에서는 element.attributeStyleMap과 CSSUnitValue의 작동 원리부터, 실전 애니메이션 패턴, TypeScript 활용법, 브라우저 지원 현황까지 실무에서 바로 쓸 수 있는 수준으로 살펴봅니다. CSS는 알고 JavaScript도 알지만 CSS Typed OM은 처음 들어보는 프론트엔드 개발자라면 이 글이 딱 맞는 출발점이 될 겁니다.
핵심 개념
기존 CSSOM vs CSS Typed OM
기존 방식을 먼저 짚어야 왜 Typed OM이 필요한지 실감이 납니다.
const el = document.getElementById('box');
// 기존 CSSOM — 문자열 반환
const width = getComputedStyle(el).width; // "200px" (문자열)
// 숫자 연산을 하려면 파싱이 필수
const newWidth = parseFloat(width) + 50; // 200 → 250
el.style.width = newWidth + 'px'; // 다시 문자열로 변환
// 실수하기 쉬운 패턴
const bad = width + 50; // "200px50" — 문자열 연결!CSS Typed OM은 이 레이어를 걷어냅니다. 값이 문자열이 아니라 타입이 있는 JavaScript 객체로 반환됩니다.
// CSS Typed OM — 타입 있는 객체 반환
el.attributeStyleMap.set('width', CSS.px(200));
const typed = el.attributeStyleMap.get('width');
typed.value; // 200 (number)
typed.unit; // "px" (string)
// parseFloat 없이 바로 연산
el.attributeStyleMap.set('width', CSS.px(typed.value + 50)); // 250px두 가지 핵심 인터페이스
CSS Houdini란 브라우저 렌더링 엔진의 내부 API를 JavaScript에 노출하는 저수준 API 집합입니다. CSS Typed OM, Paint Worklet, Properties and Values API 등이 포함되며, W3C에서 명세를 관리합니다.
CSS Typed OM에서 자주 쓰는 인터페이스는 두 가지입니다.
| 인터페이스 | 역할 | 대응 기존 API |
|---|---|---|
element.attributeStyleMap |
인라인 스타일 읽기/쓰기 | element.style |
element.computedStyleMap() |
계산된 스타일 읽기 | getComputedStyle(element) |
처음에 저도 이 둘을 헷갈렸는데, 간단히 기억하는 방법이 있습니다. "직접 설정한 스타일"은 attributeStyleMap, "브라우저가 최종 계산한 스타일"은 computedStyleMap()입니다.
값을 표현하는 두 가지 형태
Typed OM에서 CSS 값은 크게 두 타입으로 표현됩니다.
CSSUnitValue: 단일 단위를 가진 값.CSS.px(42)→{ value: 42, unit: 'px' }CSSMathValue:calc(56em + 10%)처럼 복합 수식을 표현하는 값
CSSMathValue는 calc()나 min() 같은 표현식이 포함된 경우 반환되는데, 이 타입에는 .value 프로퍼티가 없습니다. computedStyleMap()으로 읽은 값을 곧바로 .value로 접근하면 런타임 오류가 날 수 있으니, 이 부분은 아래 "흔한 실수" 섹션에서 다시 다룹니다.
타입이 있는 값을 생성할 때는 CSS factory 메서드를 사용합니다.
CSS.px(42) // CSSUnitValue { value: 42, unit: 'px' }
CSS.em(1.5) // CSSUnitValue { value: 1.5, unit: 'em' }
CSS.percent(50) // CSSUnitValue { value: 50, unit: 'percent' }
CSS.deg(180) // CSSUnitValue { value: 180, unit: 'deg' }
CSS.number(0.5) // CSSUnitValue { value: 0.5, unit: 'number' }
CSS.ms(300) // CSSUnitValue { value: 300, unit: 'ms' }**
StylePropertyMap**은attributeStyleMap이 반환하는 객체 타입입니다.Map과 동일한 인터페이스(get,set,has,delete,keys,entries)를 제공합니다. 저는 처음에 그냥Map처럼 쓰기 시작했는데, 실제로Map과 거의 똑같아서 바로 익숙해질 수 있었습니다.
실전 적용
예시 1: 인라인 스타일 읽고 더하기
가장 기본적인 패턴입니다. 요소의 현재 margin 값을 읽어서 더하는 시나리오인데, 이것만 익혀도 parseFloat 지옥에서 벗어나는 데 충분합니다.
const el = document.querySelector('.card');
// 초기값 설정
el.attributeStyleMap.set('margin-top', CSS.px(10));
// 읽기 → 연산 → 쓰기
// 주의: set()을 직전에 호출했기 때문에 여기서 get()은 null이 아닙니다.
// 인라인 스타일이 없는 요소에서 get()을 호출하면 null이 반환될 수 있습니다.
const current = el.attributeStyleMap.get('margin-top');
console.log(current.value); // 10 (number)
console.log(current.unit); // "px"
el.attributeStyleMap.set('margin-top', CSS.px(current.value + 5));
// margin-top이 15px로 안전하게 설정됩니다.TypeScript strict 환경에서는 get()의 반환 타입이 CSSStyleValue | null이므로, 이렇게 처리하면 안전합니다.
const el = document.querySelector('.card')!;
el.attributeStyleMap.set('margin-top', CSS.px(10));
const current = el.attributeStyleMap.get('margin-top');
if (current instanceof CSSUnitValue) {
// 여기서 current.value는 number로 좁혀집니다.
el.attributeStyleMap.set('margin-top', CSS.px(current.value + 5));
}타입 가드 하나로 null 체크와 타입 좁히기를 동시에 처리할 수 있어서 꽤 깔끔합니다.
예시 2: requestAnimationFrame 애니메이션에서 정밀 계산
애니메이션 루프에서 매 프레임마다 문자열을 파싱하는 건 불필요한 오버헤드입니다. Typed OM을 쓰면 파싱 없이 객체를 CSS 엔진에 직접 전달할 수 있어 코드도 훨씬 깔끔해집니다.
const el = document.querySelector('.moving-box');
let x = 0;
function animate() {
x += 2;
// CSSTranslate는 브라우저 전역 객체입니다. 별도 import 없이 사용할 수 있습니다.
el.attributeStyleMap.set(
'transform',
new CSSTranslate(CSS.px(x), CSS.px(0))
);
if (x < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);기존 방식과 나란히 놓고 보면 차이가 명확합니다.
// 기존 방식 — 매 프레임마다 문자열 생성 + 브라우저 파싱
el.style.transform = `translateX(${x}px)`;
// Typed OM — 객체 직접 전달, 파싱 없음
el.attributeStyleMap.set('transform', new CSSTranslate(CSS.px(x), CSS.px(0)));예시 3: computedStyleMap()으로 계산된 스타일 읽기
getComputedStyle()의 Typed OM 버전입니다. CSS 클래스나 상속으로 결정된 최종 계산값을 타입 있는 형태로 읽을 수 있습니다.
const el = document.querySelector('.text');
const styleMap = el.computedStyleMap();
const fontSize = styleMap.get('font-size');
// CSSUnitValue { value: 16, unit: 'px' }
const lineHeight = styleMap.get('line-height');
// CSSUnitValue { value: 24, unit: 'px' }
// 파싱 없이 바로 연산
const ratio = lineHeight.value / fontSize.value; // 1.5 (number)
console.log(`Line height ratio: ${ratio}`);기존 방식이라면 parseFloat(getComputedStyle(el).fontSize)처럼 써야 했을 부분이 깔끔하게 정리됩니다.
예시 4: CSS Custom Properties와 연동
CSS Properties and Values API와 함께 쓰면 타입 있는 커스텀 프로퍼티를 정의하고 Typed OM으로 안전하게 읽고 쓸 수 있습니다. 테마 시스템이나 애니메이션 변수 관리에 유용한 패턴입니다.
// 주의: CSS.registerProperty()는 Chrome/Edge에서만 지원됩니다.
// Firefox는 CSS Typed OM과 이 API 모두 미지원 상태입니다.
CSS.registerProperty({
name: '--rotation-angle',
syntax: '<angle>',
inherits: false,
initialValue: '0deg'
});
const el = document.querySelector('.spinner');
el.attributeStyleMap.set('--rotation-angle', CSS.deg(45));
const angle = el.attributeStyleMap.get('--rotation-angle');
console.log(angle.value); // 45 (number)
console.log(angle.unit); // "deg"
// 30도씩 증가
el.attributeStyleMap.set('--rotation-angle', CSS.deg(angle.value + 30));CSS.registerProperty()와 CSS Typed OM을 함께 쓰면 Firefox 미지원 범위가 겹치므로, 이 패턴은 Chrome/Edge 전용 환경(사내 대시보드, 크롬 익스텐션, Electron 앱)에서 특히 빛을 발합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 타입 안전성 | 숫자 값이 항상 number로 반환되어 '10' + 5 = '105' 같은 문자열 연결 버그를 구조적으로 차단 |
| 파싱 오버헤드 없음 | JS 객체를 CSS 엔진에 직접 전달하므로 문자열 직렬화/역직렬화 비용이 발생하지 않음 |
| 단위 인식 연산 | 값과 단위가 분리되어 있어 절대 단위 간 변환과 기본 산술 연산이 가능 — px → cm 변환이 필요한 인쇄 레이아웃 도구 같은 곳에서 유용 |
| 값 유효성 보장 | 허용 범위를 벗어난 값을 CSS 엔진 레벨에서 자동 클램핑하여 처리 |
| TypeScript 친화적 | CSSUnitValue, CSSMathValue 등 구체적인 타입으로 좁힐 수 있어 정적 분석 품질이 향상 |
솔직히 실무에서 가장 체감되는 건 타입 안전성입니다. getComputedStyle이 string을 반환하는 한, TypeScript를 써도 parseFloat를 빠뜨리는 실수를 정적으로 잡을 방법이 없습니다. 그 한 줄짜리 구조적 보장이 생각보다 큰 차이를 만듭니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 브라우저 지원 미완성 | Firefox가 아직 CSS Typed OM을 미구현 상태 (Bugzilla #1278697 추적 중) | csstools/css-typed-om 폴리필 적용 또는 점진적 향상 패턴 사용 |
| 실제 성능 이점은 조건부 | 이론상 파싱 비용이 없지만, Chrome·Safari 현재 구현에서는 내부 변환 비용이 여전히 발생할 수 있어 이점이 항상 실현되지는 않음 | 성능 크리티컬 경로에서는 직접 벤치마크 후 적용 여부 결정 |
| API 학습 곡선 | element.style 대비 새로운 인터페이스와 factory 메서드 학습 필요 |
MDN 가이드와 이 글의 예시로 빠르게 적응 가능 |
| 폴리필 한계 | csstools/css-typed-om이 전체 명세를 완전히 구현하지 않음 |
사용하는 기능이 폴리필에서 지원되는지 사전 확인 필요 |
점진적 향상 패턴:
typeof CSS !== 'undefined' && CSS.px처럼 기능 감지 후 Typed OM을 사용하고, 미지원 환경에서는 기존 CSSOM으로 폴백하는 방식으로 안전하게 도입할 수 있습니다.
실무에서 가장 흔한 실수
-
attributeStyleMap과computedStyleMap()의 혼동 — 인라인 스타일을 쓸 때는attributeStyleMap.set(), 계산된 최종 스타일을 읽을 때는computedStyleMap().get()입니다.attributeStyleMap.get()은 인라인으로 직접 설정된 값만 반환하기 때문에, CSS 클래스나 상속으로 적용된 스타일은null이 반환될 수 있습니다. -
CSSMathValue결과에서.value직접 접근 —computedStyleMap()이 항상CSSUnitValue를 반환하지는 않습니다.calc()나min()같은 표현식이 포함된 경우CSSMathValue가 반환되는데, 이 타입에는.value프로퍼티가 없습니다.instanceof CSSUnitValue로 타입을 먼저 확인한 뒤 접근하는 것을 권장합니다. -
Firefox 미지원 확인 없이 프로덕션 적용 — 현재 Firefox에서는 Typed OM이 동작하지 않습니다. 폴리필이나 기능 감지 없이 사용하면 Firefox 사용자 전체에서 오류가 발생합니다.
typeof CSS !== 'undefined' && CSS.px로 지원 여부를 먼저 확인하는 것이 안전합니다.
마치며
이 글을 읽고 나면, 코드베이스에서 parseFloat(getComputedStyle(...).someProperty) 패턴이 보일 때마다 그 자리가 Typed OM으로 교체할 수 있는 지점임을 알아볼 수 있을 겁니다. CSS Typed OM은 "문자열로 CSS를 다루던 방식"에서 "타입 있는 객체로 CSS를 다루는 방식"으로 전환점을 만들어 주며, 문자열 파싱 없이도 구조적으로 안전한 스타일 조작 코드를 작성할 수 있게 해줍니다.
Firefox 지원 미완성이라는 현실적 제약이 있지만, Chrome/Edge 전용 환경이나 폴리필을 함께 쓴다면 지금 바로 도입할 만한 가치가 충분합니다.
지금 바로 시작해볼 수 있는 3단계:
-
Chrome DevTools 콘솔에서
document.body.attributeStyleMap.set('background', new CSSKeywordValue('red'))를 입력해 보면, Typed OM이 실제로 동작하는 것을 바로 확인할 수 있습니다. -
기존 프로젝트에서
parseFloat(getComputedStyle(패턴을 검색해 마이그레이션 후보를 파악해보면 좋습니다.typeof CSS !== 'undefined' && CSS.px로 지원 여부를 감지한 뒤 점진적 교체가 가능합니다. -
Firefox 지원이 필요한 환경이라면
csstools/css-typed-om폴리필을 설치해볼 수 있습니다.pnpm add @csstools/css-typed-om후 진입점에서 임포트하면 미지원 환경에서도 동일한 API를 사용할 수 있습니다.
다음 글: CSS Houdini의 또 다른 축인 Paint Worklet(CSS Paint API)으로 JavaScript에서 커스텀 배경 이미지를 그리는 방법 —
registerPaint()와 Canvas API의 만남
참고 자료
- CSS Typed Object Model API | MDN Web Docs
- Using the CSS Typed Object Model | MDN Guide
- Working with the new CSS Typed Object Model | Chrome for Developers
- CSS Typed OM Level 1 | W3C 공식 명세
- csstools/css-typed-om | GitHub (폴리필)
- The Typed Object Model | CSS-Tricks
- A Practical Overview Of CSS Houdini | Smashing Magazine
- Firefox CSS Typed OM 구현 추적 | Bugzilla #1278697