Back

CSS :has() 셀렉터 완벽 정복: 20년 만에 등장한 부모 선택자

개발자들이 20년 넘게 기다려온 기능이 드디어 나왔습니다. CSS 부모 셀렉터요.

예전엔 자식 요소에 따라 부모 스타일을 바꾸고 싶으면 무조건 JavaScript를 써야 했어요. 클래스 토글하고, DOM 변화 감지하고... 복잡했죠. 그런데 이제 :has()로 순수 CSS만으로 해결할 수 있게 됐습니다.

솔직히 말해서, :has()는 그냥 "부모 셀렉터"라고 부르기엔 아까워요. 형제 관계, 자손 조건, 심지어 "다음에 뭐가 오는지"에 따라서도 선택할 수 있거든요. 진짜 게임체인저입니다.

이 글에서는 :has()를 제대로 다뤄볼게요. 기초 문법부터 실전 패턴, 성능 최적화까지 전부 다룹니다. 끝까지 읽으면 여러분 코드에서 JavaScript 의존성이 확 줄어들 겁니다.

왜 이렇게 오래 걸렸을까?

잠깐, 그런데 왜 부모 셀렉터가 이제서야 나온 걸까요?

예전에 불가능했던 이유

CSS 셀렉터는 원래 부모→자식 방향으로만 작동해요.

/* ✅ 이건 돼요 - 부모 기준으로 자식 선택 */ .card .title { font-size: 1.5rem; } /* ❌ 이건 안 됐어요 - 자식 기준으로 부모 선택 */ /* .card:contains(.featured-badge) { ... } ← 예전엔 불가능 */

그래서 개발자들은 억지로 이런 방법들을 썼죠:

  1. 직접 클래스 추가: .card.has-image { ... } 같은 식으로
  2. JavaScript로 처리: DOM 바뀔 때마다 클래스 토글
  3. HTML 구조 변경: CSS 한계 때문에 마크업을 뜯어고침

다 불편하고, 유지보수도 힘들었어요.

성능 문제 때문이었습니다

브라우저 입장에서 부모 셀렉터는 골칫거리였어요. CSS 셀렉터는 성능상 오른쪽→왼쪽으로 평가하거든요. 근데 부모 셀렉터를 구현하려면:

  1. 자손 요소 전부 찾고
  2. 각각에 대해 DOM 트리를 거슬러 올라가서
  3. 조상한테 스타일 적용

복잡한 페이지에선 렌더링이 심하게 느려질 수 있었죠. 다행히 :has() 스펙이 이걸 효율적으로 처리하도록 설계돼서 이제 실무에서도 충분히 쓸 만합니다.

:has() 기본 문법

:has() 안에 셀렉터를 넣으면, 그 셀렉터에 해당하는 자손이 있는 요소를 선택해요.

기본 형태

부모:has(자식셀렉터) { /* 부모한테 적용되는 스타일 */ }

괄호 안 조건을 만족하는 자식이 있으면 부모가 선택되는 거예요.

첫 번째 예제

카드 컴포넌트를 한번 볼까요?

<div class="card"> <img src="product.jpg" alt="상품"> <h3>상품명</h3> <p>설명입니다...</p> </div> <div class="card"> <h3>이미지 없는 카드</h3> <p>텍스트만 있어요.</p> </div>

예전 같으면 이미지 유무에 따라 스타일 다르게 하려면 클래스를 일일이 추가하거나 JavaScript를 써야 했어요. 근데 이제는:

/* 기본 카드 스타일 */ .card { padding: 1rem; border-radius: 8px; background: white; } /* 이미지 있으면 그리드로 */ .card:has(img) { display: grid; grid-template-columns: 200px 1fr; gap: 1rem; } /* 이미지 없으면 가운데 정렬 */ .card:not(:has(img)) { text-align: center; max-width: 400px; }

JavaScript 단 한 줄 없이, 콘텐츠에 따라 자동으로 레이아웃이 바뀝니다. 깔끔하죠?

부모 셀렉터 그 이상: :has()의 진짜 실력

"부모 셀렉터"라고만 부르기엔 :has()가 할 수 있는 게 훨씬 많아요.

형제 요소 기준으로 선택하기

+ 결합자랑 같이 쓰면 형제 관계도 조건으로 쓸 수 있어요:

/* 바로 뒤에 required input이 있는 label 선택 */ label:has(+ input:required) { font-weight: bold; } label:has(+ input:required)::after { content: " *"; color: #e74c3c; }

폼 스타일링할 때 진짜 유용합니다.

깊은 곳에 있는 요소까지 탐색

:has() 안의 셀렉터는 자손 전체를 탐색해요:

/* 에러 메시지가 어디든 있으면 섹션 강조 */ .form-section:has(.error-message) { border-left: 4px solid #e74c3c; background: #fdf2f2; } /* 편집 가능한 셀이 있는 행 강조 */ tr:has(td[contenteditable="true"]) { background: #fffef0; }

여러 조건 조합

:has() 여러 개를 연결해서 AND 조건으로 쓸 수 있어요:

/* 이미지랑 뱃지 둘 다 있는 카드 */ .card:has(img):has(.featured-badge) { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); border: 2px solid gold; }

OR 조건은 괄호 안에 쉼표로:

/* 비디오든 이미지든 있으면 */ .card:has(img, video) { min-height: 300px; }

실전에서 이렇게 씁니다

이론은 여기까지하고, 실제로 어디에 쓰면 좋은지 봅시다.

1. 폼 유효성 검사 UI

:has()의 끝판왕 활용법이에요. JavaScript 없이 실시간 검증 피드백이 가능합니다:

/* 유효하지 않은 입력이 있으면 */ .form-group:has(input:invalid:not(:placeholder-shown)) { --input-border-color: #e74c3c; --input-bg: #fdf2f2; } /* 유효한 입력이면 */ .form-group:has(input:valid:not(:placeholder-shown)) { --input-border-color: #27ae60; --input-bg: #f0fdf4; } .form-group input { border: 2px solid var(--input-border-color, #ddd); background: var(--input-bg, white); transition: all 0.2s ease; } /* 에러 메시지는 필요할 때만 표시 */ .form-group .error-message { display: none; color: #e74c3c; font-size: 0.875rem; } .form-group:has(input:invalid:not(:placeholder-shown)) .error-message { display: block; }

진짜 JavaScript 한 줄도 없이 완성됩니다.

2. 네비게이션 활성 상태

자식 메뉴 중 하나가 활성화됐을 때 부모 메뉴도 강조하고 싶다면:

/* 하위 링크가 active면 드롭다운 버튼도 강조 */ .nav-dropdown:has(.nav-link.active) > .dropdown-toggle { color: var(--primary-color); font-weight: bold; } /* 서브메뉴가 있으면 화살표 표시 */ .nav-item:has(.submenu)::after { content: "▼"; font-size: 0.75em; margin-left: 0.5rem; }

3. 빈 상태(Empty State) 처리

컨테이너 안에 아이템이 없을 때 자동으로 안내 문구 표시:

.item-grid:not(:has(.item)) { display: flex; align-items: center; justify-content: center; min-height: 300px; } .item-grid:not(:has(.item))::before { content: "표시할 항목이 없습니다"; color: #999; font-style: italic; }

4. 아이템 개수에 따른 레이아웃

이건 좀 신기한 활용법이에요. 자식 개수에 따라 스타일을 다르게 할 수 있습니다:

/* 하나뿐이면 전체 너비 */ .item:only-child { width: 100%; } /* 딱 2개면 반반 */ .item:first-child:nth-last-child(2), .item:last-child:nth-last-child(2) { width: 50%; } /* 4개 이상이면 그리드로 */ .item-container:has(.item:nth-child(4)) { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }

5. 키보드 포커스 관리

접근성 향상에도 좋아요:

/* 카드 안 요소에 포커스가 가면 카드 전체 강조 */ .card:has(:focus-visible) { outline: 3px solid var(--focus-color); outline-offset: 2px; } /* 포커스된 카드 외에는 살짝 흐리게 */ .card-container:has(.card:focus-visible) .card:not(:focus-visible) { opacity: 0.7; }

6. 콘텐츠 기반 반응형 레이아웃

헤더에 검색바가 있냐 없냐에 따라 레이아웃을 바꾸는 식으로:

.header:has(.search-bar):has(.user-menu) { display: grid; grid-template-columns: auto 1fr auto; } .header:has(.search-bar):not(:has(.user-menu)) { display: grid; grid-template-columns: auto 1fr; }

7. 테이블 동적 스타일링

/* 체크된 행 강조 */ tr:has(input[type="checkbox"]:checked) { background: #e3f2fd; } /* 정렬 가능한 테이블이면 헤더에 커서 변경 */ table:has(th[data-sortable]) thead { cursor: pointer; } /* 빈 테이블 표시 */ table:not(:has(tbody tr))::after { content: "데이터가 없습니다"; display: block; text-align: center; padding: 2rem; color: #999; }

고급 테크닉

:has()와 :not() 콤보

이 조합이 진짜 강력해요:

/* 광고 없는 컨테이너만 패딩 추가 */ .container:not(:has(.advertisement)) { padding: 2rem; } /* 미디어 없는 텍스트 전용 카드 */ .card:not(:has(img, video, iframe)) { font-size: 1.1rem; line-height: 1.8; }

개발할 때 디버깅용으로 활용

이거 진짜 꿀팁이에요:

/* alt 없는 이미지 잡아내기 */ img:not([alt]), img[alt=""] { outline: 5px solid red !important; } /* action 없는 form 찾기 */ form:not([action]) { outline: 3px dashed orange !important; } /* 보안 취약한 새 창 열기 링크 */ a[target="_blank"]:not([rel*="noopener"]) { outline: 3px solid purple !important; }

개발 모드에서만 적용하면 접근성이나 보안 이슈를 한눈에 볼 수 있습니다.

"이전 형제" 선택 트릭

CSS엔 "이전 형제 셀렉터"가 없지만, :has()로 비슷하게 흉내낼 수 있어요:

/* active 아이템 바로 앞에 있는 아이템 */ .item:has(+ .item.active) { border-right: none; } /* 특별한 콘텐츠 앞에 오는 제목 */ h2:has(+ .special-content) { color: var(--accent-color); border-bottom: 3px solid currentColor; }

성능, 걱정해야 할까?

당연히 궁금하실 거예요. "부모 셀렉터가 성능 문제 때문에 안 됐다며? 지금은 괜찮은 거야?"

모던 브라우저는 이렇게 최적화합니다

  1. 탐색 깊이 제한: 무한정 깊이 탐색 안 해요
  2. 결과 캐싱: 한 번 계산하면 저장해둠
  3. DOM 변경 일괄 처리: 매번 다시 계산 안 하고 모아서 처리

그래도 지켜야 할 것들

구체적인 셀렉터 사용

/* ❌ 너무 광범위 */ div:has(img) { ... } /* ✅ 범위 한정 */ .card:has(img) { ... }

깊은 중첩 피하기

/* ❌ 너무 깊어요 */ .page:has(.section .container .row .col .card .badge) { ... } /* ✅ 직접 관계로 한정 */ .card:has(> .badge) { ... }

직접 자식 결합자 활용

/* ❌ 모든 자손 탐색 */ .menu:has(.active) { ... } /* ✅ 직접 자식만 확인 */ .menu:has(> .menu-item.active) { ... }

브라우저 지원 현황

2024년 말 기준으로 거의 다 지원합니다:

  • Chrome: 105+ (2022년 8월)
  • Edge: 105+ (2022년 8월)
  • Safari: 15.4+ (2022년 3월)
  • Firefox: 121+ (2023년 12월)
  • Opera: 91+

전 세계 사용자의 95% 이상:has() 쓸 수 있어요. 이제 프로덕션에서 써도 됩니다.

혹시 모르니까, 폴백 처리

레거시 브라우저 대응이 필요하다면:

/* 기본 스타일 */ .card { display: block; } /* :has() 지원하면 덮어쓰기 */ @supports selector(:has(*)) { .card:has(img) { display: grid; grid-template-columns: 200px 1fr; } }

흔한 실수들

실수 1: 명시도(Specificity) 착각

흔히 :has()가 명시도에 영향을 주지 않는다고 오해합니다. 하지만 사실 :has()는 인자로 전달된 셀렉터 중 가장 높은 명시도를 가져옵니다.

/* 명시도 계산 */ .card { ... } /* (0, 1, 0) */ .card:has(img) { ... } /* (0, 1, 1) -> img가 (0, 0, 1)이라서 추가됨 */ .card:has(#hero) { ... } /* (1, 1, 0) -> #hero가 (1, 0, 0)이라서 확 올라감 */

:where()는 명시도가 항상 0이지만, :has()는 내부 내용물에 따라 명시도가 변한다는 점을 꼭 기억하세요.

/* 명시도가 자연스럽게 높아지므로 덮어쓰기 문제 주의 */ .card:has(img) { ... }

실수 2: 모든 곳에 :has() 남발

간단한 건 그냥 클래스로:

/* ❌ 과하게 복잡 */ .btn:has(svg):has(span) { ... } /* ✅ 클래스 하나로 해결 */ .btn.icon-button { ... }

실수 3: 전체 선택자에 :has()

/* ❌ 이러면 느려요 */ *:has(.something) { ... } /* ✅ 범위 좁히기 */ .component:has(.something) { ... }

CSS 네스팅이랑 같이 쓰면

요즘 CSS 네이티브 네스팅이랑 같이 쓰면 코드가 훨씬 깔끔해져요:

.card { padding: 1rem; &:has(img) { display: grid; grid-template-columns: 200px 1fr; & img { border-radius: 8px; } } &:has(.badge) { position: relative; & .badge { position: absolute; top: -10px; right: -10px; } } }

실전 리팩토링 예시

네비게이션 컴포넌트를 리팩토링해 봅시다.

Before: JavaScript 필요했던 시절

<nav class="main-nav"> <ul class="nav-list"> <li class="nav-item has-submenu expanded"> <a href="#">제품</a> <ul class="submenu"> <li><a href="#" class="active">소프트웨어</a></li> <li><a href="#">하드웨어</a></li> </ul> </li> </ul> </nav>
// 이런 코드가 필요했음 document.querySelectorAll('.nav-item').forEach(item => { if (item.querySelector('.submenu')) { item.classList.add('has-submenu'); } if (item.querySelector('.active')) { item.classList.add('expanded'); } });

After: 순수 CSS로 해결

<nav class="main-nav"> <ul class="nav-list"> <li class="nav-item"> <a href="#">제품</a> <ul class="submenu"> <li><a href="#" class="active">소프트웨어</a></li> <li><a href="#">하드웨어</a></li> </ul> </li> </ul> </nav>
/* JavaScript? 필요 없어요 */ .nav-item:has(.submenu) > a::after { content: "▼"; } .nav-item:has(.active) > .submenu { display: block; } .nav-item:has(.active) > a { font-weight: bold; color: var(--primary); }

결과:

  • ✅ HTML이 깔끔해짐 (상태 클래스 불필요)
  • ✅ JavaScript 의존성 제거
  • ✅ DOM 바뀌면 자동으로 스타일도 업데이트
  • ✅ 성능도 더 좋음

마무리

CSS :has()는 단순 부모 셀렉터가 아닙니다. 스타일링 로직을 표현하는 방식 자체가 바뀐 거예요. 20년간 JavaScript로 억지로 해결하던 게 이제 CSS 한 줄로 됩니다.

핵심 정리:

  1. :has()관계형 셀렉터예요. 부모, 형제, 자손 조건 다 됩니다.
  2. 브라우저 지원 95% 이상. 지금 당장 프로덕션에서 써도 돼요.
  3. 성능 걱정? 구체적인 셀렉터 쓰고, 깊은 중첩만 피하면 괜찮아요.
  4. JavaScript 코드가 줄어듭니다. 상태 클래스 토글하던 코드, 이제 필요 없어요.
  5. CSS 네스팅, 컨테이너 쿼리랑 같이 쓰면 더 강력해져요.

지금 바로 여러분 코드에서 JavaScript로 클래스 토글하는 부분 하나 찾아보세요. :has()로 바꿀 수 있는지 확인해 보시고요. 생각보다 많은 곳에서 쓸 수 있을 겁니다.

CSSWeb DevelopmentFrontendPerformanceCSS Selectors

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요