← 목록으로 돌아가기

CSS의 혁명, :has() 선택자 완벽 가이드: 부모를 선택하는 마법

CSS

CSS :has() Selector

CSS 역사상 가장 오랫동안 개발자들이 염원해왔던 기능, 바로 "부모 선택자"입니다. JQuery의 .parent().closest()를 쓰지 않고는 불가능했던 그 스타일링이, 드디어 순수 CSS만으로 가능해졌습니다. 주인공은 바로 :has() 의사 클래스(Pseudo-class)입니다. 2024년부터 모든 메이저 브라우저(Chrome, Safari, Firefox, Edge)에서 지원하기 시작한 이 혁명적인 선택자를 3,000자 가이드로 정리해드립니다.

1. :has()란 무엇인가?

MDN 정의에 따르면 :has(), 관계형 의사 클래스입니다. 쉽게 말해 **"인수(괄호 안의 선택자)와 일치하는 요소를 하나라도 포함하고 있는 요소"**를 선택합니다.

/* 자식 중에 img 태그가 있는 article 요소만 선택 */
article:has(img) {
  background-color: #f0f9ff;
  border: 1px solid #bae6fd;
}

이 코드는 img를 가진 article의 배경색을 바꿉니다. 만약 img가 없다면 스타일은 적용되지 않습니다. 즉, 자식의 상태에 따라 부모의 스타일을 결정할 수 있게 된 것입니다!

2. 실전 활용 패턴 BEST 3

2.1 "카드가 이미지를 가질 때만" 레이아웃 변경하기

블로그 카드 컴포넌트를 만들 때, 썸네일 이미지가 있는 경우와 없는 경우의 레이아웃을 다르게 하고 싶을 때가 많습니다. 예전에는 React에서 hasImage 같은 클래스를 조건부로 붙여줬어야 했습니다.

.card {
  display: grid;
  grid-template-columns: 1fr; /* 기본: 1단 */
}

/* 이미지를 포함하고 있다면 2단 컬럼으로 변경 */
.card:has(.card-image) {
  grid-template-columns: 1fr 1fr;
}

JS 로직 없이 CSS만으로 우아하게 해결됩니다.

2.2 폼 유효성 검사 스타일링 (Form Validation)

입력 필드에 에러가 있을 때, 그 input 뿐만 아니라 이를 감싸고 있는 라벨이나 컨테이너 그룹 전체의 색상을 빨간색으로 바꾸고 싶다면 어떨까요?

/* input이 invalid 상태라면, 그 부모인 .input-group의 테두리를 빨갛게 */
.input-group:has(input:invalid) {
  border-color: red;
}

/* 에러 메시지(p.error)가 존재한다면 라벨 색상 변경 */
label:has(+ input + p.error) {
  color: red;
}

복잡한 JS 상태 관리 없이도 폼의 시각적 피드백을 풍부하게 만들 수 있습니다.

2.3 이전 형제 선택하기 (Previous Sibling)

CSS에는 '다음 형제(+)'나 '일반 형제(~)' 선택자는 있었지만, '이전 형제'를 선택하는 방법은 없었습니다. :has()를 응용하면 이것이 가능해집니다.

/* 마우스가 호버된 아이템의 '앞에 있는' 아이템 선택 */
.item:has(+ .item:hover) {
  transform: scale(0.95);
  opacity: 0.7;
}

애플의 독(Dock) 메뉴처럼 마우스를 올린 아이템 주변이 반응하는 인터랙션을 CSS만으로 구현할 수 있습니다.

3. 논리 조합: :not()과 함께 쓰기

:has():not()과 결합했을 때 더욱 강력해집니다.

/* h2 태그를 포함하지 '않은' 섹션만 선택 */
section:not(:has(h2)) {
  border: 1px dashed gray; /* 제목 없는 섹션 강조 표시 */
}

이처럼 특정 요소가 '결여된' 상태를 스타일링하는 것은 기존에는 불가능에 가까웠던 작업입니다.

4. 성능 이슈는 없을까?

:has()는 브라우저 렌더링 엔진 입장에서 꽤 비싼 연산입니다. 요소를 렌더링하다가 자식 요소를 확인하기 위해 다시 DOM 트리를 확인해야 하기 때문입니다. 하지만 최신 브라우저 엔진들은 고도로 최적화되어 있어, 일반적인 웹 페이지 규모에서는 성능 저하를 거의 체감할 수 없습니다. 다만 수천 개의 요소가 있는 거대한 리스트에서 복잡한 :has() 연쇄를 사용하는 것은 피하는 것이 좋습니다.

5. 결론: CSS-in-JS의 종말?

:has(), 컨테이너 쿼리(@container), 중첩(Nesting) 등 최근 CSS의 발전 속도는 눈부십니다. 과거 JS로만 처리해야 했던 많은 로직들이 순수 CSS의 영역으로 넘어오고 있습니다.
물론 동적인 상태 관리가 필요한 부분은 여전히 JS가 필요하겠지만, **"스타일은 CSS에게, 로직은 JS에게"**라는 본연의 역할 분담이 더욱 명확해지고 있습니다. 지금 당장 :has()를 써보세요. 제이쿼리의 추억과 함께, 새 세상이 열릴 것입니다.

6. 실무 활용 패턴: :has()로 해결하는 추가 문제들

6.1 빈 상태(Empty State) 처리

리스트가 비어있을 때 "데이터가 없습니다"와 같은 빈 상태 메시지를 보여주는 것도 :has()로 우아하게 해결됩니다. .list-container:not(:has(.list-item)) 형태로 JavaScript 없이 빈 상태를 감지하고 적절한 UI를 표시할 수 있습니다.

6.2 다크모드 토글 연동

html 태그에 :has()를 적용하면, 자식 요소의 상태에 따라 전역 테마를 전환할 수 있습니다. 체크박스 하나로 전체 페이지의 다크모드를 제어하는 것이 CSS만으로 가능해진 것입니다.

6.3 반응형 컴포넌트 레이아웃

컨테이너 쿼리(@container)와 :has()를 결합하면, 컴포넌트가 자신이 포함하는 콘텐츠에 따라 레이아웃을 자동으로 조절하는 "자기 인식 컴포넌트(Self-Aware Components)"를 만들 수 있습니다. 이미지가 있으면 2단 그리드, 없으면 1단 레이아웃 같은 패턴을 프레임워크 없이 구현할 수 있습니다.

7. 성능 고려사항과 주의점

:has()는 강력하지만, 남용하면 성능 문제를 일으킬 수 있습니다. 브라우저는 :has() 내부의 선택자가 변경될 때마다 부모 요소의 스타일을 재계산해야 하므로, 깊은 DOM 트리에서 복잡한 :has() 선택자는 레이아웃 스래싱의 원인이 될 수 있습니다.

  • :has() 내부에 다른 :has()를 중첩하지 마세요.
  • 가능한 선택자의 범위를 좁히세요 (body:has(...)보다 .container:has(...)가 낫습니다).
  • 애니메이션이 관련된 경우, will-change 속성으로 브라우저에 힌트를 제공하세요.

8. 결론

:has() 선택자는 CSS 역사에서 가장 혁명적인 추가 기능 중 하나입니다. "부모를 선택할 수 없다"는 CSS의 오래된 제약을 깨뜨리며, JavaScript 의존도를 대폭 줄이는 선언적 UI 패턴의 새로운 지평을 열었습니다. 컨테이너 쿼리, CSS Nesting과 함께 :has()를 마스터한다면, JavaScript에 의존하지 않고도 놀라울 정도로 정교한 반응형 인터랙션을 구현할 수 있습니다. CSS의 르네상스는 지금 이 순간에도 진행 중입니다.

X. 깊게 파헤치는 CSS 렌더링 파이프라인과 :has() 성능 역학 (Deep Dive)

과거 CSS의 핵심 철학은 "Cascade" 즉 하향식이었습니다. 부모가 브라우저에 페인트되면 그 안의 자식들을 렌더링하는 워크플로우에 완벽히 최적화되어 있었죠. 하지만 :has()는 이 렌더링 파이프라인의 역행을 의미합니다.

1. Style Recalculation의 숨겨진 비용

:has() 조건이 붙은 선택자는 브라우저가 DOM 트리의 자식 노드 변경(Mutation)을 감지할 때마다, 부모 트리의 스타일을 역으로 재조정(Style Recalculation)해야 함을 의미합니다.
만약 body:has(.modal-open)와 같이 전역 범위에 무분별하게 :has()를 흩뿌리면, 깊은 뎁스의 작은 DOM 조각 하나가 변경될 때마다 브라우저는 전체 화면의 Reflow를 발생시킬 위험이 있습니다.
따라서 엔터프라이즈 환경에서는 :has()의 사용 범위를 명확한 '컴포넌트 바운더리' 내로 제한해야 하며, CSS Containment(예: contain: layout size) 속성과 결합하여 재계산의 여파를 격리시키는 고도의 CSS 엔지니어링이 필수적입니다.

2. JavaScript의 영역을 포식하는 CSS

하지만 이러한 한계에도 불구하고 :has()가 가져온 혁명은 눈부십니다. 기존에는 입력 폼(Input Form)의 유효성 검사 에러 라벨을 띄우거나, 다중 카드 리스트에서 Hover 시 다른 카드들을 흐리게(Dimmed) 처리하려면 복잡한 React State와 JavaScript 이벤트 리스너가 필요했습니다.
이제는 form:has(input:invalid) 한 줄로 폼 테두리를 붉게 물들이고, .card-wrapper:has(.card:hover) .card:not(:hover) 단 한 줄로 나머지 카드의 오퍼시티를 낮춥니다.
이것은 단순한 코딩 단축이 아닙니다. 자바스크립트의 Main Thread 연산을 완전히 회피하고, Browser의 CSS 파싱 엔진(GPU Acceleration)에 UI 상태 처리를 위임하여 프론트엔드의 렌더링 속도를 한 차원 끌어올리는 아키텍처적 도약입니다.

X. 깊게 파헤치는 CSS 렌더링 파이프라인과 :has() 성능 역학 (Deep Dive)

과거 CSS의 핵심 철학은 "Cascade" 즉 하향식이었습니다. 부모가 브라우저에 페인트되면 그 안의 자식들을 렌더링하는 워크플로우에 완벽히 최적화되어 있었죠. 하지만 :has()는 이 렌더링 파이프라인의 역행을 의미합니다.

1. Style Recalculation의 숨겨진 비용

:has() 조건이 붙은 선택자는 브라우저가 DOM 트리의 자식 노드 변경(Mutation)을 감지할 때마다, 부모 트리의 스타일을 역으로 재조정(Style Recalculation)해야 함을 의미합니다.
만약 body:has(.modal-open)와 같이 전역 범위에 무분별하게 :has()를 흩뿌리면, 깊은 뎁스의 작은 DOM 조각 하나가 변경될 때마다 브라우저는 전체 화면의 Reflow를 발생시킬 위험이 있습니다.
따라서 엔터프라이즈 환경에서는 :has()의 사용 범위를 명확한 '컴포넌트 바운더리' 내로 제한해야 하며, CSS Containment(예: contain: layout size) 속성과 결합하여 재계산의 여파를 격리시키는 고도의 CSS 엔지니어링이 필수적입니다.

2. JavaScript의 영역을 포식하는 CSS

하지만 이러한 한계에도 불구하고 :has()가 가져온 혁명은 눈부십니다. 기존에는 입력 폼(Input Form)의 유효성 검사 에러 라벨을 띄우거나, 다중 카드 리스트에서 Hover 시 다른 카드들을 흐리게(Dimmed) 처리하려면 복잡한 React State와 JavaScript 이벤트 리스너가 필요했습니다.
이제는 form:has(input:invalid) 한 줄로 폼 테두리를 붉게 물들이고, .card-wrapper:has(.card:hover) .card:not(:hover) 단 한 줄로 나머지 카드의 오퍼시티를 낮춥니다.
이것은 단순한 코딩 단축이 아닙니다. 자바스크립트의 Main Thread 연산을 완전히 회피하고, Browser의 CSS 파싱 엔진(GPU Acceleration)에 UI 상태 처리를 위임하여 프론트엔드의 렌더링 속도를 한 차원 끌어올리는 아키텍처적 도약입니다.

X. 깊게 파헤치는 CSS 렌더링 파이프라인과 :has() 성능 역학 (Deep Dive)

과거 CSS의 핵심 철학은 "Cascade" 즉 하향식이었습니다. 부모가 브라우저에 페인트되면 그 안의 자식들을 렌더링하는 워크플로우에 완벽히 최적화되어 있었죠. 하지만 :has()는 이 렌더링 파이프라인의 역행을 의미합니다.

1. Style Recalculation의 숨겨진 비용

:has() 조건이 붙은 선택자는 브라우저가 DOM 트리의 자식 노드 변경(Mutation)을 감지할 때마다, 부모 트리의 스타일을 역으로 재조정(Style Recalculation)해야 함을 의미합니다.
만약 body:has(.modal-open)와 같이 전역 범위에 무분별하게 :has()를 흩뿌리면, 깊은 뎁스의 작은 DOM 조각 하나가 변경될 때마다 브라우저는 전체 화면의 Reflow를 발생시킬 위험이 있습니다.
따라서 엔터프라이즈 환경에서는 :has()의 사용 범위를 명확한 '컴포넌트 바운더리' 내로 제한해야 하며, CSS Containment(예: contain: layout size) 속성과 결합하여 재계산의 여파를 격리시키는 고도의 CSS 엔지니어링이 필수적입니다.

2. JavaScript의 영역을 포식하는 CSS

하지만 이러한 한계에도 불구하고 :has()가 가져온 혁명은 눈부십니다. 기존에는 입력 폼(Input Form)의 유효성 검사 에러 라벨을 띄우거나, 다중 카드 리스트에서 Hover 시 다른 카드들을 흐리게(Dimmed) 처리하려면 복잡한 React State와 JavaScript 이벤트 리스너가 필요했습니다.
이제는 form:has(input:invalid) 한 줄로 폼 테두리를 붉게 물들이고, .card-wrapper:has(.card:hover) .card:not(:hover) 단 한 줄로 나머지 카드의 오퍼시티를 낮춥니다.
이것은 단순한 코딩 단축이 아닙니다. 자바스크립트의 Main Thread 연산을 완전히 회피하고, Browser의 CSS 파싱 엔진(GPU Acceleration)에 UI 상태 처리를 위임하여 프론트엔드의 렌더링 속도를 한 차원 끌어올리는 아키텍처적 도약입니다.

X. 깊게 파헤치는 CSS 렌더링 파이프라인과 :has() 성능 역학 (Deep Dive)

과거 CSS의 핵심 철학은 "Cascade" 즉 하향식이었습니다. 부모가 브라우저에 페인트되면 그 안의 자식들을 렌더링하는 워크플로우에 완벽히 최적화되어 있었죠. 하지만 :has()는 이 렌더링 파이프라인의 역행을 의미합니다.

1. Style Recalculation의 숨겨진 비용

:has() 조건이 붙은 선택자는 브라우저가 DOM 트리의 자식 노드 변경(Mutation)을 감지할 때마다, 부모 트리의 스타일을 역으로 재조정(Style Recalculation)해야 함을 의미합니다.
만약 body:has(.modal-open)와 같이 전역 범위에 무분별하게 :has()를 흩뿌리면, 깊은 뎁스의 작은 DOM 조각 하나가 변경될 때마다 브라우저는 전체 화면의 Reflow를 발생시킬 위험이 있습니다.
따라서 엔터프라이즈 환경에서는 :has()의 사용 범위를 명확한 '컴포넌트 바운더리' 내로 제한해야 하며, CSS Containment(예: contain: layout size) 속성과 결합하여 재계산의 여파를 격리시키는 고도의 CSS 엔지니어링이 필수적입니다.

2. JavaScript의 영역을 포식하는 CSS

하지만 이러한 한계에도 불구하고 :has()가 가져온 혁명은 눈부십니다. 기존에는 입력 폼(Input Form)의 유효성 검사 에러 라벨을 띄우거나,