여러 API나 비동기 소스에서 데이터를 받아 하나의 리스트로 합치고, 일관된 UI로 보여주는 일은 프론트엔드에서 매우 흔합니다. 이 글은 Promise.all/Promise.allSettled, AbortController, 병렬 수 제한, React Query/SWR, 서버 컴포넌트까지 실무에서 바로 쓰는 병합 패턴을 정리합니다.
1. 목표와 설계 기준
목표는 다음과 같습니다. 1) 빠르게 병렬로 가져오되, 2) 부분 실패를 견디며, 3) 중복 호출과 경합을 피하고, 4) 병렬 수를 제어해 서버를 보호하고, 5) 캐시/선택자/메모이제이션으로 불필요한 리렌더를 줄이는 것입니다.
2. 기본 패턴: Promise.all로 다중 리스트 병합
여러 리스트를 id로 합치는 가장 단단한 기본기입니다. 우선 각 소스는 최대한 병렬로 가져오고, Map을 사용해 O(n)으로 병합합니다.
// 예시 데이터: products, prices, stocks를 id 기준으로 병합
const fetchJSON = (url, { signal } = {}) => fetch(url, { signal }).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
const fetchProducts = (opts) => fetchJSON('/api/products', opts); // [{id, name, ...}]
const fetchPrices = (opts) => fetchJSON('/api/prices', opts); // [{id, price}]
const fetchStocks = (opts) => fetchJSON('/api/stocks', opts); // [{id, stock}]
function mergeById(primary, ...others) {
const map = new Map(primary.map(p => [p.id, { ...p }]));
for (const list of others) {
for (const item of list) {
const cur = map.get(item.id) || { id: item.id };
map.set(item.id, { ...cur, ...item });
}
}
return [...map.values()];
}
// React 훅으로 캡슐화
import { useEffect, useState } from 'react';
export function useMergedProducts() {
const [state, setState] = useState({ data: [], loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
(async () => {
try {
const [products, prices, stocks] = await Promise.all([
fetchProducts({ signal }),
fetchPrices({ signal }),
fetchStocks({ signal })
]);
const merged = mergeById(products, prices, stocks);
setState({ data: merged, loading: false, error: null });
} catch (e) {
if (signal.aborted) return; // 최신 요청만 반영
setState(prev => ({ ...prev, loading: false, error: e }));
}
})();
return () => controller.abort();
}, []);
return state;
}
핵심 포인트: 1) 병렬로 모두 가져옵니다. 2) Map 병합으로 마지막 소스가 우선권을 갖습니다. 3) AbortController로 언마운트/재요청 시 낭비를 줄입니다.
3. 부분 실패 허용: Promise.allSettled
일부 소스가 실패하더라도 화면을 최대한 채우고 싶다면 allSettled를 사용합니다. 실패한 소스는 기본값으로 대체합니다.
async function fetchMultiSafe(signal) {
const settled = await Promise.allSettled([
fetchProducts({ signal }),
fetchPrices({ signal }),
fetchStocks({ signal })
]);
const [products, prices, stocks] = settled.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
// 소스별 안전한 기본값
if (i === 0) return []; // 제품 리스트 실패: 빈 리스트
if (i === 1) return []; // 가격 실패: 가격 미표시 전략
if (i === 2) return []; // 재고 실패: 재고 미표시 전략
return [];
});
return mergeById(products, prices, stocks);
}
UI에서는 가격/재고가 없는 항목에 뱃지나 회색 처리 등으로 부분 실패를 인지할 수 있게 합니다.
4. 중복 호출·경합 방지: In-flight 캐시와 키드 중단
같은 키로 여러 번 요청이 발생하면 한 번만 네트워크를 쓰고 나머지는 동일 Promise를 공유합니다. 또한 최신 요청만 반영되게 만듭니다.
// 간단한 in-flight 요청 캐시
const inFlight = new Map();
function fetchOnce(key, fn) {
if (inFlight.has(key)) return inFlight.get(key);
const p = fn().finally(() => inFlight.delete(key));
inFlight.set(key, p);
return p;
}
// 사용 예: 필터가 동일하면 네트워크 공유
const key = 'products:all';
const products = await fetchOnce(key, () => fetchProducts());
경합(race)으로 이전 요청이 나중에 도착하는 문제는 AbortController 사용 또는 요청마다 증가하는 token을 부여하고, 최신 token만 setState 하도록 방지합니다.
let reqId = 0;
function useSafeRequest(makePromise) {
const [state, setState] = useState({ data: null, loading: false, error: null });
useEffect(() => {
let canceled = false;
const id = ++reqId;
setState(s => ({ ...s, loading: true }));
makePromise()
.then(data => { if (!canceled && id === reqId) setState({ data, loading: false, error: null }); })
.catch(e => { if (!canceled && id === reqId) setState({ data: null, loading: false, error: e }); });
return () => { canceled = true; };
}, [makePromise]);
return state;
}
5. 서버 보호: 병렬 수 제한으로 폭주 방지
상세 API를 항목 수만큼 동시 호출하면 서버가 힘들 수 있습니다. 간단한 p-limit로 동시성을 제한합니다.
function pLimit(concurrency) {
let active = 0;
const queue = [];
const next = () => {
if (active >= concurrency || queue.length === 0) return;
active++;
const { fn, resolve, reject } = queue.shift();
fn().then(resolve, reject).finally(() => { active--; next(); });
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
// 사용 예: 상세 100건을 동시 5개로 제한
const limit = pLimit(5);
const details = await Promise.all(items.map(it => limit(() => fetchJSON('/api/detail/' + it.id))));
6. React Query/SWR로 선언적 병합
캐시, 중복 제거, 재시도, 부분 실패 등을 라이브러리에 위임하면 안정성이 올라갑니다. useQueries로 병렬 fetch 후 메모이제이션으로 병합합니다.
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
export function useMergedWithRQ() {
const results = useQueries({
queries: [
{ queryKey: ['products'], queryFn: () => fetchProducts() },
{ queryKey: ['prices'], queryFn: () => fetchPrices() },
{ queryKey: ['stocks'], queryFn: () => fetchStocks() }
]
});
const loading = results.some(r => r.isLoading);
const error = results.find(r => r.error)?.error ?? null;
const [products, prices, stocks] = results.map(r => r.data ?? []);
const data = useMemo(() => mergeById(products, prices, stocks), [products, prices, stocks]);
return { data, loading, error };
}
SWR 사용 시에도 useSWRImmutable 등으로 각각 가져온 뒤 useMemo로 병합하면 유사하게 구현됩니다.
7. Next.js 서버 컴포넌트/SSR에서 병합
네트워크가 서버 내부에서 더 빠르고 안정적일 때, 서버에서 먼저 병합해 클라이언트에는 완성된 결과만 보냅니다.
// app/products/page.js (Next.js RSC 예시)
import Products from './Products';
export default async function Page() {
const [products, prices, stocks] = await Promise.all([
fetch('https://api.example.com/products').then(r => r.json()),
fetch('https://api.example.com/prices').then(r => r.json()),
fetch('https://api.example.com/stocks').then(r => r.json())
]);
const merged = mergeById(products, prices, stocks);
return <Products initialData={merged} />;
}
서버에서 병합하면 초기 페인트가 안정적이며, 클라이언트 수화 후에는 필요 시 부분 갱신만 하면 됩니다.
8. 데이터 정규화와 정렬·필터 파이프라인
• 병합 전에 스키마를 정규화합니다. 숫자/날짜 파싱, null 기본값 채우기 등으로 병합 후 로직이 단순해집니다.
• 병합 후에는 useMemo로 정렬/필터를 파생시키면 렌더링 비용을 줄일 수 있습니다.
function normalize(list) {
return list.map(x => ({
...x,
price: typeof x.price === 'number' ? x.price : null,
stock: typeof x.stock === 'number' ? x.stock : null
}));
}
const merged = normalize(mergeById(products, prices, stocks));
const sorted = useMemo(() => [...merged].sort((a, b) => (b.stock ?? -1) - (a.stock ?? -1)), [merged]);
9. 테스트와 관측 가능성
• 병합 함수는 순수 함수로 분리하고, 단위 테스트로 키 충돌, 누락 키, 중복 항목, 순서 안정성 등을 검증합니다.
• 로깅에 병합 단계별 카운트(원본 크기, 실패 수, 병합 후 크기)를 남기면 장애 분석이 빨라집니다.
test('mergeById merges by id and preserves last-writer-wins', () => {
const a = [{ id: 1, name: 'A' }];
const b = [{ id: 1, price: 100 }, { id: 2, price: 200 }];
const c = [{ id: 1, price: 110 }];
expect(mergeById(a, b, c)).toEqual([
{ id: 1, name: 'A', price: 110 },
{ id: 2, price: 200 }
]);
});
10. 빠른 문제 해결 가이드
• 일부 항목이 사라진다: 병합 키(id)가 다르거나 타입이 문자열/숫자로 섞였는지 확인하고, 키를 String(id)로 강제 일치시킵니다.
• 요청이 너무 많다: 목록을 먼저 받고, 상세는 인터섹션 옵저버나 onHover/onDemand로 늦춥니다. 또는 서버 배치 API를 도입합니다.
• UI가 자주 깜빡인다: allSettled로 기본값을 두고, 뷰에서 스켈레톤이나 placeholder를 유지합니다. React Query의 keepPreviousData 옵션 활용.
• 성능이 떨어진다: 병합 후 정렬/필터를 useMemo로 감싸고, 리스트는 가상 스크롤(react-window) 도입을 고려합니다.
FAQ
Q. Promise.all과 allSettled는 언제 고르나요? A. 핵심 소스가 실패하면 전체 의미가 없을 때는 Promise.all, 보조 소스(가격/배지 등)는 실패해도 화면을 유지하고 싶을 때 allSettled로 부분 실패 허용을 권장합니다.
Q. 병합 키가 서로 다른 경우는? A. 서버에서 조인 테이블을 제공받거나, 프런트에서 매핑 테이블을 먼저 구축해 키 변환 과정을 거친 뒤 병합합니다.
Q. 타입 안정성은 어떻게 담보하나요? A. TypeScript로 병합 전후 타입을 정의하고, 런타임에는 zod/yup으로 스키마 검증을 추가하면 안전합니다.
정리하면, 병렬 수 최대화, 부분 실패 내성, 중복 제거, 병렬 제한, 캐시·메모이제이션을 조합하면 실무에서 안정적인 비동기 데이터 다중 병합을 구현할 수 있습니다.
'React' 카테고리의 다른 글
| React와 Supabase로 풀스택 앱 구축하기 (0) | 2026.06.01 |
|---|---|
| React로 사용자 맞춤형 대시보드 구현하기 (0) | 2026.06.01 |
| React 앱에서 이미지 필터 효과 적용하기 (0) | 2026.05.30 |
| React와 D3.js를 결합한 데이터 시각화 기법 (0) | 2026.05.29 |
| React에서 오디오 플레이어 컴포넌트 만들기 (0) | 2026.05.29 |