MySQL2
Node.js에서 MySQL에 접속하려면 드라이버가 필요하다. 초기에는 mysql 패키지가 사실상 표준이었지만, 이 패키지에는 근본적인 한계가 있었다. 콜백 기반 API만 제공하고, MySQL 서버의 최신 인증 방식(caching_sha2_password)을 지원하지 않으며, Prepared Statement를 클라이언트 사이드에서만 에뮬레이션했다. mysql2는 이런 문제를 해결하면서도 기존 mysql 패키지와 호환되는 API를 유지한 드라이버다.
mysql vs mysql2: 무엇이 다른가
| 항목 | mysql | mysql2 |
|---|---|---|
| API 스타일 | 콜백 전용 | 콜백 + Promise 네이티브 |
| Prepared Statement | 클라이언트 에뮬레이션 | 서버 사이드 지원 |
| 인증 플러그인 | mysql_native_password만 | caching_sha2_password 등 최신 지원 |
| 프로토콜 파서 | JavaScript 구현 | C/C++ 바인딩 기반 고속 파서 |
| 타입 캐스팅 | 기본 제공 | 더 정밀한 커스텀 타입 캐스팅 |
| MySQL 8.0+ | 인증 문제 발생 | 완벽 호환 |
MySQL 8.0부터 기본 인증 플러그인이 caching_sha2_password로 변경되었는데, 기존 mysql 패키지로는 이 인증 방식에 연결할 수 없다. mysql2는 이를 네이티브로 지원하기 때문에 MySQL 8.0 이상을 쓴다면 사실상 mysql2가 필수다.
설치와 기본 연결
npm install mysql2
가장 간단한 연결 방법은 단일 커넥션을 만드는 것이다.
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: 'localhost',
port: 3306,
user: 'root',
password: 'password',
database: 'my_database',
});
connection.query('SELECT * FROM users WHERE id = ?', [1], (err, results) => {
if (err) throw err;
console.log(results);
});
connection.end();
그런데 실제 서버 환경에서는 단일 커넥션을 쓰는 일이 거의 없다. 요청마다 커넥션을 열고 닫으면 TCP 핸드셰이크와 인증 과정이 반복되면서 엄청난 오버헤드가 발생한다. 여기서 커넥션 풀이 필요해진다.
커넥션 풀 (Connection Pool)
커넥션 풀은 미리 여러 개의 커넥션을 만들어두고, 필요할 때 빌려쓰고, 다 쓰면 반납하는 방식이다. 새 커넥션을 만드는 비용을 줄이면서도 동시 요청을 효율적으로 처리할 수 있다.
const mysql = require('mysql2');
const pool = mysql.createPool({
host: 'localhost',
port: 3306,
user: 'root',
password: 'password',
database: 'my_database',
connectionLimit: 10, // 최대 커넥션 수
waitForConnections: true, // 풀이 꽉 차면 대기할지 여부
queueLimit: 0, // 대기 큐 제한 (0 = 무제한)
});
주요 풀 옵션
connectionLimit: 풀에 유지할 최대 커넥션 수. 너무 적으면 대기 시간이 길어지고, 너무 많으면 MySQL 서버의max_connections를 초과할 수 있다. 보통 CPU 코어 수 × 2 ~ 4 정도가 적당하다.waitForConnections:true면 풀이 꽉 찼을 때 빈 커넥션이 생길 때까지 대기한다.false면 즉시 에러를 던진다.queueLimit: 대기 큐에 쌓을 수 있는 최대 요청 수. 0이면 무제한. 트래픽 폭주 시 메모리를 보호하려면 적절한 값을 설정하는 게 좋다.idleTimeout: 사용하지 않는 커넥션을 몇 ms 후에 닫을지. 기본값은 60000(60초).maxIdle: 유휴 상태로 유지할 최대 커넥션 수.connectionLimit과 같거나 작아야 한다.
풀에서 커넥션 사용하기
풀에서 직접 쿼리를 실행하는 방법과 커넥션을 명시적으로 가져오는 방법 두 가지가 있다.
// 방법 1: pool.query() — 간단한 단일 쿼리
pool.query('SELECT * FROM users WHERE id = ?', [1], (err, results) => {
// 커넥션을 자동으로 가져오고 반환한다
console.log(results);
});
// 방법 2: pool.getConnection() — 여러 쿼리를 하나의 커넥션에서 실행
pool.getConnection((err, connection) => {
if (err) throw err;
connection.query('SELECT * FROM users', (err, results) => {
console.log(results);
connection.release(); // 반드시 반환해야 한다!
});
});
release()를 빼먹으면 커넥션이 풀에 반환되지 않아서 결국 풀이 고갈된다. 이건 정말 흔한 실수인데, 에러가 발생했을 때 release를 호출하지 않는 경우가 특히 많다. finally 블록에서 반드시 release하는 패턴을 습관화해야 한다.
let connection;
try {
connection = await pool.promise().getConnection();
const [rows] = await connection.query('SELECT * FROM users');
return rows;
} catch (error) {
console.error('쿼리 실행 오류:', error.message);
throw error;
} finally {
if (connection) connection.release(); // 에러가 나든 안 나든 반환
}
Promise API (mysql2/promise)
mysql2의 가장 큰 장점 중 하나가 mysql2/promise 모듈이다. 별도의 래퍼 없이 async/await을 네이티브로 사용할 수 있다.
const mysql = require('mysql2/promise');
async function main() {
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'password',
database: 'my_database',
connectionLimit: 10,
});
// async/await으로 깔끔하게 사용
const [rows, fields] = await pool.query('SELECT * FROM users WHERE age > ?', [20]);
console.log(rows); // 결과 배열
console.log(fields); // 컬럼 메타데이터
}
mysql2/promise의 쿼리 결과는 항상 [rows, fields] 튜플로 반환된다. rows는 결과 데이터 배열이고, fields는 각 컬럼의 메타데이터(이름, 타입, 길이 등)다. 보통 fields는 쓸 일이 없어서 비구조화 할당으로 rows만 꺼낸다.
기존 콜백 API와 Promise API를 혼용하는 것도 가능하다. 콜백 방식으로 풀을 만들고 .promise()를 호출하면 같은 풀에서 Promise 인터페이스를 사용할 수 있다.
const mysql = require('mysql2');
// 콜백 방식으로 풀 생성
const pool = mysql.createPool({ /* ... */ });
// Promise 래퍼 획득
const promisePool = pool.promise();
// 이제 async/await 사용 가능
const [rows] = await promisePool.query('SELECT 1');
Prepared Statement
Prepared Statement는 쿼리 템플릿을 서버에 미리 컴파일해두고, 실행할 때는 파라미터만 바인딩하는 방식이다. 두 가지 큰 이점이 있다.
1. SQL Injection 방지
// ❌ 위험: 문자열 결합
const query = `SELECT * FROM users WHERE name = '${userInput}'`;
// ✅ 안전: Prepared Statement
const [rows] = await pool.execute('SELECT * FROM users WHERE name = ?', [userInput]);
? 플레이스홀더에 바인딩된 값은 SQL의 일부로 해석되지 않는다. 서버가 쿼리 구조와 데이터를 분리해서 처리하기 때문에, userInput에 '; DROP TABLE users; -- 같은 악의적인 값이 들어와도 그냥 문자열로 취급된다.
2. 성능 최적화
같은 구조의 쿼리를 반복 실행할 때 서버가 실행 계획(execution plan)을 캐시하므로 파싱과 최적화 비용이 줄어든다.
// execute()는 서버 사이드 Prepared Statement를 사용
const [rows] = await pool.execute('SELECT * FROM users WHERE age > ?', [20]);
// query()는 클라이언트 사이드 치환만 한다
const [rows2] = await pool.query('SELECT * FROM users WHERE age > ?', [20]);
execute()와 query()의 차이점: execute()는 MySQL 서버의 COM_STMT_PREPARE → COM_STMT_EXECUTE 프로토콜을 사용하는 진짜 Prepared Statement다. query()는 클라이언트에서 ?를 값으로 치환한 뒤 완성된 쿼리 문자열을 서버에 보낸다. 둘 다 SQL Injection은 방지하지만, 서버 사이드 실행 계획 캐싱은 execute()만 가능하다.
단, Prepared Statement는 세션 단위로 캐시되기 때문에 커넥션 풀 환경에서는 주의가 필요하다. 커넥션이 반환되었다가 다른 요청에 할당되면 이전 세션의 Prepared Statement가 남아있을 수 있다. mysql2는 내부적으로 LRU 캐시를 두어 이를 관리하며, maxPreparedStatements 옵션으로 커넥션당 캐시 크기를 조절할 수 있다.
const pool = mysql.createPool({
// ...
maxPreparedStatements: 200, // 커넥션당 최대 Prepared Statement 수 (기본: 16000)
});
결과 타입 처리
MySQL에서 반환하는 데이터 타입과 JavaScript의 타입이 항상 일치하지는 않는다. mysql2는 MySQL 프로토콜의 바이너리 타입을 JavaScript 타입으로 자동 변환하는데, 그 매핑을 알아두면 디버깅할 때 도움이 된다.
| MySQL 타입 | JavaScript 타입 |
|---|---|
| INT, BIGINT | Number (BIGINT은 BigInt 또는 String) |
| FLOAT, DOUBLE | Number |
| DECIMAL | String (정밀도 유지를 위해) |
| VARCHAR, TEXT | String |
| DATE | Date 객체 |
| DATETIME, TIMESTAMP | Date 객체 |
| JSON | Object (자동 파싱) |
| BLOB | Buffer |
| BIT(1) | Buffer (boolean 아님!) |
주의할 점이 몇 가지 있다.
BIGINT 문제: JavaScript의 Number는 2^53까지만 안전하게 표현할 수 있다. 그보다 큰 BIGINT 값은 정밀도가 손실될 수 있다. supportBigNumbers와 bigNumberStrings 옵션으로 이를 제어할 수 있다.
const pool = mysql.createPool({
// ...
supportBigNumbers: true, // BigInt 값을 안전하게 처리
bigNumberStrings: true, // BigInt를 String으로 반환
});
DECIMAL 문제: DECIMAL(10,2) 같은 정밀 소수점 타입은 String으로 반환된다. 금액 계산 등에서 부동소수점 오류를 방지하기 위한 것이므로, parseFloat()를 쓸 때는 정밀도 손실을 감안해야 한다.
커스텀 타입 캐스팅: 원한다면 타입 변환 로직을 직접 정의할 수도 있다.
const pool = mysql.createPool({
// ...
typeCast: function (field, next) {
if (field.type === 'BIT' && field.length === 1) {
// BIT(1)을 boolean으로 변환
const bytes = field.buffer();
return bytes ? bytes[0] === 1 : null;
}
return next(); // 나머지는 기본 변환
},
});
Named Placeholder
위치 기반 ? 대신 이름 기반 플레이스홀더를 쓸 수도 있다. 파라미터가 많은 쿼리에서 가독성이 훨씬 좋다.
const pool = mysql.createPool({
// ...
namedPlaceholders: true,
});
const [rows] = await pool.execute(
'SELECT * FROM users WHERE name = :name AND age > :minAge',
{ name: 'Alice', minAge: 20 }
);
위치 기반 ?는 파라미터 순서를 정확히 맞춰야 하는데, Named Placeholder는 이름으로 매칭하니까 순서에 신경 쓸 필요가 없다. 특히 INSERT나 UPDATE처럼 컬럼이 많은 쿼리에서 유용하다.
await pool.execute(
`INSERT INTO users (name, email, age, role, status)
VALUES (:name, :email, :age, :role, :status)`,
{ name: 'Bob', email: 'bob@example.com', age: 25, role: 'user', status: 'active' }
);
스트리밍 쿼리
대량의 데이터를 조회할 때 전체 결과를 메모리에 올리면 OOM(Out of Memory)이 발생할 수 있다. mysql2는 Node.js의 Readable Stream을 지원해서 행 단위로 데이터를 처리할 수 있다.
const stream = pool.pool // 내부 풀 객체 접근
.query('SELECT * FROM large_table')
.stream();
stream.on('data', (row) => {
// 한 행씩 처리
processRow(row);
});
stream.on('end', () => {
console.log('모든 행 처리 완료');
});
stream.on('error', (err) => {
console.error('스트리밍 에러:', err);
});
Promise 기반 풀에서는 .stream() 대신 queryStream()을 사용한다. 파이프라인과 결합하면 CSV 내보내기 같은 작업에서 메모리 효율적으로 처리할 수 있다.
트랜잭션 처리
여러 쿼리를 하나의 논리적 작업 단위로 묶어야 할 때 트랜잭션을 사용한다. 핵심은 하나의 커넥션에서 모든 쿼리를 실행해야 한다는 것이다.
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[100, fromAccountId]
);
await connection.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?',
[100, toAccountId]
);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
왜 같은 커넥션이어야 하는가? MySQL의 트랜잭션은 세션(커넥션) 단위다. BEGIN을 실행한 커넥션에서 COMMIT이나 ROLLBACK을 해야 한다. pool.query()를 쓰면 쿼리마다 다른 커넥션을 사용할 수 있어서 트랜잭션이 의미가 없어진다.
pool.query()를 직접 사용하는 경우에도 내부적으로 getConnection() → query() → release()가 일어나는 건 맞지만, 각 호출마다 다른 커넥션을 받을 수 있기 때문에 트랜잭션에는 부적합하다.
TypeORM과의 관계
NestJS에서 TypeORM을 사용할 때 내부적으로 mysql2가 DB 드라이버로 동작한다. TypeORM의 DataSource 설정에서 type: 'mysql'을 지정하면 자동으로 mysql2를 로드한다.
// TypeORM이 내부적으로 mysql2를 사용
const dataSource = new DataSource({
type: 'mysql', // mysql2 드라이버를 사용
host: 'localhost',
port: 3306,
username: 'root',
password: 'password',
database: 'my_database',
extra: {
connectionLimit: 10, // mysql2 풀 옵션을 extra로 전달
maxPreparedStatements: 200,
},
});
TypeORM은 엔티티/리포지토리/마이그레이션 같은 ORM 기능을 제공하고, 실제 MySQL 통신은 mysql2가 담당하는 구조다. 따라서 mysql2의 커넥션 풀 옵션이나 타입 캐스팅 설정이 TypeORM 환경에서도 그대로 적용된다. extra 필드를 통해 mysql2의 네이티브 옵션을 직접 전달할 수 있다.
ORM 없이 직접 쿼리를 작성해야 하는 경우(복잡한 집계, 성능 크리티컬 쿼리, ORM 외부의 독립 프로세스 등)에는 mysql2를 직접 사용하는 것이 더 적합하다.
실전 패턴: 재사용 가능한 DB 접근 클래스
프로젝트에서 mysql2를 직접 사용할 때 매번 커넥션을 가져오고 반환하는 코드를 반복하면 실수가 생기기 쉽다. 이를 추상화한 접근 클래스를 만들면 깔끔하다.
import * as mysql from 'mysql2/promise';
import { PoolConnection } from 'mysql2/promise';
export class DatabaseAccess {
private pool: mysql.Pool;
constructor() {
this.pool = mysql.createPool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10,
});
}
async executeQuery<T>(query: string, params: any[] = []): Promise<T[] | null> {
let connection: PoolConnection;
try {
connection = await this.pool.getConnection();
const [rows] = await connection.query(query, params);
return rows as T[];
} catch (error) {
console.error(`쿼리 실행 오류: ${error.message}`);
return null;
} finally {
if (connection) connection.release();
}
}
async end(): Promise<void> {
await this.pool.end();
}
}
이 패턴의 핵심은 세 가지다.
- 커넥션 획득/반환을 한 곳에서 관리:
finally에서release()를 보장하니까 커넥션 누수가 없다. - 에러 핸들링 통일: 모든 쿼리의 에러 로깅이 일관적이다.
- 제네릭 타입 지원:
executeQuery<User>(...)형태로 반환 타입을 지정할 수 있다.
에러가 발생했을 때 null을 반환할지, 예외를 다시 던질지는 상황에 따라 다르다. 실패해도 계속 진행해야 하는 경우(로깅, 통계 등)에는 null 반환이 적합하고, 실패 시 즉시 중단해야 하는 경우(결제, 데이터 정합성)에는 throw가 맞다. 두 가지 메서드를 모두 제공하는 것도 좋은 전략이다.
정리
mysql2는 Node.js에서 MySQL을 사용할 때의 사실상 표준 드라이버다. 핵심 포인트를 정리하면:
- Promise 네이티브 지원:
mysql2/promise로 async/await을 바로 사용 - 서버 사이드 Prepared Statement:
execute()로 진짜 Prepared Statement 실행, SQL Injection 방지 + 실행 계획 캐싱 - 커넥션 풀:
createPool()로 커넥션 재사용,release()필수 - MySQL 8.0+ 호환:
caching_sha2_password인증 네이티브 지원 - TypeORM 등 ORM의 내부 드라이버: 직접 사용하든 ORM을 통해 사용하든 결국
mysql2가 통신을 담당
ORM을 쓰더라도 mysql2의 동작 원리를 이해하면 커넥션 풀 튜닝, 타입 매핑 문제, 트랜잭션 처리 같은 상황에서 더 정확한 판단을 내릴 수 있다.