MySQL 커넥션 풀
데이터베이스에 쿼리를 보내려면 먼저 커넥션을 맺어야 한다. 커넥션을 맺는다는 건 TCP 핸드셰이크, 인증, 세션 초기화 같은 과정을 거친다는 뜻이다. 문제는 이 과정이 꽤 비싸다는 것이다. 쿼리 하나 실행하는 데 실제 쿼리 시간보다 커넥션 맺는 시간이 더 오래 걸리는 경우도 흔하다.
가장 단순한 방식은 쿼리할 때마다 커넥션을 새로 만들고, 쿼리가 끝나면 닫는 것이다.
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];
}
이 코드는 동작하지만, 요청이 많아지면 심각한 문제가 생긴다.
- 매번 TCP 핸드셰이크 + 인증 — 네트워크 왕복이 쿼리마다 추가된다
- 동시 커넥션 폭발 — 요청 100개가 동시에 오면 커넥션 100개가 한꺼번에 생긴다. MySQL의
max_connections기본값은 151인데, 이걸 넘기면Too many connections에러가 터진다 - 커넥션 누수 위험 — 에러 발생 시
connection.end()를 호출하지 못하면 커넥션이 좀비처럼 남아있게 된다
커넥션 풀은 이 문제를 해결한다. 미리 일정 수의 커넥션을 만들어두고, 필요할 때 빌려 쓰고, 다 쓰면 반납하는 방식이다. 커넥션을 새로 만드는 게 아니라 이미 만들어진 걸 재활용하기 때문에 커넥션 생성 비용이 사라지고, 동시 커넥션 수도 제어할 수 있다.
mysql2에서 커넥션 풀 생성
mysql2/promise의 createPool()로 풀을 생성한다.
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
waitForConnections가 true일 때, 대기 큐에 쌓일 수 있는 최대 요청 수다.
0(기본값) — 무제한- 양수 — 해당 수만큼만 대기, 초과하면 에러
기본값 0을 쓰면 큐가 무한히 쌓일 수 있어서 메모리 문제가 생길 수 있다. 프로덕션에서는 적절한 상한을 두는 것이 안전하다.
const pool = mysql.createPool({
// ... 기본 설정
connectionLimit: 10,
waitForConnections: true,
queueLimit: 50, // 대기 요청이 50개를 넘으면 에러
});
idleTimeout
mysql2 v3.x부터 지원하는 옵션이다. 사용하지 않는 유휴 커넥션을 자동으로 정리한다. 밀리초 단위로 설정하며, 이 시간 동안 사용되지 않은 커넥션은 풀에서 제거된다.
const pool = mysql.createPool({
// ...
idleTimeout: 60000, // 60초 동안 미사용 시 제거
});
트래픽이 간헐적인 서비스에서 유용하다. 새벽에 트래픽이 없을 때 불필요한 커넥션을 유지하는 비용을 줄일 수 있다.
쿼리 실행 방식
커넥션 풀에서 쿼리를 실행하는 방법은 두 가지다.
방법 1: pool.execute() 직접 사용
const [rows] = await pool.execute('SELECT * FROM users WHERE id = ?', [1]);
가장 간단한 방법이다. 풀에서 자동으로 커넥션을 빌려와서 쿼리를 실행하고, 끝나면 자동으로 반납한다. 단일 쿼리를 실행할 때 적합하다.
pool.query()도 동일하게 동작하지만, execute()는 내부적으로 prepared statement를 사용해서 SQL injection 방어에 더 안전하고 반복 쿼리 시 약간의 성능 이점이 있다.
방법 2: getConnection()으로 커넥션 직접 관리
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()를 여러 번 호출하면 각각 다른 커넥션에서 실행될 수 있기 때문에 트랜잭션이 깨진다.
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();
}
}
이 패턴은 실무에서 반복적으로 쓰이기 때문에, 보통 헬퍼 함수로 추상화한다.
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에 접근할 수 있다.
// 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();
}
},
};
이렇게 하면 사용하는 쪽에서는 풀의 존재를 신경 쓸 필요가 없다.
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는 풀의 내부 상태를 확인할 수 있는 속성을 제공한다.
// pool._allConnections.length — 전체 커넥션 수
// pool._freeConnections.length — 유휴 커넥션 수
// pool._connectionQueue.length — 대기 중인 요청 수
이 값들을 주기적으로 로깅하면 풀이 고갈되는 상황을 미리 감지할 수 있다.
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() 호출 누락
// ❌ 에러 발생 시 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();
}
풀을 매번 새로 생성
// ❌ 요청마다 풀을 새로 만들면 의미가 없다
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 정도에서 시작하고, 대기 큐 모니터링으로 조정한다
관련 문서
- mysql2 - mysql2 기본 사용법
- TypeORM으로 마이그레이션 관리
- async-handler로 Express 에러 전파