useInfiniteQuery로 무한스크롤 검색 리스트 상태 관리 개선
문제를 발견한 계기
오토위니 통합검색 페이지는 검색 결과가 표시된 뒤 다음 페이지가 존재하면 하단에 More search results 버튼(이하 More 버튼으로 표시)이 노출되고, 이 버튼을 누르면 다음 페이지의 검색 결과를 이어 붙이는 무한스크롤형 리스트입니다. 그런데 페이지를 확인하던 중 이상 현상을 발견했습니다. 검색 리스트는 아직 비어 있는데, More 버튼만 먼저 떠 있는 것이었습니다.
원인을 디버깅해 보니 이 페이지는 메인 검색 API 외에 보조 API(리스트 중간에 끼워 넣는 추천 매물)를 함께 호출하고 있었고, More 버튼의 노출 조건은 메인 쿼리의 isLoading만 보고 있었습니다. 보조 API 응답이 늦어지면 리스트 병합은 미뤄지는데 버튼 조건은 이미 참이 되어, 버튼이 리스트보다 먼저 나타난 것입니다.
버그 자체는 조건식 하나만 고쳐도 막을 수 있었습니다. 하지만 코드를 들여다보니 문제는 조건식이 아니라, 서버 데이터를 useState로 복사해 두고 useEffect로 직접 동기화하는 구조 자체였습니다. 그래서 조건식만 고치는 대신 useInfiniteQuery로 리팩토링을 진행하게 되었습니다.
기존 코드의 문제점
기존 구조는 아래와 같습니다. 이해를 돕기 위해 실제 코드보다 간단하게 정리했고, 차량 사이에 추천 매물을 끼워 넣는 병합 로직은 개선 전후가 같아서 헬퍼 함수로 빼두었습니다.
utils/mergeCarsAndPromos.ts
// 차량 리스트 사이에 추천 매물을 끼워 넣음(차량 10대마다 1개)
export const mergeCarsAndPromos = (cars: Car[], promoCars: PromoCar[]) => {
const merged: (Car | PromoCar)[] = [];
cars.forEach((car, index) => {
merged.push(car);
const promo = promoCars[Math.floor(index / 10)];
if ((index + 1) % 10 === 0 && promo) merged.push(promo);
});
return merged;
};SearchPage.tsx (개선 전)
export default function SearchPage() {
const [page, setPage] = useState(1);
// 서버 응답을 복사해 누적하는 리스트 state
const [displayList, setDisplayList] = useState<(Car | PromoCar)[]>([]);
const { cars, totalCount, isLoading } = useCarList({ keyword, sort, page }); // 메인 검색
const { promoCars, promoIsLoading } = usePromoList({ keyword, page }); // 보조 검색(추천 매물)
// 키워드가 바뀌면 누적 리스트를 직접 초기화
useEffect(() => {
setDisplayList([]);
setPage(1);
}, [keyword]);
// 두 API 응답이 모두 도착하면 effect에서 병합해 리스트에 이어 붙임
useEffect(() => {
if (cars && !promoIsLoading) {
setDisplayList([...displayList, ...mergeCarsAndPromos(cars, promoCars ?? [])]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cars, promoCars]);
// More 버튼 — 페이지 번호를 직접 증가
const handleMore = () => setPage((prev) => prev + 1);
return (
<>
{displayList.map((item) => (
<ResultCard key={item.id} item={item} />
))}
{/* More 버튼 노출 조건 — 메인 쿼리 기준으로만 판단 */}
{!isLoading && totalCount > displayList.length && (
<MoreButton onClick={handleMore} />
)}
</>
);
}이 코드 구조에는 문제가 몇 가지 있었습니다.
- 서버 응답이 이미 TanStack Query 캐시에 있는데 displayList로 한 번 더 복사합니다. 같은 데이터가 두 군데에 있으니, 둘을 맞추는 일은 전부 컴포넌트가 떠안게 됩니다.
- 데이터가 도착해도 바로 그리지 못합니다. 렌더 → useEffect → setState → 재렌더로 한 바퀴를 더 돌아야 하고, 그 사이에 깜빡임이나 버튼만 먼저 뜨는 순간이 생깁니다.
- 병합 effect가 !promoIsLoading을 조건으로 걸고 있어서, 보조 API가 늦으면 리스트 병합만 미뤄지고 다른 UI는 그 사실을 모릅니다. 처음 발견한 버그가 정확히 이 지점이었습니다.
- 키워드 변경, 정렬 변경 등 조건이 바뀔 때마다 setDisplayList([])를 직접 호출해야 합니다. 한 곳이라도 빠뜨리면 이전 결과가 새 결과에 섞입니다.
useInfiniteQuery로 개선
페이지 누적은 TanStack Query에 위임하고, 화면용 병합 리스트는 useMemo로 렌더 중에 파생하도록 바꿨습니다. 먼저 API 훅입니다.
api/useCarList.ts
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query';
interface CarListResponse {
items: Car[];
totalCount: number;
}
// axios 응답에서 body만 반환해 이후 데이터 접근을 단순화
const fetchCarList = async (keyword: string, sort: string, page: number) => {
const { data } = await api.get<CarListResponse>('/cars', {
params: { keyword, sort, page },
});
return data;
};
// select를 쓴 이유: 캐시 원본(응답 전체)은 그대로 두고, 화면에서 쓸 모양(페이지별 items 배열)만 골라내기 위함
// 모듈 레벨에 둔 이유: 컴포넌트 안 인라인 함수는 매 렌더 재생성되어 select 결과의 메모이제이션이 깨짐
const selectCarList = (data: InfiniteData<CarListResponse>) => ({
pages: data.pages.map((page) => page.items),
totalCount: data.pages[0]?.totalCount ?? 0,
});
export const useCarList = ({ keyword, sort }: CarListParams) => {
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
// 키에는 검색 조건만 넣음 — 페이지 번호는 pageParam이 관리하고, 조건이 바뀌면 누적 페이지가 자동 리셋됨
queryKey: ['carList', keyword, sort],
// pageParam(페이지 번호)은 TanStack Query가 넘겨줌
queryFn: ({ pageParam }) => fetchCarList(keyword, sort, pageParam),
enabled: !!keyword, // 키워드가 있을 때만 호출
initialPageParam: 1, // 첫 요청은 1페이지
// 다음 페이지 번호를 계산 — undefined를 반환하면 마지막 페이지(hasNextPage가 false)
getNextPageParam: (lastPage, allPages) => {
// 지금까지 불러온 아이템 수
const loadedCount = allPages.flatMap((page) => page.items).length;
// 빈 페이지가 오거나 전부 불러왔으면 종료 — totalCount가 어긋나도 More 버튼이 무한히 남지 않도록 방어
if (lastPage.items.length === 0 || loadedCount >= lastPage.totalCount) {
return undefined;
}
return allPages.length + 1;
},
select: selectCarList,
});
return {
carPages: data?.pages ?? [],
totalCount: data?.totalCount,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
};
};보조 검색 훅(usePromoList)도 같은 구조로 전환하되, queryKey에 메인과 동일하게 sort까지 포함해 두 쿼리의 페이지 인덱스가 항상 1:1로 맞도록 했습니다. 컴포넌트는 이렇게 단순해집니다.
SearchPage.tsx (개선 후)
export default function SearchPage() {
// 페이지 누적·리셋·다음 페이지 여부를 전부 TanStack Query가 관리
const { carPages, totalCount, isFetchingNextPage, hasNextPage, fetchNextPage } =
useCarList({ keyword, sort });
const { promoPages, promoIsFetchingNextPage, fetchPromoNextPage } =
usePromoList({ keyword, sort });
// 캐시에 쌓인 페이지로 렌더 중에 리스트를 계산 — effect + setState 단계가 없어 추가 렌더·깜빡임이 없음
// useMemo를 쓴 이유: 페이지 데이터가 안 바뀐 렌더에서는 병합을 건너뛰고 이전 배열을 재사용하기 위함
const displayList = useMemo(
() => carPages.flatMap((cars, i) => mergeCarsAndPromos(cars, promoPages[i] ?? [])),
[carPages, promoPages]
);
// More 버튼 — 메인/보조 다음 페이지를 함께 요청(페이지 인덱스 1:1 유지)
const handleMore = () => {
fetchNextPage();
fetchPromoNextPage();
};
return (
<>
{displayList.map((item) => (
<ResultCard key={item.id} item={item} />
))}
{/* 다음 페이지가 있고 두 쿼리 모두 요청 중이 아닐 때만 노출 */}
{hasNextPage && !isFetchingNextPage && !promoIsFetchingNextPage && (
<MoreButton onClick={handleMore} />
)}
</>
);
}이번에 사용한 useInfiniteQuery 옵션 정리
| queryKey | 필수 | 캐시 식별자. 페이지 번호는 넣지 않고 검색 조건만 넣는 것이 핵심입니다. 조건이 바뀌면 키가 바뀌면서 누적된 페이지가 자동으로 리셋되므로, 기존의 setDisplayList([]) 같은 수동 초기화가 전부 사라집니다. |
|---|---|---|
| queryFn | 필수 | 인자로 { pageParam }을 받습니다. 이 값을 요청 파라미터로 사용하면 페이지 번호 state를 직접 관리할 필요가 없습니다. |
| initialPageParam | 필수(v5) | 첫 요청에 전달될 pageParam입니다. 1페이지부터 시작하도록 1로 두었습니다. |
| getNextPageParam | 필수 | (lastPage, allPages)를 받아 다음 pageParam을 반환합니다. undefined를 반환하면 마지막 페이지로 간주되고, 이 결과가 곧 hasNextPage가 됩니다. 즉 "더 불러올 수 있는가"라는 판단 로직이 컴포넌트의 조건식에서 API 훅 안으로 옮겨집니다. 저는 누적 로드 수와 totalCount를 비교하되, 서버의 totalCount와 실제 아이템 수가 불일치할 때 More 버튼이 영원히 남는 것을 막기 위해 빈 페이지가 오면 즉시 종료하는 방어 로직을 넣었습니다. |
| select | 선택 | 캐시 원본을 화면용 데이터로 변환합니다. 캐시 자체는 건드리지 않으므로 원본과 파생을 분리할 수 있습니다. 한 가지 주의할 점은 인라인 함수로 넘기면 매 렌더마다 재생성되어 메모이제이션이 깨지므로, 모듈 레벨 함수로 정의해야 참조가 안정된다는 것입니다. |
| enabled | 선택 | !!keyword로 두어 키워드가 있을 때만 호출되도록 했습니다. 기존 useQuery와 동일합니다. |
반환값 중에서는 세 가지를 사용했습니다.
- fetchNextPage: 다음 페이지를 요청합니다. 기존의 "page를 1 올리는 setState"를 대체합니다.
- hasNextPage: getNextPageParam이 undefined가 아니면 true입니다. More 버튼을 보여줄지 판단하는 기준이 됩니다.
- isFetchingNextPage: 다음 페이지를 요청하는 중인지 여부입니다. 첫 로딩(isLoading)과 구분되어서, 추가 로딩 스켈레톤과 버튼 숨김을 따로 처리할 수 있습니다.
처음 발견했던 버그도 자연스럽게 해결됐습니다. More 버튼의 조건이 "메인 쿼리가 로딩 중인가"에서 "다음 페이지가 존재하고, 두 쿼리 모두 요청 중이 아닌가"라는 선언적 조건으로 바뀌면서, 보조 API가 늦게 와도 버튼이 리스트보다 먼저 뜰 수 없는 구조가 되었습니다
그런데 '성능'이 좋아진 걸까?
리팩토링을 하면서 스스로에게 던졌던 질문입니다. useState로 리스트를 들고 있는 것과 useInfiniteQuery가 캐시에 들고 있는 것, 어차피 둘 다 자바스크립트 힙 메모리인데 성능 차이가 있을까?
결론부터 말하면 저장 자체는 차이가 없습니다. TanStack Query의 캐시도 결국 메모리 위에 있는 객체입니다. useState 복사본이 없어지긴 했지만, 어차피 배열에 담기는 것은 같은 객체를 가리키는 참조라서 메모리 절약이라고 부를 만한 수준도 아닙니다. 그래서 이 리팩토링을 "캐싱 덕분에 빨라졌다"라고 쓸 수는 없었습니다.
그래도 달라진 점은 있습니다. 렌더 횟수입니다. 기존에는 데이터가 도착한 뒤 effect를 거쳐 setState가 실행되면서 렌더가 한 사이클 더 돌았는데, 지금은 useMemo로 렌더 중에 리스트를 만들기 때문에 그 렌더가 사라졌습니다.
결국 이번 개선으로 얻은 것은 성능보다는 정확성과 유지보수성입니다. 두 API의 도착 순서에 따라 달라지던 동작, 수동 초기화를 빠뜨릴 가능성 같은 버그의 여지를 구조에서 제거했고, 직접 관리하던 페이지 누적, 초기화, 병합 동기화 코드가 사라진 만큼 앞으로 손댈 코드도 줄었습니다.
느낀 점
서버 상태를 useState로 복사하는 순간, 그 데이터의 동기화 책임은 라이브러리에서 내 코드로 넘어온다는 것을 체감했습니다. 초기화 시점, 병합 순서, 로딩 조건을 전부 직접 챙겨야 했고, 처음 발견한 More 버튼 버그도 결국 그중 하나를 빠뜨린 결과였습니다. TanStack Query를 쓰면서 응답을 useState에 옮겨 담고 있다면 한 번 의심해 볼 만한 신호라고 생각합니다.
이번에는 오토위니 통합검색 페이지에 우선 적용했습니다. 같은 무한스크롤 리스트 패턴을 쓰는 파츠위니 통합검색 페이지에도 동일한 구조가 남아 있어, 앞으로 이 패턴을 기준 삼아 일관성 있게 리팩토링해 나갈 계획입니다.
- #infinite scroll
- #react
- #tanstack query
- #TypeScript
- #useInfiniteQuery
- #무한스크롤
- #프론트엔드
- #프론트엔드 개발