H3 육각형과 Geohash 직사각형 중 아직 결정하지 못했다면, 먼저 H3 vs Geohash vs S2 비교 글을 읽어보세요. 이 글은 그다음 단계인 어떤 npm 패키지를 설치할 것인가에 대한 내용입니다.
h3-js와 ngeohash는 각각 H3와 Geohash에서 가장 많이 다운로드되는 JavaScript 라이브러리입니다. 저희는 KunYu의 GeoHash & H3 변환 도구에서 두 라이브러리를 모두 사용하고 있어요. 번들 크기는 22배, 인코딩 속도는 4~6배 차이가 나며, 프로덕션에서 주의해야 할 점도 완전히 다릅니다. 아래에 실측 수치와 실제로 배포한 후에야 발견한 문제들을 정리했어요.
번들 크기: 86 KB vs 3.8 KB
node_modules에서 ES 모듈 엔트리 포인트를 직접 gzip 압축하여 측정한 결과입니다:
| 항목 | h3-js v4.4.0 | ngeohash v0.6.3 |
|---|---|---|
| ES module (gzipped) | 86 KB | 3.8 KB |
| 전체 패키지 크기 | 6.5 MB | 52 KB |
| 런타임 의존성 | 0 | 0 |
| 구현 방식 | C → Emscripten | Pure JavaScript |
이 차이는 구현 방식에서 비롯돼요. h3-js는 Uber의 C 라이브러리를 Emscripten으로 컴파일한 것으로, 정이십면체 투영, 육각형 그리드 탐색, 폴리곤 클리핑이 모두 하나의 JS 파일에 포함되어 있고 tree-shaking이 불가능합니다. { latLngToCell }만 import해도 전체 464 KB가 번들에 포함돼요. ngeohash는 손으로 작성한 JS 파일 6개로 이루어져 있어서, 라이브러리 전체가 h3-js의 타입 정의 파일보다 작습니다.
gzip 기준 86 KB는 React DOM과 거의 같은 크기예요. 서버 사이드에서는 상관없지만, 인코딩 호출 하나만 필요한 프론트엔드 프로젝트라면 함수 하나를 위해 React DOM 하나를 더 보내는 셈이에요.
인코딩 속도: ngeohash가 4~6배 빠름
Node.js v24, 작업당 100,000회 반복, 1,000회 워밍업:
| 작업 | h3-js | ngeohash | 차이 |
|---|---|---|---|
| 인코딩 (lat/lng → cell) | 850K ops/sec | 4.86M ops/sec | ngeohash 5.7배 |
| 디코딩 (cell → lat/lng) | 1.73M ops/sec | 7.58M ops/sec | ngeohash 4.4배 |
| 이웃 셀 (인접 셀) | 614K ops/sec | 465K ops/sec | h3-js 1.3배 |
| 경계 / BBox | 550K ops/sec | 9.29M ops/sec | ngeohash 17배 |
벤치마크 스크립트 (.mjs로 저장한 후 node로 실행):
import { latLngToCell, cellToLatLng, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";
const ITERATIONS = 100_000;
function bench(name, fn) {
for (let i = 0; i < 1000; i++) fn(); // warmup
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) fn();
const ms = performance.now() - start;
const opsPerSec = Math.round((ITERATIONS / ms) * 1000);
console.log(`${name}: ${opsPerSec.toLocaleString()} ops/sec`);
}
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
const hash = ngeohash.encode(37.7749, -122.4194, 6);
bench("h3-js encode", () => latLngToCell(37.7749, -122.4194, 9));
bench("ngeohash encode", () => ngeohash.encode(37.7749, -122.4194, 6));
bench("h3-js decode", () => cellToLatLng(h3Cell));
bench("ngeohash decode", () => ngeohash.decode(hash));
bench("h3-js neighbors", () => gridDisk(h3Cell, 1));
bench("ngeohash neighbors", () => ngeohash.neighbors(hash));
bench("h3-js boundary", () => cellToBoundary(h3Cell));
bench("ngeohash bbox", () => ngeohash.decode_bbox(hash));
단순 인코딩/디코딩에서는 ngeohash가 압도적이에요 — 순수 JS 비트 인터리빙 연산은 V8 JIT의 강점과 정확히 맞아떨어지고, FFI 오버헤드도 없습니다. h3-js는 매 호출마다 마샬링 비용을 지불해요: 인자가 Emscripten 힙에 들어가고, C 함수가 실행되고, 결과가 다시 JavaScript로 복사됩니다.
하지만 이웃 검색에서는 h3-js가 앞서요. gridDisk는 정이십면체 그리드를 타이트한 C 루프로 탐색하며, 컴파일된 알고리즘의 효율성이 직렬화 비용을 상쇄합니다.
경계 행은 공정한 비교가 아닙니다: cellToBoundary는 육각형의 6개 꼭짓점 좌표를 계산하고, decode_bbox는 단순히 4개의 숫자를 반환하니까요. 하지만 이것이 프로덕션에서 실제로 호출하게 되는 연산들이에요.
요청당 수백만 개의 포인트를 처리하는 게 아니라면, 인코딩 속도는 라이브러리 선택 기준이 아닙니다. h3-js가 좌표 하나를 인코딩하는 데 1.2마이크로초면 충분히 빠릅니다.
API 비교
인코딩과 디코딩
import { latLngToCell, cellToLatLng } from "h3-js";
import ngeohash from "ngeohash";
// H3: resolution 9 ≈ 0.11 km²
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"
// Geohash: precision 6 ≈ 0.74 km²
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"
// Decode
const [lat, lng] = cellToLatLng(h3Cell);
const { latitude, longitude } = ngeohash.decode(hash);
이웃 셀
import { gridDisk } from "h3-js";
// H3: center + 6 hexagonal neighbors
const h3Neighbors = gridDisk(h3Cell, 1);
// Geohash: 8 neighbors (N, NE, E, SE, S, SW, W, NW)
const ghNeighbors = ngeohash.neighbors(hash);
셀 경계
import { cellToBoundary } from "h3-js";
// H3: 6 [lat, lng] vertices
const h3Boundary = cellToBoundary(h3Cell);
// Geohash: bounding box
const bbox = ngeohash.decode_bbox(hash);
// → [minLat, minLon, maxLat, maxLon]
decode_bbox는 [minLat, minLon, maxLat, maxLon] 순서로 반환해요. 남, 서, 북, 동 순서입니다. GeoJSON에서 사용하는 [west, south, east, north] 순서도 아니고, Leaflet의 LatLngBounds가 기대하는 순서도 아닙니다. 잘못 사용하면 지구 반대편에 바운딩 박스가 생기게 돼요 — 에러도 없이, 완전히 잘못된 결과만 나옵니다. 항상 명시적으로 디스트럭처링하세요:
const [minLat, minLon, maxLat, maxLon] = ngeohash.decode_bbox(hash);
h3-js는 자체 TypeScript 타입 정의를 포함하고 있고, ngeohash는 별도로 @types/ngeohash를 설치해야 해요.
프로덕션 주의사항
H3 셀 ID가 JSON 직렬화 시 조용히 잘림
저희가 가장 많은 시간을 날린 문제예요. H3 셀 ID는 64비트 정수로, JavaScript의 안전 정수 범위(2^53 − 1)를 초과하기 때문에 h3-js는 hex 문자열로 반환합니다: "8928308280fffff".
문제는 API 레이어에서 나타나요. 백엔드의 어딘가에서 H3 ID를 문자열이 아닌 JSON 숫자로 기록하면, JSON.parse가 값을 조용히 잘라냅니다 — 유효하지만 완전히 다른 숫자가 되고, 에러도 없고, 쿼리는 빈 결과를 반환해요. 저희는 서비스 간 데이터가 도무지 매칭되지 않아 디버깅 세션 전체를 소비한 후에야 직렬화 문제를 추적해냈어요.
해결 방법은 간단하지만 철저해야 합니다: 데이터베이스의 문자열 컬럼, API 스키마의 문자열 필드, TypeScript 인터페이스의 문자열 타입. 예외 없이. "이 ID의 값은 그렇게 크지 않을 거야"라고 가정하지 마세요.
ngeohash 정수 인코딩은 52비트로 제한됨
encode_int와 decode_int는 Base32 문자열 대신 정수를 사용하지만, 52비트로 제한되어 있어요(IEEE 754 float64 안전 정수 한계). bitDepth를 52 이상으로 설정하면 정밀도가 조용히 손실됩니다 — 에러가 발생하지 않아요.
Redis도 내부적으로 52비트 Geohash 정수를 사용하므로, 가장 일반적인 백엔드와는 맞아요. 하지만 64비트 Geohash 정수를 사용하는 Python이나 Java 라이브러리에서 로직을 포팅하는 경우, 값이 일치하지 않게 되고 디버깅하기 어려워요 — 결과가 "비슷해 보이기" 때문이에요. 마지막 몇 비트만 다를 뿐입니다.
ngeohash와 다른 Geohash 라이브러리의 결과가 항상 일치하지는 않음
동일한 좌표에 대해 서로 다른 Geohash 구현이 다른 결과를 낼 수 있어요. 원인은 부동소수점 반올림입니다: 좌표가 두 셀의 경계에 정확히 위치하면, 라이브러리마다 다른 이웃 셀로 반올림할 수 있습니다.
저희는 KunYu의 Geohash 도구를 만들면서 이 문제를 겪었어요 — 프론트엔드의 ngeohash 출력과 백엔드의 Python geohash 라이브러리가 간헐적으로 불일치했습니다. 드문 일이지만 실제로 발생해요. GPS 좌표(약 3미터 정확도)를 수백 미터에 걸치는 셀로 인코딩하는 경우 실질적인 영향은 미미합니다. 하지만 Node.js와 Python/Redis가 정확히 일치해야 한다면, 알려진 셀 경계 위의 좌표를 사용하여 통합 테스트를 작성하세요. Redis의 GEOHASH 명령이 시스템 간 일관성을 위한 가장 안전한 참조 구현입니다.
h3-js v3 → v4: 모든 함수 이름이 변경됨
프로젝트가 아직 v3를 사용하고 있다면, v4로 업그레이드할 때 거의 모든 함수 이름이 바뀌어요:
| v3 | v4 |
|---|---|
geoToH3 |
latLngToCell |
h3ToGeo |
cellToLatLng |
kRing |
gridDisk |
hexRing |
gridRingUnsafe |
compact |
compactCells |
v4에서는 에러 처리 방식도 변경되었어요 — 잘못된 입력은 null을 반환하는 대신 예외를 던집니다. h3-js/legacy에 호환 레이어가 있지만, 에러 동작은 v4 시맨틱을 따릅니다. 레거시 래퍼에 장기적으로 의존하는 것보다 일회성 찾기-바꾸기 마이그레이션이 더 깔끔해요. 전체 이름 변경 표는 v3→v4 마이그레이션 가이드에서 확인할 수 있습니다.
어떤 라이브러리를 사용할까
| 시나리오 | 선택 | 한 줄 이유 |
|---|---|---|
| Redis GEO / Elasticsearch | ngeohash | Redis가 내부적으로 Geohash를 사용 — 동작 일치 |
| 프론트엔드 근접 검색 | ngeohash | 3.8 KB vs 86 KB |
| 육각형 히트맵 / Deck.gl | h3-js | Deck.gl이 H3HexagonLayer를 네이티브 지원 |
| 글로벌 집계 (일관된 셀 면적) | h3-js | H3 셀 면적은 위도에 따른 변화가 3% 미만 |
| 로그에서 읽기 쉬운 ID | ngeohash | "9q8yyk"이 "8928308280fffff"보다 낫다 |
| 폴리곤 커버리지 / 다중 해상도 | h3-js | polygonToCells, compactCells, 부모/자식 계층 |
| 서버 사이드 배치 처리 | 둘 다 가능 | 둘 다 충분히 빠름 — 라이브러리가 아니라 인덱스 시스템 기준으로 선택 |
JavaScript 생태계에서 S2의 상황은 좋지 않아요: s2-geometry는 8년간 업데이트되지 않았고, nodes2ts는 더 완성도가 높지만 커뮤니티가 작습니다. 프로젝트에서 S2가 필요하다면, 공식 라이브러리를 실행하는 Python/Go 마이크로서비스를 만들고 Node.js 앱에서 호출하는 것이 실용적인 접근법이에요.
브라우저에서 Geohash와 H3 인코딩을 직접 체험해보려면, KunYu의 GeoHash & H3 변환 도구를 사용하세요 — 좌표를 붙여넣으면 셀 ID를 확인하고 경계를 지도 위에서 시각화할 수 있어요.
FAQ
h3-js는 WebAssembly를 사용하나요?
아니요. C에서 컴파일되었음에도 불구하고, h3-js는 Emscripten을 사용하여 .wasm이 아닌 일반 JavaScript를 생성해요. 장점은 별도의 wasm 파일 로딩이 필요 없고 모든 최신 브라우저에서 작동한다는 것이에요. 단점은 번들 크기(gzip 기준 86 KB)와 호출 오버헤드가 네이티브 wasm보다 크다는 것입니다. 커뮤니티에서 wasm 빌드에 대한 논의가 있었지만, 공식적인 계획은 없어요.
ngeohash는 ESM exports를 지원하나요?
네이티브 ESM exports는 없어요 — 패키지가 CommonJS만 지원합니다. 프론트엔드 번들러(Vite, webpack)가 CJS → ESM 변환을 자동으로 처리해주기 때문에, 번들된 프로젝트에서는 import ngeohash from "ngeohash"가 잘 작동해요. 하지만 "type": "module"로 설정된 순수 ESM Node.js 프로젝트에서는 createRequire나 커뮤니티 포크가 필요합니다. 이 라이브러리는 주간 npm 다운로드 10만 이상이지만 2020년 이후 새 버전이 퍼블리시되지 않았어요.
두 라이브러리를 같은 프로젝트에서 함께 사용할 수 있나요?
네, 충돌 없이 사용할 수 있어요. 흔한 패턴은 백엔드에서 공간 집계와 분석을 위해 H3를 사용하고, 프론트엔드에서 가벼운 지오펜스 체크를 위해 ngeohash를 사용하며, API를 통해 각각의 셀 ID를 교환하는 것이에요. 두 인덱스 시스템은 독립적으로 작동하므로 서로 간의 변환이 필요하지 않습니다.