2026년 11월 2일 월요일
미단재

PHP·Node·TS·Flutter 4언어 CI를 self-hosted 러너에 올리며 밟은 함정들

시리즈 CI·테스트 인프라 삽질 현재 위치 14 / 14
  1. CI 테스트 5분+ → 46초: MySQL 튜닝부터 paratest 병렬화까지
  2. 자체 호스팅 러너가 컨테이너일 때 CI가 깨지는 6가지
  3. GitHub Actions로 AI가 만든 코드를 검증하는 파이프라인
  4. GitHub CI 환경 전환 사례: Mac → Linux 러너 이전과 문제 해결
  5. GitHub 조직 이름을 바꿨더니 저장소 다섯 개의 워크플로를 고쳐야 했다
  6. CI 연속 실패 일지: #129‑#144 PR을 고치며 배운 15가지 교훈
  7. 테스트가 서로를 망가뜨릴 때: 주문 테스트 고아 행으로 찾은 스위트 오염
  8. "회원탈퇴가 없네?"에서 시작해 1221개 테스트까지
  9. AI 개발 컨설팅 첫 상담에서 실제로 묻는 질문들
  10. "클론 333, 방문자 2" — 퍼블릭 저장소 트래픽 숫자의 진실
  11. 개별로는 통과하는데 전체에서만 죽는다 — AIFid 테스트 스위트가 composer test를 멈춘 날들
  12. 게으름이 개발자를 움직이게 만든다
  13. Claude Code로 구현한 이슈‑배포 자동화 플로우
  14. PHP·Node·TS·Flutter 4언어 CI를 self-hosted 러너에 올리며 밟은 함정들

시리즈 소개 보기

요약: 채팅 SaaS 저장소 하나에 PHP, Node, TypeScript, Dart 네 언어가 섞여 있어 언어별로 CI 잡을 나눴다. 결제 문제로 호스팅 러너가 막혀 self-hosted 러너로 옮기는 과정에서 컨테이너 러너의 포트 접근, PHP 버전 편차, xdebug 커버리지, 1시간짜리 테스트까지 연달아 터졌고, 각각의 원인과 해결을 정리했다.

저장소 구성과 CI 정책

AIFid는 고객사 웹사이트에 위젯을 심어 상담하는 SaaS다. 한 저장소에 언어가 네 개 들어 있다.

디렉터리 언어 CI 잡이 하는 일
backoffice/ PHP 8.x / CodeIgniter 4 PSR-12, PHPStan level 6, PHPUnit
engine/ Node 22 lint, 테스트 (Redis 필요)
widget/, console/ TypeScript lint, 빌드(타입체크), 위젯 번들 크기
flutter/aifid_sdk/ Dart flutter analyze, flutter test

dev → main PR 하나에 CI 잡 4개가 언어별로 돈다. PHP는 DB와 Redis 컨테이너를 띄우고, Node는 Redis만, 프론트엔드는 컨테이너 없이, Flutter는 Dart 쪽만 검증한다.

정책은 단순하다. feature → dev PR에는 CI를 걸지 않고, dev → main 배포 PR에서만 네 잡을 전부 돌린다. 트리거는 이렇게 main 대상 PR로 한정했다.

on:
  pull_request:
    branches: [main]     # 비우면 모든 PR 에서 돌아 정책이 무의미해진다
  workflow_dispatch:

dev로 가는 단계의 검증은 pre-push 훅이 로컬에서 맡는다. 푸시 대상이 dev일 때만 composer analyze, composer test, npm run lint, npm test를 돌리고 실패하면 푸시를 막는다. 대신 CI가 늦게 돈다는 약점이 있고, 이 글의 사고 대부분이 거기서 나왔다.

첫 CI는 코드가 아니라 결제에서 죽었다

dev → main PR을 처음 만들자 잡 세 개(당시엔 세 개였다)가 4~13초 만에 모두 실패했다. 로그의 원인은 테스트가 아니었다.

The job was not started because recent account payments have failed
or your spending limit needs to be increased.

GitHub 호스팅 러너 자체가 배정되지 않은 것이다. 그래서 self-hosted 러너를 직접 띄우기로 했고, 처음엔 로컬 Mac을 러너로 등록했다. 이때 알게 된 점이 있었다. 러너 등록 토큰 발급(gh api -X POST .../actions/runners/registration-token)은 Claude Code의 권한 분류기가 자동 승인하지 않는다. 저장소에서 코드를 실행할 수 있는 권한이라 맞는 동작이고, 사용자가 명시적으로 승인해야 진행됐다.

Mac 러너로 바꾸자 숨어 있던 문제가 하나씩 나왔다.

  • Mac에 이미 떠 있던 시스템 mysqld가 3306을 쓰고 있어 CI용 MySQL 컨테이너 포트가 충돌했다. CI 전용 포트(13306)로 분리해 해결했다.
  • CI가 처음 도니 그동안 아무도 몰랐던 PSR-12 위반이 한 건 드러났다. dev에 CI가 없으니 당연한 결과였다.

이후 조직 레벨의 Linux 러너로 다시 옮겼다. macOS 러너는 services:를 못 써서 잡마다 docker run과 정리 스텝을 손으로 넣어야 했기 때문이다. 여기서부터가 본론이다.

함정 1. 러너가 컨테이너면 127.0.0.1은 호스트가 아니다

Linux 러너는 그 자체가 도커 컨테이너였다. services:의 ports:나 docker run -p로 게시한 포트는 도커 호스트의 127.0.0.1에 열리는데, 러너 컨테이너에서 그 주소는 자기 자신이라 Connection refused가 났다.

전에는 러너 컨테이너가 127.0.0.1:3306으로 접속해 거부당했고, 후에는 실행별 도커 네트워크에 러너와 MySQL 컨테이너를 함께 붙여 컨테이너 이름으로 접속한다.

해결은 실행마다 전용 네트워크를 만들어 러너와 DB 컨테이너를 같이 붙이고 컨테이너 이름으로 접속하는 것이다.

docker network create "$CI_NETWORK"
docker network connect "$CI_NETWORK" "$(hostname)"   # 러너 자신을 붙인다
docker run -d --name "$CI_MYSQL_HOST" --network "$CI_NETWORK" \
  -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=aifid_test \
  mysql:8.0

호스트 포트를 쓰지 않으니 같은 호스트에서 잡 여러 개가 동시에 돌아도 포트가 부딪히지 않는다. 정리는 if: always() 스텝에서 컨테이너와 네트워크를 지운다.

준비 대기에도 함정이 있었다. MySQL 8 공식 이미지는 초기화 중에 임시 서버를 잠깐 띄운다. 이 단계에서는 소켓으로 mysqladmin ping이 성공해서, 진짜 서버가 올라오기 전에 PHPUnit이 붙으면 Connection refused로 죽었다(당시 765개 테스트가 전부 실패했다). 그래서 TCP로 실제 인증 쿼리가 성공할 때까지 기다린다.

for i in $(seq 1 60); do
  docker exec "$CI_MYSQL_HOST" mysql -uroot -proot -h 127.0.0.1 -e "SELECT 1" aifid_test >/dev/null 2>&1 \
    && docker exec "$CI_REDIS_HOST" redis-cli ping >/dev/null 2>&1 && { ready=1; break; }
  sleep 1
done

함정 2. 러너 이미지가 제각각이다

같은 라벨(self-hosted, Linux, X64, docker)이어도 배정되는 러너에 따라 환경이 달랐다. 어떤 러너는 PHP 8.5, 네이티브 Ubuntu 24.04 러너는 8.3.6이라 composer.json의 요구를 못 채워 composer install이 실패했다.

PHP 설치도 한 번 크게 막혔다. shivammathur/setup-php가 쓰는 ondrej PPA에 이 러너의 Ubuntu 26.04용 패키지가 아직 없어서 apt-get update가 실패했고, 액션이 무한 재시도하면서 CI가 30분 넘게 멈췄다. 네이티브 저장소에 PHP 8.5가 이미 있어 setup-php를 버리고 apt로 직접 설치하는 "PHP 보장" 스텝으로 바꿨다. 핵심은 세 가지다.

  • 현재 PHP가 기준(8.4) 미만일 때만 PPA에서 8.5를 설치하고 update-alternatives로 기본 지정한다.
  • 확장은 php${V}-xxx로 지금 쓰는 버전에 맞춰, 없는 것만 설치한다. 버전 없는 php-intl은 다른 버전을 끌어온다.
  • composer.lock이 요구하는 확장(sqlite3 등)을 빠뜨리지 않는다. 하나 빠졌다가 한 번 더 실패했다.

apt 설치 중 needrestart가 "재시작할 서비스 선택" 대화상자를 띄우면 비대화형 러너가 영원히 기다린다. 잡 env에 DEBIAN_FRONTEND=noninteractive와 NEEDRESTART_MODE=a를 걸어 막았다.

함정 3. 테스트는 전부 통과했는데 잡이 실패했다

백오피스 잡이 이런 출력으로 끝났다.

OK, but there were issues!
Tests: 915, Assertions: 2075, PHPUnit Warnings: 1.

경고는 "XDEBUG_MODE=coverage가 설정돼야 한다" 하나였다. phpunit.dist.xml이 failOnWarning="true"에 커버리지 리포트까지 요구하는데, apt로 깐 xdebug는 기본 모드라 커버리지를 수집하지 않는다. 예전에는 setup-php의 coverage: xdebug가 이 모드를 켜 줬는데 apt로 바꾸며 사라진 것이다. 잡 env에 XDEBUG_MODE: coverage를 넣어 해결했다.

환경변수에는 더 고약한 함정이 하나 더 있었다. 처음에 database.tests.port처럼 점이 든 이름으로 넘겼는데 PHPUnit까지 도달하지 못했다. PHP는 점이 든 이름을 $_SERVER와 $_ENV에 넣지 않고, Composer는 자식 프로세스 환경을 array_intersect_key(getenv(), $_SERVER)로 만든다. 그래서 composer test로 PHPUnit을 띄우는 순간 값이 통째로 사라진다. 이 때문에 CI가 테스트용 컨테이너가 아니라 로컬 개발 DB에 붙은 채로 한동안 초록불이었다. 지금은 밑줄 이름(database_tests_port)으로 넘기고 tests/bootstrap.php가 점 이름으로 옮긴다.

비슷한 사고가 Redis에도 있었다. 테스트 하나가 포트를 로컬 개발값(6389)으로 박아 두고 $_ENV까지 덮어써서, CI에서 Connection refused로 네 건이 죽었다. Mac 러너에서는 그 포트에 개발 Redis가 떠 있어 우연히 통과했을 뿐이다. 접속 대상은 환경변수가 정하고, 없을 때만 로컬 기본값으로 떨어지게 고쳤다.

함정 4. 백오피스 CI가 1시간 타임아웃에 걸렸다

가장 아팠던 문제는 속도였다. 백오피스 잡이 이렇게 죽었다.

The process "phpunit" exceeded the timeout of 3600 seconds.

DB가 의심스러워 MySQL 계측 스텝을 임시로 넣어 봤는데 결과는 반대였다. 커넥션은 2~3개로 고정, abort 0, 테스트도 378개째까지 전부 통과 중이었다. 범인은 DB가 아니라 스위트가 느린 것이었다. CI4의 $refresh = true는 테스트 클래스가 아니라 메서드마다 전체 regress → migrate를 돌린다. 마이그레이션 38개에 DB 테스트가 수백 개니 DDL이 수만 건이고, InnoDB의 DROP/CREATE는 파일 조작이라 건당 수십 ms가 든다. 실측으로 러너의 MySQL이 567초 동안 테이블을 4,707번 열었다.

백오피스 PHPUnit 시간 비교. 메서드마다 스키마를 재생성하면 1시간을 넘겨 타임아웃, 스키마를 1회만 만들고 TRUNCATE하면 3분 22초, 비밀번호 해시 비용을 분리하면 1분 23초, TRUNCATE 대신 DELETE를 쓰면 28초.

세 단계로 줄였다.

  1. 스키마는 프로세스당 한 번. $migrateOnce = true, $refresh = false로 두고, 새 트레이트 TruncatesTables가 테스트마다 데이터만 비운다. 행이 있는 테이블만 비우고, 판별은 LIMIT 1 조회로 한다(InnoDB의 information_schema.TABLE_ROWS는 추정치라 믿을 수 없다). 마이그레이션 자체를 검증하는 *MigrationTest만 일부러 $refresh = true로 남겼다.
  2. 비밀번호 해시 비용 분리. 운영 Argon2id 파라미터(메모리 64MB, 반복 4회)는 해시 1회에 약 190ms인데, 테스트 픽스처가 사용자를 만들 때마다 이 비용이 얹혀 612회 호출에 111초가 들었다. 전체 202초의 55%였다. 비용 파라미터를 Config\Password로 빼고 phpunit.dist.xml에서만 낮췄다. 설정 기본값은 PHP 기본값 그대로라 운영 해시 강도는 바뀌지 않는다.
  3. TRUNCATE 대신 DELETE. InnoDB의 TRUNCATE는 테이블스페이스를 다시 만드는 파일 조작이라 행 몇 개를 지우는 DELETE보다 훨씬 비싸다. 915건 기준 실측은 이랬다.
방식 시간
TRUNCATE 81초
DELETE 28초
DELETE + ALTER TABLE ... AUTO_INCREMENT = 1 81초

대가도 있다. AUTO_INCREMENT가 리셋되지 않으니 'role_id' => 1처럼 id 리터럴을 박은 테스트는 깨진다. 전체에서 한 건 걸렸고 insertID() 값을 쓰도록 고쳤다. 원래도 다른 테스트가 먼저 돌면 바뀌는 값이라 기대면 안 되는 코드였다.

DB 컨테이너에도 같은 철학을 적용했다. 테스트 DB는 휘발성이라 내구성이 필요 없으므로 --tmpfs /var/lib/mysql과 --innodb-flush-log-at-trx-commit=0, --innodb-flush-method=nosync, --sync-binlog=0으로 디스크 I/O와 동기 flush를 껐다. 체크포인트 스톨을 막기 위한 설정이다.

함정 5. Flutter는 git 소유권에서 죽었다

Dart 잡은 flutter pub get이 exit 128로 죽었다.

fatal: detected dubious ownership in repository

러너는 root로 도는데 툴캐시의 Flutter SDK 소유자가 달라서 git이 거부한 것이다. flutter-action 직후에 한 줄을 넣어 해결했다.

- name: Flutter SDK git safe.directory 등록
  run: git config --global --add safe.directory "$FLUTTER_ROOT"

참고로 flutter_sdk 잡은 Dart 쪽만 본다. 네이티브(iOS/Android) 쪽 SDK는 아직 CocoaPods trunk와 Maven Central에 배포되기 전이라 이 잡에서 검증하지 않고, 주석에 그 이유를 남겼다.

CI 밖에서 도는 언어별 릴리스 워크플로

CI(ci.yml) 말고 모바일 SDK 배포용 워크플로가 따로 있다. 태그를 푸시하면 돈다.

워크플로 태그 러너 핵심 스텝
iOS ios/vX.Y.Z macOS ARM64 swift test, pod spec lint, pod trunk push
Android android/vX.Y.Z Linux JDK 17, ./gradlew :aifid-sdk:test, Maven Central 업로드
Flutter flutter/vX.Y.Z Linux flutter analyze/test, dart pub publish

세 워크플로 모두 공통 안전장치가 있다. 태그 버전과 패키지 파일의 버전(podspec, gradle.properties, pubspec.yaml)이 다르면 실패하고, CHANGELOG.md에 그 버전 항목이 없어도 배포를 막는다. workflow_dispatch의 dry_run 기본값은 true라서 실수로 수동 실행해도 게시되지 않는다. iOS 워크플로만 macOS 러너에서 돈다(swift test와 pod spec lint를 맥에서 돌린다).

배운 점

  • CI를 늦게 돌리면 사고도 늦게 터진다. dev → main에서만 CI가 돌아서 러너 전환 때 생긴 문제가 배포 PR에서야 나왔다. 로컬 Docker로 DB 연결과 테스트 명령을 먼저 재현하고 YAML 문법은 ruby -ryaml로 확인한다.
  • "초록불"을 의심하라. 점 이름 환경변수 때문에 CI가 로컬 DB에 붙은 채 통과한 적이 있다. 로컬 환경에서 우연히 통과하던 테스트(하드코딩 Redis 포트)는 CI에서 처음 드러난다.
  • 느린 CI는 원인을 계측으로 확정하라. DB를 의심해 계측을 넣었더니 DB는 멀쩡했고 스위트가 문제였다. 추측으로 DB 옵션을 더 얹었다면 시간만 썼을 것이다.
  • 러너 환경은 가정하지 마라. 컨테이너 여부, PHP 버전, 확장, 커버리지 드라이버, 파일 소유권이 러너마다 다르다. 워크플로가 필요한 것을 스스로 보장해야 한다.
  • 이 교훈은 저장소의 CLAUDE.md에도 옮겨 두었다. 같은 실수를 다른 저장소가 반복하지 않게 하려는 것이다.

이어 읽을 글

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.