[#1 - Streaming]

Streaming

Streaming이란 개천,하천이라는 뜻을 가진 Stream이라는 단어에서 유래된 용어로, 데이터를 개천이 흐르듯이 순차적으로 보내준다는 의미를 갖는다. Streaming은 NextJS에서 오래 걸리는 컴포넌트의 렌더링을 사용자가 좀 더 좋은 환경에서 기다릴 수 있도록 렌더링이 빠르게 가능한 요소들부터 순차적으로 출력해주는 방식으로 동작한다. 예를 들어 데이터 호출을 통해 받아오는 데이터의 양이 방대한 경우 페이지 전체의 로딩이 길어지는데 그에 따라 사용자가 접속 요청을 보냈음에도 불구하고 빈 회색 화면을 보고만 있는 것 보다는 기다릴 필요 없는 요소들을 먼저 보여주는 느낌이다.

특히 Streaming은 렌더링 된 페이지를 캐싱해놓는 기능인 Full Route Cache가 동작하지 않는 Dynamic Page에서 사용된다. Dynamic Page 내에서 Header, SideBar와 같이 데이터 호출이 필요없는 요소들은 방대한 양의 데이터 호출이 완료될 때까지 기다렸다가 한꺼번에 렌더링 될 필요가 없다. 또한 UX를 개선하기 위해서 Streaming 기능을 사용한다.

 

loading.tsx

Streaming을 적용하는 방법은 매우 간단하다. 우리는 앞서 404 Page를 커스터마이징하는 방법을 간단하게 알아보았었다. NextJS가 프레임워크라는 점을 이용해 단순히 not-found라는 이름으로 파일을 생성해주면 Data Fetching이 실패했을 때 NextJS 자체적으로 not-found로 구현해놓은 페이지를 보여주는 방식으로 동작한다. Streaming 또한 같은 방식으로 동작한다. 바로 Data Fetching이 진행되는 동안 보여줄 로딩 화면을 구성하는 loading 파일을 생성하면 된다. loading.tsx와 같이 파일을 생성한 후 로딩 화면에 맞게 UI를 생성해놓으면 NextJS가 Streaming이 적용된 Dynamic Page에서 로딩 시간이 긴 컴포넌트에 대해서는 loading 페이지를 보여주게 된다. 하지만 loading 페이지를 사용하는데에 주의해야 할 점들이 몇가지 있다.

1. loading 파일은 일반 Component 파일에는 Streaming이 적용되지 않고 page 파일에만 Streaming을 적용해준다.
- NextJS 내에 layout이나 일반 요소들을 구성하는 다양한 컴포넌트들이 존재하는데, 특정 경로의 메인 UI를 담당하는 page 파일에 대해서만 Streaming이 적용된다. loading 파일과 같은 경로에 존재한다고해서 무조건적으로 Streaming이 적용되는 것은 아니다.
2. loading 파일은 async 키워드가 붙은 page 컴포넌트만 Streaming을 적용해준다.
- 1번 내용의 연장선으로 Streaming이 적용되는 파일은 오직 메인 UI를 구성하는 page 파일뿐인데 그중에서도 async 키워드가 붙어 Data Fetching 기능이 포함되어 있는 Dynamic Page에 대해서만 Streaming이 적용된다. async 키워드가 붙지 않은 컴포넌트는 Data Fetching이 필요 없는 컴포넌트이며 그에 따라 Static Page로 생성이 가능하기 때문에 Streaming이 필요 없이 우선적으로 렌더링되는 요소들이기 때문이다. 만약 page 파일이 아닌 다른 컴포넌트들에게도 Streaming을 적용하기 위해서는 React에서 사용했던 Suspense Component를 사용해야 한다. Suspense에 대해서는 다음 단락에서 다루어볼 예정이다.
3. 현재 Streaming이 적용된 page 파일의 하위 경로의 모든 page 파일들에는 최상위 경로의 page에 적용된 loading 파일이 동일하게 적용된다.
- layout 파일과 동일한 개념으로 상위 경로의 layout이 하위 경로에도 동일하게 적용되는 것처럼 loading 파일의 UI 또한 하위 경로의 모든 page 파일에게 동일한 UI가 적용된다.
4. loading 파일로 적용된 Streaming은 브라우저에서 Query String이 변경될 경우에는 Triggering 되지 않는다.
- 예를 들어 검색창을 통해 검색한 결과를 통해 Data Fetching을 하는 page인 경우 page 파일에서 최초로 Data Fetching을 하는 경우 Streaming이 정상적으로 작동하는 것을 볼 수 있지만, 그 이후 추가적인 검색으로 인해 Query String이 변경되는 경우에는 Streaming이 적용되지 않고 단순 페이지 대기 화면이 지속되는 현상이 발생한다. Query String이 변경되는 page에 Streaming을 적용하기 위해서는 앞서 말했던 Suspense Component를 사용해주어야 한다.

 

Suspense Component

Suspense Component를 사용하는 방식은 앞서서 학습한 경험이 있고 React 프로젝트를 진행할 때에도 사용해 본 경험이 있기 때문에 어렵지 않게 사용할 수 있을 것이다. Streaming 적용 관점에서 사용법을 되짚어보자면 Streaming을 적용하고자 하는 Component의 외부에 react에서 불러온 Suspense Component를 씌워주고 대체할 요소를 fallback 옵션을 통해서 넣어주면 된다. 사용한 예시를 간단하게 코드로 확인하면 다음과 같다.

import {Suspense} from 'react';

export default async function Page(){
  const response = await fetch(API);
  if(!response.ok) return <div>Error</div>;
  const data = await response.json();
  return (
    <Suspense fallback={<div>Loading....</div>}>
      <div>{data}</div>
    </Suspense>
  )
}

위의 코드와 같이 사용하였다면 page 파일이 아닌 다른 일반적인 Component들에도 Streaming을 적용할 수 있게 된다. 하지만 우리가 loading 파일이 아닌 Suspense를 사용하고자 하는 가장 큰 이유는 page 파일이 아닌 다른 Component에도 Streaming을 적용하고자하는 것과 Query String이 변경되는 경우에도 적용하고자 하는 것이다. 따라서 첫번째 이유는 Suspense Component를 사용함으로써 해결하였지만 두번째 이유에 대해서는 아직 해결하지 못했다.

Query String이 변경되는 경우에도 적용을 하기 위해서는 아주 간단하게 Suspense에 옵션 하나를 추가해주면 된다. 바로 key 옵션을 넣어주는 것이다. fallback 옵션처럼 key 옵션을 넣어주고 옵션의 값에 searchParams로부터 받아온 Query String 값을 넣어주면 된다. 이와 같이 동작하는 이유는 Suspense의 특성에 대해 알아야한다.

Suspense Component는 최초에 로딩 후 내부 컴포넌트가 렌더링이 완료되고 나서는 다시는 로딩 상태를 표시해주지 않는다. 그 이후에 어떤 동작이 발생하여도 최초에 동작한 이후에는 로딩 상태를 보여주지 않는 것이다. 하지만 React에서 컴포넌트는 key 값이 변경되면 컴포넌트가 아예 변경되었다고 인식하기 때문에 key 값을 옵션으로 부여해주고 옵션 값으로 Query String을 넣어주면 Query String이 변경될 때마다 Suspense Component가 동작하게 되고 그에 따라 로딩 상태가 매번 표시되는 것이다. 사용 방법은 아래와 같다.

import {Suspense} from 'react';

export default async function Page({searchParams}:{searchParams: {q?: string}){
  const response = await fetch(API);
  if(!response.ok) return <div>Error</div>;
  const data = await response.json();
  return (
    <Suspense key={searchParams.q || ""} fallback={<div>Loading....</div>}>
      <div>{data}</div>
    </Suspense>
  )
}

 

[#2 - Skeleton UI]

Skeleton UI

Skeleton UI란 Skeleton이 뼈대라는 의미를 가진 단어에서 온 용어로 뼈대 역할을 하는 UI를 의미한다. 우리는 앞서 Suspense를 통해 화면이 렌더링되는 동안 Data Fetching이 모두 완료되지 않았을 때 로딩 화면을 보여줌으로써 사용자 경험에 더 좋은 방향으로 설계를 하는 방법을 학습했다. Suspense를 통해 로딩 화면을 출력해줌으로써 Streaming을 구현하여 사용자에게 막연한 기다림과 좋지 못한 UX를 제공하지 않도록 하는 좋은 기능을 학습하였지만 로딩 시간동안 보다 더 구체적인 UI를 보여주면서 UX를 조금 더 개선시킬 수 있도록 해주는 것이 바로 Skeleton UI이다. 특별한 기능을 사용하거나 Hook이나 라이브러리를 사용하는 것은 아니고 단순히 렌더링 되는 요소들을 동일하게 생성한 후 뼈대와 같이 회색으로 요소들을 미리보기처럼 보여주며 동작한다. Skeleton UI를 구현한 후 Suspense의 fallback 옵션으로 Skeleton UI를 넣어주면 막연한 로딩 화면보다 조금 더 가시적인 대기 화면을 사용자에게 제공할 수 있게 된다.

우리가 실제로 자주 사용하는 Youtube에 초기 접속을 하면 화면의 UI에 맞게 회색 요소들로 가득 찬 로딩 화면을 경험해 본 적이 있을 것이다. 그것이 바로 Skeleton UI이다. 아래 사진을 참고해보자.

Skeleton UI

 

React Loading Skeleton

Skeleton UI를 퍼블리싱을 통해 직접 제작하는 방법으로 진행하였지만 React 라이브러리 중에 Animation 기능까지 포함된 Skeleton UI를 제공해주는 라이브러리도 존재한다. 추후에 사용해보면 좋을 것 같다.

 

[#3 - Error Handling]

error.tsx

우리는 여태 데이터 호출과 관련하여 오류나 에러 처리를 하기 위해서는 try-catch 문을 주로 사용하여 에러를 잡아내고 사용자에게 메세지 형태로 출력을 해주는 방식을 사용했었다. NextJS에서는 보다 편하게 에러 UI를 생성하는 방법이 존재한다. 현재 위치해 있는 페이지에 해당하는 page.tsx 파일과 동일한 경로에 error.tsx라는 이름으로 파일을 생성해주면 page.tsx 파일에서 데이터 호출을 포함한 다양한 에러가 발생했을 경우 error.tsx 파일에 생성해둔 에러 UI를 자동으로 출력해준다. 또한 Router 경로 설정을 위해 하위 폴더가 존재하는 경우 하위에 있는 모든 페이지들에도 동일한 에러 UI가 적용이 된다. 이것은 데이터 호출을 기다리는 동안 Suspense의 fallback 속성처럼 대체 UI를 생성해두는 것과 같은 맥락이다.

여기서 주의해야 할 점이 있다.

에러라는 것은 Client Component, Server Component 여부를 불문하고 어디서든지 발생이 가능한 요소이다. 따라서 error.tsx의 최상단에는 반드시 Client Component라는 것을 지칭하는 "use client" 지시자를 입력해주어야 한다. 에러 자체는 클라이언트, 서버 측 불문하고 발생하지만 Server Component는 클라이언트 측에서는 렌더링이 되지 않기 때문에  error.tsx 파일을 Client Component로 설정해두지 않으면 클라이언트 측에서는 해당 에러 UI로 대체를 할 수 없기 때문이다. 따라서 Server 측에서 초기 렌더링 시 한 번, Client 측에서 Hydration 과정으로 인해 또 한 번, 양측 모두에서 렌더링이 발생하는 Client Component로 설정을 해주어야 한다.

 

error Props

우리가 에러 UI를 생성하였지만 발생한 에러에 대한 정보, 에러 메세지 등을 확인하고 그 부분을 출력을 해주고 싶은 경우도 발생한다. 이런 경우 error.tsx 파일의 Error Component에 error Props를 전달해주면 확인이 가능하다. NextJS에서는 error라는 이름의 Props로 현재 발생한 에러의 정보를 Error Component에게 전달해준다. 이 때 error Props의 타입은 NextJS에서 제공해주는 Error로 지정을 해주면 된다. 지금까지의 내용을 바탕으로 error.tsx 파일의 예시를 간단하게 코드로 작성해보았다.

"use client"

import {useEffect} from 'react';

export default function Error({error} : {error: Error}){
  //error가 발생할 때마다 error 객체의 에러 메세지 출력
  useEffect(() => {
    console.log(error.message);
  },[error])
  return (
    <div>
      <h1>에러가 발생했습니다.</h1>
      <p>{error.message}</p>
    </div>
  )
}

 

reset(Error) & refresh(useRouter)

NextJS가 Error Component한테 제공해주는 Props는 error 객체말고도 reset이라는 함수도 존재한다. reset 함수는 말그대로 에러 상태를 리셋하고 페이지 렌더링을 다시 시도하는 동작을 제공한다. 현재 간단하게 제작된 서버를 종료해두고 클라이언트 측에서 데이터 호출을 시도해 보았는데 당연히 에러가 발생했고 그에 따라 Error Component UI가 렌더링 되었다. 이후에 reset 함수를 적용하기 위해서 코드를 추가해보았다.

"use client"

import {useEffect} from 'react';

export default function Error({error,reset} : {error: Error; reset: () => void}){
  //error가 발생할 때마다 error 객체의 에러 메세지 출력
  useEffect(() => {
    console.log(error.message);
  },[error])
  return (
    <div>
      <h1>에러가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  )
}

위와 같이 에러 상태를 리셋하고 재호출을 위한 버튼을 추가하고 버튼의 onClick 이벤트에 reset 콜백 함수를 추가해주었다. 하지만 reset 함수를 넣어주었음에도 불구하고 서버를 재시작했음에도 에러가 사라지지 않았다. 그 이유는 무엇일까.

reset 함수는 클라이언트 측에서 현재 서버로부터 받은 데이터를 다시 한 번 렌더링을 시도해보는 역할을 할 뿐 서버측에서 데이터를 다시 가져올 수 있도록 API 호출을 시도한다거나 하는 작업은 하지 못하기 때문이다. 따라서 이전에 오류가 발생했던 이유가 서버 측에서 받아온 데이터가 없기 때문이었는데 reset을 통해 다시 렌더링 해봤자 변함이 없는 것이다.

이 문제를 해결하기 위한 대안이 2가지가 있다. 하나씩 살펴보자.

 

1. button 태그의 onClick 이벤트 핸들러의 콜백 함수로 reset이 아닌 window.location.reload()를 주어서 페이지 새로고침을 통해 데이터를 불러오도록 하기.
"use client"

import {useEffect} from 'react';

export default function Error({error,reset}: {error: Error; reset: () => void){
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>다시 시도</button>
    </div>
  )
}

- 가장 심플하게 처리할 수 있는 방법이다. 하지만 단점이 조금 존재한다.

a.브라우저 측에 저장된 상태 값이나 데이터들이 페이지 새로 고침으로 인해 휘발되거나 초기화 될 수 있다.
b.동일하거나 하위 경로에 존재하는 컴포넌트들 중 에러가 발생하지 않는 컴포넌트들도 리렌더링이 발생하여 불필요한 렌더링이 발생한다.

=> 오류가 발생한 부분만 깔끔하게 리렌더링 시키기 위해 2번 방법으로 대체

 

2. useRouter Hook의 refresh method와 reset 함수 사용하기.

- refresh method현재 페이지에 필요한 Server Component들을 Next Server 측에 다시 한 번 실행하도록 요청을 보내는 method이다. 그렇게 받아온 Server Component의 결과값을 화면에 업데이트 시켜준다.

- reset는 에러 상태를 초기화하고 컴포넌트들을 다시 렌더링하는 기능을 한다. 따라서 reset을 실행하면 에러 상태를 초기화하고 refresh method를 통해 받아온 데이터를 갖고 컴포넌트를 리렌더링하여 화면을 업데이트 시킬 수 있다.

위의 두 과정을 합해서 onClick의 콜백 함수로 주면 어떨까. 아래 코드와 같이 작성해보았다.

"use client"

import {useEffect} from 'react';
import {useRouter} from 'next/navigation';

export default function Error({error,reset}: {error: Error; reset: () => void){
  const router = useRouter();
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => {
        router.refresh();
        reset();
      }}>다시 시도</button>
    </div>
  )
}

여전히 오류가 발생한다. 그 이유는 refresh method는 비동기적으로 동작하는 method이기 때문이다. refresh method를 통해 Server Component로부터 아직 데이터를 받아오기 전에 reset Function이 먼저 실행되게 되어 reset만 단독으로 실행했을 때와 다를 바가 없게 되었다.

=> 두 함수를 동시에 실행해주는 방법을 사용해야 한다.

 

startTransition(React 18)

React 18버전부터 추가된 method인 startTransition은 하나의 콜백 함수를 인자로 받아서 콜백 함수 내부에 있는 UI를 변경하도록 동작하는 로직을 모두 일괄적으로 실행해주는 기능을 한다. 따라서 refresh method와 reset function을 내부에 넣어주게 되면 두 method가 하나 같이 동작함에 따라 오류 상태가 초기화되고 새로 받아온 데이터 값을 정상적으로 업데이트 시켜줄 수 있게 되었다. 코드는 다음과 같다.

"use client"

import {useEffect, startTransition} from 'react';
import {useRouter} from 'next/navigation';

export default function Error({error,reset}: {error: Error; reset: () => void){
  const router = useRouter();
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => {
        startTransition(() => {
          router.refresh();
          reset();
        })
      }}>다시 시도</button>
    </div>
  )
}

 

마지막으로 error.tsx 파일을 생성함에 있어서 주의해야 할 점은 파일 내부에서의 위치이다. error.tsx 파일을 동일한 경로와 그 하위 경로에 있는 파일들에 모두 적용이 된다. 따라서 특정 페이지에서 상위 폴더의 에러 UI와 다른 UI를 렌더링 해주고 싶은 경우에는 해당 경로의 page.tsx 파일과 같은 위치에 error.tsx 파일을 생성해주면 된다.

또한 error.tsx 파일을 자신과 동일한 위치에 있는 layout.tsx까지만 렌더링을 해주기 때문에 하위 경로에 있는 layout.tsx 파일은 에러가 발생에 따라 영향을 받지 않는 요소라고 하더라도 무시되어 렌더링 되지 않을 수도 있다. 따라서 App Router 버전에서 에러 UI를 페이지 별로 구분을 해야한다면 번거롭더라도 각 page 파일에 맞게 error.tsx 파일을 생성해주는 것이 더 좋을 수 있다.

+ Recent posts