[원티드 프리온보딩 프론트엔드 챌린지] 7월

Kangsick·2023년 6월 28일
0

CSR(Client-side Rendering)과 장단점

클라이언트 사이드 렌더링이란 클라이언트인 브라우저가 자바스크립트를 이용하여 동적으로 웹 페이지 렌더링을 처리하는 방식을 의미한다. 초기에 빈페이지가 로드되고 클라이언트에서 데이터를 요청하면 필요한 데이터만 백엔드 서버에 요청하여 데이터를 비동기적으로 받아와 렌더링을 한다.

  • 장점으로는 서버부하가 감소되어 비용이 저렴하고 호스팅하기 쉬우며, 후속페이지 로드시간이 SSR보다 빠르다. 또한 전체 페이지를 리로드할 필요없이 필요한 부분만 데이터 요청이 가능하여 SSR보다 로드가 빠르기 때문에 사용자 경험에서의 이점이 있다.

  • 단점으로는 초기페이지 로드시간이 SSR에 비해 느리며, SEO에 친화적이지 않다.

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

서버 사이드 렌더링이란 서버에서 렌더링을 할 준비를 마친후 HTML을 브라우저에게 보내는 방식을 의미한다.

  • SSR이 필요한 이유는 기존 CSR에서 단점인 초기 페이지 로드시간이 SSR보다 느린 문제를 해결할 수 있으며, SEO에 최적화 문제를 해결할 수 있기 때문이다. 초기 페이지 로딩이 느리면 UX 측면에서 불편함을 느끼고 이탈할 가능성이 높다. 그리고 SEO에 친화적이지 않는 CSR에와 달리, SSR은 SEO 최적화 문제를 해결할 수 있기 때문에 검색엔진에서의 노출과 경쟁 우위 확보, 유기적 트래픽 증가 등 다양한 이점들을 가져올 수 있다.

Next.js 프로젝트에서 yarn start(or npm run start) 스크립트를 실행했을 때 실행되는 코드

터미널에 npm run start를 실행하게 되면 아래와 같은 코드가 나온다.

$ npm run start                                                                                     
> nodejs-my-app@0.1.0 start
> next start

package.json 파일을 찾아보면 scripts에 start가 실행이 된다.

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
}

이 스크립트에 대해 설명을 하자면 dev는 개발모드에서 Next.js를 시작하는 실행이며 build는 프로덕션 용도로 애플리케이션을 빌드하는 실행, start는 프로덕션 서버를 시작하는 실행이다.
next start 명령어를 자세히 들어다 보기 위해 packages/next/src/cli/next-start.ts 파일을 확인해보았다.

#!/usr/bin/env node

import arg from 'next/dist/compiled/arg/index.js'
import { startServer } from '../server/lib/start-server'
import { getPort, printAndExit } from '../server/lib/utils'
import isError from '../lib/is-error'
import { getProjectDir } from '../lib/get-project-dir'
import { CliCommand } from '../lib/commands'
import { resolve } from 'path'
import { PHASE_PRODUCTION_SERVER } from '../shared/lib/constants'
import loadConfig from '../server/config'

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

    // 별칭
    '-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_OPTION') {
      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']
  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

  const config = await loadConfig(
    PHASE_PRODUCTION_SERVER,
    resolve(dir || '.'),
    undefined,
    undefined,
    true
  )
	
// 서버 실행
  await startServer({
    dir,
    isDev: false,
    hostname: host,
    port,
    keepAliveTimeout,
    useWorkers: !!config.experimental.appDir,
  })
}

export { nextStart }

이 파일에서 startServer를 이용하여 서버를 실행한다는 것을 알 수 있었다. import로 가져온 startServer의 코드를 있는 곳을 찾았다.
packages/next/src/server/lib/start-server.ts

import type { Duplex } from 'stream'
import type { IncomingMessage, ServerResponse } from 'http'
import type { ChildProcess } from 'child_process'

import http from 'http'
import { isIPv6 } from 'net'
import * as Log from '../../build/output/log'
import { normalizeRepeatedSlashes } from '../../shared/lib/utils'
import { initialEnv } from '@next/env'
import {
  genRouterWorkerExecArgv,
  getDebugPort,
  getNodeOptionsWithoutInspect,
} from './utils'

export interface StartServerOptions {
  dir: string
  prevDir?: string
  port: number
  isDev: boolean
  hostname: string
  useWorkers: boolean
  allowRetry?: boolean
  isTurbopack?: boolean
  isExperimentalTurbo?: boolean
  keepAliveTimeout?: number
  onStdout?: (data: any) => void
  onStderr?: (data: any) => void
}

type TeardownServer = () => Promise<void>

// 서버 실행을 위해 보내는 startServer 함수
export async function startServer({
  dir,
  prevDir,
  port,
  isDev,
  hostname,
  useWorkers,
  allowRetry,
  keepAliveTimeout,
  onStdout,
  onStderr,
}: StartServerOptions): Promise<TeardownServer> {
  const sockets = new Set<ServerResponse | Duplex>()
  let worker: import('next/dist/compiled/jest-worker').Worker | undefined
  let handlersReady = () => {}
  let handlersError = () => {}

  let isNodeDebugging: 'brk' | boolean = !!(
    process.execArgv.some((localArg) => localArg.startsWith('--inspect')) ||
    process.env.NODE_OPTIONS?.match?.(/--inspect(=\S+)?( |$)/)
  )

  if (
    process.execArgv.some((localArg) => localArg.startsWith('--inspect-brk')) ||
    process.env.NODE_OPTIONS?.match?.(/--inspect-brk(=\S+)?( |$)/)
  ) {
    isNodeDebugging = 'brk'
  }

  let handlersPromise: Promise<void> | undefined = new Promise<void>(
    (resolve, reject) => {
      handlersReady = resolve
      handlersError = reject
    }
  )
  let requestHandler = async (
    _req: IncomingMessage,
    _res: ServerResponse
  ): Promise<void> => {
    if (handlersPromise) {
      await handlersPromise
      return requestHandler(_req, _res)
    }
    throw new Error('Invariant request handler was not setup')
  }
  let upgradeHandler = async (
    _req: IncomingMessage,
    _socket: ServerResponse | Duplex,
    _head: Buffer
  ): Promise<void> => {
    if (handlersPromise) {
      await handlersPromise
      return upgradeHandler(_req, _socket, _head)
    }
    throw new Error('Invariant upgrade handler was not setup')
  }

  // setup server listener as fast as possible
	// 웹 서버 객체를 만들기 위해 사용하는 코드
  const server = http.createServer(async (req, res) => {
    try {
      if (handlersPromise) {
        await handlersPromise
        handlersPromise = undefined
      }
      sockets.add(res)
      res.on('close', () => sockets.delete(res))
      await requestHandler(req, res)
    } catch (err) {
      res.statusCode = 500
      res.end('Internal Server Error')
      Log.error(`Failed to handle request for ${req.url}`)
      console.error(err)
    }
  })

  if (keepAliveTimeout) {
    server.keepAliveTimeout = keepAliveTimeout
  }
  server.on('upgrade', async (req, socket, head) => {
    try {
      sockets.add(socket)
      socket.on('close', () => sockets.delete(socket))
      await upgradeHandler(req, socket, head)
    } catch (err) {
      socket.destroy()
      Log.error(`Failed to handle request for ${req.url}`)
      console.error(err)
    }
  })

  let portRetryCount = 0

  server.on('error', (err: NodeJS.ErrnoException) => {
    if (
      allowRetry &&
      port &&
      isDev &&
      err.code === 'EADDRINUSE' &&
      portRetryCount < 10
    ) {
      Log.warn(`Port ${port} is in use, trying ${port + 1} instead.`)
      port += 1
      portRetryCount += 1
      server.listen(port, hostname)
    } else {
      Log.error(`Failed to start server`)
      console.error(err)
      process.exit(1)
    }
  })

  let targetHost = hostname

  await new Promise<void>((resolve) => {
    server.on('listening', () => {
      const addr = server.address()
      port = typeof addr === 'object' ? addr?.port || port : port

      let host = !hostname || hostname === '0.0.0.0' ? 'localhost' : hostname

      let normalizedHostname = hostname || '0.0.0.0'

      if (isIPv6(hostname)) {
        host = host === '::' ? '[::1]' : `[${host}]`
        normalizedHostname = `[${hostname}]`
      }
      targetHost = host

      const appUrl = `http://${host}:${port}`

      if (isNodeDebugging) {
        const debugPort = getDebugPort()
        Log.info(
          `the --inspect${
            isNodeDebugging === 'brk' ? '-brk' : ''
          } option was detected, the Next.js proxy server should be inspected at port ${debugPort}.`
        )
      }

      Log.ready(
        `started server on ${normalizedHostname}${
          (port + '').startsWith(':') ? '' : ':'
        }${port}, url: ${appUrl}`
      )
      resolve()
    })
    server.listen(port, hostname)
  })

  try {
    if (useWorkers) {
      const httpProxy =
        require('next/dist/compiled/http-proxy') as typeof import('next/dist/compiled/http-proxy')

      let renderServerPath = require.resolve('./render-server')
      let jestWorkerPath = require.resolve('next/dist/compiled/jest-worker')

      if (prevDir) {
        jestWorkerPath = jestWorkerPath.replace(prevDir, dir)
        renderServerPath = renderServerPath.replace(prevDir, dir)
      }

      const { Worker } =
        require(jestWorkerPath) as typeof import('next/dist/compiled/jest-worker')

      const routerWorker = new Worker(renderServerPath, {
        numWorkers: 1,
        // TODO: do we want to allow more than 10 OOM restarts?
        maxRetries: 10,
        forkOptions: {
          execArgv: await genRouterWorkerExecArgv(
            isNodeDebugging === undefined ? false : isNodeDebugging
          ),
          env: {
            FORCE_COLOR: '1',
            ...((initialEnv || process.env) as typeof process.env),
            PORT: port + '',
            NODE_OPTIONS: getNodeOptionsWithoutInspect(),
            ...(process.env.NEXT_CPU_PROF
              ? { __NEXT_PRIVATE_CPU_PROFILE: `CPU.router` }
              : {}),
            WATCHPACK_WATCHER_LIMIT: '20',
          },
        },
        exposedMethods: ['initialize'],
      }) as any as InstanceType<typeof Worker> & {
        initialize: typeof import('./render-server').initialize
      }
      let didInitialize = false

      for (const _worker of ((routerWorker as any)._workerPool?._workers ||
        []) as {
        _child: ChildProcess
      }[]) {
        // eslint-disable-next-line no-loop-func
        _worker._child.on('exit', (code, signal) => {
          // catch failed initializing without retry
          if ((code || signal) && !didInitialize) {
            routerWorker?.end()
            process.exit(1)
          }
        })
      }

      const workerStdout = routerWorker.getStdout()
      const workerStderr = routerWorker.getStderr()

      workerStdout.on('data', (data) => {
        if (typeof onStdout === 'function') {
          onStdout(data)
        } else {
          process.stdout.write(data)
        }
      })
      workerStderr.on('data', (data) => {
        if (typeof onStderr === 'function') {
          onStderr(data)
        } else {
          process.stderr.write(data)
        }
      })

      const { port: routerPort } = await routerWorker.initialize({
        dir,
        port,
        hostname,
        dev: !!isDev,
        workerType: 'router',
        isNodeDebugging: !!isNodeDebugging,
        keepAliveTimeout,
      })
      didInitialize = true

      const getProxyServer = (pathname: string) => {
        const targetUrl = `http://${
          targetHost === 'localhost' ? '127.0.0.1' : targetHost
        }:${routerPort}${pathname}`
        const proxyServer = httpProxy.createProxy({
          target: targetUrl,
          changeOrigin: false,
          ignorePath: true,
          xfwd: true,
          ws: true,
          followRedirects: false,
        })

        proxyServer.on('error', (_err) => {
          // TODO?: enable verbose error logs with --debug flag?
        })
        return proxyServer
      }

      // proxy to router worker
      requestHandler = async (req, res) => {
        const urlParts = (req.url || '').split('?')
        const urlNoQuery = urlParts[0]

        // this normalizes repeated slashes in the path e.g. hello//world ->
        // hello/world or backslashes to forward slashes, this does not
        // handle trailing slash as that is handled the same as a next.config.js
        // redirect
        if (urlNoQuery?.match(/(\\|\/\/)/)) {
          const cleanUrl = normalizeRepeatedSlashes(req.url!)
          res.statusCode = 308
          res.setHeader('Location', cleanUrl)
          res.end(cleanUrl)
          return
        }
        const proxyServer = getProxyServer(req.url || '/')

        // http-proxy does not properly detect a client disconnect in newer
        // versions of Node.js. This is caused because it only listens for the
        // `aborted` event on the our request object, but it also fully reads
        // and closes the request object. Node **will not** fire `aborted` when
        // the request is already closed. Listening for `close` on our response
        // object will detect the disconnect, and we can abort the proxy's
        // connection.
        proxyServer.on('proxyReq', (proxyReq) => {
          res.on('close', () => proxyReq.destroy())
        })
        proxyServer.on('proxyRes', (proxyRes) => {
          res.on('close', () => proxyRes.destroy())
        })

        proxyServer.web(req, res)
      }
      upgradeHandler = async (req, socket, head) => {
        const proxyServer = getProxyServer(req.url || '/')
        proxyServer.ws(req, socket, head)
      }
      handlersReady()
    } else {
      // when not using a worker start next in main process
      // 작업자가 사용하지 않을때, next 참조
      const next = require('../next') as typeof import('../next').default
      const addr = server.address()
			
      const app = next({
        dir,
        hostname,
        dev: isDev,
        isNodeDebugging,
        httpServer: server,
        customServer: false,
        port: addr && typeof addr === 'object' ? addr.port : port,
      })
      // handle in process
      requestHandler = app.getRequestHandler()
      upgradeHandler = app.getUpgradeHandler()
      await app.prepare()
      handlersReady()
    }
  } catch (err) {
    // fatal error if we can't setup
    handlersError()
    console.error(err)
    process.exit(1)
  }

  // return teardown function for destroying the server
  async function teardown() {
    server.close()
    sockets.forEach((socket) => {
      sockets.delete(socket)
      socket.destroy()
    })

    if (worker) {
      await worker.end()
    }
  }
  return teardown
}

모든 node 웹 서버 애플리케이션은 웹 서버 객체를 만들어야된다고 node js 공식사이트에 설명이 있다. 웹 서버 객체는 createServer를 이용하는데, 위에 코드에서 server라는 변수에 할당을 하고 있다.

profile
성장하는 프론트엔드 개발자의 길

0개의 댓글