고객사마다 다른 앱을, 빌드 한 번으로
고객사별 브랜치의 공통 변경 관리와 온프레미스 반입 부담을 줄이려고 코드 빌드와 구성 선택을 나눴어요. Manifest로 바꿀 수 있는 범위와 Host·Core 호환성 검증을 함께 정리합니다.

고객사가 하나 늘면 빌드도 한 벌 늘었어요. 둘이면 두 벌, 셋이면 세 벌.
당연한 것 같죠? 저도 오래 그렇게 생각했습니다. 고객사마다 들어가는 제품이 다르고, 로고가 다르고, 로그인 방식까지 다르니까요.
그런데 이 구조에는 조용한 비용이 있어요. 같은 코드를 N번 빌드한다는 건, 실패할 기회도 N번이라는 뜻이거든요.
빌드가 늘어날 때 같이 늘어나는 것들
빌드가 여러 벌이면 이런 질문이 생깁니다. "A사에 나간 버전이랑 B사에 나간 버전이 같은 커밋에서 나온 게 맞나?"
보통은 맞아요. 그런데 "보통은"으로 대답해야 하는 질문이라는 게 문제죠.
배포 시점이 다르면 그 사이에 의존성 하나가 조용히 갱신될 수도 있고, 캐시가 다르게 남아 있을 수도 있어요. 확인하려면 두 결과물을 뜯어봐야 하는데, 그럴 거면 애초에 안 갈라놓는 게 낫죠.
브랜치 통합과 구성 변경은 다른 문제였어요
고객사별 브랜치를 직접 운영할 때는 공통 변경 이력을 추적하고 버그 수정을 각 브랜치에 전파해야 했어요. 단일 코드베이스로 옮긴 이유입니다.
고객사 구성을 빌드 시점에 정하는 방식도 고려했어요. 하지만 온프레미스에서는 배포를 협의하고 빌드를 반입해야 했습니다. 계약 해지에 따른 플러그인 비활성화나 설정 변경까지 매번 전체 빌드 반입에 의존하는 부담을 줄이고 싶었어요.
그래서 코드 산출물을 만드는 단계와 이미 반입된 기능의 활성화·설정을 고르는 단계를 나눴습니다. 브랜치 통합은 공통 변경 관리를, Manifest 갱신은 운영 중 구성 변경을 다뤄요.
만드는 일과 고르는 일을 갈랐어요
그래서 순서를 바꿨습니다. 빌드는 한 번만 돌아요. 대신 결과물이 하나의 덩어리가 아니라 이름 붙은 조각들로 나옵니다.
공용 기반이 한 묶음, 제품이 각자 한 묶음, 플러그인이 각자 한 묶음, 고객사별 설정이 또 각자 한 묶음.
그리고 배포할 때 명세서를 보고 필요한 조각만 가져다 제자리에 놓습니다. 이 고객사는 이 제품 셋과 이 플러그인 둘, 하는 식으로요.
(주문서 보고 창고에서 상자 꺼내는 일이에요. 새로 만드는 게 아니라요.)
조각에 이름을 붙이는 규칙
"이름 붙은 조각"이라고 쉽게 말했는데, 여기가 설계의 대부분이에요. 이름이 흔들리면 조립이 통째로 흔들리거든요.
| 조각 종류 | 경계 기준 | 바뀌는 빈도 |
|---|---|---|
| 공용 기반 | 모든 고객사가 무조건 받는 것 | 낮음 |
| 제품 | 제품 단위로 켜고 끌 수 있는 것 | 중간 |
| 플러그인 | 제품 안에서 다시 켜고 끌 수 있는 것 | 높음 |
| 고객사 설정 | 코드가 아니라 값인 것 | 아주 높음 |
마지막 줄이 중요해요. 고객사별로 다른 것 중에 코드가 섞여 들어가는 순간, 그 고객사는 다시 자기 빌드를 갖게 되니까요. 다르게 두고 싶은 건 값으로만 표현되어야 했습니다.
그리고 무엇을 고를지는 명세서 파일 하나가 결정해요.
// 고객사 명세서 — 코드가 아니라 데이터입니다
{
"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"
}
// 조립기는 이 파일만 읽습니다.
// 여기 없는 조각은 결과물에 들어갈 수 없어요.위 예시는 같은 빌드 세트의 조각을 고르는 모양이에요. 이 예시의 동일 버전 표기를 모든 기능의 버전이 같아야 한다는 규칙으로 일반화하지는 않아요. 기능별 Manifest에는 Host·Core의 허용 버전 범위를 선언하고 비호환일 때 오류를 안내합니다.
여기서 규율 하나를 박았습니다
이런 구조에서 제일 흔한 타락이 뭔지 아세요? 빌드가 끝난 뒤에 파일을 옮기는 거예요.
경계가 안 맞으면 복사해서 맞추면 되잖아요. 여기 있어야 할 파일이 저기 있으면 옮기면 되고요. 한 줄이면 되는데.
그 한 줄이 쌓이면 결국 아무도 결과물의 구조를 설명하지 못하게 됩니다. 빌드가 만든 게 아니라 스크립트가 주물러 놓은 모양이 되거든요.
그래서 규칙을 문장으로 못 박았어요. 빌드 뒤에 파일을 복사하거나 재분류해서 경계를 보상하지 않는다. 경계가 안 맞으면 옮기는 게 아니라 빌드를 고칩니다.
규율을 문장이 아니라 검사로
"빌드 뒤에 파일을 옮기지 않는다"를 문서에만 적어두면 반년 안에 무너져요. 급한 날이 반드시 오거든요. 그래서 검사로 바꿨습니다.
// 빌드 직후 산출물 트리를 지문으로 만든다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() // 나가면 안 되는 게 섞이지 않았는가
// 하나라도 실패하면 배포가 멈춥니다.// 경고가 아니라 중단이에요.단일 인스턴스로 유지해야 하는 기반 버전과 기능별 Host·Core 호환 범위는 구분해야 해요. 아티팩트의 출처·해시 검증과 기능 계약의 호환성 검증도 서로 다른 조건입니다.
실패 메시지도 신경 썼어요. "검증 실패"만 뜨면 아무도 못 고치거든요. 어느 조각이, 어느 명세서 줄 때문에, 무엇을 기대했는데 무엇이 없었는지까지 한 줄에 나오게 했습니다.
그런데 배포 자동화를 못 쓰는 고객사가 있었어요
설계가 예쁘게 끝났다고 생각한 시점에 현실이 왔습니다. 배포 파이프라인 자체를 못 쓰는 환경이 있었거든요. 결과물을 그냥 통째로 받아서 웹 서버에 올려야 하는 곳이요.
조각으로 나눠놨는데 조립해 줄 사람이 없는 상황인 거죠.
그래서 선택 빌드를 하나 더 만들었어요. 고객사를 지정하면 조립까지 끝난 완성본이 바로 나옵니다. 받아서 올리면 끝인 모양으로요.
중요한 건 여기서도 사후 이동 단계를 안 만들었다는 거예요. 빌드가 처음부터 그 모양으로 출력합니다. 복사·평탄화 단계가 없어요.
두 갈래가 생기면 위험해집니다
선택 빌드를 따로 만든 순간, 산출물을 만드는 길이 두 개가 됐어요. 이게 제일 무서웠습니다. 길이 둘이면 언젠가 결과가 갈라지거든요.
그래서 두 길이 같은 것을 만든다는 걸 증명하게 했어요.
// 같은 고객사에 대해 두 경로가 같은 결과를 내는지const a = assemble(fullBuild, manifestOf("client-x")); // 조립 경로const b = selectiveBuild("client-x"); // 선택 빌드 경로
expect(digestOf(a)).toEqual(digestOf(b));
// 다르면 어느 쪽이 틀렸는지가 아니라// "두 길이 갈라졌다"는 사실 자체를 사고로 봅니다두 경로의 동등성을 비교할 때는 최적화 옵션과 입력 조건도 같아야 해요. 둘 다 화면이 뜬다는 사실만으로 결과물이 동일하다고 판단하지는 않습니다.
남겨둔 구멍도 있습니다
정직하게 하나 적어둘게요. 명세서가 고른 조각이 실제로 다 들어왔는지는, 아직 검사기가 명세서를 직접 읽어서 확인하지 않아요.
조립 단계에서 확인은 하는데, 최종 검사기의 계약에는 아직 없습니다. 알고 있고, 언젠가 옮길 거예요.
(알면서 안 한 것과 모르는 건 다르니까, 문서에 적어뒀습니다.)
치른 값
좋은 얘기만 적으면 재미없으니 비용도 적어 둘게요.
| 치른 것 | 얼마나 | 왜 받아들였나 |
|---|---|---|
| 조립 단계 신규 구현 | 설계 포함 몇 주 | 한 번 만들면 고객사 수와 무관하게 재사용됨 |
| 빌드 1회 시간 증가 | 전체를 다 만드니까 늘어남 | 배포마다 도는 게 아니라 커밋마다 한 번이라 총량은 줄었음 |
| 조각 경계 논쟁 | 초기에 반복적으로 | 경계를 못 정하면 조립도 못 하니 피할 수 없는 비용 |
| 명세서 관리 부담 | 고객사마다 파일 하나 | 대신 빌드 설정 한 벌이 사라짐 |
두 번째 줄이 처음엔 반대 근거로 쓰였어요. "빌드가 더 오래 걸린다"고요. 그런데 세어 보니 예전엔 고객사 수만큼 빌드가 돌았고, 지금은 한 번만 돕니다. 한 번이 느려진 대신 횟수가 사라진 거예요.
그래서 뭐가 달라졌냐면
"A사 버전이랑 B사 버전이 같은 커밋에서 나온 게 맞나?"에 이제 맞다고 대답할 수 있어요. 같은 빌드에서 나왔으니까요. 확인은 검사기가 합니다.
고객사를 추가하는 일도 빌드를 늘리는 일이 아니라 명세서를 하나 쓰는 일이 됐고요.
만드는 일과 고르는 일이 갈리면, 고르는 일은 실패할 수가 없어요. 이미 만들어진 것 중에서 고르는 거니까요.
이 시리즈의 주제가 여기서 또 반복돼요. 중앙 파일도, 브랜딩도, 릴리스도, 메뉴도, 그리고 이번엔 배포까지 — 이미 반입된 기능의 활성화·설정은 Manifest 갱신 뒤 새로고침으로 반영하고, 새 코드나 수정 파일은 별도로 반입한다는 경계를 공유합니다.
새로고침으로 바뀌는 것과 새 반입이 필요한 것
Manifest만 바꿔서 사용할 수 있는 것은 이미 반입된 플러그인의 활성화와 설정이에요. 열린 화면을 실시간으로 교체하는 방식은 아니고 다음 새로고침부터 적용됩니다. 아직 반입하지 않은 코드까지 생겨나는 것은 아니에요.
로컬·개발 서버에서 직접 동작을 확인했고, 고객사 확장을 중앙 라우트 코드 수정 없이 처리했습니다. 향후에는 하나의 전체 배포 산출물에 서브도메인별 Manifest를 연결해 SaaS 구성을 관리하려고 했어요. 이 부분은 확장 설계 의도이며 SaaS 운영 실적은 아닙니다.

