junyeokk
Blog
Database·2024. 11. 04

MySQL2

Node.js에서 MySQL에 접속하려면 드라이버가 필요하다. 초기에는 mysql 패키지가 사실상 표준이었지만, 이 패키지에는 근본적인 한계가 있었다. 콜백 기반 API만 제공하고, MySQL 서버의 최신 인증 방식(caching_sha2_password)을 지원하지 않으며, Prepared Statement를 클라이언트 사이드에서만 에뮬레이션했다. mysql2는 이런 문제를 해결하면서도 기존 mysql 패키지와 호환되는 API를 유지한 드라이버다.


mysql vs mysql2: 무엇이 다른가

항목mysqlmysql2
API 스타일콜백 전용콜백 + Promise 네이티브
Prepared Statement클라이언트 에뮬레이션서버 사이드 지원
인증 플러그인mysql_native_passwordcaching_sha2_password 등 최신 지원
프로토콜 파서JavaScript 구현C/C++ 바인딩 기반 고속 파서
타입 캐스팅기본 제공더 정밀한 커스텀 타입 캐스팅
MySQL 8.0+인증 문제 발생완벽 호환

MySQL 8.0부터 기본 인증 플러그인이 caching_sha2_password로 변경되었는데, 기존 mysql 패키지로는 이 인증 방식에 연결할 수 없다. mysql2는 이를 네이티브로 지원하기 때문에 MySQL 8.0 이상을 쓴다면 사실상 mysql2가 필수다.


설치와 기본 연결

bash
npm install mysql2

가장 간단한 연결 방법은 단일 커넥션을 만드는 것이다.

javascript
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)

커넥션 풀은 미리 여러 개의 커넥션을 만들어두고, 필요할 때 빌려쓰고, 다 쓰면 반납하는 방식이다. 새 커넥션을 만드는 비용을 줄이면서도 동시 요청을 효율적으로 처리할 수 있다.

javascript
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과 같거나 작아야 한다.

풀에서 커넥션 사용하기

풀에서 직접 쿼리를 실행하는 방법과 커넥션을 명시적으로 가져오는 방법 두 가지가 있다.

javascript
// 방법 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하는 패턴을 습관화해야 한다.

javascript
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을 네이티브로 사용할 수 있다.

javascript
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 인터페이스를 사용할 수 있다.

javascript
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 방지

javascript
// ❌ 위험: 문자열 결합
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)을 캐시하므로 파싱과 최적화 비용이 줄어든다.

javascript
// 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_PREPARECOM_STMT_EXECUTE 프로토콜을 사용하는 진짜 Prepared Statement다. query()는 클라이언트에서 ?를 값으로 치환한 뒤 완성된 쿼리 문자열을 서버에 보낸다. 둘 다 SQL Injection은 방지하지만, 서버 사이드 실행 계획 캐싱은 execute()만 가능하다.

단, Prepared Statement는 세션 단위로 캐시되기 때문에 커넥션 풀 환경에서는 주의가 필요하다. 커넥션이 반환되었다가 다른 요청에 할당되면 이전 세션의 Prepared Statement가 남아있을 수 있다. mysql2는 내부적으로 LRU 캐시를 두어 이를 관리하며, maxPreparedStatements 옵션으로 커넥션당 캐시 크기를 조절할 수 있다.

javascript
const pool = mysql.createPool({
  // ...
  maxPreparedStatements: 200, // 커넥션당 최대 Prepared Statement 수 (기본: 16000)
});

결과 타입 처리

MySQL에서 반환하는 데이터 타입과 JavaScript의 타입이 항상 일치하지는 않는다. mysql2는 MySQL 프로토콜의 바이너리 타입을 JavaScript 타입으로 자동 변환하는데, 그 매핑을 알아두면 디버깅할 때 도움이 된다.

MySQL 타입JavaScript 타입
INT, BIGINTNumber (BIGINT은 BigInt 또는 String)
FLOAT, DOUBLENumber
DECIMALString (정밀도 유지를 위해)
VARCHAR, TEXTString
DATEDate 객체
DATETIME, TIMESTAMPDate 객체
JSONObject (자동 파싱)
BLOBBuffer
BIT(1)Buffer (boolean 아님!)

주의할 점이 몇 가지 있다.

BIGINT 문제: JavaScript의 Number는 2^53까지만 안전하게 표현할 수 있다. 그보다 큰 BIGINT 값은 정밀도가 손실될 수 있다. supportBigNumbersbigNumberStrings 옵션으로 이를 제어할 수 있다.

javascript
const pool = mysql.createPool({
  // ...
  supportBigNumbers: true,   // BigInt 값을 안전하게 처리
  bigNumberStrings: true,    // BigInt를 String으로 반환
});

DECIMAL 문제: DECIMAL(10,2) 같은 정밀 소수점 타입은 String으로 반환된다. 금액 계산 등에서 부동소수점 오류를 방지하기 위한 것이므로, parseFloat()를 쓸 때는 정밀도 손실을 감안해야 한다.

커스텀 타입 캐스팅: 원한다면 타입 변환 로직을 직접 정의할 수도 있다.

javascript
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

위치 기반 ? 대신 이름 기반 플레이스홀더를 쓸 수도 있다. 파라미터가 많은 쿼리에서 가독성이 훨씬 좋다.

javascript
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처럼 컬럼이 많은 쿼리에서 유용하다.

javascript
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을 지원해서 행 단위로 데이터를 처리할 수 있다.

javascript
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 내보내기 같은 작업에서 메모리 효율적으로 처리할 수 있다.


트랜잭션 처리

여러 쿼리를 하나의 논리적 작업 단위로 묶어야 할 때 트랜잭션을 사용한다. 핵심은 하나의 커넥션에서 모든 쿼리를 실행해야 한다는 것이다.

javascript
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를 로드한다.

typescript
// 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를 직접 사용할 때 매번 커넥션을 가져오고 반환하는 코드를 반복하면 실수가 생기기 쉽다. 이를 추상화한 접근 클래스를 만들면 깔끔하다.

typescript
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();
  }
}

이 패턴의 핵심은 세 가지다.

  1. 커넥션 획득/반환을 한 곳에서 관리: finally에서 release()를 보장하니까 커넥션 누수가 없다.
  2. 에러 핸들링 통일: 모든 쿼리의 에러 로깅이 일관적이다.
  3. 제네릭 타입 지원: 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의 동작 원리를 이해하면 커넥션 풀 튜닝, 타입 매핑 문제, 트랜잭션 처리 같은 상황에서 더 정확한 판단을 내릴 수 있다.


관련 문서