제한된 환경에서 Next.js 마이크로프론트엔드 구현하기
목차

Next.js App Router로 구성된 애플리케이션 안에 여러 서비스의 화면이 들어 있었습니다. 각 서비스는 JavaScript SDK를 통해 호출할 수 있었지만, 화면 코드는 하나의 Next.js 빌드에 포함돼 있었습니다.
서비스 A의 화면만 수정해도 서비스 B와 C를 포함한 애플리케이션 전체를 다시 빌드하고 배포해야 했습니다. 서비스가 추가될수록 변경한 코드의 범위와 실제 배포하는 범위 사이의 차이가 커졌습니다.
이 범위를 줄이기 위해 Next.js는 호스트로 유지하고, 개별 화면은 독립적으로 빌드하는 React SPA로 분리했습니다. 호스트는 실행 중에 version.json을 조회해 서비스별 버전을 결정하고, 필요한 앱을 렌더링하기위해 자바스크립트 모듈을 불러옵니다.
폴더를 나눠도 빌드는 하나였습니다
기존 구조에서도 서비스별 폴더와 컴포넌트는 구분돼 있었습니다. 하지만 Next.js가 각 서비스의 코드를 import하고 있었기 때문에 최종 결과물은 하나의 애플리케이션으로 만들어졌습니다.

여기서 바꾸려던 것은 브라우저가 코드를 내려받는 시점이 아니라 빌드와 배포의 단위였습니다. 동적 import로 화면 코드를 필요할 때 내려받게 하더라도, 그 코드가 호스트 빌드에 포함돼 있으면 수정 후 호스트를 다시 빌드해야 한다는 점은 같습니다.
배포가 잦지는 않았지만, 서비스가 늘어나도 같은 구조를 유지하면 개별 화면을 수정할 때마다 전체 빌드를 거쳐야 했습니다. 화면 내부의 변경을 반영하는 데 다른 서비스의 결과물까지 다시 만들 필요가 없도록 경계를 나누고 싶었습니다.
코드를 나누는 데서 한 걸음 더
원했던 것은 서비스별로 의존성이 없고, 독립적으로 관리가 가능한 구조였습니다.
화면 코드를 별도 패키지로 옮겨도, Next.js가 그 패키지를 가져와 함께 빌드한다면 화면을 수정한 뒤 호스트도 다시 배포해야 합니다. 코드가 놓인 위치는 달라져도 배포 과정은 크게 달라지지 않습니다.
그래서 개별 화면을 별도로 빌드한 다음, 실행 중인 Next.js 애플리케이션이 필요한 화면을 불러오도록 런타임에 통합되는 구조를 채택했습니다. Next.js는 화면을 담는 호스트 역할을 하고, 분리한 화면은 React SPA로 구성했습니다. 각 앱은 브라우저에서 React로 UI를 렌더링하고 내부 상태를 관리합니다. 빌드 도구로는 Vite를 사용했습니다. 이렇게 따로 배포해서 불러오는 화면을 이후에는 원격 앱이라고 부르겠습니다.
분리 기준은 서비스 화면의 실행 단위로 잡았습니다. 원격 앱이 자신의 진입점과 내부 상태를 관리하고, 호스트에는 화면을 시작하고 종료하는 데 필요한 인터페이스만 노출하도록 했습니다. 작은 컴포넌트마다 앱을 나누기보다는 함께 변경되는 코드를 같은 빌드 단위에 두는 편이 의존 관계를 관리하기에 맞았습니다.
인프라를 새로 구성하기도 어려웠습니다. 클라우드나 Docker, Kubernetes를 사용할 수 없어 기존 온프레미스의 VM, L4, Nginx에서 해결해야 했습니다. React SPA를 빌드한 정적 파일을 기존 환경에서 제공하고, 호스트가 이를 불러오는 방식은 그 안에서 적용할 수 있었습니다.
호스트의 배포와 롤백 구성은 앞서 쓴 제한된 환경에서 Next.js 무중단 배포 구현하기에서 다뤘습니다. 이번에는 따로 빌드한 화면을 어떻게 연결했는지에 집중하겠습니다.
Next.js 호스트에 React SPA를 연결
호스트와 원격 앱을 연결할 때는 Script Injection, 즉 브라우저에서 필요한 스크립트 태그를 추가하는 방식을 사용했습니다.
SDK를 통해 들어온 요청에 맞춰 호스트가 표시할 화면을 결정합니다. 그 화면의 진입 스크립트를 불러온 뒤, 원격 앱이 제공하는 함수를 호출해 준비된 DOM 요소 안에 UI를 그립니다. SDK로 화면을 호출하는 외부 흐름과, 내부에서 화면을 구성하는 방식을 나눠 생각한 것입니다.
호스트는 원격 앱의 위치와 버전을 확인하고, 스크립트를 불러오고, 화면을 붙이거나 정리합니다. 불러오는 동안의 로딩 상태와 실패 시 안내도 호스트가 담당합니다.
원격 앱은 전달받은 영역 안에서 자신의 UI와 상태를 관리합니다. 호스트가 원격 앱의 내부 컴포넌트나 상태 저장소까지 알 필요는 없습니다. 처음 화면을 띄우는 데 필요한 초기값과 최소한의 이벤트 정도만 주고받도록 범위를 좁혔습니다.
연결 흐름을 줄이면 다음과 같습니다. remote.js는 진입 스크립트를 설명하기 위한 이름입니다.
JavaScript SDK로 화면(Next.js host container) 호출
→ Next.js 호스트가 요청한 서비스 종류 확인
→ version.json에서 해당 서비스에 사용할 버전 조회
→ 서비스와 버전에 맞는 리소스 URL 결정
→ 진입 스크립트(remote.js)와 필요한 스타일 로드
→ mount(container, props)로 화면 표시
→ 화면을 벗어날 때 unmount로 정리
React SPA를 연결할 때는 빌드한 HTML을 그대로 삽입하는 대신, 호스트가 호출할 수 있는 진입점을 뒀습니다. 원격 앱은 화면을 붙이는 mount와 정리하는 unmount를 제공하고, 호스트는 스크립트를 불러온 뒤 이 진입점을 찾아 호출합니다.
어떤 서비스의 어떤 버전인지
호스트가 불러올 대상을 결정하는 데 사용한 파일은 version.json이었습니다. 이 파일에는 서비스 종류와 각 서비스에 사용할 버전이 명시돼 있었습니다. 호스트는 요청받은 서비스에 해당하는 항목을 찾고, 그 버전에 맞는 원격 앱을 불러옵니다.
무엇을 어디에 연결할지 기록한 파일이라는 점에서 소스맵을 떠올릴 수 있지만, 담고 있는 정보와 용도는 다릅니다. 소스맵이 빌드된 코드와 원본 코드의 위치를 연결한다면, 여기서 version.json은 서비스 종류와 실행할 앱의 버전을 연결하는 목록입니다. 디버깅을 위한 파일이 아니라, 실제로 어떤 앱을 불러올지 결정하는 설정으로 사용했습니다.
서비스별 버전 정보를 단순화하면 다음과 같은 형태입니다. 키와 값은 설명을 위한 예시이며, 실제 파일의 스키마를 그대로 옮긴 것은 아닙니다.
{
"services": {
"service-a": { "version": "1.4.2" },
"service-b": { "version": "1.2.0" },
"service-c": { "version": "2.1.3" }
}
}
각 서비스는 독립된 항목과 버전을 갖습니다. 예를 들어 service-b를 수정했다면 해당 앱의 새 버전을 빌드해 배포한 뒤, version.json에서 service-b의 버전을 1.2.0에서 1.2.1로 바꿉니다. 호스트는 이 항목을 조회해 새 버전의 리소스를 불러오고, service-a와 service-c는 기존 버전을 유지합니다.
여기서 중요한 것은 이 파일을 호스트의 빌드 결과물에 고정하지 않고 실행 중에 조회한다는 점입니다. 파일 이름이 version.json이어도 빌드 시점에 import해서 번들에 포함하면, 버전을 바꿀 때 호스트를 다시 빌드해야 합니다. 서비스별 버전을 따로 관리하는 것과 호스트를 다시 배포하지 않고 그 변경을 반영하는 것이 함께 필요했습니다.
이 조건이 갖춰지면, 호스트와 주고받는 인터페이스가 유지되는 화면 변경은 원격 앱만 빌드하고 배포할 수 있습니다. 반대로 호스트에 새 기능이 필요하거나 전달하는 값의 형태가 달라지면 호스트 변경도 필요합니다. 분리 이후에도 함께 수정해야 하는 경우는 남습니다.
더욱 중요한 일
처음 연결하는 흐름은 단순했지만, 사용자가 화면을 열고 닫거나 로딩 도중 이동하는 상황까지 생각하면 챙길 일이 늘어났습니다.
같은 원격 앱을 다시 열 때마다 스크립트 태그를 추가하면 초기화 코드가 중복 실행될 수 있습니다. 그래서 URL을 기준으로 로딩 상태를 관리했습니다. 이미 불러오는 중이라면 같은 Promise를 기다리고, 로드가 끝났다면 기존 스크립트를 사용하도록 했습니다. 다만 스크립트를 한 번 받는 것과 화면을 한 번만 그리는 것은 별개입니다. 화면을 다시 열면 새 컨테이너에 맞춰 마운트해야 합니다.
Next.js App Router에서는 이 로직이 실행되는 위치도 중요했습니다. window와 document를 사용하는 스크립트 로딩과 DOM 조작은 클라이언트 컴포넌트의 Effect처럼 브라우저에서 실행되는 위치에 뒀습니다. 서버에서는 컨테이너와 로딩 UI를 렌더링하고, 브라우저에서 준비된 영역에 원격 앱을 붙였습니다. 서버와 클라이언트의 역할 구분은 Next.js 공식 문서에서도 확인할 수 있습니다.
화면을 벗어나면 원격 앱을 정리해야 합니다. React 루트를 해제하는 것뿐 아니라, 앱이 등록한 전역 이벤트 리스너와 타이머도 남지 않도록 해야 합니다. React의 root.unmount 문서에서도 외부 코드가 관리하는 화면을 제거할 때 정리 과정이 필요한 이유를 설명합니다.
실패 역시 한 가지로 묶기 어려웠습니다. 파일을 받지 못한 경우, 파일은 받았지만 진입점이 없는 경우, 마운트 중 오류가 난 경우는 확인할 곳이 다릅니다. 로더에는 타임아웃과 네트워크 오류 처리를 두고, 마운트 전에는 필요한 함수가 있는지 확인했습니다.
독립된 배포와 동일한 실행 환경
빌드와 배포를 나눴다고 실행 환경까지 격리되는 것은 아닙니다.
Script Injection으로 불러온 원격 앱은 호스트와 같은 문서, 같은 JavaScript 실행 환경을 사용합니다. 전역 CSS가 다른 영역에 영향을 주거나, 전역 객체를 덮어쓰거나, 무거운 작업이 화면 전체를 멈추게 할 가능성은 그대로 있습니다.
따라서 로드나 마운트 실패를 감지해 해당 영역에 안내를 보여줄 수는 있어도, 모든 오류가 그 안에만 머문다고 말할 수는 없습니다. 스타일이 적용되는 범위와 전역 변수 사용을 제한하고, 앱을 닫았을 때 정리가 되는지도 함께 살펴봐야 합니다.
공유 코드 역시 비슷했습니다. 공통 훅과 API 클라이언트는 별도 toolkit에서 관리했지만, 편하다는 이유로 화면별 로직까지 넣으면 작은 수정에도 여러 앱을 다시 배포해야 합니다. 함께 쓰는 코드가 늘수록 변경의 영향 범위도 다시 넓어질 수 있습니다.
호스트와 원격 앱의 버전이 항상 같지 않다는 점도 신경 써야 합니다. 호스트가 전달하는 값이나 원격 앱이 내보내는 이벤트를 바꿀 때는 현재 운영 중인 조합에서도 동작하는지 확인해야 합니다. 인터페이스를 작게 유지한 이유이기도 합니다.
배포 파일과 version.json을 갱신하는 순서도 중요합니다. version.json이 새 버전을 먼저 가리키면 아직 없는 파일을 요청할 수 있고, 이전 파일을 바로 지우면 이미 열려 있는 화면에서 추가 리소스를 가져오지 못할 수 있습니다. 버전별 파일을 구분하고, 새 파일을 제공할 준비가 된 뒤 version.json의 참조를 바꾸며, 이전 파일을 얼마나 보관할지도 정해야 합니다. version.json에 오래된 캐시가 남으면 버전 변경이 바로 반영되지 않을 수 있으므로, 버전별 정적 파일과 이 설정 파일의 캐시 정책도 구분해서 살펴봐야 합니다.
이런 관리가 화면 수에 비례해 늘어난다는 점은 분리의 비용입니다. 호스트와 원격 앱의 버전을 오류 정보에 함께 남기고, 실제 배포된 파일과 설정이 맞는지 확인하는 과정을 더 보완하려고 합니다. 전체 빌드와 개별 앱 빌드 시간도 측정해야 효과를 구체적으로 이야기할 수 있습니다.
끝으로
현재 모든 화면을 마이크로프론트엔드로 전환한 것은 아닙니다. 개발 중인 화면은 독립적으로 빌드하고 배포할 수 있도록 구성하고 있고, 운영 중인 화면도 전환하고 있습니다.
앞으로도 추가될 서비스들이나 전환될 서비스들은 대부분 독립적인 배포 파이프라인을 갖도록 설계와 개발을 할 예정입니다. 다만 의존성과 서비스의 특성 등을 우선적으로 고려 할 거 같습니다. 얻는 이점이 많은만큼 관리 포인트도 많아지기때문입니다.
처음의 질문은 여전히 분리 기준으로 남아 있습니다. “이 화면 하나를 고쳤는데, 다른 화면까지 함께 배포해야 할까?” 지금은 그 질문에 맞춰 새 화면의 경계를 정하고, 기존 화면을 조금씩 옮기고 있습니다.