e2e 테스트 시간의 39%는 ALTER TABLE 이었습니다 — MySQL 하네스의 정리 비용 줄이기

정기창·

MySQL 에 붙어 도는 e2e 테스트의 시간을 재 보니, 가장 큰 몫은 테스트 본문이 아니라 테스트 사이의 정리에 있었습니다. 파일 소요 시간 합의 39%가 ALTER TABLE … AUTO_INCREMENT = 1 이었고, 14%는 하네스 함수를 부를 때마다 새 연결을 열고 세션을 고정하는 데 들었습니다.

「CI 테스트 시간 줄이기」 연재의 네 번째 글입니다. 1편(전체 개요), 2편(TypeScript 7 타입 검사), 3편(vitest 선택 격리)에 이어 e2e 하네스를 다룹니다. 두 가지를 고쳐 로컬 4샤드 기준 381.0초가 205.5초가 됐지만, 오래 붙든 질문은 숫자보다 «연결을 다시 쓰면서도 호출마다 새 연결과 같게 할 수 있는가»였습니다.

하네스가 지키던 두 가지 약속

대상은 B2B 커머스 운영 시스템의 NestJS API 입니다. e2e 는 로컬 Docker 의 MySQL 8.4 위에 진짜 앱을 띄워 HTTP 로 부르고, mysql2 3.23.2 · drizzle-orm · Vitest 4.1.10 · Node 24 로 120파일 4,287건을 4샤드로 돌립니다(CI 와 로컬 공통 스크립트).

샤드마다 새 DB(app_test_s1~s4)를 쓰고, 샤드끼리는 동시에, 샤드 안의 파일은 차례로 돕니다. 앞 파일이 남긴 행이 뒤 파일을 흔들지 않도록 테스트는 beforeEach 나 afterAll 에서 정리 함수를 부릅니다. 정리 함수 15개의 약속은 둘입니다. 목록 순서가 곧 DELETE 순서이고(FK 자식 먼저), 정리가 끝나면 목록의 표는 다음 id 가 1입니다. 테스트가 «처음 만든 행의 id 는 1» 같은 단언에 기대기 때문입니다.

API 로 만들 수 없는 상태를 심거나 DB 에 남은 값을 읽는 날것 SQL 함수(rawSql · rawSelect · rawExecute)도 있습니다. 정리와 날것 SQL 모두 부를 때마다 새 연결을 열고 닫았고, 날것 SQL 은 앱 풀과 같은 세션 고정(UTC 시간대 · STRICT sql_mode)도 걸었습니다.

먼저 쟀습니다 — 시간은 테스트 사이에 있었습니다

커밋하지 않는 도구 둘로 쟀습니다. 파일별 훅 시간을 더하는 임시 vitest 리포터(onHookStart · onHookEnd)와, mysql.createConnection 을 감싸 문장 종류별 시간을 재는 하네스 계측판입니다. 네 샤드의 파일 소요 시간 합은 1,315.7초였습니다.

항목횟수시간비율
ALTER TABLE … AUTO_INCREMENT = 1100,320문장512.7초39%
새 연결 열기72,333회144.8초11%
세션 고정 SET113,186문장41.6초3%
DELETE133,169문장109.4초8%

정리 함수 13,036번을 통째로 묶으면 635.8초, 합의 48%였습니다. 다만 계측판으로 돈 실행은 wall 404초로 계측 없는 판(381초)보다 길었고, 훅 사건은 러너가 묶어 보내 개별 값이 100ms 안팎 흔들립니다. 그래서 이 표로는 어디부터 볼지만 정하고, 효과는 계측 없는 판끼리 비교했습니다. 아래 ms 값은 조용할 때 한 동작씩 15번 잰 중앙값입니다.

해법 1 — AUTO_INCREMENT 가 1 보다 큰 표만 ALTER 합니다

ALTER TABLE … AUTO_INCREMENT = 1 은 DDL 이라 빈 표에도 데이터 사전을 고치고 커밋하는 비용이 들어, 한 문장에 약 3.6ms(4샤드 동시 약 5.1ms)가 걸렸습니다. 큰 정리 함수 하나는 부를 때마다 ALTER 를 29문장 보냈지만, 그 테스트가 행을 넣지 않은 표는 이미 1 이라 되돌릴 것이 없습니다. 그래서 information_schema.TABLES 를 한 번 읽어(표 29개에 0.94ms) 1 보다 큰 표에만 ALTER 를 보내기로 했습니다.

통계 캐시를 끄지 않으면 낡은 값을 읽습니다

함정이 있었습니다. MySQL 8 은 INFORMATION_SCHEMA 의 통계 열을 캐시하는데, TABLES.AUTO_INCREMENT 도 그 목록에 있습니다. 캐시 수명인 information_schema_stats_expiry 의 기본값은 86400초(24시간)이고, 한 세션이 캐시한 통계는 다른 세션도 읽습니다. 공식 문서는 캐시를 건너뛰고 늘 스토리지 엔진에서 최신 값을 읽으려면 이 값을 0 으로 두라고 안내합니다.

기본값 그대로면 한 번 읽힌 1 이 남아 행이 들어간 표를 건너뛸 수 있고, 다음 테스트의 id 가 오류 없이 2나 3부터 시작합니다. 그래서 정리 전용 연결의 세션에 0 을 겁니다. 이 줄을 빼 보니 정리 계약 스펙이 expected 3 to be 1 로 빨개졌습니다.

없는 표는 «되돌릴 것 없음»과 같은 모양입니다

리뷰에서 하나를 더 짚었습니다. information_schema 는 없는 표에 행을 돌려주지 않아, 1 보다 큰 표만 골라 읽으면 이름이 틀렸거나 사라진 표가 «되돌릴 것 없음»과 같은 모양으로 넘어갑니다. 표마다 ALTER 를 치던 예전 하네스라면 errno 1146 으로 멈췄을 실수입니다. 그래서 조건 없이 읽어 목록과 대조하고, 없는 표가 있으면 이름을 들고 던집니다. 이 대조로 왕복이 늘지는 않습니다(조회 1번, ALTER 묶음 0~1번).

DELETE 는 한 왕복으로 묶었습니다

DELETE 는 표마다 보내던 것을 statements.join(';\n') 으로 이어 한 왕복에 보냈고, 빈 표 29개 기준 7.8ms 가 0.9ms 가 됐습니다. multipleStatements 는 정리 전용 연결에만 켰습니다. 이 연결로 가는 문장은 하네스 상수 목록으로 만든 것뿐이라 테스트의 문장이 섞일 길이 없습니다. 서버는 여러 문장 중 하나가 실패하면(예: FK 위반 errno 1451) 나머지를 실행하지 않으므로 순서 계약도 그대로입니다. 아래는 일반 이름으로 줄인 예입니다(실제 하네스는 이 연결도 파일 안에서 다시 씁니다).

// cleanup.ts
import mysql from 'mysql2/promise';

export const DB = { host: '127.0.0.1', user: 'app', password: 'test', database: 'app_test_s1' };

export async function openCleanupConnection(): Promise<mysql.Connection> {
  const conn = await mysql.createConnection({ ...DB, multipleStatements: true });
  await conn.query('SET SESSION information_schema_stats_expiry = 0'); // 통계 캐시를 건너뜁니다
  return conn;
}

export async function resetAutoIncrement(
  conn: mysql.Connection,
  tables: readonly string[],
): Promise<void> {
  if (tables.length === 0) return;
  const [rows] = await conn.query(
    `SELECT TABLE_NAME AS t, AUTO_INCREMENT AS ai FROM information_schema.TABLES
      WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)`,
    [tables],
  );
  const found = rows as Array<{ t: string; ai: number | string | null }>;
  const present = new Set(found.map((r) => r.t));
  const missing = tables.filter((t) => !present.has(t));
  if (missing.length > 0) throw new Error(`되돌릴 표가 DB 에 없습니다: ${missing.join(', ')}`);
  const dirty = found.filter((r) => r.ai !== null && Number(r.ai) > 1).map((r) => r.t);
  if (dirty.length > 0) {
    await conn.query(dirty.map((t) => `ALTER TABLE \`${t}\` AUTO_INCREMENT = 1`).join(';\n'));
  }
}

바꾼 뒤에는 예전 하네스와 문장 단위로 대조했습니다. 두 하네스를 임시 스펙 하나에서 불러 보낸 문장을 모으되, 함수마다 AUTO_INCREMENT 가 있는 표를 전부 7 로 올려 조건부 ALTER 가 빠짐없이 걸리게 했습니다. 15개 함수 모두 DELETE 가 같은 순서 · 같은 글자였고 ALTER 대상 집합도 같았습니다. 이 단계만으로 로컬 wall 이 381.0초에서 272.0초가 됐습니다(−28.6%).

해법 2 — COM_RESET_CONNECTION 으로 되돌려 연결을 다시 씁니다

새 연결을 열고 고정하고 닫는 데 2.72ms 가 들었고, 이것이 한 번의 e2e 에서 7만 번 넘게 일어났습니다. 되돌리고 다시 고정하면 0.54ms 입니다. 다만 다시 쓰면 의미가 달라집니다. 한 호출이 남긴 사용자 변수나 트랜잭션, 잠금이 넘어가면, 틀리는 것은 그 호출을 한 테스트가 아니라 그다음 테스트입니다.

되돌림이 비우는 것과 비우지 않는 것

돌려받을 때마다 mysql2 의 conn.reset(), 곧 COM_RESET_CONNECTION 을 보냅니다. 공식 문서에 따르면 이 명령은 다시 인증하지 않고 트랜잭션을 롤백하고, 표 잠금과 GET_LOCK() 잠금을 풀고, 임시 표와 사용자 변수를 지우고, LAST_INSERT_ID() 를 0 으로 되돌립니다. 직접 재 보니 짐작하지 못한 것이 둘 있었습니다.

  • 세션 변수는 «접속 직후»가 아니라 «전역값»으로 돌아갑니다. 핸드셰이크가 정한 collation_connection(utf8mb4_unicode_ci)이 되돌림 뒤 전역값(utf8mb4_0900_ai_ci)이 됐습니다. 문서에도 SET NAMES 처럼 암묵적으로 정해진 변수까지 전역값으로 초기화한다고 적혀 있습니다. 그래서 연결을 열 때 문자셋 변수 셋을 읽어 두고, 되돌린 뒤 시간대 · sql_mode 고정과 한 문장으로 다시 겁니다.
  • 기본 DB 는 되돌리지 않습니다. USE information_schema 뒤 되돌려도 DATABASE() 는 그대로였습니다. 받아 주면 다음 호출이 남의 DB 에서 조용히 돌기 때문에 USE 는 실행 전에 거절하고, 다른 DB 의 표는 db.table 로 한정하게 했습니다.

이 거절은 실수를 막는 장치일 뿐 경계는 아닙니다. 앞의 공백과 주석을 걷은 첫 낱말만 보므로, MySQL 이 실행하는 주석(/*! … */) 안의 USE 는 지나갑니다.

연결을 열 때마다 되돌림을 스스로 검증합니다

되돌림이 정말 세션을 비우는지는 연결을 열 때마다 잽니다. 사용자 변수를 심고 시간대를 바꾸고 트랜잭션을 연 뒤 되돌려, 셋이 다 사라졌는지 봅니다. MySQL 에는 @@in_transaction 이 없어(MariaDB 의 변수입니다) 열린 트랜잭션은 OK 패킷의 상태 비트로 읽습니다.

import mysql, { type ResultSetHeader } from 'mysql2/promise';
import { afterAll } from 'vitest';
import { DB } from './cleanup';

interface Lane { conn: mysql.Connection; dead: boolean; restore: string; params: unknown[] }
let idle: Lane | undefined;

async function proveResetClearsSession(conn: mysql.Connection): Promise<void> {
  await conn.query("SET @probe = 1, time_zone = '+05:45'"); // 전역값과 겹치지 않을 시간대
  await conn.query('START TRANSACTION');
  await conn.reset(); // COM_RESET_CONNECTION
  const [rows] = await conn.query('SELECT @probe AS probe, @@session.time_zone AS tz');
  const [seen] = rows as Array<{ probe: unknown; tz: string }>;
  const [ok] = (await conn.query('DO 0')) as unknown as [ResultSetHeader];
  const inTransaction = (ok.serverStatus & 1) !== 0; // SERVER_STATUS_IN_TRANS
  if (seen?.probe !== null || seen.tz === '+05:45' || inTransaction) {
    throw new Error('COM_RESET_CONNECTION 이 세션을 비우지 않았습니다');
  }
}

async function openLane(): Promise<Lane> {
  const conn = await mysql.createConnection({ ...DB, timezone: 'Z' });
  const lane: Lane = { conn, dead: false, restore: '', params: [] };
  conn.on('error', () => {
    lane.dead = true; // 리스너가 없으면 이 사건이 프로세스를 죽입니다
  });
  try {
    const [rows] = await conn.query(
      'SELECT @@character_set_client AS a, @@character_set_results AS b, @@collation_connection AS c',
    );
    const [cs] = rows as Array<{ a: string; b: string | null; c: string }>;
    lane.restore =
      "SET time_zone = '+00:00', SESSION sql_mode = 'STRICT_TRANS_TABLES', " +
      'character_set_client = ?, character_set_results = ?, collation_connection = ?';
    lane.params = [cs.a, cs.b, cs.c];
    await proveResetClearsSession(conn);
    await conn.query(lane.restore, lane.params);
  } catch (err) {
    conn.destroy();
    throw err;
  }
  return lane;
}

async function settle(lane: Lane): Promise<void> {
  try {
    await lane.conn.reset();
    await lane.conn.query(lane.restore, lane.params);
  } catch (err) {
    lane.conn.destroy();
    throw err;
  }
  if (!lane.dead && idle === undefined) idle = lane;
  else await lane.conn.end().catch(() => lane.conn.destroy());
}

const LEADING_USE = /^(?:\s|\/\*[\s\S]*?\*\/|--[^\n]*(?:\n|$)|#[^\n]*(?:\n|$))*USE\b/i;

export async function rawQuery<T = unknown>(sql: string, params: unknown[] = []): Promise<T> {
  if (LEADING_USE.test(sql)) throw new Error('USE 는 받지 않습니다 — db.table 로 한정하세요');
  let lane = idle;
  idle = undefined; // 빌려 간 동안 자리를 비웁니다
  if (lane === undefined || lane.dead) {
    lane?.conn.destroy();
    lane = await openLane();
  }
  let out: T;
  try {
    out = (await lane.conn.query(sql, params))[0] as T;
  } catch (err) {
    await settle(lane).catch(() => undefined); // 원래 오류가 먼저입니다
    throw err;
  }
  await settle(lane);
  return out;
}

afterAll(async () => {
  const lane = idle;
  idle = undefined;
  if (lane) await lane.conn.end().catch(() => lane.conn.destroy());
});

빌려 가는 동안 빈자리를 비워 두므로 동시에 빌리면 둘째는 새 연결을 엽니다. 줄을 세우면 서로의 잠금을 기다리는 교착이 생길 수 있어서입니다. 쉬는 연결이 서버 쪽에서 끊기면 mysql2 는 'error' 사건으로만 알리고, 듣는 쪽이 없으면 프로세스가 죽습니다.

파일이 끝나면 하네스 모듈의 afterAll 이 닫습니다. vitest 기본 훅 순서('stack')에서 이 훅은 그 파일의 다른 afterAll 뒤에 돌고, 파일 격리 덕분에 연결이 파일을 넘지 않습니다. 앞선 afterAll 이 던지면 이 훅은 돌지 않지만, 그때는 워커가 끝나며 연결이 끊기고 그 파일은 이미 빨갛습니다. 이 단계로 272.0초가 205.5초가 됐습니다(−24.4%).

리뷰에서 바로잡힌 근거 한 문장

부끄럽지만 처음 주석에는 자가 검증의 근거를 «mysql2 의 reset() 은 응답이 오류 패킷이어도 성공으로 부른다»고 적었습니다. 틀린 문장이었습니다. mysql2 3.23.2 에서 이 명령의 응답 처리 함수만 보면 패킷을 열어 보지 않지만, 그보다 먼저 모든 명령의 공통 부모(Command.execute)가 packet.isError() 를 확인해 reject 합니다. 오류(1047)를 돌려주는 가짜 서버로도 확인했습니다.

근거는 고쳤지만 자가 검증은 남겼습니다. 오류 패킷은 이미 시끄럽습니다. 이 검증이 잡으려는 것은 반대쪽, OK 를 돌려주고도 세션을 비우지 않는 경우입니다. 사이에 낀 프록시나 서버, 드라이버의 회귀가 그런 모양일 수 있습니다. 근거가 틀렸다고 장치까지 걷어 냈다면 그 장치가 막던 다른 실패도 함께 내려놓을 뻔했습니다.

계약 스펙 9건과 결함 주입 8종

하네스의 약속 «다시 써도 호출마다 새 연결과 같다»를 앱 없이 하네스와 DB 만 보는 스펙 9건으로 고정했습니다. 사용자 변수, 시간대와 sql_mode, 재고정 문장, 열어 둔 트랜잭션, LAST_INSERT_ID(), GET_LOCK, USE, 문자셋, 동시 대여입니다. 재고정 문장 케이스만은 보낸 문장을 봅니다. 로컬은 전역값이 고정값과 같아 재고정을 빼도 같은 값이 나온다는 맹점을 리뷰에서 짚었기 때문입니다.

스펙이 그 약속을 정말 재는지는 예전 글 「결함 주입으로 방어선 24곳을 망가뜨려 봤습니다」에서처럼 하네스를 한 곳씩 망가뜨려 확인했습니다. 백업하고, 바꾸고, 파일 하나만 돌리고, 되돌린 뒤 해시를 대조했습니다.

주입빨강 / 전체모양
① 돌려받을 때 reset() 을 뺌7 / 9뒤 케이스 5건이 정리 단계에서 잠금 대기 초과(1205) · 실행 302초
② 문자셋 재설정을 뺌1 / 9문자셋 케이스
③ 시간대 · sql_mode 재고정을 뺌1 / 9보낸 문장 케이스만
④ 빌려 간 동안 자리를 비우지 않음8 / 9닫힌 연결이 자리에 남음
⑤ 자가 검증의 reset() 을 뺌9 / 9연결을 열 때마다 자가 검증 실패
⑥ USE 를 통과시킴1 / 9USE 케이스
⑦ 통계 만료 0 을 뺌0 / 9정리 계약 스펙이 잡음
⑧ 정리 목록에 없는 표를 더함9 / 9대조를 넣기 전에는 초록

가장 오래 본 줄은 ①입니다. 커밋되지 않은 INSERT 가 행 잠금을 쥔 채 남아, 실패 대부분이 그 케이스가 아니라 뒤 케이스의 정리 단계에서 났습니다. ⑦은 정리 계약 스펙이 잡으므로 두 스펙은 함께 있어야 합니다.

측정 — 로컬 −46%, CI −35%

로컬은 12코어 · 4샤드에서, 분배가 같도록 새 계약 스펙을 뺀 118개 파일로 비교했습니다. 시작 직전이나 끝난 직후의 1분 부하가 10 이상인 표본은 버렸습니다.

판wall 중앙값차이user+sys
기준 (표본 3)381.0초—441.2초
해법 1 (표본 3)272.0초−109.0초 (−28.6%)422.6초
해법 1+2 (표본 4)205.5초−66.5초 (−24.4%)340.7초

합치면 −175.5초(−46.1%)입니다. 해법 1 은 wall 이 109초 줄 때 테스트 프로세스의 CPU 시간(user+sys)은 19초만 줄어, 줄어든 몫은 대부분 서버의 DDL 을 기다리던 시간이었다고 읽었습니다. 같은 판 표본끼리도 ±6~9% 흔들렸다는 점은 함께 봐야 합니다.

CI 는 8코어 러너에서 돕니다. 러너는 VM 마다 속도가 달라, 코드와 무관한 단계(lint · 웹 빌드) 시간으로 등급을 가르고 같은 등급끼리 비교했습니다. e2e 단계는 정상 등급에서 336.3초가 218.3초(−35%, 각 3회 평균), 아주 느린 등급에서 483.5초가 299초(−38%)가 됐습니다. 개선 후 값에는 그사이 늘어난 테스트 90건(2.1%)이 들어 있고, 아주 느린 등급은 표본이 적어 참고용입니다. 두 환경 모두 단계 시간은 가장 늦게 끝나는 샤드가 정했고, 늘 같은 샤드였습니다.

넣지 않은 것

기준은 가장 늦는 샤드 시간의 ±5%, 약 ±10초였습니다. 그 안의 차이는 흔들림과 구별할 수 없습니다.

후보가장 늦는 샤드의 상한까닭
DB 준비를 globalSetup 한 번으로2.9초 (1.4%)샤드마다 새 DB 라 첫 이관(약 17초)은 남습니다
순수 중복 테스트 정리2.8초 (1.4%)커버리지를 그대로 두었습니다
이관 리허설 두 파일 건너뛰기0 (실측 +4.5초)두 파일이 다른 샤드에 있습니다

마지막 줄은 실제로 돌려 봤습니다. 두 파일이 있던 샤드는 28초와 39초 빨라졌지만, 가장 늦는 샤드는 그대로라 전체는 변하지 않았습니다. 줄일 수 있는 것과 줄여서 의미가 있는 것은 다르다는 생각이 들었습니다. 분배가 바뀌면 다시 판단해야 합니다.

이 방법이 맞지 않는 경우

  • 테스트가 id 값에 기대지 않으면 되돌림 자체가 필요한지부터 볼 만합니다. 이 방법은 그 약속을 싸게 지킬 뿐입니다.
  • 연결을 다중화하는 프록시 뒤라면 되돌림이 직접 접속과 같다고 가정할 수 없습니다. 자가 검증은 그때 크게 실패하라고 둔 장치입니다.
  • vitest 훅 순서를 바꾸거나 파일 격리를 끄면 연결 수명의 전제가 깨집니다.
  • multipleStatements 는 문장이 전부 상수일 때만 안전합니다. 호출자 문장이 오는 경로에 켜면 SQL 주입의 문을 엽니다.

이번 측정이 증명하지 않는 것

  • 39% 와 14% 는 계측판의 비율이라 초 단위로 옮기면 안 됩니다.
  • 로컬 숫자는 부하로 걸러 낸 표본 3~4개의 중앙값입니다.
  • CI 숫자는 러너 등급을 사후에 가른 비교라 같은 등급 안의 VM 차이와 테스트 증가분을 떼어 내지 못했습니다.
  • 계약 스펙은 제가 열거한 세션 상태만 잽니다. 준비된 문장 · HANDLER 같은 나머지는 공식 문서에 기댑니다.
  • 결함 주입 8종은 제가 떠올린 실패 모양일 뿐입니다.

마치며

돌이켜 보면 시간을 가장 많이 쓴 곳은 빠르게 만드는 코드가 아니라 «바뀌기 전과 같다»를 보이는 일이었습니다. ALTER 를 줄이는 데는 조건 한 줄이면 됐지만, 그 조건이 낡은 통계를 읽지 않는지, 없는 표를 조용히 넘기지 않는지, 예전과 같은 문장을 보내는지 확인하는 데 더 많은 줄이 들었습니다.

테스트 시간을 줄이는 일은 테스트를 지우는 일이 아니라, 테스트가 기대는 약속을 더 싸게 지키는 일이라는 생각이 들었습니다. 다음 편에서는 이 작업 중에 만난 「밤에만 실패한 테스트」를 정리하겠습니다.

MySQLE2E 테스트테스트 하네스mysql2VitestCI 최적화