아일랜드 아키텍처 × 마이크로 프론트엔드: 팀이 늘수록 번들이 줄어드는 구조 (Island Architecture × Micro Frontend)
마이크로 프론트엔드를 도입한 뒤 오히려 성능 지표가 나빠지는 상황, 생각보다 흔합니다. 저도 직접 겪었는데요 — 팀이 3개에서 6개로 늘어나던 시점에 초기 JS 번들이 2.8배로 불어났고, 브라우저 네트워크 탭을 열어보니 React가 세 번 로드되고 있었습니다. 팀 독립성은 얻었는데 TTI(Time to Interactive)는 되려 늘어났고, "이게 맞나?" 싶었죠.
아일랜드 아키텍처는 이 역설을 정면으로 다루는 접근입니다. 페이지 대부분은 정적 HTML로 두고, 쇼핑카트·알림 벨·실시간 재고 같이 실제로 인터랙션이 필요한 "섬(island)"에만 선택적으로 JavaScript를 붙입니다. 마이크로 프론트엔드의 팀 자율성에 아일랜드의 선택적 하이드레이션을 더하면, 팀 수가 늘어도 사용자에게 내려가는 JavaScript가 늘지 않는 구조를 만들 수 있습니다.
이 글에서는 두 패턴이 어떻게 맞물리는지, Astro + Module Federation 조합으로 어떻게 구현하는지, 그리고 솔직히 어떤 함정이 있는지까지 짚어보겠습니다. 개념부터 실제 코드, 실무 에피소드까지 순서대로 살펴보시면 됩니다.
핵심 개념
아일랜드 아키텍처 — "정적이 기본, JS는 필요한 곳만"
2020년 Katie Sylor-Miller가 처음 제안하고 Jason Miller가 대중화한 이 패턴은 철학이 단순합니다. SSR로 HTML을 빠르게 내보내되, 클라이언트에 전달하는 JavaScript는 최소화하자는 거죠. 전통적인 SSR/CSR 앱이 페이지 전체를 하이드레이션하는 반면, 아일랜드 아키텍처는 특정 컴포넌트만 선택적으로 처리합니다.
하이드레이션(Hydration): 서버에서 생성한 정적 HTML에 이벤트 리스너와 상태를 붙여 React·Vue 같은 프레임워크가 제어할 수 있게 만드는 과정. "정적인 뼈대에 근육을 붙이는 것"이라고 생각하면 이해가 빠릅니다.
Astro가 이 패턴에서 사실상 표준처럼 쓰이는 데는 이유가 있는데요. client:idle, client:visible 같은 디렉티브로 "언제 하이드레이션할지"를 컴포넌트마다 다르게 지정할 수 있고, React·Vue·Svelte를 같은 페이지에서 혼용할 수 있습니다.
---
import SearchBar from './SearchBar.jsx' // React 컴포넌트
import CartBadge from './CartBadge.vue' // Vue 컴포넌트
import StaticNav from './StaticNav.astro' // JS 없음
---
<StaticNav /> <!-- 정적 HTML만, JS 0바이트 -->
<SearchBar client:idle /> <!-- 브라우저 유휴 시점에 하이드레이션 -->
<CartBadge client:visible /> <!-- 뷰포트 진입 시 하이드레이션 -->렌더링 후 HTML 출력을 보면 <SearchBar>는 <astro-island> 커스텀 엘리먼트로 감싸지고, data-astro-cid·data-component-url 같은 메타데이터가 붙습니다. Astro의 런타임이 이 메타데이터를 보고 적절한 시점에 하이드레이션을 실행하는 구조입니다.
마이크로 프론트엔드 — 백엔드 MSA를 프론트엔드에 적용하면
마이크로서비스가 백엔드를 독립 서비스로 쪼갠 것처럼, 마이크로 프론트엔드는 대형 웹 앱을 팀별로 독립 개발·배포 가능한 단위로 분리합니다.
| 가치 | 설명 |
|---|---|
| 팀 자율성 | 결제 팀은 React, 추천 팀은 Svelte — 각자 원하는 스택 사용 가능 |
| 독립 배포 | A팀 배포가 B팀 릴리스에 영향을 주지 않음 |
| 명확한 오너십 | 버그가 생기면 어느 팀 코드인지 즉시 특정 가능 |
백엔드 MSA를 경험한 분이라면 익숙한 개념인데, 프론트엔드에 적용할 때 가장 큰 차이점은 "번들링"입니다. 서비스끼리 HTTP로 통신하는 MSA와 달리, 마이크로 프론트엔드는 결국 같은 브라우저 탭에서 실행되기 때문에 코드가 합쳐지는 방식이 중요합니다. Module Federation이 이 역할을 담당합니다 — 런타임에 원격 모듈을 동적으로 로딩해서 별도 팀이 빌드한 컴포넌트를 한 페이지에 합칩니다.
두 패턴이 만나면 생기는 시너지
솔직히 처음엔 "그냥 두 개를 억지로 붙인 거 아닌가?" 싶었는데, 실제로 보면 서로 다른 문제를 보완적으로 해결합니다.
| 패턴 | 팀 독립성 | 번들 크기 제어 | 빠른 TTI |
|---|---|---|---|
| 마이크로 프론트엔드만 | 달성 | 어려움 (중복 위험) | 어려움 |
| 아일랜드 아키텍처만 | 제한적 | 달성 | 달성 |
| 두 패턴 결합 | 달성 | 달성 | 달성 |
아일랜드 경계가 팀 경계와 맞아떨어지면, 각 팀이 자기 아일랜드만 책임지고, 사용자는 해당 아일랜드가 실제로 필요해지는 시점에만 그 팀의 코드를 내려받습니다. INP(Interaction to Next Paint) 개선 가능성도 여기서 옵니다 — 다만 실제 수치는 아일랜드 내부 구현과 네트워크 조건에 따라 크게 달라지므로, "이 구조를 쓰면 자동으로 빨라진다"기보다 "성능 저하 없이 팀을 확장할 수 있는 구조적 기반을 만든다"고 이해하는 게 더 정확합니다.
INP(Interaction to Next Paint): Core Web Vitals의 응답성 지표. 사용자 입력에 대해 다음 화면 업데이트까지 걸리는 시간. 200ms 이하 양호, 200~500ms 개선 필요, 500ms 초과 불량.
실전 적용
예시 1: Astro + Module Federation으로 React 팀 컴포넌트를 아일랜드로 통합하기
가장 현실적인 시나리오입니다. Astro를 셸(shell) 앱으로 쓰고, React 팀이 Module Federation으로 컴포넌트를 노출하면, Astro 셸이 런타임에 불러와서 아일랜드로 마운트합니다.
React 팀 — Cart 앱을 원격 모듈로 노출:
// cart-app/vite.config.ts (React 팀)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@module-federation/vite'
export default defineConfig({
plugins: [
react(),
federation({
name: 'cartApp',
filename: 'remoteEntry.js',
exposes: {
'./Cart': './src/components/Cart.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '>=18' },
'react-dom': { singleton: true, requiredVersion: '>=18' },
},
}),
],
build: { target: 'esnext' },
})Astro 셸 — 원격 모듈을 클라이언트 래퍼로 감싸서 아일랜드로 마운트:
Module Federation의 원격 모듈 로딩은 브라우저 런타임에서 일어납니다. Astro의 프론트매터(---)는 빌드 또는 SSR 시점에 실행되므로, 원격 모듈을 그곳에서 직접 await하면 실제로 동작하지 않을 수 있습니다. 대신 클라이언트 전용 래퍼 컴포넌트를 두고, Module Federation이 런타임에 동적 임포트를 가로채는 방식을 활용합니다.
// shell/src/components/CartWrapper.tsx (셸 팀)
import React, { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
// Module Federation이 런타임에 동적 임포트를 가로채서 원격 모듈을 반환합니다
const Cart = React.lazy(() =>
// @ts-ignore — MF가 런타임에 주입하는 경로
import('cartApp/Cart').catch(() => ({
default: () => <p>장바구니를 일시적으로 불러올 수 없습니다</p>,
}))
)
export default function CartWrapper() {
return (
<ErrorBoundary fallback={<p>장바구니 서버에 문제가 생겼습니다</p>}>
<Suspense fallback={<p>로딩 중...</p>}>
<Cart />
</Suspense>
</ErrorBoundary>
)
}---
// shell/src/pages/index.astro
import StaticHeader from '../components/StaticHeader.astro'
import StaticFooter from '../components/StaticFooter.astro'
import CartWrapper from '../components/CartWrapper.tsx'
import RecommendWrapper from '../components/RecommendWrapper.tsx' // Vue 팀도 동일 패턴
---
<html>
<body>
<StaticHeader /> <!-- JS 없음, 즉시 렌더링 -->
<!-- 뷰포트 진입 시 하이드레이션: 스크롤 전엔 JS 로드 없음 -->
<RecommendWrapper client:visible />
<!-- 브라우저 유휴 시점에 하이드레이션: LCP 보호 -->
<CartWrapper client:idle />
<StaticFooter /> <!-- JS 없음 -->
</body>
</html>| 코드 포인트 | 의미 |
|---|---|
singleton: true |
React를 한 번만 로드. 이게 빠지면 React 인스턴스가 두 개 떠서 훅이 터짐 |
requiredVersion: '>=18' |
버전 협상 범위 지정. 완전 고정보다 유연하게 운영 가능 |
ErrorBoundary |
원격 MFE 서버가 다운되어도 전체 페이지가 깨지지 않도록 격리 |
client:idle |
메인 스레드 여유 시점까지 하이드레이션 지연 → LCP 보호 |
client:visible |
스크롤 전엔 JS 로드 자체를 안 함 → 초기 번들 절감 |
예시 2: Astro Server Islands로 분산 SSR 마이크로 프론트엔드
팀마다 독립 서버를 운영하고, 메인 페이지가 서버 아일랜드로 각 팀 컴포넌트를 비동기 병렬 렌더링하는 방식입니다. ML팀이 추천 엔진 서버를 따로 운영하면서 메인 페이지 성능에 영향을 주지 않는 구조로 쓸 수 있습니다.
---
// shell/src/pages/product/[id].astro
import ProductInfo from '../components/ProductInfo.astro'
import RecommendIsland from '../components/RecommendIsland.astro'
import ReviewIsland from '../components/ReviewIsland.astro'
const { id } = Astro.params
---
<article>
<!-- 제품 기본 정보: 정적 SSR, CDN 캐시 가능 -->
<ProductInfo productId={id} />
<!-- ML팀 추천 서버에서 병렬 렌더링 — 지연돼도 나머지 페이지를 블로킹하지 않음 -->
<RecommendIsland server:defer userId={Astro.locals.userId} />
<!-- UGC팀 리뷰 서버, 마찬가지로 병렬 처리 -->
<ReviewIsland server:defer productId={id} />
</article>---
// components/RecommendIsland.astro — ML팀 소유, ML팀 독립 서버에서 렌더링
const { userId } = Astro.props
const recommendations = await fetchFromMLService(userId)
---
<section class="recommendations">
{recommendations.map(item => <ProductCard {...item} />)}
</section>
server:defer: 해당 컴포넌트를 별도 서버 요청으로 분리해 메인 HTML 응답을 블로킹하지 않고 비동기로 채워 넣는 Astro Server Islands 디렉티브. 개인화 콘텐츠처럼 캐시할 수 없는 부분을 독립 서버로 분리할 때 특히 유용합니다. 추천 엔진이 500ms가 걸려도 제품 상세 정보는 즉시 사용자에게 보입니다.
예시 3: e커머스 팀 구조 — 아일랜드 경계와 팀 경계를 맞추기
실무에서 자주 맞닥뜨리는 과제인데요 — 기술 구조와 조직 구조가 따로 놀면 마이크로 프론트엔드의 이점이 반감됩니다. 각 아일랜드의 하이드레이션 전략이 해당 팀의 데이터 특성·사용자 행동 패턴과 맞아야 제대로 작동합니다.
| 아일랜드 | 담당 팀 | 하이드레이션 전략 | 이유 |
|---|---|---|---|
| 제품 목록 | 카탈로그 팀 | 정적 HTML (JS 없음) | 검색 봇 크롤링 + 초기 LCP 최적화 |
| 장바구니 뱃지 | 커머스 팀 | client:load |
페이지 진입 즉시 인터랙티브 필요 |
| 추천 위젯 | ML 팀 | server:defer |
개인화 콘텐츠 + 독립 서버 운영 |
| 재고 표시 | 물류 팀 | client:visible |
스크롤 이후에만 필요 |
| 리뷰 섹션 | UGC 팀 | client:visible |
스크롤 이후에만 필요 |
카탈로그 팀 코드가 수정돼도 커머스 팀 장바구니 배포에 영향을 주지 않습니다. ML 팀이 추천 알고리즘을 바꿔도 메인 페이지 HTML 응답 시간은 그대로입니다. 이 구조의 핵심은 "팀 경계 = 아일랜드 경계"가 일치한다는 점인데, 이게 맞지 않으면 A팀이 수정할 때 B팀 아일랜드를 건드려야 하는 상황이 생깁니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 성능 + 독립성 동시 확보 | 마이크로 프론트엔드의 조직 이점과 아일랜드의 성능 이점을 결합, 팀이 늘어도 사용자 JS 증가 없음 |
| 점진적 하이드레이션 | 사용자가 보거나 상호작용하는 부분만 JS 로드, 초기 로딩 속도 극대화 |
| 프레임워크 독립성 | 같은 페이지에서 React·Vue·Svelte 혼용 가능, 팀별 스택 선택권 보장 |
| 독립 배포 | 각 아일랜드를 별도 CI/CD 파이프라인으로 운영, A팀 배포가 B팀에 영향 없음 |
| Module Federation 2.0 | Webpack 의존 없이 Vite·Rspack 지원, Rspack 조합 시 빌드 속도가 크게 향상됨 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 복잡성 급증 | 저장소·빌드 파이프라인·배포 프로세스가 팀 수만큼 늘어남 | Nx 모노레포 + Module Federation으로 관리 집중화 |
| 아일랜드 간 상태 공유 | 서로 다른 팀의 아일랜드가 장바구니 수량 같은 공유 상태를 동기화해야 할 때 복잡해짐 | CustomEvent / BroadcastChannel / nanostores로 팀 간 이벤트 계약 정의 |
| 의존성 버전 충돌 | host와 remote가 서로 다른 버전의 공유 라이브러리를 요구할 때 협상 실패 | shared에 requiredVersion: '>=X' 범위로 지정, 팀 간 버전 정책 합의 |
| UI/UX 불일치 | 팀별 독립 개발 시 버튼 스타일·인터랙션 패턴 제각각 | 공유 디자인 시스템 및 컴포넌트 라이브러리 필수 운영 |
| 호스트 오염 | 아일랜드가 전역 CSS·이벤트 리스너를 오염시킬 수 있음 | Shadow DOM 또는 CSS Modules로 스코프 격리 |
| 라우트 수준 한계 | Astro Server Islands는 컴포넌트 수준 MFE에 적합, 전체 라우트 앱 통합은 미지원 | 라우트 수준 통합이 필요하면 Single-SPA 또는 Nginx 기반 라우팅 조합 고려 |
호스트 오염(Host Pollution): 마이크로 프론트엔드가 전역 스코프(
window.foo = ...), 전역 CSS(:root { --color: red }), 전역 이벤트 리스너를 덮어써서 다른 아일랜드나 셸에 의도치 않은 영향을 주는 현상.
실무에서 가장 흔한 실수
1. singleton: true 없이 Module Federation을 설정하는 것
처음 세팅할 때 shared 설정을 빠뜨리거나 singleton: true를 누락하면 React 인스턴스가 두 개 떠서 "Invalid hook call" 에러가 납니다. 에러 메시지가 훅 문제처럼 보여서 컴포넌트 코드를 한참 뒤지다가 결국 번들러 설정이 문제였다는 걸 발견하게 됩니다 — 저도 처음엔 이걸로 반나절을 날렸습니다. 원인을 알고 나면 허탈할 정도로 간단한 수정인데, Module Federation 설정을 먼저 점검하는 습관을 들여두면 이 고통을 피할 수 있습니다.
2. 아일랜드 간 상태 공유를 셸에서 중간 다리 역할로 해결하려는 것
장바구니 팀 아일랜드에서 수량을 변경했는데 헤더 팀의 장바구니 뱃지가 업데이트되어야 하는 상황처럼, 서로 다른 팀의 아일랜드끼리 상태를 공유해야 하는 경우가 생깁니다. 아일랜드 간에는 props를 직접 전달할 수 없어서 Astro 셸에 중간 로직을 두다 보면 셸이 점점 비대해집니다. CustomEvent나 BroadcastChannel로 팀 간 이벤트 계약을 명시적으로 정의하거나, nanostores 같은 프레임워크 독립 스토어를 shared 의존성으로 두는 방식이 훨씬 깔끔합니다.
3. 모든 컴포넌트를 아일랜드로 만드는 것
client:load를 남발하면 전통적인 CSR과 다를 게 없어집니다. "이 컴포넌트가 정말 첫 화면에서 즉시 인터랙티브해야 하는가?"를 물어보고, 그렇지 않다면 client:visible이나 client:idle로 늦추거나 정적 HTML로 두는 것이 핵심입니다. 아일랜드가 많을수록 좋은 게 아니라, 아일랜드가 정확한 위치에 있을수록 좋습니다.
마치며
지금 바로 시작해볼 수 있는 3단계가 있습니다:
-
pnpm create astro@latest my-shell로 프로젝트를 생성하고, 기존 React 컴포넌트 하나를client:idle로 마운트해볼 수 있습니다. 브라우저 네트워크 탭에서 JS 파일이 언제 요청되는지 확인해보시면 하이드레이션 타이밍 차이가 눈에 보입니다. -
@module-federation/vite플러그인을 추가해서 기존 앱 하나를 원격 모듈로 노출해볼 수 있습니다.exposes에 컴포넌트 하나만 등록하고,React.lazy(() => import('앱이름/컴포넌트'))패턴으로 불러오는 최소 구성부터 시작하는 것을 권장합니다. -
어떤 컴포넌트가 어느 팀 오너십인지 명확히 정하고, 그 경계를 아일랜드 경계와 일치시켜볼 수 있습니다. "이 버그를 누가 고쳐야 하지?"라는 질문에 즉시 답할 수 있는 구조가 되면, 기술과 조직이 함께 정렬된 시점을 실감하게 됩니다.
"팀이 늘수록 성능이 나빠진다"는 역설은 구조의 문제입니다. 아일랜드 경계가 팀 경계와 맞아떨어지는 순간, 팀을 더해도 사용자 경험은 오히려 나아질 수 있습니다.
참고 자료
- An Approach to Astro's Server Islands for Fast Micro-Frontends | talent500
- Composing micro-frontends with Astro's Server Islands | Medium
- Islands Architecture | Astro 공식 문서
- Server Islands | Astro 공식 문서
- Microfrontends Island Architecture | Better Programming
- Islands Architecture | patterns.dev
- Module Federation 2.0: webpack vs Rspack vs Vite 2026 | PkgPulse
- Module Federation 2.0 Reaches Stable Release | InfoQ
- Seamless Integration of React and Vue in Astro Using Module Federation | Leapcell
- Islands Architecture | Jason Miller
- Understanding Micro Frontends | Frontend Mastery
- 대규모 프론트엔드 아키텍처의 새로운 패러다임 | 올리브영 테크블로그