본문 바로가기

공부

Next.js 13 마이그레이션 소감 및 아쉬운 점

들어가기

12버전을 오래 사용하면서 피부로 와닿던 Next.js의 문제점들을

vercel이 인지하고 해당 내용으로 13버전을 준비한다고 했을 때

묘한 쾌감을 느꼈었는데

이번에 토이프로젝트에서 13버전으로 마이그레이션 작업을 진행했다.

 

내가 Next.js 13을 기다렸던 이유는 훨씬 빨라진 터보팩도 있지만

무엇보다 기존 pages 폴더의 구조적 한계였다.

 

파일 경로가 곧 URL이 되는 구조로 인해 pages 폴더에 파일을 함부로 추가할 수가 없었기에

전체 프로젝트 구조는 번잡해졌고

중첩 레이아웃이 적용이 되지 않기에 페이지마다 레이아웃을 따로 설정해야 했다.

 

그리고 가장 중요한 것은 위와 같은 구조적 문제로 인해

유사 SPA 방식을 지원한다고 소개하는 next/router 패키지는 말그대로 유사 SPA이기 때문에

SPA에서의 장점을 살리기가 어려웠다.

 

나는 12 버전에서 NextJS 환경에서 SPA 서비스를 구현하기 위해 다양한 방법을 생각했었는데

한가지는 prefetch를 이용한 방식이었다.

이 방식으로 인해 SPA 방식의 문제점인 초기 로딩 속도를 확 줄이면서도 지연 없는 페이지 이동을 구현했었는데

이렇게 하면 좋았던 점이 Next.js에서 리액트처럼 한 페이지에서 컴포넌트들로 SPA를 구현하게 된다면

Next.js의 장점인 SSR, SSG, ISR 같은 기능을 전혀 이용할 수가 없지만 이런 방식으로 하게 되면

서버 사이드 렌더링을 최대한 활용하면서 컴포넌트마다 lazy를 지정할 필요도 없이 최소한의 번들 사이즈로 SPA 느낌을 줄 수 있었고 실제 프로덕션으로 배포해도 될만큼 안정적이었다.

 

그럼에도 아쉬웠던 것은 SPA로 구현하게 되면 생각하게 되는 Suspense, Errorboundary, Context 등을 지정할 수가 없고 만약 순차적으로 진행되는 서비스가 아니라면 위에 설명한 방식은 큰 효과가 없고

각 페이지 파일이 별도로 존재하기 때문에 레이아웃을 모든 파일마다 지정해야 한다.

 

13 버전에서는 아직 베타 버전이지만 위와 같은 문제점을 상당 부분 해결 가능해 보여서 매우 기대된다.

 

 

Next.js 13에서 좋았던 점

 

사용하면서 개인적으로 좋았던 부분 위주로 설명하자면 (공식 문서를 보며 학습한 내용을 정리했다. 관심이 있거나 혼동되는 부분이 있다면 아래 참고 링크에서 확인하기 바란다.)

 

 

1. 파일 경로 기반이 아닌 폴더 기반 경로로 변경

 

13 버전에서는 pages 폴더 대신 app 폴더를 사용한다. (pages 폴더도 사용가능하지만 같은 경로라면 app 폴더가 우선이며, api routes는 여전히 pages 폴더에 사용중이지만 이것마저 곧 거취를 정할 예정이라고 한다.)

 

기본적인 구조만 설명하자면

 

폴더 이름이 곧 경로다.

 

각 폴더에서는 다음 파일들이 사용된다.

 

- layout.tsx -> 레이아웃 파일이다. app 폴더 바로 아래에 있을 경우 _app, _document 역할을 대신한다. app 폴더의 layout.tsx부터 시작해서 하위 경로의 layout.tsx들이 children으로 붙여진다. 즉, 중첩 레이아웃이 구현된다.

// app/layout.js
export default function RootLayout({ children }: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
- page.tsx -> 페이지 파일이다. 실제 페이지 소스를 이곳에 추가한다.
- head.tsx -> next/head 패키지를 대체하는 파일이다. <head></head>에 들어간다.
- loading.tsx -> 선언하게 되면 레이아웃을 제외한 부분에 suspense로 묶인다. 즉, 레이아웃에서 비동기는 Suspense가 걸리지 않는다.
- error.tsx -> 선언하게 되면 레이아웃을 제외한 부분에 Errorboundary로 묶인다. 즉, 레이아웃에서 발생한 예외는 걸리지 않는다.
- template.tsx -> layout.tsx와 매우 유사하게 동작하지만 차이점이 있다면 layout.tsx는 최대한 적게 리렌더링 되도록 설계되어 있다. 예를 들어 페이지에 한 번 방문했을 때는 layout.tsx가 렌더링 되지만 그 하위 경로 간의 이동은 리렌더링되지 않는다. 만약 페이지 이동마다 페이지 트렌지션 애니메이션이 발생이 필요하거나 하는 경우는 이 파일을 사용한다.

 

Suspense는 서버 사이드에서 동작하지 않기 때문에 기존 Next.js 12에서 Suspense를 쓰기 위해선 useEffect를 활용해 마운트가 된 후에 Suspense가 붙도록 따로 Wrapper 컴포넌트를 만들어서 써야 했지만 이제는 loading.tsx에 필요한 스켈레톤만 명시하면 쉽게 Suspense가 지원이 된 것이다.

 

 

이제 폴더 이름만 경로에 포함되기 때문에 폴더 아래에 테스트 파일 등 컴포넌트를 둬도 된다.

 

 

 

2. 네비게이션 캐시

 

이제 많이 쓰는 useRouter는

next/router 패키지가 아니라 next/navigation 패키지로 이동되었다.

 

그러면서 라우팅 방식도 많이 변경되었는데

 

next.js 공식 문서에 따르면 페이지 네비게이션에는 두가지 방식을 지원한다고 한다.

 

- 하드 네비게이션

클라이언트 캐시를 무시하고 완전히 새로 받아오는 것이다.

 

- 소프트 네비게이션

클라이언트 캐시를 받아들이고 필요한 부분만 가져와서 빠르게 렌더링한다.

 

소프트 네비게이션이 디폴트 옵션이고 발동 조건은

하위 경로로 이동하는 경우엔 소프트 네비게이션이 동작하고

상위 경로에서 이동하는 경우엔 하드 네비게이션이 동작한다.

예를 들어

/dashboard/a/b까지 이동한 후에 /dashboard/c 로 가는 경우는 하드네비게이션이 된다.

/dashboard/a/b까지 이동한 후에 /dashboard/a/b/2 로 가는 경우는 소프트네비게이션이 된다.

 

popstate 이벤트 같이 뒤로가기, 앞으로가기도 소프트네비게이션이 된다.

 

 

 

3. 서버 컴포넌트가 기본값

 

 app 폴더는 이제 리액트 서버 컴포넌트가 기본값이다. (리액트 최소 버전도 18.2를 요구한다.)

리액트 서버 컴포넌트와 서버 사이드 렌더링은 완전히 다른 말이다.

리액트 서버 컴포넌트는 서버 사이드 렌더링과 다르게 브라우저에 전달되지 않는다.

리액트 서버 컴포넌트에선 클라이언트 컴포넌트를 포함할 수 있다.

하지만 클라이언트 컴포넌트에서 서버 컴포넌트를 포함할 수는 없다.

 

클라이언트 컴포넌트는 쉽게 생각해서 useState, useEffect 같은 리액트 라이프사이클 훅이 하나라도 들어가면 무조건 클라이언트 컴포넌트로 사용해야 한다.

 

app 폴더에서 만드는 모든 파일은 서버 컴포넌트로 인식되기 때문에

만약 클라이언트 컴포넌트로 선언하길 원한다면

 

next.js에서는 파일 최상단에 'use client'를 붙여줘야 한다.

 

'use client';

// This is a Client Component. It receives data as props and
// has access to state and effects just like Page components
// in the `pages` directory.
export default function HomePage({ recentPosts }) {
  return (
    <div>
      {recentPosts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

 

아직 나도 서버 컴포넌트가 익숙하지 않지만

개념을 듣자마자 생각난 인사이트가 있는데

 

클라이언트 컴포넌트는 사용자에게 전달되는 번들 파일이고

서버 컴포넌트는 서버에서 실행되고 번들 파일 용량에 포함되는 것이 아니기 때문에

 

클라이언트 컴포넌트에서 처리하는 로직을 서버 컴포넌트로 옮긴다면

그것 자체로 최적화가 되는 것이다.

 

최적화가 상당히 단순해질 것으로 기대된다.

 

 

4. SSR, SSG, ISR 통합

 

위 코드는 기존 pages에서 보던 getServerSideProps 즉 SSR과 같은 코드다.

 

이제 fetch 하나로 SSR, SSG, ISR를 사용한다.

 

어떻게 가능하냐면 next.js는 기본적으로 폴리필을 제공하는 API가 몇개 있는데

그 중 하나가 Node.js에 있는 fetch API 이다.

이 API에 옵션들을 더 넣어서 사용한다.

 

위 fetch 코드에서 cache 옵션을 빼면 디폴트로 SSG가 된다. (force-cache가 디폴트)

 

 

이렇게 좋아보이지만 막상 업그레이드 해서 써보려고 하니

큰 문제점이 있었는데

그 부분은 아래 단점에 적겠다.

 

 

5. 렌더링 경로간 데이터 페칭 중복 캐싱

렌더링 경로 중에 같은 fetch를 여러 번 하는 경우가 있을 수 있는데

예를 들어 상위 경로의 layout이나 page에서 사용자 정보를 fetch하고

현재 경로의 page에서 같은 사용자 정보를 fetch 한다면

중복 요청하지 않고 한 번만 이뤄진다.

이것은 클라이언트 컴포넌트라면 전체 페이지를 다시 로드하지 않는 한 캐시가 유지되고

서버 컴포넌트라면 한 번의 렌더링 프로세스 간에 유지된다.

 

위 코드는 위 중복 캐싱과는 상관 없는 코드이지만

페칭 관련해서 재밌어 보여서 가져왔다.

 

만약 한 페이지에서 여러 API를 동시에 fetch 한다면 어떻게 해야할까?

 

만약 위 코드가

await getArtist(username);

await getArtistAlbums(username);

이런 식으로 썼다면 네트워크 탭에선 폭포수 형태로 나타났을 것이다.

 

의도적으로 순차적으로 가져오게 하고 싶다면 위 방식대로 쓰는 것이지만

parallel 하게 가져오고 싶다면 위 코드처럼 Promise.all을 사용할 것을 권장한다.

 

내가 여기서 재밌었던 점은

Next.js가 최대한 자바스크립트 생태계에 어울리도록 많이 고민했구나를 느껴서다.

 

 

한 가지 더 미세팁을 주자면

 

위처럼 서버 컴포넌트라면 그냥 fetch를 await 한 것으로도 Suspense가 잘 작동하지만

클라이언트 컴포넌트라면 Suspense가 작동하지 않을 것이다.

그런 경우 react 18에서 제공하는 use 훅을 사용하면 된다.

이 훅은 await와 흡사하지만 async 함수가 아닌 곳에서도 사용할 수 있고

무엇보다 Suspense가 지원하도록 래핑된다.

더 자세한 것은 다음 링크를 보면 된다. (https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#usepromise)

function Note({id}) {
  // This fetches a note asynchronously, but to the component author it looks
  // like a synchronous operation.
  const note = use(fetchNote(id));
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  );
}

 

 

아쉬운 점

 

아쉽기도 하지만 아직 잘 몰라서 그럴 수도 있는 부분이지만

그래도 느꼈던 점을 적자면

 

 

1. fetch API 말고 다른 서드파티 라이브러리는 아직 지원하지 않는다.

아직 대책도 마땅히 없는 듯 하다.

fetch API를 Next.js에서 커스텀해서 사용하여 SSR, SSG 등을 통합했지만

다른 데이터 페칭 라이브러리는 아직 사용할 수 없다.

 

예를 들어 

revalidate를 줘야 하는데 줄 수가 없다.

 

이런 경우를 위해 Next.js는 revalidate 만큼은 다른 방법을 제공하는데

 

이런 식으로 revalidate 상수를 선언하면 된다.

이 방법은 아주 임시적으로 제공하는 것이라고 한다.

 

 

2. 상위 경로로 데이터를 전달할 수 없음

기존 12버전에서는 하위 경로의 getStaticProps를 통해 props를 주면

최상위 경로인 _app.tsx에서 하위 경로까지 props가 전달되는 구조였고

그래서 react-query 같은 데이터 페칭 라이브러리를 이용해 SSG에서 prefetch를 하고 브라우저로 dehydrate를 할 수가 있었는데

 

next.js 13에서는 _app.tsx의 appProps를 대체할 방법이 없다.

전역 변수 등을 이용해야 할 것 같은데 아직은 아래 코드를 대체할 방법이 보이지 않는다.

 

export async function getStaticProps() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery('posts', getPosts);

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

 

서버 사이드 렌더링을 최대한 활용하기 위해

기존 react-query의 prefetchQuery를 사용했는데

현재 서버 사이드에선 fetch만 제공하기 때문에

위 코드는 사용하기도 어렵고

 

// _app.tsx

const { dehydratedState, session } = pageProps;
  return (
    <SessionProvider session={session as Session}>
      <RecoilRoot>
        <QueryClientProvider client={queryClient}>
          <Hydrate state={dehydratedState}>
            <ToastContainer />
            <Component {...pageProps} />
          </Hydrate>
        </QueryClientProvider>
      </RecoilRoot>
    </SessionProvider>
 );

_app.tsx에서 Hydrate에 dehydratedState를 넘겨야 하는데

 

현재 상위 경로로 데이터를 전달하는 방법을 제공하지 않는다.

 

그래서 fetch를 사용하도록 변경하고 hydrate 대신 react-query의 initialData에 넘겨주는 방식으로 구현했다.

 

// app/posts/page.tsx

export const revalidate = 60 * 60;

function PostsPage() {
  const posts = use(fetchPostsFromFirebase());

  return <Posts posts={posts} />;
}




// app/posts/posts.tsx

interface Props {
  posts: PostType[];
}
function Posts({ posts }: Props) {
  const { data, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery(
      'posts',
      async ({ pageParam = 0 }): Promise<{ posts: PostType[] }> => {
        return apiInstance
          .get(getApiAddress('POSTS'), {
            params: { count: 5, ts: pageParam > 0 ? pageParam : null },
          })
          .then((res) => res.data);
      },
      {
        getNextPageParam: (lastPage) =>
          lastPage.posts.length > 0
            ? lastPage.posts[lastPage.posts.length - 1].createdAt._seconds
            : undefined,
        staleTime: 60 * 1000,
        initialData: {
          pageParams: [0],
          pages: [{ posts }],
        },
      },
    );

 

 

생각보다 12 최신 버전에서 13으로 업그레이드 하는 과정에서 시간 소요도 많았고

서버 컴포넌트 사용이 미숙한 탓에 렌더링 오류 해결에 어려움을 많이 겪었다.

 

그래도 굉장히 올바른 변화라고 생각한다.

그렇게 생각한 이유 중 하나는 이번 변경에서 가장 크게 느꼈던 것은

기존 Next.js는 라이브러리가 아니라 프레임워크이기 때문에

배우기 위해선 진입장벽이 어느 정도 있는 편이었는데

그것을 내려놓고 최대한 자바스크립트 생태계에 어울리도록 노력한 점이 많이 보였다.

 

Next.js를 잘 모르더라도 getServerSideProps가 SSR인 것을 모르더라도

자바스크립트 개발자라면 쉽게 코드를 이해하게 된 것 같다.

 

앞으로 Next.js가 더 잘 나갈 것을 예상하며

나도 그만큼 업무에서나 토이프로젝트에서나 훨씬 잘 활용하고 싶다.

 

 

 

참고:

https://beta.nextjs.org/docs/getting-started

 

Getting Started | Next.js

Get started with Next.js in the official documentation, and learn more about all our features!

nextjs.org

 

 

학습 추천:

https://vercel.com/templates/next.js/app-directory

 

Next.js 13 App Playground – Vercel

Explore the new app directory in Next.js 13.

vercel.com

next.js 13의 다양한 특징을 주요 테마 별로 활용하기 좋게 되어 있다. 강력 추천!