원티드 프리온보딩 챌린지 7월 - 사전과제

KwakKwakKwak·2023년 6월 28일
1

Next.js

목록 보기
1/1
post-thumbnail

사전 과제

Assignment) 개인 블로그에 아래 질문에 대한 포스팅을 하고 링크를 제출해주세요.

  • CSR(Client-side Rendering)이란 무엇이며, 그것의 장단점에 대하여 설명해주세요.
  • SPA(Single Page Application)로 구성된 웹 앱에서 SSR(Server-side Rendering)이 필요한 이유에 대하여 설명해주세요.
  • Next.js 프로젝트에서 yarn start(or npm run start) 스크립트를 실행했을 때 실행되는 코드를 Next.js Github 레포지토리에서 찾은 뒤, 해당 파일에 대한 간단한 설명을 첨부해주세요.

    https://nextjs.org/docs/getting-started (Next.js 세팅 가이드)
    https://github.com/vercel/next.js/ (Next.js Github 레포지토리)
    _document.js, _app.js, getServerSideProps 같은 요소들에 대해 설명을 요구하는 과제가 아닙니다. 오히려 Next.js 코드 베이스 내부를 살펴보라는 의미입니다. 사전과제 여부나 제출된 과제 퀄리티가 수강 가능 여부 및 이후의 과정에 영향을 미치지는 않을 것이나, 3번 과제를 해보는 것이 큰 학습이 될 것이라고 확신합니다. 반드시 한번 살펴보시길 권장드립니다.

풀이

  1. CSR(Client-side Rendering)이란 무엇이며, 그것의 장단점에 대하여 설명해주세요.

    CSR은 Client-Side Rendering의 줄임말로 SSR(Server-Side Rendering)과 대비되는 랜더링 패러다임이다. 결국 렌더링을 어느 사이드에서 할 것인가?란 질문에 클라이언트 사이드와 서버 사이드로 나눠 접근하는 것인데, CSR은 렌더링을 클라이언트 사이드인 브라우저에서 담당하는 방식이고 SSR서버가 렌더링하여 전송하는 방식이다. 이를 이해하려면 클라이언트 사이드와 서버 사이드가 어떻게 다른지 먼저 이해해야 한다.

    웹페이지는 크게 클라이언트서버 두 부분으로 나누어져있다. 쉽게 설명하자면, 브라우저 상에 우리가 마우스와 키보드 등의 인풋 장치로 접근할 수 있는 '눈으로 보이는' 부분과, 상호작용이 발생했을 때 실제로 그 상호작용의 결과를 반영하고 보여주기 위해 뒷단에서 일어나는 '눈으로 보이지 않는' 부분이 바로 클라이언트서버인 것이다.

    우리가 브라우저를 통해 웹페이지에 접속할 때 웹페이지는 단순히 한순간에 팝 하고 생기지 않는다. 매우 짧은 시간에 웹페이지를 페인팅하기 위한 소스들을 서버로부터 받아오고 차례대로 뼈대를 조립한 다음 페인팅을 하여 우리 눈 앞에 보여지는 것이다. 그 과정을 흔히 Critical Render Path라고 일컫는다.

    Critical Render Path는 다음 이미지와 같은 과정으로 진행되며 원칙적으로 현재 띄워져있는 웹페이지의 요소 하나에 변경사항이 생길 때마다 이 Critical Render Path가 반복 실행된다.

    그런데 웹이 발전함에 따라, 정적 사이트에 interactive한 요소들이 추가되고 JavaScript 코드가 여럿 달리기 시작하면서 더이상 요소 하나가 변경될 때마다 페이지 전체를 로드하는 방식으로는 성능 개선이 어려워지기 시작했다. 더욱이 변경사항이 생길 때마다 페이지를 통째로 로드해오면 그 사이에 번쩍이는 하얀 화면을 맞닥뜨리게 되는데 사용자 경험 측면에서 굉장한 문제였다. 그래서 페이지 전체를 다시 로드해오는 것이 아니라, '필요한 부분'만 부분적으로 '동적으로' 로드해 전체 페이지는 유지하면서도 끊김없는 사용자 경험을 제공하는 방식을 고안하게 되는데 이것이 SPA(Single Page Application)이다(기존 방식은 MPA(Multi Page Application)).

    SPA 방식을 채택하면 거의 대부분 CSR 방식을 채택하게 되는데, 데이터를 부분적으로 서버에 요청하여 필요 부분만 브라우저에서 렌더링하면 되기 때문에 완성된 페이지를 주고받는 SSR보다 CSR이 선호됐다. CSR은 최초 요청 시 HTML, CSS, JS 등 리소스를 모두 다운받아 브라우저에서 렌더링을 실행한다. 따라서 한 번만 렌더링하면 그 다음부터는 별도의 리소스 요청 없이 데이터만 요청하기 때문에 서버 트래픽 부담이 적다. 또한 페이지 이동 시 전환속도가 빠르고 더 나은 사용자 경험을 제공할 수 있다.

    그러나 항상 모든 분야가 그렇듯, 여러 방법이 있으면 각각의 trade-off가 존재한다.

    • 느린 TTV(Time to View)와 TTI(Time to Interact)
      처리를 거쳐 렌더링이 가능한 HTML을 보내는 SSR과 달리 CSR은 그 과정을 브라우저에서 진행하기 때문에 JS를 실행하고 데이터를 불러와 채워넣기 전까지 사용자는 빈 페이지를 보게 된다. 이는 브라우저의 환경에 따라 천차만별의 접근시간을 가지게 된다. 인터넷이 잘 갖춰져 있는 환경에서는 문제가 없지만, 인터넷 환경이 취약한 곳에선 눈에 띄게 접근 시간이 늘어나게 되는 단점이 있다.
    • 취약한 검색 엔진 최적화
      검색 엔진은 자동화된 '크롤러'란 봇으로 웹사이트의 메타데이터를 읽고 이를 바탕으로 유저들에게 노출해주는데, 대다수의 검색 엔진 크롤러가 JS를 지원하지 않아 첫 렌더링을 브라우저에서 뒤늦게 실행하는 CSR 특성상 검색 경쟁에서 약점을 가진다. 구글 검색 엔진 크롤러는 JS를 지원하지만 국내 1위 포털인 네이버는 JS를 지원하지 않으므로 여전히 고려해야할 문제이다.
    • 보안에 취약하다
      CSR은 서버로부터 리소스를 제공받아 브라우저에서 모든 렌더링 및 실행을 수행하므로 보안에 취약하다. 어떤 서버에서 어떤 데이터를 받아오는지와 같은 정보나, 실행하는데 필요한 코드와 키 요소들이 클라이언트에 모아지므로 상대적으로 보안에 취약할 수 밖에 없다.
  2. SPA(Single Page Application)로 구성된 웹 앱에서 SSR(Server-side Rendering)이 필요한 이유에 대하여 설명해주세요.

    	인간은 항상 끊임없이 발전을 추구한다. 새로운 패러다임이 제시되어도, 금방 단점을 발견하며 놀랍게도 다른 방식과 결합하여 또 다른 방안을 제시한다. 웹 개발에서도 그렇다. `SPA` 방식이 웹 개발의 스탠다드가 되었지만, `CSR`의 단점을 극복하기 위해 여러 방식들을 고안하는데 이것이 바로 최근 `React` Scene에서 새로운 선두주자로 두각을 드러내는 `Next.js`의 이야기이다.

    앞서 말했듯 CSR은 사용자 경험 측면에서 혁신적인 장점을 가지고 있었으나 그에 못지 않은 단점들도 있었다. 첫 페이지 로딩 속도가 느리고, SEO 최적화의 문제를 가지고 있었다. 모든 환경이 최상의 컨디션을 갖추고 있진 않기 때문에, 좀 더 보편적으로 렌더링 방식을 최적화할 필요가 있었다.

    그래서 개발자들은 CSRSSR을 동시에 활용하는 하이브리드 방식을 생각해낸다. 이 방식을 고안한 배경은 다음과 같다.

    • 동적으로 렌더링해야 하는 페이지보다 정적 페이지의 수가 훨씬 많다.

    • 필요하지 않은 페이지까지 CSR로 처리하는 것은 낭비다.

      동적으로 처리해야하는 동적 페이지는 CSR로 처리하고, 그럴 필요가 없는 정적 사이트는 서버 사이드에서 렌더링하도록 '최적화'한 것이다.

    사실 서버 사이드 렌더링이 끝이 아니다. SSG(Static Site Generation)ISR(Incremental Site Regeneration) 방식으로 더 자세하게 나눌 수 있다. 서버에 요청이 들어올 때마다 페이지를 제작해서 보내는게 아니라, 애초에 빌드할 때부터 정적인 사이트를 미리 만들어놔 캐싱해두고 이를 재사용하는 SSG, 주기적으로 최신화하는 ISR 방식으로 세분화되는 것이다. Next.js는 기본적으로 SSG 방식을 권유하고, 필요시에 CSR / SSR 방식을 활용할 것을 권장한다.

    따라서 SPA로 구성된 웹 앱에서 서버 사이드 렌더링은 동적 요소가 없지만 최신 데이터가 반영된 사이트가 필요할 때 가장 효율적으로 웹페이지를 렌더링할 수 있는 좋은 방식이다.

  3. Next.js 프로젝트에서 yarn start(or npm run start) 스크립트를 실행했을 때 실행되는 코드를 Next.js Github 레포지토리에서 찾은 뒤, 해당 파일에 대한 간단한 설명을 첨부해주세요.

    우선 Next.js 프로젝트에서 npm run start 스크립트 실행 코드는 /src/cli/next-start 에 있었다.

//next.js/packages/next/src/cli/next-start.ts

#!/usr/bin/env node

import { startServer } from '../server/lib/start-server'
  ...

  await startServer({
    dir,
    isDev: false,
    hostname: host,
    port,
    keepAliveTimeout,
    useWorkers: !!config.experimental.appDir,
  })
}

export { nextStart }

위 코드를 요약하자면 결국 startServer 함수를 실행하기 위해 여러 값들을 설정하고 해당 함수로 넘겨준다.

// next.js/packages/next/src/server/lib/start-server.ts

...
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 = () => {}

  ...

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

위 코드는 start-server.ts 코드다. 필요한 부분만 옮겨적어봤는데도 양이 엄청나다. 대강 이해하기로 HTTP 서버를 생성하고 server.on() 메소드를 통해 지정된 포트에서 서버를 통해 클라이언트 요청을 리스닝하는 것 같았다. 그리고 teardown() 함수로 서버를 깔끔하게 종료시키는 것도 관찰할 수 있었다. 이를 좀 더 정확하게 이해하고 싶어 chatGPT에 질문해봤다.

그냥 단순히 코드 링크만 보냈을 뿐인데 원하는대로 해당 파일을 분석해줬다. 자세한 설명은 얻어내진 못했지만 핵심 기능은 잘 설명하는 듯 했다. 코드 중간에 worker라는 객체가 자주 등장했는데 이 객체가 하는 역할이 무엇인지 이해가 안돼 이 부분도 물어보았다.

더 자세한 내용은 경험이 부족하여 이해하기 어려웠지만, jest-worker가 Jest 테스트 프레임워크의 일부라는 지식을 얻었다.

Next.js 소스코드 구조를 살펴보면서 알게된 것은 Next.js가 단순히 소스코드만 있는 것이 아니라 서버까지 구동시키는 풀스택 앱이란 것이다. Next.js 자체 서버가 구현돼있어 소스코드를 브라우저에서 렌더링하는 React와 다르다는 것을 알게 되었다.

Next.js 스크립트 중 next dev 스크립트의 경우 빌드 이전 개발용 모드이기 때문에 핫 리로딩 기능이 지원되는 개발 서버 실행 코드였고, 과제로 제시된 next start가 애플리케이션이 배포되어 빌드된 파일을 바탕으로 앱을 실행시킬 수 있는 서버를 구동하는 코드였다. next dev의 경우 빌드 이전 상태이기 때문에 SSG를 지원하지 않지만, 프로덕션 모드인 next startSSG를 적극 활용한다는 점도 알게 되었다.


내가 사용하는 프레임워크의 소스코드를 직접 뜯어본 경험은 처음이었다. 소스코드 분석을 통해 조금이나마 이 프레임워크가 어떤 순서와 로직으로 구동되는지 알 수 있는 경험이었고, 덕분에 렌더링 패러다임에 대해 빠삭하게 알 수 있는 시간이었다!


추가) Next.js 프레임워크에 대한 인식

위 글을 쓸 때까지만 해도 Next.js는 Rendering에 특화된 'Advanced React Framework'로 생각했었다. 그런데 점심을 먹으며 개발바닥 유튜브를 시청하다가 향로님이 Next.js에 대해 많은 사람들이 오해하고 있는 점에 대해 짧막하게 언급해주신 걸 보고 내 Next.js에 대한 오해를 바로잡게 되었다.

https://json.media/blog/proper_understading_of_nextjs
아주 짧은 글인데, Next.js가 물론 SSR이란 강력한 기능을 제공하는 프레임워크임은 틀리지 않았으나, Next.js의 포지션 자체가 애초에 React란 경량화 라이브러리의 불안정성을 보완해주는 stable한 trusted partner, first-party급 framework이기 때문에 이 점을 더욱 중요하게 생각해야 한다는 내용이다.

이 글을 읽고 단번에 공감이 되었다. 마침 하루 전에 멋사 프론트엔드 기초 세션의 마지막 날에 부원들에게 React RouterReact Query를 알려주었는데, 특히 React Router의 경우 '이건 사실 Next.js 쓰면 안써도 되는데..'라는 생각이 들었고 또 React 프로젝트를 생산성있게 개발하려면 이런 서드파티 라이브러리들을 여러 개 알아야하고 잘 조합해야 개발 및 유지보수에 어려움이 없어지는 골치아픈 상황이 연출된다는 점이 은근한 피로로 다가온다는 점에서 실제 프로덕션 레벨에서는 이러한 불안정성이 훨씬 치명적으로 다가갈 수 있겠다는 생각이 들었다.

그래서 ReactNext.js 13버전을 발표했을 때 아예 발표회에 참석해 긴밀하게 협력하고 있음을 강조한 것도 자신들의 라이브러리의 약점을 메꿔줄 수 있는 유일한 길이 Next.js이기 때문이었을 것이다.

다시 한 번 Next.js와 사랑에 빠지지 않을 수가 없다!

0개의 댓글