들어가기전에..
안녕하세요. 저는 프론트엔드와 백엔드를 포함해, 개발을 처음 시작하는 분들부터 현업에서 근무 중인 개발자까지 다양한 분들을 대상으로 개인 과외를 진행하고 있습니다.
요번에 쓰는 글은 한 프론트엔드 개발자가 자신의 개인 프로젝트 중 문의가 생겨 저에게 도움을 요청했던 내용입니다.
문의사항
//.env
VITE_APP_TRAIN_STATION_URL='https://apis.data.go.kr/1613000/TrainInfoService/getCtyAcctoTrainSttnList'
먼저 코드는 아래와 같아. 프론트 쪽 코드야
// train.ts
const response = await Promise.all(
AreaCode.map(async (area) => {
return await axios.get(TRAIN_STATION_URL, {
params: {
serviceKey: SERVICE_KEY,
pageNo: '1',
numOfRows: '57',
_type: 'json',
cityCode: area,
},
});
}),
);
const data = response.map((item) => item.data.response.body.items.item);
const startStation = data
.flat()
.filter((item) => item && item.nodename)
.map((item) => item.nodename);
return startStation;
export const FetchTrainStation = async () => {
const instance = axios.create({
baseURL: TRAIN_URL,
headers: {
'Content-Type': 'application/json',
},
params: {
serviceKey: SERVICE_KEY,
_type: 'json',
},
});
try {
const response = await instance.get('/getCtyCodeList');
const stations = response.data.response.body.items.item;
const station = await instance.get('/getCtyAcctoTrainSttnList', {
params: {
serviceKey: SERVICE_KEY,
pageNo: '1',
numOfRows: '57',
_type: 'json',
cityCode: ,
},
});
return station;
} catch (e) {
console.log(e);
}
};
이러한 코드를 보여주시면서 '일단 결론부터 말씀드리면 api 요청을 더 최적화된 방법으로 하는방법을 원하는데TRAIN_STATION_URL 이 api가 지역 코드를 통해 해당 지역의 기차역 리스트를 가져왔습니다.
근데 전 지역코드가 api 없는줄알고 직접 배열만들어서 promise all로 순회하는작업을 통해 각 지역의 기차리스트를 끌고왔죠 약 331개더군요 그런데 근데 알고보니 지역코드도 api가 따로있더군요.
그러면 먼저 지역코드 API를 먼저 불러와서 그 배열 값을 순회하여 getCtyAcctoTrainSttnList로 배열을 보내서 기차역 리스트를 받는 형태인데 직접 지역코드 배열을 만들어서 하느냐, 아니면 지역코드 api를 주소를 통해 배열을 받아서 하는지 어떤게 나은지 궁금하고, 그리고 이런 방식이 promise all 밖에 없는지 아니면 더 최적화된 방식이 있는지 궁금하다는 내용이였습니다.
또한 17개 HTTP연결이 한꺼번에 열려 초기 로딩이 7초까지 늘어났고, 간헐적으로 429 Too Many Requests가 터지게 되는 상황이 발생하게 되었죠.
그럼 이 부분을 어떻게 해결했을까?
먼저 두가지의 선택지가 있었습니다. 하나는 AreaCode 배열을 하드코딩한 뒤, 그 위에 동시 요청 제한(p-limit)과 클라이언트 캐싱만 적용해 성능을 확보하는 방법 있었고, 다른 하나는 getCtyCodeList 엔드포인트를 호출해 cityCode를 동적으로 가져온 후 캐싱하는 방법이 있었죠.
따로 AreaCode를 만들어서 처리하게 되면 첫 로드도 빨라지고 코드가 단순해지는대신 행정 구역 코드가 변경·추가되면 소스를 수정해 다시 배포해야 하는 단점이 있었고,
getCtyCodeList 엔드포인트를 호출해 cityCode를 동적으로 가져온 후 캐싱하게 되면 초기 로딩 때 API 호출이 1회 늘어나지만, 시·도 코드가 변해도 배포 없이 자동으로 반영된다는 유지보수상의 이점이 있습니다.
질문자는 잦은 API 호출을 줄이고 싶어했고, 지역 코드 데이터는 자주 변하지 않는다는 전제가 있었기 때문에 우선적으로 AreaCode 배열을 하드코딩하는 방식을 선택하게 되었습니다.
이렇게 한 뒤에 하나의 선택지가 남았죠. '그럼 Promise.all만으로 충분한가?' 초기에는 17개의 지역 코드에 대해 Promise.all을 통해 동시에 역 목록 API를 호출했습니다. 이 방식은 코드는 간단하지만, 실제로는 모든 요청을 한꺼번에 날리는 구조이기 때문에
브라우저의 동시 연결 수 제한, 서버 측 Rate Limit(예: 429 Too Many Requests), 단기적인 네트워크 병목등의 문제가 발생했었습니다.
이러한 문제를 해결하기 위해 제가 추천을 했던 것이 p-limit를 사용한 호출 방법이였습니다.
p-limit?
p-limit은 동시성(concurrency) 을 제어할 수 있는 Promise 유틸 라이브러리입니다. Promise.all처럼 모든 작업을 병렬 처리하지만, "동시에 몇 개까지 실행할지"를 제한할 수 있습니다.
예시 코드는 아래와 같은데
const limit = pLimit(10); // 동시에 10개만 실행
const tasks = AreaCode.map(code =>
limit(() => fetchStationsByCityCode(code))
);
await Promise.all(tasks);
이렇게 하게 되면 전체 요청은 한 번에 만들어지지만 실제로는 10개씩만 실행되고, 하나가 끝나야 다음 요청이 이어지는 방식으로 처리됩니다.
이렇게 하면 브라우저와 서버 모두에 부하를 줄여주며, 네트워크 환경이 불안정한 상황에서도 비교적 안정적으로 데이터를 불러올 수 있게 해주는 효과가 발생하죠.
그 외에도 Promise.allSettled을 사용하여 전체 요청 중 일부가 실패해도 전체 작업이 멈추지 않도록 했습니다.
실패한 요청은 콘솔에 경고만 남기고, 성공한 데이터만으로 역 목록을 구성할 수 있도록 처리했고, 응답 데이터는 localStorage에 24시간 단위로 캐싱하여, 이후 재방문 시에는 네트워크 요청 없이 즉시 데이터가 로딩되도록 했습니다.
이 덕분에 첫 로딩은 약 5~6초가 걸렸지만, 두 번째부터는 0.2초 이내에 화면이 렌더링될 수 있었습니다.
그렇게 하여 아래와 같이 코드를 수정시켰습니다.
// train.ts
import axios from 'axios';
import pLimit from 'p-limit';
import { AreaCode } from './areaCodes'; // 하드코딩된 지역코드
const TRAIN_BASE = 'https://apis.data.go.kr/1613000/TrainInfoService';
const SERVICE_KEY = import.meta.env.VITE_APP_TRAIN_SERVICE_KEY;
const STATION_CACHE_KEY = 'train_station_list';
const STATION_CACHE_TTL = 24 * 60 * 60 * 1000; // 24시간
const api = axios.create({
baseURL: TRAIN_BASE,
headers: { 'Content-Type': 'application/json' },
params: { serviceKey: SERVICE_KEY, _type: 'json' },
});
/** 각 지역의 기차역 목록 요청 */
async function getStationsByCity(cityCode: string) {
const { data } = await api.get('/getCtyAcctoTrainSttnList', {
params: { cityCode, pageNo: 1, numOfRows: 57 },
});
return data.response.body.items.item || [];
}
/** 전체 역 목록 요청 + 캐싱 */
export async function fetchAllStations(): Promise<any[]> {
const cached = localStorage.getItem(STATION_CACHE_KEY);
if (cached) {
const { at, stations } = JSON.parse(cached);
if (Date.now() - at < STATION_CACHE_TTL) return stations;
}
const limit = pLimit(10); // 동시 요청 제한
const tasks = AreaCode.map(code =>
limit(() => getStationsByCity(code))
);
const results = await Promise.allSettled(tasks);
const stations = results
.filter(r => r.status === 'fulfilled')
.flatMap(r => (r as PromiseFulfilledResult<any[]>).value);
// 캐싱
localStorage.setItem(STATION_CACHE_KEY, JSON.stringify({ at: Date.now(), stations }));
return stations;
}
/** 역 이름만 추출 (nodename) */
export async function fetchStationNames(): Promise<string[]> {
const stations = await fetchAllStations();
return stations
.filter(s => s && s.nodename)
.map(s => s.nodename);
}
결과
이 최적화 작업을 통해 어떤 결과를 얻게 되었는 지 확인해봅시다.
| 항목 | 최적화 전 (Promise.all) | 최적화 후 (p-limit + 캐싱) |
| 첫 로딩 속도 | 약 3.43초 (3431.07ms) | 약 1.44초 (1439.65ms) |
| 서버 오류 (429) | 간혈적으로 발생 | 아직까지 발생한 이력 없음 |
| 재방문 속도 | 동일하게 느림 | 0.2 초 이내 (캐시 덕분에 거의 즉시) |
| 실패 복구 | 전부 실패 시 전체 실패 | 일부 실패해도 정상 처리 가능 (Promise.allSettled) |
마치며
이번 작업을 통해 단순한 API 호출 외에도 요청을 얼마나 똑똑하게 분산시킬 수 있는지, 반복되는 요청을 어떻게 최소화할 수 있는지, 그리고 작은 유틸 하나(p-limit)로 전체 구조를 얼마나 안정시킬 수 있는지를 확인할 수 있었습니다.
무엇보다도 이 구조를 적용한 질문자 역시 불안정했던 API 호출 문제를 해결하고, 체감 성능까지 크게 향상되는 결과에 매우 만족해했습니다.
이 글이 비슷한 API 구조나 대량 요청 최적화 문제로 고민 중인 개발자에게 조금이나마 도움이 되길 바랍니다.
'프로그래밍(Web) > Javascript(TS,Node)' 카테고리의 다른 글
| [바미] JS - 오목 (0) | 2024.10.31 |
|---|---|
| [바미] JS - 스도쿠 게임 (0) | 2024.10.29 |
| [바미] JS - 테트리스 (0) | 2024.09.21 |
| [바미] Node-Express 간단한 REST API 예제 (0) | 2024.08.01 |
| [바미] 심심해서 만들어 본 슬롯머신 (0) | 2024.06.07 |