테크톡

Next.js가 프레임워크인 이유1: next가 대신 해주는 것들

Ahyeon, Jung 2024. 8. 12. 19:36

네트워크 탭을 읽는거에 익숙해질 때쯤, 청크 파일은 왜 내 코드는 이상한 파일로 변경되었고, 이 파일들은 누가 보내주는거냐고 물어봤다가 거절당한 기억이 있다. 이제는 그 친구들이 빌드된 파일들이고 Nginx가 서빙한다는 걸 알게 되었지만 그때는 그냥 호기심이 들었다가 다른 게 급해서 금방 잊혀졌었다.

사실 회사가 굳이 위험부담을 안고 갈까 하는 의문이 아직도 남아있는데, 사이드 범위 안에서는 vercel이 다 감당해주니까 오히려 사이드에서 Next.js를 많이 사용해보는것도 나쁘지 않을거같다. Next를 처음 사용할때 누가 파일을 제공하는 건가에 의문이 있었다. 웹 서버와 백 서버의 차이를 이해하지 못해서 맨날 백이랑 레포를 합쳐야하는거 아닌가,, 뭐 이런 생각을 했었다. 이 때 가졌던 의문 하나는 라이브러리와 프레임워크를 이해하지 못했다. 사실상 리액트가 왜 프레임워크가 아닌건지를 몰랐다. 여전히 차이를 어렴풋이 이해하고 있길래 알아봤다.

리액트는 왜 라이브러리고 NextJS는 왜 프레임워크일까

리액트가 렌더링을 시작하는 방법

CRA를 기준으로, 리액트는 html, main.js, app.js를 생성한다. 브라우저의 요청이 들어오면 html을 보내고, html 안에서 main.js를 불러낸다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>EMOTIARY | 감정 기록 다이어리</title>
    <script defer="defer" src="/static/js/main.280c0095.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

 

main.js는 컴포넌트 트리를 관리하는 Root 객체를 만든다. 그리고 render 메서드를 통해 최상위 컴포넌트인 <App />과 연결하여 트리 구조의 모든 자식 리액트 컴포넌트를 불러온다.

ReactDOM.createRoot().render(document.getElementById("root"), <App />)

 

최상위 컴포넌트에서 바로 다른 컴포넌트들을 부르거나, react-route-dom를 설치하여 라우팅을 진행한다. 이 라우팅도 개발자가 설치해서 진행하기 때문에, 리액트는 라이브러리다.

Next.js이 렌더링을 시작하는 방법

NextJS는 pages router든, app router든 파일 구조로 라우팅이 되기 때문에 파일 위치만 정해서 생성해주면 자동으로 진행한다. 파일 구조 기반 '시스템'이다.

DOM 트리를 만들거나 컴포넌트를 render하는 과정이 없다. 즉, NextJS는 리액트의 프레임워크로서, 해당 과정을 내가 아닌 Next가 진행해주는 점에서 프레임워크이다. 

NextJS가 실행되기 위해서는 react가 필수적으로 설치되어있어야하는 걸 확인할 수 있다. NextJS는 이 React의 renderToString을 활용하여 초기 렌더링을 서버에서 수행한다.

Next는 컴포넌트를 응답값으로 넣어 보낸다

Next는 서버에 요청이 들어오면 컴포넌트를 만들어 응답값으로 보내주는 매커니즘을 가졌다.

import express from "express";
import { createServer as createViteServer } from "vite";

const TEMPLATE = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Turbopack Test App</title>
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script type="module" src="/src/vite-entry-client.jsx"></script>
  </body>
</html>`;

async function createServer() {
  const app = express();
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: "custom",
  });
  app.use(vite.middlewares);
  app.use("*", async (req, res, next) => {
    const url = req.originalUrl;
    try {
      const template = await vite.transformIndexHtml(url, TEMPLATE);
      const { render } = await vite.ssrLoadModule("/src/vite-entry-server.jsx");
      const appHtml = await render(url);
      const html = template.replace(`<!--ssr-outlet-->`, appHtml);
      res.status(200).set({ "Content-Type": "text/html" }).end(html);
    } catch (e) {
      vite.ssrFixStacktrace(e);
      next(e);
    }
  });

  const listener = app.listen(0, () => {
    console.log(`Local: http://localhost:${listener.address().port}`);
  });
}

createServer();

 

요청에서 url을 추출하여 해당 페이지의 컴포넌트를 불러와 template 내에 replace하여 응답값으로 보낸다.

export function render() {
  return ReactDOMServer.renderToString(<App />);
}

 

리액트의 Server API인 renderToString을 활용하여 컴포넌트를 문자열로 변형하기 때문에, template에 합쳐 응답값으로 보낼 수 있다.

renderToString

renderToString이란, 리액트 컴포넌트를 문자열 형태의 HTML로 변환하는 메서드이다. 이를 통해 Reat 컴포넌트를 렌더링한 후 해당 HTML을 클라이언트에 전달할 수 있다. renderToString – React

 

renderToString – React

The library for web and native user interfaces

react.dev

순수 리액트를 가지고 서버 사이드 렌더링을 구현한다고 할 때 실제로 renderToString을 사용한다.

 

단, 리액트 18버전 부터 renderToPipeableStream 또는 renderToReadableStream 메서드이 사용을 권장한다. app router의 등장과도 관계가 있어서 다음 포스팅에 더 알아보겠다.

 

어쨌든 Next는 요청이 들어오면 문자열 HTML로 변환된 리액트 컴포넌트를 TEMPLATE 안에 replace로 넣어 응답값으로 전체 html 코드를 보낸다.

이건 생각보다 더 복잡한 과정이다

컴포넌트를 서버에서 만들어서 응답값으로 보내주는건 간단해 보이지만, 사실 그 과정에서 Next가 하는건 굉장히 크다. app router를 사용했는지, pages router를 사용했는지에 따라 라우팅을 다르게 해주고, 현재 환경이 개발환경인지, 런타임인지 판단하고, getStaticProps, getInitialStaticProps 등의 어떤 데이터 패칭을 사용했는지도 판단하다. 그냥 초반 파일만 보더라도 1,500 코드가 넘으며, 깃허브에서 제공하는 Symbols도 50개가 넘는다.

 

즉, Next.js는 개발자가 상대적으로 적은 코드로도 복잡한 웹 애플리케이션을 만들 수 있게 도와주는 애플리케이션이다.

next.js/packages/next/src/server/render.tsx at canary · vercel/next.js (github.com)

 

next.js/packages/next/src/server/render.tsx at canary · vercel/next.js

The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.

github.com

코드를 통해 살펴본 것들

  • 파일 기반의 라우팅 정보를 받아와 라우팅을 진행한다.
  • vercel의 엣지 서버(vercel이 CDN과 유사하게 글로벌 엣지 네트워크에서 가까운 요청 처리)가 아닌 경우 에러 소스를 판별
  • 요청의 쿠키 parser
  • getStaticProps, getServerSideProps, getInitialProps 등의 데이터 페칭 메소드 실행 여부를 확인
  • getStaticProps의 여부(있으면 SSG) 확인 후 렌더링(문자열 변환)
  • 잘못된 pathname을 가진 요청인 경우 /404.js 렌더링(문자열 변환)
  • 렌더링된 HTML, 상태 코드, 헤더 등을 포함한 HTTP 응답을 생성
  • 자동 정적 최적화(Automatic Static Optimization)을 적용할 수 있는지 확인
  • 페이지 미리 렌더링(Prerendering)이 가능한 경우 처리
  • 코드 분할(Code Spliting)을 적용하여 필요한 JS 번들을 페이지에 주입
  • 캐시 헤더 설정

사실상 UI, 비즈니스 로직 관리에서 추가된 서버 관리 역할을 Next.js가 가져가주었다고 보면 된다.

Next.js가 라우팅을 대신해주는 방법

리액트 Next 차이점을 물어보면 대부분의 사람들이 파일 구조 기반 라우팅이라고 답할정도로, 초기에 가장 체감되는 것은 라우팅의 방식이다. Next가 라우팅을 해주는 방식은 react-router-dom가 해주던 것들을 해준다는 식으로 이해할 수 있다.

// 라우터 인스턴스를 관리하는 객체
const singletonRouter: SingletonRouterBase = {
  router: null, // holds the actual router instance
  readyCallbacks: [],
  ready(callback: () => void) {
    if (this.router) return callback()
    if (typeof window !== 'undefined') {
      this.readyCallbacks.push(callback)
    }
  },
}

// singletoRouter에 추가될  URL 관련 속성
const urlPropertyFields = [
  'pathname',
  'route',
  'query',
  'asPath',
  'components',
  'isFallback',
  'basePath',
  'locale',
  'locales',
  'defaultLocale',
  'isReady',
  'isPreview',
  'isLocaleDomain',
  'domainLocales',
] as const

// singletoRouter가 처리하는 라우터 이벤트 목록
const routerEvents = [
  'routeChangeStart',
  'beforeHistoryChange',
  'routeChangeComplete',
  'routeChangeError',
  'hashChangeStart',
  'hashChangeComplete',
] as const
export type RouterEvent = (typeof routerEvents)[number]

// 라우터 메서드 목록
const coreMethodFields = [
  'push',
  'replace',
  'reload',
  'back',
  'prefetch',
  'beforePopState',
] as const

urlPropertyFields.forEach((field) => {
  Object.defineProperty(singletonRouter, field, {
    // 해당 라우터 인스턴스 생성해서 속성 추가하기
  })
})

routerEvents.forEach((event) => {
  singletonRouter.ready(() => {
    Router.events.on(event, (...args) => {
    // 이벤트 동작시키기
    })
  })
})

// 리액트 컴포넌트에서 라우터에 접근할 수 있도록 하는 컨텍스트 훅
export function useRouter(): NextRouter {
  const router = React.useContext(RouterContext)
  return router
}

// 싱글톤 패턴을 활용하여 하나만 존재하기를 보장
export function createRouter(
  ...args: ConstructorParameters<typeof Router>
): Router {
  singletonRouter.router = new Router(...args)
  singletonRouter.readyCallbacks.forEach((cb) => cb())
  singletonRouter.readyCallbacks = []

  return singletonRouter.router
}

 

NextJS는 singletonRouter에서 라우팅을 관리한다. urlPropertyFields 내에 pathname, route, query, asPath 등 라우터와 관련된 속성들을 정의한다. 그리고 컴포넌트 내에서는 useRouter를 활용하여 이 라우팅 정보 전역 객체(RouterContext)에 접근한다.

function getFilesForRoute(
  assetPrefix: string,
  route: string
): Promise<RouteFiles> {
  if (process.env.NODE_ENV === 'development') {
    const scriptUrl =
      assetPrefix +
      '/_next/static/chunks/pages' +
      encodeURI(getAssetPathFromRoute(route, '.js')) +
      getAssetQueryString()
    return Promise.resolve({
      scripts: [__unsafeCreateTrustedScriptURL(scriptUrl)],
      // Styles are handled by `style-loader` in development:
      css: [],
    })
  }
  return getClientBuildManifest().then((manifest) => {
    if (!(route in manifest)) {
      throw markAssetError(new Error(`Failed to lookup route: ${route}`))
    }
    const allFiles = manifest[route].map(
      (entry) => assetPrefix + '/_next/' + encodeURI(entry)
    )
    return {
      scripts: allFiles
        .filter((v) => v.endsWith('.js'))
        .map((v) => __unsafeCreateTrustedScriptURL(v) + getAssetQueryString()),
      css: allFiles
        .filter((v) => v.endsWith('.css'))
        .map((v) => v + getAssetQueryString()),
    }
  })
}

 

Router에 저장하기 위해 빌드된 파일들 중 pages 내부의 파일들을 불러와 파일 배열로 보관한다. 이 과정에서 .css 파일은 제거된다. 

 loadRoute(route: string, prefetch?: boolean) {
   return withFuture<RouteLoaderEntry>(route, routes, () => {
     return resolvePromiseWithTimeout(
       getFilesForRoute(assetPrefix, route)  // Router 전역 객체 가져옴
         .then(({ scripts, css }) => {
           return Promise.all([
             entrypoints.has(route)
               ? [] // 리소스를 가져온 경우 X
               : Promise.all(scripts.map(maybeExecuteScript)), // 자바스크립트 가져옴
                Promise.all(css.map(fetchStyleSheet)), // 스타일시트 가져옴
              ] as const)
            })
            .then((res) => {
              return this.whenEntrypoint(route).then((entrypoint) => ({
                entrypoint,
                styles: res[1], // 스타일 정보를 저장
              }))
            }),
          MS_MAX_IDLE_DELAY, // 최대 지연 시간을 설정
          markAssetError(new Error(`Route did not complete loading: ${route}`)) // 라우트 로딩 실패 시 에러 발생
        )
          .then(({ entrypoint, styles }) => {
            const res: RouteLoaderEntry = Object.assign<
              { styles: RouteStyleSheet[] },
              RouteEntrypoint
            >({ styles: styles! }, entrypoint) // 엔트리 포인트와 스타일 정보를 결합하여 반환
            return 'error' in entrypoint ? entrypoint : res // 에러가 있으면 에러 반환, 그렇지 않으면 결과 반환
          })
          .catch((err) => {
            if (prefetch) {
              // 프리페치 중 에러 발생 시 캐시하지 않음
              throw err
            }
            return { error: err } 
          })
      })
    },

 

Router 전역 객체를 가져와서 해당 URL(pathname, entrypoint)에 맞는 파일들을 가져오고, 자바스크립트와 스타일 파일

Next.js가 라우팅을 대신해주는 방법(간략ver)

사실 코드를 보면서 온갖것들을 다 해주길래 막막하기만 했는데, 이미 라우팅 부분만 발췌하여 주니어들이 이해할 수 있는 식으로 간단하게 로직을 구현해준 천재들이 있었다.

 

프리온보딩 5월 오종택 개발자님 강의 中

// 폴더 구조로 라우트 구성하기
const pages: Record<string, { default: React.ElementType }> = import.meta.glob(
"./pages/*.tsx", { eager: true }
);
const routes = Object.keys(pages).map((path) => {
const name = path.match(/\.\/pages\/(.*)\.tsx/)?.[1] ?? "";
return {
name,
path: `/${name === "index" ? "" : name}`,
component: pages[path].default,
};
});
const container = document.getElementById("root") as HTMLElement;
ReactDOM.createRoot(container).render(
<Router>
{routes.map(({ path, component: Component }) => (
<Route key={path} path={path} component={<Component />} />
))}
</Router>
);

 

단, 실제로 Next.js는 react-router-dom을 사용하지는 않는다. 이해를 돕기 위한 사용이다.

 

직접 서버사이드에서 라우팅을 구현한 분도 계신다.

React-Router-Dom

리액트에서는 react-router-dom을 설치하고 <BrowserRouter/>와 <Router/>를 활용하여 라우팅을 할 수 있다.

const RoutesList = [
  { path: Paths.Landing, element: <Landing />, isMobileVisible: true, isFooter: true },
  { path: '*', element: <NotFound />, isMobileVisible: false },
];

const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        {RoutesList.map(({ path, element, isMobileVisible, isFooter }, index) => (
          <Route
            key={index}
            path={path}
            element={
                <Mobile isMobile={isMobile} isFooter={!!isFooter}>
                  <Layout>{element}</Layout>
                </Mobile>
            }
          />
        ))}
      </Routes>
    </BrowserRouter>
  );
};

export default Router;

remix-run/react-router: Declarative routing for React (github.com)

 

GitHub - remix-run/react-router: Declarative routing for React

Declarative routing for React. Contribute to remix-run/react-router development by creating an account on GitHub.

github.com

export function createHashRouter(
  routes: RouteObject[],
  opts?: DOMRouterOpts
): RemixRouter {
  return createRouter({
    basename: opts?.basename,
    future: {
      ...opts?.future,
      v7_prependBasename: true,
    },
    history: createHashHistory({ window: opts?.window }),
    hydrationData: opts?.hydrationData || parseHydrationData(),
    routes,
    mapRouteProperties,
    unstable_dataStrategy: opts?.unstable_dataStrategy,
    unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
    window: opts?.window,
  }).initialize();
}

 

react-router-dom 의 경우, RouterProvider 를 통해 라우팅 정보를 저장하고, 맞는 path의 컴포넌트를 보관하다가 보내준다. 실제로 코드를 보면 메모이제이션 등의 상태 관리가 추가되므로, 온갖 것들이 다 모여있는 Next.js를 까는 것보다 react-router-dom를 먼저 까보는게 이해하기는 더 쉬울거같다,,

 

간단하게 말하면, 리액트는 빈 html과 모든 페이지의 자바스크립트를 연결해주는 역할을 하고, react-router-dom은 라우트를 통해 path별로 해당 url에 맞는 자바스크립트를 코드 스플리팅, 번들링하여 초기 속도를 개선해준다.

 

이 과정을 NextJS가 해준다는 것을 알고 사용하자.

NextJS 프레임워크가 대신 해주는 것들

다시 돌아와서, NextJS는 프레임워크로써 개발자들의 편의를 위해 다양한 기능들을 제공해준다. 코드를 열어보면서 확인할 수도 있지만, 사실 그건 그냥 구경이었기에, 공식문서를 살펴보자. 그래도 생각보다 구경한 것들이 문득문득 생각나서 재밌다.

 

Next.js는 full-stack 웹 애플리케이션을 위한 리액트 프레임워크이다. 번들링, 컴파일 등 리액트를 사용할 수 있도록 추상화하고 자동으로 설정해준다.

Routing

파일 구조 라우팅에 대해서는 가장 많이 알려져있다. redirect 메서드를 활용하여 리다이렉트가 가능하다. 이는 데이터 패칭 후 조건에 따라 리다이렉트 하는 방식으로 활용할 수 있다. 파일 구조 기반으로 다이나믹 라우팅이 가능하며, 404, 로딩, 에러 핸들링이 가능하다.

Data Fetching

사실  Next.js의 핵심이 데이터 패칭이고 이것때문에 App Router가 등장했다고 생각한다. 하지만 일이 커지니 다음 기회에..

Pages Router

 

getStaticProps, getServerSideProps, getInitialProps 데이터 패칭 API를 활용할 수 있다.

 

App Router

 

서버 컴포넌트, 클라이언트 컴포넌트에서 데이터 패칭을 할 수 있으며, 캐시를 설정할 수 있다. Suspense를 활용하여 연속적인 데이터 패칭을 할 수 있다.

Rendering

App Router

서버 컴포넌트, 클라이언트 컴포넌트, 다이나믹 렌더링을 설정할 수 있다.

Caching

Styling

css, tailwindCSS, Sass 등을 지원한다.

단, 현재 서버 컴포넌트 안에서 자바스크립트를 지원하지 않기 때문에 CSS-in-JS 라이브러리 사용은 주의해야한다. useServerInsertedHTML 훅을 활용하면 서버 사이드 렌더링이 되는 동안 CSS-in-JS의 스타일이 적용된다.

Optimizing

Image, Videos, Fonts, Metadata, Scripts 등을 최적화할 수 있다.

 

Next 좀 더 알아보기

Next.js에서 동적 가져오기를 사용한 코드 분할  |  Articles  |  web.dev

 

 

Next.js에서 동적 가져오기를 사용한 코드 분할  |  Articles  |  web.dev

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Next.js에서 동적 가져오기를 사용한 코드 분할 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 코드 분

web.dev

AMP가 Next.js 앱에서 빠른 속도를 보장하는 방법  |  Articles  |  web.dev

 

NextJS가 추가로 권장하는 사항들

Deploying: Production Checklist | Next.js (nextjs.org)