프론트엔드

[프론트엔드] 워터폴 현상 & 문제가 되는 이유

취업 드가자잇 2025. 5. 16. 18:30

 

이전에 면접에서 네트워크 워터폴 현상에 대해서 알고 있냐는 질문을 받은적이 있다.

이 글에서 워터폴에 대해 프론트엔드 개발의 관점에서 이해한 내용을 정리해보고, 느낀점에 대해 서술해보고자 한다.

 

워터폴 현상 (Waterfall problem) (feat. 서버 컴포넌트)

이름처럼 워터폴 현상은 여러 비동기 작업(fetch 등)이 순차적으로, 연쇄적으로 실행되면서 성능 병목이 발생하는 상황을 말한다. 예를 들어 다음과 같은 코드는 각 요청이 이전 요청에 의존해 순차적으로 실행된다.

// 비동기 요청들이 의존적으로 순차적으로 실행되어, 앞 요청이 끝나야 다음 요청이 가능해지는 상황
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(user.id);

 

 

리액트의 서버 컴포넌트 관점에서 생각해본다면 성능 이슈를 유발할 수 있는 큰 요인이라고 느꼈다. 그 이유는 리액트의 서버컴포넌트는 fetch가 완료되어야만 렌더링을 시작할 수 있기 때문이다.

 

따라서 서버 컴포넌트 내부에서 워터폴이 발생하면 클라이언트가 HTML을 전달받기까지 대기 시간이 길어질 수 있으며, 전체 응답 시간은 각 요청의 응답 시간 합이 될 것이다.

 

해결 방안

1. 의존성이 없다면 Promise.all로 처리

만약 fetch간의 의존성이 없다면 Promise.all로 병렬처리를 하는 것도 괜찮다고 생각했다.

하지만 Promise.all은 입력 배열의 순서대로 결과를 반환하지만, 각 Promise는 병렬로 처리되며 해결 순서에는 영향을 미치지 않기 때문에

다음 요청이 이전 요청의 결과를 필요로 한다면 병렬 처리 자체가 불가능하다.

const [posts, comments] = await Promise.all([
  fetch("/api/posts").then(res => res.json()),
  fetch("/api/comments").then(res => res.json()),
]);

 

 

2. 새로운 엔드포인트를 파자 (서버 개발자와 소통하자)

의존성을 느슨하게 만들기 위해 백엔드와 협의하여 엔드포인트를 분리하는 것도 방법이라고 느꼈다.

예를 들어, Promise.all로 병렬처리를 하는 방식을 고수하면서도 의존성을 처리하고자 한다면 아래 예시처럼 더 세분화된 엔드포인트를 만들어 부분 병렬화를 시도해볼 수도 있을 것이다.

// 먼저 간단한 user ID만 요청
const { id } = await fetch("/api/user/id").then(res => res.json());

// 이후 병렬 처리
const [posts, comments] = await Promise.all([
  fetch(`/api/posts?userId=${id}`).then(res => res.json()),
  fetch(`/api/comments?userId=${id}`).then(res => res.json()),
]);

 

 

 

3. 서버 컴포넌트 내 Suspense와 Streaming 활용 (Next.js 13+ 이상)

최근 공부하며 리액트 서버컴포넌트내에서도 Suspense를 사용할 수 있다는 것을 알게 되었다.

그렇다면 CSR에서처럼 CSR처럼 부분 렌더와 로딩 UI를 통해 UX 보완이 가능하겠다는 생각이 들었다. 

여기서 Streaming이라는 개념이 등장하는데 서버에서 먼저 보낼 수 있는 건 빨리 보내고, 느린 건 나중에 밀어주는 것이 Streaming + Suspense의 핵심이라고 한다. 

// (서버 컴포넌트)
import { Suspense } from 'react'
import FastComponent from './FastComponent'
import SlowComponent from './SlowComponent'

export default function Page() {
  return (
    <div>
      <FirstComponent /> {/* 즉시 렌더 가능 */}
      
      <Suspense fallback={<div>로딩 중입니다...</div>}>
        <SecondComponent /> {/* 느린 데이터 → streaming 처리 */}
      </Suspense>
    </div>
  )
}

 

Suspense + Streaming 항상 이득일까?

모든 해결 방안이 그렇듯 완벽한 솔루션은 없을 것이다. Suspense + streaming도 항상 성능 향상을 보장하는 것은 아니다.

예를 들어, 이전에 나왔던 예시처럼 fetch간 의존성이 있는 경우, 내부 fetch가 느려지면 결국 전체 페이지 준비가 지연되기 때문에 성능적으로는 크게 개선되지 않을 것이다. 

// async 서버 컴포넌트 + Suspense = 항상 이득일까?
const user = await getUser();
const posts = await getPosts(user.id); // 의존적

 

 

결론

결과적으로 개발자는 문제를 해결하기 위해 다양한 방법을 제안할 수 있어한다고 느꼈다.

그런 역량을 발전시키기 위해서는 평소에 문제를 바라보는 여러 관점을 열어두는 것이 중요하다고 느꼈다.