
(새로운 공식 CSS 로고를 위해 제출된 로고 샘플들 (출처: CSS has a new official logo))
올해 구글 IO에서도 어김없이 다양한 CSS 기술이 발표되었다. 물론 이번 컨퍼런스의 스포트라이트는 대부분 AI 관련 기술이 가져갔지만, 일반적으로 프론트엔드 개발자 사이에서도 CSS의 새로운 기능은 React나 Next.js의 업데이트만큼 즉각적인 호응을 얻지 못하는 경우가 많다. 기술 발표를 볼 때는 ‘오 이런 기능도 나오네, 유용하겠네’ 하면서도, 속으로는 ‘그래서 저걸 언제 쓸 수 있으려나’ 싶은 생각이 먼저 들기도 한다. CSS 외에 챙겨야 할 것도 산더미라 당장 못 쓰는 기술에는 자연스럽게 관심이 멀어지기 마련이다.

(관심이 없어요 그냥)
IE 지옥에서는 벗어났지만, 모든 사용자가 최신 버전의 브라우저를 사용하는 것도 아니고, 각 브라우저마다 특정 기술의 지원 시점이 다르다 보니 새로운 기술에 나온 시점에 caniuse에 때려 봐도 미지원의 빨간 박스는 익숙한 풍경이었다. 어떤 과정을 거쳐서 표준이 되길래 새로운 기술을 사용하는 데에 오래 걸리는 걸까.

(올 5월 업데이트된 Chrome 137에 추가된 if())

(Chrome 125에 추가된 Anchor Positioning)
W3C 문서를 기준으로 CSS 표준화 과정을 요약하면 다음과 같다. 특정 기술이 어떤 단계에 있는지도 확인할 수 있다.
W3C는 일반적으로 CR 단계부터는 실제 환경에서 사용해도 좋다고 권장하고 있지만, WD에서 CR로 넘어가는 단계에서 다른 CSS 기능과 충돌할 수 있는 다양한 엣지 케이스를 포함한 모든 시나리오에 대해 다양한 테스트 케이스를 거치기 때문에 이 과정 자체가 상당히 오래 걸린다고 한다. 그래서 어느 정도 브라우저 대응이 되고 꽤 사용된다 하더라도 WD에 머물러 있는 경우도 있다.

(몇몇 브라우저를 제외하고는 대응이 되는 Container Queries의 경우도 아직 WD)
어쨌든 Container Queries 외에 최근에 나온 두 가지 기술의 경우 크로스 브라우징 대응이 불가능하다. Chrome의 점유율만큼이나 W3C에서 구글의 영향력은 강하고 적극적으로 인력을 투입해 아이디어를 내고 표준화 과정에 주도적으로 참여하고 있기 때문에 Chrome 최신 버전에서 가장 먼저 사용할 순 있지만, 긍정적인 여론이 형성되고 다른 브라우저에 적용되는 데에는 여전히 시간이 필요하다.
‘I’ll Look forward to using these features in 10 years time’. 이번 구글 IO에 What’s new in web이라는 발표에서 발표자가 예전에는 이런 코멘트를 보지 않았냐고 언급하는 문장을 가져와 봤다. 실제로 특정 기능이 아이디어 단계에서 모든 주요 브라우저에 적용되기까지 몇 년이나 걸리는 지지부진한 일이기도 했다.
최근에 일반적으로 사용되는 기술도 최초 공개 작업 초안(First Public Working Draft, FPWD)에서 Baseline에 적용되기까지는 꽤 오랜 시간이 걸렸다. Flexbox의 경우 Baseline은 고사하고 최종 문법이 정해지기까지 2년 정도의 시간이 소요되기도 했다.
| 기술명 | FPWD | Baseline | W3C 히스토리 |
|---|---|---|---|
| CSS Grid | 2011년 | 2017년 | https://www.w3.org/standards/history/css-grid-1/ |
| Flexbox | 2009년 | 2017년 | https://www.w3.org/standards/history/css-flexbox-1/ |
| CSS Variables | 2012년 | 2017년 | https://www.w3.org/standards/history/css-variables-1/ |
| CSS Transitions | 2009년 | 2012~2014년 | https://www.w3.org/standards/history/css-transitions-1/ |

(최근 주요 기술의 Baseline 도달까지 걸린 시간 (출처: Google I/O 'What's new in web'))
하지만 상황은 달라지고 있다. What’s new in web 발표를 이어서 보다 보면 상단의 표를 통해 베이스라인에 도달하는 기간이 짧아지고 있음을 보여주고 있는데, 이는 IE의 퇴장과 더불어 주요 모던 브라우저의 협업이 크게 기여했다.
베이스라인에 도달했다는 건 특정 CSS 기술이 최신 브라우저 모두에서 안정적으로 사용할 수 있게 되었다는 의미로, 더 명확하게는 자동 업데이트되는 주요 모던 브라우저(Chrome, Edge, Firefox, Safari)에서 사용 가능한 시점을 의미한다.
자료에서 볼 수 있듯이 베이스라인에 적용되는 시점이 눈에 띄게 짧아졌다. 웹 플랫폼이 거대해지고 서비스가 다각화되며, 개발자와 사용자의 요구가 많아지면서 새로운 기술에 대한 니즈가 어느 때보다 커졌기 때문이다. 물론 새로운 기술이 막 나오는 시점에 프로덕션에 바로 적용할 순 없지만, 어떤 기술에 나오고 있는지 꾸준히 팔로우하는 것이 상대적으로 중요해졌다.
실무에 적용할 수 있는 시점이 점점 짧아진다면 최신 CSS에 관심을 가져야 할 이유가 예전처럼 지평선 너머 어딘가를 바라보며 “언젠가 쓸 일이 있겠지”라며 쓸쓸하게 중얼거릴 일은 아니게 되지 않을까. 적용만 가능하다면 CSS로 대체되는 UI 요소는 다음과 같은 확실한 장점이 있다.
그래서 이번 글에서는 최근에 베이스라인에 도달한 주요 기술과 최근에 발표된 새로운 기술 몇 가지를 ‘이전’과 ‘이후’의 코드 비교를 통해 그 변화를 소개하려고 한다.
z-indexPopover와 Dialog(Modal 방식)는 약간의 차이가 있지만 여기선 편의상 Popover로 통칭한다. Popover를 구현하는 방식에는 여러 가지가 있겠지만 별도의 요소를 만든다고 가정했을 때 기존에는 별도로 DOM 요소를 찾아서 이벤트를 추가해야 했다. 혹은 useState를 통한 상태 관리로 is-active 같은 별도 class에 opacity나 visibility 같은 원하는 CSS property를 먼저 지정해 놓고 상태에 따라 해당 요소의 노출 여부를 결정했다.
// React 컴포넌트 예시
import { useState, useEffect, useRef } from 'react';
function CustomPopoverComponent() {
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef(null);
// 외부 클릭 시 Popover 요소 닫기
useEffect(() => {
function handleClickOutside(event) {
if (popoverRef.current && !popoverRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [popoverRef]);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle the popover</button>
{isOpen && (
<div ref={popoverRef} className="popover">
Popover content
</div>
)}
</div>
);
}
export default CustomPopoverComponent;
추가로 고려해야 할 점이 있다는 건, 관리 포인트가 늘어난다는 뜻이기도 하다.
z-index 관리: 다른 요소 위에 띄우기 위해선 해당하는 요소의 z-index를 관리해 줘야 한다. 별도의 컨벤션을 만들고 z-index에 해당하는 값을 정해진 변수에 담아 관리한다고 해도 꽤나 성가신 일이다. 급하게 핫픽스가 필요할 때 z-index: 9999; 편법을 썼던 지난날을 이 자리를 빌려 고백한다.position에 따라 z-index 쌓임 맥락이 결정되기도 하고, A와 B 요소가 있다고 가정했을 때 B가 A보다 높은 z-index의 값을 갖고 있다 하더라도 B 요소의 부모 요소가 A의 z-index보다 낮다면 부모 요소의 z-index를 넘어설 수 없어 의도한 대로 구현할 수 없다.물론 이 모든 걸 고려해서 세밀하게 관리 가능한 컴포넌트를 구성할 수도 있고, 바퀴를 다시 발명하지 말라는 격언에 따라 잘 만들어진 라이브러리를 통해 관리할 수도 있다. 그럼에도 불구하고 언제든지 발생할 수 있는 이슈이고, 이런 이슈에 대한 이해가 있다면 문제해결을 위한 시야가 넓어지고 유지보수는 훨씬 수월해질 거라고 생각한다.
잠깐 심심해서 찾아봤는데 z-index의 최댓값은 2,147,483,647이라고 한다. W3C 명세에 z-index는 auto | <integer> | inherit으로 명시되어 있고, 브라우저 엔진이 내부 구현에 32비트 정수를 사용하므로 실제 사용할 수 있는 양수 최댓값은 2,147,483,647이기 때문이라는데. 아 예, 뭐 다시 본론으로 가자.
다음의 코드는 Popover API MDN 문서 그대로 구현했다. useState, useEffect, useRef 없이 구현 가능하기에 훨씬 간소해진다.
// Popover API 사용 예시
function PopoverAPIComponent() {
return (
<>
<button popovertarget="mypopover">
Toggle the popover
</button>
<div id="mypopover" popover="auto">
Popover content
</div>
</>
);
}
export default PopoverAPIComponent;
popovertarget과 popover attribute들을 추가하는 것만으로도 Popover API가 적용되었다. 언급했던 관리 포인트와 관련된 기능을 자동으로 구현한다.
z-index나 쌓임 맥락에 시달릴 필요가 없다.react-focus-lock 같은 라이브러리를 사용할 필요가 없다.그 외에 별도의 이벤트 추가 없이 ESC키를 통해 해당 Popover를 닫을 수 있는 등 Popover를 위해 나온 Web API인 만큼 여러 측면에서 자동 구현되어 있기 때문에 DX 측면에서도 유용하다.
select 태그는 스타일링하기 가장 까다로운 요소였다. 웹 브라우저는 select 태그의 실질적인 UI 렌더링 없이 사용 중인 OS에 위임하고, 브라우저에서는 난독화되어 있었기 때문에 CSS로 자유롭게 제어하는 것이 극히 제한적이었다. 그래서 과거에는 보통 아래와 같은 제한된 선택지 앞에서 고민해야 했다.
div와 ul 같은 태그로 커스텀 요소 만들기: 별도의 커스텀 요소를 만들고 사용하는 방식으로 디자인은 완벽히 구현 가능하지만, 네이티브 기능(키보드 탐색, 상태 관리 등)과 접근성, 이벤트 모두 직접 구현해야 한다. 과거의 나는 다음과 같이 구현했었다.import React, { useRef, useState, useEffect } from 'react';
const CustomDefaultSelect = ({ options, value, onSelect, placeholder = '선택해 주세요', disabled = false }) => {
const [isOpen, setIsOpen] = useState(false);
const selectRef = useRef(null);
const selectedOption = options.find((item) => item.value === value);
const handleSelectClick = () => {
if (disabled) return;
setIsOpen(!isOpen);
};
const handleOutsideClick = (event) => {
if (selectRef.current && !selectRef.current.contains(event.target)) {
setIsOpen(false);
}
};
const handleOptionClick = (option) => {
if (onSelect) {
onSelect(option);
}
setIsOpen(false);
};
useEffect(() => {
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
const displayValue = selectedOption ? selectedOption.label : placeholder;
return (
<div
className={`${isOpen ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''}`}
ref={selectRef}
>
{label ? <label>{label}</label> : null}
<div>
<div onClick={handleSelectClick}>
<p>{displayValue}</p>
<span>
<span className={'icon'} />
</span>
</div>
{isOpen && (
<ul>
{options?.map((option) => (
<li
key={option.value}
onClick={() => {
handleOptionClick(option);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default CustomDefaultSelect;<Input />이나 <Combobox />를 활용하지만 결론적으로는 1안과 비슷한 방식을 취하고 있고, 번들 사이즈가 커질 수 있다.select {
-webkit-appearance:none; /* for chrome */
-moz-appearance:none; /*for firefox*/
appearance:none;
}
select::-ms-expand{
display:none;/*for IE10,11*/
}
select {
background:url('arr_pink.gif') no-repeat 97% 50%/15px auto;
}기존의 select 태그의 경우 커스텀을 위해 option 태그 안쪽에 별도로 사용할 태그를 선언한다 하더라도 브라우저는 이를 읽고도 무시했다. 개발자 도구로 HTML을 살펴보면 해당 요소가 있음에도 읽씹한다. 차가운 놈..
<select>
<option>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#007bff" />
</svg>
<span>HTML</span>
</option>
<option>
<span>🔥</span>
<span>CSS</span>
</option>
</select>
select {
&, &::picker(select) {
appearance: base-select;
}
}
select 선택자에 appearance: base-select; 를 추가하는 순간 select의 렌더링된 내부를 변경하고 HTML 파서를 변경해서 거의 모든 태그를 select의 하위 태그로 허용하게 된다. 말 그대로 읽씹 금지 property.
::checkmark와 :checked, ::picker(select) 같이 각 상황에 맞는 다양한 가상 선택자에 접근해서 커스텀이 가능하다.
기존의 div나 ui 등을 통해 커스텀한 요소와의 큰 차이점이라면, 사용자가 정의하는 디자인을 구현하면서 동시에 select 요소의 기본 기능과 접근성 및 키보드 탐색 등의 기능을 그대로 유지할 수 있다는 점이다.
Customizable select 요소를 구현하는 방법은 MDN 문서를 통해서도 자세히 확인할 수 있다.
:target-current: 스크롤 인터렉션도 CSS만으로스크롤 위치에 따라 fixed나 sticky되어 있는 메뉴의 스타일링을 위해선 scroll 이벤트를 통한 감지가 필요했다. 각 섹션의 위치(offsetTop)을 계산해서 현재 화면에 보이는 섹션을 감지하고, 해당 요소에 is-active 같은 클래스를 동적으로 부여함으로써 구현이 가능했다. 구현 자체가 어려운 건 아니지만 스로틀링이나 디바운싱을 통한다 하더라도 스크롤을 지속적으로 감지한다는 건 성능에 부담을 주는 일이기도 했다.
// 스크롤 위치에 따라 목차를 활성화하는 로직
window.addEventListener('scroll', () => {
let currentSectionId = '';
sections.forEach(section => {
const sectionTop = section.offsetTop;
if (pageYOffset >= sectionTop - 60) {
currentSectionId = section.getAttribute('id');
}
});
navLinks.forEach(a => {
a.classList.remove('is-active');
if (a.href.includes(currentSectionId)) {
a.classList.add('is-active');
}
});
});
:target-current물론 현재에는 Chrome 140(Canary)에서만 사용 가능한 실험적인 기능이지만, :target-current를 사용하면 이 모든 로직을 CSS로 해결할 수 있다.
/* 기본 링크 스타일 */
nav a {
color: grey;
transition: color 0.3s;
}
/* 현재 스크롤 위치에 해당하는 섹션을 가리키는 a 태그를 선택 */
nav a:target-current {
color: red;
font-weight: bold;
}
Chrome 팀에서 일하고 있는 Una의 포스팅을 보면 좀 더 자세히 해당 기술을 살펴볼 수 있다. CSS로 대체 가능하다는 것이 많은 이점을 있다는 걸 자연스럽게 보여주는 예시가 아닐까 싶다.
View Transitions API, CSS Nesting 등이 소개되고 웹 생태계에 자리 잡았던 것처럼 수많은 변화가 지금도 일어나는 중이다. 구글 IO에서 중점적으로 발표되었던 Anchor positioning이나 캐로셀 구현을 위한 CSS 스펙들을 포함해 현재에도 많은 기술이 최신 버전의 브라우저 혹은 Chrome Canary에 적용되고 있다.
구글 팀에서는 JavaScript 없이 CSS만으로 캐로셀을 만드는 포스팅을 게시하기도 하고, 그 외에 다양한 UI 요소를 역시나 JavaScript 없이 구현한 CSS Carousel Gallery를 통해 소개하고 있기도 하다. 구글이 브라우저 시장을 점유하는 만큼 컨퍼런스나 별도의 페이지를 통해 소개하면서 새로운 기술을 주도하는 편이다.
다음 표는 이번 구글 IO에서 발표되었던 What's new in web UI 자료 중 일부 페이지로, 최근에 적용되고 있는 새로운 기술이다. UI/UX, 그리고 DX의 발전을 위해 여러 가지 실험적인 시도와 실질적 도입이 꾸준히 되고 있으니 관심이 생긴다면 하나씩 체크해 보는 것도 좋다.

(신규 CSS 기술 현황 (출처: Google I/O 'What's new in web UI'))
새로운 기술에 관심을 둔다는 건 개별 기술의 사용법을 완벽히 숙지하는 것이 아니라, 웹 플랫폼 자체가 어떻게 진화하고 있는지 큰 흐름을 이해하는 것이라고 생각한다. 기술이라는 건 결국 사용자와 개발자의 니즈로 만들어지고, 그런 니즈는 웹을 관통하는 트렌드와 무관하지 않기 때문이다.
과거에는 JavaScript 라이브러리에 의존했던 수많은 기능이 이제 브라우저의 기본 기능으로 흡수되고 있고, 베이스라인에 도달하는 기간은 점점 짧아지고 있다. 말인즉슨, 프론트엔드 개발자들이 UI 구현의 부담을 덜고, 더 복잡한 비즈니스 로직과 창의적인 사용자 경험 설계에 집중할 수 있는 환경이 만들어지고 있음을 의미하는 게 아닐까. ‘10년 뒤에나 쓸 수 있겠지’라고 외면하기에는 그 미래가 점점 다가오고 있다.
난 눈동냥, 귀동냥의 힘을 믿는 편이다. 익숙하지 못한 기술이라도 컨퍼런스의 발표나 기술 아티클을 통해 가볍게 접해놓으면 이후에 덜 부담스럽고 유연하게 받아들일 힘이 생긴다. 이 글을 읽고 있는 분들에게도 지금 이 글이 조금이라도 그런 힘을 줄 수 있는 유익한 글이었으면 하는 바람이 있다. 이제 새로운 기술을 접할 때 떠올릴 질문은 ‘10년 뒤에 쓰면 되나요?’가 아니라, ‘이 기술로 서비스를 어떻게 개선할 수 있을까요?’가 되어야 할 때라는 생각을 한다.