고객사마다 다른 앱을, 빌드 한 번으로
고객사가 늘 때마다 빌드를 한 벌씩 더 돌리고 있었어요. 이제는 한 번 만들어 두고 배포할 때 고릅니다. 만드는 일과 고르는 일을 갈라놓은 이야기예요.

고객사가 하나 늘면 빌드도 한 벌 늘었어요. 둘이면 두 벌, 셋이면 세 벌.
당연한 것 같죠? 저도 오래 그렇게 생각했습니다. 고객사마다 들어가는 제품이 다르고, 로고가 다르고, 로그인 방식까지 다르니까요.
그런데 이 구조에는 조용한 비용이 있어요. 같은 코드를 N번 빌드한다는 건, 실패할 기회도 N번이라는 뜻이거든요.
빌드가 늘어날 때 같이 늘어나는 것들
빌드가 여러 벌이면 이런 질문이 생깁니다. "A사에 나간 버전이랑 B사에 나간 버전이 같은 커밋에서 나온 게 맞나?"
보통은 맞아요. 그런데 "보통은"으로 대답해야 하는 질문이라는 게 문제죠.
배포 시점이 다르면 그 사이에 의존성 하나가 조용히 갱신될 수도 있고, 캐시가 다르게 남아 있을 수도 있어요. 확인하려면 두 결과물을 뜯어봐야 하는데, 그럴 거면 애초에 안 갈라놓는 게 낫죠.
N벌 빌드가 실제로 만든 사고들
추상적으로 들릴 테니 실제로 뭐가 터졌는지 적어 볼게요. 전부 "빌드가 여러 벌이라서" 생긴 일들이에요.
| 증상 | 진짜 원인 | 언제 발견됐나 |
|---|---|---|
| A사에서만 특정 화면이 흰 화면 | 그 빌드에서만 청크 해시가 갱신돼 캐시가 어긋남 | 고객사 담당자 전화 |
| B사 로그인이 갑자기 실패 | 배포 시점이 달라 의존성 패치 버전이 서로 다름 | 다음 날 아침 |
| 같은 버그를 두 번 고침 | A사 빌드에만 핫픽스가 들어감 | 2주 뒤 회귀 |
| 릴리스 노트가 안 맞음 | 어느 커밋이 어디 나갔는지 사람이 기억으로 관리 | 분기 정산 때 |
공통점이 보이시나요. 전부 "다른 게 다르다"가 아니라 "같아야 할 게 달랐다"예요. 고객사마다 다른 건 원래 다른 게 맞는데, 같은 코드까지 달라져 버린 겁니다.
재현 가능한 빌드로 해결하면 되지 않나
먼저 이 길을 검토했어요. 빌드를 결정적으로 만들면 N번 돌려도 같은 결과가 나오니까요. 맞는 말인데, 우리 문제를 다 덮진 못했습니다.
| 해결안 | 되는 것 | 안 되는 것 |
|---|---|---|
| 재현 가능한 빌드 | 같은 입력이면 같은 출력 | 배포 시점이 다르면 입력 자체가 달라짐 |
| 의존성 잠금 강화 | 버전 드리프트 차단 | 캐시·환경 차이는 그대로 |
| 빌드 결과 해시 비교 | 사후에 다름을 발견 | 이미 나간 뒤라 늦음 |
| 빌드를 한 번만 돌리기 | 달라질 기회 자체를 제거 | 조립 단계를 새로 만들어야 함 |
마지막 줄이 비용이 제일 컸어요. 그런데 앞의 셋은 전부 "달라졌는지 확인하는" 방법이고, 마지막만 "달라질 수 없게 하는" 방법이더라고요. 확인은 놓칠 수 있지만, 불가능한 건 놓칠 수가 없죠.
만드는 일과 고르는 일을 갈랐어요
그래서 순서를 바꿨습니다. 빌드는 한 번만 돌아요. 대신 결과물이 하나의 덩어리가 아니라 이름 붙은 조각들로 나옵니다.
공용 기반이 한 묶음, 제품이 각자 한 묶음, 플러그인이 각자 한 묶음, 고객사별 설정이 또 각자 한 묶음.
그리고 배포할 때 명세서를 보고 필요한 조각만 가져다 제자리에 놓습니다. 이 고객사는 이 제품 셋과 이 플러그인 둘, 하는 식으로요.
(주문서 보고 창고에서 상자 꺼내는 일이에요. 새로 만드는 게 아니라요.)
조각에 이름을 붙이는 규칙
"이름 붙은 조각"이라고 쉽게 말했는데, 여기가 설계의 대부분이에요. 이름이 흔들리면 조립이 통째로 흔들리거든요.
| 조각 종류 | 경계 기준 | 바뀌는 빈도 |
|---|---|---|
| 공용 기반 | 모든 고객사가 무조건 받는 것 | 낮음 |
| 제품 | 제품 단위로 켜고 끌 수 있는 것 | 중간 |
| 플러그인 | 제품 안에서 다시 켜고 끌 수 있는 것 | 높음 |
| 고객사 설정 | 코드가 아니라 값인 것 | 아주 높음 |
마지막 줄이 중요해요. 고객사별로 다른 것 중에 코드가 섞여 들어가는 순간, 그 고객사는 다시 자기 빌드를 갖게 되니까요. 다르게 두고 싶은 건 값으로만 표현되어야 했습니다.
그리고 무엇을 고를지는 명세서 파일 하나가 결정해요.
// 고객사 명세서 — 코드가 아니라 데이터입니다
{
"client": "client-x",
"base": "base@2026.7.3",
"products": ["product-a@2026.7.3", "product-b@2026.7.3"],
"plugins": ["plugin-export@2026.7.3"],
"settings": "client-x/settings.json"
}
// 조립기는 이 파일만 읽습니다.
// 여기 없는 조각은 결과물에 들어갈 수 없어요.버전이 전부 같은 게 보이시죠. 같은 빌드에서 나온 조각들이니까 당연히 같아야 해요. 그리고 이게 나중에 검사의 근거가 됩니다 — 섞인 버전이 있으면 그 자체가 사고 신호예요.
여기서 규율 하나를 박았습니다
이런 구조에서 제일 흔한 타락이 뭔지 아세요? 빌드가 끝난 뒤에 파일을 옮기는 거예요.
경계가 안 맞으면 복사해서 맞추면 되잖아요. 여기 있어야 할 파일이 저기 있으면 옮기면 되고요. 한 줄이면 되는데.
그 한 줄이 쌓이면 결국 아무도 결과물의 구조를 설명하지 못하게 됩니다. 빌드가 만든 게 아니라 스크립트가 주물러 놓은 모양이 되거든요.
그래서 규칙을 문장으로 못 박았어요. 빌드 뒤에 파일을 복사하거나 재분류해서 경계를 보상하지 않는다. 경계가 안 맞으면 옮기는 게 아니라 빌드를 고칩니다.
규율을 문장이 아니라 검사로
"빌드 뒤에 파일을 옮기지 않는다"를 문서에만 적어두면 반년 안에 무너져요. 급한 날이 반드시 오거든요. 그래서 검사로 바꿨습니다.
// 빌드 직후 산출물 트리를 지문으로 만든다
const tree = walk(outputDir)
.map(f => `${f.path}:${sha256(f.bytes)}`)
.sort();
// 조립·배포 단계가 끝난 뒤 다시 만든다
// 두 지문에서 "경로가 바뀐 파일"이 하나라도 있으면 실패
const moved = diffPaths(treeAfterDeploy, tree);
if (moved.length > 0) {
fail(`빌드 뒤 이동이 감지됐습니다: ${moved.join(", ")}`);
}내용이 바뀐 건 봐주고 경로가 바뀐 것만 잡느냐고요? 반대예요. 내용은 조립 과정에서 정당하게 합쳐질 수 있지만, 경로는 빌드가 정한 그대로여야 합니다. 경로가 움직였다는 건 누군가 결과물을 주물렀다는 뜻이거든요.
이 검사를 켠 첫 주에 두 군데가 걸렸어요. 하나는 제가 반년 전에 넣은 한 줄이었습니다.
배포하는 쪽은 아무것도 안 만들어요
배포 단계의 권한도 좁혔습니다. 다시 빌드하지 않고, 결과물 내용을 고치지도 않아요. 하는 일은 고르고, 놓고, 검사하는 것뿐입니다.
검사가 꽤 깐깐해요. 조립된 결과물을 두고 이런 걸 봅니다.
| 검사 | 막는 사고 |
|---|---|
| 빌드 세트 식별자 일치 | 다른 시점에 만든 조각이 섞임 |
| 소스 리비전 일치 | 어느 커밋에서 나왔는지 모호해짐 |
| 단일 인스턴스 버전 일치 | 한 벌만 있어야 할 게 두 벌 실림 |
| 파일 해시 일치 | 기록된 것과 다른 내용이 실림 |
여기서 하나라도 어긋나면 배포가 멈춥니다. 사람이 눈으로 확인하는 게 아니라요.
(이 시리즈를 읽으신 분은 익숙한 패턴이죠. 규칙은 문서가 아니라 실패하는 검사로 존재합니다.)
검사기의 계약
배포 단계가 "고르고, 놓고, 검사한다"고 했는데, 검사가 실제로 뭘 보는지 적어 둘게요. 이 목록이 곧 배포의 계약이에요.
// 조립된 결과물에 대한 검사 계약
check.allChunksReferenced() // 아무도 안 쓰는 조각이 실려 있지 않은가
check.noMissingReference() // 참조하는데 없는 조각이 있지 않은가
check.singleBaseVersion() // 섞인 버전이 있지 않은가
check.entryHtmlIntegrity() // 진입 문서가 참조하는 경로가 전부 존재하는가
check.noSourceMapLeak() // 나가면 안 되는 게 섞이지 않았는가
// 하나라도 실패하면 배포가 멈춥니다.
// 경고가 아니라 중단이에요.세 번째 줄이 앞에서 명세서 버전을 맞춰 둔 이유예요. 조각들의 버전이 하나라도 다르면, 그건 누군가 다른 빌드의 산출물을 섞었다는 뜻이니까요.
실패 메시지도 신경 썼어요. "검증 실패"만 뜨면 아무도 못 고치거든요. 어느 조각이, 어느 명세서 줄 때문에, 무엇을 기대했는데 무엇이 없었는지까지 한 줄에 나오게 했습니다.
그런데 배포 자동화를 못 쓰는 고객사가 있었어요
설계가 예쁘게 끝났다고 생각한 시점에 현실이 왔습니다. 배포 파이프라인 자체를 못 쓰는 환경이 있었거든요. 결과물을 그냥 통째로 받아서 웹 서버에 올려야 하는 곳이요.
조각으로 나눠놨는데 조립해 줄 사람이 없는 상황인 거죠.
그래서 선택 빌드를 하나 더 만들었어요. 고객사를 지정하면 조립까지 끝난 완성본이 바로 나옵니다. 받아서 올리면 끝인 모양으로요.
중요한 건 여기서도 사후 이동 단계를 안 만들었다는 거예요. 빌드가 처음부터 그 모양으로 출력합니다. 복사·평탄화 단계가 없어요.
두 갈래가 생기면 위험해집니다
선택 빌드를 따로 만든 순간, 산출물을 만드는 길이 두 개가 됐어요. 이게 제일 무서웠습니다. 길이 둘이면 언젠가 결과가 갈라지거든요.
그래서 두 길이 같은 것을 만든다는 걸 증명하게 했어요.
// 같은 고객사에 대해 두 경로가 같은 결과를 내는지
const a = assemble(fullBuild, manifestOf("client-x")); // 조립 경로
const b = selectiveBuild("client-x"); // 선택 빌드 경로
expect(digestOf(a)).toEqual(digestOf(b));
// 다르면 어느 쪽이 틀렸는지가 아니라
// "두 길이 갈라졌다"는 사실 자체를 사고로 봅니다이 테스트가 실제로 한 번 깨졌어요. 선택 빌드에만 최적화 옵션 하나가 다르게 들어가 있었거든요. 결과물은 둘 다 정상 동작했습니다. 그래서 더 위험했어요 — 눈으로는 절대 못 찾을 차이였으니까요.
남겨둔 구멍도 있습니다
정직하게 하나 적어둘게요. 명세서가 고른 조각이 실제로 다 들어왔는지는, 아직 검사기가 명세서를 직접 읽어서 확인하지 않아요.
조립 단계에서 확인은 하는데, 최종 검사기의 계약에는 아직 없습니다. 알고 있고, 언젠가 옮길 거예요.
(알면서 안 한 것과 모르는 건 다르니까, 문서에 적어뒀습니다.)
치른 값
좋은 얘기만 적으면 재미없으니 비용도 적어 둘게요.
| 치른 것 | 얼마나 | 왜 받아들였나 |
|---|---|---|
| 조립 단계 신규 구현 | 설계 포함 몇 주 | 한 번 만들면 고객사 수와 무관하게 재사용됨 |
| 빌드 1회 시간 증가 | 전체를 다 만드니까 늘어남 | 배포마다 도는 게 아니라 커밋마다 한 번이라 총량은 줄었음 |
| 조각 경계 논쟁 | 초기에 반복적으로 | 경계를 못 정하면 조립도 못 하니 피할 수 없는 비용 |
| 명세서 관리 부담 | 고객사마다 파일 하나 | 대신 빌드 설정 한 벌이 사라짐 |
두 번째 줄이 처음엔 반대 근거로 쓰였어요. "빌드가 더 오래 걸린다"고요. 그런데 세어 보니 예전엔 고객사 수만큼 빌드가 돌았고, 지금은 한 번만 돕니다. 한 번이 느려진 대신 횟수가 사라진 거예요.
그래서 뭐가 달라졌냐면
"A사 버전이랑 B사 버전이 같은 커밋에서 나온 게 맞나?"에 이제 맞다고 대답할 수 있어요. 같은 빌드에서 나왔으니까요. 확인은 검사기가 합니다.
고객사를 추가하는 일도 빌드를 늘리는 일이 아니라 명세서를 하나 쓰는 일이 됐고요.
만드는 일과 고르는 일이 갈리면, 고르는 일은 실패할 수가 없어요. 이미 만들어진 것 중에서 고르는 거니까요.
이 시리즈의 주제가 여기서 또 반복돼요. 중앙 파일도, 브랜딩도, 릴리스도, 메뉴도, 그리고 이번엔 배포까지 — 전부 "빌드 없이 바꾼다"는 한 가지 집착의 결과물입니다.

