
커넥션 풀: DB 연결 재사용
데이터베이스 커넥션 풀의 개념과 성능 최적화를 경험을 통해 이해한 과정

데이터베이스 커넥션 풀의 개념과 성능 최적화를 경험을 통해 이해한 과정
빠른 SSD를 샀는데 왜 느릴까요? 1차선 시골길(SATA)과 16차선 고속도로(NVMe). 인터페이스가 성능의 병목이 되는 이유.

LP판과 USB. 물리적으로 회전하는 판(Disc)이 왜 느릴 수밖에 없는지, 그리고 SSD가 어떻게 서버의 처리량을 100배로 만들었는지 파헤쳐봤습니다.

데이터베이스 트랜잭션의 개념과 ACID 특성을 경험을 통해 이해한 과정

CPU 성능의 90%는 캐시가 결정합니다. 데이터 지역성, MESI 프로토콜, 캐시 사상 방식, TLB, 그리고 분기 예측과 NUMA까지. 개발자가 반드시 알아야 할 성능 최적화의 모든 것.

서비스 초기에는 사용자가 적어서 문제없었습니다. 하지만 트래픽이 조금씩 늘어나면서 이상한 현상이 발생하기 시작했습니다. 갑자기 API 응답이 느려지더니, "Too many connections" 에러가 터지면서 서비스가 먹통이 됐습니다. 서버를 재시작하면 잠깐 괜찮아지다가 또 같은 문제가 반복됐죠.
모니터링 툴을 보니 DB 연결 수가 계속 증가하다가 한계에 도달하면 터지는 패턴이었습니다. 처음엔 "DB 서버 스펙이 부족한가?" 싶어서 스케일업을 고려했습니다. 하지만 선배 개발자가 제 코드를 보더니 한숨을 쉬며 말했습니다. "커넥션 풀 안 쓰고 있네?"
그때까지 저는 매 요청마다 새로운 DB 연결을 만들고 닫는 게 당연한 줄 알았습니다. 하지만 이게 얼마나 비효율적이고 위험한 방식인지 깨닫는 데는 오래 걸리지 않았습니다. 커넥션 풀을 도입하고 나서 같은 트래픽에서도 DB 연결 수가 안정적으로 유지됐고, 응답 속도도 눈에 띄게 빨라졌습니다.
커넥션 풀을 처음 접했을 때 가장 혼란스러웠던 부분은 "왜 연결을 재사용해야 하는가?"였습니다. HTTP 요청은 매번 새로 만들고 닫는데, DB 연결도 그렇게 하면 안 되나? 라는 생각이었죠.
제가 작성한 초기 코드는 이랬습니다:
app.get('/users/:id', async (req, res) => {
// 매 요청마다 새 연결 생성
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'password',
database: 'mydb'
});
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [req.params.id]);
// 연결 닫기
await connection.end();
res.json(rows[0]);
});
이 코드는 작동은 했지만, 문제가 많았습니다. 트래픽이 조금만 늘어나도 DB 연결 생성/해제 비용이 누적되면서 성능이 급격히 떨어졌습니다. 특히 동시 요청이 100개만 넘어가도 DB가 버티지 못했죠.
또 다른 혼란은 "풀(Pool)의 크기를 얼마로 설정해야 하는가?"였습니다. 너무 작으면 요청이 대기하고, 너무 크면 DB에 부담이 간다는데, 정확히 어떤 기준으로 정해야 할지 감이 안 왔습니다.
커넥션 풀을 이해하는 데 결정적이었던 비유는 "공용 자전거 대여소"였습니다.
자전거를 타려고 할 때마다 새 자전거를 사서 타고, 다 타면 버리는 사람은 없죠. 대신 대여소에 있는 자전거를 빌려 타고, 다 타면 반납합니다. 다음 사람은 그 자전거를 다시 빌려서 탑니다. 이렇게 하면:
커넥션 풀도 정확히 같은 원리입니다. DB 연결을 미리 만들어두고(자전거 대여소), 필요할 때 빌려 쓰고(대여), 다 쓰면 반납합니다(반환). 다음 요청은 그 연결을 재사용합니다.
이 비유를 듣자마자 무릎을 쳤습니다. 아, 그래서 "풀(Pool)"이라고 하는구나. 연결들의 저수지라는 뜻이었습니다.
코드로 표현하면 이렇습니다:
// 커넥션 풀 생성 (앱 시작 시 한 번만)
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'password',
database: 'mydb',
connectionLimit: 10 // 최대 10개 연결 유지
});
app.get('/users/:id', async (req, res) => {
// 풀에서 연결 빌리기
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(rows[0]);
} finally {
// 연결 반납 (닫는 게 아님!)
connection.release();
}
});
createConnection 대신 createPool을 사용하고, end() 대신 release()를 호출합니다. 연결을 닫는 게 아니라 풀에 반납하는 겁니다. 이렇게 바꾸고 나니 같은 트래픽에서도 DB 연결 수가 10개로 유지되면서 안정적으로 동작했습니다.
DB 연결을 만드는 건 생각보다 비쌉니다. TCP 핸드셰이크, 인증, 세션 초기화 등 여러 단계를 거쳐야 하죠.
제가 측정해본 결과:
100배 차이입니다. 초당 1000개 요청이 들어오면, 연결 생성만으로 50-100초가 걸립니다. 하지만 커넥션 풀을 쓰면 1초도 안 걸리죠.
DB 서버는 동시 연결 수에 제한이 있습니다. MySQL 기본값은 151개입니다. 만약 웹 서버가 3대이고, 각 서버가 동시에 100개 요청을 처리하면?
커넥션 풀은 DB 연결 수를 제한해서 DB 서버를 보호합니다.
연결을 수동으로 관리하면 실수로 닫지 않는 경우가 생깁니다:
const connection = await mysql.createConnection({...});
const [rows] = await connection.execute('SELECT ...');
// 에러 발생 시 connection.end()가 실행 안 됨!
if (rows.length === 0) {
throw new Error('Not found');
}
await connection.end();
이런 코드가 쌓이면 연결이 계속 열려 있어서 결국 "Too many connections" 에러가 발생합니다.
커넥션 풀은 try-finally로 안전하게 관리할 수 있습니다:
const connection = await pool.getConnection();
try {
// 작업 수행
} finally {
connection.release(); // 에러가 나도 반드시 실행됨
}
가장 중요한 설정은 connectionLimit입니다. 이걸 어떻게 정할까요?
공식이 있습니다:
connections = ((core_count * 2) + effective_spindle_count)
예를 들어, DB 서버가 4코어 CPU + SSD(spindle = 1)라면:
connections = (4 * 2) + 1 = 9
하지만 실제로는 이보다 모니터링과 부하 테스트가 중요합니다.
제 경우:
연결을 얼마나 오래 기다릴지 설정합니다:
const pool = mysql.createPool({
connectionLimit: 10,
queueLimit: 0, // 대기 큐 제한 (0 = 무제한)
waitForConnections: true, // 연결 대기 여부
acquireTimeout: 10000, // 연결 획득 타임아웃 (10초)
timeout: 60000 // 유휴 연결 타임아웃 (60초)
});
acquireTimeout은 중요합니다. 너무 길면 요청이 오래 대기하고, 너무 짧으면 불필요한 에러가 발생합니다. 저는 보통 10초로 설정합니다.
오래된 연결은 끊어질 수 있습니다. 풀에서 가져온 연결이 유효한지 확인해야 합니다:
const pool = mysql.createPool({
connectionLimit: 10,
enableKeepAlive: true, // Keep-alive 활성화
keepAliveInitialDelay: 10000 // 10초마다 ping
});
또는 연결을 가져올 때 직접 검증:
const connection = await pool.getConnection();
try {
await connection.ping(); // 연결 유효성 확인
// 작업 수행
} finally {
connection.release();
}
요즘은 대부분 ORM을 사용하니까, ORM에서 커넥션 풀을 어떻게 설정하는지가 중요합니다.
Prisma는 자동으로 커넥션 풀을 관리합니다:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// DATABASE_URL에 풀 설정 포함
// postgresql://user:password@localhost:5432/mydb?connection_limit=10
또는 코드에서 설정:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
// 커넥션 풀 설정
log: ['query', 'info', 'warn', 'error'],
});
// Prisma는 내부적으로 풀을 관리하므로
// 명시적으로 연결을 가져오거나 반납할 필요 없음
const users = await prisma.user.findMany();
Sequelize는 명시적으로 풀을 설정합니다:
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'mysql',
pool: {
max: 10, // 최대 연결 수
min: 0, // 최소 연결 수
acquire: 30000, // 연결 획득 타임아웃
idle: 10000 // 유휴 연결 해제 시간
}
});
TypeORM도 비슷합니다:
import { DataSource } from 'typeorm';
const AppDataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'user',
password: 'password',
database: 'mydb',
extra: {
max: 10, // 최대 연결 수
min: 2, // 최소 연결 수
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
}
});
어느 날 갑자기 "Connection pool exhausted" 에러가 발생했습니다. 모니터링을 보니 연결이 계속 증가하다가 한계에 도달하는 패턴이었죠.
문제는 이 코드였습니다:
async function getUserWithPosts(userId) {
const connection = await pool.getConnection();
const [users] = await connection.execute('SELECT * FROM users WHERE id = ?', [userId]);
if (users.length === 0) {
throw new Error('User not found'); // connection.release() 안 됨!
}
const [posts] = await connection.execute('SELECT * FROM posts WHERE user_id = ?', [userId]);
connection.release();
return { ...users[0], posts };
}
에러가 발생하면 release()가 실행 안 돼서 연결이 누수됐습니다.
async function getUserWithPosts(userId) {
const connection = await pool.getConnection();
try {
const [users] = await connection.execute('SELECT * FROM users WHERE id = ?', [userId]);
if (users.length === 0) {
throw new Error('User not found');
}
const [posts] = await connection.execute('SELECT * FROM posts WHERE user_id = ?', [userId]);
return { ...users[0], posts };
} finally {
connection.release(); // 항상 실행됨
}
}
트랜잭션을 사용할 때는 같은 연결을 유지해야 합니다:
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();
}
}
beginTransaction()과 commit() 사이에는 같은 연결을 사용해야 하므로, 풀에서 연결을 가져와서 트랜잭션이 끝날 때까지 유지합니다.
AWS Lambda 같은 서버리스 환경에서는 커넥션 풀이 까다롭습니다. 함수가 종료되면 연결이 끊기지 않고 남아있어서, 다음 실행 시 재사용할 수 있지만, 여러 인스턴스가 동시에 실행되면 연결 수가 폭발합니다.
해결책 1: RDS Proxy 사용AWS RDS Proxy는 서버리스 환경을 위한 커넥션 풀 관리 서비스입니다:
// Lambda 함수
const mysql = require('mysql2/promise');
let pool;
exports.handler = async (event) => {
if (!pool) {
pool = mysql.createPool({
host: process.env.RDS_PROXY_ENDPOINT, // RDS Proxy 엔드포인트
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 1 // Lambda당 1개만
});
}
const [rows] = await pool.execute('SELECT * FROM users');
return { statusCode: 200, body: JSON.stringify(rows) };
};
해결책 2: Prisma Data Proxy
Prisma는 서버리스를 위한 Data Proxy를 제공합니다:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // Data Proxy URL
}
커넥션 풀을 제대로 사용하려면 모니터링이 필수입니다.
const pool = mysql.createPool({
connectionLimit: 10
});
// 주기적으로 풀 상태 로깅
setInterval(() => {
console.log({
totalConnections: pool._allConnections.length,
freeConnections: pool._freeConnections.length,
queuedRequests: pool._connectionQueue.length
});
}, 10000); // 10초마다
풀 사용률이 80%를 넘으면 알림을 받도록 설정:
function checkPoolHealth() {
const usage = (pool._allConnections.length - pool._freeConnections.length) / pool.config.connectionLimit;
if (usage > 0.8) {
console.warn('Connection pool usage high:', usage * 100 + '%');
// 알림 전송 (Slack, PagerDuty 등)
}
}
setInterval(checkPoolHealth, 5000);
Express 앱에서 커넥션 풀을 전역으로 관리:
// db.ts
import mysql from 'mysql2/promise';
export 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: 20,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 10000
});
// routes/users.ts
import { pool } from '../db';
router.get('/users/:id', async (req, res) => {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(rows[0]);
} finally {
connection.release();
}
});
대량 데이터 처리 시 풀 크기를 동적으로 조정:
// 평소: 작은 풀
const normalPool = mysql.createPool({
connectionLimit: 10
});
// 배치 작업: 큰 풀
const batchPool = mysql.createPool({
connectionLimit: 50
});
async function runBatchJob() {
const connection = await batchPool.getConnection();
try {
// 대량 데이터 처리
for (let i = 0; i < 100000; i++) {
await connection.execute('INSERT INTO logs VALUES (?)', [i]);
}
} finally {
connection.release();
}
}
각 서비스마다 독립적인 풀 관리:
// user-service
const userPool = mysql.createPool({
connectionLimit: 10
});
// order-service
const orderPool = mysql.createPool({
connectionLimit: 20 // 주문 서비스는 더 많은 연결 필요
});
커넥션 풀은 DB 연결을 미리 만들어두고 재사용하는 메커니즘으로, 연결 생성/해제 비용을 줄이고 DB 서버 부하를 제한하며 연결 누수를 방지합니다. 적절한 풀 크기 설정, try-finally를 통한 안전한 연결 관리, 그리고 지속적인 모니터링이 핵심입니다. 실제로 DB를 사용하는 모든 애플리케이션은 반드시 커넥션 풀을 사용해야 합니다.