CSR/SSR with Next.js

Haz·2023년 6월 23일
0

개발여행기

목록 보기
11/24
post-thumbnail

CSR과 SSR, 그리고 NEXT.js


CSR과 SSR, 도대체 어디에서 렌더링할 것이냐, 그것이 문제로다! 꼭 햄릿이 된 것 마냥 프레임워크를 사용하는 개발자들의 머리를(특히 나 같은 주니어의 머리를) 아프게 하는 이 문제는 자바스크립트 프레임워크의 역사를 따라 함 께 흐르고 있다. 아주 지난하면서도 트렌디한 문제라는 면에서 거의 종교 분쟁급이다.

렌더링을 어디서 하는 게 왜 이렇게 중요한 문제가 된 걸까?



SPA의 등장


Static Web → <iframe> 도입 → XMLHttpRequest → Ajax(Asynchronous JavaScript and XML)

웹이 발전하는 흐름 중 위에서 언급한 부분은 여기서 다룰 CSR, SSR 뿐만 아니라 동기와 비동기를 다루는 이슈와도 밀접한 연관이 있다.

1990년대 중반까지 웹은 Static, 즉 웹페이지에서 극히 일부분만 변경되더라도 모든 부분의 변경을 서버에 요청하고, 결과를 반환받아 렌더링시켜줘 전체 화면이 깜박이게 되는 정적 웹사이트 방식이었다. 회원가입 시에 필수 단계인 우편번호 찾기 창에서 동을 입력하고 아주 작은 찾기 버튼 하나만 눌러도 창의 정보가 한참이나 보이지 않았다가 '가입하지 말까?'하는 생각이 들 때쯤 겨우 창이 보인다면 한국인 성격에 얼마나 갑갑했을까?😂

1996년도에는 <iframe> 태그가 도입되면서 변경이 될 것 같은 부분은 이를 활용해 부분적으로 웹페이지를 렌더링했다. 현재도 <iframe>은 광고 전략을 위해 많이 사용되고 있는데(요새는 Adblock 플러그인을 대부분 사용하고 있을테니(?) 해제하고) 포털에서 이슈를 검색해 뉴스 기사를 아무거나 클릭해보면 번쩍이는 광고들을 볼 수 있을 것이다. 이때 새로고침을 하면 기사 부분은 캐시가 남아 금방 렌더링되지만 광고 영역은 한참 후에 광고가 뜨게 된다. <iframe>을 이용해 새 광고를 불러오기 때문이다.

1998년 이후로는 XMLHttpRequest가 등장하여 드디어 JSON 형식으로 서버에 필요한 데이터를 요청하고, 받아와서 JavaScript를 통해 페이지에 렌더링할 수 있었다. 이게 발전하여 본격적으로 사용되기 시작하면서 AJAX라는 이름으로 불리게 되었다. 한 페이지에서 필요한 데이터를 요청해서 변경해야할 부분을 업데이트하는 SPA(Single Page Application)가 이렇게 탄생하게 됐다.



CSR(Client-side Rendering)


client side rendering

SPA 서비스가 늘어나면서 자연스럽게 수요가 커진 게 바로 CSR이다. 이전 정적 웹사이트에 비해 훨씬 역동적이고 섬세한 느낌을 줄 수 있기 때문에 단일 페이지 애플리케이션에서 장점이 극대화되기 때문이다.

CSR 내부 동작 방식

클라이언트 사이드 렌더링은 말 그대로 렌더링이 클라이언트 쪽에서 일어나는 방식이다. 클라이언트를 직역하면 고객인데 뭘 말하는 건지 긴가민가했는데, 조금 생각해보니 당연하게도 브라우저였다.

브라우저의 주소 입력창에 URL을 입력하면 서버에 사이트와 관련된 요청을 송신하고 페이지 렌더링을 하기 위한 준비를 시작한다.

그럼 서버에서는 최소한의 HTML 페이지과 함께 꼭 필요한 JavaScript 파일과 CSS 파일을 보내주며, 브라우저는 이를 함께 다운 받는다.

브라우저는 수신한 app.js 파일을 활용해서 API를 서버로 요청하고 페이지를 렌더링한다. 이 시점에서 페이지가 모두 보이고, 상호작용이 가능해진다.

CSR 장점

CSR의 장점은 명확하다. 첫째로 동적으로 상호작용하기 때문에 더이상 Blinking 현상도 없고 요청에 대한 응답도 빠르다. 그래서 사용자 경험이 더 나아진다. 둘째로 페이지를 로드할 때 다른 페이지에 필요한 요소들도 함께 가져오기 때문에 다른 페이지로 이동할 때 속도가 빠르다. 마지막으로 프론트엔드 개발자가 할 일과 백엔드 개발자가 할 일을 확실하게 나눠주었다(고 한다🙄).

CSR 단점

하지만 단점도 명확하다. 첫째로 서비스에서는 가장 치명적인 SEO가 어려워진다. CSR로 개발된 웹서비스는 크롤러가 구조를 파악하기가 어렵다. 그렇기 때문에 정적으로 개발된 사이트보다 포털 노출에서 밀릴 수 있다. 둘째로 첫 페이지 로드 이후 JS 파일을 통해 수신할 데이터 양이 많거나, 기기 성능이 좋지 않을 경우에는 퍼포먼스가 크게 떨어진다. 셋째로 무조건 자바스크립트 엔진이 작동해야만 한다. 이로 인해 오래된 브라우저에서는 문제가 발생하거나 접근성에 문제가 발생할 수 있다. 마지막으로 SSR에 비해 개발하기가 복잡하다. 자바스크립트와 리액트 같은 프레임워크에 대한 깊은 지식을 활용해야하기 때문이다.

이런 단점들 때문에 SSR이 부각되기 시작하면서 본격적으로 이와 관련된 논의가 시작됐다.


SSR(Server-side Rendering)


Server side rendering

서버 사이드 렌더링은 정적 웹사이트(Static Web)에서 영감을 받아 탄생한 방식이다. 페이지 구성의 주체가 이전처럼 서버에 있는 방식이고, CSR의 단점들을 일부 보완할 수 있다.

SSR 내부 동작 방식

CSR과 마찬가지로 브라우저의 주소입력창에 URL을 담아 요청을 보내면 서버에서는 모든 필요한 데이터를 취합해 HTML 파일을 만들고 브라우저로 먼저 전송한다. 이때 HTML 파일은 렌더링이 될 준비를 마친 상태기 때문에 브라우저는 빠르게 렌더링해줄 수 있어서 이미 구조적으로 완성된 페이지를 보여주지만, 상호작용은 안된다.

자바스크립트 파일을 다운 받으면서 브라우저는 사용자가 요청한 상호작용을 기억하고 있다가 다운이 완료된 이후에 실행한다.

SSR 장점

SSR은 CSR과는 달리 완성된 화면이 먼저 보이기 때문에 체감 상 CSR보다 빠르게 페이지가 로드되는 것처럼 느껴진다. 게다가 서버에서 렌더링이 될 준비를 마쳐서 전송하기 때문에 웹 크롤러가 페이지의 뼈대를 읽어 효율적인 SEO가 가능하다. 또한 자바스크립트 엔진이 작동하지 않아도 우선 페이지 자체는 보여줄 수 있다.

SSR 단점

하지만 당연히 단점도 존재한다. 정적 웹사이트의 요소를 가져온 만큼 Blinking 이슈가 발생할 수 있다. 첫 페이지를 가져올 때는 빠르지만 다른 페이지의 구성 요소는 이동할 때 다시 서버에서 구성해서 가져와야하기 때문이다. 이런 부분 때문에 서버에 과부하가 걸리기 쉽다. 사용자가 10명이라면 괜찮지만, 1000명, 10000명을 넘어 몇십 만명, 몇백만 명이 쓰는 거대한 서비스라면 서버에 많은 무리가 가게 된다. 가장 치명적인 부분은 첫 페이지 로드 시에 아무리 버튼을 눌러도 반응이 없을 수 있다는 점이다. 분명 버튼은 있는데 아무런 피드백이 없다면 사용자 입장에서는 '서비스가 멈춘 건가? 컴퓨터가 멈춘 건가? 인터넷이 이상한가?' 등 순간적으로 많은 생각이 들 수밖에 없고, 이는 서비스에 대한 부정적인 인식으로 이어지게 되기 때문이다.


NEXT.js

Next.js

바야흐로 자바스크립트 프레임워크의 천하전국시대에 혜성처럼 등장해 엄청난 주목을 받고 있는 Next.js는 React.js 기반의 SSR 프레임워크다. Next.js는 CSR 기반이었던 React.js에서 애플리케이션 초반에 부담이 간다는 단점과 SEO 최적화가 어렵다는 단점 보완이 가능하고, 개발하기 위해 새로 스택을 배운다는 생각이 들지 않을 정도로 비슷하기 때문에 기술 부채도 적다.

Yarn Start(npm run start) 스크립트 실행하기

프로젝트를 시작하기 위해 보일러 플레이트를 구성했다면 무언가를 따로 설정하지 않아도 package.json 파일에 개발 서버, 프로덕션 빌드 등을 위한 스크립트가 작성되어 있다.

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

그 중에서도 yarn start 혹은 npm run start 명령어를 터미널에 입력해 실행할 경우 start에 해당하는 스크립트가 실행된다.

Next.js는 오픈소스 프로젝트이기 때문에 Github을 통해서 start 스크립트가 어떻게 실행되는지 코드를 확인해봤다.


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 = {
    // 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_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 }

코드 상단부에서 필요한 모듈들을 import 해오고, 옵션들에 이상이 없는지 확인하는 함수가 가장 먼저 작동하게 된다. 이후에 --help 옵션이 명령어에 포함되어있다면 콘솔로 출력해주어야할 문구들이 작성되어있고, process.exit(0)로 이후 작업을 중지한다.

--help 옵션이 없다면 조건문이 실행되지 않고 아래로 내려가 서버 연결을 확인하는 작업을 하게된다. Timeout될 시간을 설정하는 코드인 것 같은데 undefined거나 무한대거나, 음수일 경우는 오류를 뱉고 작업이 중지되도록 되어있다.

해당 작업들이 모두 완료된 이후에 이것들을 가지고 프로덕션 서버를 준비하여 서버를 실행한다.




마무리

매일 명령어를 입력해보기만 했지, 그 내부 코드를 본 건 처음인데 서버 실행하는 코드가 100줄이나 되는 것도 신기했고 이렇게나 타입을 이용한 예외 처리나 try & catch로 세세한 부분까지 신경 썼다는 것에 놀랐다.

또 이번 기회에 CSR, SSR이 왜 필요하고 어떤 장단점들이 있는지 되짚어보는 계기가 되어서 좀 더 차이가 머릿속에 명확해지는 것 같다.

원티드 챌린지에 참여해서 또 새로운 것들을 많이 알아갈 수 있다면 좋겠다😁

profile
나도 재밌고, 남들도 재밌는 서비스 만들어보고 싶다😎

0개의 댓글