들어가며

처음에는 하나의 Next.js 앱으로 시작해 빠르게 기능을 붙여 나갔습니다. 하지만 기능이 늘어나면서 앱은 점점 무거워지고, 배포 시간도 길어졌어요. 특히 독립적으로도 잘 동작할 수 있는 기능이 메인 앱에 묶여 있다 보니, 사용자는 쓰지 않는 코드까지 함께 내려받아야 했습니다.

그래서 조금 다른 길을 선택해 보기로 했습니다. Micro Frontend Architecture(MFA)를 도입해 메인 앱과 뷰어를 분리하고, 경계에서 라우팅을 제어하도록 한 거죠. 덕분에 팀은 개발과 배포를 더 가볍게 할 수 있었고, 사용자에게는 꼭 필요한 코드만 전달할 수 있었습니다.


 

본문

1) 모노레포에서의 분리 원칙

  • 공통자원은 공통패키지로, 나머지는 앱별로 독립 유지
    예: 유틸/타입 등 만 공통패키지로 공유
  • 서로의 UI/상태 의존은 끊기
    메인앱과 뷰어앱이 각자 필요한 자원을 갖도록 선을 그었어요.
  • 이렇게 하면 번들이 자연스럽게 슬림해지고, CI에서도 앱 단위로 병렬 빌드/배포가 쉬워집니다.

2) 경계에서 라우팅: Middleware + Rewrite

메인 앱은 특정 경로를 기준으로 요청을 뷰어 앱으로 rewrite 합니다. URL은 유지되지만 응답은 뷰어가 담당해요.

// client/middleware.ts (요지)
import { NextResponse } from 'next/server';

export async function middleware(request: Request) {
  const url = new URL(request.url);
  const { pathname, search } = url;

  if (pathname.startsWith('/mf-viewer')) {
    // 예: ENV.VIEWER_URL = https://viewer.example.com
    const target = `${process.env.VIEWER_URL}${pathname}${search}`;
    return NextResponse.rewrite(new URL(target));
  }

  // 기본 흐름
  return NextResponse.next();
}

메인 앱에서 /mf-viewer/** 요청은 뷰어 앱으로 rewrite 합니다.


3) 인증 게이트는 뷰어 앱 입구에서 가볍게

보호가 필요한 경로(예: /mf-viewer)는 뷰어 앱의 미들웨어에서 인증 여부만 확인하고, 필요하면 /login으로 돌려보냅니다. 페이지 단에서는 “그려도 되는 상황”만 다루도록 흐름을 단순화했습니다.

// viewer/middleware.ts (요지)
import { NextResponse } from 'next/server';

export async function middleware(request: Request) {
  const url = new URL(request.url);
  const { pathname } = url;

  const isWorkspace = {/* /mf-viewer/preview ** */}
  const isAuthenticated = /* 로그인 유/무 */

  if (isWorkspace && !isAuthenticated) {
    // 메인 앱의 로그인 페이지로 보냄
    const loginUrl = new URL('/login', url);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

인증 판단은 뷰어 앱의 경계에서 처리하고, 페이지는 단순화합니다.


4) 화면 통합: iframe + postMessage

메인 앱은 뷰어를 iframe으로 느슨하게 포함해요. 서로의 렌더 트리를 얽지 않으면서도, 사용자는 하나의 제품처럼 자연스럽게 느낍니다.

// 예: client 측 포함 컴포넌트
export default function ViewerEmbed() {
  return (
    <iframe
      src={`/mf-viewer`}
      sandbox="allow-scripts allow-same-origin allow-popups allow-downloads allow-forms"
      title="Report Viewer"
    />
  );
}

필요한 화면만 iframe으로 포함해 느슨하게 결합합니다.

상호작용이 필요할 땐 postMessage로 얇은 메시지 프로토콜을 주고받습니다.

// 예: 메인 앱 측 메시지 수신
useEffect(() => {
  const onMessage = (event: MessageEvent) => {
    const expectedOrigin = window.location.origin;
    if (event.origin !== expectedOrigin) return;

    const msg = event.data;
    if (!msg?.type) return;

    switch (msg.type) {
      case 'EDITOR_SAVE':
        // 데이터 재조회 등
        break;
      case 'EDIT_SUB_PAGE':
        // 서브 페이지 편집 UI 열기 등
        break;
      default:
        break;
    }
  };

  window.addEventListener('message', onMessage);
  return () => window.removeEventListener('message', onMessage);
}, []);

origin을 확인하고, 메시지 타입을 좁혀 안전하게 처리합니다.


5) 보안·안정성 체크리스트

  • sandbox 최소 권한: allow-scripts, allow-same-origin을 기본으로, 필요한 경우에만 allow-downloads, allow-forms 등을 열어 주세요.
  • 메시지 검증: origin/type 체크는 필수입니다. payload는 꼭 필요한 필드만 주고받아요.

6) 우리가 얻은 것

  • 가벼운 번들: 각 앱이 자신에게 필요한 코드만 담게 되어, 사용자는 불필요한 자바스크립트를 내려받지 않습니다.
  • 개발 속도: 앱 단위로 병렬 개발/배포가 가능해지고, 리스크도 각자 관리할 수 있습니다.
  • 유연한 운영: 뷰어만 빠르게 핫픽스하거나 롤백하는 등, 운영 선택지가 넓어집니다.
  • 익숙한 경험: 사용자는 여전히 하나의 제품처럼 느끼되, 내부에선 역할이 잘 나뉩니다.

“Next+Next”만이 전부는 아니에요

특히 Multi-Zones 문서에는 “같은 도메인 안에 다른 프레임워크 앱을 함께 둘 수 있다”는 취지도 언급돼 있어요. 즉, 상황에 따라 Next + React/Vite 같은 조합도 충분히 가능합니다.

 

결론

이번 구조 개편은 거창한 게 아니고, 경계를 또렷하게 세운 선택에 가까웠어요. Next.js의 Middleware + rewrite, 그리고 iframe + postMessage 만으로도 충분히 깔끔한 마이크로 프런트엔드를 만들 수 있었습니다.

비슷한 고민을 하고 계시다면, 먼저 “어디까지를 이 앱의 책임으로 볼 것인가”부터 가볍게 나눠보세요. 필요한 부분부터 천천히 분리해도 충분히 큰 효과를 체감하실 거예요. 🙌

+ Recent posts