Rails 테스트 스위트 및 CI 시간 절반으로 단축하기

The Whop chop: how we cut a Rails test suite and CI time in half

작성자
발행일
2025년 09월 03일

핵심 요약

  • 1 테스트 스위트 속도 향상을 위한 핵심은 정확한 프로파일링(StackProf, TestProf)을 통해 숨겨진 로깅 및 팩토리 오버헤드를 식별하고 제거하는 것입니다.
  • 2 FactoryBot 최적화(let_it_be, create_default)와 test-queue로의 병렬화 전환은 성능을 크게 개선했지만, 동시에 테스트 격리 문제를 노출시켰습니다.
  • 3 시간 관련 오류, 전역 상태 오염, 캐시 누수 등 불안정한 테스트를 해결하여 테스트 스위트의 신뢰성과 속도를 동시에 확보했습니다.

도입

Evil Martians는 클라이언트 Whop의 Rails 애플리케이션 테스트 스위트 실행 시간을 절반으로 단축하는 프로젝트를 수행했습니다. 이 글은 느린 테스트 스위트의 근본 원인을 파악하고 해결하는 과정을 상세히 설명합니다. 초기 목표는 런타임 최적화였으나, 이 과정에서 드러난 테스트 격리 문제 해결이 신뢰성 높은 테스트 스위트를 구축하는 데 핵심적인 요소였음이 밝혀집니다. TestProf와 StackProf와 같은 강력한 프로파일링 도구를 활용하여 병목 현상을 진단하고, 체계적인 접근 방식을 통해 성능과 안정성을 동시에 확보했습니다.

이 프로젝트는 테스트 스위트 최적화를 위해 여러 단계에 걸친 심층적인 분석과 개선 작업을 포함했습니다.

1. 프로파일링을 통한 숨겨진 로거 발견

  • 초기 진단: TEST_STACK_PROF=1 SAMPLE=200 bundle exec rspec spec 명령과 speedscope.app을 사용하여 200개 테스트 샘플을 프로파일링했습니다.
  • 병목 현상 식별: StackProf 보고서에서 과도한 데이터베이스 활동(Trilogy#query), 외부 서비스 호출(Stripe, OpenAI), 그리고 상당한 로깅 활동이 주요 용의자로 지목되었습니다.
  • 숨겨진 로거 해결:
    • Rails의 상세 SQL 로깅: config/environments/test.rb에서 config.active_record.verbose_query_logs = false, config.active_record.query_log_tags_enabled = false, config.log_level = :fatal 설정을 통해 비활성화했습니다. 이는 전체 프로젝트에서 가장 큰 성능 향상을 가져왔습니다.
    • Sentry 커스텀 로거: Sentry::Testing.fake!config.before(:suite)에 추가하여 테스트 환경에서 Sentry 로깅을 비활성화했습니다.
  • 결과: 이 변경 사항들로 단일 프로세스 실행 시간은 25분에서 12분으로, CI 실행 시간(16개 병렬 프로세스)은 4분에서 2분으로 각각 절반으로 단축되었습니다.

2. 팩토리 오버헤드 관리

  • 다음 병목 현상: 로깅 문제가 해결된 후, 프로파일러는 객체 생성, 특히 FactoryBot 사용으로 인한 오버헤드를 지목했습니다.
  • TagProf 활용: TAG_PROF_FORMAT=html TAG_PROF=type TAG_PROF_EVENT=sql.active_record,factory.create,sidekiq.inline bundle exec rspec 명령으로 factory.create 이벤트가 총 실행 시간의 절반 이상을 차지함을 확인했습니다.
  • 전략 1: let_it_be: TestProf의 let_it_be를 사용하여 describe 블록 내 모든 예제에서 동일한 데이터를 한 번만 생성하고 재사용하여 불필요한 데이터베이스 레코드 생성을 줄였습니다. 단, 상태 누수에 주의해야 합니다.
  • 전략 2: FactoryDefault: 중복 연관 객체 생성을 줄이기 위해 FPROF=1 FACTORY_DEFAULT_PROF=1 bundle exec rspec spec/services/some_heavy_spec.rb로 프로파일링하여 불필요하게 생성되는 연관 객체를 식별했습니다. create_default를 사용하여 공통 연관 객체를 한 번만 생성하고 재사용하도록 변경했습니다.

3. 병렬화 전환 및 테스트 격리 문제 노출

  • parallel_tests의 한계: 기존 parallel_tests gem은 테스트 파일을 프로세스에 미리 할당하여, 느린 테스트가 있는 워커는 다른 워커가 유휴 상태일 때까지 전체 빌드 시간을 지연시키는 문제가 있었습니다.
  • test-queue로 전환: 개별 테스트 예제를 중앙 큐에 넣고 워커가 완료 즉시 다음 작업을 가져가는 test-queue로 전환하여 모든 워커가 끝까지 바쁘게 작동하도록 했습니다.
  • 예상치 못한 결과: 전환 후 100개 이상의 테스트가 실패하는 “A sea of red” 상황이 발생했습니다. 이는 test-queue의 높은 동시성 환경이 그동안 숨겨져 있던 테스트 간의 의존성과 상태 누수를 여과 없이 드러낸 결과였습니다.

4. 불안정한 테스트 격리 해결

  • 사례 1: 시간 여행 및 역설:
    • 전역 Timecop.freeze 누수: Timecop.freezeTimecop.return이 호출되지 않아 다음 테스트에 시간이 고정되는 문제가 발생했습니다. rails_helper.rbconfig.after(:each) { Timecop.return }을 추가하여 해결했습니다.
    • Doorkeeper 미스터리: 병렬 워커에서 Timecop.freeze로 인해 과거 시간으로 고정된 상태에서 다른 테스트가 현재 시간에 유효한 Doorkeeper 토큰을 생성하면, 검증 시 만료된 것으로 처리되어 403 오류가 발생했습니다. rspec --bisect로 원인을 찾아 불필요한 Timecop.freeze 호출을 제거하여 해결했습니다.
  • 사례 2: 전역 오염 정리:
    • 상수 충돌: 동일한 이름의 상수가 다른 값으로 정의되어 마지막에 실행된 상수가 적용되는 문제가 발생했습니다. 상수의 이름을 파일별로 고유하게 변경하여 해결했습니다.
    • 캐시 및 설정 누수: PermissionsManager가 역할을 전역적으로 메모리에 캐싱하여 테스트 간에 오래된 데이터가 유출되었습니다. rails_helper.rbbefore 훅에서 PermissionsManager::SystemRoles.clear_cache!를 호출하여 해결했습니다. 전역 gRPC 클라이언트가 재설정되지 않는 문제도 after 훅을 통해 nil로 재설정하여 해결했습니다.
    • Sidekiq Enterprise unique! 모드 누수: 특정 테스트에서 Sidekiq Enterprise의 unique! 모드를 검증해야 했으나, 이 전역 설정이 다른 테스트에 영향을 주어 실패를 유발했습니다. around 훅을 사용하여 unique! 모드를 활성화하고 테스트 실행 후 ensure 블록에서 Sidekiq::Enterprise::Unique::Client 미들웨어를 제거하여 완벽한 격리를 보장했습니다.

결론

Whop의 테스트 스위트 속도를 높이려는 여정은 단순히 테스트를 빠르게 만드는 것을 넘어, 더욱 견고하고 신뢰할 수 있으며 규율 있는 테스트 스위트를 구축하는 결과로 이어졌습니다. 숨겨진 로거를 수정하여 얻은 초기 2배의 속도 향상도 중요했지만, 불안정한 테스트를 해결하는 과정에서 얻은 교훈이 진정한 가치였습니다. `test-queue`로의 전환은 테스트 스위트의 모든 추상화 누수와 상태 의존성을 드러내는 궁극적인 스트레스 테스트가 되었고, 이를 해결함으로써 테스트 스위트의 품질이 근본적으로 향상되었습니다. 핵심 교훈은 다음과 같습니다: 프로파일링을 통해 병목 현상을 정확히 파악하고, 팩토리 사용을 최적화하며, 숨겨진 I/O에 주의하고, 병렬 실행 환경에서 드러나는 테스트 격리 문제를 `rspec --bisect` 및 `around` 훅과 같은 도구를 활용하여 철저히 해결해야 한다는 것입니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!