왜 "내 머신에서는 됩니다"가 반복되는가

빌드가 깨지는 대부분의 원인은 코드가 아니라 환경이다. 개발자마다 Node 18과 20이 섞여 있고, CI 러너에는 시스템 OpenSSL 버전이 다르며, apt-get install로 설치한 패키지는 실행 시점의 저장소 상태에 따라 달라진다. Dockerfile의 FROM ubuntu:22.04조차 재현 가능하지 않다. 같은 태그가 시점마다 다른 레이어를 가리키기 때문이다.

Nix는 이 문제를 패키지 관리자 레벨에서 해결한다. 모든 의존성을 입력 해시로 고정하고, 빌드 결과를 /nix/store 아래 해시 경로에 격리한다. 입력이 같으면 출력이 같다는 것을 구조적으로 보장한다.

Flakes로 입력 고정하기

실무에서는 예전 방식(channels)보다 flakes를 쓴다. flake.lock이 nixpkgs 커밋 해시를 잠그기 때문에, 6개월 뒤 다른 머신에서 nix develop을 실행해도 동일한 툴체인이 재현된다.

{
  description = "backend build env";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
    in {
      devShells.${system}.default = pkgs.mkShell {
        packages = with pkgs; [
          go_1_22
          postgresql_16
          golangci-lint
        ];
        shellHook = ''
          echo "go: $(go version)"
        '';
      };
    };
}

flake.lock은 반드시 커밋한다. 이 파일이 없으면 "재현 가능"이라는 전제가 무너진다.

CI에서 그대로 사용하기

핵심은 로컬 개발 셸과 CI가 동일한 flake를 쓰는 것이다. 별도의 CI 스크립트로 의존성을 다시 설치하지 않는다. 그래야 로컬에서 통과한 빌드가 CI에서도 그대로 통과한다.

name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cachix/install-nix-action@v27
        with:
          extra_nix_config: |
            experimental-features = nix-command flakes
      - name: lint & build
        run: |
          nix develop --command golangci-lint run ./...
          nix develop --command go build ./...

nix develop --command은 셸에 진입하지 않고 지정한 명령만 flake 환경에서 실행한다. CI 로그가 깔끔하고 캐싱과도 잘 맞는다.

바이너리 캐시로 빌드 시간 줄이기

Nix가 느리다는 오해는 대부분 캐시 미스에서 온다. 공식 cache.nixos.org는 nixpkgs 대부분을 커버하지만, 사내 커스텀 빌드는 별도 캐시가 필요하다. Cachix나 자체 S3 바이너리 캐시를 붙이면 CI에서 같은 파생물을 재빌드하지 않는다.

# 자체 결과물을 캐시에 push (예: Cachix)
nix build .#myapp
cachix push my-org ./result

# 소비 측 설정 (~/.config/nix/nix.conf)
substituters = https://cache.nixos.org https://my-org.cachix.org
trusted-public-keys = my-org.cachix.org-1:AAAA...=

Docker 이미지도 재현 가능하게

Nix는 Dockerfile 없이 결정적인 이미지를 만든다. dockerTools.buildLayeredImage는 타임스탬프를 고정하고 레이어를 내용 기반으로 나눠서, 코드가 바뀌지 않으면 동일한 다이제스트를 낸다.

pkgs.dockerTools.buildLayeredImage {
  name = "myapp";
  tag = "latest";
  contents = [ self.packages.${system}.myapp ];
  config.Cmd = [ "/bin/myapp" ];
}

일반 Dockerfile과 비교하면 차이가 분명하다.

항목DockerfileNix dockerTools
베이스 이미지가변 태그해시 고정
동일 입력 → 동일 이미지보장 안 됨보장됨
레이어 분할명령 순서 기반의존성 그래프 기반

주의점과 도입 전략

Nix는 러닝 커브가 가파르다. Nix 언어의 지연 평가와 파생물 개념에 익숙해지는 데 시간이 든다. 팀 전체를 한 번에 전환하기보다, 먼저 devShell로 로컬 개발 환경만 통일하는 것을 권한다. 이 단계만으로도 "버전 불일치" 이슈의 상당수가 사라진다.

또한 /nix/store는 계속 커지므로 nix-collect-garbage -d로 주기적으로 정리해야 한다. 언어별 패키지 매니저(npm, pip 등)와의 통합은 dream2nix나 poetry2nix 같은 도구가 필요하며, 여기서 실무 마찰이 가장 크다. 무리하게 모든 것을 Nix로 감싸기보다, 재현성이 정말 중요한 경계(툴체인, CI, 배포 이미지)부터 적용하는 편이 현실적이다.