한 줄 요약 — 테스트가 느린 이유는 PHP가 아니라 디스크였습니다. MySQL을 메모리 위로 올리고, InnoDB 체크포인트 스톨을 잡고, 마지막으로 paratest로 병렬화하면서 워커끼리 부딪히는 DB와 writable 디렉터리를 분리했습니다.

시작: 러너를 늘렸는데도 느렸다

GitHub Actions 무료 2,000분을 다 써서 CI가 멈춘 뒤로, 집에 있는 우분투 팬리스 PC에 셀프호스티드 러너를 직접 띄워 쓰고 있습니다. GitHub 조직 단위로 등록하고 Docker로 러너 3대를 돌립니다. 분 단위 과금이 사라지니 대기열은 없어졌는데, 이번엔 한 번 도는 데 걸리는 시간이 문제였습니다.

CI4 기반 제품들의 PHPUnit 통합 테스트는 대부분 실제 MySQL에 붙습니다. 저장소 하나가 5분을 넘기기 시작하자, 클로드코드가 PR을 올리고 결과를 기다리는 시간이 코드를 쓰는 시간보다 길어졌습니다. AI로 개발하면 검증 속도가 곧 개발 속도라는 걸 이때 실감했습니다.

처음엔 CPU가 부족하다고 생각했습니다. 그런데 테스트가 도는 동안 top을 보면 CPU는 한가했고, iostat에서 디스크 쓰기 대기가 계속 높게 찍혀 있었습니다. 병목은 PHP가 아니라 MySQL의 fsync였습니다.

최종 결과부터

단계 변경 내용 테스트 시간
0 기본 MySQL 컨테이너, 단일 프로세스 5분 10초+
1 데이터 디렉터리를 tmpfs로 약 2분 50초
2 innodb-flush-log-at-trx-commit=0 등 내구성 옵션 해제 약 2분 05초
3 체크포인트 스톨 진단 후 redo 용량·I/O 설정 조정 약 1분 40초
4 paratest 4워커 + 워커별 DB 스키마 + WRITEPATH 분리 46초

순서가 중요합니다. 병렬화를 먼저 했다면 느린 디스크 하나에 네 워커가 동시에 몰려 오히려 더 느려졌을 겁니다. 단일 프로세스에서 DB 병목을 먼저 없앤 뒤에야 병렬화가 효과를 냈습니다.

1단계: MySQL 데이터를 tmpfs로

CI용 DB는 매번 새로 만들고 버립니다. 디스크에 남길 이유가 없으니 데이터 디렉터리를 통째로 메모리(tmpfs)에 올렸습니다. tmpfs에서는 fsync가 사실상 아무 일도 하지 않습니다.

한 가지 걸림돌이 있었습니다. GitHub Actions의 services: 컨테이너는 command를 받지 못해서 mysqld 옵션을 넘길 방법이 마땅치 않습니다. 그래서 서비스 컨테이너 대신 스텝에서 직접 docker run으로 띄웠습니다. 러너도 Docker 안에서 돌기 때문에, 러너 컨테이너와 같은 Docker 네트워크(ci-net)에 붙여 컨테이너 이름으로 접속합니다.

- name: MySQL 기동 (tmpfs)
  run: |
    NAME=ci-mysql-${{ github.run_id }}-${{ github.run_attempt }}
    echo "DB_HOST=$NAME" >> "$GITHUB_ENV"

    docker run -d --name "$NAME" --network ci-net \
      --tmpfs /var/lib/mysql:rw,size=2g \
      -e MYSQL_ROOT_PASSWORD=root \
      mysql:8.4 \
      --skip-log-bin \
      --innodb-flush-log-at-trx-commit=0 \
      --innodb-doublewrite=OFF \
      --innodb-buffer-pool-size=512M \
      --innodb-redo-log-capacity=512M \
      --innodb-io-capacity=10000 \
      --innodb-io-capacity-max=20000 \
      --innodb-flush-neighbors=0

    until docker exec "$NAME" mysqladmin ping -uroot -proot --silent; do
      sleep 1
    done

- name: MySQL 정리
  if: always()
  run: docker rm -f "$DB_HOST" || true

컨테이너 이름에 run_id와 run_attempt를 붙인 이유는 러너가 3대라서입니다. 같은 머신에서 여러 잡이 동시에 돌 때 이름이 겹치지 않아야 하고, 실패한 잡이 남긴 컨테이너는 if: always() 정리 스텝이 치웁니다.

2단계: 내구성을 버린다

tmpfs만으로도 절반 가까이 줄었지만, MySQL은 여전히 "디스크에 안전하게 쓰기 위한" 일을 하고 있었습니다. 운영 DB라면 절대 끄면 안 되는 옵션들이지만, CI DB는 잡이 끝나면 사라지니 지킬 데이터가 없습니다.

옵션 기본값의 동작 CI에서 바꾼 이유
innodb-flush-log-at-trx-commit=0 커밋마다 redo 로그를 쓰고 fsync (기본 1) 테스트는 커밋이 매우 잦습니다. 약 1초마다 몰아서 쓰게 하면 커밋 비용이 거의 사라집니다.
skip-log-bin MySQL 8부터 바이너리 로그가 기본으로 켜짐 복제도 복구도 하지 않으니 쓰기만 두 번 하는 셈입니다.
innodb-doublewrite=OFF 페이지 손상 대비로 같은 페이지를 두 번 기록 정전에 대비할 데이터가 없습니다.
innodb-flush-neighbors=0 회전 디스크를 위해 인접 페이지를 묶어서 플러시 메모리에서는 묶을 이유가 없고, 필요 없는 페이지까지 씁니다.

이 설정을 운영 서버의 my.cnf에 복사하는 일만은 없어야 합니다. 그래서 설정을 my.cnf 파일로 두지 않고 워크플로의 docker run 인자에만 적었습니다. CI 밖으로 새어 나갈 경로를 아예 만들지 않으려는 것입니다.

3단계: 가끔 멈추는 테스트 — 체크포인트 스톨

2단계까지 하자 평균은 빨라졌는데 이상한 현상이 남았습니다. 평소 0.3초 걸리는 테스트 클래스가 어떤 실행에서는 4~5초씩 걸렸습니다. 매번 같은 테스트가 느린 게 아니라, 실행 중 특정 시점에 무엇이 돌든 다 같이 느려졌습니다.

이런 패턴은 InnoDB 체크포인트 스톨을 의심해 볼 만합니다. InnoDB는 변경 내용을 redo 로그에 먼저 쓰고, 버퍼 풀의 더티 페이지는 나중에 천천히 플러시합니다. 그런데 redo 로그는 정해진 용량을 돌려 쓰기 때문에, 아직 플러시되지 않은 변경이 용량 한계에 가까워지면 InnoDB가 새 쓰기를 멈추고 더티 페이지부터 강제로 내려씁니다. 이 순간 모든 쿼리가 같이 멈춥니다.

테스트는 이 상황을 만들기 딱 좋습니다. 테이블을 만들고, 지우고, 비우는 DDL과 대량 INSERT가 짧은 시간에 몰리기 때문입니다. MySQL 8.4의 redo 로그 기본 용량(innodb_redo_log_capacity)은 100MB인데, 테스트 한 바퀴가 이 정도는 금방 채웠습니다.

진단 방법

테스트가 도는 동안 옆 터미널에서 1초마다 체크포인트 나이(현재 LSN과 마지막 체크포인트 LSN의 차이)가 redo 용량의 몇 퍼센트인지 찍어 봤습니다.

SELECT
  ROUND((cur.v - chk.v) / @@innodb_redo_log_capacity * 100, 1) AS checkpoint_age_pct,
  dirty.v AS dirty_pages,
  waits.v AS log_waits
FROM
  (SELECT variable_value v FROM performance_schema.global_status
    WHERE variable_name = 'Innodb_redo_log_current_lsn')    cur,
  (SELECT variable_value v FROM performance_schema.global_status
    WHERE variable_name = 'Innodb_redo_log_checkpoint_lsn') chk,
  (SELECT variable_value v FROM performance_schema.global_status
    WHERE variable_name = 'Innodb_buffer_pool_pages_dirty') dirty,
  (SELECT variable_value v FROM performance_schema.global_status
    WHERE variable_name = 'Innodb_log_waits')               waits;
# 테스트 실행 중 1초 간격으로 관찰
while true; do
  docker exec "$DB_HOST" mysql -uroot -proot -N -e "$(cat checkpoint.sql)"
  sleep 1
done

읽는 법은 단순합니다. checkpoint_age_pct가 낮게 오르내리면 정상이고, 80~90%대에 붙어 머무는 구간이 테스트가 멈칫하는 시점과 겹치면 스톨입니다. 같은 정보는 SHOW ENGINE INNODB STATUS\G의 LOG 섹션에서도 볼 수 있습니다. Log sequence number와 Last checkpoint at의 차이가 같은 값입니다. Innodb_log_waits가 올라간다면 로그 버퍼 자체가 작다는 뜻이니 이것도 함께 봅니다.

해결

제 경우 느려진 순간이 체크포인트 나이가 한계에 붙은 구간과 정확히 겹쳤습니다. 손댄 것은 두 가지입니다.

  • redo 용량을 늘렸습니다. innodb-redo-log-capacity=512M. tmpfs 위라 이 용량만큼 메모리를 쓰지만, 테스트 한 바퀴 동안 한계에 닿지 않을 만큼이면 충분합니다.
  • 플러시를 공격적으로 시켰습니다. innodb-io-capacity의 기본값(200)은 느린 디스크를 기준으로 한 값입니다. 메모리 위에서는 훨씬 높게 잡아야 InnoDB가 백그라운드에서 더티 페이지를 미리 내려씁니다.

이후로는 가끔 튀던 테스트가 사라졌고, 실행 시간의 편차도 거의 없어졌습니다. 평균보다 편차가 줄어든 게 더 반가웠습니다. 실행할 때마다 시간이 다르면 다음 최적화의 효과를 잴 수가 없기 때문입니다.

함께 볼 것: DDL을 몇 번 하고 있나
CI4의 DatabaseTestTrait에서 $refresh = true이면 기본적으로 테스트 메서드마다 마이그레이션을 되돌렸다가 다시 올립니다. DDL이 많을수록 redo가 빨리 차니, 마이그레이션을 클래스당 한 번만 해도 되는 테스트라면 protected $migrateOnce = true;(시드는 $seedOnce)를 검토해 볼 만합니다.

4단계: paratest로 병렬화

DB가 더는 병목이 아니게 되자 이번엔 CPU가 놀고 있었습니다. PHPUnit은 프로세스 하나로 테스트를 차례로 돌리기 때문입니다. paratest를 붙여 테스트 클래스를 여러 프로세스에 나눠 돌렸습니다.

composer require --dev brianium/paratest

vendor/bin/paratest --processes=4 --configuration=phpunit.xml.dist

명령만 바꾸면 끝날 것 같지만, 처음 돌리자마자 테스트가 무작위로 깨졌습니다. 네 워커가 같은 DB와 같은 writable 디렉터리를 동시에 쓰고 있었기 때문입니다.

공유 자원 병렬 실행 시 증상 해결
테스트 DB 한 워커의 마이그레이션 롤백이 다른 워커가 쓰던 테이블을 지움, 행 개수 검증 실패 워커별 스키마 (ci4_test_1 ~ ci4_test_4)
writable/cache 한 테스트의 cache()->clean()이 다른 워커 캐시를 지움, Throttler 기반 레이트리밋 테스트가 남의 카운트로 실패 워커별 WRITEPATH
writable/uploads 업로드 테스트의 정리 코드가 다른 워커의 파일까지 삭제
writable/session, logs 세션 파일 충돌, 로그 내용을 검증하는 테스트가 다른 워커의 로그를 읽음

둘 다 paratest가 워커마다 넣어 주는 환경변수 TEST_TOKEN(1, 2, 3, 4…)으로 풉니다.

워커별 DB 스키마 분리

먼저 워크플로에서 워커 수만큼 스키마를 만듭니다.

- name: 워커별 테스트 스키마 생성
  run: |
    for i in 1 2 3 4; do
      docker exec "$DB_HOST" mysql -uroot -proot -e \
        "CREATE DATABASE ci4_test_$i CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
    done

그리고 app/Config/Database.php의 생성자에서 테스트 환경일 때 DB 이름 뒤에 토큰을 붙입니다. .env의 database.tests.database = ci4_test가 기본값이라, 로컬에서 그냥 phpunit을 돌리면 토큰이 없어 원래대로 ci4_test를 씁니다.

public function __construct()
{
    parent::__construct();

    if (ENVIRONMENT === 'testing') {
        $this->defaultGroup = 'tests';

        // paratest 워커마다 별도 스키마: ci4_test_1, ci4_test_2 ...
        $token = getenv('TEST_TOKEN');
        if ($token !== false && $token !== '') {
            $this->tests['database'] .= '_' . $token;
        }
    }
}

스키마를 나누는 대신 워커별 DBPrefix를 쓰는 방법도 있습니다. 하지만 프리픽스는 원시 SQL이나 프리픽스를 안 붙인 쿼리에서 새기 쉽습니다. 스키마를 아예 나누면 그럴 여지가 없습니다.

WRITEPATH 선점 트릭

writable 경로는 설정이 아니라 상수(WRITEPATH)라서 실행 중에 바꿀 수 없습니다. 대신 정의되는 순서를 이용할 수 있습니다. CI4의 테스트 부트스트랩(system/Test/bootstrap.php)은 경로 상수를 defined('WRITEPATH') || define(...) 형태로 정의합니다. 즉 먼저 정의된 값이 이깁니다. 그래서 프레임워크 부트스트랩보다 앞에서 워커별 경로로 먼저 정의해 버렸습니다.

<?php
// tests/bootstrap.php
// CI4 테스트 부트스트랩은 WRITEPATH가 이미 있으면 덮어쓰지 않는다.
// paratest 워커마다 writable 디렉터리를 먼저 정의해 선점한다.

$token = getenv('TEST_TOKEN');

if ($token !== false && $token !== '') {
    $base = dirname(__DIR__) . '/writable/test-workers/w' . $token;

    foreach (['cache', 'logs', 'session', 'uploads', 'debugbar'] as $dir) {
        $path = $base . '/' . $dir;
        // 워커들이 동시에 부모 디렉터리를 만들 수 있으니 경합에 안전하게
        if (! is_dir($path) && ! @mkdir($path, 0775, true) && ! is_dir($path)) {
            throw new RuntimeException("Cannot create {$path}");
        }
    }

    define('WRITEPATH', realpath($base) . DIRECTORY_SEPARATOR);
}

require dirname(__DIR__) . '/vendor/codeigniter4/framework/system/Test/bootstrap.php';
<!-- phpunit.xml.dist -->
<phpunit bootstrap="tests/bootstrap.php" ...>

Config\Cache, Config\Session, 로거 경로가 모두 WRITEPATH를 기준으로 잡히기 때문에 이 한 군데만 바꾸면 나머지는 따라옵니다. 설정 파일에 writable 경로를 직접 적어 둔 곳이 있다면 그 부분만 WRITEPATH 기준으로 고치면 됩니다. writable/test-workers/는 .gitignore에 넣었습니다.

워커 수는 몇이 좋은가

팬리스 PC에서 러너 3대가 같은 CPU를 나눠 쓰기 때문에, 워커를 코어 수만큼 늘린다고 비례해서 빨라지지는 않았습니다. 2, 4, 6으로 바꿔 가며 재 보니 4에서 가장 짧았고, 6부터는 오히려 느려졌습니다. 다른 저장소의 잡이 동시에 돌 때를 생각하면 넉넉하게 남겨 두는 편이 낫습니다. 테스트 클래스 크기가 고르지 않으면 가장 긴 클래스 하나가 전체 시간을 정하니, 지나치게 큰 테스트 클래스는 나누는 것도 효과가 있습니다.

돌아보며

  1. 재 보기 전엔 아무것도 모른다. CPU 탓이라고 확신했는데 실제 병목은 fsync였습니다. iostat 한 번이 추측 한 시간보다 정확했습니다.
  2. 병목을 없앤 다음에 병렬화한다. 공유 자원이 느린 상태에서 병렬화하면 줄을 서는 사람만 늘어납니다. 순서를 바꿨다면 46초는 나오지 않았을 겁니다.
  3. CI DB는 운영 DB가 아니다. 운영에서 지켜야 할 내구성 옵션이 CI에서는 순수한 비용입니다. 대신 그 설정이 운영으로 새지 않도록 워크플로 안에만 둡니다.
  4. 평균보다 편차를 본다. 체크포인트 스톨은 평균을 크게 바꾸지 않았지만, 실행마다 시간이 들쭉날쭉하게 만들었습니다. 편차가 크면 다음 개선의 효과를 잴 수 없습니다.
  5. 병렬 테스트는 격리 테스트다. paratest를 붙이자 깨진 테스트들은 사실 원래부터 공유 상태에 기대고 있던 테스트였습니다. 병렬화가 숨은 의존성을 드러내 준 셈입니다.

5분이 46초가 되자 클로드코드가 PR을 올리고 결과를 받아 고치는 한 바퀴가 눈에 띄게 빨라졌습니다. AI가 코드를 쓰는 속도는 이미 충분히 빠릅니다. 이제 남은 병목은 그 코드를 검증하는 쪽이고, 그 속도는 이렇게 하나씩 재고 줄여 나가면 됩니다.