이제 Homebrew에 올인하지 않고, nix-darwin으로 Mac 관리하기
2021년부터 제 Mac의 거의 모든 소프트웨어를 Homebrew로 설치했습니다. 명령줄 도구는 formula로, GUI 애플리케이션은 cask로 설치하고 마지막에 Brewfile 하나로 모두 기록했죠. 이 방식은 정말 편리했습니다. 컴퓨터를 바꿀 때 brew bundle 한 번이면 다시 설치할 수 있었고, 소프트웨어도 비교적 깔끔하게 삭제할 수 있었습니다.
하지만 몇 년 쓰다 보니 문제도 점점 많아졌습니다.
Homebrew의 문제
먼저 버전입니다. Homebrew의 formula는 기본적으로 최신 버전만 제공하고, 일부 openjdk@17, python@3.12 처럼 버전이 붙은 formula를 제외하면 특정한 구버전을 설치하기가 꽤 번거롭습니다. 게다가 brew upgrade 를 한 번 실행하면 모든 것이 함께 업그레이드되고, 가끔 어떤 도구가 업그레이드된 뒤 문제가 생겨도 되돌리기가 쉽지 않습니다.
다음은 의존성입니다. Homebrew가 formula를 설치할 때는 그 의존성도 함께 설치하는데, 이런 의존성이 제가 직접 설치한 것들과 섞입니다. 가장 골치 아팠던 것은 Rust입니다. 제 Rust는 mise로 설치했는데, 어떤 formula가 Rust에 의존하기만 하면 Homebrew가 자기 몫의 Rust를 또 설치해 버려서 /opt/homebrew/bin 에도 또 한 세트의 cargo 와 rustc가 생겼습니다. 두 Rust 중 어느 것이 먼저 발견될지는 PATH 의 순서에 따라 달라지고, 그 결과 때로는 cargo build 가 제가 생각했던 버전이 아닌 경우가 있습니다.
그래서 현재 cargo 가 Homebrew에서 온 것인지 확인하는 함수를 따로 작성하기도 했습니다.
# check if using correct cargo
function check_cargo() {
cargo_path=$(which cargo 2>/dev/null)
if [[ -z "$cargo_path" ]]; then
echo "Cargo is not installed or not in PATH."
return 1
fi
if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
echo "$cargo_path cargo path error"
fi
}
Python도 비슷한 상황이었습니다. 제 Brewfile에는 Python 패키지가 여러 개 있었는데, 예를 들어 numpy, cffi, cryptography 같은 것들이었습니다. 모두 다른 formula가 의존성으로 끌고 온 것이라 제가 직접 설치한 적은 없었고, 덤으로 python@3.12까지 끌려 들어와 mise로 설치한 Python과 또 섞여 버렸습니다.
마지막은 Brewfile 자체입니다. 계속 쓰고 있긴 했지만, 사실 사후에 기록하는 방식이었습니다. 뭔가 설치한 뒤 brew bundle dump 를 실행해 현재 상태를 내보내는 식이었죠. 게다가 dump된 내용에는 제가 직접 설치하지 않고 의존성으로 들어온 formula가 잔뜩 섞여 있어서 Brewfile은 점점 길어졌고, 나중에는 formula가 거의 200개에 이르렀는데 그중 제가 직접 설치한 것은 절반 정도였을 겁니다.
왜 Nix인가
예전에 한동안 NixOS를 써 보면서 선언적으로 시스템을 관리하는 방식이 참 마음에 들었습니다. 모든 것을 설정 파일에 적고, 원하는 것은 쓰고 필요 없으면 지운 다음 rebuild 한 번 하면 시스템이 설정에 설명된 모습으로 바뀝니다. 제가 Brewfile을 계속 유지했던 것도 사실 이런 선언적인 느낌을 원했기 때문이지만, Brewfile은 그 일을 완전히 해내지는 못했습니다.
Nix의 패키지는 모두 /nix/store 에 설치되고, 각 패키지는 독립된 경로를 가지며 의존성도 각자 격리되어 있습니다. 그래서 어떤 도구가 빌드할 때 Rust에 의존하더라도 그 Rust는 자기 의존성 안에만 존재하고, 제 PATH 에는 나타나지 않으며, mise로 설치한 Rust와 충돌할 일도 없습니다. 게다가 nixpkgs에는 미리 컴파일된 바이너리 캐시가 있어서 대부분의 패키지는 Homebrew만큼 빠르게 설치됩니다.
그렇다면 Mac에서 Nix로 시스템을 관리하려면 nix-darwin을 사용해야 합니다. 이는 macOS용 NixOS 설정 모듈과 같은 것으로, 소프트웨어 설치 외에도 시스템 설정, launchd 서비스, 환경 변수를 관리할 수 있고 Homebrew 모듈까지 있어 Nix 설정으로 Homebrew에 무엇을 설치할지 선언할 수 있습니다.
역할 분담
다만 Nix가 모든 것에 적합한 것은 아닙니다. GUI 애플리케이션은 nixpkgs에 다 있지 않고, 있더라도 공식 dmg만큼 쓰기 좋지 않은 경우가 많습니다. 반면 Homebrew의 cask는 이 부분을 이미 잘 해내고 있습니다. 또한 프로그래밍 언어 런타임은 프로젝트마다 다른 버전을 쓰고 싶었는데, 이 일을 Nix에 맡기기에는 너무 무겁습니다.
그래서 최종적으로는 이렇게 나눴습니다.
| 담당하는 것 | 맡기는 도구 |
|---|---|
| 시스템 설정, 명령줄 도구, launchd 서비스 | nix-darwin |
| GUI 애플리케이션(cask) | Homebrew, nix-darwin이 선언 |
| 프로그래밍 언어와 그 툴체인 | mise |
설치
먼저 Nix를 설치하고 flakes를 켭니다. 그다음 nix-darwin의 설명에 따라 /etc/nix-darwin 아래에 템플릿으로 flake.nix 파일을 생성합니다.
sudo mkdir -p /etc/nix-darwin
sudo chown $(id -nu):$(id -ng) /etc/nix-darwin
cd /etc/nix-darwin
nix flake init -t nix-darwin/master
sed -i '' "s/simple/$(scutil --get LocalHostName)/" flake.nix
처음 설치할 때는 시스템에 아직 darwin-rebuild 라는 명령이 없으므로 nix run 으로 한 번 실행해야 합니다.
nix run nix-darwin/master#darwin-rebuild -- switch
이후에는 설정을 바꿀 때마다 아래 명령만 실행하면 적용됩니다.
darwin-rebuild switch
설정
템플릿으로 생성된 flake.nix 는 모든 설정을 configuration 라는 함수 안에 작성하는데, 저도 그 안에 그대로 이어서 추가했습니다. 아래는 현재 제 설정의 대략적인 모습이며, 일부 소프트웨어는 생략했습니다.
{
description = "Brian's nix-darwin system";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin.url = "github:nix-darwin/nix-darwin/master";
nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs@{ self, nix-darwin, nixpkgs }:
let
configuration = { pkgs, ... }: {
nixpkgs.hostPlatform = "aarch64-darwin";
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = "nix-command flakes";
environment.systemPackages = [
# 常用命令行工具
pkgs.bat
pkgs.eza
pkgs.fd
pkgs.fzf
pkgs.ripgrep
pkgs.jq
pkgs.btop
pkgs.yazi
pkgs.zellij
pkgs.zoxide
pkgs.starship
pkgs.gh
pkgs.helix
pkgs.neovim
# 开发工具
pkgs.cmake
pkgs.ninja
pkgs.just
pkgs.lazygit
pkgs.hyperfine
pkgs.tokei
# 网络工具
pkgs.mtr
pkgs.nmap
pkgs.iperf3
pkgs.doggo
pkgs.nexttrace
# 语言环境交给 mise
pkgs.mise
];
homebrew = {
enable = true;
taps = [ "xxxbrian/tap" ];
brews = [
"openssl@3"
"sqlite"
"readline"
"libyaml"
"pkgconf"
"icu4c"
"zlib"
];
casks = [
"1password"
"ghostty"
"google-chrome"
"orbstack"
"raycast"
"surge"
"telegram"
"visual-studio-code"
"zed"
];
onActivation.cleanup = "zap";
};
security.pam.services.sudo_local.touchIdAuth = true;
system.defaults = {
NSGlobalDomain = {
ApplePressAndHoldEnabled = false;
KeyRepeat = 2;
InitialKeyRepeat = 15;
};
dock = {
autohide = false;
show-recents = false;
tilesize = 58;
};
};
system.configurationRevision = self.rev or self.dirtyRev or null;
system.stateVersion = 6;
};
in
{
darwinConfigurations."brian-mbp-m1max" = nix-darwin.lib.darwinSystem {
modules = [ configuration ];
};
};
}
명령줄 도구
environment.systemPackages 에 나열된 것이 설치할 명령줄 도구이며, 이름은 기본적으로 Homebrew와 같습니다. 이름이 다르면 search.nixos.org 에서 검색해 보면 됩니다. 설치 후에는 모두 /run/current-system/sw/bin 에 나타납니다.
때로는 nixpkgs의 패키지가 제가 원하는 것과 조금 다를 수 있는데, 이때는 overlay로 고칠 수 있습니다. 예를 들어 nexttrace는 제 환경에서 libpcap을 추가로 링크해야 해서, 이렇게 빌드 인자를 덮어쓸 수 있습니다.
nixpkgs.overlays = [
(final: prev: {
nexttrace = prev.nexttrace.overrideAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [ prev.libpcap ];
});
})
];
Homebrew
nix-darwin이 Homebrew를 대신 설치해 주지는 않으므로 Homebrew 자체는 기존 방식대로 설치해야 합니다. 그다음 nix-darwin은 homebrew 설정을 바탕으로 Brewfile을 생성하고, switch 때마다 brew bundle 을 호출해 실행합니다.
즉, Brewfile은 이제 제가 컴퓨터에서 dump한 것이 아니라 설정에서 생성되는 것이 됩니다. 덕분에 그 안에는 제가 직접 작성한 cask만 들어가고, 의존성들이 잔뜩 섞여 들지 않습니다.
onActivation.cleanup = "zap" 는 제가 가장 중요하다고 생각하는 항목입니다. 이걸 설정하면 switch 때마다 설정에 없는 formula와 cask가 모두 제거되는데, --zap 방식의 제거라서 애플리케이션이 ~/Library 에 남긴 것까지 함께 정리합니다. 그래서 소프트웨어를 하나 지우고 싶으면 설정에서 그 줄을 지운 뒤 switch 만 하면 됩니다.
다만 바로 그 때문에 처음 switch 전에 cask를 빠짐없이 나열해야 합니다. 그렇지 않으면 설정에 넣지 않은 애플리케이션이 그 데이터와 함께 삭제됩니다. 저는 그때 먼저 brew list --cask 로 모든 cask를 나열하고 하나하나 확인한 뒤에야 실행할 수 있었습니다.
brews 에는 몇 개의 라이브러리만 남겼습니다. 예를 들어 openssl@3, readline, sqlite, zlib 같은 것들입니다. 어떤 것들은 로컬에서 컴파일해야 하는데, 예를 들어 mise로 특정 언어 버전을 컴파일하거나 C 확장이 포함된 패키지를 빌드할 때 이런 빌드 스크립트가 Mac에서는 기본적으로 Homebrew 경로에서 이 라이브러리들을 찾습니다. 그래서 이것들을 Homebrew에 남겨 두는 편이 오히려 가장 간단합니다.
시스템 설정
system.defaults 로는 원래 시스템 설정에서 직접 클릭하거나 defaults write 로 바꿔야 했던 많은 것을 설정할 수 있습니다. 예를 들어 키 반복 속도, Dock 크기와 동작 같은 것입니다. 저는 제가 쓰던 항목들을 모두 넣어 두어서, 다른 컴퓨터로 바꿔도 이 설정들이 그대로 따라갑니다.
security.pam.services.sudo_local.touchIdAuth 는 sudo 가 Touch ID로 인증할 수 있게 합니다. 이걸 켜면 터미널에서 sudo 를 입력할 때 더 이상 비밀번호를 입력하지 않아도 되고, 지문만 한 번 누르면 됩니다.
launchd 서비스
예전에 brew services 로 돌리던 백그라운드 서비스, 예를 들어 aria2도 이제는 nix-darwin에서 launchd 사용자 서비스로 바로 선언할 수 있습니다.
environment.userLaunchAgents.aria2 = {
enable = true;
target = "org.nix-darwin.aria2.plist";
text = ''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.nix-darwin.aria2</string>
<key>ProgramArguments</key>
<array>
<string>${pkgs.aria2}/bin/aria2c</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
'';
};
${pkgs.aria2} 는 aria2가 /nix/store 에 있는 실제 경로로 치환되므로, 이 서비스는 항상 설정에 있는 그 버전을 가리킵니다.
mise
각 언어의 버전은 작년에 이미 전부 mise에 맡겼고, 이번에도 바뀌지 않았습니다. 유일한 차이는 mise 자체를 예전에는 Homebrew로 설치했는데 이제는 Nix로 설치한다는 점입니다. 바로 위 systemPackages 에 있는 한 줄, pkgs.mise. ~/.config/mise/config.toml 은 원래 그대로이고, Nix는 건드리지 않습니다.
그래서 이제 세 도구의 경계가 아주 명확합니다. Nix는 시스템과 명령줄 도구를, Homebrew는 GUI 애플리케이션을, mise는 언어를 관리합니다. 명령줄 도구를 모두 Nix로 설치하게 바꿨기 때문에 Homebrew가 의존성 때문에 몰래 Rust를 설치하는 일도 없어졌고, which cargo 는 언제나 mise가 관리하는 Rust를 가리킵니다.
마이그레이션
전체 마이그레이션은 조금씩 진행했습니다.
- 먼저
brew leaves로 제가 직접 설치한 formula를 모두 나열하고, nixpkgs에서 해당 패키지를 찾아environment.systemPackages에 적었습니다. - 다음으로
brew list --cask로 모든 cask를 나열해 그대로homebrew.casks에 적었습니다. - 확인한 뒤
onActivation.cleanup = "zap"를 켜고darwin-rebuild switch를 실행해 설정에 없는 formula를 모두 정리하게 했습니다.
마지막 단계를 마치고 나면 brew leaves 에는 그 몇 개의 라이브러리만 남고, 명령줄 도구는 모두 /run/current-system/sw/bin 아래의 Nix 버전으로 바뀌어 있습니다.
현재
이제 명령줄 도구를 설치하고 싶으면 systemPackages 에 한 줄 추가하고, 애플리케이션을 설치하고 싶으면 casks 에 한 줄 추가한 뒤 darwin-rebuild switch하면 됩니다. 삭제도 마찬가지로 그 줄을 지우고 switch 만 하면 됩니다. 컴퓨터 전체에 무엇이 설치되어 있고 시스템이 어떻게 설정되어 있는지는 모두 /etc/nix-darwin/flake.nix 라는 파일 하나에 적혀 있습니다.
게다가 switch 할 때마다 새로운 시스템 버전이 생성되므로, 잘못 바꿨다면 darwin-rebuild switch --rollback 으로 이전 버전으로 되돌릴 수도 있습니다. 이는 Homebrew를 쓰던 때에는 상상도 못 했던 일입니다.