모든 플랫폼에서 동작하는 하이라이트 만들기

재영·2025년 11월 6일

하이라이트

목록 보기
1/1
post-thumbnail

들어가며

봄봄은 뉴스레터를 모아 읽고 습관을 만드는 서비스입니다.
습관은 '읽기'만으로는 형성되지 않습니다. 저장하고, 활용하는 과정이 필요합니다. 그래서 본문 중 원하는 부분을 저장하는 하이라이트, 그 위에 생각을 남기는 메모를 만들었습니다. 이 두 기능은 사용자가 중요한 문장을 다시 찾고, 새롭게 알게 된 내용을 활용할 수 있도록 돕습니다.

이러한 목표를 구현하기 위해, 저는 브라우저의 Selection/Range API와 앵커(Anchor) 저장 전략을 조합하여 하이라이트를 설계했습니다. 아래에서 앵커 저장 전략의 선택 배경과 하이라이트 구현 과정을 단계별로 소개하겠습니다.

먼저 이해해야 할 것들

하이라이트는 결국 브라우저에서 “사용자가 드래그한 구간”을 안전하게 읽고, 나중에 복원하는 일입니다. 여기서 핵심은 Selection과 Range입니다.


1) Selection — “현재 선택 상태”를 나타내는 객체

  • 정의: 사용자가 선택한 텍스트 범위 또는 커서(캐럿) 위치를 나타내는 브라우저 객체
  • 역할: 하이라이트/메모 로직의 출발점(현재 선택 상태)

획득 방법

const selection = window.getSelection();
if (!selection) return; // 브라우저 지원 및 안전 체크

핵심 속성·메서드

  • rangeCount — 포함된 Range 개수(일반적으로 0 또는 1)
  • isCollapsed — 드래그 상태가 접혀 있는지 여부
  • getRangeAt(index) — Range 객체 가져오기(보통 getRangeAt(0))
  • removeAllRanges() / addRange(range) — 선택 영역 초기화/적용

2) Range — “선택 구간을 좌표로 표현하는” 객체

  • 정의: 문서 내 연속 구간을 표현하며, 시작/끝을 노드 + 오프셋으로 갖는다
  • 관계: Selection이 “상태”라면, Range는 그 상태의 정밀 좌표

획득 방법

const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;

핵심 속성·메서드(실무 필수)

  • startContainer / startOffset — 시작 지점(노드, 노드 내 문자 오프셋)
  • endContainer / endOffset — 끝 지점(노드, 노드 내 문자 오프셋)
  • toString() — 범위의 텍스트 내용 추출(quote 저장 등에 활용)
  • getBoundingClientRect() — 범위를 감싸는 단일 박스(툴팁 중앙 배치 등)
  • getClientRects() — 줄바꿈 등으로 나뉜 다중 라인 박스 목록
  • intersectsNode(node) — 특정 노드와의 교차 여부 확인(겹침 처리에 유용)
  • commonAncestorContainer — 시작/끝이 속한 최하위 공통 조상 노드
    • 텍스트 노드일 수 있으므로 순회 루트로 쓸 땐 parentNode로 올려 사용 권장

요약: Selection으로 “지금 선택된 상태”를 얻고, Range로 그 선택의 “정확한 좌표”를 다룹니다. 이 좌표를 안정적으로 저장·복원하는 것이 하이라이트 구현의 핵심입니다.

앵커 설계 옵션 비교 & 채택 이유

이제 선택된 Range를 “다시 열어도 같은 자리”로 복원하려면, 그 위치를 식별할 앵커(Anchor)가 필요합니다.

앵커를 어떻게 설계하느냐에 따라 내구성과 정확도가 크게 달라지는데요. 흔히 쓰는 세 가지 방식을 먼저 비교한 뒤, 봄봄에서 왜 특정 조합을 채택했는지를 설명하겠습니다.

세 가지 접근 방법

1) 텍스트 기반

  • TextQuote: 선택문 quote + 앞뒤 prefix/suffix로 위치 추정
  • 장점: DOM이 조금 바뀌어도 문장이 같으면 복원이 쉬움
  • 단점: 동일 문장 반복 시 모호성, 전역 검색으로 성능 비용

2) 구조 기반(요소 단위)

  • CSS Selector / DOM Path: 선택자 경로로 요소 찾기
  • 장점: 배우기 쉽고 빠름
  • 단점: 텍스트 노드 단위 정밀도 부족, 클래스/구조 변경에 취약

3) 구조 기반(노드·텍스트 정밀)

  • XPath: 트리 축/인덱스로 요소/텍스트 노드 직접 지정(text()[1] 등)
  • 장점: 정밀도 최고(텍스트 노드 + 오프셋), 결정적 복원
  • 단점: 중간 노드 삽입·이동 등 구조 변화에 인덱스 민감

이 세 가지 방법의 저장 형태를 간단한 예시 코드로 설명드리겠습니다.

<main class="article-body">
  <h1>오늘의 뉴스레터</h1>
  <p>아침 루틴을 만들기 위한 작은 팁들.</p>
  <p>데이터는 습관을 만든다. 반복은 이해를 낳는다.</p>
  <p>마지막으로 추천 아티클을 소개합니다.</p>
</main>

위와 같은 html이 있을 때, 두 번째 <p>의 “데이터는 습관을 만든다” 구절을 하이라이트 한다고 가정해봅시다.

  1. 텍스트 기반 — TextQuote
{
  "quote": "데이터는 습관을 만든다",
  "prefix": "",
  "suffix": " 반복은 이해를 낳는다."
}
  1. 구조 기반(요소 단위) — CSS Selector
{
  "selector": ".article-body > p:nth-of-type(2)",
  "quote": "데이터는 습관을 만든다",
  "offsetInElement": { "start": 0, "end": 13 } // 옵션: 요소 내부 오프셋
}
  1. 구조 기반(노드·텍스트 정밀) — XPath
{
  "startXPath": "/html/body/main/p[2]/text()[1]",
  "startOffset": 0,
  "endXPath": "/html/body/main/p[2]/text()[1]",
  "endOffset": 13,
}

봄봄에서 XPath를 선택한 이유

봄봄의 뉴스레터는 사용자에게 발송된 뒤 내용/형식이 변경되지 않는 특성이 있습니다. 즉, 서비스 관점에서 문서 구조가 고정됩니다. 이 전제 덕분에 XPath의 약점(구조 변동 민감도)이 크게 문제가 되지 않고, 대신 아래 장점을 최대화할 수 있습니다.

  • 텍스트 노드 레벨 정밀도: …/p[2]/text()[1]처럼 텍스트 노드를 직접 가리켜 startOffset/endOffset을 안정적으로 적용할 수 있습니다.
  • 빠르고 결정적인 복원: 전역 탐색 없이 즉시 노드 접근이 가능해, 다수 하이라이트의 배치 복원 성능이 좋습니다.
  • 템플릿 일관성과의 궁합: 발송 후 레이아웃이 바뀌지 않으므로 인덱스 기반 경로가 안정적으로 유지됩니다.

하이라이트 구현: 단계별로 뜯어보기

하이라이트의 사용자 흐름은 크게 네 단계입니다.

  1. 선택/클릭 감지
  2. 선택 저장(앵커 생성)
  3. 복원(페이지 방문 시)
  4. DOM에 하이라이트 그리기/지우기

각 단계를 구체적으로 살펴봅시다.


선택/클릭 감지

하이라이트 UX의 첫 단추는 “언제 툴바를 띄울지” 를 정확히 판단하는 것입니다. 핵심은 두 가지입니다.

  1. 플랫폼별 이벤트 순서 차이를 이해하고,
  2. 단순 클릭 vs 드래그 선택을 안정적으로 구분하기.

A. 플랫폼별 이벤트 흐름 파악하기

플랫폼마다, 그리고 단순 클릭과 꾹 눌러 드래그(텍스트 선택) 시의 이벤트 흐름이 다릅니다. 이를 명확히 분리해 처리해야 잘못된 동작을 방지할 수 있습니다.

1) PC 웹(브라우저)

  • 클릭 & 드래그: pointerdown → mousedown → pointerup → mouseup → click

2) iOS

  • 클릭: pointerdown → touchstart → pointerup → touchend → mousedown → mouseup → click
  • 드래그: pointerdown → touchstart → pointerup → touchend

3) Android

  • 클릭: iOS와 동일
  • 드래그: pointerdown → touchstart → contextmenu(드래그 시작) → pointercancel → touchcancel → contextmenu(드래그 끝)

Android는 드래그 시작/끝 모두 contextmenu 가 발생합니다.
즉, 하나의 이벤트로 선택 완료 시점을 감지하게 되는 셈이죠.


B. 클릭 vs 드래그 판별하기

Selection API만으로도 대부분 판별이 가능합니다.

  • selection.rangeCount > 0 그리고
  • selection.isCollapsed === false → 드래그(텍스트 선택) 상태
  • 그 외 → 단순 클릭/선택 해제
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
  // ✅ 드래그(선택) 상태 로직
  // openToolbarFromSelection(selection);
  return;
}
// ⛔ 선택 없음 → 단순 클릭 또는 선택 해제

Android의 특수성
위 방식은 PC와 iOS에서 잘 작동하지만, Android는 예외입니다.
Android는 드래그 완료 시 contextmenu 이벤트가 발생하는 독특한 구조를 가지고 있습니다. 이로 인해 하나의 이벤트 핸들러로 클릭과 드래그를 함께 처리하기 어렵습니다.
따라서 Android에서는 이벤트 레벨에서 역할을 분리합니다:

  • contextmenu → 선택 완료(드래그) 처리
  • click → 기존 하이라이트 클릭 처리

C. 최종 구현 (핵심 코드)

위 분석을 바탕으로, 플랫폼별로 서로 다른 이벤트 핸들러 조합이 필요합니다:

플랫폼전략사용 이벤트
iOS하이라이트 클릭 + 드래그 선택을 하나의 핸들러로 통합pointerup
Android드래그 선택과 하이라이트 클릭을 별도 핸들러로 분리contextmenu + click
PC 웹iOS와 동일한 통합 방식 + 선택 해제 감지mouseup + selectionchange

이를 구현한 코드는 다음과 같습니다:

// Android 전용: 드래그 선택 완료 처리
const handleSelectionComplete = useCallback(() => {
  const selection = window.getSelection();
  if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
    openToolbarFromSelection(selection);
    return;
  }
}, [openToolbarFromSelection]);

// Android 전용: 기존 하이라이트 클릭 처리
const handleHighlightClick = useCallback(
  (e: PointerEvent | MouseEvent) => {
    const target = e.target as HTMLElement;

    // 1) 기존 하이라이트 클릭 → 편집/툴바
    if (target.tagName === 'MARK') {
      openToolbarFromHighlight(target);
      return;
    }

    // 2) 아닐 경우 → 툴바 닫기
    onHide();
  },
  [openToolbarFromHighlight, onHide],
);

// iOS/PC 웹 전용: 클릭과 드래그를 하나의 핸들러로 통합
const handleHighlightClickOrSelection = useCallback(
  (e: PointerEvent | MouseEvent) => {
    const target = e.target as HTMLElement;

    // 1) 기존 하이라이트 클릭
    if (target.tagName === 'MARK') {
      openToolbarFromHighlight(target);
      return;
    }

    // 2) 드래그(선택) 여부
    const selection = window.getSelection();
    if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
      openToolbarFromSelection(selection);
      return;
    }

    // 3) 둘 다 아님 → 닫기
    onHide();
  },
  [onHide, openToolbarFromHighlight, openToolbarFromSelection],
);

// === 플랫폼별 안전한 이벤트 바인딩 ===
if (isIOS()) {
  // iOS: pointerup 하나로 클릭/드래그 통합 처리
  document.addEventListener('pointerup', handleHighlightClickOrSelection);
} else if (isAndroid()) {
  // Android: 드래그는 contextmenu, 클릭은 click으로 분리
  document.addEventListener('contextmenu', handleSelectionComplete);
  document.addEventListener('click', handleHighlightClick);
} else if (!isWebView()) {
  // PC 웹: mouseup으로 통합, selectionchange로 해제 감지
  document.addEventListener('mouseup', handleHighlightClickOrSelection);
  document.addEventListener('selectionchange', handleSelectionClear);
}

핵심 포인트:

  • 하이라이트 클릭 → 드래그 선택 → 그 외 해제의 3단 분기
  • 플랫폼별 이벤트 타이밍 차이를 고려한 안전한 바인딩
  • Android는 contextmenu의 특수성 때문에 핸들러 분리 필수

선택 저장(앵커 생성)

하이라이트를 저장하는 목표는 간단합니다. 지금의 Range를 "나중에 정확히 복원"할 수 있을 만큼 결정적인 좌표로 바꾸는 것인데요.
여기서는 Selection/Range → XPath + 오프셋으로 변환하는 과정을 설명합니다.


어떤 XPath를 저장하는 것이 가장 좋을까?

처음에는 드래그 시작 노드의 XPath+offset, 드래그 끝 노드의 XPath+offset을 그대로 저장하려 했습니다.
하지만 이렇게 하면 start↔end 사이의 모든 노드를 나중에 복원 시 다시 찾아 조립해야 했습니다. 드래그가 길수록 이 과정이 복잡해지고 오류 여지가 커집니다.

그래서 방향을 바꿨습니다.

  1. commonAncestorContainer(공통 조상)를 구해 그 조상의 XPath만 저장
    • range.commonAncestorContainer 속성으로 공통 조상을 가져올 수 있습니다.
  2. 공통 조상 하위의 모든 TextNode를 직렬로 연결한 하나의 "논리 텍스트"로 보고, 그 안에서 startOffset/endOffset을 계산해 저장

이렇게 하면 저장 형식은 단순해지고, 복원 시에도 한 번의 트리 순회로 정확한 Range를 재구성할 수 있습니다.


예시로 보는 오프셋 계산

<main class="article-body">
  <h1>오늘의 뉴스레터</h1>
  <p>아침 루틴을 만들기 위한 작은 팁들.</p>
  <p>데이터는 습관을 만든다. 반복은 이해를 낳는다.</p>
  <p>마지막으로 추천 아티클을 소개합니다.</p>
</main>

첫 번째 <p>의 "아침"부터 세 번째 <p>의 "합니다"까지 드래그했다고 가정해 봅시다.

  • 공통 조상은 <main class="article-body">이고, 이 요소의 XPath를 저장합니다.

  • 오프셋 계산은 <main> 내부의 모든 TextNode를 순서대로 연결한 뒤,

    • startOffset: 드래그 시작 지점까지의 누적 문자 수
    • endOffset: 드래그 끝 지점까지의 누적 문자 수

    로 정의합니다.

구체적인 계산 과정:

TextNode 1: "오늘의 뉴스레터" (8자)
TextNode 2: "아침 루틴을 만들기 위한 작은 팁들." (20자)
TextNode 3: "데이터는 습관을 만든다. 반복은 이해를 낳는다." (27자)
TextNode 4: "마지막으로 추천 아티클을 소개합니다." (22자)

→ startOffset = 8 (h1 끝) + 0 ("아침"의 시작) = 8
→ endOffset = 8 + 20 + 27 + 22 = 77

구현 코드

1) 노드 → XPath: getXPathForNode

const ROOT_PATH = '.';

/** 특정 노드의 XPath를 구하는 함수 */
export const getXPathForNode = (node: Node, root: Node = document): string => {
  // TextNode일 경우 부모 요소로 이동
  if (node.nodeType === Node.TEXT_NODE) node = node.parentNode!;
  if (node === root) return ROOT_PATH;

  const index =
    Array.from(node.parentNode!.childNodes)
      .filter((n) => n.nodeName === node.nodeName)
      .indexOf(node as ChildNode) + 1;

  return (
    // 재귀적으로 계산
    getXPathForNode(node.parentNode!, root) +
    '/' +
    node.nodeName.toLowerCase() +
    `[${index}]`
  );
};

2) 컨테이너 내부 오프셋 계산: getHighlightOffsets

export const getHighlightOffsets = (container: Element, range: Range) => {
  let start = -1;
  let end = -1;
  let currentOffset = 0;

  // NodeFilter.SHOW_TEXT를 통해서 TextNode만 필터링
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);

  while (walker.nextNode()) {
    const node = walker.currentNode as Text;

    if (node === range.startContainer)
      start = currentOffset + range.startOffset;

    if (node === range.endContainer)
      end = currentOffset + range.endOffset;

    currentOffset += node.textContent!.length;
  }
  return { start, end };
};
  • 컨테이너 내부의 모든 TextNode를 좌→우 순회하면서 currentOffset을 누적
  • startContainer/endContainer를 만나면 해당 노드 내 오프셋을 더해 문서 내 절대 오프셋으로 변환

3) 저장 페이로드 생성: saveSelection

export const saveSelection = (range: Range) => {
  const container =
    // 공통 조상이 TextNode일 경우 부모 Element를 공통 조상으로 함
    range.commonAncestorContainer.nodeType === Node.TEXT_NODE
      ? range.commonAncestorContainer.parentElement!
      : (range.commonAncestorContainer as Element);

  const xpath = getXPathForNode(container);
  const { start, end } = getHighlightOffsets(container, range);

  return {
    location: {
      startXPath: xpath,
      startOffset: start,
      endXPath: xpath,  // 공통 조상 XPath를 양쪽에 저장
      endOffset: end,
    },
    color: theme.colors.primaryLight, // 하이라이트 색
    text: range.toString(), // 선택된 텍스트(검증/미리보기용)
  };
};

API 설계 변경 기록
초기에는 startXPath/endXPath에 각각의 노드 XPath를 저장하려 했으나,
멀티 문단 드래그에서 복원이 복잡해져서 공통 조상의 XPath를 양쪽에 동일하게 저장하는 형태로 전환했습니다. 😅

복원(페이지 방문 시) — 저장된 앵커로 Range 재구성

저장까지 했다면, 이제는 저장된 데이터(location)를 바탕으로 DOM에서 다시 정확한 Range를 만들어 <mark>로 그려야 합니다.
복원의 핵심은 단순합니다. 저장의 역순으로 진행하면 됩니다.

  • 입력: startXPath(=공통 조상), startOffset, endOffset
  • 목표: 공통 조상 컨테이너 안에서 문자 오프셋 → (텍스트 노드, 노드 내 오프셋)으로 매핑해 Range 생성

1) XPath로 컨테이너 노드 복원하기

/** XPath로 노드를 다시 찾는 함수 */
export const getNodeByXPath = (xpath: string, root: Document = document) => {
  const result = document.evaluate(
    xpath,                              // 저장된 XPath
    root,                               // 보통 document
    null,                               // 네임스페이스 리졸버(필요 없으면 null)
    XPathResult.FIRST_ORDERED_NODE_TYPE,// 첫 번째 매칭 노드만 가져오기
    null,
  );
  return result.singleNodeValue;        // Element | Text | null
};
  • XPath는 W3C 표준 쿼리 언어이기 때문에 브라우저에 표준 API가 있습니다.
  • document.evaluate를 쓰면 한 줄로 노드를 쉽게 되찾을 수 있습니다.

2) 컨테이너 내부 오프셋 → Range로 변환하기

function getHighlightRange(container: Node, start: number, end: number) {
  const range = document.createRange();
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);

  let currentOffset = 0;

  const positions = {
    startNode: null as Text | null,
    endNode: null as Text | null,
    startOffset: 0,
    endOffset: 0,
  };

  while (walker.nextNode()) {
    const node = walker.currentNode as Text;
    const len = node.textContent!.length;

    // start 지점이 이 텍스트 노드 범위 안에 들어오는가?
    if (
      !positions.startNode &&
      start >= currentOffset &&
      start <= currentOffset + len
    ) {
      positions.startNode = node;
      positions.startOffset = start - currentOffset; // 노드 내부 오프셋으로 변환
    }

    // end 지점이 이 텍스트 노드 범위 안에 들어오는가?
    if (
      !positions.endNode &&
      end >= currentOffset &&
      end <= currentOffset + len
    ) {
      positions.endNode = node;
      positions.endOffset = end - currentOffset; // 노드 내부 오프셋으로 변환
    }

    currentOffset += len; // 다음 텍스트 노드로 이동하기 전에 누적 길이 갱신
  }

  if (!positions.startNode || !positions.endNode) {
    // 저장/복원 정규화 규칙이 다르면 오프셋 매칭 실패 가능
    throw new Error('Offset 변환 실패');
  }

  range.setStart(positions.startNode, positions.startOffset);
  range.setEnd(positions.endNode, positions.endOffset);

  return range;
}
  • 컨테이너 내부의 모든 텍스트 노드를 왼쪽→오른쪽으로 순회하면서,
  • 저장된 startOffset/endOffset이 어떤 텍스트 노드의 몇 번째 문자에 해당하는지 계산해 Range를 만듭니다.

3) 전체 복원 흐름 (요약)

// 1) 컨테이너 복원
const container = getNodeByXPath(highlight.location.startXPath);
if (!container) return;

// 2) Range 재구성
const range = getHighlightRange(
  container,
  Number(highlight.location.startOffset),
  Number(highlight.location.endOffset),
);

// 3)  태그로 하이라이트 그리기
renderHighlight(range, highlight.color, highlight.id);

참고: renderHighlight 함수는 다음 섹션에서 자세히 다룹니다.
이 함수는 Range 내의 텍스트 노드들을 찾아 각 노드의 해당 구간을 <mark> 태그로 감싸는 역할을 합니다.

DOM에 하이라이트 그리기/지우기

하이라이트 표시 방법은 크게 두 가지가 있습니다.

  1. CSS Custom Highlight API: 실제 DOM을 건드리지 않고, HighlightRegistry에 Range를 등록해 ::highlight(name)로 스타일링합니다.
  2. DOM 래핑 방식: 선택 구간을 <mark>(또는 <span>) 같은 실제 요소로 감싸 스타일링합니다.

봄봄은 <mark> 래핑을 채택했습니다. 이유는 다음과 같습니다.

  • 의미/접근성(A11y): <mark>는 "문맥상 강조된 텍스트"라는 시맨틱 태그라서 스크린 리더 등 보조기술에서 의미가 분명합니다.
  • 상호작용: <mark data-highlight-id="…">처럼 식별자/메타데이터를 바로 심어 클릭·롱프레스·툴팁 등 이벤트 타깃으로 쓰기 쉽습니다. Custom Highlight는 DOM 노드를 생성하지 않아서 dataset/이벤트 바인딩이 어렵습니다.
  • 다중 색상/스타일: 색상·밑줄·점선 등 스타일을 하이라이트 단위로 자유롭게 적용 가능합니다.
  • 호환성/일관성: 다양한 웹뷰/브라우저에서 동일한 동작을 보장하기 용이합니다. Custom Highlight는 구현·지원 레벨/행동이 플랫폼별로 상이할 수 있습니다.

하이라이트 그리기: 텍스트 조각만 <mark>로 감싸기

하이라이트는 텍스트 노드 하나 안에서도 start~end 구간만 칠해야 합니다. 그래서 원래 텍스트를 before | middle | after로 나누고, middle만 <mark>로 감싸 한 번에 교체합니다.

export const highlightNodeSegment = (
  node: Text,
  start: number,
  end: number,
  color: string,
  highlightId: number,
) => {
  const parent = node.parentNode!;
  const before = node.textContent!.slice(0, start);
  const middle = node.textContent!.slice(start, end);
  const after  = node.textContent!.slice(end);

  // 의미 태그: 문맥상 강조
  const mark = document.createElement('mark');

  // 하이라이트 색
  mark.style.backgroundColor = color;

  // 상호작용/삭제를 위한 식별자
  mark.dataset.highlightId = String(highlightId);

  // 텍스트 채우기
  mark.textContent = middle;

  // DocumentFragment로 한 번에 교체 → reflow 최소화
  const frag = document.createDocumentFragment();
  if (before) frag.appendChild(document.createTextNode(before));
  frag.appendChild(mark);
  if (after)  frag.appendChild(document.createTextNode(after));

  // 기존 텍스트 노드를 전체 교체
  parent.replaceChild(frag, node);
};

왜 DocumentFragment인가요?
appendChild를 여러 번 하는 대신, 오프라인 트리에서 조립 → 한 번에 교체하면 reflow/repaint 비용을 줄일 수 있습니다.


하이라이트 지우기: <mark>를 텍스트로 환원

삭제는 간단합니다. 특정 highlightId를 가진 <mark>들을 찾아 내부 텍스트 노드로 되돌리기만 하면 됩니다.

export const removeHighlightFromDOM = (highlightId: number) => {
  const marks = document.querySelectorAll(
    `mark[data-highlight-id="${highlightId}"]`,
  );

  marks.forEach((mark) => {
    const textNode = document.createTextNode(mark.textContent ?? '');
    mark.replaceWith(textNode);
  });
};

전체 흐름 정리

지금까지 하이라이트 구현의 네 단계를 모두 살펴봤습니다:

  1. 선택/클릭 감지 - 플랫폼별 이벤트 처리
  2. 선택 저장 - Range를 XPath + 오프셋으로 변환
  3. 복원 - 저장된 데이터로 Range 재구성
  4. 그리기/지우기 - <mark> 태그로 시각화

이 네 단계가 유기적으로 연결되어 "읽은 내용을 저장하고 다시 찾아볼 수 있는" 하이라이트 기능을 완성합니다.

전체 코드

전체 구현은 GitHub에서 자세하게 확인할 수 있습니다.

마무리하며

하이라이트는 "선택한 구간을 표시한다"만 보면 단순해 보입니다. 그러나 모든 플랫폼에서 일관되게 동작하도록 이벤트를 다루고, 저장/복원 로직을 안정적으로 설계하며, 실전의 예외 케이스(멀티 문단, 웹뷰 차이, 리렌더링 충돌)들을 처리하는 순간부터 복잡도가 급격히 올라갑니다.

이번 글에서는 그 복잡도를 낮추기 위해 컨테이너 XPath + 내부 오프셋이라는 앵커 전략을 선택하고, 선택→저장→복원→그리기/삭제까지의 전 과정을 단계별로 정리했습니다.

핵심은 세 가지였습니다.

  • 정확성: 공통 조상과 오프셋 기반으로 멀티 문단도 결정적으로 복원
  • 일관성: iOS/Android/PC 각각의 이벤트 흐름을 분리해 안정적 트리거 확보
  • 실용성: 의미/접근성/상호작용에 유리한 <mark>로 DOM을 직접 마킹

하지만 "동작한다"만으로는 충분하지 않습니다. 사용자가 실제로 쓰고 싶어지는 경험을 만들려면 더 많은 디테일이 필요합니다.

다음 편에서는 하이라이트 UX를 한 단계 더 개선하는 디테일과 React 환경에서 마주치는 실전 이슈들을 구체적인 코드와 함께 공유하겠습니다.

4개의 댓글

comment-user-thumbnail
2025년 11월 6일

맛 있는 데요 ?

1개의 답글
comment-user-thumbnail
2025년 11월 6일

즐겨찾기 하이라이트~

1개의 답글