Dev Thinking
21완료

서버 컴포넌트에서 데이터 패칭 – 워터폴 방지

2025-09-22
10분 읽기

공식문서 기반의 Next.js 입문기

들어가며

3-1편에서 경계를 어디에 두느냐에 따라 실행 환경과 책임이 갈라진다는 흐름을 정리했을 때, 다음 질문이 자연스럽게 따라왔습니다. 실제 앱에서는 단일 API 이상을 동시에 준비해야 하고, 그럴 때마다 순차적 await가 다수의 응답 시간을 더하는 워터폴을 만들기 쉽습니다.

왜 서버 컴포넌트에서 데이터를 준비하면서도 워터폴이 생기는 걸까요? 브라우저에서 여러 API를 순차적으로 기다리던 방식과 병렬 실행을 구분하지 않으면, 서버 리소스를 충분히 이용할 수 없습니다. 이 글에서는 “대시보드 카드 3개(매출/사용자/최근 주문)”를 대표 시나리오로 삼아, 서버 컴포넌트의 경계를 유지하면서 병렬 패칭과 캐시 전략을 어떻게 조합해야 워터폴을 피할 수 있는지 살펴보겠습니다.

이 시나리오는 “독립적인 데이터들을 동시에 가져와야 하는데, 각 데이터가 서로 다른 API에서 오는 상황”을 가정합니다. 매출 데이터는 재무 시스템에서, 사용자 수는 인증 시스템에서, 최근 주문은 주문 시스템에서 가져온다고 생각하면 됩니다. 실제 프로덕트에서는 더 복잡할 수 있습니다 - 어떤 데이터는 캐시되어야 하고, 어떤 데이터는 실시간이어야 하며, 일부 데이터는 실패해도 UI가 유지되어야 합니다.

이런 복잡한 상황에서 Next.js가 제공하는 병렬 패칭은 단순한 성능 최적화가 아닙니다. 이는 3-1편의 경계 사고를 실현하는 도구입니다. 서버 컴포넌트에서 데이터를 준비하는 방식이 워터폴을 방지하고, 동시에 번들 크기를 줄이며, 데이터 접근 패턴을 더 효율적으로 만든다는 걸 보여드리겠습니다.

서버 컴포넌트에서 데이터 패칭

3-1편에서 경계를 정의할 때 서버 컴포넌트에 “데이터 준비”를 남겨두자고 했는데, 여러 데이터가 필요해지면 “순차적 await”가 그대로 워터폴이 됩니다. 그래서 이 절에서는 “서버에서 여러 fetch를 어떻게 동시에 실행할지”에 집중합니다.

브라우저에서는 useEffect 안에 await를 나열하면 각 요청이 앞선 요청을 기다리면서 총 로딩 시간이 합산됩니다. 서버 컴포넌트에서는 Promise.all/Promise.allSettled을 자연스럽게 쓰고, fetch의 캐시 옵션으로 각 데이터의 재검증 주기를 제어합니다. 이 설계가 왜 필요한지는 아래에서 다시 다룹니다.

참고: 서버 컴포넌트의 fetch는 브라우저의 호출 방식과 같지만, Next.js 서버 환경에서 실행됩니다. Same-Origin 제약이나 동시 연결 제한이 없고, cache/revalidate 같은 옵션을 서버가 결정하므로 “같은 API라도 실행 환경과 캐시 정책이 다르다”는 점을 의식하면 혼란을 줄일 수 있습니다.

브라우저가 아닌 서버에서 실행한다는 점은 “여러 외부 API를 동시에 부를 때 부담이 적다”는 계산을 가능하게 합니다. 예를 들어 매출, 사용자 수, 주문 데이터를 독립적으로 가져올 수 있다면, Promise.all 로 묶어서 가장 느린 API 응답 시간만큼만 기다리면 됩니다. 반대로 사용자 정보 → 해당 주문처럼 순서가 있는 데이터는 의도적으로 순차 실행을 선택해야 합니다.

Next.js의 fetch는 자체적으로 병렬화를 지원합니다. 예를 들어:

// 이 코드에서 두 fetch는 자동으로 병렬 실행됨
const [data1, data2] = await Promise.all([fetch("/api/data1"), fetch("/api/data2")]);

이처럼 명시적으로 Promise.all을 쓰면 의도를 명확히 할 수 있고, 캐시 옵션을 조정해 반복 호출을 줄이는 효과도 함께 얻습니다. (캐시 옵션은 이후 섹션에서 구체적으로 다룹니다.)

기능 구현 및 비교

이번 섹션에서는 대시보드 시나리오를 기준으로, 먼저 리액트(CSR)에서 워터폴이 생기는 패턴을 보여드린 뒤 Next.js로 동일한 목표를 구현해보겠습니다.

리액트 단독 구성 – useEffect에서 순차적으로 가져올 때 워터폴

리액트(CSR)에서는 데이터를 가져오는 로직이 컴포넌트 안에 자연스럽게 들어갑니다. 그런데 여러 데이터를 가져와야 할 때, await를 순차적으로 사용하면 워터폴이 생깁니다.

src/
├── components/
│   └── Dashboard.jsx
├── App.jsx
└── main.jsx
// src/components/Dashboard.jsx
import { useEffect, useState } from "react";
 
export function Dashboard() {
  const [sales, setSales] = useState(null);
  const [users, setUsers] = useState(null);
  const [orders, setOrders] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    let cancelled = false;
 
    async function load() {
      // 각 fetch가 이전 것을 기다리므로 워터폴 발생
      const salesRes = await fetch("/api/sales");
      const salesData = await salesRes.json();
      if (!cancelled) setSales(salesData);
 
      const usersRes = await fetch("/api/users");
      const usersData = await usersRes.json();
      if (!cancelled) setUsers(usersData);
 
      const ordersRes = await fetch("/api/orders");
      const ordersData = await ordersRes.json();
      if (!cancelled) setOrders(ordersData);
 
      if (!cancelled) setLoading(false);
    }
 
    load();
    return () => {
      cancelled = true;
    };
  }, []);
 
  if (loading) return <div>로딩 중...</div>;
 
  return (
    <div>
      <h1>대시보드</h1>
      <div className="grid grid-cols-3 gap-4">
        <div className="card">
          <h2>매출</h2>
          <p>{sales?.total}</p>
        </div>
        <div className="card">
          <h2>사용자</h2>
          <p>{users?.count}</p>
        </div>
        <div className="card">
          <h2>최근 주문</h2>
          <p>{orders?.recent?.length}건</p>
        </div>
      </div>
    </div>
  );
}

이 코드는 동작을 이해하기 쉽습니다. 브라우저에서 실행되니, 데이터를 가져오는 것도 상태를 바꾸는 것도 같은 공간에 들어갑니다. 각 fetch가 순차적으로 실행되므로, 첫 번째 API가 1초, 두 번째가 1초, 세 번째가 1초 걸린다면 총 3초의 로딩 시간이 필요합니다.

그런데 “왜 이렇게 할까요?”를 조금 다른 각도에서 보면, 한계도 보입니다. 각 fetch가 이전 것을 기다리므로, 총 로딩 시간이 API 응답 시간의 합만큼 늘어납니다. 브라우저에서는 Promise.all을 쓸 수 있지만, 그러면 코드가 이렇게 복잡해집니다:

// Promise.all을 사용한 버전 - 더 복잡해짐
useEffect(() => {
  async function load() {
    try {
      const [salesRes, usersRes, ordersRes] = await Promise.all([
        fetch("/api/sales"),
        fetch("/api/users"),
        fetch("/api/orders"),
      ]);
 
      const [salesData, usersData, ordersData] = await Promise.all([
        salesRes.json(),
        usersRes.json(),
        ordersRes.json(),
      ]);
 
      setSales(salesData);
      setUsers(usersData);
      setOrders(ordersData);
      setLoading(false);
    } catch (error) {
      // 에러 처리도 복잡해짐
      setError(error.message);
      setLoading(false);
    }
  }
 
  load();
}, []);

게다가 각 데이터별 에러 처리를 따로 해야 하고, 로딩 상태 관리도 복잡해집니다. 브라우저에서는 이 모든 로직이 번들에 포함되어야 하므로, 앱이 커질수록 클라이언트 사이드가 무거워집니다.

Next.js 구성 – 서버에서 병렬 패칭으로 워터폴 방지

Next.js에서는 같은 대시보드를 서버 컴포넌트로 구성할 수 있습니다. 핵심은 “데이터 준비는 서버에서 병렬로, UI 조립은 컴포넌트에서”로 분리하는 점입니다.

app/
├── dashboard/
│   ├── page.tsx         // 서버 컴포넌트: 데이터 준비와 화면 조립
│   └── SalesCard.tsx    // 클라이언트 컴포넌트: 필요시 상호작용
└── api/
    ├── sales/
    │   └── route.ts
    ├── users/
    │   └── route.ts
    └── orders/
        └── route.ts

먼저 page.tsx는 서버 컴포넌트로 데이터를 병렬로 준비합니다.

// app/dashboard/page.tsx
import { SalesCard } from "./SalesCard";
 
async function getSales() {
  const res = await fetch("http://localhost:3000/api/sales", {
    cache: "no-store", // 실시간 데이터이므로 캐시하지 않음
  });
  if (!res.ok) throw new Error("매출 데이터를 불러오지 못했습니다.");
  return res.json();
}
 
async function getUsers() {
  const res = await fetch("http://localhost:3000/api/users", {
    cache: "no-store",
  });
  if (!res.ok) throw new Error("사용자 데이터를 불러오지 못했습니다.");
  return res.json();
}
 
async function getRecentOrders() {
  const res = await fetch("http://localhost:3000/api/orders", {
    cache: "no-store",
  });
  if (!res.ok) throw new Error("주문 데이터를 불러오지 못했습니다.");
  return res.json();
}
 
export default async function DashboardPage() {
  // Promise.all로 병렬 실행 - 워터폴 방지
  const [sales, users, orders] = await Promise.all([getSales(), getUsers(), getRecentOrders()]);
 
  return (
    <div>
      <h1>대시보드</h1>
      <div className="grid grid-cols-3 gap-4">
        <div className="card">
          <h2>매출</h2>
          <p>{sales.total}</p>
        </div>
        <div className="card">
          <h2>사용자</h2>
          <p>{users.count}</p>
        </div>
        <SalesCard orders={orders.recent} />
      </div>
    </div>
  );
}

여기서 중요한 점은 Promise.all입니다. 각 fetch가 서로를 기다리지 않고 동시에 실행되므로, 가장 느린 API 응답 시간만큼만 기다리면 됩니다. 예를 들어 각 API가 1초씩 걸린다면, 총 1초만에 모든 데이터를 준비할 수 있습니다.

만약 일부 데이터가 실패해도 전체 UI가 깨지지 않게 하려면 Promise.allSettled를 사용할 수 있습니다. 이 메서드는 모든 프로미스가 완료될 때까지 기다리고, 성공/실패 여부를 결과로 반환합니다.

// 일부 실패를 허용하는 버전
export default async function DashboardPage() {
  const results = await Promise.allSettled([getSales(), getUsers(), getRecentOrders()]);
 
  const sales = results[0].status === "fulfilled" ? results[0].value : null;
  const users = results[1].status === "fulfilled" ? results[1].value : null;
  const orders = results[2].status === "fulfilled" ? results[2].value : null;
 
  return (
    <div>
      <h1>대시보드</h1>
      <div className="grid grid-cols-3 gap-4">
        <div className="card">
          <h2>매출</h2>
          <p>{sales?.total || "데이터 없음"}</p>
        </div>
        <div className="card">
          <h2>사용자</h2>
          <p>{users?.count || "데이터 없음"}</p>
        </div>
        <SalesCard orders={orders?.recent || []} />
      </div>
    </div>
  );
}

이제 상호작용이 필요한 부분은 클라이언트 컴포넌트로 분리합니다.

// app/dashboard/SalesCard.tsx
"use client";
 
import { useState } from "react";
 
type Order = {
  id: string;
  amount: number;
};
 
type Props = {
  orders: Order[];
};
 
export function SalesCard({ orders }: Props) {
  const [expanded, setExpanded] = useState(false);
 
  return (
    <div className="card">
      <h2>최근 주문</h2>
      <p>{orders.length}건</p>
      <button onClick={() => setExpanded(!expanded)}>{expanded ? "접기" : "펼쳐보기"}</button>
      {expanded && (
        <ul>
          {orders.map((order) => (
            <li key={order.id}>{order.amount}원</li>
          ))}
        </ul>
      )}
    </div>
  );
}

3-1편에서 정한 경계를 다시 상기하면, 이 구성은 그 기준을 그대로 따릅니다. 데이터 준비는 서버에 머무르고, 상호작용은 클라이언트 위젯에만 남깁니다. 그러니 번들이 작아지고 워터폴도 없어집니다.

리액트 vs Next.js 비교표

구분리액트 (CSR 중심)Next.js (서버/클라이언트 혼합)
실행 환경 기본값브라우저 실행이 전제서버 컴포넌트가 기본, 필요한 곳만 use client
데이터 접근 모델브라우저에서 fetch 중심(순차적 실행 쉬움)서버에서 Promise.all 병렬 실행 가능
번들 관점로딩/에러 상태까지 번들 후보가 됨데이터 준비 코드는 번들에 포함되지 않음
컴포넌트 분리 의미UI 관심사 분리가 중심데이터 준비(병렬) vs 상호작용 분리가 함께 따라옴
설계의 제약자유로움fetch 옵션/병렬 패턴을 강제하고, 그게 기준이 됨

워터폴 방지 패턴과 운영 체크리스트

서버 컴포넌트에서 데이터를 병렬로 가져오는 건 단순히 Promise.all을 쓰는 것 이상입니다. fetch의 캐시 옵션과 스트리밍을 결합해야 실무에서 효과를 봅니다. 그리고 이 패턴들은 3-1편의 경계 사고와 자연스럽게 연결됩니다.

병렬 데이터 패칭의 트레이드오프

장점

  • 응답 시간 단축: 여러 데이터 소스를 동시에 가져와서 전체 로딩 시간을 크게 줄이고, 특히 외부 API가 여러 개 필요한 대시보드에서 효과적
  • 서버 리소스 효율화: 브라우저의 연결 제한 없이 서버에서 병렬 요청을 처리하여 네트워크 병목 현상을 방지
  • 코드 단순화: 복잡한 로딩 상태 관리를 줄이고, 데이터 준비 로직을 컴포넌트와 분리하여 유지보수성 향상

단점

  • 오해의 소지: 병렬 패칭이 UI도 동시에 렌더링된다고 착각하기 쉬움 (실제로는 모든 데이터가 준비될 때까지 기다린 후 한 번에 렌더링)
  • 네트워크 비용 증가: 작은 데이터를 여러 개 병렬로 가져오면 오히려 비효율적일 수 있고, 서버 부하도 증가할 수 있음
  • 캐시 전략 복잡성: 각 데이터 소스의 변동 주기를 고려한 적절한 캐시 설정이 어려워져 잘못된 설정 시 성능 저하 발생

균형 맞추기 팁

Route Handler를 활용해 서버에서 데이터를 집계하는 패턴을 고려하세요. 작은 데이터를 여러 개 가져오는 대신 하나의 API 엔드포인트에서 집계하여 제공하면 네트워크 비용을 줄일 수 있습니다. 또한 데이터의 변동 주기에 따라 revalidate 시간을 적절히 설정하고, 실시간성이 중요한 데이터에만 no-store를 적용하세요.

예상 질문

Q1. 서버 컴포넌트에서 Promise.all을 쓰면 정말 병렬로 실행되나요?
네, 서버 환경에서는 fetch가 동시에 실행됩니다. 브라우저처럼 연결 제한(Same-Origin Policy의 영향)이나 브라우저의 동시 연결 제한(보통 6개)이 없어서 효과적입니다. Same-Origin Policy는 브라우저에서 보안을 위해 다른 도메인의 리소스 접근을 제한하는 정책으로, 서버에서는 이런 제약이 없어 여러 외부 API를 동시에 호출할 수 있습니다.

Q2. 일부 데이터가 실패하면 어떻게 하나요?
Promise.allSettled를 사용하면 실패한 데이터만 null로 처리하고, 나머지 UI는 정상 작동합니다. 에러 경계(error boundary)와 결합하면 더 견고해집니다. 예를 들어 매출 데이터만 실패해도 사용자 수와 주문 데이터는 표시할 수 있습니다.

Q3. 캐시 옵션을 너무 자주 바꾸면 성능이 나빠지나요?
네, cache: "no-store"를 남용하면 매번 네트워크 요청이 발생합니다. 데이터의 변동 주기를 고려해 revalidate를 적절히 사용하세요. 예를 들어 실시간 주식 데이터는 no-store, 상품 카테고리는 revalidate: 3600이 적합합니다.

Q4. 브라우저에서도 Promise.all을 쓰면 되지 않나요?
가능하지만, 서버 컴포넌트 방식과 비교하면 몇 가지 차이가 있습니다. 브라우저에서는 로딩 상태 관리와 에러 처리를 직접 해야 하고, 그 로직이 번들에 포함됩니다. 반대로 서버에서는 데이터 준비가 UI 렌더링 전에 완료되므로 더 간단합니다.

Q5. 데이터가 많아지면 서버 컴포넌트가 느려지지 않나요?
병렬 패칭을 적절히 사용하면 오히려 빨라집니다. 하지만 너무 많은 데이터를 한 컴포넌트에서 가져오면 서버 응답이 느려질 수 있습니다. 이때는 Route Handler로 로직을 분리하거나, 데이터를 더 작은 단위로 나누는 걸 고려하세요.

Q6. 그냥 use client를 페이지에 붙이면 안 되나요?
기술적으로는 가능하지만, 서버 컴포넌트의 장점을 포기하게 됩니다. use client를 페이지 전체에 붙이면 데이터 패칭 로직이 브라우저로 옮겨가면서 번들이 무거워지고 워터폴 현상이 생길 수 있습니다. 서버 컴포넌트는 데이터 준비와 UI 렌더링을 분리해 더 효율적인 구조를 만듭니다.

개발 경험 변화 – 코드 리뷰에서 보는 질문들

병렬 패칭을 적용하기 시작하면, 코드 리뷰 질문도 달라집니다. 리액트(CSR)에서는 “이 컴포넌트가 어떤 UI를 담당하는가?”가 중심이었다면, 여기서는 “이 데이터 패칭 전략이 경계 설계와 맞는가?”가 함께 따라옵니다.

제가 자주 하는 질문들은 이런 형태입니다:

  • 이 데이터들은 정말 독립적인가?

    • 매출과 사용자 수는 동시에 가져와도 되는지, 아니면 사용자 수를 가져온 뒤에 매출을 계산해야 하는지 확인합니다. 잘못된 병렬화는 의미 없는 데이터를 가져올 수 있습니다.
  • 캐시 전략이 데이터 성격과 맞는가?

    • 실시간 대시보드 지표에 revalidate: 3600을 설정하면 최신성이 떨어집니다. 반대로 상품 카테고리에 no-store를 설정하면 불필요한 요청이 늘어납니다.
  • 에러 처리로 인한 UI 붕괴는 방지했는가?

    • 하나의 API 실패가 전체 대시보드를 망가뜨리지 않는지 확인합니다. Promise.allSettled나 개별 에러 경계를 사용했는지 점검합니다.
  • 번들 영향은 고려했는가?

    • 이 데이터 로딩 로직이 클라이언트로 옮겨가면 어떤 코드가 추가될지 생각해봅니다. 서버 컴포넌트에 두는 게 더 효율적인지 검토합니다.

이런 습관이 쌓이면, 결국 “데이터 준비를 서버로 옮기는 것”이 워터폴 방지로 이어진다는 걸 체감하게 됩니다. 그리고 이 경험이 쌓이면 다음 단계인 “정적/동적 렌더링 선택”으로 자연스럽게 넘어가게 됩니다.

요약

3-1편의 경계 사고를 기반으로, 이번 글에서는 “서버 컴포넌트에서 여러 데이터를 병렬로 준비하는 패턴”을 정리했습니다. 리액트(CSR)에서는 순차적 await가 워터폴을 만들기 쉽지만, Next.js에서는 Promise.all과 캐시 옵션으로 이를 방지할 수 있습니다.

핵심은 “실행 환경의 분리가 데이터 전략의 분리를 강제한다”는 점입니다. 서버 컴포넌트는 데이터 준비에 집중하고, 클라이언트 컴포넌트는 상호작용에 집중하는 구조가, 결과적으로 더 빠르고 견고한 앱을 만듭니다. 병렬 패칭은 단순한 성능 최적화가 아니라, 경계 설계를 실현하는 도구입니다.

실제로 이 패턴을 적용하면 다음과 같은 변화가 생깁니다:

  • 번들 크기: 데이터 로딩 로직이 서버에 남으므로 클라이언트 번들이 가벼워집니다.
  • 사용자 경험: 워터폴이 사라져 체감 로딩 시간이 단축됩니다.
  • 코드 구조: 데이터 준비와 UI 렌더링이 명확히 분리됩니다.
  • 에러 처리: 서버에서 실패를 처리하므로 클라이언트가 더 단순해집니다.

참조