Skip to content

필사 모드: Nix 2.35가 플레이크 소스를 store에 덜 복사한다 — 6년 8개월 걸린 이슈, 그리고 업스트림이 lazy trees 대신 고른 길

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — nix build는 왜 당신의 저장소를 통째로 복사하는가

플레이크를 쓰는 저장소에서 파일 하나를 고치고 nix build를 돌려 본 적이 있다면, 이 동작을 봤을 겁니다.

$ vim README.md          # 한 글자 고침
$ nix build .#myapp
copying '/home/me/big-monorepo' to the store...

빌드에 README가 필요하든 말든, 소스 트리 전체가 /nix/store로 복사됩니다. 다음 번에 또 한 글자를 고치면, 또 복사됩니다. 트리가 수 기가바이트짜리 모노레포라면 이건 농담이 아닙니다.

이게 버그가 아니라 설계였다는 게 중요합니다. Nix 2.35.0 릴리스 노트의 표현을 그대로 옮기면 이렇습니다 — 역사적으로 플레이크 소스 트리는 결정론적이고 헤르메틱한 평가를 보장하기 위해, 그 결과 store 객체가 derivation 입력으로 쓰이지 않더라도 eager하게 store로 가져와 거기서 평가되어 왔다. 구현은 단순해졌지만, 큰 저장소에서 플레이크를 못 쓰게 만들었고 소스 트리가 바뀔 때마다 불필요한 쓰기를 했다.

이 글은 그 설계를 고치는 데 왜 6년 8개월이 걸렸는지, Nix 2.35.0이 실제로 무엇을 고쳤는지, 그리고 무엇을 아직 고치지 못했는지에 관한 이야기입니다. 마지막 항목이 가장 중요합니다.

2,437일짜리 이슈 — #3121

이슈 NixOS/nix#3121 "Copy local flakes to the store lazily"는 2019년 10월 7일에 열렸습니다. 연 사람은 Eelco Dolstra — Nix를 만든 사람 본인입니다.

이 이슈는 2026년 6월 9일에 닫혔습니다. 2,437일, 약 6년 8개월, 댓글 69개. 닫은 사람은 Dolstra가 아니었고, 닫으면서 남긴 코멘트는 다섯 단어였습니다 — "Completed in #15711".

#15711은 Dolstra의 PR이 아닙니다. 여기서부터가 이야기입니다.

lazy trees — 두 번의 시도

Dolstra의 답은 lazy trees였습니다. 아이디어는 이렇습니다. 소스 트리를 store에 복사해 두고 거기서 읽는 대신, 가상 파일시스템을 하나 만들어 평가자에게 "store에 있는 것처럼" 보여 준다. 그리고 실제로 파일이 필요해질 때만, 필요한 만큼만 읽는다. nixpkgs#hello를 빌드하는데 nixpkgs 전체를 복사할 이유는 없으니까요.

PR NixOS/nix#6530 "Lazy trees"는 2022년 5월 12일에 열렸습니다. 커밋 402개, 파일 56개, +1363/-364까지 자랐습니다. 그리고 2025년 5월 18일, 머지되지 않은 채 닫혔습니다. 1,102일, 3년 만이었습니다.

닫히기 하루 전인 2025년 5월 17일, Dolstra는 PR #13225 "Lazy trees v2"를 열었습니다. 훨씬 작습니다 — 커밋 1개, 파일 41개, +539/-133. 이 글을 쓰는 2026년 7월 16일 현재 이 PR은 여전히 열려 있고, GitHub이 보고하는 병합 가능 상태는 dirty(충돌)이며, 마지막 업데이트는 2025년 10월 9일입니다. 댓글 16개에 리뷰 코멘트 17개가 붙은 채로 9개월째 멈춰 있습니다.

여기서 조심할 것이 있습니다. PR이 안 움직이는 이유를 저는 모릅니다. 리뷰 논쟁의 내용을 요약해서 "누가 막았다"고 쓰는 건 이 글의 범위 밖이고, 검증 가능한 사실도 아닙니다. 확인 가능한 사실은 이것뿐입니다 — 열려 있고, 충돌 상태이고, 9개월간 갱신이 없습니다.

그 사이, Determinate Nix는 그냥 출시했다

Dolstra는 Determinate Systems의 공동창업자입니다. 그리고 Determinate Systems는 Determinate Nix라는 자체 Nix 배포판을 냅니다.

2025년 5월 15일, Determinate Systems의 Luc Perkins가 lazy trees가 Determinate Nix 3.5.2에 들어갔다고 공지했습니다. 업스트림 Nix 2.28.3 기반이었고, 처음에는 옵트인이었습니다.

# /etc/nix/nix.custom.conf
lazy-trees = true

같은 글에 이런 문장이 있습니다 — "저희 공동창업자 Eelco Dolstra가 현재 lazy trees를 업스트림 Nix에 넣기 위한 PR을 열어 두고 있고, 곧 머지되기를 바랍니다." 그 PR이 #13225입니다. 1년 2개월 뒤에도 열려 있습니다.

두 달 뒤인 2025년 7월 11일, Graham Christensen이 Determinate Nix 3.8.0을 공지하며 이렇게 적었습니다 — 지난 릴리스에서 Determinate Nix 사용자의 5퍼센트에게 lazy trees를 롤아웃했고, 5퍼센트 롤아웃이 성공적이어서 이제 20퍼센트로 확대한다.

오픈소스 패키지 매니저를 퍼센티지 단계 롤아웃으로 배포하는 것 — 이건 상당히 새로운 광경입니다. 사용자는 determinate-nixd version을 쳐서 자기가 그 20퍼센트에 들어갔는지 확인할 수 있고, 같은 글에 opt-out하려면 support@determinate.systems로 연락하라고 적혀 있습니다.

성능 수치도 있습니다. 다만 이건 벤더 자체 측정입니다. 3.5.2 공지 글은 Nixpkgs 저장소 안에서의 평가를 예로 들며 "wall time 3배 이상 감소와 디스크 사용량 20배 이상 감소를 자주 봤다(frequently seen)"고 적었고, "가끔은 이보다 훨씬 큰 감소"도 있었다고 덧붙였습니다. 여기서 빠진 것을 그대로 적어 두겠습니다 — 하드웨어, 측정 방법론, 비교 기준 버전, 몇 회 반복인지, "자주"가 몇 퍼센트인지가 모두 없습니다. 방향은 믿을 만하지만, 저 배수를 당신의 저장소에 그대로 옮겨 적으면 안 됩니다.

핵심 — lazy-locks, 그리고 완전한 게으름의 가격

이제 이 이야기의 기술적 심장입니다. Determinate Nix 소스(v3.21.7 기준)의 설정 정의를 보면 두 개가 나란히 있습니다.

Setting<bool> lazyTrees{
    this, false, "lazy-trees",
    R"(
      If set to true, flakes and trees fetched by builtins.fetchTree are only
      copied to the Nix store when they're used as a dependency of a derivation.
      This avoids copying (potentially large) source trees unnecessarily.
    )"};

Setting<bool> lazyLocks{
    this, false, "lazy-locks",
    R"(
      If enabled, Nix only includes NAR hashes in lock file entries if they're
      necessary to lock the input (i.e. when there is no other attribute that
      allows the content to be verified, like a Git revision).
      This is not backward compatible with older versions of Nix.
      If disabled, lock file entries always contain a NAR hash.
    )"};

lazy-locks 문서의 저 문장 — "This is not backward compatible with older versions of Nix" — 이 전부를 설명합니다.

논리는 이렇습니다. flake.lock의 각 항목에는 narHash가 들어갑니다. 이건 입력 소스 트리를 NAR로 직렬화한 것의 해시입니다. 이 해시를 계산하려면 트리 전체를 읽어야 합니다. 즉 lock 파일에 narHash를 넣기로 한 이상, 게으름에는 천장이 있습니다 — 파일 하나만 필요해도 트리 전부를 훑어야 하니까요.

그래서 진짜 끝까지 게을러지려면 narHash를 lock 파일에서 빼야 합니다. 그게 lazy-locks = true입니다. 그리고 그렇게 만든 lock 파일은 예전 Nix가 읽지 못합니다. 팀원 한 명이 그 옵션을 켜고 flake.lock을 커밋하면, 안 켠 사람의 빌드가 깨집니다.

두 설정 모두 코드상 기본값이 false라는 점도 짚어 둡니다. Determinate가 롤아웃한 20퍼센트는 코드 기본값이 아니라 자기네 데몬이 관리하는 설정으로 켜 준 것입니다.

Lix의 대답 — 고치지 말고 얼려라

세 번째 구현체도 있습니다. 2024년에 갈라져 나온 포크 Lix입니다.

Lix 위키의 Flakes feature freeze 문서(2025년 10월 5일 작성, 2026년 2월 1일 갱신)는 코어 팀이 플레이크의 기능 집합과 시맨틱을 현 시점에 동결하기로 했다고 적습니다 — 버그픽스 외에는 새 기능을 넣지 않고, 새 입력 타입도, 기존 페처의 새 기능도, 플레이크 중심의 평가자 변경도 넣지 않는다. 이유는 플레이크 구현 코드가 극도로 취약하고 유지보수 부담이 크다는 것입니다.

같은 문서에 이 글의 맥락에서 가장 날카로운 문장이 있습니다. 플레이크 락킹 설계가 unsound하다는 지적인데, 요지는 이렇습니다 — lock 파일에 있는 것이 fetch 가능하더라도, 이 Nix 구현체가 같은 입력에 대해 생성했을 값이 아니면 락 비교가 실패한다. 그래서 libfetchers의 입력 포맷이나 lock 파일 포맷에 어떤 divergence가 생기든 구현체 간 비호환이 발생한다.

Determinate의 lazy-locks가 정확히 그 divergence입니다. Lix 위키는 그 클래스의 문제를 일반론으로 지목했고, Determinate는 그걸 옵트인 플래그 뒤에 두었고, 업스트림은 아예 하지 않기로 했습니다. 같은 문제에 세 개의 다른 답입니다.

Nix 2.35가 실제로 한 일

Nix 2.35.0은 2026년 7월 13일에 태그되고 같은 날 공지됐습니다. (릴리스 노트 문서 상단에 적힌 날짜는 2026-06-22입니다 — 노트를 자른 날과 실제 태그가 3주 차이 납니다. 태그와 공지가 같은 날인 2026-07-13이 실제 릴리스 날짜입니다.)

머지된 것은 PR #15711 "Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)"입니다. 작성자는 Sergei Zimmerman(@xokdvium), 2026년 4월 19일에 열려 4월 27일에 머지됐습니다. 커밋 3개, 파일 14개, +180/-37.

lazy trees v1이 402커밋에 56파일, +1363/-364였다는 걸 떠올려 보면 자릿수가 다릅니다.

PR 본문에 접근법이 그대로 적혀 있습니다.

3rd time's the charm! (...) This repurposes a slightly less lazy (but also more deterministic) approach than determinate nix has taken. We do still pay to cost of hashing an input once to compute the store path and narHash daemon-client-side.

"Determinate nix가 택한 것보다 약간 덜 게으르지만 더 결정론적인 접근". 그리고 "입력을 한 번 해싱하는 비용은 여전히 낸다".

작동 방식은 릴리스 노트에 있습니다 — 복사 횟수를 줄이기 위해, Nix는 이제 먼저 복사하지 않고 해싱하며, .outPath가 derivation 어트리뷰트에 들어가지 않을 것이므로 실제로 store에 가져올 필요가 없으리라고 가정한다. 이 가정이 틀렸을 경우 약간의 추가 작업이라는 비용이 들지만, 전형적인 사용 사례에서는 작업이 줄어든다.

복사(copy)를 없앤 것이지, 순회(walk)와 해싱을 없앤 게 아닙니다.

이게 Dolstra와 무관하게 진행된 것도 아닙니다. Zimmerman은 이슈 #3121에 2026년 4월 19일 이렇게 적었습니다 — "#15711에서 시도해 봤습니다. @edolstra가 그 PR을 가능하게 만드는 좋은 작업을 많이 했고, 저는 조각을 맞췄을 뿐입니다." 실제로 이 PR이 딛고 선 PR #14050 "Fix fetchToStore caching"은 Dolstra가 2025년 10월 6일 업스트림에 머지한 것입니다.

fetchTarball 쪽도 같은 패턴입니다. Determinate Nix 3.21.0(2026년 5월 22일)이 "Lazy fetchTarball"을 실었고, 5일 뒤인 5월 27일 업스트림에 PR #15920 "fetchTarball: Use the tarball fetcher"가 머지됐습니다. 양쪽 다 작성자는 Dolstra입니다. (두 패치가 같은 것이라고 단정하진 않겠습니다 — 확인한 건 작성자와 날짜뿐입니다.)

성능 수치는 릴리스 노트에 한 줄 있습니다 — 이 변경은 전형적인 평가에서 필요한 디스크 사용량을 크게 줄일 것으로 기대되며(expected to), nixpkgs 타르볼을 가져와 푸는 데 fetchTree/플레이크 경로든 fetchTarball 경로든 약 2배 속도 향상을 낸다. 이건 프로젝트 자체 측정이고, 하드웨어·방법론·비교 대상 버전이 명시돼 있지 않습니다. 디스크 쪽은 "기대된다"이고 속도 쪽만 단정형이라는 원문의 온도차도 그대로 옮겨 둡니다.

업스트림에는 lazy-treeslazy-locks 같은 설정이 없습니다. GitHub 코드 검색 기준 NixOS/nix 저장소에 두 문자열의 히트는 0입니다. 켤 것도 끌 것도 없이 기본 동작이 바뀌었습니다. 이게 Determinate 방식과의 실질적 차이입니다 — 플래그를 늘리는 대신, 호환을 깨지 않는 만큼만 게을러졌습니다.

무엇이 여전히 안 고쳐졌나

여기가 이 글에서 제일 중요한 절입니다. 릴리스 노트가 스스로 한계를 적어 뒀는데, 이런 문장은 대개 요약에서 사라집니다.

원문은 소스 트리 전체를 만지고 NAR 직렬화를 해싱하는 일은 불가피하다(unavoidable)고 말하고, 이유를 두 개 답니다.

  1. 플레이크 입력의 경우 narHash 무결성을 eager하게 검사해야 한다.
  2. 플레이크의 outPath 어트리뷰트는 미리 알려져 있어야 하고, 하위 호환을 위해 content-addressed store path 문자열이어야 한다.

그러니까 3기가바이트 모노레포에서 파일 하나를 고치면, nix build는 이제 3기가바이트를 store에 복사하지는 않지만 3기가바이트를 읽고 해싱하기는 합니다. 큰 트리에서 이건 여전히 눈에 띄는 비용입니다. 사라진 것은 store 쓰기와 디스크 점유이지, 파일시스템 순회가 아닙니다.

그 앞 단계인 2.32에서 이미 절반이 정리돼 있었다는 점도 릴리스 노트가 짚습니다 — Nix 2.32 이후로 평가 중 소스 읽기의 모든 I/O는(path:hg+: 스타일 입력 제외) 원래 파일시스템 위치로, 타르볼 기반 입력은 ~/.cache/nix/tarball-cache-v2 bare git 저장소로 통로가 잡혔다. 남아 있던 것이 store path 계산을 위한 fetch였고, 2.35가 그걸 걷어낸 것입니다.

그리고 가정이 틀리는 경우가 있습니다. .outPath가 실제로 derivation 어트리뷰트에 들어가는 표현식이라면 — 릴리스 노트의 표현으로 "가정이 틀렸을 경우" — 추가 작업 비용을 냅니다. 노트는 이걸 "slight"라고 부르고 구체적인 수치는 주지 않습니다. 그런 수치는 발표된 적이 없으니 여기서 지어내지 않겠습니다.

마지막으로 이건 재현성을 개선하는 변경이 아닙니다. 성능 변경입니다. 결과 store path는 그대로여야 하고, 평가자 관점에서는 여전히 복사가 일어난 것처럼 동작합니다 — toString ./.은 계속 컨텍스트 없는 content-addressed store path 문자열을 내고, 플레이크의 outPath에서 파일을 읽는 코드도 계속 동작하며 IFD로 취급되지 않습니다. 노트가 이 호환성 목록을 길게 나열하는 이유가 있습니다. 하위 호환이 이 설계의 제약 조건 그 자체였으니까요. 참고로 이 "재현성의 비용"이라는 주제를 배포판 쪽에서 다른 각도로 보고 싶다면, Debian이 재현 불가능한 패키지의 testing 이행을 막기 시작한 이야기가 같은 날 올라와 있습니다.

그래서, 당신은 무엇을 해야 하나

업그레이드가 값을 하는 경우

  • 큰 저장소·모노레포에서 플레이크를 쓰고, copying '...' to the store가 반복 루프의 체감 비용인 경우. 복사가 사라지는 것만으로 이득이 납니다.
  • CI에서 nixpkgs 타르볼을 반복해서 가져와 푸는 경우. 릴리스 노트가 약 2배를 이야기하는 지점이 정확히 여기입니다(프로젝트 자체 측정).
  • /nix/store의 디스크 압박이 실제 문제인 경우. 매 평가마다 소스 트리 사본이 쌓이던 게 줄어듭니다.

별 차이 없거나, 서두를 이유가 없는 경우

  • 트리가 작은 경우. 순회와 해싱이 남아 있고 원래 복사도 쌌다면, 남는 게 별로 없습니다.
  • 플레이크를 안 쓰는 경우. 이 변경은 플레이크와 fetchTree/fetchTarball 경로 이야기입니다.
  • 모노레포의 병목이 평가가 아니라 빌드 자체인 경우. 3기가바이트를 여전히 읽고 해싱한다는 사실은 안 바뀝니다.

Determinate Nix의 lazy-trees를 켤지 고민 중이라면 — 이건 2.35와 별개의 물건입니다. 더 게으르지만, 끝까지 게을러지려면 lazy-locks가 필요하고 그건 문서가 명시적으로 하위 호환을 깬다고 말합니다. 팀 전원이 같은 Nix 구현체를 쓰는 게 보장되지 않는다면 lazy-locks는 켜지 마십시오. flake.lock은 팀 공유 파일이고, 구현체 간 lock 비호환은 Lix 위키가 이미 일반론으로 지적해 둔 알려진 함정입니다.

Lix를 쓴다면 플레이크에 새 기능이 오지 않습니다. 이건 사고가 아니라 공표된 정책이고, 장기 계획은 플레이크를 코드베이스 밖 플러그인으로 빼는 것입니다.

마치며

정리하면 이렇습니다. 2019년에 Nix 창시자가 연 "소스를 게으르게 복사하자"는 이슈가 2026년에 닫혔습니다. 닫은 건 그의 402커밋짜리 PR이 아니라, 다른 사람이 그의 선행 작업 위에 올린 180줄짜리 PR이었습니다. 그 사이 같은 기능이 다운스트림 상용 배포판에서 퍼센티지 롤아웃으로 먼저 출시됐고, 또 다른 포크는 이 영역 전체를 동결했습니다.

기술적 교훈은 단순합니다. 이 문제의 어려움은 게으름을 구현하는 데 있지 않았습니다 — Determinate가 2025년에 증명했듯 그건 됩니다. 어려움은 하위 호환을 깨지 않고 게을러지는 데 있었고, 그래서 업스트림에 들어간 답은 "약간 덜 게으르지만 더 결정론적인" 것이었습니다. narHash를 lock 파일에 계속 넣기로 한 이상 트리 전체 해싱은 남고, 그건 릴리스 노트가 직접 "unavoidable"이라고 적은 대로입니다.

그리고 이게 이 이야기에서 가장 오래 남을 문장 같습니다. 큰 diff가 문제를 푸는 게 아니라, 제약 조건을 받아들인 작은 diff가 문제를 푼다는 것. PR 제목이 이미 알고 있었습니다 — maybe 3rd time's the charm.

참고 자료

현재 단락 (1/91)

플레이크를 쓰는 저장소에서 파일 하나를 고치고 `nix build`를 돌려 본 적이 있다면, 이 동작을 봤을 겁니다.

작성 글자: 0원문 글자: 9,840작성 단락: 0/91