통합 테스트를 작성할 때 SQLite나 H2 같은 인메모리 데이터베이스를 사용했다가 운영 환경에서 예외가 터지는 경험을 해봤을 것이다.
다만 로컬 DB 환경을 고정해두고 테스트하자니 테스트 데이터 격리가 깨지거나 CI/CD 파이프라인에서 매번 DB를 구축하는 작업이 꽤나 번거롭다.
이번에는 인메모리 DB가 가져오는 치명적인 한계가 무엇인지, 왜 Testcontainers가 필요한지, 그리고 Node.js 환경에서 Docker 기반 MySQL 통합 테스트를 어떻게 구현하는지 완벽하게 정리해서 소개하겠다.

 

1. 테스트용 인메모리 DB가 실무에서 위험한 이유

대부분의 백엔드 개발자들은 테스트 속도를 높이기 위해 SQLite나 H2 같은 인메모리 DB를 활용한다. 문제는 운영에서 사용하는 DB(MySQL, PostgreSQL 등)와 인메모리 DB 간의 문법 및 기능 지원 차이다.
MySQL 전용 JSON 함수나 `ON DUPLICATE KEY UPDATE` 같은 구문, 인덱스 락 동작 방식은 인메모리 DB에서 동일하게 동작하지 않는다. 이로 인해 테스트 코드는 정상 통과하지만 운영 데이터베이스에 배포되는 순간 SQL 문법 에러가 발생하는 불상사가 자주 일어난다.

비교 항목인메모리 DB (SQLite/H2)Testcontainers (실제 Docker DB)
실행 속도매우 빠름 (수십 ms)보통 (컨테이너 구동에 2~5초 소요)
운영 DB 일치성낮음 (특수 함수 및 락 미지원)100% 동일 (운영과 동일한 엔진/버전)
격리성프로세스 단위 격리완벽한 컨테이너 독립 격리
CI 파이프라인 적용설정 간단함Docker 데몬 환경 필요

 

2. Testcontainers 동작 원리와 환경 구성

Testcontainers는 테스트 코드 실행 시점에 Docker API를 호출하여 필요한 데이터베이스 컨테이너를 동적으로 띄워준다. 테스트가 완료되면 컨테이너를 자동으로 파기하므로 매번 깨끗하게 격리된 DB 환경을 보장한다.

  • 문제: 개발자마다 로컬 DB 데이터가 달라 테스트 결과가 불일치함 → 해결: Testcontainers가 무상태(Stateless) 컨테이너를 자동 생성하여 일관성 유지
  • 문제: CI 서버에 별도의 MySQL 서비스를 상시 띄워두어야 함 → 해결: 테스트 실행 시점에만 컨테이너가 생성되고 종료 시 자동 삭제됨

 

3. Jest와 Testcontainers 연동 실전 코드

Node.js 환경에서 Jest와 `@testcontainers/mysql` 패키지를 조합하여 실제 MySQL 8.0 데이터베이스 기반의 통합 테스트를 작성해보자.

 

✗ 잘못된 예제: 인메모리 DB(SQLite)를 활용한 단위 테스트

SQLite를 사용하여 MySQL 전용 JSON 추출 함수(`JSON_EXTRACT`)를 검증하려고 하면 구문 오류가 발생하거나 잘못된 검증 결과를 낳는다.

// SQLite 기반 테스트 - MySQL 전용 문법 검증 불가
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');

test('사용자 옵션 JSON 조회 테스트', async () => {
  const db = await open({ filename: ':memory:', driver: sqlite3.Database });
  await db.exec('CREATE TABLE users (id INT, settings TEXT)');
  await db.exec("INSERT INTO users VALUES (1, '{\"theme\": \"dark\"}')");

  // MySQL 전용 JSON_EXTRACT 함수를 호출하면 SQLite에서 에러 발생
  const result = await db.get("SELECT JSON_EXTRACT(settings, '$.theme') as theme FROM users WHERE id = 1");
  expect(result.theme).toBe('dark');
});

 

✓ 올바른 예제: Testcontainers 기반의 실제 MySQL 통합 테스트

Testcontainers로 Docker 기반 MySQL 8.0 컨테이너를 구동하여 실제 운영과 동일한 환경에서 쿼리를 검증한다.

const { MySqlContainer } = require('@testcontainers/mysql');
const mysql = require('mysql2/promise');

describe('User Repository 통합 테스트', () => {
  let container;
  let connection;

  // 전체 테스트 시작 전 MySQL 컨테이너 구동
  beforeAll(async () => {
    container = await new MySqlContainer('mysql:8.0')
      .withDatabase('test_db')
      .withUser('test_user')
      .withRootPassword('root_pass')
      .start();

    connection = await mysql.createConnection({
      host: container.getHost(),
      port: container.getPort(),
      user: container.getUsername(),
      password: container.getUserPassword(),
      database: container.getDatabase(),
    });

    await connection.execute(`
      CREATE TABLE users (
        id INT AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(50),
        settings JSON
      )
    `);
  }, 60000); // 컨테이너 구동 시간을 고려해 타임아웃 60초 설정

  // 테스트 종료 후 컨테이너 및 DB 연결 정리
  afterAll(async () => {
    if (connection) await connection.end();
    if (container) await container.stop();
  });

  test('MySQL JSON_EXTRACT 함수 연산 정상 작동 검증', async () => {
    await connection.execute(
      "INSERT INTO users (name, settings) VALUES (?, ?)",
      ['홍길동', JSON.stringify({ theme: 'dark', notifications: true })]
    );

    const [rows] = await connection.execute(
      "SELECT name, JSON_UNQUOTE(JSON_EXTRACT(settings, '$.theme')) as theme FROM users WHERE name = ?",
      ['홍길동']
    );

    expect(rows[0].theme).toBe('dark');
  });
});

 

테스트 실행 및 결과 출력

Jest를 실행하면 Docker 컨테이너가 생성된 뒤 실제 쿼리가 수행되고 무사히 통과한다.

PASS tests/integration/user.test.js (8.241 s)
  User Repository 통합 테스트
    ✓ MySQL JSON_EXTRACT 함수 연산 정상 작동 검증 (42 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        8.352 s

 

4. Testcontainers 사용 시 흔한 실수와 주의사항

Testcontainers는 강력하지만 올바르게 설정하지 않으면 테스트 실행 시간이 기하급수적으로 늘어난다.

✗ 매 테스트 케이스(`it`/`test`)마다 컨테이너를 새로 생성하는 방식
✓ `beforeAll`을 활용해 파일 단위로 컨테이너를 공유하거나, Reusable Containers 설정을 켜서 컨테이너 재사용성을 높여야 한다.

✗ 컨테이너 자원 정리(`container.stop()`)를 생략하여 Docker 리소스가 누수되는 현상
✓ Testcontainers 내부의 Ryuk 컨테이너가 자원을 자동 회수하지만, 코드 수준에서 `afterAll` hook을 통해 명시적으로 `stop()`을 호출하는 것이 안전하다.

 

5. 요약 및 마무리

Testcontainers로 구축한 격리된 테스트 환경은 운영 환경과의 갭을 줄여주는 핵심 요소다. 인메모리 DB의 가짜 안전함에 속지 않고 실제 DB 엔진을 테스트에 도입하는 작은 습관이 모여서 장애 없는 프로덕션 배포를 만든다는 점을 잊지 말자. 이 글의 Jest 및 Testcontainers 연동 코드를 참고해 CI 파이프라인에 적용해보면, DB 환경 차이로 인한 예외 발생 없는 완벽한 통합 테스트 결과를 얻을 수 있을 것이다.