중앙 조립 코드를 전부 걷어내다 — 런타임 기여 플랫폼 아키텍처
고객사별 브랜치에서 공통 수정 전파가 어려웠어요. 단일 코드베이스와 확장 기여 구조로 옮기고, 온프레미스의 반입 부담을 고려해 Manifest로 바꿀 수 있는 범위를 정했습니다.

어느 레포에나 하나쯤 있는 파일이 있죠. 제품 라우트를 전부 열거한 중앙 config요.
// 어디에나 있는 그 파일const PRODUCT_ROUTES = {
[Product.A]: aRoutes,
[Product.B]: bRoutes,
// 제품이 늘 때마다 여기를 고친다
};(네, 그 파일이요.)
이 파일의 문제는 기술이 아니라 심리예요. 새 기능을 붙일 때마다 모두가 이 파일을 열어야 하고, 연 사람은 누구든 조금씩 망칩니다. 저도 망쳐 봤고요.
그래서 저희는 이 파일을 금지했어요. "재빌드 없는 플랫폼" 시리즈의 첫 편은, 중앙 코드가 아무것도 모르게 만든 이야기입니다. 이 집착이 어디까지 갔는지 보여드릴게요.
공통 수정을 고객사마다 옮기던 문제
고객사별 브랜치를 직접 운영했어요. 공통 기능의 변경 이력을 추적하기 어렵고, 같은 버그 수정도 각 브랜치에 전파해야 했습니다. 기존 상속 기반 코드와 중앙 라우트 수정 방식에서는 고객사 요구가 늘수록 중앙 변경도 커졌어요.
단일 코드베이스로 옮기며 고객사 확장을 각 확장 지점의 기여로 받도록 바꿨습니다. 브랜치를 줄이는 일과 중앙 코드가 제품별 상세를 덜 알게 만드는 일을 함께 진행한 거예요.
문제 — 제품이 늘 때마다 중앙 파일이 자란다
제품 6종 × 고객사 8곳 규모가 되면 이 패턴은 병목이 돼요. 새 제품 추가가 중앙 파일 수정을 요구하고, 중앙 파일은 모든 제품을 알아야 하니 모든 패키지에 의존하고, 결국 무엇 하나 독립적으로 배포할 수 없게 됩니다.

빌드 시점에 고정할지, 실행할 때 고를지
고객사 구성을 빌드 시점에 정리하는 방식도 고려했어요. 하지만 온프레미스에서는 배포를 협의하고 빌드를 반입해야 했습니다. 이미 반입된 플러그인의 비활성화나 설정 변경에도 이 과정이 반복되는 부담을 줄이고 싶었어요.
그래서 기여 목록은 빌드 단계에서 생성하고, 실행 시에는 Manifest를 통해 반입된 기능의 활성화·설정을 선택하도록 했습니다. Manifest 갱신 뒤 새로고침하면 반영되고, 새로운 코드나 수정된 파일의 반입은 별도예요.
구조 — 기여, 조립, 스냅샷
흐름을 뒤집었어요
각 제품 패키지가 자기 Route·사이드바 메뉴·테마를 Contribution으로 선언하면, 그게 불변 Artifact(SemVer + SHA-256 digest)로 게시되고, Release가 exact 버전으로 조립해요. 런타임에서는 Contribution Manager가 유일한 writer로 assembly를 합성하고, UI 컴포넌트는 읽기 전용 Registry snapshot만 소비합니다. 중앙에는 제품 목록이 존재하지 않아요. 조립 결과만 존재합니다.
제품 패키지가 기여를 선언하는 모양이 이래요.
// packages/product-a/contribution/routes.entry.tsexport const routes = defineContribution({
contributionKey: "product-a:routes", // 누가 제공하는가surfaceKey: "app.routes", // 어떤 계약 지점에 꽂히는가order: 20,
load: () => import("./product-a.routes.impl"),
});
// 이 파일 어디에도 다른 제품이 등장하지 않습니다.// 그리고 코어 어디에도 "product-a" 가 등장하지 않습니다.양쪽이 서로를 모른다는 게 핵심이에요. 제품은 자기가 어느 계약 지점에 꽂히는지만 알고, 코어는 그 계약 지점이 있다는 것만 압니다. 누가 꽂혔는지는 조립 결과를 봐야 알아요.
order를 20처럼 띄운 것도 이유가 있어요. 제품끼리 순서를 협상해야 하는데, 연속된 정수면 하나 끼울 때마다 전부 다시 매겨야 하거든요.
키는 세 개로 나뉩니다
contributionKey(누가 제공하는가), surfaceKey(어떤 계약 지점에 꽂히는가), assemblyKey(어느 위치의 조립 결과를 읽는가). 컴포넌트가 키 문자열을 직접 조합하는 것도 금지예요 — 키는 codegen된 상수로만 씁니다. 문자열 조합을 허용하는 순간, "암묵적 중앙 열거"가 문자열 리터럴의 형태로 부활하거든요.
키를 셋으로 나눈 이유
앞에서 키가 세 종류라고 했는데, 왜 하나로 안 했는지 적을게요.
처음엔 하나면 될 것 같았어요. "이 기여는 이 자리에 꽂힌다" 하나로요. 그런데 실제로는 서로 다른 질문 세 개가 섞여 있었습니다.
| 키 | 답하는 질문 | 누가 소유하나 |
|---|---|---|
| 기여 키 | 누가 제공하는가 | 제공하는 패키지 |
| 표면 키 | 어떤 계약 지점에 꽂히는가 | 계약을 정의한 쪽 |
| 조립 키 | 어느 위치의 결과를 읽는가 | 읽는 화면 |
이걸 하나로 합치면 어떻게 되냐면, 제공자를 바꿀 때 읽는 쪽도 같이 고쳐야 합니다. 셋이 갈려 있으면 제공자가 바뀌어도 표면과 조립 위치는 그대로예요.
그리고 키를 조합하는 걸 금지했어요
사소해 보이는데 이게 이 설계의 핵심 방어선이었습니다.
// 금지 — 문자열 조합const key = `product.${productId}.routes`;
// 허용 — 생성된 상수만import { PRODUCT_ROUTES_SURFACE } from "@generated/keys";문자열 조합을 허용하면 무슨 일이 생기냐면, 중앙 열거가 문자열 리터럴의 모습으로 부활합니다. 중앙 파일은 없는데 코드 곳곳에서 제품 id를 알고 있는 상태가 되는 거죠.
그리고 이건 더 나빠요. 중앙 파일은 최소한 한 곳에 모여 있어서 찾을 수라도 있는데, 흩어진 문자열은 찾을 수도 없거든요.
조립기 쪽 코드는 이렇게 생겼어요. 여기가 제일 단순해야 하는 자리입니다.
// 조립기는 제품 이름을 모릅니다function assemble(surfaceKey, manifests) {
const entries = manifests
.flatMap(m => m.contributions)
.filter(c => c.surfaceKey === surfaceKey)
.sort((a, b) => a.order - b.order);
if (entries.length === 0 && isRequired(surfaceKey)) {
throw new AssemblyError(`조립 실패: ${surfaceKey} 에 기여가 없습니다`);
// 기본값으로 대체하지 않습니다
}
return entries;
}열 줄쯤 되는 이 함수가 예전의 중앙 config 파일을 대체한 전부예요. 그리고 이 함수는 제품이 몇 개든 안 자랍니다.
마지막 조건문이 자동 대체 금지의 실체고요. 여기에 ?? DEFAULT를 한 번 쓰는 순간, 조립이 실패해도 화면이 그럴듯하게 뜨기 시작합니다.
조립이 실패하면 어떻게 보이나
자동 대체를 금지했다고 했는데, 그럼 실제로 뭐가 뜨는지 궁금하실 것 같아요.
| 상황 | 금지한 동작 | 실제 동작 |
|---|---|---|
| 기여가 누락됨 | 빈 배열로 진행 | 명시적 에러 상태 |
| 버전이 안 맞음 | 가장 가까운 버전 사용 | 조립 거부 |
| 표면이 없음 | 조용히 무시 | 빌드 실패 |
| 권한이 없음 | 기본 화면 | 접근 거부 표시 |
두 번째 줄이 특히 중요했어요. "가장 가까운 버전"은 편의 같지만, 그 순간 무엇이 실제로 배포됐는지 아무도 모르게 됩니다.
세 번째는 빌드 시점에 잡혀요. 꽂을 자리가 없는데 꽂으려 하면 애초에 빌드가 안 됩니다.
누락은 버그고, 버그는 시끄러워야 해요. 조용한 fallback은 버그를 기능처럼 보이게 만듭니다.
지키는 원칙 둘
enum을 string으로 바꾸는 건 중앙화 제거가 아니다
이 전환에서 가장 조심한 함정이에요. 중앙 enum을 지우고 if (productId === 'a')로 바꾸면 겉보기엔 중앙 의존이 사라진 것 같지만, 사실 타입 안전성만 잃고 중앙 지식은 그대로거든요. 그래서 게이트가 중앙 enum뿐 아니라 제품 식별자 raw string 비교 자체를 탐지해요. raw string은 전송 경계(transport boundary)에서만 허용되고, 경계 안쪽에서는 adapter가 usage별 Definition Registry와 exact join으로만 해석합니다.
자동 fallback 금지
조립에서 뭔가 누락됐을 때 조용히 기본값으로 대체하는 코드를 전부 금지했어요. 누락된 assembly는 명시적 에러 상태로 표면화됩니다. fallback은 당장의 화면은 살리지만, 잘못된 조립이 프로덕션까지 조용히 흘러가게 만들거든요. 누락은 버그고, 버그는 시끄러워야 해요.
enum을 string으로 바꾸는 함정도 코드로 보면 명확해요.
// 전 — 중앙 enum
switch (product) {
case Product.A: return aRoutes;
case Product.B: return bRoutes;
}
// "중앙화를 제거했다"고 착각하기 쉬운 변경if (productId === "product-a") return aRoutes;
if (productId === "product-b") return bRoutes;
// 타입 안전성만 잃었고, 중앙 지식은 그대로 남아 있습니다
// 실제 제거return assemble("app.routes", manifests);
// 제품 이름이 등장하지 않습니다| 판정 기준 | 통과 | 불통과 |
|---|---|---|
| 제품 이름이 코어에 있나 | 없음 | 있음 (enum이든 문자열이든) |
| 새 제품 추가 시 코어를 고치나 | 안 고침 | 고침 |
| 제품 하나를 지우면 | 그 기여만 사라짐 | 분기가 남아 죽은 코드가 됨 |
두 번째 줄이 제일 간단한 시험이에요. 새 제품을 추가할 때 코어 패키지에 커밋이 생기면, 중앙화는 아직 안 없어진 겁니다. 이름만 바뀐 거죠.
강제 — 문서가 아니라 게이트
반복되는 컨벤션과 경계 규칙은 검사로 옮겼어요. 확인 가능한 예로 TypeScript AST 기반 컴포넌트 props 검사가 있습니다. flat props와 참조 타입을 살피고 정상·위반·예외 사례를 테스트하며, 위반이 남으면 PR 정적 검사가 실패하도록 연결했습니다.
이전 글의 ‘50여 개 규칙’과 검사 코드 줄 수는 근거를 다시 확인하지 못해 제외했어요. 자동 검사가 선언된 규칙을 확인하는 것과 설계·화면 전체의 타당성을 보장하는 것은 구분합니다.
(게이트는 폐기된 아키텍처 문서가 레포에 남아 있는지, 권위 문서에 필수 문장이 있는지까지 검사해요. 집요하죠. 1편의 "문서의 공식 사망 선고"가 여기서 기계 검증으로 이어집니다.)
그리고 이 규칙들을 검사하는 게이트가 실제로 뭘 보는지 적어 둘게요.
| 규칙 | 검사 방법 |
|---|---|
| 코어에 도메인 이름 금지 | AST 에서 식별자·문자열 리터럴 스캔 |
| 키 문자열 조합 금지 | 템플릿 리터럴이 키 위치에 오는지 검사 |
| 자동 fallback 금지 | 조립 결과에 ?? · || 기본값 패턴 검사 |
| 폐기 문서 잔존 금지 | 아카이브 표시가 없는 옛 설계 문서 탐지 |
| 권위 문서 필수 문장 | 단일 권위 선언이 살아 있는지 확인 |
마지막 두 줄이 좀 유별나 보이죠. 문서까지 검사합니다. 폐기된 설계 문서가 표시 없이 남아 있으면 게이트가 실패해요.
AI와 같이 개발하면서 이게 필요해졌어요. 사람은 "아 이거 옛날 거네" 하고 넘기지만, 문서를 읽는 도구는 그 감을 못 씁니다. 폐기 표시가 없으면 폐기된 설계가 그대로 다음 제안으로 돌아와요.
치른 값
| 불편해진 것 | 실제로 겪은 일 |
|---|---|
| 새 기여를 붙이는 절차가 김 | 선언 → 생성 → 검증 세 단계를 거침 |
| 디버깅 경로가 길어짐 | 화면에 없을 때 조립 결과부터 봐야 함 |
| 게이트 자체를 유지해야 함 | 규칙 코드에도 테스트가 필요 |
| 타입 생성물을 커밋 | 대신 낡지 않도록 검사로 강제 |
두 번째 줄이 초반에 제일 아팠어요. 예전에는 중앙 파일 하나만 열면 전부 보였는데, 이제는 "무엇이 조립됐는가"를 따로 확인해야 하거든요.
그래서 조립 결과를 볼 수 있는 경로를 따로 만들었습니다. 구조를 숨긴 게 아니라 다른 곳으로 옮긴 거라, 그 다른 곳을 볼 방법이 없으면 안 되니까요.
(추상화를 만들 때는 그 추상화를 들여다보는 창도 같이 만들어야 해요. 안 그러면 디버깅이 고고학이 됩니다.)
호환성과 복구에도 조건을 뒀어요
기능별 Manifest에는 Host·Core의 허용 버전 범위를 선언했어요. 범위를 벗어나면 해당 페이지에 오류 toast나 오류 페이지로 안내합니다. 모든 기능의 버전을 같은 숫자로 맞추는 규칙과는 달라요.
digest별 과거 파일을 보존하고 Manifest의 참조를 이전 파일로 돌려 복구할 수 있게 했습니다. 과거 파일이 남아 있고 접근 가능해야 하며, 데이터 마이그레이션까지 되돌리는 의미는 아니에요. 이 흐름은 로컬·개발 서버에서 직접 확인했습니다.
결과
향후에는 하나의 전체 배포 산출물에서 서브도메인별 Manifest로 고객사 구성을 관리하려고 했어요. SaaS 확장을 고려한 설계 목적이며, 실제 SaaS 운영이나 테넌트 보안 격리를 검증한 성과는 아닙니다.
신규 고객사 확장을 중앙 라우트 코드 수정 없이 확장 지점에서 처리했어요. 새 기능 코드를 만드는 일과 기존 기능의 설정을 바꾸는 일을 같은 의미로 설명하지 않으려 합니다. 릴리스는 재빌드 없이 Artifact 조합으로 만들어지고(CI/CD 편에서 상세), 사이드바 같은 Shell UI도 조립 결과만 소비합니다(사이드바 편에서 상세).
아키텍처의 핵심은 결국 "무엇을 할 수 있는가"가 아니라 "무엇을 못 하게 만들었는가"였어요.
다음 편은 이 원칙이 디자인까지 번진 이야기예요. 고객사가 브랜딩을 통째로 바꿔도 빌드는 없습니다 — 토큰 3계층으로요.

