패키지 관리자 Nix를 처음 접하는 사람이 가장 놀라는 지점 중 하나는 그 표현식 언어가 '게으르다(lazy)'는 사실이다. 게으른 평가란 값이 실제로 필요해지는 순간까지 계산을 미루는 방식으로, 명령형 언어에만 익숙한 개발자에게는 낯설다. 그러나 이 게으름이야말로 방대한 패키지 저장소인 Nixpkgs를 지탱하는 동시에, 그 복잡성의 근원이기도 하다. 개발자 파리드 자카리아는 이 특성을 극단까지 밀어붙여, Nix의 속성 경로(attribute path)로 슈퍼마리오 브라더스 3을 실제로 구동하는 실험을 공개했다.
게으름이 만드는 무한 트리
게으른 평가를 관찰하는 가장 간단한 방법은, 접근한 속성만 평가된다는 점을 확인하는 것이다. 더 극단적으로는 속성 집합(attribute set) 안에 끝없는 재귀를 담을 수 있다. 실제로 Nixpkgs에는 자기 자신을 포함하는 '바닥 없는' 속성 집합이 가득하다. pkgs는 자기 자신을 담고 있고, 그 안의 모든 패키지 집합도 마찬가지지만, 매번 같은 스토어 경로로 귀결된다. 재귀가 종료될 수 있는 이유가 바로 게으름이라면, 애초에 재귀가 바닥에 닿을 필요조차 없다. 무한히 깊은 속성 집합을 만들어 세 단계를 파고들면 정확히 세 단계 분량만 평가되고, 나머지 무한한 가지는 아무도 요청하지 않았으므로 결코 만들어지지 않는다.
여기서 저자는 핵심적인 재해석에 도달한다. 속성 경로란 결국 게으르게 생성되는 트리를 걸어 내려가는 여정이며, 그렇다면 그 경로 자체를 무언가의 입력으로 삼을 수 있지 않겠느냐는 것이다.
속성 경로가 곧 컨트롤러 입력
그는 속성 경로를 슈퍼마리오 브라더스 3의 버튼 입력 시퀀스로 정의했다. 트리의 각 노드는 게임의 한 프레임이고, 각 자식 노드는 새로운 프레임을 만들어내는 버튼 입력이다. 게임 상태는 본질적으로 재귀적이므로 이 발상은 자연스럽게 맞아떨어진다. 예컨대 .rightb는 오른쪽+B, 즉 '오른쪽으로 달리기'이고 .rightab는 '달리며 점프'다. 경로의 출력은 실제 하드웨어에서 그 순서대로 버튼을 눌렀을 때 보게 될 바로 그 프레임이다. .#level1 같은 접두어는 1-1 스테이지 시작 지점까지 데려다주는 미리 준비된 입력 묶음이며, 경로 끝에 .play를 붙이면 전체 플레이가 하나의 녹화 영상으로 이어진다.
이 실험의 백미는 그 모든 프레임 하나하나가 스토어 안의 개별 파생(derivation)으로 존재한다는 점이다. 코드는 fzakaria/nes-nix로 공개되어 있고 ROM은 플레이크 입력으로 처리되어 다른 게임에도 적용할 수 있도록 일반화되어 있다. 각 입력은 자신만의 파생이 되며, 바로 앞 입력의 세이브 상태를 입력으로 받는다. 따라서 어떤 파생도 조상 프레임을 다시 에뮬레이션하지 않는다. 프레임 스크린샷도 함께 생성되어 영상 이어붙이기에 쓰인다.
스토어가 곧 세이브 히스토리
실무적으로 이는 Nix 스토어가 에뮬레이터의 세이브 상태 이력 그 자체가 된다는 뜻이다. 100번 입력한 플레이의 중간 지점에서 갈라져 나오는 비용은 단 한 번의 입력이며, 끝에 이어 붙이는 비용도 같다. 반대로 의존성 그래프가 곧 입력 시퀀스이므로, 어떤 프레임을 만든 버튼이 무엇이었는지를 Nix에게 되물을 수도 있다. 이때 녹화 영상은 아무것도 새로 에뮬레이션하지 않는다. 경로상의 모든 프레임이 이미 스토어에 각자의 입력 결과로 놓여 있으므로, 그저 프레임들을 가리키는 심링크 디렉터리를 ffmpeg에 넘기면 된다.
한계도 분명하다. Nix는 기본 설정에서 약 2,400번 입력쯤에서 멈춘다. 폭주 재귀를 막는 max-call-depth 기본값이 10,000이고 입력 한 번당 약 네 단계의 중첩 호출이 들기 때문인데, 이는 구조적 제약이 아니라 안전장치라 값을 1,000만으로 올리면 20,000번까지 늘어난다. 2만 번 입력은 저자의 노트북에서 약 14초가 걸리며 비용은 입력 수에 선형으로, 입력당 대략 0.7밀리초다. 그다음 병목은 커널이다. 속성 경로는 하나의 argv 요소인데 리눅스가 인자 하나의 크기를 131,072바이트(MAX_ARG_STRLEN)로 제한하고 입력 하나가 6바이트이므로, 단일 인자로는 21,845번이 상한이 된다. 이 벽은 입력 시퀀스를 인자가 아니라 파일에서 읽어들이는 방식으로 우회할 수 있고, 그렇게 해도 바이트 단위로 동일한 파생이 나와 같은 스토어 경로를 공유한다.
평가를 넘어 실제 빌드로 가면 또 다른 성격이 드러난다. Nix는 파생을 병렬로 잘 빌드하지만 이 재귀는 꼬리 재귀라 본질적으로 직렬이다. 빌드 시간 역시 입력 수에 선형으로 늘어나는데, 흥미롭게도 대체자(substituter)를 켜면 입력당 약 1.27초, 끄면 0.28초다. 즉 프레임을 에뮬레이션하는 비용보다 해당 파생이 캐시에 있는지 확인하는 왕복 비용이 더 크다는 뜻이며, preferLocalBuild나 allowSubstitutes 설정으로 이 비용을 피할 수 있다.
이 실험이 던지는 실무적 통찰은 마리오 그 자체가 아니다. 우리는 속성 경로를 '존재하는 것들의 목록에 매기는 좌표', 곧 이름으로만 여겨왔다. 그러나 게으른 평가 아래에서 속성 경로는 사실상 하나의 프로그램이다. 평가기가 걸어 내려가며 필요한 것을 그때그때 생성하는 일련의 단계인 것이다. Nixpkgs가 우연히 그 메커니즘으로 소프트웨어를 기술하고 있을 뿐, 트리가 반드시 카탈로그일 이유는 없다. 재현 가능한 상태 기계의 저장 계층으로서 스토어가 제법 쓸 만하다는 점과 맞물리면, 우리의 '패키지 관리자'가 게임 상태를 저장하고 분기하는 도구로도 충분히 합리적으로 쓰일 수 있다는 결론에 이른다.
TTJ 코딩클래스 정규반
월급 외 수입,
코딩으로 만들 수 있습니다
17가지 수익 모델을 직접 실습하고, 1,300만원 상당의 자동화 도구와 소스코드를 받아가세요.
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공