junyeokk
Blog
database·2024. 08. 27

MySQL 커넥션 풀

데이터베이스에 쿼리를 보내려면 먼저 커넥션을 맺어야 한다. 커넥션을 맺는다는 건 TCP 핸드셰이크, 인증, 세션 초기화 같은 과정을 거친다는 뜻이다. 문제는 이 과정이 꽤 비싸다는 것이다. 쿼리 하나 실행하는 데 실제 쿼리 시간보다 커넥션 맺는 시간이 더 오래 걸리는 경우도 흔하다.

가장 단순한 방식은 쿼리할 때마다 커넥션을 새로 만들고, 쿼리가 끝나면 닫는 것이다.

javascript
const mysql = require('mysql2/promise');

async function getUser(id) {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'my_app'
  });

  const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [id]);
  await connection.end();
  return rows[0];
}

이 코드는 동작하지만, 요청이 많아지면 심각한 문제가 생긴다.

  1. 매번 TCP 핸드셰이크 + 인증 — 네트워크 왕복이 쿼리마다 추가된다
  2. 동시 커넥션 폭발 — 요청 100개가 동시에 오면 커넥션 100개가 한꺼번에 생긴다. MySQL의 max_connections 기본값은 151인데, 이걸 넘기면 Too many connections 에러가 터진다
  3. 커넥션 누수 위험 — 에러 발생 시 connection.end()를 호출하지 못하면 커넥션이 좀비처럼 남아있게 된다

커넥션 풀은 이 문제를 해결한다. 미리 일정 수의 커넥션을 만들어두고, 필요할 때 빌려 쓰고, 다 쓰면 반납하는 방식이다. 커넥션을 새로 만드는 게 아니라 이미 만들어진 걸 재활용하기 때문에 커넥션 생성 비용이 사라지고, 동시 커넥션 수도 제어할 수 있다.


mysql2에서 커넥션 풀 생성

mysql2/promisecreatePool()로 풀을 생성한다.

javascript
const mysql = require('mysql2/promise');

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

풀을 한 번 생성하면 애플리케이션이 종료될 때까지 재사용한다. 보통 모듈 레벨에서 하나만 만들고 export해서 쓴다.


주요 옵션

connectionLimit

풀이 동시에 유지하는 최대 커넥션 수다. 기본값은 10이다.

이 값을 어떻게 정할지가 핵심인데, 무작정 크게 잡으면 안 된다. MySQL 서버도 커넥션마다 메모리를 할당하기 때문에, 커넥션이 많아지면 서버 메모리가 부족해진다. 또 커넥션이 너무 많으면 컨텍스트 스위칭 비용이 늘어나서 오히려 전체 처리량이 떨어진다.

일반적인 가이드라인:

  • 소규모 앱: 5~10
  • 중규모 앱: 10~30
  • 대규모 앱: 서버 스펙과 부하 테스트 결과에 따라 결정

MySQL 공식 문서에서도 "커넥션 수를 줄이면 오히려 성능이 좋아지는 경우가 많다"고 말한다. HikariCP의 풀 사이징 가이드에서는 connections = (CPU 코어 수 * 2) + 유효 스핀들 수라는 공식을 제안하기도 한다. SSD 환경에서는 CPU 코어 수의 2~3배 정도가 적당하다.

waitForConnections

풀의 모든 커넥션이 사용 중일 때 새로운 요청이 들어오면 어떻게 할지를 결정한다.

  • true (기본값) — 커넥션이 반납될 때까지 큐에서 대기한다
  • false — 즉시 에러를 던진다

거의 항상 true로 설정한다. false로 설정하면 순간적인 부하에도 에러가 발생해서 사용자 경험이 나빠진다.

queueLimit

waitForConnectionstrue일 때, 대기 큐에 쌓일 수 있는 최대 요청 수다.

  • 0 (기본값) — 무제한
  • 양수 — 해당 수만큼만 대기, 초과하면 에러

기본값 0을 쓰면 큐가 무한히 쌓일 수 있어서 메모리 문제가 생길 수 있다. 프로덕션에서는 적절한 상한을 두는 것이 안전하다.

javascript
const pool = mysql.createPool({
  // ... 기본 설정
  connectionLimit: 10,
  waitForConnections: true,
  queueLimit: 50,  // 대기 요청이 50개를 넘으면 에러
});

idleTimeout

mysql2 v3.x부터 지원하는 옵션이다. 사용하지 않는 유휴 커넥션을 자동으로 정리한다. 밀리초 단위로 설정하며, 이 시간 동안 사용되지 않은 커넥션은 풀에서 제거된다.

javascript
const pool = mysql.createPool({
  // ...
  idleTimeout: 60000,  // 60초 동안 미사용 시 제거
});

트래픽이 간헐적인 서비스에서 유용하다. 새벽에 트래픽이 없을 때 불필요한 커넥션을 유지하는 비용을 줄일 수 있다.


쿼리 실행 방식

커넥션 풀에서 쿼리를 실행하는 방법은 두 가지다.

방법 1: pool.execute() 직접 사용

javascript
const [rows] = await pool.execute('SELECT * FROM users WHERE id = ?', [1]);

가장 간단한 방법이다. 풀에서 자동으로 커넥션을 빌려와서 쿼리를 실행하고, 끝나면 자동으로 반납한다. 단일 쿼리를 실행할 때 적합하다.

pool.query()도 동일하게 동작하지만, execute()는 내부적으로 prepared statement를 사용해서 SQL injection 방어에 더 안전하고 반복 쿼리 시 약간의 성능 이점이 있다.

방법 2: getConnection()으로 커넥션 직접 관리

javascript
const connection = await pool.getConnection();
try {
  const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [1]);
  return rows;
} finally {
  connection.release();  // 반드시 반납!
}

커넥션을 직접 빌려와서 사용하는 방식이다. 반드시 release()를 호출해야 한다. 호출하지 않으면 커넥션이 풀에 반납되지 않아서 결국 풀이 고갈된다. 이를 커넥션 누수(connection leak)라고 한다.

try/finally 패턴으로 에러가 발생해도 반드시 반납되도록 하는 것이 필수다.

이 방식은 주로 트랜잭션에서 사용한다. 여러 쿼리를 하나의 커넥션에서 실행해야 트랜잭션이 보장되기 때문이다.


트랜잭션과 커넥션 풀

트랜잭션은 반드시 같은 커넥션에서 실행해야 한다. pool.execute()를 여러 번 호출하면 각각 다른 커넥션에서 실행될 수 있기 때문에 트랜잭션이 깨진다.

javascript
async function transferMoney(fromId, toId, amount) {
  const connection = await pool.getConnection();
  try {
    await connection.beginTransaction();

    await connection.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ?',
      [amount, fromId]
    );
    await connection.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, toId]
    );

    await connection.commit();
  } catch (error) {
    await connection.rollback();
    throw error;
  } finally {
    connection.release();
  }
}

이 패턴은 실무에서 반복적으로 쓰이기 때문에, 보통 헬퍼 함수로 추상화한다.

javascript
async function withTransaction(callback) {
  const connection = await pool.getConnection();
  try {
    await connection.beginTransaction();
    const result = await callback(connection);
    await connection.commit();
    return result;
  } catch (error) {
    await connection.rollback();
    throw error;
  } finally {
    connection.release();
  }
}

// 사용
await withTransaction(async (conn) => {
  await conn.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
  await conn.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
});

beginTransaction(), commit(), rollback(), release() 보일러플레이트를 한 번만 작성하고 재사용할 수 있다. 콜백에 connection을 넘기기 때문에 콜백 안에서 해당 커넥션으로 쿼리를 실행하면 된다.


쿼리 래퍼 패턴

풀을 모듈로 감싸서 export하면, 애플리케이션 전체에서 일관된 방식으로 DB에 접근할 수 있다.

javascript
// db.js
const mysql = require('mysql2/promise');

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

module.exports = {
  query: async (sql, params) => {
    const [rows] = await pool.execute(sql, params);
    return rows;
  },
  transaction: async (callback) => {
    const connection = await pool.getConnection();
    try {
      await connection.beginTransaction();
      const result = await callback(connection);
      await connection.commit();
      return result;
    } catch (error) {
      await connection.rollback();
      throw error;
    } finally {
      connection.release();
    }
  },
};

이렇게 하면 사용하는 쪽에서는 풀의 존재를 신경 쓸 필요가 없다.

javascript
const db = require('./db');

// 단순 쿼리
const users = await db.query('SELECT * FROM users WHERE active = ?', [true]);

// 트랜잭션
await db.transaction(async (conn) => {
  await conn.execute('INSERT INTO orders (user_id, total) VALUES (?, ?)', [1, 5000]);
  await conn.execute('UPDATE users SET order_count = order_count + 1 WHERE id = ?', [1]);
});

호출부가 깔끔해지고, 커넥션 관리 로직이 한 곳에 모여 있어서 유지보수가 쉬워진다.


풀 모니터링

운영 환경에서는 풀 상태를 모니터링하는 것이 중요하다. mysql2는 풀의 내부 상태를 확인할 수 있는 속성을 제공한다.

javascript
// pool._allConnections.length  — 전체 커넥션 수
// pool._freeConnections.length — 유휴 커넥션 수
// pool._connectionQueue.length — 대기 중인 요청 수

이 값들을 주기적으로 로깅하면 풀이 고갈되는 상황을 미리 감지할 수 있다.

javascript
setInterval(() => {
  console.log({
    total: pool.pool._allConnections.length,
    free: pool.pool._freeConnections.length,
    queued: pool.pool._connectionQueue.length,
  });
}, 30000);

대기 큐가 지속적으로 쌓이면 connectionLimit을 올리거나 쿼리 성능을 개선해야 한다는 신호다.


커넥션 풀 vs 단일 커넥션 비교

항목단일 커넥션커넥션 풀
커넥션 생성 비용매 요청마다 발생최초 1회 (이후 재사용)
동시 처리제한적connectionLimit까지 병렬 처리
커넥션 누수 위험높음 (수동 close)낮음 (release로 반납)
트랜잭션직접 관리getConnection()으로 격리
적합한 상황스크립트, 일회성 작업웹 서버, 지속적 서비스

자주 하는 실수

release() 호출 누락

javascript
// ❌ 에러 발생 시 release()가 호출되지 않는다
const conn = await pool.getConnection();
const [rows] = await conn.execute('SELECT ...');
conn.release();

// ✅ try/finally로 반드시 반납
const conn = await pool.getConnection();
try {
  const [rows] = await conn.execute('SELECT ...');
  return rows;
} finally {
  conn.release();
}

풀을 매번 새로 생성

javascript
// ❌ 요청마다 풀을 새로 만들면 의미가 없다
app.get('/users', async (req, res) => {
  const pool = mysql.createPool({ ... });
  const [rows] = await pool.execute('SELECT * FROM users');
  res.json(rows);
});

// ✅ 풀은 모듈 레벨에서 한 번만 생성
const pool = mysql.createPool({ ... });

app.get('/users', async (req, res) => {
  const [rows] = await pool.execute('SELECT * FROM users');
  res.json(rows);
});

end() vs release() 혼동

  • connection.release() — 커넥션을 풀에 반납한다. 풀에서 빌린 커넥션에 사용한다.
  • connection.end() — 커넥션을 완전히 닫는다. createConnection()으로 만든 단일 커넥션에 사용한다.

풀에서 빌린 커넥션에 end()를 호출하면 해당 커넥션이 풀에서 영구적으로 제거된다. 풀의 커넥션 수가 줄어들면서 성능이 저하될 수 있다.


정리

  • 커넥션 풀은 TCP 핸드셰이크+인증 비용을 제거하고 동시 커넥션 수를 connectionLimit으로 제어한다
  • 트랜잭션은 반드시 getConnection()으로 같은 커넥션을 유지하고, try/finally로 release()를 보장해야 한다
  • connectionLimit은 CPU 코어 수 × 2~3 정도에서 시작하고, 대기 큐 모니터링으로 조정한다

관련 문서