Skip to main content

로컬에서는 멀쩡하던 서버가 배포만 하면 갑자기 예민해지는 이유는 간단하다. 내 노트북에서는 내가 좋은 사람처럼만 요청을 보내지만, 운영 환경에서는 세상이 조금 더 거칠기 때문이다. 누군가는 새로고침을 난사하고, 어떤 봇은 로그인 엔드포인트를 두드리고, 배포 도중에는 서버가 종료 신호를 받기도 한다.

이번 장의 목표는 하나다. 내 컴퓨터에서만 잘 되는 서버를, 세상 밖에서도 버티는 서버로 바꾸는 것이다. 기능을 더 붙이는 장이 아니라, 서버를 단단하게 만드는 장이라고 생각하면 된다.

왜 배포 전 체크리스트가 필요할까?

운영 장애는 꼭 거대한 버그 때문에만 생기지 않는다. 오히려 NODE_ENV를 안 켰다거나, 종료 신호를 무시했다거나, 요청 제한이 없었다거나, 로그가 너무 엉망이라 원인을 못 찾는 식의 사소한 실수에서 자주 시작된다.1

그래서 배포 전에는 이렇게 점검해야 한다.

  • 지금 서버는 운영 모드로 실행되는가?

  • 너무 많은 요청이 와도 최소한 버티는가?

  • 죽기 전에 상태를 알리고, 진행 중인 요청을 정리하는가?

  • 에러가 나면 로그만 보고도 무슨 일이 있었는지 추적할 수 있는가?

  • 헬스체크가 있어서 외부 시스템이 이 서버의 상태를 판단할 수 있는가?

이 다섯 가지를 챙기면 서버가 갑자기 철이 든다.

NODE_ENV=production은 선택이 아니라 선언이다

운영 서버에서는 NODE_ENV=production을 분명하게 설정해야 한다. 이 값에 따라 Express는 더 덜 장황한 에러 메시지를 만들고, 일부 동작도 운영 환경에 맞게 달라진다.1 단순히 기분만 생산적이 되는 게 아니라, 실제 동작이 달라진다.

그리고 중요한 점이 하나 더 있다. 환경 변수는 문제가 있으면 서버 시작 시점에 바로 실패해야 한다. 실행하다가 세 번째 요청쯤에서 JWT_SECRET이 없다는 사실을 알게 되면, 그건 서버가 아니라 시한폭탄이다.

JavaScript 예시

require('dotenv').config()

function requireEnv(name) {
  const value = process.env[name]

  if (!value) {
    throw new Error(`${name} 환경 변수가 필요합니다.`)
  }

  return value
}

const config = {
  nodeEnv: process.env.NODE_ENV || 'development',
  port: Number(process.env.PORT || 3000),
  logLevel: process.env.LOG_LEVEL || 'info',
  jwtSecret: requireEnv('JWT_SECRET'),
}

if (!Number.isInteger(config.port)) {
  throw new Error('PORT는 숫자여야 합니다.')
}

module.exports = config

이렇게 해 두면 서버는 시작하자마자 설정을 검사한다. 필요한 값이 없으면 조용히 버티지 않고 크게 알려 준다. 운영에서는 이런 성격이 오히려 착하다.

첫 번째 방패: Helmet과 기본 보안 습관

Express 서버를 바깥세상에 공개하면, 최소한의 보안 헤더는 붙여 두는 편이 좋다. helmet은 여러 HTTP 보안 헤더를 설정해 주는 미들웨어다. 클릭재킹, MIME 스니핑 같은 흔한 위험을 줄이는 데 도움이 된다.2

여기에 express.json()의 크기 제한도 같이 두는 습관이 좋다. 초보자일수록 body는 그냥 들어오는 줄 알기 쉽지만, 아주 큰 요청 본문은 메모리를 괴롭히는 데 제법 효과적이다. 서버 입장에서는 반갑지 않은 손님이다.

JavaScript 예시

const express = require('express')
const helmet = require('helmet')
const compression = require('compression')

const app = express()

app.disable('x-powered-by')

app.use(helmet())
app.use(compression())
app.use(express.json({ limit: '10kb' }))

여기서 compression()은 응답을 압축해 전송량을 줄이는 데 도움이 된다. 다만 Express 공식 운영 가이드는 트래픽이 많은 환경에서는 앱 안보다 리버스 프록시 레벨에서 압축을 처리하는 방식도 좋다고 설명한다.1 즉, 지금은 Express 미들웨어로 감을 익히고, 나중에 Nginx 같은 프록시를 두면 그쪽에서 맡기는 구조도 생각할 수 있다.

trust proxy는 언제 필요한가?

배포 환경이 Nginx, 로드 밸런서, CDN 같은 리버스 프록시 뒤에 있다면 trust proxy 설정이 중요해진다. 이 값을 적절히 설정해야 Express가 req.ip, req.protocol, req.hostname 같은 정보를 더 올바르게 해석할 수 있다.3

하지만 여기서 중요한 단서가 있다. trust proxy는 무조건 켜는 값이 아니다. 프록시 구조를 모른 채 막 true로 두면, X-Forwarded-* 헤더를 신뢰하는 방식 때문에 오히려 잘못된 값을 믿게 될 수도 있다.3

즉, 초보자에게는 이렇게 기억하는 편이 안전하다.

  • 로컬 단독 실행이면 보통 기본값(false)로 둔다.

  • Nginx나 클라우드 로드 밸런서 뒤에 있을 때, 실제 프록시 구조를 이해한 뒤에 켠다.

  • 특히 IP 기반 제한이나 HTTPS 판별을 쓸 때 더 신중하게 본다.

예를 들어 프록시 1단 뒤에 있다는 구조가 명확하다면 이런 식으로 쓸 수 있다.

app.set('trust proxy', 1)

이 한 줄은 편리하지만, "프록시가 한 단계 있고 그 프록시가 X-Forwarded-* 헤더를 올바르게 다뤄 준다"는 전제가 있을 때 의미가 있다.3

과한 손님은 문 앞에서 조절한다: Rate Limit

요청 제한은 서버의 짜증 지수를 낮춰 주는 기능이다. 한 IP가 너무 짧은 시간에 요청을 몰아치면, 서버는 정중하게 그만 좀요라고 말해야 한다. express-rate-limit은 이런 역할을 쉽게 붙일 수 있는 대표적인 미들웨어다.4

중요한 포인트는 모든 경로에 같은 제한을 거는 것보다, 경로 성격에 맞게 다르게 거는 것이다. 예를 들어 일반 API는 넉넉하게, 로그인 같은 민감한 경로는 더 엄격하게 잡는 편이 자연스럽다.

JavaScript 예시

const rateLimit = require('express-rate-limit')

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    success: false,
    message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
  },
})

const authLimiter = rateLimit({
  windowMs: 10 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    success: false,
    message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
  },
})

app.use('/api', apiLimiter)
app.use('/api/auth', authLimiter)

이렇게 하면 일반 API는 15분에 100번까지, 로그인 경로는 10분에 5번까지만 허용할 수 있다. 비밀번호 맞히기 게임을 하는 봇 입장에서는 꽤 불편해진다.

한 가지 더 기억하자. 지금 예시는 메모리 기반이라 서버 한 대 기준으로 이해하는 것이 좋다. 인스턴스가 여러 대인 구조라면 공용 저장소 기반 전략이 더 어울릴 수 있다.

로그는 미래의 나를 구해 준다

운영에서 가장 무서운 말은 분명 에러가 났는데 로그가 없다이다. 그다음으로 무서운 말은 로그는 있는데 너무 많아서 못 찾겠다다. 그래서 로그는 많이 찍는 것보다 잘 찍는 것이 중요하다.1

좋은 로그에는 보통 이런 정보가 들어간다.

  • 언제 발생했는가

  • 어떤 요청이었는가

  • 어느 경로였는가

  • 어떤 요청 ID였는가

  • 에러라면 스택이 무엇인가

초보자일수록 console.log('왔다감')의 유혹이 강하지만, 운영에서는 구조화된 로그가 훨씬 유용하다. JSON 형태로 남기면 검색과 필터링이 쉬워진다. Express 공식 가이드도 운영 로그에는 console.log()보다 전용 로깅 도구를 권장한다.1

JavaScript 예시

const { randomUUID } = require('crypto')
const pino = require('pino')
const pinoHttp = require('pino-http')

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: {
    paths: ['req.headers.authorization', 'req.body.password'],
    censor: '[REDACTED]',
  },
})

app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || randomUUID()
  res.setHeader('X-Request-ID', req.id)
  next()
})

app.use(
  pinoHttp({
    logger,
    customProps(req) {
      return { reqId: req.id }
    },
  })
)

app.get('/api/ping', (req, res) => {
  req.log.info({ path: req.path }, 'ping 요청')
  res.json({ success: true, message: 'pong' })
})

여기서 핵심은 req.id다. 요청마다 고유 ID를 붙여 두면, 나중에 로그를 뒤질 때 같은 요청의 흐름을 따라가기 쉬워진다. 마치 택배 송장 번호 같은 느낌이다. 그리고 authorization, password 같은 민감한 값은 가려야 한다. 로그가 편하다고 비밀까지 다 쓰면, 그건 메모가 아니라 유출이다.

헬스체크는 서버의 안부 메시지다

헬스체크는 말 그대로 서버가 자기 상태를 말해 주는 엔드포인트다. 로드 밸런서나 컨테이너 플랫폼은 이 주소를 주기적으로 호출해서, 이 서버에 요청을 보내도 되는지 판단한다.5

운영에서는 보통 두 가지를 나눠 생각한다.

  • liveness: 프로세스가 살아 있는가?

  • readiness: 지금 트래픽을 받아도 되는가?

둘은 비슷해 보여도 다르다. 서버 프로세스는 살아 있지만, 데이터베이스 연결이 끊겨 있거나 종료 준비 중이라면 트래픽을 받으면 안 된다. 그럴 때 readiness는 503을 돌려주는 편이 낫다.

JavaScript 예시

let isReady = false
let isShuttingDown = false

async function connectResources() {
  // 예: DB 연결, Redis ping
  isReady = true
}

app.get('/health/live', (req, res) => {
  res.status(200).json({
    status: 'ok',
    uptime: process.uptime(),
  })
})

app.get('/health/ready', (req, res) => {
  if (!isReady || isShuttingDown) {
    return res.status(503).json({
      status: 'not_ready',
      shuttingDown: isShuttingDown,
    })
  }

  return res.status(200).json({
    status: 'ready',
  })
})

이렇게 해 두면 외부 시스템이 서버 상태를 훨씬 똑똑하게 판단할 수 있다. 서버가 저 아직 숨은 쉬어요와 지금 손님 받을 수 있어요를 구분해서 말하는 셈이다.

곱게 내려가는 법: Graceful Shutdown

서버는 시작만 잘하면 끝이 아니다. 끝나는 법도 배워야 한다. 배포나 스케일 조정이 일어나면 프로세스는 SIGTERM 같은 종료 신호를 받는다. 이때 아무 처리 없이 바로 꺼지면, 진행 중인 요청이 중간에 끊기고 연결도 어정쩡하게 남을 수 있다.5

그래서 graceful shutdown이 필요하다. 순서는 보통 이렇다.

  • 종료 시작 상태로 바꾼다.

  • readiness를 503으로 바꿔 새 요청이 덜 오게 만든다.

  • HTTP 서버가 새 연결을 받지 않게 한다.

  • 진행 중인 작업이 끝날 시간을 잠깐 준다.

  • DB, Redis 같은 자원을 정리하고 종료한다.

JavaScript 예시

const http = require('http')
const app = require('./app')

const server = http.createServer(app)
let isShuttingDown = false

server.listen(3000, () => {
  console.log('server started')
})

async function shutdown(signal) {
  if (isShuttingDown) {
    return
  }

  isShuttingDown = true
  console.log(`${signal} 수신, 서버 종료를 시작합니다.`)

  // readiness는 이제 503을 반환하게 만든다.
  app.locals.isShuttingDown = true

  server.close(async (err) => {
    if (err) {
      console.error('서버 종료 중 오류', err)
      process.exit(1)
    }

    try {
      // 예: await dbPool.end();
      // 예: await redis.quit();
      console.log('자원 정리 완료')
      process.exit(0)
    } catch (error) {
      console.error('정리 작업 실패', error)
      process.exit(1)
    }
  })

  setTimeout(() => {
    console.error('종료 시간이 너무 오래 걸려 강제 종료합니다.')
    process.exit(1)
  }, 10000)
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

이 코드는 최소한의 뼈대다. 중요한 점은 process.exit()를 아무 데서나 바로 때리는 것이 아니라, server.close()로 새 연결을 막고 정리 시간을 준다는 데 있다.

성능은 거창한 튜닝보다 나쁜 습관을 줄이는 데서 시작한다

운영 전 체크리스트에서 성능은 숫자 놀음보다 습관 점검에 가깝다. Node.js는 이벤트 루프 기반이라, 한 번 막히면 다른 요청도 같이 답답해진다. 그래서 아래 습관이 중요하다.1

  • fs.readFileSync, 큰 JSON 동기 처리, 무거운 반복문처럼 이벤트 루프를 오래 막는 코드를 피한다.

  • 외부 API 호출과 DB 쿼리에는 반드시 타임아웃을 둔다. 기다림도 무한이면 장애가 된다.

  • 큰 파일이나 큰 응답은 한꺼번에 메모리에 올리기보다 스트리밍을 고려한다.

  • DB 연결은 요청마다 새로 만들지 말고 풀을 사용한다.

  • 응답 압축, 바디 크기 제한처럼 싸고 효과 좋은 기본기를 먼저 챙긴다.

성능 문제는 종종 서버가 느린 게 아니라, 서버가 한 가지 일에 너무 오래 붙잡혀 있는 데서 시작된다. Node.js 서버는 부지런한데, 한 명이 문을 막고 서 있으면 줄이 길어지는 구조라고 생각하면 이해가 쉽다.

JavaScript 실전 뼈대

지금까지 나온 내용을 아주 작게 합치면 이런 모양이 된다.

const express = require('express')
const helmet = require('helmet')
const compression = require('compression')
const rateLimit = require('express-rate-limit')

const app = express()
app.locals.isShuttingDown = false

app.disable('x-powered-by')

app.use(helmet())
app.use(compression())
app.use(express.json({ limit: '10kb' }))

app.use(
  '/api',
  rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 100,
    standardHeaders: true,
    legacyHeaders: false,
  })
)

app.get('/health/live', (req, res) => {
  res.json({ status: 'ok' })
})

app.get('/health/ready', (req, res) => {
  if (app.locals.isShuttingDown) {
    return res.status(503).json({ status: 'not_ready' })
  }

  res.json({ status: 'ready' })
})

app.get('/api/hello', (req, res) => {
  res.json({ message: '운영 준비 중인 Express 서버입니다.' })
})

app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({
    success: false,
    message: process.env.NODE_ENV === 'production'
      ? '서버 내부 오류가 발생했습니다.'
      : err.message,
  })
})

module.exports = app

딱 봐도 화려하진 않지만, 운영 전에 꼭 필요한 안전장치들은 들어 있다. 서버도 사람처럼 기본 생활 습관이 중요하다.

TypeScript 버전은 무엇이 더 좋아질까?

TypeScript로 옮기면 로직 자체가 바뀌지는 않는다. 대신 실수를 더 빨리 잡는다. 특히 설정값, 헬스체크 응답, 종료 상태 같은 것이 타입으로 드러나기 때문에, 나중에 코드를 읽을 때 훨씬 덜 헤맨다.

TypeScript 예시: config/env.ts

import 'dotenv/config'

function requireEnv(name: string): string {
  const value = process.env[name]

  if (!value) {
    throw new Error(`${name} 환경 변수가 필요합니다.`)
  }

  return value
}

function parsePort(value: string | undefined): number {
  const port = Number(value ?? 3000)

  if (!Number.isInteger(port)) {
    throw new Error('PORT는 숫자여야 합니다.')
  }

  return port
}

export const config = {
  nodeEnv: process.env.NODE_ENV ?? 'development',
  port: parsePort(process.env.PORT),
  jwtSecret: requireEnv('JWT_SECRET'),
  logLevel: process.env.LOG_LEVEL ?? 'info',
} as const

TypeScript 예시: app.ts

import express, { Request, Response } from 'express'
import helmet from 'helmet'
import compression from 'compression'
import rateLimit from 'express-rate-limit'

const app = express()

app.locals.isShuttingDown = false as boolean

app.disable('x-powered-by')

app.use(helmet())
app.use(compression())
app.use(express.json({ limit: '10kb' }))

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
})

app.use('/api', apiLimiter)

app.get('/health/live', (_req: Request, res: Response) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() })
})

app.get('/health/ready', (_req: Request, res: Response) => {
  if (app.locals.isShuttingDown) {
    return res.status(503).json({ status: 'not_ready' })
  }

  return res.status(200).json({ status: 'ready' })
})

export default app

TypeScript 예시: server.ts

import http from 'http'
import app from './app'
import { config } from './config/env'

const server = http.createServer(app)
let isShuttingDown = false

server.listen(config.port, () => {
  console.log(`server started on port ${config.port}`)
})

async function shutdown(signal: string): Promise<void> {
  if (isShuttingDown) {
    return
  }

  isShuttingDown = true
  app.locals.isShuttingDown = true

  console.log(`${signal} 수신, 종료를 시작합니다.`)

  server.close((err?: Error) => {
    if (err) {
      console.error(err)
      process.exit(1)
    }

    process.exit(0)
  })

  setTimeout(() => {
    process.exit(1)
  }, 10000)
}

process.on('SIGTERM', () => {
  void shutdown('SIGTERM')
})

process.on('SIGINT', () => {
  void shutdown('SIGINT')
})

TypeScript 버전의 장점은 크고 복잡한 곳에서 더 빛난다. 예를 들어 app.locals.isShuttingDown 같은 상태를 여기저기 다룰 때, 타입 정보가 있으면 실수를 줄이기 쉽다. 자잘한 오타와 이상한 값이 코드를 덜 망친다.

배포 직전, 이것만은 꼭 확인하자

마지막으로 정말 실전용 체크리스트를 짧게 정리해 보자.

  • NODE_ENV=production이 실제 배포 환경에서 설정되어 있는지 확인한다.

  • 필수 환경 변수를 서버 시작 시점에 검사한다.

  • helmet, 바디 크기 제한, 요청 제한을 붙인다.

  • 프록시 뒤라면 실제 구조를 이해한 뒤 trust proxy를 설정한다.

  • 요청 ID가 포함된 구조화 로그를 남기고, 민감 정보는 가린다.

  • /health/live, /health/ready 같은 헬스체크를 만든다.

  • SIGTERM, SIGINT를 받아 graceful shutdown 하도록 만든다.

  • 동기식 무거운 작업, 무한 대기 외부 호출, 너무 큰 메모리 사용을 점검한다.

  • 프로세스가 죽었을 때를 대비해 PM2나 systemd 같은 재시작 전략을 준비한다.1

이 정도만 챙겨도 서버는 꽤 달라진다. 단순히 실행되는 서버가 아니라, 문제가 생겨도 덜 당황하는 서버가 된다.

멋진 서버는 에러가 없는 서버가 아니다. 에러가 나도 덜 무너지고, 종료할 때도 덜 민폐 끼치고, 로그를 보면 무슨 일이 있었는지 말해 주는 서버다. 운영 준비는 결국 그런 서버를 만드는 일이다.

참고

1Express Production Best Practices: Performance and Reliability

3Express Behind Proxies

5Express Health Checks & Graceful Shutdown

책 목록

처음부터 배포까지, Node.js Express 서버 수업

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기