Geohash 기반의 캐싱으로 API 중복 호출 방지하기
📌 도입
“땅따먹기” 라는 게이미피케이션 기능을 적용한 러닝 앱 서비스 [요이땅]을 개발하며 가장 큰 고민이 있었다.
핵심 기능인 “땅 따먹기”를 어떻게 사용자에게 시각적으로 보여줄 수 있을까?
우리 팀에서는 땅 따먹기의 범위를 대략 150m * 150m 정도의 범위로 설계 (이하 해당 범위를 “타일” 이라고 명칭함) 하였고, 전국을 기준으로 하였을 때, 약 950만개의 타일을 가지게 된다.
전국을 대상으로 하면, 최대 950만개의 타일 데이터를 서버의 자원안에서 감당할 수 없을 것이라고 판단했다.
그래서 우리는 서울, 경기, 인천 등 일부 지역을 대상만을 타겟으로 하여 30만개의 타일을 관리하였다.
백엔드 측에서 PostGIS의 공간 인덱스를 활용하여, 데이터를 효율적으로 가져오도록 했다.
📌 지도에서 타일을 보여줄 때 API 호출 문제
클라이언트 측에서 지도에서 “점령된 타일 정보”를 확인할 때 문제가 발생했다.
Webview 안에서** “타일 지도”** 라는 이름의 기능을 제공했다. 지도에서 점령여부를 확인 하기 위해서 다음과 같은 기준으로 서버에 요청을 보내 정보를 받아왔다.
interface TileParams {
swLat: number
swLng: number
neLat: number
neLng: number
zoomLevel?: number // 클러스터링 여부에 따라
zodiacId?: number // 특정 팀 or 전체
}
현재 보이는 화면에서 남서-북동 좌표를 함께 API 요청 시 넘겨주면, 해당 범위 안에 있는 타일 정보를 넘겨주는 구조였다.

“사용자가 지도를 Drag , Zoom 이벤트를 통해 탐색했을 때” 조금만 이동해도, 보이는 화면이 달라지기 때문에 타일 조회를 위해 매번 API 요청을 보내야했다.
때문에, 사용자가 ‘타일지도’를 탐색하는 인터렉션에 따라 서버에 계속 반복되는 요청을 보낼 수 밖에 없었다.
단순히 1초 동안 화면을 N번 움직이게 된다면, 사용자가 M 명일 때, 서버에 초당 발생되는 요청은 N * M 번이 될 것이고, 서버에서 처리하는 시간을 고려한다면, 사용자가 많아졌을 때 부하는 배로 증가할 것이다.
반복된 API 요청은 서버 부하가 증가할 수 있을 뿐만아니라, 사용자의 기기의 성능에도 큰 문제를 미치게 된다.
이러한 문제는 자연스레** 최악의 사용자 경험에 연결** 될 수 있다고 판단했다.
목표
“불필요한 네트워크 요청을 줄이고, 사용자에게 체감되는 속도(응답 시간, UX)를 개선하는 것”
📌 해결 과정
✅ 현재 상황 수치로 확인하기
먼저, 최적화 하기 전의 상황을 인지할 필요가 있었다. 얼마나 문제가 발생하는지 알아야지, 개선의 의미를 얻을 수 있다고 생각했기 때문이다.
다이어트 하기전 몸무게를 측정하는 것과 같다
단순 API 호출을 파악하기 보다, 체계적인 KPI를 기준을 가지고 측정하고 싶었다.
그래서 ChatGPT를 통해 현재 상황에 따른 KPI를 제안해달라고 하였고, 다음 내용을 얻을 수 있었다.
AI가 추천해준 KPI 성능 측정 지표
| 분류 | 지표 | 이유 |
|---|---|---|
| 호출 빈도 KPI | ✅ 총 API 호출 횟수 | 개선 전/후 호출량 변화를 가장 명확히 보여줌 |
| ✅ 중복 호출 횟수 | 캐싱 및 요청 최적화의 직접적 효과를 수치로 증명 | |
| 응답 시간 KPI | ✅ 평균 응답 시간 | 캐싱 및 네트워크 부하 개선의 체감 성능 지표 |
| ✅ P95/P99 응답 시간 | 극단적(느린) 케이스를 잡아 UX 신뢰도 확보 | |
| 캐싱 효율 KPI | ✅ 캐시 적중률 (Hit Rate) | 캐싱 로직이 제대로 작동하는지를 직접 보여줌 |
| ✅ 캐시 미스율 (Miss Rate) | 캐시 실패율 관리에 필수 (Hit Rate과 짝지어 봄) | |
| UX KPI | ✅ 로딩 상태 표시 횟수 | 사용자 체감에 직접 영향 — 로딩 스피너가 줄었는지 |
| ✅ 인터랙션 대기 시간 | 실제 사용자 반응 속도를 측정하는 체감 지표 |
1. 모니터링 도구 만들기
호출 빈도 KPI 를 측정하기 위해서, 브라우저의 Performance API를 사용했다. 직접 개발자도구의 네트워크 탭을 통해서 확인 할 수도 있지만, 조금 더 정밀한 측정을 하고 싶어서 해당 API를 활용하기로 했다.
Claude 를 통해 Performance API 를 활용한 API-monitor 로직을 구현을 부탁했다.
추후에는 캐싱 내용을 확인하기 위해 Tanstack Query Devtool을 사용했다.
모니터링 도구 구현을 위한 프롬프트
## 작업 목표
- Next.js 환경에서 API 호출 및 UX 성능을 모니터링할 수 있는 간단한 도구를 만들어라.
- Performance API 를 사용해라
---
## 파일 구조
다음 두 파일을 생성하라.
- `utils/api-monitor.ts`
→ API 호출 및 UX 관련 지표를 수집하는 **싱글톤 클래스** (class 기반)
- `components/ApiMonitorPanel.tsx`
→ 측정 시작/종료 및 결과를 표시하는 **UI 컴포넌트** (상단 fixed 배치)
---
## `api-monitor.ts` 요구사항
- 클래스명: `ApiMonitor`
- 싱글톤 패턴으로 인스턴스 관리
- 이모지를 활용해 콘솔 가독성을 높일 것 (예: 📊, ⏱️, ⚡, 💾, 🧭 등)
- 다음 지표를 모두 측정하고 저장할 수 있도록 할 것:
| 분류 | 지표 | 설명 |
|------|------|------|
| 호출 빈도 KPI | 총 API 호출 횟수 | 모든 fetch 요청 횟수 |
| 호출 빈도 KPI | 중복 호출 횟수 | 같은 URL로 연속 요청한 횟수 |
| 응답 시간 KPI | 평균 응답 시간 | 모든 요청의 평균 latency(ms) |
| 응답 시간 KPI | P95, P99 응답 시간 | 응답시간 분포 상위 95%, 99% 구간 |
| 캐싱 효율 KPI | 캐시 적중률 | 캐시로 응답된 비율 |
| 캐싱 효율 KPI | 캐시 미스율 | 캐시가 실패한 비율 |
| UX KPI | 로딩 상태 표시 횟수 | 로딩 indicator가 노출된 횟수 |
| UX KPI | 인터랙션 대기 시간 | 사용자 액션 → 결과 표시까지 소요시간 |
📌 추가 요구사항
- 각 API 호출의 `startTime`, `endTime`, `duration`을 기록
- `start()`, `stop()`, `getResult()` 메서드 제공
- 결과는 콘솔에 표(`console.table`)로 표시
---
3️⃣ # `ApiMonitorPanel.tsx` 요구사항
✅ 컴포넌트명: `ApiMonitorPanel`
✅ Fixed bottom-right UI로 배치 (`position: fixed; bottom: 20px; right: 20px;`)
✅ 버튼 3개를 제공:
- ▶️ **측정 시작** (`onStart`)
- ⏹️ **측정 종료**
- 📊 **결과 보기**
✅ 버튼 클릭 시 `ApiMonitor`의 메서드를 직접 호출
---
## 4️⃣ 예시 출력 형태
콘솔 예시: 📞 호출: 3회 (API: 3, 캐시: 0) | 🌐 API 2065ms
2. 측정 시나리오 세우기
(개선 전, 후)의 차이를 분명하게 알기 위해서는 객관적인 “기준”이 필요했다.
그래서 아래와 같이 시나리오를 정했다. 하지만, 직접 측정하는 것이다보니 약간의 오차가 있을 수 있다.
측정 시나리오
- 1분간 테스트
- 전체 타일 API, Zoom 16레벨
- 신논현 - 언주 - 강남역 - 역삼역 구간을 지속적으로 반복 탐색
- 동일하게 3번 반복하여 측정

✅ 개선 전 :: KPI 측정 결과
전체 측정 내용 중 반복 호출 KPI, UX KPI, 캐싱 KPI 를 중심으로 확인했다.
“개선 전” - 측정 결과
| 내용 | 측정 결과 |
|---|---|
| 총 API 호출 | 46회 |
| 실제 API 호출 | 46회 |
| 분당 호출 | 46.1회 |
| 로딩 표시 횟수 (= API 호출 수) | 46회 |
| 측정 시간 | 59.9초 |

✅ 첫번째 전략 :: Debouce 전략
처음에는 “단순하게 접근”했다.
사용자가 반복되는 많은 이벤트를 발생시켜서 API 호출이 많아진다면, 이를 의도적으로 제한하자는 것이었다.
예를 들어, ( 서울 용산구 → 경기도 성남시 분당구 ) 로 이동한다고 해보자.
이동하는 과정에서 몇번의 이벤트가 발생하게 될텐데, 이때 지나가는 모든 지역의 타일을 보여주지 않아도 된다고 생각했다.
특정 지역을 확인하고 싶어서 지도는 여러번 빠르게 이동하게 될 텐데, 지나가는 범위의 타일은 굳이 보여줄 필요가 없다고 생각했기 때문이다.
그래서 처음에는 단순히 500ms 의 Debounce을 걸어, 약간의 딜레이가 발생하게 했다.
const handleChange = debounce(() => {
// ... API 호출 로직
}, 500)
“Debounce 500ms 적용 후” - 측정 결과
| 내용 | 측정 결과 |
|---|---|
| 총 API 호출 | 30회 |
| 실제 API 호출 | 30회 |
| 분당 호출 | 29.8회 |
| 로딩 표시 횟수 (= API 호출 수) | 30회 |
| 측정 시간 | 60.5초 |

측정하는 액션이 완벽히 동일하지는 않았겠지만, 결과를 보면 확실히 API 호출 수가 대략 46회 → 30회로 기존 대비 35% 개선된 것을 볼 수 있다.
( 혹시나 다른 결과가 나올까 여러번 반복했는데, 35% 정도의 비슷한 결과가 나왔다. )
분명 개선된 수치를 얻을 수 있지만, 여전히 API가 호출 되고있고, 이 과정에서 렌더링 되는 과정이 반복적으로 발생한다는 것이다.
서버에서 공간 인덱스 덕분에 평균적으로 50 ~ 60ms의 응답 시간으로 빠르게 주어졌지만, 때에 따라 달라질 수 있다.
그래서 여전히, API를 불어오는 과정에서는 약간의 딜레이로 인해 “사용자 경험”을 떨어트릴 수 있었다.
Debounce 적용 결과
- ✅ API 호출 34.8% 감소 (46회 → 30회)
- ✅ 로딩 표시 34.8% 감소 (46회 → 30회)
남아 있는 문제점
- ❌ 캐시 미사용으로 여전히 높은 네트워크 부하
- ❌ 매 요청마다 로딩 딜레이
- ❌ 500ms 대기시간으로 인한 즉시 반응성 저하
✅ 두번째 전략 :: Geohash를 활용한 캐싱
두번째 전략으로는 Tanstack Query를 기반으로 “캐싱 처리”하는 것이었다.
캐싱을 처리하는데 있어서 가장 중요한 것은 “캐시 Key”를 생성하는 것이었다. 즉, API 요청시 전달하는 params에 대해 캐싱을 처리할 수 있는 “유일 값”을 얻을 수 있어야 했다.
처음에는 좌표를 특정 소숫점으로 정규화 한 후, 특정 문자열을 만드는 것을 고민했다.
// 초반 아이디어
const Key = `${36.12}-${127.23}`
하지만, 화면의 이동에 따라 좌표가 달라지게 되는데, 이때 “달라지는 거리 범위” 에 대한 기준이 없다면, 캐싱을 하는것이 의미가 사라지게 될 수 있었다.
모든 좌표에 대해 캐싱을 처리하게 된다면, 이를 통해 사용자의 메모리에만 큰 부담을 줄 수 있었다.
즉, 특정 “거리” 범위 안에 있는 정보는 **“하나의 Key”**로 볼 수 있어야, 캐싱의 의미를 얻을 수 있다고 판단했다.

그래서 Geohash 를 도입하게 되었다. 사실 우리 프로젝트에서 타일의 범위를 효율적으로 관리하기 위해서 Geohash Level 7 을 기준으로 이미 사용하고 있던 해싱 기술이다.
GeoHash ❓
“지리 위치 정보를 문자와 숫자가 혼합된 짧은 문자열(공간을 그리드로 분할)로 변환하는 공간 인덱싱 기법”
화면 이동 전, 후의 차이를 Geohash를 기반으로 캐싱한다면, 특정 범위 내에서는 동일한 “geohash 문자열(hashKey)”을 얻을 수 있고, 그렇다면 충분히 의미있는 캐싱 결과를 얻을 수 있을 것이라고 생각했다.

Geohash 선택 이유
- 근접 영역은 동일한 해시값을 공유 → 효율적인 캐시 키 생성 , 적절한 캐싱 단위
- 인코딩/디코딩 성능 우수 → $O(1)$
Geohash 레벨 범위
| Geohash 레벨 | 동서 방향 너비 | 남북 방향 높이 |
|---|---|---|
| 6 | 약 1.22 km | 약 0.61 km |
| 7 | 약 153 m | 약 153 m |
Geohash 범위에 따라서 “캐싱되는 데이터의 사이즈” 가 달라지고, 보여주는 타일을 묶어 보여주는 화면의 범위가 달라진다.
즉, 어떤 기준으로 하느냐에 따라 캐싱 성능에 차이를 보이게 된다.
우선 우리 서비스의 타일 범위는 Geohash 레벨 7 의 격자 크기와 같다.
지도에서 Zoom 범위에 따라 다르겠지만, 시각적으로 타일을 한 눈에 잘 보이는 크기는 아래 사진 정도 라고 생각이 들었다. 화면 이동 시 적어도 “타일 하나 만큼 범위 이상”은 이동할 것이 분명했다.
레벨 7을 기준으로 캐시 키를 생성 했을 때는 캐싱 되는 데이터의 수가 많아져 사용자 기기에 부담이 될 수 있고, 또한 캐싱을 위한 API 호출이 발생해 캐싱이 큰 의미를 가지지 못할 것이었다.

그래서 한 단계 작은 GeoHash 레벨 6 을 기준으로 인코딩하여, 캐싱을 처리하기로 결정했다.
레벨 6의 범위는 모바일 기기 기준 “한 화면” 안에서 보여지는 크기의 범위와 어느 정도 유사한 면이 있었고, 사용자가 지도를 이동하는 범위 안에서 충분히 의미있는 캐싱 결과를 얻을 수 있다고 생각했다.
| Geohash 레벨 | 동서 방향 너비 | 남북 방향 높이 |
|---|---|---|
| 6 | 약 1.22 km | 약 0.61 km |
더 낮은 레벨 5 를 선택한다면, 범위가 약 5km로 매우 넓어서, 좁은 범위의 화면 이동에서는 데이터를 아에 불러오지 못할 수 있었기 때문에, 고려하지 않았다.
Geohash 인코딩은 ngeohash 라이브러리를 통해 쉽게 처리할 수 있었고, Tanstack Query 를 통해 캐싱 처리해주었다.
// ngeohash 라이브러리를 활용한 인코딩
const geohashKey = (latitude, longitude) => {
return ngeohash.encode(latitude, longitude, precision=6)
}
// Tanstack Query 캐싱 처리
useQuery<TileMapResponse>({
queryKey: ["allTiles", geohashKey],
queryFn: () => getTeamTileMap(params),
enabled: enabled && isValidParams,
staleTime: 1000 * 60 * 2, // 2분 - 캐싱 효율 증가
gcTime: 1000 * 60 * 5, // 5분 - 캐시 유지 시간
})
과도한 메모리 사용을 방지하기 위해, “타일 지도” 페이지에 체류 시간을 예측하여 신선도 2분, 가비지컬렉터가 일어나는 시간을 5분으로 설정하였다.
geohash 레벨 6 캐싱 적용 후
| 내용 | 측정 결과 |
|---|---|
| 총 API 호출 | 9회 |
| 분당 호출 | 36.5회 |
| 캐시 적중률 | 75.0% |
| 로딩 표시 횟수 (= API 호출 수) | 9회 |
| 측정 시간 | 60.8초 |

캐싱을 적용 후 측정한 결과는 다음과 같았다.
캐싱을 한 덕분인지, 확실하게 기존 대비 API 호출 수가 많이 줄어든 것을 볼 수 있었다. 기존 46회 → 9회 로 *80.4% * 개선하여, 굉장히 의미있는 결과를 가져올 수 있었다.
무엇보다, 지도의 같은 부분을 반복해서 탐색했을 때, API 호출을 발생하지 않게 하여, 사용자에게 데이터를 불러오는 딜레이 경험을 주지 않아도 되어 확실히 체감 경험이 올라갔음을 느낄 수 있었다.
효과
- ✅ 실제 API 호출 80.4% 감소 (46회 → 9회)
- ✅ 캐시 적중률 75.0% 달성 (27/36회)
- ✅ 로딩 표시 80.4% 감소 (46회 → 9회)
✅ 세번째 전략 :: Debounce + 캐싱
마지막으로 고려하려고 했던 부분이 바로 Debounce와 캐싱을 동시에 사용하는 방법을 생각했다.
결론적으로, 이 방법은 고려하지 않기로 했다.
왜냐하면, 이미 Debounce를 적용하면, 500ms 정도의 딜레이를 의도적으로 발생시키는 것인데, debounce를 통한 딜레이가, 오히려 더 안좋은 사용자 경험을 줄 수 있을 것이라 판단했다.
캐싱되어있는 데이터를 메모리에서 불러오는 과정은 “네트워크 비용”은 전혀 들지 않을 뿐더러, 가져오는 시간은 거의 딜레이가 없기 때문에 캐싱 만으로 훨씬 더 나은 사용자 경험을 줄수 있을 것이라고 생각했다.
📌 결론
결과적으로 한번에 정리하면, 측정 결과는 다음과 같이 정리할 수 있다.
| 측정 항목 | 개선 전 | Debounce 500ms | Geohash 캐싱 |
|---|---|---|---|
| 총 API 발생 수 | 46 | 30 | 36 |
| 실제 API 호출 | 46 | 30 | 9 |
| 캐시 적중률 | 0.0% | 0.0% | 75.0% |
| 중복 호출 | 22 | 4 | 3 |
| 로딩 표시 | 46 | 30 | 9 |
아무 최적화도 하지 않은 상태 대비 Geohash 캐싱을 통해, API 호출 비용을 80% 개선할 수 있었다.
이 최적화를 통해서 기존에 발생할 수 있었던, 빈번한 API 호출 수를 줄임으로, 네트워크 부하와 서버 부담을 대폭 감소 시킬 수 있었고, 캐싱을 통해 로딩 없이 즉시 렌더링이 되도록 함으로써,** “사용자 체감” 경험을 크게 개선**할 수 있었다.
성과
✅ 네트워크 부하와 서버 부담 대폭 감소
✅ API 호출 비용 46회 → 9회 , 80.4% 개선
✅ 캐시 적중률 75% ( 발생한 API 호출 36회 中 27회 캐시 히트)
✅ 캐시 히트 시 로딩 없는 즉시 렌더링 → “사용자 체감 경험 향상”
📌 마무리 :: 회고
이번 과정을 경험하며, 뿌듯함을 크게 느낄 수 있었던 시간이었다.
단순히 이렇게 해볼까? 오 좋다!! 정도의 단순한 의사결정이 아니라, 마치 논문을 작성하듯 동일한 환경에서 각각의 방법들을 적용하고, 직접 측정하며 테스트하면서, 데이터를 기반한 근거있는 최고의 선택을 한 것 같아 마치 “프로” 같다는 자신감을 느낄 수 있었다.
처음에는 의도하지 않았지만, 지금 생각해보면 이 과정이
A/B 테스트과정이 된 것 같다.!!
또한 객관적인 정량적인 측정을 위해 찾아보다 Web Performace API 라는 것을 알게 되었고, 이것을 통해 이벤트를 추적하고, 측정할 수 있다는 것을 처음 알게되었다.
지금까지 오랜시간동안 개발해왔기에, 웬만한 것은 많이 사용해보거나, 본적이 있다고 생각을 했지만, 여전히 내가 모르는 수많은 기술들과, 고민의 결과가 많이 있다는 것을 또 한번 깨닫는 시간이 된 것 같다.
나중에 동일하게 공간 데이터를 활용하거나, 동일하게 API 호출을 개선해야 할 상황이 있을 때 오늘의 경험이 크게 도움이 될 것이라는 기대감을 가져본다.
마지막으로, 이번 개선을 통해 사용자 경험을 개선 할 수 있는 스킬을 터득한 것 같다는 생각이 들어 가장 감사하다. 이러한 경험이 쌓이고 쌓여 최고의 UX를 제공하는 프론트엔드 개발자로 성장한 개발자가 되어있길 기대해본다.
📌 Reference
함께 보면 좋은 콘텐츠
- Engineering Case Study
Next.js에서 SSR렌더링이 지연되는 문제 분석하기
본 내용은 2025년 5월 ~ 6월에 진행한 “요이땅” 프로젝트에 대한 트러블슈팅입니다.
- Engineering Case Study
React 프로젝트 번들링을 통한 FCP 개선기 (feat. Vite)
기간 내에 개발을 모두 마친 후 배포를 진행하였다. 배포를 진행하면서 감사하게도 50명의 사용자 중 몇몇 분에게 피드백을 받을 수 있었다. 그 중 좋은 도전이 되는 피드백을 받을 수 있었다.
-
Engineering Opinion2026 카카오 신입공채 코딩 테스트 후기
2025년 하반기에 카카오에서 대규모 공채가 열렸다.