SSR은 왜 주목받고 있을까?

2022년 10월 6일

#front

현업에서 NextJS를 사용하는 비율이 점점 증가합니다.

NextJS를 쓰는 이유를 알아보기 이전에, 가장 큰 특징이라고 이야기할 수 있는 SSRCSR에 대해서 알면 좋을 것같습니다.

1. CSR VS SSR

CSR

먼저 Single Page Application. 즉, SPA는 한 개의 페이지로 이루어진 애플리케이션입니다.

SPA의 동작 방식은 하나의 페이지를 동적으로 변경하여 콘텐츠를 랜더링하는 방식입니다. 페이지 리로딩이 없이 자연스러운 화면 전환과 좋은 UX로 이어집니다.

과거 웹은 Multi Page Application으로 페이지를 갱신할 때마다 서버로부터 새로운 HTML을 요청했기에 비효율적이었습니다.

SPA는 필요한 영역의 데이터만 요청해 갱신할 수 있기 효율적입니다. 이처럼 클라이언트 영역에서 화면을 제어하는 랜더링을 Client Side Rendering, 즉 CSR라고 합니다.

  • 장점
    • 변경이 필요한 부분만 요청해 갱신할 수 있기에 UX가 좋다.
    • 전체 UI를 로드하지 않기 때문에 초기 로딩 이후에 대한 로드 시간이 빠르다.
  • 단점
    • 애플리케이션의 규모가 커지면, 초기에 로딩해야하는 자바스크립트 혹은 번들의 용량이 크다. 이는 초기 로딩의 성능 저하의 원인이 된다.
    • 검색 최적화 (SEO)가 어렵다. 현존하는 검색 엔진(구글봇 제외)은 JavaScript 코드 실행없이, 파싱된 HTML을 대상으로 크롤링하는데, SPA 동작 방식은 크롤링이 되기 어렵다.

SSR

Server Side Rendering, SSR은 서버에서 사용자에게 보여질 페이지를 모두 구성해 사용자에게 보여주는 방식입니다. MPA에서 사용했던 방식이라고 볼 수 있습니다.

SPA의 단점으로 초기 로딩 속도나 SEO의 어려움이 도출되었는데, 이는 SSR을 통해 극복할 수 있을 것입니다.

  • 장점
    • 서버에서 페이지의 구성을 마쳤기 때문에 SEO에 좋다. 검색 엔진이 JavaScript 코드를 실행하지 않아도 콘텐츠를 파싱할 수 있기 때문이다.
    • (보편적으로) 전체 콘텐츠를 구성되는 시점이 CSR에 비해 더 빠르다.
  • 단점
    • 서버는 항상 각 요청이 올때마다 HTML파일을 생성하기 때문에 CDN 수준에서의 컨텐츠 캐시가 되지 않는다.
    • 페이지 로드가 너무 오래걸린다면 오히려 UX를 해칠 수 있다. (ex. 데이터가 많은 대시보드, 통계 자료 등)

2. SPA(Single Page Application)에서 SSR이 필요한 이유는?

CSR과 SSR의 장단점은 상호보완적입니다. 따라서 CSR을 사용하되, SSR을 필요한 부분에 사용하여 단점을 보완하는 방식을 찾아야할 것 같습니다.

동적인 요소(JS)가 많거나, 초기페이지 구성이 오래걸릴 때(네트워크 느릴 때) SSR을 도입하여 페이지 콘텐츠 랜더링 완료 시점을 개선할 수 있습니다. 또한 SEO가 중요한 프로젝트에서 SSR을 사용해 검색엔진에 최적화할 수 있습니다.

CSR+SSR 방식을 API로 지원하는 프레임워크 NextJS도 등장하였습니다.


3. NextJS가 실행되면 무슨 일이 일어날까?

미션 : Next.js 프로젝트를 세팅한 뒤 yarn start 스크립트를 실행했을 때 실행되는 코드를 nextjs github 레포지토리에서 찾은 뒤, 해당 파일에 대한 간단한 설명을 첨부해주세요.

1. yarn start 후 (빌드 오류)
yarn run v1.22.10
$ next start
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
Error: Could not find a production build in the '~/.next' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id
    at NextNodeServer.getBuildId (~/node_modules/next/dist/server/next-server.js:169:23)
    at new Server (~/node_modules/next/dist/server/base-server.js:58:29)
    at new NextNodeServer (~/node_modules/next/dist/server/next-server.js:70:9)
    at NextServer.createServer (~/node_modules/next/dist/server/next.js:140:16)
    at async ~/node_modules/next/dist/server/next.js:149:31
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

프로젝트 셋팅 후 yarn start 명령어를 입력하면 가장 먼저 에러를 마주칩니다. Error: Could not find a production build in the '~/.next' 라는 메시지를 보아하면 Build 파일이 없다는 것을 알려줍니다.

에러 메시지를 통해 에러의 발생지를 알 수 있습니다.

~/node_modules/next/dist/server/next-server.js:169:23, ~/node_modules/next/dist/server/base-server.js:58:29, ~/node_modules/next/dist/server/next-server.js:70:9, ~/node_modules/next/dist/server/next.js:140:16, ~/node_modules/next/dist/server/next.js:149:31.

nextjs github 페이지의 경우, https://github.com/vercel/next.js/blob/canary/packages/next/server/next.ts 라고 합니다.

// ...
// next.js
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;
    }

// ...
// next.js
 async createServer(options) {
        if (options.dev) {
            const DevServer = require("./dev/next-dev-server").default;
            return new DevServer(options);
        }
        const ServerImplementation = await getServerImpl();
        return new ServerImplementation(options);
    }

오류 파일을 하나씩 타다보면 오류 메시지를 던진 위치를 파악할 수 있습니다.

// next-server.js
getBuildId() {
    const buildIdFile = (0, _path).join(this.distDir, _constants.BUILD_ID_FILE);
    try {
        return _fs.default.readFileSync(buildIdFile, "utf8").trim();
    } catch (err) {
        // 오류시, Error 메시지를 던져주는 부분
        if (!_fs.default.existsSync(buildIdFile)) {
            throw new Error(`Could not find a production build in the '${this.distDir}' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id`);
        }
        throw err;
    }
}
2. yarn start 후 (실행)
yarn run v1.22.10
$ next start
ready - started server on 0.0.0.0:3000, url: http://localhost:3000

다시 에러 메시지의 윗 부분을 보면 yarn start 를 실행했을 때 next start 가 실행되는 것을 볼 수 있습니다.

yarn start를 했는데 next start가 실행되는 걸까요?

이것은 package.json을 확인하면 알 수 있습니다.

// ...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
// ...

yarn start시 next start가 실행되는 것을 알 수 있습니다.

이 명령어에 대한 코드는 node_modules/next/dist/lib/commands.js 그리고 깃허브 페이지에서는 https://github.com/vercel/next.js/blob/canary/packages/next/lib/commands.ts 이곳에서 확인할 수 있습니다.

export const commands: { [command: string]: () => Promise<cliCommand> } = {
  // ...
  start: () => Promise.resolve(require("../cli/next-start").nextStart),
  // ...
};

코드를 살펴보면, ../cli/next-start 에서 무언가를 다시 불러오고 있습니다. 이번엔 ../cli/next-start 경로를 따라가봅시다.

node_modules/next/dist/cli/next-start이며 깃허브 페이지에서는 https://github.com/vercel/next.js/blob/canary/packages/next/cli/next-start.ts 입니다.

import { startServer } from "../server/lib/start-server";
//...
const nextStart = (argv) => {
  //...
  const keepAliveTimeout = keepAliveTimeoutArg
    ? Math.ceil(keepAliveTimeoutArg)
    : undefined;
  (0, _startServer)
    .startServer({
      dir,
      hostname: host,
      port,
      keepAliveTimeout,
    })
    .then(async (app) => {
      const appUrl = `http://${app.hostname}:${app.port}`;
      // 이 부분이 터미널에 찍히는 부분인 것을 알 수 있습니다!
      // ready - started server on 0.0.0.0:3000, url: http://localhost:3000
      Log.ready(`started server on ${host}:${app.port}, url: ${appUrl}`);
      await app.prepare();
    })
    .catch((err) => {
      console.error(err);
      process.exit(1);
    });
};

exports.nextStart = nextStart;

위의 코드를 통해 yarn start시 로그에 찍힌 ready - started server on 0.0.0.0:3000, url: http://localhost:3000 를 보내는 곳을 알게 되었습니다.

이 때도 startServer 를 import 해서 사용하는데요. ../server/lib/start-server의 로직을 마지막으로 살펴보겠습니다.

깃허브 페이지에서는 https://github.com/vercel/next.js/blob/canary/packages/next/server/lib/start-server.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

  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)
  })
}

해당 로직에서는 Promise를 반환하는 것을 알 수 있습니다.


Profile picture

주희(Joy)
가치를 고민하는 과정을 함께해요