서버 안정성 (타임아웃, 재시도, Connection Pool, Graceful Shutdown)

서버 안정성(Server Reliability)이란 예상치 못한 오류, 네트워크 장애, 외부 서비스 지연, 프로세스 종료 등의 상황에서도 시스템이 정상적인 상태를 유지하거나 안전하게 복구할 수 있는 능력을 의미한다.

서버는 단순히 요청을 정상적으로 처리하는 것만으로 충분하지 않다. 데이터베이스 응답이 지연되거나 외부 API가 응답하지 않는 상황, 갑작스러운 종료 신호가 전달되는 상황에서도 리소스를 적절히 관리하고 데이터의 일관성을 유지해야 한다.

특히 실시간 게임 서버에서는 연결된 클라이언트, 진행 중인 게임 세션, 데이터베이스 연결, Redis 캐시 등을 함께 관리해야 한다. 따라서 서버 안정성은 개별 기능의 구현보다 장애가 발생했을 때 시스템이 어떻게 동작하는지를 설계하는 문제에 가깝다.

이 글에서는 서버 안정성을 구성하는 핵심 요소인 타임아웃과 재시도, Connection Pool, Graceful Shutdown, 시그널 처리와 리소스 정리를 살펴본다.


타임아웃(Timeout)

타임아웃이란?

타임아웃은 특정 작업이 정해진 시간 안에 완료되지 않으면 해당 작업을 중단하거나 실패로 처리하는 정책이다.

네트워크 요청이나 데이터베이스 쿼리가 언제나 정상적으로 완료된다는 보장은 없다. 외부 서버가 응답하지 않거나 데이터베이스에 과부하가 발생하면 요청이 오랫동안 대기할 수 있다.

타임아웃이 없다면 이러한 요청이 계속해서 리소스를 점유할 수 있다. 요청을 처리하는 데 필요한 메모리, 연결, 작업 슬롯이 소모되면 새로운 요청까지 처리하지 못하는 상황으로 이어진다.

예를 들어 게임 서버가 결제 검증 API를 호출했는데 외부 서버가 응답하지 않는다고 가정하자.

  • 타임아웃이 없다면 응답을 기다리는 요청이 계속 누적될 수 있다.
  • 타임아웃이 있다면 지정된 시간 이후 요청을 실패로 처리하고 후속 정책을 실행할 수 있다.

타임아웃은 단순히 기다리는 시간을 제한하는 기능이 아니다. 요청 하나가 시스템의 자원을 무한정 점유하지 못하게 하는 안정성 정책이다.

타임아웃의 종류

종류설명
Connection Timeout서버 또는 데이터베이스와 연결을 수립하는 최대 대기 시간
Request Timeout요청 전체가 완료될 때까지 허용하는 최대 시간
Read Timeout응답 데이터를 읽는 동안 허용하는 대기 시간
Write Timeout데이터를 전송하는 동안 허용하는 대기 시간
Idle Timeout일정 시간 동안 사용하지 않은 연결을 유지하는 최대 시간

각 타임아웃은 서로 다른 상황을 제어한다.

예를 들어 Connection Timeout은 연결 자체가 성립하지 않는 문제를 제한하지만, 연결에 성공한 뒤 응답이 오지 않는 문제까지 해결하지는 못한다. 따라서 외부 API나 데이터베이스를 호출할 때는 작업의 특성에 맞게 여러 타임아웃을 설정해야 한다.

Node.js에서 타임아웃 구현하기

다음은 fetch와 AbortSignal.timeout()을 이용한 외부 API 요청 예시다.

async function requestExternalApi() {
  try {
    const response = await fetch(
      "https://api.example.com/data",
      {
        signal: AbortSignal.timeout(3000),
      }
    );

    if (!response.ok) {
      throw new Error(
        `HTTP Error: ${response.status}`
      );
    }

    return await response.json();
  } catch (error) {
    if (error.name === "TimeoutError") {
      console.error("API 요청 시간 초과");
    } else {
      console.error("API 요청 실패", error);
    }

    throw error;
  }
}

위 코드는 요청이 제한 시간 안에 완료되지 않으면 타임아웃 오류를 발생시킨다.

다만 타임아웃은 클라이언트가 응답을 기다리는 시간을 제한하는 것이지, 외부 시스템에서 이미 실행된 작업까지 반드시 취소한다는 의미는 아니다. 특히 결제나 데이터 변경 요청은 타임아웃 이후에도 실제 처리가 완료되었을 가능성을 고려해야 한다.


재시도(Retry)

재시도란?

재시도는 일시적인 오류로 작업이 실패했을 때 동일한 작업을 다시 실행하는 전략이다.

네트워크 통신에서는 순간적인 연결 장애나 일시적인 서버 과부하가 발생할 수 있다. 이러한 오류는 시간이 조금 지난 뒤 다시 요청하면 정상적으로 처리될 가능성이 있다.

하지만 모든 오류를 재시도하는 것은 올바른 방법이 아니다.

예를 들어 다음과 같이 구분할 수 있다.

  • 일시적인 네트워크 오류: 재시도를 고려할 수 있다.
  • HTTP 429 Too Many Requests: 서버가 허용하는 정책에 따라 재시도할 수 있다.
  • HTTP 503 Service Unavailable: 일시적인 장애라면 재시도할 수 있다.
  • HTTP 400 Bad Request: 요청 자체가 잘못되었다면 일반적으로 재시도할 필요가 없다.
  • 권한 부족이나 잘못된 인증 정보: 원인을 해결하지 않는 한 재시도해도 실패한다.

재시도는 실패를 무조건 반복하는 기능이 아니라, 복구 가능한 실패를 제한된 비용으로 다시 시도하는 정책이다.

재시도 횟수 제한

재시도 횟수를 제한하지 않으면 장애가 발생한 서비스에 요청이 계속 유입될 수 있다.

예를 들어 초당 1,000개의 요청을 처리하는 서버에서 모든 요청이 세 번씩 재시도된다면, 최악의 경우 원래 요청량보다 훨씬 많은 작업이 발생한다. 장애가 발생한 시스템의 부하를 더욱 증가시키는 것이다.

따라서 다음과 같은 정책이 필요하다.

  • 최대 재시도 횟수를 설정한다.
  • 재시도 가능한 오류만 대상으로 한다.
  • 재시도 사이에 대기 시간을 둔다.
  • 전체 요청에 대한 제한 시간도 함께 관리한다.
  • 재시도가 실패하면 오류를 상위 계층으로 전달한다.

Exponential Backoff

Exponential Backoff는 재시도할 때마다 대기 시간을 기하급수적으로 증가시키는 방식이다.

예를 들어 초기 대기 시간이 200ms라면 다음과 같이 증가시킬 수 있다.

재시도 횟수대기 시간
1회200ms
2회400ms
3회800ms
4회1,600ms

이를 통해 장애가 발생한 서비스에 요청이 짧은 간격으로 몰리는 현상을 줄일 수 있다.

실제 운영 환경에서는 여러 클라이언트가 동시에 재시도하는 문제를 완화하기 위해 대기 시간에 무작위 지연을 추가하는 Jitter 기법도 사용한다.

Node.js 재시도 구현

const sleep = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function retry(
  operation,
  {
    maxRetries = 3,
    initialDelay = 200,
  } = {}
) {
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;

      if (attempt === maxRetries) {
        break;
      }

      // 실제 서비스에서는 오류 유형에 따라
      // 재시도 가능 여부를 먼저 판단해야 한다.
      const delay =
        initialDelay * 2 ** attempt;

      await sleep(delay);
    }
  }

  throw lastError;
}

사용 예시는 다음과 같다.

const result = await retry(
  () => requestExternalApi(),
  {
    maxRetries: 3,
    initialDelay: 200,
  }
);

이 코드는 최초 실행을 포함해 최대 네 번 작업을 시도한다.

다만 예시 구현에는 재시도 가능한 오류 판별, Jitter, 전체 작업 시간 제한이 포함되어 있지 않다. 실제 운영 코드에서는 이러한 정책을 추가해야 한다.

또한 재시도는 작업의 중복 실행을 유발할 수 있다. 결제 요청이나 재화 지급처럼 중복 실행이 치명적인 작업은 멱등성 키(Idempotency Key)나 트랜잭션 등의 보호 장치 없이 무조건 재시도해서는 안 된다.


Connection Pool

Connection Pool이란?

Connection Pool은 데이터베이스나 외부 서비스에 사용할 연결을 미리 생성하고 관리하는 구조다.

데이터베이스 연결을 요청할 때마다 새 연결을 생성하면 연결 수립에 따른 네트워크 통신과 인증 등의 비용이 반복적으로 발생한다.

반면 Connection Pool은 생성된 연결을 재사용한다. 요청이 연결을 필요로 하면 풀에서 사용 가능한 연결을 빌리고, 작업이 끝나면 다시 반환한다.

일반적인 흐름은 다음과 같다.

  1. 서버가 시작될 때 연결 풀을 생성한다.
  2. 요청이 데이터베이스 작업을 요구한다.
  3. 풀에서 사용 가능한 연결을 획득한다.
  4. SQL 쿼리를 실행한다.
  5. 작업이 끝나면 연결을 풀에 반환한다.
  6. 다음 요청이 해당 연결을 재사용한다.

Connection Pool은 연결 생성 비용을 줄이고 데이터베이스에 동시에 접근하는 연결 수를 제어한다.

Connection Pool이 필요한 이유

연결 생성 비용 절감

매번 연결을 생성하는 대신 기존 연결을 재사용하므로 연결 수립에 필요한 비용을 줄일 수 있다.

데이터베이스 부하 제한

풀의 최대 연결 수를 설정하면 하나의 서버 인스턴스가 데이터베이스에 생성하는 연결 수를 제한할 수 있다.

동시성 제어

모든 연결이 사용 중이라면 새로운 작업은 연결이 반환될 때까지 대기할 수 있다. 이를 통해 데이터베이스가 감당할 수 있는 수준으로 동시 접근을 제한할 수 있다.

하지만 Connection Pool이 있다고 해서 요청이 무한히 대기해도 되는 것은 아니다. 연결을 획득하기 위한 대기 시간에도 제한이 필요하다.

Node.js와 MySQL Connection Pool

mysql2 패키지를 사용한 예시다.

import mysql from "mysql2/promise";

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 100,
});

export async function findUser(userId) {
  const [rows] = await pool.execute(
    "SELECT id, nickname FROM users WHERE id = ?",
    [userId]
  );

  return rows[0] ?? null;
}

여기서 주요 설정은 다음과 같다.

설정의미
waitForConnections사용 가능한 연결이 없을 때 대기할지 여부
connectionLimit해당 풀에서 유지할 수 있는 최대 연결 수
queueLimit연결을 기다리는 요청 대기열의 최대 크기

queueLimit을 초과하면 연결을 획득하지 못하는 요청이 오류로 처리될 수 있다. 무제한 대기열은 요청이 계속 누적될 수 있으므로 트래픽 특성에 따라 제한하는 것이 일반적이다.

또한 쿼리가 실패하더라도 풀 자체를 매번 종료할 필요는 없다. 정상적인 풀 사용 패턴에서는 작업이 끝나면 연결을 반환하고, 서버를 종료할 때 풀 전체를 닫는다.

Connection Pool의 크기와 데이터베이스 부하

Connection Pool의 크기를 크게 설정한다고 반드시 성능이 향상되는 것은 아니다.

연결 수가 너무 적으면 요청이 연결을 기다리느라 지연될 수 있다. 반대로 연결 수가 너무 많으면 데이터베이스의 메모리와 CPU 사용량이 증가하고, 컨텍스트 전환이나 잠금 경합 등으로 성능이 오히려 저하될 수 있다.

특히 서버를 여러 인스턴스로 확장하는 환경에서는 전체 연결 수를 계산해야 한다.

예를 들어 다음과 같은 구성을 가정하자.

  • 서버 인스턴스: 5개
  • 인스턴스당 Connection Pool 최대 크기: 20개

이 경우 모든 풀이 최대치에 도달하면 데이터베이스 연결이 최대 100개까지 생성될 수 있다.

따라서 Connection Pool은 개별 서버만 고려해 설정할 것이 아니라 데이터베이스의 최대 연결 수와 다른 서비스의 연결 사용량까지 고려해 설계해야 한다.


Graceful Shutdown

Graceful Shutdown이란?

Graceful Shutdown은 서버 종료 요청을 받았을 때 진행 중인 작업을 가능한 한 안전하게 마무리한 후 프로세스를 종료하는 방식이다.

서버가 종료될 때 단순히 프로세스를 강제로 중단하면 다음과 같은 문제가 발생할 수 있다.

  • 진행 중인 HTTP 요청이 중단된다.
  • 데이터베이스 트랜잭션이 정상적으로 마무리되지 않을 수 있다.
  • 실시간 게임 세션이 예기치 않게 끊긴다.
  • 로그나 메시지 전송이 완료되지 않을 수 있다.
  • 데이터베이스와 Redis 연결이 정리되지 않는다.

Graceful Shutdown은 이러한 문제를 줄이기 위해 종료 절차를 명시적으로 정의한다.

Graceful Shutdown의 처리 순서

일반적인 서버에서는 다음과 같은 순서로 종료를 진행할 수 있다.

  1. 종료 신호를 감지한다.
  2. 신규 요청을 받지 않도록 한다.
  3. 로드 밸런서의 트래픽 분배 대상에서 제외되도록 한다.
  4. 진행 중인 요청과 작업이 완료되기를 기다린다.
  5. 게임 세션이나 메시지 처리 등 애플리케이션 자원을 정리한다.
  6. 데이터베이스 연결 풀과 Redis 연결을 종료한다.
  7. 모든 정리가 끝나면 프로세스를 종료한다.

실제 순서는 서버 구조에 따라 달라질 수 있다.

예를 들어 HTTP 서버는 신규 연결 수락을 중단하는 것이 중요하고, 실시간 게임 서버는 새로운 매치 참여를 차단하면서 기존 세션을 어떻게 이전하거나 종료할지 결정해야 한다.

또한 종료 대기 시간에는 상한을 두어야 한다. 일부 작업이 영원히 완료되지 않으면 서버가 종료되지 못하기 때문이다. 제한 시간을 초과하면 남은 작업을 취소하거나 강제 종료하는 최종 정책이 필요하다.

Node.js Graceful Shutdown 구현

다음은 HTTP 서버와 MySQL Connection Pool을 사용하는 간단한 예시다.

import http from "node:http";
import mysql from "mysql2/promise";

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,
});

const server = http.createServer(async (req, res) => {
  try {
    const [rows] = await pool.execute(
      "SELECT 1 AS result"
    );

    res.writeHead(200, {
      "Content-Type": "application/json",
    });

    res.end(JSON.stringify(rows[0]));
  } catch (error) {
    console.error("요청 처리 실패", error);

    if (!res.headersSent) {
      res.writeHead(500);
      res.end("Internal Server Error");
    }
  }
});

server.listen(3000, () => {
  console.log("Server listening on port 3000");
});

let shuttingDown = false;

function gracefulShutdown(signal) {
  if (shuttingDown) {
    return;
  }

  shuttingDown = true;

  console.log(`${signal} received. Shutting down...`);

  const forceExitTimer = setTimeout(() => {
    console.error("Shutdown timeout exceeded");

    // 최종 종료 정책.
    // 이 시점에는 일부 작업이 중단될 수 있다.
    process.exit(1);
  }, 10000);

  forceExitTimer.unref();

  // 신규 연결 수락을 중단하고 기존 연결 종료를 기다린다.
  server.close(async (serverError) => {
    try {
      if (serverError) {
        throw serverError;
      }

      // 애플리케이션의 백그라운드 작업과
      // 별도 리소스도 이 단계에서 정리해야 한다.
      await pool.end();

      console.log("Shutdown completed");

      clearTimeout(forceExitTimer);
      process.exitCode = 0;
    } catch (error) {
      console.error("Shutdown failed", error);

      clearTimeout(forceExitTimer);
      process.exitCode = 1;
    }
  });
}

process.on("SIGTERM", () => {
  gracefulShutdown("SIGTERM");
});

process.on("SIGINT", () => {
  gracefulShutdown("SIGINT");
});

위 코드는 개념을 설명하기 위한 기본 구조다.

server.close()는 신규 연결 수락을 중단하고 기존 연결의 종료를 기다린다. 다만 웹소켓처럼 연결이 오래 유지되는 프로토콜은 별도의 종료 처리가 필요할 수 있다. 또한 이미 수락된 연결에서 신규 요청이 계속 들어오는 것을 막으려면 애플리케이션 수준의 종료 상태 검사도 추가해야 한다.

운영 환경에서는 종료 신호가 들어온 직후 readiness 상태를 변경하고, 로드 밸런서가 트래픽을 제거할 시간을 확보하는 절차도 고려해야 한다.


시그널 처리와 리소스 정리

시그널이란?

시그널(Signal)은 운영체제가 프로세스에 특정 이벤트를 전달하는 메커니즘이다.

Node.js 서버는 운영체제나 프로세스 관리 도구로부터 시그널을 전달받아 종료 절차를 실행할 수 있다.

주요 시그널은 다음과 같다.

시그널일반적인 의미
SIGINT터미널에서 Ctrl+C를 누르는 등의 인터럽트
SIGTERM프로세스에 정상 종료를 요청
SIGKILL프로세스를 강제로 종료
SIGHUP전통적으로 터미널 연결 종료를 의미하며, 일부 서버에서는 설정 재로드 등에 사용

특히 SIGTERM은 Docker나 Kubernetes 같은 운영 환경에서 프로세스 종료를 요청할 때 자주 사용된다.

반면 SIGKILL은 프로세스가 직접 처리하거나 무시할 수 없다. 따라서 SIGKILL이 전달된 뒤 정리 작업을 수행하려고 해서는 안 된다.

리소스 정리가 필요한 이유

서버는 실행 중 다양한 리소스를 획득한다.

  • HTTP 서버와 WebSocket 연결
  • MySQL Connection Pool
  • Redis 연결
  • 파일 핸들
  • 백그라운드 작업
  • 메시지 큐 소비자
  • 주기적으로 실행되는 작업
  • 로그 전송 파이프라인

프로세스가 정상적으로 종료될 때는 이러한 리소스를 적절히 정리해야 한다.

예를 들어 데이터베이스 연결 풀을 종료하지 않으면 관련 소켓이나 작업이 남아 프로세스 종료를 지연시킬 수 있다. 백그라운드 작업이 계속 실행되는 경우에도 마찬가지다.

단, 프로세스가 종료되면 운영체제가 대부분의 프로세스 리소스를 회수하므로 모든 리소스가 영구적으로 남는 것은 아니다. 핵심은 리소스 회수 자체뿐 아니라 진행 중인 작업의 일관성, 외부 시스템과의 정상적인 연결 종료, 로그 유실 방지 등을 관리하는 것이다.

여러 리소스의 종료 순서 관리

실제 애플리케이션에서는 종료 작업을 하나의 함수에 전부 작성하기보다 각 리소스의 종료 책임을 분리하는 편이 관리하기 쉽다.

const resources = {
  httpServer,
  mysqlPool,
  redisClient,
  gameServer,
};

async function closeResources() {
  // 1. 신규 게임 참여 차단
  await resources.gameServer.stopAcceptingPlayers();

  // 2. 진행 중인 게임과 작업 정리
  await resources.gameServer.shutdown();

  // 3. HTTP 서버 종료
  await new Promise((resolve, reject) => {
    resources.httpServer.close((error) => {
      if (error) reject(error);
      else resolve();
    });
  });

  // 4. 저장소 연결 종료
  await resources.mysqlPool.end();
  await resources.redisClient.quit();
}

위 코드는 리소스별 종료 책임을 보여주는 개념적 예시다. 실제로 사용하려면 각 객체가 해당 메서드를 제공해야 하며, 게임 서버와 HTTP 서버의 종료 순서는 구체적인 아키텍처에 맞게 조정해야 한다.

중요한 점은 종료 작업도 실패할 수 있다는 것이다. 특정 리소스의 정리가 실패했다고 해서 나머지 리소스의 정리까지 건너뛰어서는 안 된다. 운영 코드에서는 각 종료 작업에 타임아웃과 오류 처리를 적용하고, 독립적인 리소스는 가능한 한 각각 정리하도록 구성해야 한다.

또한 종료 절차를 여러 번 호출해도 중복 정리로 문제가 발생하지 않도록 멱등성을 확보하는 것이 좋다.


실시간 게임 서버에서의 적용

실시간 게임 서버에서는 일반적인 HTTP 서버보다 종료 정책이 더 복잡할 수 있다. 게임 세션은 단순한 요청 하나가 아니라 여러 클라이언트와 서버 상태가 연결된 장기 실행 작업이기 때문이다.

예를 들어 Colyseus 기반 서버를 운영한다고 가정하자.

타임아웃과 재시도

클라이언트 연결이 일시적으로 끊겼을 때 재접속을 허용할 수 있다. 다만 재접속 대기 시간과 세션 유지 시간을 제한하고, 모든 오류를 무조건 재시도하지 않도록 해야 한다.

Connection Pool

게임 상태와 유저 정보를 저장하는 MySQL 및 Redis 연결 수를 제한한다. 서버 인스턴스가 늘어날 때 전체 연결 수가 데이터베이스의 처리 한도를 초과하지 않도록 관리한다.

Graceful Shutdown

새로운 Room 생성이나 매치 참여를 차단하고, 기존 Room을 정상적으로 종료하거나 다른 서버로 이전할 수 있는지 검토한다. 서버가 종료된다는 사실만으로 게임 상태가 자동으로 다른 서버에 복구되는 것은 아니다.

시그널 처리와 리소스 정리

종료 신호를 받으면 신규 세션 수락을 중단하고, 저장해야 할 상태와 로그를 처리한 뒤 데이터베이스 및 Redis 연결을 종료한다. 단, 모든 게임 상태를 무조건 종료 시점에만 저장하면 프로세스 강제 종료 시 데이터가 유실될 수 있으므로 중요한 상태는 플레이 중에도 적절히 영속화해야 한다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다