치춘짱베리굿나이스

데이터 불러오기, Suspense 본문

ClientSide/React

데이터 불러오기, Suspense

치춘 2022. 5. 13. 14:55

컴포넌트의 데이터 Fetching & Rendering

크게 세 가지 방법이 있으며, 각각 수행 방법과 시간에 차이가 있다

fetch-on-render (렌더링 직후 불러오기)

useEffect(() => {
    fetchData();
}, []); // Mount될 때 데이터 불러오기

많은 컴포넌트에서 useEffect를 통해 컴포넌트가 마운트되었을 때 데이터를 불러오는 방식을 사용한다

이 방식의 단점은 컴포넌트가 마운트 및 렌더링된 이후에 데이터를 불러오기 시작한다는 점이다

이는 곧 ‘waterfall’ 이라는 문제로 이어진다

const MovieList = () = {
    const [movieData, setMovieData] = useState(null); // 데이터 초기화

    useEffect(() = {
        fetchData().then(v => setMovieData(v));
    }, []);

    if (movieData === null) return <LoadingComponent />;
    return (
        <ul>
            { movieData.map((v) => <li key={v}>{ v.title }</li>) }
        </ul>
    )
}

const UserList = () => {
    const [userData, setUserData] = useState(null);

    useEffect(() => {
        fetchUserData().then(v => setUserData(v));
    }, []);

    if (userData === null) return <LoadingComponent />;
    return (
        <ul>
            { userData.map((v) => <li key={v}>{ v.name }</li>) }
        </ul>
    )
}

...
<MovieList />
<UserList />

위와 같은 컴포넌트 구조가 있다고 해 보자

MovieListUserList는 각각 useEffect 안에서 데이터를 따로따로 불러오고 있다

이 구조의 치명적인 단점은 위의 MovieList에서 데이터를 전부 불러와야 UserList의 데이터를 불러오기 시작한다는 것이다

  1. MovieList에서 movieData 받아오기 시작
  2. Loading...
  3. movieData 받아오기 완료, 컴포넌트에 그려주기 완료
  4. UserList에서 userData 받아오기 시작
  5. Loading...
  6. userData 받아오기 완료, 컴포넌트에 그려주기 완료

‘워터폴' 이란, 이처럼 충분히 병렬로 데이터를 받아올 수 있음에도 불구하고 순차적으로 데이터가 불러와지는 현상이며, fetch-on-render 구조에서 흔히 일어난다

순차적으로 데이터를 받아오다 보니 전체 컴포넌트를 렌더링하는 데에 시간이 굉장히 오래 걸리게 되며, 작은 컴포넌트에서는 괜찮겠지만 프로젝트의 규모가 커질 수록 데이터를 받아오는 속도가 느려지며 렌더링이 늦어진다

만약 movieData 를 받아오는 데 1초, userData를 받아오는 데 10초가 걸린다면 전체 데이터를 받아오는 데 11초가 걸린다

fetch-then-render (불러오기 이후 렌더링)

위의 워터폴 현상을 방지하기 위해 렌더링 전에 데이터를 받아와 보자

const fetchAllData = () => {
    return Promise.all([
        fetchData(),
        fetchUserData() // Promise를 배열로 적용하여 두 fetch 함수가 병렬로 실행됨
    ]).then(([movieData, userData]) => {
        return { movieData, userData }; // 데이터 함께 반환
    })
}

const promise = fetchAllData(); //컴포넌트 마운트 전 데이터 불러오기

const MovieList = () = {
    const [movieData, setMovieData] = useState(null); 
    const [userData, setUserData] = useState(null); // 데이터 초기화

    useEffect(() = {
        promise.then(data => {
            setMovieData(data.movieData);
            setUserData(data.userData);
        });
    }, []);

    if (movieData === null) return <LoadingComponent />;
    return (
        <ul>
            { movieData.map((v) => <li key={v}>{ v.title }</li>) }
        </ul>
        <UserList />
    )
}

const UserList = ({ userData }) => {
    if (userData === null) return <LoadingComponent />;
    return (
        <ul>
            { userData.map((v) => <li key={v}>{ v.name }</li>) }
        </ul>
    )
} // 모든 fetching 작업을 밖에서 진행하기 때문에, 
// UserList는 자식 컴포넌트로 만들어 데이터를 받아와 그려주기만 한다

컴포넌트 구조를 살짝 수정하였다

컴포넌트의 완전 바깥에서 데이터를 받아오고, 이 데이터를 두 컴포넌트에서는 갖다가 사용하기만 한다

이번에는 데이터 불러오기 순서가 아래와 같이 바뀐다

  1. fetchData()movieData 불러오기 시작
  2. fetchUserData()userData 불러오기 시작 (여기까지 fetchAllData())
  3. Loading...
  4. movieData 불러오기 완료
  5. userData 불러오기 완료, MovieList 컴포넌트에 그려주기 완료

movieData가 다 불러와져야만 userData를 받기 시작하던 워터폴 현상이 사라졌고, 병렬로 데이터를 받기 때문에 아까보단 속도 면에서 조금 완화되었으나, Promise.all() 의 특성상 모든 데이터가 반환되기까지 기다려야 하기 때문에, MovieList 컴포넌트는 movieData가 전부 받아와졌음에도 불구하고 userData 때문에 더 기다려야 하는 문제점이 생겼다

만약 movieData 를 받아오는 데 1초밖에 안 걸리지만 userData를 받아오는 데 10초가 걸린다면 Promise.all을 이용하여 두 데이터를 모두 받아오는 데에 10초의 시간을 기다려야 한다

movieData 입장에선 분통터지는 일이다

Promise.all() 을 사용하지 않고 두 fetching 작업을 promise 2개로 분리하면 되긴 하지만... 데이터의 종류가 2개가 아니라 더 많다고 생각해보자... 대형 프로젝트일 수록 로직이 점점 복잡해져 제작자도 헷갈리는 상황이 온다

Render-as-you-fetch (불러올 때 렌더링 - Suspense 사용하기)

fetch-then-render 방식에서는 setState 이전에 데이터를 (컴포넌트 밖에서) 미리 불러왔었다

따라서 다음과 같은 순서로 데이터 불러오기 및 렌더링이 진행되었다

  1. 데이터 불러오기 시작 (fetchAllData())
  2. 데이터 불러오기 완료
  3. 렌더링 시작 (MovieList)

Suspense를 사용하면 이 순서를 개선시킬 수 있다

  1. 데이터 불러오기 시작 (fetchAllData())
  2. 렌더링 시작 (Suspense + MovieList)
  3. 데이터 불러오기 완료

Suspense가 뭐하는 녀석이길래?

Suspense는 컴포넌트의 내부에서 사용하는 값이 아직 resolve되지 않았을 경우 (완전히 받아와지지 않았을 경우) 컴포넌트의 데이터가 준비되지 않았다고 판단한다

Suspense의 하위 컴포넌트 데이터가 준비되지 않았다고 판단될 경우, 그 컴포넌트의 자리에 대신 props로 넘겨준 폴백 컴포넌트를 렌더링한다

데이터가 준비되었을 경우, 해당 데이터를 그대로 렌더링하고, 에러가 발생했을 경우, ErrorBoundary에 에러 처리를 넘겨준다

이를 이용하면 데이터를 받아오는 함수 (fetch, axios 등) 로부터 응답을 마냥 기다릴 필요도 없고, 네트워크 요청을 보낸 거의 직후 렌더링을 시작하기 때문에 속도면에서도 지장이 없으며, 더 쉽게 로딩 화면 등을 구현할 수 있어 사용자 경험도 개선된다

Suspense 사용 전 작업하기 (wrapPromise)

const wrapPromise = (promise) => {
    let status = 'pending'; // 데이터 수신 대기중 (resolve되지 않음)
    let result; // 서버에서 fetching 한 데이터 결과
    let suspender = promise.then(
        (r) => {
            status = 'success';
            result = r; // 결과가 잘 받아졌을 때
        },
        (e) => {
            status = 'error';
            result = e; // 결과가 에러일 때
        }
    );
    let read = () => { //read() 메서드로 현재 데이터 상황을 받아올 수 있음
            if (status === 'pending') throw suspender;
            else if (status === 'error') throw result;
            else if (status === 'success') return result;
    }

    return { read };
}

const fetchData = () => {
    const promise = axios(/* 데이터 받아오기 위한 사전 설정 */)
        .then((res) => res.data);

    return wrapPromise(promise);
}

const fetchUserData = () => {
    const promise = axios(/* 데이터 받아오기 위한 사전 설정 */)
        .then((res) => res.data);

    return wrapPromise(promise);
}

Suspense가 데이터의 resolve 여부를 판단하기 위해서는 데이터를 fetch하는 함수를 조금 다르게 설계해야 한다

wrapPromise를 선언하고, 이를 이용하여 데이터 fetching 함수를 한번 감싸주면 된다

wrapPromiseread 메서드를 통해 데이터의 resolve 여부에 따라 다른 status와 값을 반환한다

fetchaxios나 둘 다 Promise 객체를 (axiosAxiosPromise) 반환하므로, 이 값을 promise 변수에 담은 뒤 wrapPromise로 한번 감싸주면, 서버로부터 라이브러리를 통해 받아온 값의 상태를 알 수 있다

Suspense는 이 read() 메서드로 받아온 값을 읽어들이고 컴포넌트를 렌더링한다

const fetchAllData = () => {
    let moviePromise = fetchData();
    let userPromise = fetchUser();
    return {
        movie: wrapPromise(moviePromise),
        user: wrapPromise(userPromise),
    }
}

위에서 만든 wrapPromise를 이용하여 데이터를 받아오는 두 함수를 감싸주면 준비완료

이제 fetchAllData() 의 반환값 객체에서 movie, user 키에 접근하면 각각의 read() 메서드를 통해 상태를 확인할 수 있다

Suspense 사용하여 데이터 받아보기

const resource = fetchAllData(); //컴포넌트 마운트 전 데이터 불러오기
// 여기까진 동일하게 작성

const MovieUserPage = () => {
    return (
        <Suspense fallback={<h1>Loading...</h1>}>
            <MovieList />
            <Suspense fallback={<h1>Loading...</h1>}>
                <UserList />
            </Suspense>
        </Suspense>
    );
}
// MovieList 컴포넌트와 UserList 컴포넌트를 보여주는 페이지
// 각각의 컴포넌트가 데이터를 받아오고 있기 때문에, Suspense로 감싸주었다

const MovieList = () => {
    const movieData = resource.movie.read();
    return (
        <ul>
            { movieData.map((v) => <li key={v}>{ v.title }</li>) }
        </ul>
    );
}

const UserList = () => {
    const userData = resource.user.read();
    return (
        <ul>
            { userData.map((v) => <li key={v}>{ v.name }</li>) }
        </ul>
    )
}
// MovieList와 UserList는 위에서 선언한 wrapPromise 반환 객체를 사용하며,
// read 메서드를 통해 데이터 및 현재 상태를 가져온다
// read 메서드에서 반환하는 상태가 pending일 때 Loading... 을 렌더링하고,
// 상태가 error이면 ErrorBoundary에 위임,
// 상태가 success이면 데이터를 그대로 받아와 렌더링한다

wrapPromise로 데이터를 받아온 두 컴포넌트를 Suspense로 감싸주었다

이제 아래와 같은 순서로 렌더링이 진행된다

  1. fetchAllData는 Promise를 그대로 반환하지 않고, 특별한 자원 (resource) 을 반환한다. 이 자원을 이용하여 Suspense를 이용할 수 있게 해 준다
  2. 리액트에서 MovieUserPage 의 렌더링을 시도한다
  3. MovieUserPage는 자식 컴포넌트인 MovieListUserList를 반환한다
  4. 이번에는 리액트에서 MovieList의 렌더링을 시도한다
    • 렌더링을 시도하면 resource.movie.read() 메서드를 호출한다
    • 아직 데이터를 받아오지 못했으므로, (pending) 컴포넌트 렌더링을 멈춘다
  5. MovieList 컴포넌트의 렌더링을 실패했으므로, UserList 렌더링을 시도한다
    • 렌더링을 시도하면 resource.user.read() 메서드를 호출한다
    • 아직 데이터를 받아오지 못했으므로, (pending) 컴포넌트 렌더링을 멈춘다
  6. 리액트는 MovieUserPage와 자식 컴포넌트 모두 렌더링을 시도했으나 실패하였다
  7. MovieList 렌더링이 멈춰 있으므로, 가장 가까운 상위 Suspense 태그의 fallback을 찾아 화면에 렌더링한다
  8. 데이터 fetching이 끝날 때까지 Loading... 을 렌더링한 채로 대기한다

resource 객체의 데이터를 아직 받지 못했으므로 렌더링 대기 상태에 들어갔으며, 데이터가 들어올 때마다 리액트는 렌더링을 재시도한다

  1. resource.movie의 데이터가 전부 불러와졌다면, MovieList 컴포넌트의 렌더링이 완료되고 Loading... 이 사라진다
  2. resource.user의 데이터가 전부 불러와졌다면, UserList 컴포넌트의 렌더링이 완료되고 MovieList 하위의 Loading... 도 사라진다

설명으로는 장황해졌지만, 실상 wrapPromise를 적절히 작성하고 Suspense로 감싸주기만 하면 쉽게 구현이 가능하다

또한 코드가 많이 복잡하지도 않아 (wrapPromise는 다른 파일로 분리하면 된다) 컴포넌트가 많아져도 어렵지 않게 구조를 잡을 수 있다

const MovieUserPage = () => {
    return (
        <Suspense fallback={<h1>Loading...</h1>}>
            <MovieList />
            <UserList />
        </Suspense>
    );
}

MovieListUserList가 모두 데이터를 받았을 때 한꺼번에 렌더링하고 싶다면 Suspense를 위와 같이 감싸주면 된다

const MovieUserPage = () => {
    return (
        <Suspense fallback={<h1>Loading...</h1>}>
            <MovieList />
        </Suspense>
        <Suspense fallback={<h1>Loading...</h1>}>
            <UserList />
        </Suspense>
    );
}

아예 별개로 따로따로 렌더링하고 싶다면 Suspense 경계를 각각 부여하여 독립시켜주면 된다

주의할 점

const resource = fetchAllData(); //컴포넌트 마운트 전 데이터 불러오기
// 여기까진 동일하게 작성

const MovieUserPage = () => {
    return (
        <Suspense fallback={<h1>Loading...</h1>}>
            <MovieList />
            <Suspense fallback={<h1>Loading...</h1>}>
                <UserList />
            </Suspense>
        </Suspense>
    );
}

렌더링을 수행하기 전에 (MovieUserPage) 데이터 불러오기를 시작해야 한다는 점 (fetchAllData()) 에 주의하자

컴포넌트가 렌더링을 시작할 때까지 데이터 불러오기를 미루고 싶지 않다면 말이다...

const initialResource = fetchAllData();

const MovieUserPage = ({ resource }) => {
...
    return (
        <Suspense fallback={<h1>Loading...</h1>}>
            <MovieList />
            <Suspense fallback={<h1>Loading...</h1>}>
                <UserList />
            </Suspense>
        </Suspense>
    );
}

const App() => {
    const [resource, setResource] = useState(initialResource);

    const handleButtonClick = () => {
        const nextPage = getNextPage(); // 다음 페이지 번호를 받아오는 함수
        setResource(fetchAllData(nextPage));
    };

    return (
        <>
            <button onClick={handleButtonClick}>다음 페이지</button>
            <MovieUserPage resource={resource} />
        </>
    );
}
// 상위 페이지

props에 따라 데이터를 다르게 받아와야 해서 컴포넌트 내에서 데이터를 받아와야 할 것 같다면, 이벤트 핸들러와 상위 컴포넌트를 이용해서 데이터를 처리하자

button 태그에 연결된 핸들러에서 데이터를 미리 받아다가 props로 넘겨주면 ‘컴포넌트 밖에서 데이터 받아오기를 시작해야 한다' 는 조건에 부합하며, 코드와 데이터를 동시에 불러올 수 있다

결론

사실 Suspense를 쓰면 좋다! 밖에 몰랐어서 그냥 Suspense 안에 아무런 선제조치 없이 컴포넌트를 때려박았는데, 로딩 페이지 동작을 안 하길래 ??? 하고 있었다

뭐든 써보기 전에 이론 공부는 필수임을... 느꼈다

처음에는 lazy같은 것들도 적용해야 하는 줄 알았는데 일단 거기까진 필요없을 것 같다

참고자료

데이터를 가져오기 위한 Suspense (실험 단계) - React

 

데이터를 가져오기 위한 Suspense (실험 단계) – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

'ClientSide > React' 카테고리의 다른 글

useClickOutside 직접 구현하기  (0) 2022.07.01
react-portal 사용해보기  (0) 2022.05.15
IntersectionObserver + 무한스크롤  (0) 2022.05.12
Custom Hook  (0) 2022.05.09
Too many re-renders 오류  (0) 2022.05.07
Comments