전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

중앙 조립 코드를 전부 걷어내다 — 런타임 기여 플랫폼 아키텍처

어느 레포에나 있는 그 중앙 config 파일, 저희는 금지했어요. 각 패키지가 기여를 게시하고 중앙엔 조립 결과만 남는 아키텍처 — 그리고 그걸 게이트로 강제한 이야기예요.

2026. 07. 14. 17:51:398분 읽기프로젝트/데이터 플랫폼
#아키텍처#모노레포#마이크로프론트엔드
조회 37
중앙 컨베이어를 해체하고 부품이 스스로 조립되는 일러스트

어느 레포에나 하나쯤 있는 파일이 있죠. 제품 라우트를 전부 열거한 중앙 config요.

// 어디에나 있는 그 파일
const PRODUCT_ROUTES = {
  [Product.A]: aRoutes,
  [Product.B]: bRoutes,
  // 제품이 늘 때마다 여기를 고친다
};

(네, 그 파일이요.)

이 파일의 문제는 기술이 아니라 심리예요. 새 기능을 붙일 때마다 모두가 이 파일을 열어야 하고, 연 사람은 누구든 조금씩 망칩니다. 저도 망쳐 봤고요.

그래서 저희는 이 파일을 금지했어요. "재빌드 없는 플랫폼" 시리즈의 첫 편은, 중앙 코드가 아무것도 모르게 만든 이야기입니다. 이 집착이 어디까지 갔는지 보여드릴게요.

문제 — 제품이 늘 때마다 중앙 파일이 자란다

제품 6종 × 고객사 8곳 규모가 되면 이 패턴은 병목이 돼요. 새 제품 추가가 중앙 파일 수정을 요구하고, 중앙 파일은 모든 제품을 알아야 하니 모든 패키지에 의존하고, 결국 무엇 하나 독립적으로 배포할 수 없게 됩니다.

런타임 기여 플랫폼 — Contribution에서 read-only Registry까지 단방향 조립 흐름과 금지 목록
Contribution에서 읽기 전용 Registry까지, 단방향 조립 흐름.

먼저 시도했다 접은 것들

중앙 파일을 없애는 길이 하나만 있는 게 아니에요. 순서대로 시도했다가 하나씩 접었습니다.

시도무엇을 해결했나왜 접었나
동적 import 로 감싸기번들은 갈라짐중앙 파일이 여전히 제품 목록을 앎
제품이 시작 시 스스로 등록중앙 파일이 사라짐언제 등록되는지가 로드 순서에 달림
런타임에 파일을 훑어 수집수동 목록 불필요무엇이 언제 로드되는지 빌드 시점에 알 수 없음
빌드가 목록을 생성순서·시점이 결정적생성기와 계약을 만들어야 함

두 번째가 제일 오래 붙잡고 있었어요. 겉보기엔 완벽하거든요. 그런데 등록이 부수 효과라서, 어떤 제품이 등록됐는지가 import 순서에 따라 달라집니다. 그러면 같은 코드가 환경마다 다르게 동작해요.

세 번째는 편한데 위험합니다. 파일을 훑는 순간 "무엇이 들어 있는지"가 빌드 결과를 봐야만 알 수 있게 되거든요. 정적 분석도 안 되고, 안 쓰는 것도 딸려 들어옵니다.

그래서 네 번째로 갔어요. 훑기는 빌드 단계에서만 하고, 결과는 생성된 목록으로 고정합니다. 런타임은 그 목록만 봐요.

구조 — 기여, 조립, 스냅샷

흐름을 뒤집었어요

각 제품 패키지가 자기 Route·사이드바 메뉴·테마를 Contribution으로 선언하면, 그게 불변 Artifact(SemVer + SHA-256 digest)로 게시되고, Release가 exact 버전으로 조립해요. 런타임에서는 Contribution Manager가 유일한 writer로 assembly를 합성하고, UI 컴포넌트는 읽기 전용 Registry snapshot만 소비합니다. 중앙에는 제품 목록이 존재하지 않아요. 조립 결과만 존재합니다.

제품 패키지가 기여를 선언하는 모양이 이래요.

// packages/product-a/contribution/routes.entry.ts
export 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이든 문자열이든)
새 제품 추가 시 코어를 고치나안 고침고침
제품 하나를 지우면그 기여만 사라짐분기가 남아 죽은 코드가 됨

두 번째 줄이 제일 간단한 시험이에요. 새 제품을 추가할 때 코어 패키지에 커밋이 생기면, 중앙화는 아직 안 없어진 겁니다. 이름만 바뀐 거죠.

강제 — 문서가 아니라 게이트

이 모든 규칙은 마크다운 문서가 아니라 CI에서 강제돼요. TypeScript Compiler API로 전체 소스 AST를 파싱하는 2,700줄 규모의 커스텀 게이트가 규칙 코드 50여 개 — 금지된 레이어 의존 방향, 다른 패키지 내부 구현 직접 import, 중앙 enum/switch/Record, 레거시 패턴 식별자, 전역 singleton, 자동 fallback — 를 검사하고 위반 시 빌드를 실패시킵니다. 게이트 스크립트 자체에도 1,400줄 넘는 테스트가 붙어요. 검사기가 틀리면 규칙 전체가 무너지니까요.

(게이트는 폐기된 아키텍처 문서가 레포에 남아 있는지, 권위 문서에 필수 문장이 있는지까지 검사해요. 집요하죠. 1편의 "문서의 공식 사망 선고"가 여기서 기계 검증으로 이어집니다.)

그리고 이 규칙들을 검사하는 게이트가 실제로 뭘 보는지 적어 둘게요.

규칙검사 방법
코어에 도메인 이름 금지AST 에서 식별자·문자열 리터럴 스캔
키 문자열 조합 금지템플릿 리터럴이 키 위치에 오는지 검사
자동 fallback 금지조립 결과에 `??` · `||` 기본값 패턴 검사
폐기 문서 잔존 금지아카이브 표시가 없는 옛 설계 문서 탐지
권위 문서 필수 문장단일 권위 선언이 살아 있는지 확인

마지막 두 줄이 좀 유별나 보이죠. 문서까지 검사합니다. 폐기된 설계 문서가 표시 없이 남아 있으면 게이트가 실패해요.

AI와 같이 개발하면서 이게 필요해졌어요. 사람은 "아 이거 옛날 거네" 하고 넘기지만, 문서를 읽는 도구는 그 감을 못 씁니다. 폐기 표시가 없으면 폐기된 설계가 그대로 다음 제안으로 돌아와요.

치른 값

불편해진 것실제로 겪은 일
새 기여를 붙이는 절차가 김선언 → 생성 → 검증 세 단계를 거침
디버깅 경로가 길어짐화면에 없을 때 조립 결과부터 봐야 함
게이트 자체를 유지해야 함규칙 코드에도 테스트가 필요
타입 생성물을 커밋대신 낡지 않도록 검사로 강제

두 번째 줄이 초반에 제일 아팠어요. 예전에는 중앙 파일 하나만 열면 전부 보였는데, 이제는 "무엇이 조립됐는가"를 따로 확인해야 하거든요.

그래서 조립 결과를 볼 수 있는 경로를 따로 만들었습니다. 구조를 숨긴 게 아니라 다른 곳으로 옮긴 거라, 그 다른 곳을 볼 방법이 없으면 안 되니까요.

(추상화를 만들 때는 그 추상화를 들여다보는 창도 같이 만들어야 해요. 안 그러면 디버깅이 고고학이 됩니다.)

결과

이제 새 제품 모듈 추가는 새 패키지 하나를 만들고 Contribution을 선언하는 일이에요. 중앙 파일 수정은 없어요. 릴리스는 재빌드 없이 Artifact 조합으로 만들어지고(CI/CD 편에서 상세), 사이드바 같은 Shell UI도 조립 결과만 소비합니다(사이드바 편에서 상세).

아키텍처의 핵심은 결국 "무엇을 할 수 있는가"가 아니라 "무엇을 못 하게 만들었는가"였어요.

다음 편은 이 원칙이 디자인까지 번진 이야기예요. 고객사가 브랜딩을 통째로 바꿔도 빌드는 없습니다 — 토큰 3계층으로요.

시리즈 이어읽기

재빌드 없는 플랫폼

1 / 5

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.