재빌드 없는 릴리스 — write-once 아티팩트 레지스트리와 CAS 포인터
레지스트리 없이 파일시스템 원자성만으로 불변 배포를 만들었어요. EEXIST가 지키는 write-once 게시, CAS 포인터 전환, 그리고 재빌드 금지를 정규식으로 강제하는 no-build 정책까지.

npm 레지스트리도, 아티팩트 저장소도 없이 배포 파이프라인을 만들어야 했어요. 쓸 수 있는 보증은 파일시스템의 원자성뿐.
그런데요 — 파일시스템이면 충분하더라고요. 2편에서 "릴리스는 불변 Artifact의 조립"이라고 했는데, 이번 글이 그 조립 파이프라인의 구현입니다. 불변성·동시성·롤백을 전부 fs 원자성 위에 쌓았어요.

선택지가 없었던 게 오히려 좋았어요
먼저 상황을 정확히 적어 둘게요. 사내망 안에서 도는 배포였고, 외부 패키지 레지스트리도 오브젝트 스토리지도 쓸 수 없었어요. 남은 건 서버의 파일시스템 하나였습니다.
| 후보 | 줬을 이점 | 우리가 못 쓴 이유 |
|---|---|---|
| 사내 패키지 레지스트리 | 버전 불변성과 조회가 공짜 | 운영 주체가 없었고 새로 세울 여력도 없었음 |
| 오브젝트 스토리지 | 원자적 쓰기와 버전 관리 내장 | 망 정책상 접근 불가 |
| 컨테이너 이미지 레지스트리 | 이미지 단위 불변성 | 정적 산출물 하나 때문에 도입하기엔 과함 |
| 파일시스템 | 있음. 그게 전부 | 보증이 rename과 open 플래그뿐 |
그래서 질문이 바뀌었어요. "무엇을 도입할까"가 아니라 "파일시스템이 주는 보증만으로 어디까지 되나"가 됐습니다.
결론부터 말하면 꽤 멀리까지 되더라고요. 불변성, 동시성, 롤백 — 셋 다 rename 하나와 open 플래그 두 개 위에 세울 수 있었습니다. 아래는 그 이야기예요.
CI — 바뀐 것만, 한 번만
매트릭스는 그래프로
CI는 패키지 단위로 돌아요. 매트릭스 생성기가 변경 파일 경로를 패키지로 매핑하고, 비공개 소스 패키지의 의존 그래프를 고정점 반복으로 전파해서 영향받는 소비자 패키지까지만 빌드 대상에 넣습니다. 빌드 스크립트나 루트 설정이 바뀌면? 전체 재빌드로 폴백해요. 부분 최적화가 정합성을 이기면 안 되니까요.
영향 전파는 고정점 반복으로 돌아요. 한 번 훑어서는 부족하거든요. A가 바뀌면 A를 쓰는 B, B를 쓰는 C까지 가야 합니다.
// 변경된 파일 → 패키지 → 영향받는 소비자까지
let targets = new Set(changedFiles.map(toPackage));
for (;;) {
const before = targets.size;
for (const pkg of graph.packages) {
if (pkg.dependencies.some(d => targets.has(d))) {
targets.add(pkg.name);
}
}
if (targets.size === before) break; // 더 안 늘면 끝
}그리고 폴백 규칙을 뒀어요. 부분 최적화가 정합성을 이기면 안 되니까요.
| 바뀐 것 | 판단 | 이유 |
|---|---|---|
| 패키지 소스 파일 | 그 패키지와 소비자만 | 영향 범위가 그래프로 닫힘 |
| 빌드 스크립트 | 전체 재빌드 | 무엇이 영향받는지 그래프로 알 수 없음 |
| 루트 설정 | 전체 재빌드 | 모든 패키지의 입력이 바뀜 |
| 잠금 파일 | 전체 재빌드 | 의존성 실제 버전이 바뀔 수 있음 |
이 표에서 중요한 건 두 번째 줄 이하예요. 애매하면 전부 다시 만듭니다. CI 시간을 아끼려다 잘못된 산출물을 내보내는 것보다, 느린 게 낫거든요.
게시는 write-once
스테이징 디렉터리에 읽기 전용 권한으로 파일을 쓰고, sha256 digest와 크기를 검증한 뒤, fs.rename으로 버전 디렉터리에 원자 승격해요. 같은 버전을 다시 게시하려 들면 파일시스템이 EEXIST로 거부합니다. "덮어쓰기가 불가능한 저장소"를 OS 수준에서 만든 거예요.
(EEXIST가 이렇게 든든한 에러인 줄 몰랐습니다.)
write-once를 실제로 만드는 건 세 줄이에요.
// 1. 스테이징에 쓴다 (읽기 전용 권한으로)
await writeFile(stagingPath, bytes, { mode: 0o444 });
// 2. 내용을 검증한다
assert(sha256(bytes) === expectedDigest);
assert(bytes.length === expectedSize);
// 3. 원자적으로 승격한다
await rename(stagingDir, `${registry}/${name}/${version}`);
// 같은 버전이 이미 있으면 여기서 EEXIST — 덮어쓸 방법이 없습니다세 번째 줄이 전부예요. 같은 이름과 버전으로 두 번 게시하려 들면 파일시스템이 거부합니다. 애플리케이션 코드가 판단하는 게 아니라, OS가 거부해요.
| 방식 | 불변성 보장 | 실패 시 상태 |
|---|---|---|
| 그냥 덮어쓰기 | 없음 | 중간까지 쓰인 파일이 남음 |
| 쓰기 전 존재 확인 | 확인과 쓰기 사이에 틈이 있음 | 경쟁 상황에서 깨짐 |
| 스테이징 + rename | 같은 경로는 한 번만 만들어짐 | 스테이징만 남고 정본은 무사 |
두 번째 줄이 흔한 실수예요. 있는지 보고 없으면 쓰는 코드는 대부분의 시간 잘 동작합니다. 그리고 동시에 두 배포가 돌 때 조용히 깨져요.
EEXIST가 이렇게 든든한 에러인 줄 몰랐습니다. 실패인데 안심되는 실패거든요.
CD — 조립만 하고, 빌드는 금지
릴리스 4단계
validate가 스펙-레지스트리 정합성과 digest 무결성을 검증하고 → assemble이 exact 버전 조합으로 append-only 릴리스 디렉터리를 만들고 → attest가 실제 브라우저(헤드리스)로 부트스트랩 스모크를 돌려 attestation 문서를 발행하고 → pointer activate가 활성 릴리스 포인터를 교체합니다.
심장은 포인터 교체
락 파일(wx 플래그)로 상호배제를 잡고, 현재 포인터가 기대값과 같은지 비교한 뒤(compare-and-swap), tmp 파일 rename + 디렉터리 fsync로 crash-safe하게 바꿔요. 열린 세션은 부트 시점 릴리스에 고정되니까 배포 중 버전 혼합이 없고, 롤백은 재배포가 아니라 포인터 재전환입니다.
// 포인터 전환의 뼈대 (일반화)acquireLock('.cas.lock') // wx open — 이미 있으면 실패assert(readPointer() === expected) // comparewriteTmp(newPointer); fsyncFile()
rename(tmp, POINTER); fsyncDir() // swap — 원자 교체releaseLock()네 단계가 각각 뭘 막는지 적어 둘게요. 하나씩 이유가 있어서 생긴 단계들이에요.
| 단계 | 하는 일 | 막는 사고 |
|---|---|---|
| validate | 명세와 레지스트리 정합성 · digest 무결성 | 명세에 적힌 버전이 실제로 없는 채 배포됨 |
| assemble | 정확한 버전 조합으로 릴리스 디렉터리 생성 | 섞인 버전이 한 릴리스에 들어감 |
| attest | 실제 브라우저로 부팅 스모크 후 증명서 발행 | 빌드는 성공했는데 브라우저에서 안 뜨는 조합 |
| point | 포인터를 새 릴리스로 교체 | 전환 중 두 버전이 섞여 보이는 상태 |
순서를 바꿀 수 없다는 게 핵심이에요. attest가 point 앞에 있는 이유는 명확합니다 — 증명하지 못한 걸 사용자에게 보내지 않겠다는 뜻이니까요.
그리고 릴리스 디렉터리는 append-only예요. 새 릴리스가 옛 릴리스를 지우지 않습니다. 그래서 롤백이 파일 복원이 아니라 포인터 한 줄 되돌리기가 돼요.
원칙을 코드로 — no-build 정책
"릴리스는 조립만, 재빌드 금지"라는 원칙은 문서로만 두면 반드시 침식돼요. 그래서 CD 스크립트 소스 자체를 정규식으로 스캔해서 child_process, 번들러, 컴파일러 호출 패턴이 등장하면 실패시키는 자가 검사를 넣었습니다. 원칙이 코드가 되면, 원칙 위반은 컴파일 에러와 같은 무게를 가져요.
그 자가 검사는 이렇게 생겼어요.
// CD 스크립트가 스스로를 검사합니다
const forbidden = [
/child_process/, // 외부 프로세스 실행
/\bexeca\b/,
/from ["']vite["']/, // 번들러
/typescript\.transpile/,
];
for (const file of cdScriptFiles) {
const src = read(file);
const hit = forbidden.find(re => re.test(src));
if (hit) fail(`릴리스 단계는 빌드하지 않습니다: ${file} (${hit})`);
}정규식 검사라 우회하려면 우회할 수 있어요. 알고 있습니다. 목적이 악의를 막는 게 아니거든요. 급한 날에 무심코 넣는 한 줄을 막는 거예요.
실제로 이 검사가 처음 걸린 것도 악의가 아니었어요. 배포 직전에 설정 파일 하나만 변환해서 넣으려던 시도였습니다. 합리적으로 보였고, 그래서 더 위험했어요.
attestation — 배포 전에 증명서를 받습니다
포인터 전환은 attestation 검증을 통과해야만 실행돼요. 조립된 릴리스가 실제 브라우저에서 부팅되는지 스모크로 확인하고, 그 결과와 파일 해시를 서명 문서처럼 남깁니다. "빌드가 성공했다"와 "이 조합이 브라우저에서 뜬다"는 다른 명제고, 뒤쪽을 증명해야 배포예요.
치른 값, 그리고 안 한 것
| 치른 것 | 왜 받아들였나 | |
|---|---|---|
| 배포 인프라를 직접 소유 | 문제가 생기면 우리가 고쳐야 함 — 대신 동작을 끝까지 설명할 수 있음 | |
| 릴리스 디렉터리가 계속 쌓임 | 디스크를 먹음 — 보관 기간을 정해서 정리, 롤백 가능 범위와 맞바꿈 | |
| 단계가 넷이라 배포가 느려짐 | 한 번에 밀어 넣던 시절보다 느림 — 대신 어디서 멈췄는지가 항상 명확 | |
| 파일시스템 의존 | 다른 저장소로 옮기려면 다시 짜야 함 | 필요해지면 그때 바꾸기로 |
그리고 안 한 것도 하나 적어 둘게요. 서명이나 검증 체계까지는 가지 않았습니다. 사내망 안에서 도는 배포라 위협 모델이 거기까지 아니었어요.
필요 없어서 안 한 것과, 몰라서 안 한 것은 다릅니다. 그래서 문서에 "지금은 필요 없어서 안 함"이라고 적어 뒀어요. 나중에 상황이 바뀌면 이 문장이 시작점이 되겠죠.
이 파이프라인 전체(릴리스+아티팩트 스크립트 약 7,700 LOC)는 프론트엔드 팀이 직접 소유해요. 배포 인프라를 외부에 위임하는 대신 가장 낮은 수준의 보증 위에 쌓았기 때문에, 동작을 끝까지 설명할 수 있는 배포 체계가 됐습니다.
(설명할 수 없는 배포는 남의 배포니까요.)

