들어가며
지난 포스팅에서 Fuse.js를 활용해서 검색 기능을 개선할 수 있었다.
[Fuse.js] 라이브러리 도입 후 겪은 검색 UX 이슈와 해결 과정
들어가며기존에는 지역명과 위치 정보가 강하게 결합된 JSON 데이터에 의존하고 있었다.이후 Kakao Local API를 활용하면서, 지역명으로 위·경도를 조회할 수 있게 되었고검색용 데이터와 좌표 데이
meal-coding.tistory.com
개선 목표를 4가지로 정의하고 이 중에서 3가지 목표를 달성했으며, 1가지가 남은 상황이다.
[ ✅ ] 1️⃣ 일부 오타 허용
[ ✅ ] 2️⃣ 연관성 높은 검색 결과 제공
[ ✅ ] 3️⃣ 부분 검색 지원을 통한 하이라이트
[ ] 4️⃣ 캐시와 TTL을 통해 Kakao Local API 중복 호출을 줄이고 응답 비용을 절감
이제 캐시와 TTL을 적용해서 API 중복 호출을 줄이고자 한다.

Kakao Local API 중복 호출이 개선된다면 주소로 좌표 변환하는 단계가 생략되므로
이를 통해 불필요한 네트워크 요청을 제거하고 응답 속도를 개선할 수 있다.

지역명과 위/경도, 그리고 기상청에서 정의하는 수치예보 모델 격자 좌표 간의 연관 관계는 쉽게 변하지 않는다.
따라서 5분 뒤에 호출하는 API의 결과와 7일 뒤에 호출하는 API의 결과는 서로 동일할 것으로 기대할 수 있다.
해당 데이터는 시간에 따른 변화가 거의 없기 때문에 캐시 적용에 적합한 특성을 가진다.
여기에 적당한 유효시간을 TTL로 적용하면 일정 주기로 새로운 데이터로 바뀌므로
혹시 있을 데이터 변동에도 대응할 수 있다.
내용
📝 #1. 개선 계획
1️⃣ localStorage와 같은 persistent storage에 캐싱
TanStack Query의 캐시를 활용해서 Kakao Local API 결과 값을 캐싱하려고 했는데
Codex가 localStorage에 저장해서 persistence를 보장해 주자고 제안했다.
Codex)
좌표 변환 결과는 사용자에게 직접 보여주기 위한 핵심 서버 상태가 아니라, 최종적으로 날씨 데이터를 가져오기 위해 사용하는 중간 단계 데이터이기 때문에 검색 보조용 변환 결과라고 본다.
공식문서는 TanStack Query를 서버 상태용 도구로 정의하지만, 네 검색 좌표 캐시는 최종 UI 상태보다 다음 호출을 위한 중간 변환 결과에 가까웠기 때문에 별도 TTL 캐시로 두는 선택도 설계상 타당하다.
틀린 말은 아닌 것 같아서 (재밌어 보이기도 하고) Codex의 의견대로 localStorage에 위/경도 데이터를 캐싱 해보기로 했다.
이왕 localStorage에 캐싱하기로 했으니 좀 더 일반화 해서 다양한 데이터 유형도 지원해 주기 위해서 타입에는 제네릭을 사용한다.
localStorage를 사용할 때 주의할 점이 있는데
바로 localStorage는 용량 제한이 있고, 브라우저 환경에 종속되기 때문에 서버 사이드에서는 사용할 수 없다는 점이다.
2️⃣ 데이터와 함께 caching 시점을 저장해 TTL 적용
만약 TanStack Query의 캐시를 활용했다면 staleTime를 적절한 값으로 저장해서 유효 시간을 지정해줬을 것이다.
하지만 이번에는 localStorage에 캐싱하기로 했으니 유효 시간 로직도 직접 만들어야 한다.
간단하게 구현하기 위해서 캐싱 시점의 시각 데이터도 같이 저장해서 TTL을 적용 해보고자 한다.
현재 시각과 캐싱 시점(savedAt)의 차이를 계산하여 TTL을 초과했는지 판단하는 방식으로 구현했다.

🏗️ #2. 캐싱 및 TTL 로직 구현
[ 캐싱 기본 동작 로직 ]
localStorage에 캐싱을 진행하기 위해 기본 동작을 지원하는 코드가 필요하다.
먼저 캐싱 데이터 타입을 정의한다.
앞서 말한대로 다양한 데이터를 저장하기 위해서 value의 타입은 제네릭을 사용했고,
캐싱 시점을 함께 작성해 정의했다.
TTL 기반 storage 캐시 엔트리
export interface CachedValue<T> {
value: T;
savedAt: number;
}
TTL을 상수로 정의해서 전역으로 관리하고 일관된 유효 기간을 적용하게 만든다.
주소명에 대한 위/경도 데이터는 7일, 위/경도에 대한 수치예보 모델 격자 좌표는 30일간 유효하다.
수치예보 모델 격자 좌표는 행정구역 정보이고 변동성이 낮기 때문에 비교적 긴 TTL을 적용했다.
TTL 상수
// 7 day
export const GEO_CACHE_TTL = 1000 * 60 * 60 * 24 * 7;
// 30 day
export const GRID_CACHE_TTL = 1000 * 60 * 60 * 24 * 30;
localStorage에 캐싱 데이터 조회, 저장하는 메서드를 만들면 기본 준비는 끝난다.
조회, 저장 메서드는 함수형 코딩에서 말하는 계산 메서드 개념에 집중해서 만든다.
localStorage에서 캐싱 데이터 조회 및 저장
/**
* localStorage에서 캐싱 데이터 조회
* @param key
* @param ttlMs
* @returns
*/
export const getStorageCache = <T>(key: string, ttlMs: number): T | null => {
const raw = localStorage.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as CachedValue<T>;
const isExpired = Date.now() - parsed.savedAt > ttlMs;
if (isExpired) {
localStorage.removeItem(key);
return null;
}
return parsed.value;
} catch {
localStorage.removeItem(key);
return null;
}
};
/**
* localStorage에 캐싱 데이터 저장
* @param key
* @param value
*/
export const setStorageCache = <T>(key: string, value: T): void => {
const payload: CachedValue<T> = {
value,
savedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify(payload));
};
[ 캐싱 보완 및 키(key) 생성 계산 메서드 ]
데이터 변동에 대한 대응을 위해 캐시 버전 정보도 넣어서 보완하면
캐싱된 예전 데이터를 안전하게 무시할 수 있다고 해서 함께 정의했다.
이 부분은 지금 당장에 필요 없지만 도입한 이유는 다음과 같다.
회사 서비스를 실제로 운영해보면서 데이터 변경, 추가를 요청하는 고객들의 요구사항들이 빈번했기 때문이다.
대응을 위한 안전 장치를 만들어서 연습한다는 의미에서 의의가 있다고 본다.
지역명 정보 및 좌표 정보 변동을 대비한 캐시 버전
export const GEO_CACHE_VERSION = "v1";
export const GRID_CACHE_VERSION = "v1";
캐싱에 키(key)로 사용할 문자열을 생성해 주는 계산 메서드도 정의한다.
키(key) 생성 계산 메서드
// key 일반화
export const normalizeRegionKey = (region: string): string =>
region.trim().replace(/\s+/g, " ").toLowerCase();
// 안정성 강화를 위한 캐시 key 조회
export const getGeoCacheKey = (region: string): string =>
`geo-cache:${GEO_CACHE_VERSION}:${normalizeRegionKey(region)}`;
// 안정성 강화를 위한 캐시 key 조회
export const getGridCacheKey = (lat: number, lon: number): string =>
`grid-cache:${GRID_CACHE_VERSION}:${lat.toFixed(4)}_${lon.toFixed(4)}`;
[ 액션 메서드 ]
캐싱 기본 동작 로직과 보완, 키(key) 생성 계산 메서드를 조합해서 캐싱 액션 메서드를 만들 차례이다.
액션 메서드를 요청하는 도메인에 해당하는 데이터를 반환해 주는 것이 목표이다.
액션 메서드
/**
* 지역명으로 위/경도 반환
* - 캐싱 데이터 우선 반환
* @param region
* @returns
*/
const getLatLonByRegion = async (region: string): Promise<LatLon> => {
const cacheKey = getGeoCacheKey(region);
const cached = getStorageCache<LatLon>(cacheKey, GEO_CACHE_TTL);
if (cached) {
return cached;
}
const latLon = await fetchLatLonByRegion(region);
setStorageCache(cacheKey, latLon);
return latLon;
};
/**
* 위경도로 기상청 좌표를 생성해 반환
* - 캐싱 데이터 우선 반환
* @param lat
* @param lon
* @returns
*/
const getGridByLatLon = (lat: number, lon: number): GridCoord => {
const cacheKey = getGridCacheKey(lat, lon);
const cached = getStorageCache<GridCoord>(cacheKey, GRID_CACHE_TTL);
if (cached) {
return cached;
}
const grid = convertToGridCoord({ lat, lon });
setStorageCache(cacheKey, grid);
return grid;
};
/**
* 지역명으로 기상청 좌표 생성해 반환
* @param region
* @returns
*/
export const resolveGridCoordByRegion = async (region: string): Promise<GridCoord> => {
const { lat, lon } = await getLatLonByRegion(region);
return getGridByLatLon(lat, lon);
};
아래의 메서드는 Github 참고
✅ fetchLatLonByRegion()
✅ convertToGridCoord()
마지막에 정의된 resolveGridCoordByRegion() 액션 메서드를 호출하는 것으로 Sequence diagram의 동작이 수행된다.
🎉 #3. 적용 결과
이제 검색 화면에서 동일한 연관 검색을 여러 번 눌러도 최초 한 번만 Kakao Local API가 호출되고 있다.

GitHub - J-mung/My_Weather_Bot: 기상청 API를 활용한 날씨 예보 애플리케이션
기상청 API를 활용한 날씨 예보 애플리케이션. Contribute to J-mung/My_Weather_Bot development by creating an account on GitHub.
github.com
정리
검색 기능 개선 계획을 드디어 마무리 했다.
[ ✅ ] 1️⃣ 일부 오타 허용
[ ✅ ] 2️⃣ 연관성 높은 검색 결과 제공
[ ✅ ] 3️⃣ 부분 검색 지원을 통한 하이라이트
[ ✅ ] 4️⃣ 캐시와 TTL을 통해 Kakao Local API 중복 호출을 줄이고 응답 비용을 절감
이번 포스팅에서 가장 중점으로 살펴야 할 내용은 아무래도 상태 범위 정의라고 생각된다.
나는 당연하게도 위/경도와 수치예보 모델 격자 좌표를 TanStack Query의 캐싱으로 관리하려 했다.
반면에 Codex는 최종 결과를 얻기 위한 중간 데이터이므로 localStorage에 저장하는 것도 좋다고 했다.
다양한 설계 방식을 비교해 보고 적용해 보는 과정 자체가 의미 있다고 판단해 Codex의 의견을 따랐던 내용이다.
덕분에 TanStack Query의 캐싱 대상을 다시 한번 정리해 볼 수 있는 계기가 됐다.