아티클
다중 레이아웃 및 다중 테마 아키텍처 설계 패턴
하나의 프런트엔드가 100개가 넘는 테마와 여러 레이아웃을 지원하면서, 커스텀 로직이 공용 컴포넌트에 스며들지 않게 하려면 어떻게 나눠야 할까요? 이 글에서는 core / view / theme leaf의 계층 구조, 타입이 지정된 설정, 테마 패밀리의 완전성을 확인하는 테스트, 테마 CSS의 온디맨드 로딩과 각 계층의 장단점을 살펴봅니다.
· 업데이트 · 10분 분량
저는 한때 대규모 Nx / Angular 프런트엔드 플랫폼의 아키텍처를 재정비하는 일을 맡았습니다. 이 플랫폼은 100개가 넘는 테마와 여러 레이아웃을 지원해야 했습니다. 다른 팀원들이 각자 익숙한 개발 방식으로 다중 레이아웃·다중 테마 하위 작업을 처리하는 일이 잦았고, 이런 방식이 오랫동안 쌓이면서 기술 부채가 됐습니다. 같은 요구사항을 구현하는 로직이 여러 가지로 존재하게 된 거죠.
이 글에서는 당시의 핵심 설계를 설명합니다. 계층을 어떻게 나눴는지, 100개가 넘는 테마의 완전성을 어떻게 유지했는지, 브라우저가 필요한 CSS만 다운로드하게 한 방법을 다룹니다. 원래 production 코드는 공개할 수 없어, 독창적인 코드와 가상의 테마 세 가지로 다시 구현해 frontend-engineering-case-studies에 올렸습니다. 직접 조작해 볼 수 있는 온라인 Demo도 있습니다. 글에 나온 코드는 모두 이 공개 버전에서 가져왔습니다.
먼저 결론부터 말하면, ‘어떻게 보이는지’와 ‘무엇을 하는지’를 분리한 뒤, 컴파일러와 테스트가 100개가 넘는 테마의 일관성을 지키게 하는 것입니다.
문제는 색상 변경이 아니다#
여러 테마를 지원할 때 가장 흔한 시작점은 공용 컴포넌트에 조건을 추가하는 것입니다. 예를 들어 A 테마에는 버튼을 하나 더 넣고, B 테마는 이벤트 기간에 다른 페이지로 이동하게 합니다:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
테마가 세 개뿐일 때는 이렇게 작성해도 빠릅니다. 하지만 테마가 100개가 되면 모든 공용 컴포넌트가 ‘특정 테마에서만 성립하는’ 분기를 잔뜩 떠안게 됩니다. 그 결과는 다음과 같습니다.
- A 테마의 요구사항을 변경하면 B 테마가 조용히 망가질 수 있지만, B를 확인할 담당자는 없습니다.
- 같은 차이를 컴포넌트마다 한 번씩 구현하고, 구현 방식도 서로 다릅니다.
- 테마를 새로 추가할 때 ‘필요한 곳을 모두 채웠는지’를 누구도 확신할 수 없습니다.
따라서 해결할 문제는 색상 변경만이 아닙니다. 각 테마가 외관과 제품 기능에서 차이를 가질 수 있게 하면서도, 테마 패밀리 전체의 완전성과 예측 가능성을 유지해야 합니다.
먼저 구분하기: 레이아웃과 테마는 별개#
집에 비유하면 이해하기 쉽습니다.
- 구조와 배관: 기둥과 전기·수도 시설입니다. 모든 집에 동일하고, 거주자의 취향에 따라 바꿔서도 안 됩니다.
- 레이아웃: 방의 구조입니다. 방이 두 개인지 세 개인지, 거실이 어디에 있는지처럼 ‘요소를 어떻게 배치할지’를 결정합니다.
- 테마: 인테리어입니다. 벽 색상, 소재, 모서리의 둥근 정도처럼 ‘요소가 어떻게 보일지’를 결정합니다.
프런트엔드에서도 이 세 가지는 각각 다른 계층에 대응합니다. 한데 섞이면 ‘색 하나 바꾸려고 데이터 흐름까지 수정하는’ 상황이 생깁니다.
4계층 구조#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
아래에서 위로 살펴보겠습니다.
core: 렌더링만 담당#
core 컴포넌트는 ‘준비가 끝나 바로 렌더링할 수 있는’ 데이터만 받고, 사용자의 의도(intent)만 외부로 전달합니다. 현재 어떤 테마인지 알지 못하고, routing이나 API 응답 형식도 다루지 않으며, 브랜드 에셋도 참조하지 않습니다.
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
모든 테마가 core를 공유하므로 영향 범위가 가장 큽니다. 그래서 규칙도 가장 엄격합니다. 필요한 것만 입력받고, 의도만 출력합니다.
view: 조정 담당#
view는 중간 역할을 합니다. data service에서 데이터를 가져와 core가 사용할 형태로 변환하고, core가 의도를 전달하면 actions facade에 처리를 맡깁니다.
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
데이터가 어디서 오는지(data service), 클릭하면 무엇을 할지(actions facade) 각각 명확한 경계가 있어 화면 컴포넌트에 흩어지지 않습니다.
레이아웃은 이 계층에 두는 것이 적합합니다. 서로 다른 구조는 각기 다른 shared view로 만들 수 있습니다. 같은 data service와 facade를 사용하고, core를 조합하는 배치만 달라집니다. 공개 Demo는 단순화를 위해 shared view 하나만 제공하지만, production 재정비 때는 26개를 도입했습니다.
theme leaf: 테마마다 하나의 리프 노드#
각 테마는 아주 얇은 컴포넌트 하나로 표현하며, 완전한 외관 설정을 선택해 shared view에 전달하는 일만 합니다.
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
여기에는 비즈니스 조건이 전혀 없습니다. production에서 대표적인 theme leaf 하나를 완전히 재구성한 결과 23줄이 됐습니다. 같은 경로의 이전 구현은 수백 줄이었습니다.
selector: 패밀리 구성원을 명시적으로 나열#
selector는 runtime 테마 id에 따라 해당 leaf를 렌더링합니다.
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
코드는 평범해 보이지만 핵심은 ‘명시적’이라는 점입니다. 테마가 무엇인지, 각 테마가 어떤 leaf에 연결되는지 한눈에 볼 수 있고, 테스트로 하나씩 확인할 수 있습니다.
컴파일러가 누락된 항목을 알려주게 하기#
테마가 많아질수록 가장 걱정되는 건 빠뜨리는 일입니다. 새 테마를 추가할 때 설정 추가를 잊거나, 설정 형식을 바꿨는데 일부 기존 테마가 따라오지 못할 수 있습니다.
먼저 테마 목록을 단일 진실 공급원으로 작성한 다음, TypeScript의 satisfies을 사용해 모든 테마에 완전한 설정이 있는지 확인합니다.
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
DEMO_THEMES에 테마를 추가하고 THEME_APPEARANCES을 빠뜨리면 컴파일이 실패합니다. 필드 이름을 잘못 쓰거나 CSS 경로 형식이 틀려도 마찬가지입니다.
satisfies를 직접 타입으로 지정하는 방법보다 나은 점은 형태를 검사하면서도 각 값의 구체적인 타입을 유지한다는 것입니다. 그래서 나중에 THEME_APPEARANCES.aurora를 사용할 때 일반적인 ThemeAppearance로 타입이 넓어지지 않습니다.
테스트로 테마 패밀리 전체를 지키기#
컴파일러는 설정을 검사할 수 있지만, ‘이 테마가 실제로 렌더링되는지’까지 확인하지는 못합니다. 이 부분은 테마 패밀리 완전성 테스트에서 선언된 테마를 하나씩 열거해 확인합니다.
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
설정에도 대응하는 테스트가 있습니다. 각 테마 설정의 id 필드는 설정표의 해당 key와 일치해야 하며, CSS 경로도 패밀리 규칙을 따라야 합니다.
이 두 검사를 합치면 ‘sibling 테마를 빠뜨리지 않았는지’를 사람이 review하던 작업을 CI가 막아내는 오류로 바꿀 수 있습니다. 테마가 100개가 넘으면 사람이 전부 확인할 수 없습니다.
현재 사용하는 테마의 CSS만 다운로드하기#
계층화는 유지보수 문제를 해결하지만, 다중 테마에는 사용자에게 직접 영향을 주는 비용이 하나 더 있습니다. 바로 CSS입니다.
기존 방식은 136개 테마의 stylesheet를 모두 한데 묶어 패키징했습니다. 사용자는 어떤 테마를 쓰든 전부 다운로드하고 파싱해야 했습니다. 온디맨드 로딩으로 바꾸자 단일 테마의 리소스가 약 2.4 MB에서 17 KB로 줄어 약 99% 감소했습니다.
구현 자체는 간단합니다. 각 테마 설정에서 자체 stylesheetHref을 선언하고, 단 하나뿐인 loader가 페이지에 적용되는 테마 <link> 하나를 관리합니다.
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
플랫폼은 테마를 전환할 때 이를 호출합니다.
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
‘단 하나뿐’이라는 점이 중요합니다. 곳곳에서 각자 <link>를 추가하면 테마를 반복해서 전환한 뒤 이전 테마의 스타일이 남거나 CSS 두 개가 서로 덮어쓸 수 있습니다. 테스트에서는 여러 번 전환한 뒤에도 페이지에 관리 대상 stylesheet가 하나만 남는지 확인합니다.
테마 CSS에는 design token(CSS 변수)만 정의하고, core 스타일은 이 변수만 읽습니다.
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
따라서 테마를 바꿀 때는 변수표만 교체하면 되고 core 스타일은 전혀 바꿀 필요가 없습니다. 밝은 모드와 어두운 모드는 같은 파일 안의 다른 변수 세트입니다.
트레이드오프#
이 설계에 비용이 없는 것은 아닙니다.
- 파일 수가 늘어납니다. 테마마다 leaf 하나, 레이아웃마다 view 하나를 두므로 작은 파일이 많아집니다. 대신 각 파일의 책임을 한눈에 알 수 있고 완전성을 검사할 수 있습니다.
- 설정이 프로그래밍 언어가 되어서는 안 됩니다. config는 외관과 경계가 분명한 차이를 표현하는 데 적합합니다. 설정에 조건이나 흐름을 쓰기 시작하면 기존 if/else를 다른 곳에 숨기는 것뿐입니다.
- core에는 더 강한 계약 테스트가 필요합니다. 모든 테마가 공유하므로 한 번의 변경이 100개가 넘는 테마에 영향을 줍니다.
- runtime에서 CSS를 전환할 때는 단일 owner가 필요합니다. 조정되지 않은 stylesheet 요청은 깜박임이나 스타일 잔류를 일으킬 수 있습니다. 이것이 loader가 하나뿐이어야 하는 이유이기도 합니다.
- 캐시는 여전히 중요합니다. 테마를 하나만 로드하더라도 안정적인 파일명과 cache header에 따라 재방문 속도가 달라집니다.
이런 설계가 필요한 경우#
제품에 밝은 모드와 어두운 모드만 있다면 CSS 변수만으로 충분하며, 이 전체 구조는 필요하지 않습니다.
다음 상황이 나타나기 시작하면 고려해 볼 만합니다.
- 테마나 고객별 버전이 10개를 넘고 계속 늘어납니다.
- 테마 간 차이가 색상뿐 아니라 레이아웃, 기능 토글, 문구에도 있습니다.
- 한 테마의 요구사항을 변경하면 다른 테마에 의도치 않은 영향이 자주 생깁니다.
- 새 테마를 추가할 때 어디를 보완해야 하는지 누구도 확신하지 못합니다.
근거의 범위#
이 글에서 언급한 production 수치가 어디까지를 가리키는지 분명히 하겠습니다.
- 23줄은 재구성 후 대표적인 완전형 theme leaf 하나를 가리킵니다. 모든 leaf와 파일이 23줄이라는 뜻은 아닙니다.
- 해당 migration에서는 새 아키텍처 모듈 70개를 도입했습니다(facade 24개, data service 10개, theme config 10개, shared view 26개). 이는 변경 범위이지, 성능 향상이나 코드 감소의 결과가 아닙니다.
- 2.4 MB → 17 KB는 production에서 측정한 수치입니다. 공개 Demo의 작은 CSS 세 파일로는 같은 수치를 재현할 수 없습니다.
- 같은 품질 프로젝트에서 핵심 측정 페이지의 Lighthouse Performance 점수는 50+에서 90+로 올랐습니다. 다만 여러 개선이 합쳐진 결과이지 CSS 온디맨드 로딩만으로 달성한 것은 아닙니다.
공개 repo가 입증하는 것은 아키텍처 메커니즘 자체입니다. 타입이 지정된 계약, runtime 전환, 테마 패밀리 완전성 테스트, 온디맨드 CSS, 그리고 이러한 검사를 실행하는 CI가 여기에 포함됩니다. 전체 주장 대조표는 repo의 근거 범위 감사에서 확인할 수 있습니다.
직접 확인하고 싶다면 온라인 Demo에서 세 가지 테마와 언어, 밝고 어두운 모드를 전환해 보고 GitHub의 소스 코드와 비교해 보세요.