정의
텍스트 앵커링(text anchoring) 은 웹 문서의 특정 구간을 나중에 다시 찾을 수 있도록 좌표를 저장하는 일이다. 견고한(robust) 앵커링은 그 좌표를 하나가 아니라 성질이 다른 여러 단서로 남기고, 복원할 때 각 단서에 가중치를 매겨 후보를 고른 뒤, 확신이 부족하면 붙이지 않는 방식을 말한다.
왜 필요한가
하이라이트나 메모를 저장할 때 가장 단순한 방법은 DOM 경로와 offset을 적는 것이다.
// 다음 방문에 거의 확실히 깨지는 저장 방식
{ path: "body > article > p:nth-child(3)", start: 42, end: 58 }
광고가 하나 삽입되고, 문단 순서가 바뀌고, 프레임워크가 목록을 다시 그리면 같은 좌표가 전혀 다른 글자를 가리킨다.
반대로 인용문만 저장하면 같은 표현이 문서에 여러 번 나올 때 어느 것인지 알 수 없고, 오타 교정이나 조사 하나만 바뀌어도 정확 일치가 실패한다.
그래서 W3C Web Annotation 모델은 selector를 여러 개 함께 저장하도록 설계됐다. 하나가 깨져도 나머지가 버티고, 여러 단서가 같은 지점을 가리키면 확신이 올라간다. 그리고 대부분의 도메인에서 엉뚱한 곳에 붙는 것이 안 붙는 것보다 나쁘다는 원칙이 임계값 설계를 좌우한다.
동작 원리
1단계: 문서를 하나의 정규화된 문자열로 만든다
DOM을 순회하며 유니코드 NFC로 통일하고 연속 공백을 하나로 합쳐 문서 전체를 단일 문자열로 만든다. 동시에 정규화된 위치 → 원본 텍스트 노드와 offset의 매핑을 보존한다.
// 노드 단위가 아니라 문서 단위로 공백을 처리해야 한다
// <p>구글<b>랩스</b></p> → "구글랩스" (중간에 공백이 끼면 안 됨)
SCRIPT, STYLE, hidden, aria-hidden 요소는 제외한다. 화면에 보이지 않는 글자가 섞이면 사용자가 선택한 문장과 인덱스가 어긋난다.
2단계: 저장할 때 여러 selector를 함께 남긴다
{
quote: { exact: "확신이 부족하면 멈춘다", prefix: "…앞 96자…", suffix: "…뒤 96자…" },
position: { start: 10432, end: 10444 } // 정규화 문자열 기준
}
요소 경계를 텍스트 경계로 옮기고 양 끝 공백을 다듬은 뒤 저장한다.
3단계: 복원은 3단 후보 탐색 + 가중 점수
| 단계 | 방법 | 점수 특성 |
|---|---|---|
| 정확 일치 | 정규화 문자열에서 exact 검색 | 가장 높은 가중치 |
| 문맥 일치 | 앞뒤 문맥 일부(예: 32자)로 근처 후보 탐색 | 중간 가중치 |
| 흐릿한 일치 | 토큰 창을 굴리며 Dice 유사도 등으로 유사 구간 탐색 | 점수 상한을 낮게 걸어 자동 적용에서 배제 |
각 후보의 점수를 0~1로 정규화한 뒤 세 갈래로 판정한다.
- 최고 점수가 하한 미만 → 미해결로 처리하고 붙이지 않는다.
- 1등과 2등의 점수 차가 작으면 → 후보가 있어도 모호로 처리한다. 이것은 확신이 아니라 경쟁 상황을 보는 별도 지표다.
- 상한 이상일 때만 자동으로 하이라이트한다. 그 사이 구간은 표시만 남기고 강조는 하지 않는다.
4단계: 복원 후 재검증
만들어 낸 Range의 내용을 다시 정규화해 원래 인용문과 대조한다. 어긋나면 실패로 처리한다. 마지막 안전망이다.
실무 적용
임계값은 상수로 한곳에 모으고 이름을 붙인다. 도메인마다 조정해야 하는 값이라 흩어지면 손댈 수 없게 된다.
export const CONFIDENCE = {
LOW: 0.78, // 이 아래는 미해결
HIGH: 0.9, // 이 위만 자동 적용
AMBIGUITY_GAP: 0.08, // 1등과 2등 차이가 이보다 작으면 모호
} as const;
캡처 시점도 중요하다. 사용자가 마우스를 떼는 순간 앵커를 계산해 값으로 들고 있어야 한다.
document.addEventListener("mouseup", () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
pendingAnchor = captureAnchor(selection.getRangeAt(0)); // 즉시 값으로 고정
});
저장 버튼 클릭까지 Range 객체를 들고 있으면, 그사이 페이지가 다시 그려지며 텍스트 노드가 교체돼 저장이 실패한다.
트레이드오프
단서를 여러 개 저장하면 저장 용량이 늘고 캡처 비용도 커진다. 문맥을 앞뒤 96자씩 담으면 주석 하나가 수백 바이트를 차지한다.
문맥 길이는 양방향 트레이드오프다. 짧으면 반복되는 구간에서 모호해지고, 길면 문서가 조금만 수정돼도 문맥 일치가 깨진다.
흐릿한 매칭은 복원율을 눈에 띄게 올리지만 오부착 위험을 함께 들여온다. 점수 상한을 걸어 자동 적용에서 빼는 것이 실질적인 타협점이다.
사용하면 안 되는 경우
- 내가 통제하는 정적 문서. 문서에 안정적인 ID를 부여할 수 있다면 그쪽이 훨씬 싸고 정확하다.
- 틀린 부착이 치명적인 도메인(법률 문서 서명, 의료 기록 등)에서 흐릿한 매칭을 자동 적용하는 것. 이런 곳은 미해결로 두고 사람이 확인하게 한다.
- 문서 전체가 매 방문마다 새로 생성되는 화면. 앵커가 아니라 데이터 모델 쪽에 식별자를 두는 편이 맞다.
흔한 실수
- 위치 offset을 단독으로 신뢰한다. 문서가 한 글자만 바뀌어도 전부 밀린다. 가산점 용도로만 쓴다.
- 노드 단위로 공백을 정규화한다. 인라인 태그로 쪼개진 단어 사이에 없는 공백이 생겨 정확 일치가 실패한다.
- 모호성 간격을 두지 않는다. 임계값만 있으면 0.91과 0.90인 두 후보 중 앞의 것을 아무 근거 없이 고르게 된다.
- 숨김 요소를 인덱스에 포함한다. 사용자가 본 적 없는 글자가 섞여 offset이 어긋난다.
- 선택 직후가 아니라 저장 시점에 앵커를 계산한다. 호스트 페이지의 리렌더 한 번에 조용히 실패한다.
- 재검증 단계를 생략한다. 점수는 높은데 실제로는 다른 구간을 가리키는 경우를 마지막에 걸러 낼 방법이 사라진다.
관련 개념
- browser-extension-message-based-rpc — 복원 결과를 다른 실행 공간의 화면으로 전달하는 상위 경로
- test-oracle — "확신이 부족하면 답하지 않고 멈춘다"는 같은 원칙의 다른 적용