프로그래밍/next.js

프리패칭(Background Prefetching) - Next.js 서비스의 초기 로딩 속도 개선 기록

d 0_0 b 2026. 7. 15. 23:04

장마철, 비 조심하세요

 

 

최근 관리자 화면의 로딩 속도를 개선했습니다.

 

gif로 화면을 따고 싶지만, 내부 유출 문제로 그러지 못해서 아쉽습니다....

 

 

해당 화면은 제품 제작에 필요한 기본 데이터를 관리하는 곳입니다. 자재, 원판 규격, 품목, 부품 구성 같은 데이터를 한 화면에서 다루고 있습니다.

 

 

처음에는 사용자가 화면에 들어올 때 모든 데이터를 한 번에 불러오고 있었습니다. 데이터가 많지 않을 때는 큰 문제가 없어 보였지만, 실제 운영 데이터가 쌓이면서 첫 화면 진입이 느려졌습니다. 저장이나 수정 후에도 전체 화면을 다시 불러오는 구조였기 때문에 체감 속도도 좋지 않았습니다.

문제 상황

개선 전 기준정보 화면의 응답 시간은 다음과 같았습니다.

2.75s
1.50s
1.43s
1.42s
1.43s

중앙값은 약 1.43초였습니다.

이 화면은 첫 진입 시 다음 데이터를 모두 서버에서 조회했습니다.

  • 자재 데이터
  • 원판 규격 데이터
  • 부품 종류 데이터
  • 품목 템플릿 데이터
  • 품목별 부품 구성 데이터

사용자가 처음 보는 화면은 하나의 탭이지만, 실제로는 아직 보지 않은 탭의 데이터까지 모두 가져오고 있었습니다.

또한 저장, 수정, 삭제 이후에는 router.refresh()로 전체 페이지를 다시 불러왔습니다. 작은 수정 하나에도 모든 데이터를 다시 조회하는 구조였습니다.

 

원인 분석

가장 큰 원인은 초기 화면에 필요한 데이터와 나중에 필요한 데이터가 분리되어 있지 않은 점이었습니다.

 

사용자는 첫 화면에서 자재 목록만 바로 확인하면 됩니다.

원판 규격이나 품목 템플릿은 해당 탭을 열 때 필요합니다. 하지만 기존 구조에서는 모든 탭의 데이터를 서버 렌더링 단계에서 한 번에 가져왔습니다.

두 번째 문제는 데이터 갱신 방식이었습니다. 예를 들어 부품 구성 하나를 수정해도 전체 기준정보 페이지를 다시 불러왔습니다. 변경된 영역만 갱신해도 되는 상황에서 화면 전체가 다시 로딩되었습니다.

세 번째 문제는 중복 API 호출이었습니다. 품목 템플릿 API가 이미 품목별 부품 구성을 포함할 수 있는데, 별도의 부품 구성 API도 함께 호출하고 있었습니다. 같은 성격의 데이터를 여러 번 가져오면서 백그라운드 로딩 시간이 길어졌습니다.

 

 


 

개선 방향

 

중점적으로 개선할 방향은 프리패칭입니다.

프리패칭 예시

 

  • 사용자가 곧 볼 가능성이 높은 데이터를 화면 진입 후 미리 가져오는 방식입니다.
  • 이번에는 첫 화면 렌더링 후 나머지 기준 데이터를 백그라운드에서 가져왔습니다.

그 후에는 첫 화면에 필요한 최소 데이터만 먼저 로드하고, 나머지는 단계적으로 불러오는 방식을 적용 했습니다.

 

 

 

 

처음에는 쿼리 자체를 줄이는 방식부터 검토했습니다. 느린 화면을 보면 보통 가장 먼저 떠올리는 방법은 DB 인덱스 추가, 쿼리 최적화, 캐싱입니다.

이 방법들도 필요할 수 있지만, 이번 문제의 핵심은 단일 쿼리 성능보다 화면이 데이터를 가져오는 방식에 있었습니다.

 

 

현재 서비스의 화면

 

기준정보 화면은 여러 종류의 데이터를 한 화면에서 관리합니다. 자재, 원판 규격, 품목 템플릿, 부품 구성 데이터가 모두 연결되어 있습니다. 기존 구조에서는 화면에 처음 들어오는 순간 이 데이터를 전부 서버에서 조회했습니다.

 

문제는 사용자가 처음부터 이 모든 데이터를 보지 않는다는 점입니다. 첫 화면에서 실제로 필요한 데이터는 자재 목록뿐입니다. 원판 규격이나 품목 템플릿은 해당 탭을 열었을 때 필요합니다. 즉, 데이터의 중요도와 조회 시점이 서로 달랐습니다.

그래서 개선 기준을 다음처럼 잡았습니다.

  1. 첫 화면 렌더링에 필요한 데이터만 서버에서 조회합니다.
  2. 나머지 데이터는 화면이 열린 뒤 백그라운드에서 미리 가져옵니다.
  3. 사용자가 백그라운드 로딩 전에 탭을 열면 해당 탭에 필요한 데이터만 즉시 가져옵니다.
  4. 저장, 수정, 삭제 이후에는 전체 화면을 다시 불러오지 않고 변경된 데이터만 다시 조회합니다.
  5. 여러 API로 나뉜 백그라운드 요청은 가능한 한 하나로 묶습니다.

 

이 기준을 적용하면 서버 렌더링 단계에서 처리해야 하는 일이 줄어듭니다. 사용자는 첫 화면을 더 빨리 볼 수 있고, 나머지 데이터는 사용자가 화면을 읽는 동안 미리 준비됩니다.

서버 렌더링에서 반드시 필요한 데이터만 남기기

기존에는 페이지 진입 시 전체 데이터를 가져왔습니다.

export default async function MasterDataPage() {
  const data = await getMasterData();

  return <MasterDataManager initialData={data} />;
}

getMasterData()는 자재, 원판 규격, 부품 종류, 품목 템플릿, 부품 구성 데이터를 모두 조회했습니다. 이 방식은 구현이 단순합니다. 클라이언트 컴포넌트는 처음부터 모든 데이터를 가지고 시작하기 때문입니다.

하지만 서버 렌더링 단계에서 모든 데이터를 기다려야 합니다. 사용자가 첫 화면에서 보지 않는 데이터도 초기 응답 시간을 늦춥니다.

개선 후에는 첫 화면에 필요한 자재 데이터만 조회합니다.

export default async function MasterDataPage() {
  const data = await getInitialMasterData();

  return <MasterDataManager initialData={data} />;
}
export async function getInitialMasterData() {
  const materials = await prisma.material.findMany({
    orderBy: [{ isActive: "desc" }, { thickness: "asc" }, { name: "asc" }],
  });

  return {
    materials: materials.map(serializeMaterial),
    materialBoards: [],
    standardParts: [],
    furnitureItems: [],
    furnitureItemParts: [],
  };
}

데이터 구조는 기존과 동일하게 유지했습니다. 대신 초기에는 빈 배열을 넣어둡니다. 이렇게 하면 클라이언트 컴포넌트의 상태 구조를 크게 바꾸지 않고도 초기 조회 비용을 줄일 수 있습니다.

탭별로 필요한 데이터 정의하기

다음으로 각 탭이 어떤 데이터를 필요로 하는지 명시했습니다.

const tabRequirements = {
  materials: ["materials"],
  boards: ["materials", "materialBoards"],
  furnitureItems: [
    "materials",
    "standardParts",
    "furnitureItems",
  ],
};

이렇게 분리하면 탭을 열 때 필요한 데이터만 확인할 수 있습니다. 이미 불러온 데이터는 다시 요청하지 않고, 아직 없는 데이터만 API로 가져옵니다.

async function loadSections(sections) {
  const targetSections = sections.filter(
    (section) =>
      !loadedSections.has(section) &&
      !loadingSections.has(section),
  );

  if (targetSections.length === 0) {
    return;
  }

  const entries = await Promise.all(
    targetSections.map(async (section) => {
      const response = await fetch(sectionEndpoints[section]);
      return [section, await response.json()];
    }),
  );

  setData((current) => {
    const next = { ...current };

    for (const [section, value] of entries) {
      Object.assign(next, { [section]: value });
    }

    return next;
  });
}

여기서 loadedSections와 loadingSections를 따로 둔 이유는 중복 요청을 막기 위해서입니다. 백그라운드 프리패치가 진행 중인 상태에서 사용자가 같은 탭을 클릭할 수 있습니다. 이때 같은 API를 다시 호출하면 프리패치의 장점이 줄어듭니다.

백그라운드 프리패치 추가하기

탭별 지연 로딩만 적용하면 첫 화면은 빨라집니다. 하지만 사용자가 다음 탭을 눌렀을 때 로딩을 기다릴 수 있습니다.

그래서 첫 화면이 열린 직후 나머지 데이터를 백그라운드에서 미리 가져오도록 했습니다.

useEffect(() => {
  const timer = window.setTimeout(() => {
    prefetchRemainingSections().catch(onError);
  }, 250);

  return () => window.clearTimeout(timer);
}, []);

250ms 지연을 둔 이유는 초기 렌더링과 hydration 직후에 바로 네트워크 요청을 몰아넣지 않기 위해서입니다. 사용자가 첫 화면을 보는 데 필요한 작업을 먼저 끝내고, 이후에 다음 탭 데이터를 준비합니다.

이 방식은 lazy load와 prefetch를 함께 사용합니다.

  • lazy load는 “필요할 때 가져오기”입니다.
  • prefetch는 “곧 필요할 가능성이 높은 데이터를 미리 가져오기”입니다.

탭 UI에서는 두 방식을 같이 쓰는 편이 좋습니다. 첫 화면은 가볍게 유지하면서도, 사용자가 이동할 가능성이 높은 화면은 미리 준비할 수 있습니다.

프리패치 API를 하나로 묶기

처음에는 백그라운드에서 여러 API를 동시에 호출했습니다.

/api/master-data/material-boards
/api/master-data/standard-parts
/api/master-data/furniture-items

하지만 Vercel 서버리스 환경에서는 API가 나뉘어 있을수록 함수 호출 비용이 늘어납니다. 각각의 라우트가 별도의 서버리스 함수로 실행될 수 있고, 콜드 스타트나 DB 연결 비용도 반복될 수 있습니다.

그래서 백그라운드 프리패치 전용 API를 만들었습니다.

export async function GET() {
  const [materialBoards, standardParts, furnitureItems] =
    await Promise.all([
      prisma.materialBoard.findMany(...),
      prisma.standardPart.findMany(...),
      prisma.furnitureItem.findMany(...),
    ]);

  return jsonOk({
    materialBoards: materialBoards.map(serializeMaterialBoard),
    standardParts: standardParts.map(serializeStandardPart),
    furnitureItems: furnitureItems.map(serializeFurnitureItem),
  });
}

클라이언트에서는 이 API 하나만 호출합니다.

const response = await fetch("/api/master-data/prefetch");
const payload = await response.json();

setData((current) => ({
  ...current,
  ...payload,
  furnitureItemParts: deriveFurnitureItemParts(payload.furnitureItems),
}));

이 구조의 장점은 단순합니다. 브라우저 요청 수가 줄고, 서버리스 함수 호출 수도 줄어듭니다. DB 조회는 서버 안에서 Promise.all로 병렬 실행됩니다.

중복 데이터 조회 제거하기

품목 템플릿 데이터에는 각 품목의 부품 구성이 포함될 수 있습니다. 그런데 기존 구조에서는 품목 템플릿을 가져온 뒤, 부품 구성 목록도 별도 API로 다시 가져왔습니다.

이 중복을 없애기 위해 품목 템플릿 응답에서 부품 구성 리스트를 파생시켰습니다.

function deriveFurnitureItemParts(furnitureItems) {
  return furnitureItems.flatMap((item) =>
    item.parts.map((part) => ({
      ...part,
      furnitureItemName: item.name,
    })),
  );
}

이렇게 하면 품목 템플릿 API 응답 하나로 두 가지 상태를 채울 수 있습니다.

  • 품목 템플릿 목록
  • 전체 부품 구성 목록

데이터의 원본은 하나로 두고, 화면에서 필요한 형태만 파생시키는 방식입니다. 같은 데이터를 두 번 조회하지 않아도 됩니다.

저장 후 전체 새로고침 제거하기

마지막으로 저장 후 갱신 방식을 바꿨습니다.

기존 방식은 간단했습니다.

await mutate(...);
router.refresh();

하지만 이 방식은 수정 범위와 관계없이 전체 페이지를 다시 불러옵니다. 작은 부품 구성 하나를 수정해도 자재, 원판 규격, 품목 템플릿 전체를 다시 조회합니다.

개선 후에는 요청 URL을 기준으로 영향을 받는 섹션만 다시 가져옵니다.

function mutationSections(url: string) {
  if (url.includes("/furniture-item-parts")) {
    return ["furnitureItems"];
  }

  if (url.includes("/material-boards")) {
    return ["materialBoards"];
  }

  if (url.includes("/materials")) {
    return ["materials", "materialBoards", "furnitureItems"];
  }

  return ["materials"];
}
async function reloadSectionsAfterMutation(url: string) {
  const required = new Set(tabRequirements[activeTab]);

  mutationSections(url).forEach((section) => {
    if (loadedSections.has(section)) {
      required.add(section);
    }
  });

  await loadSections(Array.from(required), { force: true });
}

이 방식은 두 가지 기준을 함께 사용합니다.

첫 번째는 현재 탭에 필요한 데이터입니다. 사용자가 보고 있는 화면은 저장 후 즉시 최신 상태여야 합니다.

두 번째는 이미 불러온 데이터 중 변경 영향을 받을 수 있는 데이터입니다. 아직 사용자가 열어보지 않은 데이터는 굳이 다시 가져오지 않습니다.

이렇게 바꾸면 저장 후 화면 전체가 다시 로딩되는 느낌이 줄어듭니다. 필요한 영역만 갱신됩니다.

결과

개선 전 기준정보 화면의 응답 시간은 다음과 같았습니다.

2.75s
1.50s
1.43s
1.42s
1.43s

개선 후에는 다음과 같았습니다.

2.69s
0.80s
0.60s
0.60s
0.60s

중앙값 기준으로 약 1.43초에서 0.60초로 줄었습니다. 약 58% 개선되었습니다.

첫 요청은 여전히 2초대가 나옵니다. Vercel 서버리스 콜드 스타트 영향이 남아 있습니다. 하지만 웜 상태에서는 0.6초 안팎으로 응답합니다.

백그라운드 프리패치 API는 웜 상태 기준 약 1.2초 정도가 나왔습니다. 이 요청은 첫 화면 렌더링을 막지 않기 때문에 초기 진입 체감 속도에는 직접적인 부담을 주지 않습니다.

 

정리

 

첫 화면에 필요한 데이터는 서버 렌더링에서 처리하고, 나중에 필요한 데이터는 백그라운드에서 준비했습니다.

용자가 먼저 접근한 탭은 즉시 lazy load했습니다. 저장 후에는 전체 페이지를 다시 불러오지 않고 영향받는 데이터만 갱신했습니다.

관리자 화면은 시간이 지나면서 데이터가 계속 늘어납니다. 처음에는 모든 데이터를 한 번에 가져오는 구조가 편하지만, 운영 데이터가 쌓이면 화면 단위의 데이터 경계를 다시 잡아야 합니다.

 

이번 개선은 백엔드 구조를 크게 바꾸지 않고 Next.js 안에서 처리했습니다. 그래도 초기 응답 시간은 중앙값 기준 약 58% 줄었습니다. 구조를 조금만 나눠도 체감 속도는 꽤 크게 달라질 수 있습니다.