CSR / SSR with Next.js

성택·2022년 9월 27일
0
post-thumbnail

CSR(Client-side Rendering)과 SSR(Server-side Rendering)의 차이를 명확히 알려면,
우선 SPA(Single page Application)의 개념을 이해하고 넘어가는게 좋을 것 같다.

SPA란? (<=> MPA : Multi page application)

용어 그대로 하나의 페이지를 가진 어플리케이션이다. 초기 웹에서는 여러 개의 HTML 문서들이 서버에 있으면, 페이지가 바뀔 때마다 HTML 문서들을 받아와서 유저에게 보여주는 Static Sites 구조였다. 이는 페이지가 바뀜에 따라 전체 문서가 재렌더링되는 다소 비효율적인 방식이었다.
이러한 전통적인 방식은 fetch와 Ajax가 등장한 이후 전체 문서가 아니라 필요한 데이터만 동적으로 받아올 수 있는 SPA 방식으로 점차 변화하였다. 오늘날 많은 사람들이 사용하는 리액트, 뷰, 스벨트 등 다양한 프레임워크들이 이러한 SPA 방식을 따라 사용되고 있다. SPA은 기본적으로 CSR 방식으로 사용되었는데, 필요에 따라 SSR 방식으로도 사용될 수도 있다.

1. CSR(Client-side Rendering)의 장단점

CSR은 client-side의 자바스크립트가 모든 UI를 만드는 것을 의미한다.
브라우저가 처음 HTML을 가지고 올 때 빈 HTML 태그와 자바스크립트 링크만 가지고 와서, 유저가 처음 접속하면 빈 화면만 보게 된다. 이후 링크된 자바스크립트 파일을 가져와 다운이 완료되면 자바스크립트에 의해 UI가 그려지고 로직들이 동작하게 된다.
만약 브라우저에서 자바스크립트가 비활성화된다면 유저는 <noscript> 라는 빈 태그만 보게 된다.

  • 장점
    페이지 이동 시 빠른 이동과 더 나은 사용자 경험을 제공한다.
  • 단점
    자바스크립트를 다운받는 동안 유저는 빈 화면만 볼 수 있다.
    HTML 파일이 비어있어 SEO가 좋지 않다.

2. SPA로 구성된 웹 앱에서 SSR(Server-side Rendering)이 필요한 이유

SSR은 클라이언트에서 모든 것을 처리하던 CSR과 달리 서버에서 필요한 데이터를 모두 가져오는 방식으로, 서버에서 모든 렌더링을 마치고 일부 Data와 결합된 HTML 파일을 가져오는 방식이다. 모든 자바스크립트 파일을 다운받아 렌더링하는 CSR과 달리 서버에서 미리 렌더링되어 있는 HTML을 받아오고, 이후에는 동적으로 페이지를 구성하기 때문에 CSR보다 첫 번째 페이지의 로딩이 더 빠르다. 또한 HTML에 정보들이 있기 때문에 CSR 방식보다 SEO에 유리하다는 장점도 있다.

  • 장점
    첫 페이지 로딩이 빠르다.
    완성된 HTML 파일을 받아오기 때문에 SEO가 좋다.
  • 단점
    새로운 페이지로 이동할 때마다 서버에 요청해서 페이지를 받기 때문에, 깜박임 현상이 있다.
    서버에 과부하가 걸릴 수 있다.
    뷰는 볼 수 있어도, 기능이 동작하지 않는 경우가 발생할 수 있다. (TIV는 짧지만, TTI가 길 수 있다.)

CSR과 SSR은 각각의 장단점을 가지고 있고, 개발자는 필요에 따라 자신에게 맞는 방식을 사용하는 것이 좋다. CSR과 SSR 뿐 아니라 SSG 방식과 ISR 방식도 있다.
Next.js는 리액트 안에서 hybrid static, SSR을 쉽게 사용할 수 있도록 하는 프레임워크다.

3. Next.js 프로젝트를 세팅한 뒤 yarn start 스크립트를 실행했을 때

Next.js 를 시작하는 방법은 Next.js 공식 홈페이지에 잘 설명되어 있다.
가장 쉬운 방법은 create-next-app 을 사용하는 것이다.

npx create-next-app@latest
# or
yarn create next-app
# or
pnpm create next-app

타입 스크립트를 사용한다면 --ts 와 함께 사용하면 된다.

npx create-next-app@latest --ts
# or
yarn create next-app --typescript
# or
pnpm create next-app --ts

생성된 프로젝트에서 yarn start 스크립트를 실행하면 어떻게 되는지 코드를 통해 알아보자

yarn start 를 실행하면 어떻게 되는지 찾기 위해서 next.js 레포지토리에서 command.ts 파일을 찾았다.

https://github.com/vercel/next.js/blob/canary/packages/next/lib/commands.ts

export type cliCommand = (argv?: string[]) => void

export const commands: { [command: string]: () => Promise<cliCommand> } = {
  build: () => Promise.resolve(require('../cli/next-build').nextBuild),
  start: () => Promise.resolve(require('../cli/next-start').nextStart),
  export: () => Promise.resolve(require('../cli/next-export').nextExport),
  dev: () => Promise.resolve(require('../cli/next-dev').nextDev),
  lint: () => Promise.resolve(require('../cli/next-lint').nextLint),
  telemetry: () =>
    Promise.resolve(require('../cli/next-telemetry').nextTelemetry),
  info: () => Promise.resolve(require('../cli/next-info').nextInfo),
}

여러 가지 커맨드가 모여져 있는데 그 중 start 명령어는 next-start 파일에서 nextStart를 호출하는 것을 알 수 있었다.

cli 폴더를 찾아 next-start.ts 파일을 살펴보았다.

https://github.dev/vercel/next.js/blob/canary/packages/next/cli/next-start.ts

import arg from 'next/dist/compiled/arg/index.js'
import { startServer } from '../server/lib/start-server'
import { getPort, printAndExit } from '../server/lib/utils'
import * as Log from '../build/output/log'
import isError from '../lib/is-error'
import { getProjectDir } from '../lib/get-project-dir'
import { cliCommand } from '../lib/commands'

const nextStart: cliCommand = (argv) => {
  const validArgs: arg.Spec = {
    // Types
    '--help': Boolean,
    '--port': Number,
    '--hostname': String,
    '--keepAliveTimeout': Number,

    // Aliases
    '-h': '--help',
    '-p': '--port',
    '-H': '--hostname',
  }
  let args: arg.Result<arg.Spec>
  try {
    args = arg(validArgs, { argv })
  } catch (error) {
    if (isError(error) && error.code === 'ARG_UNKNOWN_OP.TION') {
      return printAndExit(error.message, 1)
    }
    throw error
  }
  if (args['--help']) {
    console.log(`
      Description
        Starts the application in production mode.
        The application should be compiled with \`next build\` first.

      Usage
        $ next start <dir> -p <port>

      <dir> represents the directory of the Next.js application.
      If no directory is provided, the current directory will be used.

      Options
        --port, -p      A port number on which to start the application
        --hostname, -H  Hostname on which to start the application (default: 0.0.0.0)
        --keepAliveTimeout  Max milliseconds to wait before closing inactive connections
        --help, -h      Displays this message
    `)
    process.exit(0)
  }

  const dir = getProjectDir(args._[0])
  const host = args['--hostname'] || '0.0.0.0'
  const port = getPort(args)

  const keepAliveTimeoutArg: number | undefined = args['--keepAliveTimeout']
  if (
    typeof keepAliveTimeoutArg !== 'undefined' &&
    (Number.isNaN(keepAliveTimeoutArg) ||
      !Number.isFinite(keepAliveTimeoutArg) ||
      keepAliveTimeoutArg < 0)
  ) {
    printAndExit(
      `Invalid --keepAliveTimeout, expected a non negative number but received "${keepAliveTimeoutArg}"`,
      1
    )
  }

  const keepAliveTimeout = keepAliveTimeoutArg
    ? Math.ceil(keepAliveTimeoutArg)
    : undefined

  startServer({
    dir,
    hostname: host,
    port,
    keepAliveTimeout,
  })
    .then(async (app) => {
      const appUrl = `http://${app.hostname}:${app.port}`
      Log.ready(`started server on ${host}:${app.port}, url: ${appUrl}`)
      await app.prepare()
    })
    .catch((err) => {
      console.error(err)
      process.exit(1)
    })
}

export { nextStart }

중간 중간 추가적인 커맨드를 구성하는 코드들이 있었는데, 마지막으로 startServer 함수를 호출한다.

--help 커맨드에 달려있는 주석을 보면 next build 를 먼저 선행해야 한다고 되어있다.

실제로 테스트를 위해 생성한 next 프로젝트에서 아무것도 하지 않고 곧바로 next start 를 입력하면 에러가 발생하는 것을 알 수 있었다.

yarn build를 해주면 .next 폴더가 생성되면서 서버를 시작하기 위한 준비가 끝난다.

이 상태에서 yarn start 를 실행하면 서버가 켜진다. startServer는 어떤 식으로 동작하는지 한 번 살펴보았다.

https://github.dev/vercel/next.js/blob/canary/packages/next/cli/next-start.ts

import type { NextServerOptions, NextServer, RequestHandler } from '../next'
import { warn } from '../../build/output/log'
import http from 'http'
import next from '../next'

interface StartServerOptions extends NextServerOptions {
  allowRetry?: boolean
  keepAliveTimeout?: number
}

export function startServer(opts: StartServerOptions) {
  let requestHandler: RequestHandler

  // http 모듈의 createServer 를 이용해서 웹 서버 객체를 만듦
  const server = http.createServer((req, res) => {
    return requestHandler(req, res)
  })

  if (opts.keepAliveTimeout) {
    server.keepAliveTimeout = opts.keepAliveTimeout
  }

  return new Promise<NextServer>((resolve, reject) => {
    let port = opts.port
    let retryCount = 0

    server.on('error', (err: NodeJS.ErrnoException) => {
      if (
        port &&
        opts.allowRetry &&
        err.code === 'EADDRINUSE' &&
        retryCount < 10
      ) {
        warn(`Port ${port} is in use, trying ${port + 1} instead.`)
        port += 1
        retryCount += 1
        server.listen(port, opts.hostname)
      } else {
        reject(err)
      }
    })

    let upgradeHandler: any

    if (!opts.dev) {
      server.on('upgrade', (req, socket, upgrade) => {
        upgradeHandler(req, socket, upgrade)
      })
    }

    server.on('listening', () => {
      const addr = server.address()
      const hostname =
        !opts.hostname || opts.hostname === '0.0.0.0'
          ? 'localhost'
          : opts.hostname

      const app = next({
        ...opts,
        hostname,
        customServer: false,
        httpServer: server,
        port: addr && typeof addr === 'object' ? addr.port : port,
      })

      requestHandler = app.getRequestHandler()
      upgradeHandler = app.getUpgradeHandler()
      resolve(app)
    })

    server.listen(port, opts.hostname)
  })
}

startServer에서는 http.create() 를 통해 웹 서버 객체를 만들고, 프로미스를 반환하는데 이 프로미스 객체를 보니 에러가 나는 경우, 업그레이드가 필요한 경우, 서버가 정상적으로 작동하는 경우로 나누어져 있었다.

서버가 정상적으로 켜지는 경우는

      const app = next({
        ...opts,
        hostname,
        customServer: false,
        httpServer: server,
        port: addr && typeof addr === 'object' ? addr.port : port,
      })
      ...
	resolve(app)

이 코드로 보이는데, next() 가 무엇인지 살펴보러 next.ts 파일을 봤다.

https://github.com/vercel/next.js/blob/canary/packages/next/server/next.ts

코드가 조금 길긴한데, 읽어보니 render 메서드에서 this.getServer().renderToHTML(...args) 를 반환하고 있었다.

  async renderToHTML(...args: Parameters<Server['renderToHTML']>) {
    const server = await this.getServer()
    return server.renderToHTML(...args)
  }

getServer 메서드에서는 this.serverPromise를 반환하는데 코드를 보니까 createServer로 서버를 만들어서 serverPromise 에 할당하는 듯 했다.

  private async getServer() {
    if (!this.serverPromise) {
      setTimeout(getServerImpl, 10)
      this.serverPromise = this.loadConfig().then(async (conf) => {
        this.server = await this.createServer({
          ...this.options,
          conf,
        })
        if (this.preparedAssetPrefix) {
          this.server.setAssetPrefix(this.preparedAssetPrefix)
        }
        return this.server
      })
    }
    return this.serverPromise
  }

createServer 메서드

  private async createServer(options: DevServerOptions): Promise<Server> {
    if (options.dev) {
      const DevServer = require('./dev/next-dev-server').default
      return new DevServer(options)
    }
    const ServerImplementation = await getServerImpl()
    return new ServerImplementation(options)
  }

getServierImpl() 로 새 객체를 만들어서 반환하는 것 같은데... getServerImpl() 은 또 next-server.ts에서 가져오는 모양.

https://github.com/vercel/next.js/blob/canary/packages/next/server/next-server.ts

import { RenderOpts, renderToHTML } from './render'

export default class NextNodeServer extends BaseServer {
	// ...
	protected async renderHTML(
    req: NodeNextRequest,
    res: NodeNextResponse,
    pathname: string,
    query: NextParsedUrlQuery,
    renderOpts: RenderOpts
  ): Promise<RenderResult | null> {
    // Due to the way we pass data by mutating `renderOpts`, we can't extend the
    // object here but only updating its `serverComponentManifest` field.
    // https://github.com/vercel/next.js/blob/df7cbd904c3bd85f399d1ce90680c0ecf92d2752/packages/next/server/render.tsx#L947-L952
    renderOpts.serverComponentManifest = this.serverComponentManifest
    renderOpts.serverCSSManifest = this.serverCSSManifest
    renderOpts.fontLoaderManifest = this.fontLoaderManifest

    if (this.hasAppDir && renderOpts.isAppPath) {
      return appRenderToHTMLOrFlight(
        req.originalRequest,
        res.originalResponse,
        pathname,
        query,
        renderOpts
      )
    }

    return renderToHTML(
      req.originalRequest,
      res.originalResponse,
      pathname,
      query,
      renderOpts
    )
  }

next-server.ts 파일의 NextNodeServer 클래스에서 renderHTML이라는 메서드를 볼 수 있었는데, 반환값이renderToHTML()이고, 이게 또 render.tsx에서 가져온다;;

https://github.com/vercel/next.js/blob/canary/packages/next/server/render.tsx

renderToHTML 을 보다보니 조금 익숙한 것들이 보인다.

export async function renderToHTML(
...
	// Component will be wrapped by ServerComponentWrapper for RSC
  let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) =
    renderOpts.Component

React Server Component를 구성하는 JSX.Element 발견.

  const AppContainerWithIsomorphicFiberStructure: React.FC<{
    children: JSX.Element
  }> = ({ children }) => {
    return (
      <>
        {/* <Head/> */}
        <Noop />
        <AppContainer>
          <>
            {/* <ReactDevOverlay/> */}
            {dev ? (
              <>
                {children}
                <Noop />
              </>
            ) : (
              children
            )}
            {/* <RouteAnnouncer/> */}
            <Noop />
          </>
        </AppContainer>
      </>
    )
  }

리액트 엘리먼트를 반환하는 AppContainerWithIsomorphicFiberStructurectx 에서 활용되는 것을 볼 수 있다.

  const ctx = {
    err,
    req: isAutoExport ? undefined : req,
    res: isAutoExport ? undefined : res,
    pathname,
    query,
    asPath,
    locale: renderOpts.locale,
    locales: renderOpts.locales,
    defaultLocale: renderOpts.defaultLocale,
    AppTree: (props: any) => {
      return (
        <AppContainerWithIsomorphicFiberStructure>
          {renderPageTree(App, OriginComponent, { ...props, router })}
        </AppContainerWithIsomorphicFiberStructure>
      )
    },
    defaultGetInitialProps: async (
      docCtx: DocumentContext,
      options: { nonce?: string } = {}
    ): Promise<DocumentInitialProps> => {
      const enhanceApp = (AppComp: any) => {
        return (props: any) => <AppComp {...props} />
      }

      const { html, head: renderPageHead } = await docCtx.renderPage({
        enhanceApp,
      })
      const styles = jsxStyleRegistry.styles({ nonce: options.nonce })
      jsxStyleRegistry.flush()
      return { html, head: renderPageHead, styles }
    },
  }
  let props: any
  ...
  props = await loadGetInitialProps(App, {
    AppTree: ctx.AppTree,
    Component,
    router,
    ctx,
  })

그리고 이 ctxloadGetInitialProps 에서 다시 활용되는데 이름을 보니까 초기값을 가져오는 듯

    try {
      data = await getServerSideProps({
        req: req as IncomingMessage & {
          cookies: NextApiRequestCookies
        },
        res: resOrProxy,
        query,
        resolvedUrl: renderOpts.resolvedUrl as string,
        ...(pageIsDynamic ? { params: params as ParsedUrlQuery } : undefined),
        ...(previewData !== false
          ? { preview: true, previewData: previewData }
          : undefined),
        locales: renderOpts.locales,
        locale: renderOpts.locale,
        defaultLocale: renderOpts.defaultLocale,
      })
      canAccessRes = false
    } catch (serverSidePropsError: any) {
      // remove not found error code to prevent triggering legacy
      // 404 rendering
      if (
        isError(serverSidePropsError) &&
        serverSidePropsError.code === 'ENOENT'
      ) {
        delete serverSidePropsError.code
      }
      throw serverSidePropsError
    }

서버사이드 렌더링을 하는 코드로 보인다.

  const renderDocument = async () => {
    async function loadDocumentInitialProps(
      renderShell?: (
        _App: AppType,
        _Component: NextComponentType
      ) => Promise<ReactReadableStream>
    ) {
      const renderPage: RenderPage = async (
        options: ComponentsEnhancer = {}
      ): Promise<RenderPageResult> => {
        ...
       const html = await renderToString(
          <Body>
            <AppContainerWithIsomorphicFiberStructure>
              {renderPageTree(EnhancedApp, EnhancedComponent, {
                ...props,
                router,
              })}
            </AppContainerWithIsomorphicFiberStructure>
          </Body>
        )
        return { html, head }
      }
    ...
      let styles
      if (hasDocumentGetInitialProps) {
        styles = docProps.styles
        head = docProps.head
      } else {
        styles = jsxStyleRegistry.styles()
        jsxStyleRegistry.flush()
      }

      return {
        bodyResult,
        documentElement,
        head,
        headTags: [],
        styles,
      }
    }
  }

드디어 페이지를 렌더링하는 코드 발견. renderToString 메서드를 쓰는 것을 볼 수 있는데 파일 위 쪽으로 가면 이 메서드를 볼 수 있다.

async function renderToString(element: React.ReactElement) {
  if (!shouldUseReactRoot) return ReactDOMServer.renderToString(element)
  const renderStream = await ReactDOMServer.renderToReadableStream(element)
  await renderStream.allReady
  return streamToString(renderStream)
}

async function renderToStaticMarkup(element: React.ReactElement) {
  if (!shouldUseReactRoot) return ReactDOMServer.renderToStaticMarkup(element)
  return renderToString(element)
}

코드를 보니 서버에 있는 ReactDOM 요소를 string으로 변환해서 렌더링하는 코드로 보인다.

yarn start 를 하면 결국 서버를 실행하고, 서버에 있는 ReactDOM의 초기 렌더링 값을 HTML형태의 string으로 반환해서 렌더링하는 것으로 보인다.

처음으로 프레임워크의 코드를 뜯어 보았는데 이해가 안되는 코드들도 많고 우선 코드량이 엄청나다... 이런 코드들을 어떻게 다 만들 수 있었는지 참.

나도 언젠가 저런 것들을 만들어야지!

profile
frontend developer

0개의 댓글