dev.to

The Search Highlight That Deletes Your Selection

검색 하이라이트가 사용자의 선택 영역을 삭제하는 이유

검색어를 강조하려고 innerHTML로 문단을 매번 다시 만들면, 사용자가 선택한 텍스트의 DOM 노드가 제거되어 복사 내용이 사라질 수 있습니다. CSS Custom Highlight API로 노드 구조를 건드리지 않고 Range를 오버레이로 칠하면 선택 영역을 보존하면서 검색 결과를 표시할 수 있습니다.

AI 요약

사용자가 문단을 드래그해 선택한 뒤 복사했는데 붙여넣은 내용이 비어 있는 버그가 발생할 수 있습니다. 이때 사용자는 선택과 복사 외에 아무것도 하지 않았다고 설명하지만, 몇 초 전에 입력한 검색어가 페이지 안에서 계속 동작하며 문단의 검색 결과를 강조하고 있었을 수 있습니다. 문제는 검색어가 선택된 텍스트와 겹치는지 여부가 아니라, 검색 강조 기능이 문단을 다시 구성하는 방식 자체에 있습니다.

■ innerHTML이 선택 영역을 삭제하는 방식

검색 결과를 강조하는 가장 익숙한 구현은 정규식으로 일치 항목을 찾은 다음, 해당 부분을 <mark>로 감싸고 결과를 innerHTML에 다시 대입하는 방식입니다. 검색 입력란의 input 이벤트마다 이 함수를 실행하면 다음과 같은 흐름이 됩니다. 검색어를 이스케이프하고, 대소문자를 구분하지 않는 정규식으로 일치 항목을 찾고, 원래 텍스트를 <mark> 태그가 포함된 HTML 문자열로 바꾼 뒤 container.innerHTML에 기록합니다.

하지만 container.innerHTML = ...은 기존 자식 노드를 수정하거나 필요한 부분만 패치하지 않습니다. 컨테이너 안에 있던 모든 자식 노드를 제거하고, 전달받은 문자열을 파싱해 완전히 새로운 노드 집합을 만듭니다. 브라우저가 가상 DOM처럼 기존 노드를 비교해 재사용하거나 변경 범위를 최소화해 주지 않습니다. 따라서 문제를 일으키는 것은 정규식이나 <mark> 태그가 아니라 기존 DOM을 통째로 교체하는 innerHTML 대입입니다.

■ 브라우저의 선택 영역은 DOM 노드를 참조합니다

브라우저의 텍스트 선택은 Range로 표현됩니다. Range는 시작점과 끝점이라는 두 개의 경계점을 가지며, 각 경계점은 특정 DOM 노드와 그 노드 안의 오프셋을 참조합니다. 사용자가 문단의 일부를 드래그해 선택하면 브라우저는 해당 텍스트 노드를 기준으로 선택 영역을 관리합니다.

이 상태에서 innerHTML 대입이 실행되면 선택 영역의 경계점이 참조하던 노드가 문서에서 제거됩니다. 그러면 Range가 더 이상 가리킬 대상이 없어지므로 선택 영역은 사라집니다. 선택한 텍스트 안에 검색어가 포함되어 있는지, 검색어가 문단의 처음이나 끝에 있는지, 검색어가 한 글자뿐인지와 관계없이 같은 현상이 발생합니다. 모든 자식 노드가 제거되기 때문에, 검색 강조와 무관해 보이는 텍스트를 선택했더라도 해당 노드에 걸려 있던 선택 영역은 함께 사라집니다. 사용자가 화면에서 보던 노란색 ::selection 강조도 선택 자체가 사라지면서 없어집니다.

■ 오프셋 저장은 동작하지만 잘못된 계층의 해결책입니다

다음으로 흔히 시도하는 방법은 DOM을 다시 만들기 전에 선택 영역의 시작·끝 오프셋을 저장하고, innerHTML로 문단을 재구성한 뒤, 같은 오프셋 위치에 새로운 Range를 만들어 selection.addRange()로 선택을 복구하는 것입니다. 이 방식은 기술적으로 동작할 수 있습니다. 그러나 검색어가 입력될 때마다 선택을 저장하고, 새 노드 구조에서 위치를 다시 계산하고, 선택을 다시 적용해야 합니다. 화면의 색상만 바꾸려던 기능이 매 입력마다 브라우저 선택 상태를 직접 보존하는 로직까지 떠안게 됩니다. 이는 문제를 잘못된 계층에서 해결하고 있다는 신호입니다.

■ 스타일만 바꿀 때는 CSS Custom Highlight API를 사용합니다

검색 결과 강조는 문서 구조나 텍스트 내용을 변경하는 기능이 아니라, 특정 문자가 화면에서 어떤 색으로 보일지를 결정하는 순수한 스타일링 작업입니다. CSS Custom Highlight API는 이 목적에 맞춰 DOM 노드를 삽입하거나 제거하지 않고 Range 집합을 시각적으로 칠합니다.

구현에서는 먼저 CSS.highlights.delete("search-results")로 이전 검색 결과를 제거합니다. 검색어가 비어 있으면 종료하고, 그렇지 않으면 document.createTreeWalker()와 NodeFilter.SHOW_TEXT를 사용해 컨테이너 안의 텍스트 노드를 순회합니다. 각 텍스트 노드에서 검색어의 위치를 찾을 때마다 새로운 Range를 만들고, 해당 노드의 시작 오프셋과 끝 오프셋으로 범위를 설정합니다. 모든 일치 항목을 ranges 배열에 모은 뒤 new Highlight(...ranges)로 Highlight 객체를 만들고 CSS.highlights.set("search-results", ...)로 이름을 등록합니다.

CSS에서는 ::highlight(search-results)에 background-color: gold와 color: #111 같은 스타일을 지정합니다. 이 과정에는 노드 삽입, 노드 제거, 텍스트 분할, innerHTML 대입이 전혀 없습니다. 문단의 텍스트 노드는 페이지가 처음 파싱될 때 만들어진 상태로 유지되고, Highlight는 CSS.highlights에 등록된 Range 집합을 브라우저가 오버레이처럼 그리도록 합니다. 사용자가 마우스로 만든 선택 영역도 별도의 Range와 기존 노드에 남아 있으므로 검색 기능이 존재하는지 알지 못한 채 유지됩니다.

■ 여러 하이라이트가 겹칠 때의 우선순위

실제 페이지에서는 검색 결과 강조 하나만 존재하지 않을 수 있습니다. 코드 뷰어에서는 syntax highlighting과 search-result highlighting이 같은 코드 블록 위에 동시에 적용될 수 있습니다. CSS Custom Highlight API의 Highlight 객체에는 이런 겹침을 제어하기 위한 priority 속성이 있습니다.

예를 들어 syntax Highlight의 priority가 기본값인 0이고, 검색 결과 Highlight의 priority를 1로 설정하면 두 하이라이트가 같은 문자를 덮을 때 검색 결과가 위에 표시됩니다. 충돌하는 속성은 낮은 우선순위부터 적용되고 높은 우선순위의 스타일이 나중에 적용됩니다. 두 객체의 priority를 설정하지 않으면 모두 0이 되며, 같은 우선순위에서는 최근에 등록된 Highlight가 선택될 수 있으므로 검색 결과를 항상 syntax coloring 위에 두려면 명시적으로 우선순위를 설정하는 편이 필요합니다.

다만 브라우저가 기본으로 제공하는 하이라이트는 모든 커스텀 하이라이트보다 위에 그려집니다. ::selection, ::spelling-error 같은 기본 하이라이트는 Custom Highlight의 priority로 앞지를 수 없습니다. 사용자의 실제 선택 영역을 검색 결과보다 우선해 표시하는 동작은 플랫폼이 보장하는 영역이므로, DOM을 다시 만들어 선택 영역과 경쟁하는 기존 구현보다 Custom Highlight API가 브라우저의 기본 동작에 맞는 접근입니다.

■ 브라우저 지원과 호환성

CSS Custom Highlight API는 더 이상 실험용 플래그에만 의존하는 기능이 아닙니다. Chrome과 Edge는 105 버전부터 2022년에 지원했으며, Safari는 17.2 버전에서 2023년 12월에 추가했습니다. Firefox는 140 버전에서 2025년 6월에 지원해 Baseline의 새 사용 가능 기능으로 분류되는 계기가 되었습니다.

브라우저별 차이도 확인해야 합니다. Firefox의 구현은 아직 ::highlight() 안에서 text-decoration과 text-shadow를 적용하지 않습니다. 반면 background-color, color, font-weight는 모든 브라우저에서 동작합니다. 따라서 크로스 브라우저 기능에서는 이 세 가지 속성을 중심으로 사용하고, 밑줄이나 그림자 효과는 Chromium과 Safari에서만 가능한 보조 기능으로 취급해야 합니다.

이번 사례의 핵심은 렌더링 오류나 복사 기능의 브라우저 버그가 아닙니다. 검색 기능이 의도하지 않은 부작용으로 사용자의 선택 영역이 의존하던 정확한 DOM 노드를 삭제했기 때문입니다. ‘텍스트의 모양만 바꾼다’는 요구를 ‘텍스트 구조를 다시 만든다’로 구현하면, 한 번의 디바운스된 검색 입력만으로도 선택 영역과 클립보드 동작을 깨뜨릴 수 있습니다. 정규식과 <mark> 태그를 함께 사용하면서 innerHTML을 다시 대입하는 코드가 있다면, 그 코드가 단순한 스타일 변경을 위해 DOM 전체를 교체하고 있지는 않은지 점검해야 합니다.

원문: dev.to / 번역·요약: Trawling