jineecode

프리온보딩 프론트엔드 챌린지 3차 본문

챌린지

프리온보딩 프론트엔드 챌린지 3차

지니코딩 2022. 9. 16. 10:53

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

  • 자바스크립트를 이용하여 브라우저에서 직접 페이지를 렌더링하는 것을 의미한다.

장점:
일단 로드되고 나면 사이트 내에서 돌아다닐 때 로드되는 과정이 없어지므로 사용성이 좋아진다.
서버를 호출할 때마다 전체 UI를 다시 로드할 필요가 없다.

단점:
애플리케이션 규모가 커질수록 JS양이 증가하여 페이지가 다른 면에서 무거워질 수 있다.
초기 페이지 로드에 더 많은 시간이 걸린다.
SEO에 친화적이지 않다.

SPA(Single Page Application)로 구성된 웹 앱에서 SSR(Server-side Rendering)이 필요한 이유에 대하여 설명해주세요.

CSR의 단점을 극복하며 SPA의 장점도 가져갈 수 있다.

  • SSR을 하므로 SEO에 친화적이지 않은 CSR의 단점을 극복할 수 있다.
  • 초기 페이지 로드가 빨라진다.
  • SPA로 구성되어 있으므로 html을 새로고침하지 않아도 된다.

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

yarn start

SSR은 서버에서 사용자에게 보여줄 html을 모두 구성하여 사용자에게 페이지를 보여주는 방식이라는 것을 인지하고 천천히 확인해 보았다.
Next.js를 create 하면 '_app.jsx'을 확인할 수 있다.

import "../styles/globals.css"
import type { AppProps } from "next/app"

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

export default MyApp

MyApp은 ComponentpageProps를 props로 받는다.

  • Component: 요청한 페이지. /pages/index.jsx 파일이 props로 들어온다. index.jsx를 삭제하면 404 Error 페이지가 뜬다.
  • pageProps: getInitialProps를 통해 내려 받은 props 이다.

Nextjs repo

이제 레포지토리를 확인해보자

cli

yarn start를 하므로 cli를 가장 먼저 확인해보았다.

  1. packages/next/cli/next-start.ts
import { startServer } from "../server/lib/start-server"
import arg from "next/dist/compiled/arg/index.js"

// next Start
const nextStart: cliCommand = argv => {
  const validArgs: arg.Spec = {
    // Types
    "--help": Boolean,
    // ...
  }

  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
  }

  // 서버에서 쓰이는 변수들
  const dir = getProjectDir(args._[0])
  const host = args["--hostname"] || "0.0.0.0"
  const port = getPort(args)
  const keepAliveTimeoutArg: number | undefined = args["--keepAliveTimeout"]

  // ...

  // 서버 실행
  startServer({
    // startServer:  https://github.com/vercel/next.js/blob/canary/packages/next/server/lib/start-server.ts
    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() // app 구축 준비!
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}

서버에 필요한 변수를 준비하고 서버를 스타트하는 것 같다.
startServer가 어떻게 서버구축을 해주고 있는지 찾아보았다.

server

  1. packages/next/server/lib/start-server.ts
import type { NextServerOptions, NextServer, RequestHandler } from "../next"
import next from "../next"

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

  const server = http.createServer((req, res) => {
    return requestHandler(req, res)
  })

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

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

      const app = next({
        // 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)
  })
}

next가 어디에 있는지 찾아보있다.

  1. packages/next/server/next.ts
export class NextServer {
  constructor(options) {
    this.options = options
  }

  get hostname() {
    return this.options.hostname
  }
  get port() {
    return this.options.port
  }

  getRequestHandler(): RequestHandler {
    return async (
      req: IncomingMessage,
      res: ServerResponse,
      parsedUrl?: UrlWithParsedQuery
    ) => {
      const requestHandler = await this.getServerRequestHandler()
      return requestHandler(req, res, parsedUrl)
    }
  }

  getUpgradeHandler() {
    return async (req: IncomingMessage, socket: any, head: any) => {
      const server = await this.getServer()
      // @ts-expect-error we mark this as protected so it
      // causes an error here
      return server.handleUpgrade.apply(server, [req, socket, head])
    }
  }

  // ...

  async prepare() {
    const server = await this.getServer()
    return server.prepare()
  }

  // ...

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

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

여기서부터 너무 아득해졌는데 NextServer라고 Class명을 지은 거 보니 next에서 뭔가 nextServer를 만드는 것(create) 같았다.

  1. 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> {
    renderOpts.serverComponentManifest = this.serverComponentManifest
    renderOpts.serverCSSManifest = this.serverCSSManifest
    renderOpts.fontLoaderManifest = this.fontLoaderManifest

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

점점 아득해져가는데 눈에 들어오는 class를 데려와봤다. NextNodeServer class 안에 renderHTML을 발견했다.
뭔가 여기서 SSR의 느낌이 나서 render 해주는 파일을 찾아 들어가봤다.

  1. packages/next/server/render.tsx
    renderToHTML을 찾아서 까봤다...
export async function renderToHTML() {
  // ...
  const AppContainer = ({ children }: { children: JSX.Element }) => (...)

  const AppContainerWithIsomorphicFiberStructure: React.FC<{...}> = ({children}) => {return(...)}

  const Body = ({ children }: { children: JSX.Element }) => {
    // 개발자 도구에서 본 듯한 DOM 발견!
    return inAmpMode ? children : <div id="__next">{children}</div>;
  };

  const renderDocument = async () => {
      // Document를 render해주는 함수를 발견

    async function loadDocumentInitialProps(
      renderShell?: (
        _App: AppType,
        _Component: NextComponentType
      ) => Promise<ReactReadableStream>
    ) {
      const renderPage: RenderPage = (
        options: ComponentsEnhancer = {}
      ): RenderPageResult | Promise<RenderPageResult> => {
    // ...

        const html = ReactDOMServer.renderToString(
          <Body>
            <AppContainerWithIsomorphicFiberStructure>
              {renderPageTree(EnhancedApp, EnhancedComponent, {
                ...props,
                router,
              })}
            </AppContainerWithIsomorphicFiberStructure>
          </Body>
        );

    // ...
  }

  const documentResult = await renderDocument();

  const htmlProps: HtmlProps = {...};

  const document = (
    <AmpStateContext.Provider value={ampState}>
      <HtmlContext.Provider value={htmlProps}>
        {documentResult.documentElement(htmlProps)}
      </HtmlContext.Provider>
    </AmpStateContext.Provider>
  );

  const documentHTML = ReactDOMServer.renderToStaticMarkup(document);

  const [renderTargetPrefix, renderTargetSuffix] = documentHTML.split(
    "<next-js-internal-body-render-target></next-js-internal-body-render-target>"
  );
}

코드를 모두 이해할 수는 없었지만, 서버에서 HTML를 만들어가는 과정이 여기서 일어나고 있다는 것이 느껴졌다.
특히 const html = ReactDOMServer.renderToString 이 부분이 핵심이었다.

이제 클라이언트 파일을 확인해보기로 했다.

client

  1. packages/next/client/next.js
import { initialize, hydrate, version, router, emitter } from "./"

initialize({})
  .then(() => hydrate())
  .catch(console.error)

initialize()이후 hydrate()를 실행하고 있다.
./ 경로를 찾아보았다.

  1. packages/next/client/index.tsx
export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{...}> {}

function renderApp(App: AppComponent, appProps: AppProps){...}

function renderReactElement(){}

function doRender(input: RenderRouteInfo): Promise<any> {...}

async function render(renderingProps: RenderRouteInfo): Promise<void> {...}

export async function hydrate(opts?: { beforeRender?: () => Promise<void> }) {...}

next.js 파일 에서 보았던 initialize 함수와 hydrate 함수와 더불어 무언가를 render하는 것 같은 함수를 가져와봤다.

  // next/client/index.jsx
  export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
  assetPrefix: string
  }> {
  initialData = JSON.parse(
  document.getElementById("**NEXT_DATA**")!.textContent!
  )
  window.**NEXT_DATA** = initialData
  }

__NEXT_DATA__ id를 가지고 있는 엘리먼트를 parse해서 initialData에 할당하고 있다.

  // next/client/index.jsx
  export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
  assetPrefix: string
  }> {
  initialData = JSON.parse(
  document.getElementById("**NEXT_DATA**")!.textContent!
  )
  window.**NEXT_DATA** = initialData

  // With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time
  // So, this is how we do it in the client side at runtime
  **webpack_public_path** = `${prefix}/_next/` //eslint-disable-line
  }
  • 주석이 달려있다.

동적 assetPrefix를 사용하면 더 이상 빌드 시 assetPrefix를 설정할 수 없습니다. 이것이 런타임에 클라이언트 측에서 수행하는 방법입니다.

  • 친절하게 주석이 적혀있었으나... 당장에 무슨 말인지 이해할 수 없었다.
appElement = document.getElementById("__next")
return { assetPrefix: prefix }

__next id를 가지고 있는 엘리먼트를 appElement에 할당한 이후, assetPrefix를 반환한다.

function renderApp(App: AppComponent, appProps: AppProps) {
  return <App {...appProps} />
}

renderApp 함수는 AppappProps 매개변수를 받아서 AppComponent를 리턴한다.

function renderReactElement(
  domEl: HTMLElement,
  fn: (cb: () => void) => JSX.Element
): void {
  // mark start of hydrate/render
  if (ST) {
    performance.mark("beforeRender")
  }

  const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete)

  if (process.env.__NEXT_REACT_ROOT) {
    if (!reactRoot) {
      // Unlike with createRoot, you don't need a separate root.render() call here
      reactRoot = ReactDOM.hydrateRoot(domEl, reactEl)
      // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing
      shouldHydrate = false
    } else {
      const startTransition = (React as any).startTransition
      startTransition(() => {
        reactRoot.render(reactEl)
      })
    }
  } else {
    // The check for `.hydrate` is there to support React alternatives like preact
    if (shouldHydrate) {
      ReactDOM.hydrate(reactEl, domEl)
      shouldHydrate = false
    } else {
      ReactDOM.render(reactEl, domEl)
    }
  }
}

React18이 안정화 될 때까지 ReactElement render 방식을 if문으로 나누고 있다.
상황에 따라서 ReactDOM.hydrateRoot하거나 ReactDOM.hydrate하거나 ReactDOM.render으로 렌더하고 있다.
ReactDOM.hydrateRoot문을 탔다면, 별도로 root.render()하지 않아도 된다.
shouldHydrate의 boolean 상태에 따라 reactEl의 fn가 달라진다 (markHydrateComplete || markRenderComplete)

ReactDOM.render()는 렌더링하기 원하는 컴포넌트, 렌더링 되길 원하는 컴포넌트들이 포함된 DOM 요소를 인수로 받는다. (https://developer.mozilla.org/ko/docs/Learn/Tools_and_testing/Client-side_JavaScript_frameworks/React_getting_started#interrogating_the_index)

function doRender(input: RenderRouteInfo): Promise<any> {
  let { App, Component, props, err }: RenderRouteInfo = input

  const appProps: AppProps = {
    ...props,
    Component,
    err,
    router,
  }

  // ...뭔가 <style> 을 붙이고 있는 것 같음...

  const elem: JSX.Element = (
    <>
      <Head callback={onHeadCommit} />
      <AppContainer>
        {renderApp(App, appProps)}
        <Portal type="next-route-announcer">
          <RouteAnnouncer />
        </Portal>
      </AppContainer>
    </>
  )

  // We catch runtime errors using componentDidCatch which will trigger renderError
  renderReactElement(appElement!, callback => (
    <Root callbacks={[callback, onRootCommit]}>
      {process.env.__NEXT_STRICT_MODE ? (
        <React.StrictMode>{elem}</React.StrictMode>
      ) : (
        elem
      )}
    </Root>
  ))

  return renderPromise
}

위에서 잘 만든 것들을 doRender 함수로 렌더한다고 이해했다.
이때 renderReactElement가 재등장하는데, renderReactElement 함수의 첫 번째 매개변수는 domEl: HTMLElement였고 두 번째 매개변수는fn: (cb: () => void) => JSX.Element였다.

  • appElementinitialize함수에서 appElement = document.getElementById('__next')로 할당하었다.
  • callback 함수<Root> 밑으로 elem을 넣어주고 있다.

후기

내가 과제를 잘 이해한 건지 모르겠다. 너무 어렵게 생각한 것 같기도 하고... 제대로 이해한 것인지 확신도 들지 않았다.
챌린지 이후 보다 더 NextJs를 이해할 수 있기를 바라며...


참조

https://web.dev/rendering-on-the-web/
https://iancoding.tistory.com/173

Comments