클라이언트 사이드 렌더링이란 클라이언트인 브라우저가 자바스크립트를 이용하여 동적으로 웹 페이지 렌더링을 처리하는 방식을 의미한다. 초기에 빈페이지가 로드되고 클라이언트에서 데이터를 요청하면 필요한 데이터만 백엔드 서버에 요청하여 데이터를 비동기적으로 받아와 렌더링을 한다.
장점으로는 서버부하가 감소되어 비용이 저렴하고 호스팅하기 쉬우며, 후속페이지 로드시간이 SSR보다 빠르다. 또한 전체 페이지를 리로드할 필요없이 필요한 부분만 데이터 요청이 가능하여 SSR보다 로드가 빠르기 때문에 사용자 경험에서의 이점이 있다.
단점으로는 초기페이지 로드시간이 SSR에 비해 느리며, SEO에 친화적이지 않다.
서버 사이드 렌더링이란 서버에서 렌더링을 할 준비를 마친후 HTML을 브라우저에게 보내는 방식을 의미한다.
터미널에 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라는 변수에 할당을 하고 있다.