DOM API 에서 발견한 숫자의 기준값

#에디터#JavaScript#브라우저

부제: 0.99999999999994315... 숫자의 의미

서론

에디터를 개발했을 때 종종 예상치 못한 문제를 만나게 되는데요.

저번에는 커서를 직접 그리는 과정에서 생긴 이슈를 공유드렸습니다. 커서를 그려주었다면 이제는 그 커서를 텍스트에 정확히 위치시키는 과정이 필요하겠죠. 이번 글에서는 바로 이 과정에서 마주친 웹 브라우저의 특이한 이슈를 소개하려 합니다.

커서를 직접 구현하다 보면 거의 필수적으로 사용하게 되는 caretRangeFromPoint API에서 발생한 문제인데요, 이는 부동소수점 연산과도 깊은 관련이 있습니다. (다만 해당 문제는 현재 발생하지 않습니다..!)

문제 상황: 간헐적으로 발생하는 이상한 커서 이동

한 문장(paragraph)이 두 줄 이상일 경우, 마지막 줄의 빈 공간을 클릭하면 커서가 올바르게 위치하지 않는 오류가 발생했습니다.

  • 정상 동작: 마지막 줄 텍스트 끝에 커서가 위치
  • 비정상 동작: 클릭한 x 좌표와 비슷한 윗줄의 동일 위치에 커서가 이동

커서를 위치시키기 위해 caretRangeFromPoint API를 사용하고 있었는데요.

caretRangeFromPoint란?

caretRangeFromPoint(x, y)

  • x, y 좌표를 받아 가장 가까운 텍스트 range를 반환
  • 단, display: block 요소는 무시됨 (글자가 들어가지 않아서일까 추측됨)

이 문제에서 가장 당황스러운 점은 재현 방식이 일정하지 않았다는 것입니다. 간헐적으로 발생하는 것 같았지만, 자세히 보니 특정 좌표에서만 반복적으로 오류가 재현되었습니다. 즉, 문제가 발생하는 위치에서는 항상 잘못된 결과가 나오고, 정상 동작하는 위치에서는 계속 정상 동작을 했습니다.

첫 번째 추측: 글자 문제

같은 너비 안에서 마지막 글자가 a일 때만 오류가 나고 e에서는 나지 않길래, 처음엔 글자(glyph) 자체의 문제라고 의심했습니다. 하지만 로그를 보니 문제는 글자 모양이 아니라 그 글자가 끝나는 좌표의 소수점 패턴이었습니다.

두 번째 추측: 페이지 너비/레이아웃 문제

다음으로는 페이지 너비(반응형 레이아웃, 줄바꿈 지점) 때문이라고 추측했습니다. 페이지를 변경하면 이슈가 잘 발생하는 너비와 잘 발생하지 않는 너비가 있었습니다. 그런데 레이아웃이 달라져도 공통적으로 경계 좌표의 소수점이 특정 패턴에 걸릴 때만 문제가 반복되었습니다.

결국 숫자(소수점) 문제

결국 문제는 글자나 레이아웃이 아니라 좌표 숫자였습니다.

빈 공간을 클릭하면 가까운 텍스트로 커서를 옮기기 위해 getBoundingClientRect()텍스트 경계값을 구해 좌표를 보정하고, 그 좌표를 다시 caretRangeFromPoint에 전달합니다. 이 보정 과정에서 생긴 미세한 소수부 오차 때문에 API가 가까운 텍스트를 찾지 못해 윗줄 텍스트를 선택하는 현상이 나타났습니다.

"혹시나…" 하는 마음으로 반올림을 적용해 보니 증상은 사라졌습니다.

하지만 근거 없는 반올림은 채택할 수 없습니다. 텍스트 에디터는 사용자의 1픽셀 차이도 정확히 반영해야 하니까요. 반올림은 임시 확인에만 사용했고, 근본 원인을 계속 추적했습니다.

진짜 원인: 기준값을 찾아낸 순간

문제를 재현하기 위해 좌표값을 하나하나 테스트해 보기 시작했습니다.

// 이상한 패턴 발견
caretRangeFromPoint(100.99999999999991); // ❌ 비정상 동작
caretRangeFromPoint(100.99999999999998); // ✅ 정상 동작
caretRangeFromPoint(100.99999999999993); // ❌ 비정상 동작
caretRangeFromPoint(100.99999999999996); // ✅ 정상 동작
caretRangeFromPoint(100.99999999999994); // ❌ 비정상 동작
caretRangeFromPoint(100.99999999999995); // ✅ 정상 동작
// 더 정확한 기준값 찾기 - 94 구간에서 세밀하게 탐색
 
caretRangeFromPoint(100.999999999999941); // ❌ 비정상 동작
caretRangeFromPoint(100.999999999999948); // ✅ 정상 동작
caretRangeFromPoint(100.999999999999943); // ❌ 비정상 동작
caretRangeFromPoint(100.999999999999944); // ✅ 정상 동작
// 더 정확한 기준값 찾기 - 943 구간에서 세밀하게 탐색
 
....

저는 이 이슈의 원인을 찾겠다고 좌표값을 하나하나 바꿔 찍으며 끝없는 삽질을 했습니다. 클릭 좌표를 0.1씩, 0.01씩, 심지어 0.000001 단위로 조정하며 수십 번 반복했죠. 그런데 마침내 — caretRangeFromPoint특정 숫자 패턴에서만 끊임없이 오동작한다는 사실을 발견했습니다.

예를 들어 562.99999999999994315... 같은 값이 나오면, 이 좌표가 마치 경계 기준점처럼 작동해 버리면서 정상적인 텍스트 Range가 아니라 null을 반환했습니다.

  • 이 기준값 왼쪽(작은 값) 에서는 caretRangeFromPointnull을 반환
  • 이 기준값 오른쪽(큰 값) 에서는 정상적으로 Range를 반환

즉, API가 경계에 걸린 소수점 값을 처리하지 못해 커서가 윗줄 텍스트로 튀거나 아예 빈 Range(null)를 내뱉는 경우가 반복된 겁니다.

특히 눈에 띄었던 점은, 기준이 **1이 아니라 아주 긴 소수부(예: nnn.99999999999994315...)**라는 사실이었습니다. 숫자가 저만큼 길어졌을 때 API가 내부적으로 이 값을 경계 기준점처럼 해석한다는 걸 알 수 있었죠.

그래서 저는 "아, 브라우저 내부에서 좌표를 처리할 때 nnn.99999999999994315... 같은 소수부를 기준값처럼 사용하고 있구나"라는 결론에 도달했습니다.

문제의 흐름

  1. 사용자가 빈 공간 클릭 → 클릭된 좌표 (x, y) 취득

  2. 좌표 보정getBoundingClientRect()로 텍스트 박스의 경계값을 구해 그 값을 기반으로 클릭 좌표를 보정

    • 이때 값은 보통 562.234893248 같은 적당한 소수점 값
  3. 보정값 저장 → 보정된 좌표를 변수(adjustedX)에 저장

    • 변수를 다시 읽거나 연산하는 과정에서 자바스크립트의 부동소수점 특성 때문에 562.2348932480000001 또는 562.23489324899994315…처럼 길어진 소수부가 드러남
  4. API 호출 → 이렇게 늘어난 값을 caretRangeFromPoint(adjustedX, y)에 전달

  5. 오동작 발생

    • 좌표가 경계 기준값에 걸리면 API가 제대로 Range를 만들지 못함
    • 기준값 왼쪽에서는 null을 반환하거나 커서가 윗줄로 튀고,
    • 기준값 오른쪽에서는 정상적으로 Range를 반환

해결 과정

해결 방법은 의외로 단순했습니다.

  1. 반올림하기 앞선 방법처럼 좌표를 적당한 자리에서 반올림해 주면, 긴 소수부가 사라져 caretRangeFromPoint가 정상적으로 동작합니다.

  2. 기준값(ε)만큼 좌표를 이동시키기 특정 소수점 값이 경계처럼 작동한다는 점을 이용해, 요소의 경계선 안쪽으로 ε만큼 좌표를 옮겨서 API를 호출하는 방법입니다.

저는 최종적으로 2번 방법을 택했습니다.

 adjustedX -= Math.ceil(0.99999999999994315);

반올림만으로도 문제는 해결되었지만, "어느 자릿수에서 반올림할 것인가"라는 근거가 모호했고, 나중에 또 다른 상황에서 부작용을 일으킬 수도 있었습니다. 반면, 경계 안쪽으로 ε만큼 이동시키는 방식은 원인을 드러내고 직접적으로 대응하는 방법이었기에 더 합리적이었습니다.

결론

이번 이슈는 DOM API부동소수점 연산이 복합적으로 얽힌 케이스였습니다. 구글에서 caretRangeFromPoint bug, caretRangeFromPoint pointer-events none 같은 키워드로 검색해도 관련 자료가 없어 직접 원인을 추적해야 했습니다.

디버깅 과정에서 소숫점이 17자리를 넘는 숫자들을 하나하나 찍어가며 확인했는데, 끝없는 0.999999... 속에서 드디어 0.99999999999994처럼 **9가 아닌 다른 숫자(4)**가 나타났을 때, 비로소 실마리를 찾았다는 기쁨을 느꼈습니다. 단순히 무의미한 0.9999의 나열이 아니라, 유효숫자의 변화가 이슈를 일으킨 원인이었던 것이죠.

물론 단순히 반올림으로 처리했다면 빠르게 문제를 덮을 수 있었을지도 모릅니다. 하지만 그랬다면 정확한 원인은 파악하지 못했을 것이고, 결국 찜찜한 마음을 남겨두어야 했을 겁니다.

다만 현재 환경(OS: macOS, 브라우저: Chrome)에서는 이 문제가 재현되지 않았습니다. 개발자 도구에서도 숫자가 17자리 이상 노출되지 않는데, 이는 IEEE 754 부동소수점 표현 방식 때문으로 보입니다. 회사 환경에서 발생한 정확한 조건은 끝내 알 수 없었지만, 앞으로도 비슷한 DOM API 관련 이슈가 발생한다면 숫자의 미세한 오차를 다시 한번 의심해볼 이유가 생겼습니다.