CSR / SSR with Next.js

엘리(Ellie)·2023년 6월 19일
0

이번에는 다음 3가지 질문에 답을 함으로써 CSR과 SSR에 대해 개념적으로 정리해보고 더 나아가 Next.js를 시작하는 과정을 코드 레벨에서 알아보려고 한다.

  1. CSR과 그의 장단점은 무엇인가?
  2. SPA로 구성된 앱에서 SSR이 필요한 이유가 무엇인가?
  3. Next.js 프로젝트에서 yarn start를 실행하면 무슨 일이 일어나는가?

CSR과 그의 장단점은 무엇인가?

CSR(Client Side Rendering)이란, 웹 애플리케이션 렌더링 기법 중 하나로 서버에서 전달 받은 데이터를 클라이언트(웹 브라우저)에서 처리해 동적으로 페이지를 그리는 방식을 뜻한다.
서버로부터 body가 비어있는 html 페이지와 스크립트 파일을 받아온 뒤, 브라우저에서 스크립트를 실행해 동적으로 DOM 요소를 만들어 화면을 보여주는 방식으로 동작한다.

그렇다면 왜 CSR 방식이 등장했을까? CSR과 대립되는 개념은 SSR(= Server Side Rendering)인데, CSR 방식이 등장하기 전에는 SSR 방식으로 웹 페이지를 그렸다. 서버에서 완전한 HTML을 생성해서 웹 브라우저로 전송하면, 브라우저에서는 완전한 HTML을 보여주는 식으로 웹이 동작했었다.
이 시기의 SSR의 문제는 브라우저에서는 화면 요소를 변경하기 위해서는 서버와 통신이 필수적이었고, 서버와 통신한 뒤 화면을 다시 그리는 동안 사용자는 꼼짝 않고 기다려야 했다는 것이다. 이는 사용자에게 좋지 않은 UX로 다가왔고, 이를 해결하기 위해 CSR이 등장했다.

CSR은 화면을 브라우저에서 그리기 때문에 사용자 인터랙션에 있어 반응이 빠르다는 장점이 있다. 예를 들어, 어떤 버튼을 눌렀을 때 추가적인 화면을 그려야 하는 웹 페이지가 있다고 하자. SSR 방식은 버튼을 눌렀을 때 서버에게 새로운 페이지를 요청하고 변경사항을 반영하는 방식으로 느리게 반응한다면, CSR 방식은 버튼을 눌렀을 때 DOM을 직접 조작해 서버와 통신하지 않고도 바로 적절한 화면을 보여줄 수 있다. 이로써 사용자는 웹 페이지가 '즉각적으로 반응한다' 라고 느낄 수 있는 것이다.

하지만 CSR 방식은 사용자와의 상호작용을 위해 희생당한 부분도 있다. 화면 렌더링과 data fetching을 클라이언트로 전환하면서 보안 이슈도 생기고, 한 페이지와 연관된 리소스가 증가하면서 번들 사이즈가 커져 초기 로드 시간이 증가한다는 점, 브라우저 캐싱이나 SEO(Search Engine Optimization) 문제도 있다.

SPA로 구성된 앱에서 SSR이 필요한 이유?

SPA(= Single Page Application)란 전체 애플리케이션을 단일 페이지로 구성하는 방식을 이야기한다. SPA는 초기 로딩 이후에는 서버로부터 페이지 전체를 다시 로드하지 않고, 동적으로 필요한 부분만 업데이트하여 사용자와 상호작용하기 때문에 빠른 상호작용을 통해 UX를 극적으로 끌어올릴 수 있다는 장점이 있다. SPA로 만든 웹 페이지는 사용자에게 마치 모바일 앱을 다루는 것과 같은 느낌을 줄 수도 있다.

하지만 SPA는 위의 CSR의 단점을 그대로 가지고 있다.

  1. 보안 문제
    : access token이나 api key 값과 같이 보안에 민감한 정보를 클라이언트가 가지고 있음으로써 보안 이슈를 신경써야 한다.
  2. 초기 로드 시간 증가
    : SPA이기 때문에 첫 페이지에 접근할 때 서버로부터 전체 애플리케이션 코드를 받아와야 한다. 이는 자바스크립트 번들 사이즈와 연관이 있는데, 첫 페이지 진입 시 큰 자바스크립트 번들을 로드하는 데 시간이 오래 걸려 사용자가 첫 페이지를 보는 데에 오래 기다리게 된다. 이는 앱이 클수록 큰 문제가 된다.
  3. 브라우저 캐싱
    : MPA(= Multi Page Application)일 때는 같은 요청에 대한 서버 응답을 브라우저가 캐싱하기 때문에 캐싱을 효율적으로 이용할 수 있었지만, 첫 페이지에 모든 것을 받아온 이후에는 페이지 요청을 하지 않는 SPA 방식에서는 무용지물이 되었다.
  4. SEO
    : 마찬가지로 url에 해당하는 페이지를 가져올 때 검색 엔진은 빈 페이지를 받기 때문에 내용을 알 수 없어서 검색 결과에 감지되지 않는다.

이런 이유로 이제는 SSR과 CSR을 같이 사용하는 하이브리드 방식이 등장하고 있다. 대표적인 예가 Next.js의 SSR, SSG(= Static Site Generation) 같이 페이지를 pre-rendering 하는 방식이다.

Next.js는 SSR, SSG 방식을 이용해 내용이 채워진 HTML을 전달하게 할 수 있다. 물론 CSR 방식을 유지할 수도 있는데 이는 그리려고 하는 화면의 특징에 따라 나뉜다. 사용자 인터랙션을 통해 페이지가 렌더링 되어야 한다면 (ex. 검색 페이지) CSR이 적합한 선택일 것이고, 인터랙션에 따라 변경되지 않는 정적인 화면이라면 (ex. 블로그 포스팅) SSR이나 SSG를 사용할 수 있겠다.

Next.js의 SSR을 사용해 미리 렌더링된 페이지를 받아옴으로써 위의 문제를 어느정도 해결할 수 있다.

  1. 보안 문제
    : 보안이 예민한 데이터는 서버에서 관리한다. 예를 들어, API_KEY를 서버에서 관리하고 클라이언트에서는 서버로 API 요청만 하는 것이다.
  2. 초기 로드 시간
    : Next.js에서는 code splitting이라는 기법을 사용해서 페이지 단위로 파일을 번들링한다. 해당 페이지에 필요한 파일만 받아오기 때문에 초기 로드 시간이 감소한다.
  3. 브라우저 캐싱
    : 이제 페이지 별로 서버에 요청을 하기 때문에 요청에 대한 응답을 캐싱하는 브라우저 캐싱의 덕을 볼 수 있다.
  4. SEO
    : SSR을 사용하는 페이지는 내용이 채워진 HTML이 전달되기 때문에 검색 엔진이 내용을 보고 검색 결과에 포함시킬지 제대로 판별할 수 있다.

기존에 CSR만으로 SPA를 구성하는 방식의 문제를 SSR을 도입함으로써 해결할 수 있게 되었다.

Next.js 프로젝트에서 yarn start를 실행하면 어떤 일이 일어날까?

이제 Next.js 코드 내부로 들어가보자. Next.js 프로젝트에서는 yarn start를 실행하면 무슨 일이 일어나게 될까?

일단 package.json의 스크립트에 대한 공식문서의 설명은 다음과 같다.

// package.json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}
  • dev: Next.js를 개발모드로 시작합니다.
  • build: 프로덕션에서 사용하기 위해 애플리케이션을 빌드합니다.
  • start: Next.js 프로덕션 서버를 시작합니다.
  • lint: Next.js의 내장 ESLint 구성을 셋업합니다.

Next.js Github 레포지토리에 들어가보니 packages/next/src/bin/next.ts가 Next.js의 시작 지점인 것 같다.

// next.ts의 코드 일부
import { commands } from '../lib/commands'

파일을 들여다보면 /lib/commands에 있는 명령어를 기반으로 실행을 하고 있다. 해당 파일(packages/next/src/lib/commands.ts)로 들어가보자.

// commands.ts의 코드 일부
export const commands: { [command: string]: () => Promise<CliCommand> } = {
  build: () => Promise.resolve(require('../cli/next-build').nextBuild),
  start: () => Promise.resolve(require('../cli/next-start').nextStart),
  ...
}

next startpackages/next/src/cli/next-start.ts 파일의 nextStart를 실행하고 있다. next-start.ts 파일에 들어가보자.

// next-start.ts
const nextStart: CliCommand = async (argv) => {
  // ...
  
  await startServer({
    dir,
    isDev: false,	// production mode로 실행한다.
    hostname: host,
    port,
    keepAliveTimeout,
    useWorkers: !!config.experimental.appDir,
  })
}

오! 드디어 서버를 실행하는 부분을 찾았다! 정리하자면 yarn start 명령어는 isDev: false로 설정함으로써 프로덕션 모드로 서버를 실행하고 있었다.

그렇다면 좀 더 들어가서 startServer는 무슨 일을 할까? packages/next/src/server/lib/start-server.ts

// start-server.ts
export async function startServer(...): Promise<TeardownServer> {
  // 서버 인스턴스 생성
  const server = http.createServer(...)
  
  if (useWorkers) {
    // 커스텀 미들웨어 적용 (proxy server 생성)
    // - 라우팅, 렌더링 등의 요청 proxy server에게 위임
    const getProxyServer = (pathname: string) => {
      const proxyServer = httpProxy.createProxy(...)
	  ...
      return proxyServer
    }
    
    requestHandler = async (req, res) => {  
      const proxyServer = getProxyServer(req.url || '/')
      proxyServer.web(req, res)
    }
  } else {
    // next 미들웨어 적용 (next server 생성)
    const app = next({
      httpServer: server,
      customServer: false,
      ...,
    })
    await app.prepare()
  }
  
  // return teardown function for destroying the server
  return teardown
}

여기서부터는 좀 복잡했지만, 핵심 기능만 봤을 때 startServer()에서 하는 일은 다음과 같다.

  1. HTTP 서버 인스턴스 생성
  2. 커스텀 미들웨어(next.config.js 파일에 정의된 미들웨어)가 있다면 적용
  3. 없다면 next 미들웨어 적용
  4. 서버 종료 함수 리턴

커스텀 미들웨어가 없다면 기본으로 제공되는 next 미들웨어가 실행된다. 우리는 기본 동작을 살펴보는 것이 목적이기 때문에 next 미들웨어가 어떻게 설정되는지 보자. packages/next/src/server/next.ts

// next.ts 코드 일부
export class NextServer {
  // 해당 server로 요청 전달
  private server?: Server
    
  async render() {
    const server = await this.getServer()
    return server.render(...args)
  }

  async renderToHTML() {
	const server = await this.getServer()
    return server.renderToHTML(...args)
  }
  
  private async createServer(options: DevServerOptions): Promise<Server> {
    let ServerImplementation: typeof Server
    if (options.dev) {
      // 개발 모드면 `next-dev-server` 인스턴스 생성
      ServerImplementation = require('./dev/next-dev-server').default
    } else {
      // 프로덕션 모드면 `next-server` 인스턴스 생성
      ServerImplementation = await getServerImpl()
    }
    const server = new ServerImplementation(options)

    return server
  }
  
  private async getServer() {
    // 서버 있으면 server 리턴
    // 없으면 createServer()로 서버 생성해서 리턴
  }
  
  ...
}

function createServer(options: NextServerOptions): NextServer {
  return new NextServer(options)
}

export default createServer

NextServer 클래스를 찾았다! 이 서버가 Next.js에서 말하는 Next 서버인가보다. 외부로는 서버에서 할 수 있는 일을 메서드로 열어주고, 내부적으로는 또 다른 서버 인스턴스를 가지고 동작을 모두 위임하고 있다.

내부 서버 인스턴스를 server 라고 하겠다. 코드를 보면 createServer 메서드에서 server 인스턴스를 생성하는데, option에 따라 개발 모드라면 개발 서버를, 프로덕션 모드라면 프로덕션 서버를 생성한다.
즉, 개발 서버와 프로덕션 서버는 같은 인터페이스를 갖지만 내부 동작 방식이 다르게 구현되어 있다는 것을 알 수 있다.

프로덕션 서버와 개발 서버 파일까지만 확인하고 일단락 지어볼까 한다. 여기서부터는 추상화 단계가 낮아서 코드를 따라가기 힘들어진다..ㅎ

// next-server.ts
export default class NextNodeServer extends BaseServer { ... }

// next-dev-server.ts
export default class DevServer extends Server { ... }

프로덕션 서버인 NextNodeServerBaseServer를 상속 받아 만들어졌고, 개발 서버인 DevServerServer를 상속 받아 만들어졌다. 여기서 ServerNextNodeServer를 다른 이름으로 가져온 것이다.

즉, Next.js의 서버 클래스는 BaseServer <- NextNodeServer <- DevServer 의 상속 구조로 이루어져 있다.

BaseServer라는 클래스를 각자 다르게 확장해서 구현한 것이 아니라 개발 서버가 프로덕션 서버를 확장해서 만들어졌다는 점이 재미있었다! 개발 서버가 결국 프로덕션 서버에서의 최적화 기능을 무시하도록 처리되어 있지 않을까 싶다.

맺으며

이렇게 (1) CSR이 무엇이고 CSR의 장단점이 무엇인지 (2) SPA에서 SSR이 필요한 이유 (3) Next.js 앱에서 yarn start를 하면 무슨 일이 일어나는지에 대한 답을 해 봤다.

React와 Next.js를 공부하며 어렴풋이 알고 있던 개념들인데 글로 정리하면서 명확해진 느낌이다. Next.js 내부 코드를 뜯어보는 일은 약간의 도전이었지만 꽤 재미있고 도움되는 경험이었다.

사실 Next.js를 공부하면서 마술같아 보이는 기능을 많이 만났다. 폴더 구조 기반으로 페이지를 라우팅하는 방식이라던가 getServerProps 함수를 정의하면 SSR로 동작하는 방식이 그런 것들이다. 이렇게 마술같아 보이는 기능을 사용하기 위해서는 암기하는 수 밖에 없는데, 그러다보면 재미를 잃기 쉬워지는 것 같다. 암기가 싫다면 원리를 이해하는 방향으로, 이번에 Next 서버가 생성되는 과정을 알아보면서 재미를 느꼈던 것 처럼 해결하면 좋을 것 같다. 나머지 궁금했던 부분들도 시간 되면 들어가서 하나씩 들여다보고 싶어졌다.

profile
신기하고 재미있는 것 만들기를 좋아합니다 :)

0개의 댓글