많은 `#[sqlx::test]` 테스트를 사용하는 프로젝트에서 재빌드 시간이 느려지는 원인과, 공유 migrator를 사용해 이를 줄이는 방법을 설명합니다.
#[sqlx::test]테스트가 많은 프로젝트를 가지고 있다면 이 글이 특히 유용할 수 있습니다.
지난 몇 년 동안 제가 작업했던 업스트림 Rust 프로젝트 중 하나는 bors의 재작성입니다. bors는 우리가 rust-lang/rust의 모든 PR을 병합하는 데 사용하는 머지 큐 봇입니다. 이 봇에 대해 더 알고 싶다면 RustWeek 2026에서 했던 제 발표를 확인해 보세요.
저는 bors의 통합 테스트 스위트에 꽤 큰 자부심을 가지고 있습니다. 여기에 많은 노력을 들였고, 그 덕분에 2026년 1월에 프로덕션에 배포한 이후로 이 봇은 거의 완벽하게 작동해 왔습니다. 최근 GitHub에 종종… 문제가 있긴 했지만요.
하지만 제가 아주 만족하지는 못하는 점도 있는데, 바로 bors, 특히 그 테스트 스위트의 증분 재빌드 시간입니다. 제 노트북에서는 매 변경 후 테스트를 다시 빌드하는 데 오랜 시간(~8-10초)이 걸리는데, 생산성 측면에서 꽤 좋지 않습니다.
최근에서야 마침내 빌드 시간을 프로파일링할 시간을 냈고1, 그 원인이 여러 요소의 조합이라는 것을 알게 되었습니다:
bors 테스트를 꽤 자주 디버깅하고 한 단계씩 따라가 보기 때문입니다.rustc가 증분 세션을 로드하고 저장하는 데 오랜 시간이 걸립니다. 이 부분은 나중에 들여다볼 계획입니다.lld가 테스트 링크에 무려 1초(!)나 걸립니다. wild를 쓰면 겨우 ~200ms입니다.bors에서 많이 사용하고 있는 sqlx::test들이 컴파일에 오랜 시간이 걸립니다. 이 글에서는 이 부분에 집중하겠습니다.비교 기준으로, 제 벤치마크에서는 touch <test-file> && time cargo test --no-run을 사용했습니다. 아무 변화도 없는 변경 후에도 테스트를 다시 컴파일하는 데 ~7.5초가 걸렸는데, 정말 느립니다.
물론 sqlx의 proc macro가 컴파일 시간을 느리게 만들 수 있다는 것은 아주 잘 알려져 있습니다. 왜냐하면 그 안에서 온갖 범죄 흥미로운 일을 하기 때문입니다2. 하지만 제가 마주친 경우는 그렇게 자명하지 않을 수도 있습니다. 제 경우 sqlx는 실제로 데이터베이스에 연결조차 하지 않았습니다! SQL 쿼리를 직접 작업할 때가 아니라면 SQLX_OFFLINE=1로 컴파일하고 있기 때문입니다. 그리고 네, sqlx 문서에서 권장하는 대로 sqlx-macros 크레이트에 opt-level = 3도 설정해 두었습니다.
그렇다면 여기서 무슨 일이 벌어지고 있는 걸까요? 이를 알아보려면 다음과 같은 테스트가 있을 때 어떤 일이 일어나는지 이해하는 것이 중요합니다:
#[sqlx::test]
async fn test_foo(pool: sqlx::PgPool) {}
#[sqlx::test] 속성은 정말 유용합니다. 테스트 실행 전에 새 데이터베이스를 만들고, 그 위에 마이그레이션을 실행한 다음, 데이터베이스 연결 풀을 넘겨주기 때문입니다. 그래서 모의 HashMap3이 아니라 실제 데이터베이스를 대상으로 테스트를 돌릴 수 있습니다.
잠깐, 제가 마이그레이션이라고 했나요? 음, 그럼 이걸 어디서 찾을까요? 당연히 디스크입니다! #[sqlx::test]를 사용할 때마다 디스크의 디렉터리에서 모든 마이그레이션을 수집하고, 각 마이그레이션을 읽고, 파싱하고, 검증하고, 해시합니다. 어쩌면 직관과 다르게, 이 부분은 그렇게 느리지 않습니다! 알고 보니 Rust는 실제로 꽤 빠르고(누가 알았겠어요, 그렇죠??), 기가바이트 단위의 마이그레이션이 없다면 I/O도 아마 문제가 아닙니다4.
더 문제인 것은 이 매크로들이 생성하는 출력입니다. 이런 테스트 하나마다 매크로는 마이그레이션의 전체 목록을 생성하고, 각 마이그레이션의 텍스트 내용과 바이트 배열 형태의 체크섬까지 Rust 소스 코드 안의 상수로 포함합니다. 그래서 매크로를 확장해 보면 각 테스트 앞에 다음과 비슷한 것이 생깁니다:
args.migrator(&::sqlx::migrate::Migrator {
migrations: ::std::borrow::Cow::Borrowed(&[
::sqlx::migrate::Migration {
version: 20240517094752i64,
description: ::std::borrow::Cow::Borrowed("create build"),
migration_type: ::sqlx::migrate::MigrationType::ReversibleUp,
sql: ::std::borrow::Cow::Borrowed("CREATE TABLE <skipped>)"),
no_tx: false,
checksum: ::std::borrow::Cow::Borrowed(&[193u8, 202u8, <skipped>]),
},
::sqlx::migrate::Migration {
<skipped>
},
<skipped>
]),
<skipped>
});
위 예시는 축약된 것이고, 많은 부분을 생략했습니다. 실제로 생성되는 코드는 훨씬 더 길고, 당연히 마이그레이션의 수와 내용에 따라 규모가 커집니다.
이런 코드가 소스 코드 안에 한 번만 들어 있다면 그렇게 나쁘지는 않습니다. 하지만 bors에는 sqlx 테스트가 약 350개 있고, 마이그레이션은 30개입니다. 이쯤 되면 그 비용이 꽤 빠르게 누적되기 시작합니다.
마이그레이션이 빌드 지연의 일부 원인일 수 있다는 가설을 검증하기 위해, 나머지를 지우고 마이그레이션이 하나만 있을 때 어떻게 되는지 테스트해 보았습니다. 그랬더니 예상대로 재빌드 시간이 즉시 ~7.5초에서 ~5초로 줄었습니다! 아마 더 말해주는 지표는 cargo expand --lib --tests 출력 크기가 30개 마이그레이션일 때는 무려 32 MiB (!)였는데, 마이그레이션 하나만 남기니 “고작” 6 MiB로 줄었다는 점입니다. 추가로 26 MiB의 Rust 코드를 컴파일하는 일이 공짜일 리는 없겠지요.
문제는 생성된 코드의 컴파일 시간만이 아니었습니다. 프로파일을 보면 proc macro 실행 중 quote 크레이트를 사용해 마이그레이션 설명 데이터를 모두 토큰으로 변환하는 작업도 무시할 수 없는 시간을 차지하는 것처럼 보였습니다.
이 동작은 꽤 눈에 띄지 않게 숨어 있을 수 있습니다. 프로젝트 초반에는 재빌드가 빨랐거나(적어도 더 빨랐으니까요). 하지만 테스트가 하나씩 추가되고, 마이그레이션도 하나씩 추가될 때마다 재빌드 시간이 조금씩 늘어나기 때문에, 어느새 문제로 다가오게 됩니다.
제가 작년에 컴파일러에 추가했던 실험적인 proc macro caching 기능이 도움이 될지 시험해 봤지만, 그렇지 않았습니다. 아마 생성된 코드를 컴파일하는 데 드는 시간이 proc macro 자체를 실행하는 시간보다 훨씬 커서, proc macro 자체가 캐시되어도 도움이 되지 않는 것 같습니다.
아니었습니다. #[sqlx::test]는 derive 매크로가 아니라 attribute 이므로, 이 플래그는 여기에는 적용되지 않습니다. 지적해준 @futile에게 감사드립니다.
처음에는 생성된 코드의 크기를 줄이는 방법을 고민하기 시작했습니다. 예를 들어 체크섬 바이트 배열을 더 압축된 형태로 표현하는 식입니다. 아마 도움이 되겠지만, 각 테스트 옆에 모든 마이그레이션에 대한 코드를 생성하는 한 여전히 코드가 너무 많을 것이라는 점을 깨달았습니다.
그다음에는 sqlx를 패치해서 마이그레이션 로딩을 컴파일 시점이 아니라 (테스트) 런타임으로 옮겨, 인라인된 마이그레이션을 없애 보려 했습니다. 실제로 이 방법은 기대한 효과를 냈습니다! 재빌드 시간은 ~5초로 줄었고, (bors의 경우에는 적어도) cargo expand 출력도 ~6 MiB로 줄었으며, 테스트 실행 시간은 측정 가능한 수준으로는 영향을 받지 않았습니다. 변경 자체도 그리 복잡하지 않았습니다. 필요한 것은 proc macro 안에서 마이그레이션을 로드한 뒤 모든 설명을 생성된 소스 코드에 박아 넣는 대신, 런타임에 마이그레이션을 로드하는 함수를 호출하는 코드를 생성하는 것뿐입니다.
즉, 본질적으로는 다음과 같이 바꾸는 것입니다(의사 코드):
fn sqlx_proc_macro() -> TokenStream {
let migrations = generate_migrations();
quote! {
Migrator {
migrations: #migrations
}
}
}
이것을 다음처럼요:
fn sqlx_proc_macro() -> TokenStream {
quote! {
Migrator {
migrations: ::sqlx::generate_migrations()
}
}
}
하지만 런타임에 마이그레이션을 로드하는 방식은 단점이 있을 수 있습니다. 예를 들어 테스트가 더 이상 자기완결적이지 않게 됩니다.
그래서 sqlx Discord 서버에 가서 혹시 사람들이 제안할 만한 것이 있는지 물어보았습니다. 제 제안은 sqlx가 런타임에 마이그레이션을 로드하도록 하거나(위에서 설명한 방식), 혹은 모든 테스트가 참조하는 공유 마이그레이션 변수를 두어서 생성 코드 팽창을 피하도록 하자는 것이었습니다. 재미있게도 한 사람이 답하면서 두 번째 해결책은 이미 구현되어 있다고 말해 주었습니다(정확히는, 어느 정도는요). 실제로 #[sqlx::test]에서 적용할 마이그레이션을 담은 변수의 경로를 지정할 수 있습니다:
// The macro generates the migrations, we store it in a single variable.
const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
// Each test just references the variable, instead of inlining all the
// migrations next to the test's source code.
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_1(pool: sqlx::PgPool) {}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_2(pool: sqlx::PgPool) {}
여기서는
const와static중 무엇이 더 나은지 확신하지 못했고, 재빌드 시간 차이도 측정하지 않았습니다. 하지만 최종 바이너리 안에 데이터가 딱 한 번만 존재하도록 보장한다는 점에서, 제게는static이 더 타당해 보입니다.
이 해결책은 bors에서 아주 잘 작동했습니다! 약간의 Find + Replace 마법으로 모든 #[sqlx::test] 인스턴스에 migrator 인자를 추가한 후, 재빌드 시간은 ~5초로 내려갔습니다.
이 방법이 동작하긴 하지만, 제가 마음에 들어 하지 않는 점은 마이그레이션 코드 팽창 문제를 피하려고 모든 테스트에 migrator = "crate::MIGRATOR" 속성을 일일이 추가해야 하고, 또 그것을 기억해야 한다는 번거로움입니다. 제 생각에는 sqlx 0.9 릴리스에서 추가된 sqlx.toml 설정 파일에 migrator 인자의 기본값을 지정할 수 있도록 하면 꽤 우아할 것입니다. 그러면 굳이 신경 쓰지 않아도 기본적으로 공유 변수가 사용될 테니까요.
저는 이 기능을 제안하는 issue를 열었습니다. sqlx 메인테이너들이 어떻게 생각할지 지켜보죠. 물론 이런 기능에 대해 우려가 있을 수도 있다는 점은 이해합니다.
어쨌든 이런 수동 해결책조차도 꽤 도움이 되었고, 테스트 재빌드 시간을 조금 더 빠르게 만들어 주었습니다. 물론 5초도 여전히 느리기 때문에, 아직 개선할 여지는 많습니다.
어쩌면 #[sqlx::test] 문서에 이런 “footgun”을 언급해 두는 것도 유용할지 모르겠습니다. 저는 여기서 그렇게 제안했습니다.
이 글에서 얻을 수 있는 핵심은 두 가지라고 생각합니다:
#[sqlx::test] 테스트가 많은 프로젝트를 가지고 있고 재빌드 시간이 길어 고생한다면, migrator = "..." 트릭을 써서 도움이 되는지 확인해 보세요.cargo expand로 코드의 바이트 크기 를 측정하는 것이 컴파일 시간을 예측하는 데 꽤 괜찮은 지표가 될 수 있습니다 :)sqlx를 사용할 때 재빌드 시간을 최적화하는 다른 제안이 있다면 Reddit에서 알려주세요.