티스토리 뷰
이 블로그를 오래 보신 분이라면 알겠지만, 나는 제이쿼리로 DOM을 주무르던 시절부터 웹을 만들어온 사람이다. 그 시절엔 뭐만 하려고 하면 자바스크립트였다. 아코디언 메뉴도 JS, 툴팁도 JS, 부모 요소 스타일 바꾸는 것도 JS. "CSS는 못 하니까"가 당연한 전제였다.
그런데 요 몇 년 사이에 그 전제가 꽤 많이 무너졌다. 최근에 사내 프로젝트 프론트 코드를 정리하다가, 예전에 자바스크립트로 짜놓은 UI 로직 중 상당수가 이제 CSS 몇 줄로 끝난다는 걸 새삼 확인했다. 실제로 걷어낸 코드가 몇백 줄이다. 오늘은 그 과정에서 실무에 바로 써먹은 모던 CSS 기능들을 정리해본다. 이론 나열이 아니라, "원래 JS로 이렇게 짰는데 지금은 이렇게 바꿨다"는 관점으로 쓴다.

사진: Unsplash, Florian Olivo
1. :has() — 드디어 생긴 부모 선택자
CSS에 부모 선택자가 없다는 건 20년 가까이 개발자들 단골 불평이었다. "자식 상태 보고 부모 스타일 바꾸기"는 무조건 자바스크립트 몫이었다.
전형적인 예가 폼 유효성 표시다. 예전엔 이런 코드를 짰다.
// 예전 방식: input 상태를 감시해서 부모에 클래스 토글
document.querySelectorAll('.form-field input').forEach((input) => {
input.addEventListener('input', () => {
input.closest('.form-field')
.classList.toggle('has-error', !input.checkValidity());
});
});
지금은 CSS만으로 된다.
/* 지금 방식: 유효하지 않은 input을 품은 필드에 스타일 */
.form-field:has(input:invalid:not(:placeholder-shown)) {
border-color: #e5484d;
}
.form-field:has(input:valid) .check-icon {
opacity: 1;
}
이벤트 리스너도, 클래스 토글도 없다. 상태는 브라우저가 이미 알고 있고, CSS가 그걸 직접 읽는다. 나는 체크박스 선택 시 카드 전체를 강조하는 UI, 이미지가 없는 게시글 카드의 레이아웃 변경, 이 두 군데에서 JS를 걷어냈다. :has()는 2023년 말부터 모든 주요 브라우저에서 지원되니, 사내 서비스처럼 브라우저를 어느 정도 통제할 수 있는 환경이면 그냥 쓰면 된다.
주의할 점 하나. :has()는 강력한 만큼 셀렉터 매칭 비용이 있다. body:has(...)처럼 최상위에 넓게 거는 건 피하고, 가능한 한 좁은 범위에 거는 게 좋다. 실제로 리스트 항목 수천 개짜리 화면에서 무심코 넓게 걸었다가 스크롤이 미묘하게 버벅여서 범위를 좁힌 적이 있다.
2. 컨테이너 쿼리 — 반응형의 기준이 화면에서 부모로
미디어 쿼리의 근본적인 한계는 기준이 항상 뷰포트라는 거다. 같은 카드 컴포넌트가 사이드바에 들어가면 좁게, 본문에 들어가면 넓게 나와야 하는데, 뷰포트 기준으로는 이걸 못 푼다. 그래서 예전엔 ResizeObserver로 부모 너비를 재서 클래스를 붙이는 코드를 짰다.
/* 지금 방식: 부모를 컨테이너로 선언 */
.card-wrapper {
container-type: inline-size;
}
/* 컨테이너 너비 기준으로 분기 */
@container (max-width: 400px) {
.card {
flex-direction: column;
}
.card .thumbnail {
width: 100%;
}
}
컴포넌트가 "내가 놓인 자리의 너비"에 스스로 반응한다. 디자인 시스템 만드는 입장에서는 이게 진짜 크다. 카드 컴포넌트를 어디에 갖다 놓든 알아서 맞춰지니까, "이 컴포넌트는 사이드바용 variant를 따로 주세요" 같은 대화가 사라진다. 우리 팀에서는 관리자 대시보드 위젯에 먼저 적용했는데, 위젯 크기를 사용자가 드래그로 바꾸는 화면이라 효과가 특히 좋았다. ResizeObserver 코드 한 덩어리가 통째로 사라졌다.

사진: Unsplash, Gabriel Heinzer
3. popover 속성 — 드롭다운/툴팁의 골칫거리 해결
드롭다운 메뉴 만들 때 제일 귀찮은 게 뭐냐고 물으면 나는 "바깥 클릭하면 닫기"라고 답한다. document에 클릭 리스너 걸고, 메뉴 내부 클릭이면 무시하고, ESC 키 처리하고, 스크롤 컨테이너 안에서 z-index 씨름하고. 다들 한 번씩 짜봤을 거다.
HTML의 popover 속성은 이걸 브라우저 기본 동작으로 만들어버렸다.
<button popovertarget="user-menu">메뉴</button>
<div id="user-menu" popover>
<a href="/settings">설정</a>
<a href="/logout">로그아웃</a>
</div>
자바스크립트 0줄. 바깥 클릭 시 닫힘, ESC로 닫힘, 최상위 레이어(top layer)에 그려져서 z-index 문제 없음, 이게 전부 기본 제공이다. 정확히는 CSS가 아니라 HTML 기능이지만, "JS 로직을 플랫폼 기본 기능으로 대체한다"는 맥락에서 같이 묶었다. 열림/닫힘 애니메이션도 @starting-style과 transition-behavior: allow-discrete를 쓰면 CSS로 처리된다. 접근성 관점에서도 직접 짠 드롭다운보다 기본 동작이 낫다.
4. 스크롤 관련: scroll-snap과 sticky
모바일에서 좌우로 넘기는 캐러셀, 예전엔 라이브러리부터 찾았다. Swiper 같은 걸 npm install 하는 순간 번들에 수십 KB가 얹힌다. 단순한 캐러셀이면 지금은 이걸로 충분하다.
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel > * {
flex: 0 0 80%;
scroll-snap-align: center;
}
손가락으로 넘기면 착착 걸린다. 자동 재생이나 무한 루프가 필요하면 그때 라이브러리를 고민하면 되고, 상품 이미지 넘겨보기 수준이면 이 여섯 줄이 끝이다.
position: sticky는 이제 새로울 것도 없지만, 아직도 스크롤 이벤트로 헤더 고정을 구현한 레거시 코드를 종종 만난다. 스크롤 리스너는 성능 문제의 단골 원인이니, 보이는 족족 sticky로 바꾸는 걸 추천한다.
5. 자잘하지만 체감 큰 것들
CSS 중첩(Nesting) — Sass 없이도 이제 브라우저가 중첩 문법을 이해한다. 빌드 도구 없이 순수 CSS만 쓰는 작은 프로젝트에서 특히 반갑다. 다만 팀 프로젝트에서는 이미 Sass/PostCSS 파이프라인이 있는 경우가 많아서, 나는 신규 사이드 프로젝트부터 적용 중이다.
.card {
padding: 16px;
&:hover {
background: #f5f5f5;
}
.title {
font-weight: 600;
}
}
text-wrap: balance — 제목이 두 줄로 떨어질 때 윗줄만 길고 아랫줄에 한 단어만 덜렁 남는 것, 그동안 JS 라이브러리로 잡던 문제다. 이제 한 줄이면 된다. 제목 요소에 걸어두면 줄 길이를 균형 있게 맞춰준다.
accent-color — 체크박스, 라디오, 프로그레스바 색상 때문에 커스텀 컴포넌트를 만들던 시절이 있었다. 브랜드 색만 맞추면 되는 상황이라면 accent-color: #6c47ff; 한 줄로 끝난다. 접근성 잘 잡힌 네이티브 컨트롤을 그대로 쓸 수 있다는 게 핵심이다.
aspect-ratio와 gap — 썸네일 비율 유지하려고 padding-top으로 비율 잡던 트릭, 기억하는 분 많을 것이다. 이제 aspect-ratio: 16 / 9; 한 줄이면 끝이다. flexbox에서도 gap이 동작한 지 오래라, margin과 음수 margin으로 아이템 간격을 잡던 코드는 보이는 대로 정리해도 된다.
dialog 요소 — 모달도 popover와 같은 맥락이다. <dialog>와 showModal()을 쓰면 포커스 트랩, 배경 딤 처리(::backdrop), ESC 닫기가 기본으로 따라온다. 모달 라이브러리 하나를 의존성에서 뺄 수 있다.

사진: Unsplash, Ferenc Almasi
삽질 기록 하나 — :has()와 우선순위 함정
좋은 이야기만 쓰면 광고 같으니, 실제로 밟았던 함정도 하나 남긴다. :has()를 도입하고 며칠 뒤, 멀쩡하던 카드의 hover 스타일이 갑자기 먹히지 않는 문제가 생겼다. 범인은 우선순위였다. :has()는 괄호 안에 들어간 셀렉터 중 가장 높은 우선순위를 자기 우선순위로 가져간다. .card:has(input:checked)는 클래스 하나짜리 셀렉터가 아니라는 얘기다. 뒤에 선언한 .card:hover가 이길 거라고 생각했는데 졌다.
해결은 둘 중 하나다. 우선순위를 맞춰주거나, 아예 :where()로 감싸서 우선순위를 0으로 만들어버리는 것.
/* :where()로 감싸면 우선순위가 오르지 않는다 */
.card:where(:has(input:checked)) {
outline: 2px solid #6c47ff;
}
이 사건 이후로 팀 컨벤션에 "상태성 :has()는 :where()로 감싸서 쓴다"를 추가했다. 모던 CSS는 기능만 늘어난 게 아니라 우선순위 계산법도 같이 복잡해졌다. 이럴 때 개발자 도구의 Computed 탭에서 어떤 규칙이 이겼는지 확인하는 습관이 시간을 크게 아껴준다.
도입할 때 실무 기준
새 기능 소개 글은 많은데, 정작 "우리 서비스에 써도 되나"에 대한 이야기는 잘 없다. 내 기준은 이렇다.
첫째, Baseline 확인이 먼저다. MDN이나 caniuse에서 "Baseline: Widely available" 표시가 있으면 사실상 고민 없이 쓴다. 위에 쓴 것 중 :has(), 컨테이너 쿼리, scroll-snap, nesting, accent-color, dialog는 이미 이 단계다. popover와 @starting-style은 지원된 지 상대적으로 얼마 안 됐으니, 서비스 사용자층의 브라우저 통계를 한 번 보고 결정하는 게 안전하다.
둘째, 망가져도 되는 곳부터 적용한다. text-wrap: balance 같은 건 미지원 브라우저에서 그냥 원래대로 보일 뿐이라 리스크가 없다. 반면 컨테이너 쿼리는 레이아웃 자체가 달라지니, @supports로 감싸거나 구형 브라우저용 기본 레이아웃을 깔아두고 시작해야 한다.
/* 미지원 브라우저용 기본값을 먼저, 신기능은 @supports로 */
@supports (container-type: inline-size) {
.card-wrapper {
container-type: inline-size;
}
}
셋째, 기존 코드를 일부러 갈아엎지는 않는다. 잘 돌아가는 JS 드롭다운을 popover로 바꾸는 작업 자체는 공수다. 나는 새 화면을 만들 때, 혹은 어차피 그 컴포넌트를 손대야 할 때만 교체한다. 리팩터링은 흐름에 얹어야지, 그 자체가 목적이 되면 끝이 없다.
마무리 — CSS를 다시 배워야 하는 시점
10년 전에 CSS를 배운 사람과 지금 CSS를 배우는 사람은 사실상 다른 언어를 배우는 거라고 느낀다. 나 같은 연차의 개발자일수록 "그건 CSS로 안 되니까 JS로"라는 반사신경이 몸에 배어 있는데, 그 반사신경이 이제 꽤 자주 틀린다. 프론트엔드 코드 리뷰를 하다 보면 연차 있는 사람일수록 오히려 옛날 방식으로 짜는 게 보인다. 나부터도 그랬다.
아이 셋 키우는 아빠 입장에서 비유하자면, 예전엔 분유 타주는 것도 기저귀 갈아주는 것도 전부 수동이었는데, 어느 순간 자동으로 해주는 도구들이 나온 상황과 비슷하다. 도구가 있는데도 손으로 하던 습관대로 하고 있다면, 그건 성실함이 아니라 그냥 정보 업데이트가 안 된 것이다.
자바스크립트가 줄어들면 좋은 점은 명확하다. 번들이 가벼워지고, 이벤트 리스너 관리 같은 버그 포인트가 사라지고, 브라우저 기본 동작이라 접근성도 대체로 낫다. 이번 주말에 본인 프로젝트에서 아코디언, 드롭다운, 캐러셀 코드를 한 번 열어보시길. 생각보다 많은 줄이 지워질 수 있다.
다음에 기회가 되면 @starting-style로 진입/퇴장 애니메이션을 CSS만으로 처리하는 방법, 그리고 View Transitions API로 페이지 전환 효과 만드는 이야기도 정리해보겠다.
