이름이 같다고 같은 객체가 아니다, Module Federation에서 '하나'가 깨지는 네 겹
![]()
Module Federation을 들인 팀이 가장 먼저 배우는 옵션은 singleton: true이고, 가장 늦게 배우는 사실은 그 옵션이 '하나'를 보장하지 않는다는 겁니다. 이름도 같고 버전도 같고 싱글톤으로 등록까지 했는데 객체가 둘일 수 있어요. 그리고 그때 대개 에러는 나지 않습니다.
채널톡이 상담·사내 메신저·데이터 분석 도구·AI 비서실장을 한 앱에 담은 업무 플랫폼 채널웍스에 Module Federation(MF)을 들이고 나서 겪은 일은 이 '두 벌' 문제의 끝없는 변주였습니다. 흔한 오해는 MF 설정에서 shared에 등록하고 singleton: true를 켜면 해결된다는 겁니다. 공식 문서가 알려 주는 것도 거기까지고요. 그런데 채널웍스 팀은 그 뒤로 세 번 더 '하나여야 할 것이 둘'인 상황을 만났고, 원인은 매번 다른 층에 있었습니다. 저는 이 글을 MF 트러블슈팅 기록보다 '자바스크립트에서 두 객체가 같다는 건 무슨 뜻인가'에 대한 해설로 읽는 편이 오래 쓸모 있다고 봅니다.
첫 번째 겹: 버전 — singleton이 푸는 유일한 문제
MF는 따로 빌드하고 따로 배포한 번들을 런타임에 불러와 한 앱처럼 조립하는 방식입니다. 채널웍스가 이걸 고른 이유는 플랫폼이 커지면서 기능을 만드는 팀들이 서로의 배포를 기다리지 않게 하려는 것이었어요. 번들마다 따로 빌드되니 여러 번들이 쓰는 라이브러리도 번들마다 따로 들어가고, 그래서 React가 여러 벌이 되는 건 MF에서는 오히려 기본값입니다.
대부분의 라이브러리는 여러 벌이어도 번들 용량만 늘 뿐 잘 돕니다. React는 다릅니다. 훅은 자기가 속한 React 인스턴스의 내부 상태를 참조하니, 컴포넌트를 렌더링하는 React와 훅이 부르는 React가 다르면 "Invalid hook call"이 납니다. React를 써 본 사람이라면 한 번쯤 봤을 에러이고, 보통은 훅을 컴포넌트 바깥에서 불렀는지부터 찾다가 마지막에야 React가 두 벌인지 의심하게 되죠. 그래서 host 설정의 shared에 react와 react-dom을 올리고 singleton: true를 켭니다. 기본값은 false이고, 켜 두면 버전이 어긋나도 경고만 남기고 한 벌만 씁니다.
여기까지가 첫 번째 겹입니다. 이 옵션이 맞춰 주는 건 '같은 키로 등록된 패키지 중 어느 버전을 쓸 것인가'뿐이에요. 이 한계를 정확히 알고 있어야 나머지 세 겹이 보입니다.
두 번째 겹: 런타임 — 같은 파일이 두 번 실행된다
React를 하나로 맞췄는데도 문제가 생겼습니다. host의 context provider 안에 remote를 렌더링하고 remote에서 같은 컨텍스트로 useContext를 불렀더니, provider를 못 찾고 초기값이 그대로 나왔습니다.
원인은 채널웍스가 MF를 들인 방식에 있었어요. 새 앱을 처음부터 MF로 쪼갠 게 아니라, 이미 돌아가는 서비스를 최대한 그대로 두고 그 위에 별도로 개발한 기능을 MF로 얹는 길을 택했습니다. 그래서 remote에 expose한 모듈 대부분이 host 자신도 쓰는 모듈이었죠. 문제는 번들러가 진입점마다 런타임을 하나씩 만든다는 데 있었습니다. 기존 앱 진입점과 MF host 진입점이 각자 런타임과 모듈 캐시를 가지면서, expose된 모듈이 각 캐시에서 한 번씩, 총 두 번 실행됐습니다. 그 안의 createContext()도 두 번 불렸고요.

webpack#17575로 이미 알려진 문제였는데, singleton으로 안 풀린 이유가 핵심입니다. shared는 host와 remote가 각자 설치한 의존성을 맞추는 자리이고, host 자신의 소스 모듈은 애초에 그 대상이 아닙니다. 해법은 두 가지였습니다. 문제 모듈을 별도 패키지로 떼어 shared에 올리거나, runtimeChunk: 'single'로 런타임을 하나로 합쳐 모듈 캐시를 하나로 유지하거나. 일반적인 MF 구조라면 앞의 것이 깔끔하지만, 기존 서비스 위에 얹은 구조에서는 뒤의 것을 안 할 이유가 없어 그쪽을 골랐습니다.
여기서 배울 건 이겁니다. 자바스크립트 모듈은 '파일'이 아니라 '런타임의 모듈 캐시에 들어간 실행 결과'입니다. 같은 파일도 캐시가 둘이면 두 번 실행되고, 모듈 스코프에서 만든 객체는 두 개가 됩니다. createContext 같은 함수가 모듈 최상단에서 불리는 코드라면 이 차이가 곧 버그가 되죠.
무엇을 하나로 둘 것인가 — 공유 DB를 고르는 문제
하나로 만드는 방법을 알고 나니 다음 질문이 남았습니다. 정확히 무엇이 하나여야 하나. 공식 문서는 '이런 것들을 싱글톤으로 할 수 있다'는 식으로만 말하니 팀이 직접 기준을 세워야 했습니다.
첫 기준은 '양쪽이 공유하는 맥락이 하나로 유지돼야 하면 공유한다'였어요. 여기서 맥락은 React 컨텍스트만이 아니라 모듈 스코프의 상태, '모두 같은 버전을 쓴다'는 약속까지 넓게 봤습니다. 디자인 시스템 bezier-react는 WindowProvider를 만들고 Tooltip·Modal·Toast 같은 오버레이가 그 값을 읽으니, 따로 두면 remote 쪽 오버레이는 null을 읽습니다. 스타일시트를 모듈 스코프에서 등록하는 styled-components도, 같은 버전을 써야 한다는 약속의 아이콘 패키지도, 상태 관리 라이브러리도 공유하기로 했죠.
곧 두 방향의 실패가 나왔습니다. 하나는 합쳐서 망가지는 경우, React Query였습니다. host의 쿼리 캐시와 전역 상태에는 인증과 세션처럼 remote가 덮어쓰거나 지우면 안 되는 정보가 있어서, 채널웍스는 캐시를 remote에 직접 열지 않고 useContext를 감싼 훅만 expose하고 있었습니다. 그런데 라이브러리를 싱글톤으로 합친 상태에서 remote가 자기 provider를 마운트하면, host가 expose한 모든 훅이 remote의 캐시를 보게 됩니다.

저는 이 장면이 마이크로서비스의 공유 데이터베이스 안티패턴과 똑같다고 봅니다. 두 서비스가 같은 테이블을 직접 읽고 쓰면 한쪽의 변경이 다른 쪽을 조용히 깨뜨리고, 그래서 우리는 데이터를 API 뒤로 숨깁니다. 라이브러리 인스턴스를 공유하는 건 그 안의 가변 상태를 공유하는 것이고, 감싼 훅을 expose하는 건 API를 여는 것입니다. 채널웍스도 remote가 자체 provider를 쓰지 않으면 합쳐도 무해하지만, remote가 같은 라이브러리로 자기 상태를 가져야 했기에 합치지 않기로 했습니다.
다른 하나는 애초에 하나일 필요가 없던 경우, 아이콘이었습니다. shared에 올린 모듈은 어떤 export가 쓰일지 빌드 시점에 몰라 tree-shaking이 안 되고, host 앱이 동기적으로 불러 써야 해서 eager: true까지 걸려 있으니 아이콘 전체가 초기 청크에 들어갔습니다. 같은 버전을 쓰자는 건 강제라기보다 권장이었으니, 미사용 아이콘을 처음부터 다 불러오는 비용을 줄이는 쪽으로 결정을 번복했죠. shared 대상에도 tree-shaking을 적용하는 옵션이 있긴 하지만 eager와 함께 쓸 수 없고, 싱글톤에 권장되는 방식은 배포 서버가 사용처를 계산해 주는 구성을 전제해 이 상황엔 맞지 않았다고 합니다. eager: true 자체도 항상 권장되는 패턴은 아닌데, 채널웍스는 host 앱의 기존 동작을 지키려고 shareStrategy: "loaded-first"와 짝지어 쓰고 있었어요. 기존 서비스 위에 MF를 얹는 선택이 이렇게 설정 곳곳에 비용을 남깁니다. 싱글톤 목록에 하나를 올릴 때마다 초기 청크가 무거워진다는 걸 알면, '일단 공유해 두자'는 결정이 공짜가 아니라는 것도 보입니다.
그래서 기준을 다시 세웁니다. "remote가 다른 번들이 만든 인스턴스를 직접 써야 하고, host가 감싼 훅으로도 우회할 수 없는 것만 싱글톤으로 둔다." React와 React DOM, 디자인 시스템이 여기 남았고, 아이콘은 공유할 인스턴스가 없어서, React Query는 감싼 훅으로 우회할 수 있어서 빠졌습니다. 서로 다른 두 버전이 함께 로드되면 스스로 예외를 던지는 dexie만 기준과 별개로 싱글톤을 유지하고요. 저는 이 기준 변경이 이 글에서 가장 좋은 판단이라고 봅니다. '같아야 할 것 같은가'라는 감각에서 '공유하지 않고 인터페이스로 열 수 있는가'라는 설계 질문으로 옮겨 갔으니까요. 공유 표면은 작을수록 안전합니다.
세 번째 겹: 키 — 패키지 이름이 아니라 요청 경로
기준대로 등록하고 런타임도 합쳤으니 이제 정말 될 것 같았습니다. 그런데 bezier-react가 분명 shared에 싱글톤으로 올라가 있는데도 일부 컴포넌트에서만 useWindow()가 null을 돌려줬습니다.
원인은 키였습니다. shared의 키는 패키지 이름이 아니라 import 요청 경로와 맞춰집니다. 신규 디자인 시스템 스펙을 반영한 beta 컴포넌트는 @channel.io/bezier-react/beta에서 가져오는데, 이건 @channel.io/bezier-react와 다른 문자열이라 등록되지 않은 키로 취급됐어요. remote는 beta 컴포넌트만 자기 번들에서 가져왔고, WindowProvider가 한 벌 더 생겼습니다. MF 트러블슈팅 가이드에도 있는 의도된 동작이라 서브패스를 별도 키로 등록해서 풀었습니다.
사람은 '@channel.io/bezier-react/beta'를 bezier-react의 일부로 읽지만, 번들러에게 그건 그냥 다른 문자열입니다. 패키지가 exports 필드로 서브패스를 늘릴 때마다 shared 설정도 따라 늘어야 한다는 뜻이고, 이건 사람이 기억에 의존해서는 언젠가 반드시 놓치는 종류의 일입니다.
네 번째 겹: 설치 경로 — 같은 버전이 두 벌 깔린다
서브패스까지 등록했는데 useWindow()는 여전히 null이었습니다. 런타임에서 함수의 출처를 추적해 보니 bezier-react가 또 두 벌이었어요. 이번엔 MF가 아니라 pnpm의 동작 방식이 원인이었습니다.
pnpm은 peer 의존성이 다르게 해석되면 같은 버전의 패키지라도 따로 설치하고 설치 경로에 그 차이를 표기합니다. bezier-react 4.0.0-next.14의 peer인 아이콘 패키지가 host 앱에서는 0.60.0, host에 설치된 내부 패키지에서는 0.59.0이었고, 그래서 같은 버전의 bezier-react가 host 빌드 안에 두 경로로 있었습니다. 편의상 59B와 60B라고 하죠.

shared는 import된 사본을 키마다 모두 등록하고, 런타임은 같은 키·같은 버전 중 먼저 등록된 하나만 남깁니다. 'bezier-react' 키에는 경로 알파벳순으로 앞서는 59B가 올라갔는데, beta 서브패스는 내부 패키지가 쓰지 않아 'bezier-react/beta' 키에는 60B만 올라갔어요. 결국 AppProvider는 59B 쪽 컨텍스트를 채우고 beta 오버레이는 60B 쪽 컨텍스트를 읽었습니다. 같은 버전 번호, 같은 패키지 이름, 둘 다 싱글톤인데 객체는 둘이었던 거죠.
가장 간단한 해법은 아이콘 패키지 버전을 맞추는 겁니다. 다만 시간이 지나면 다시 어긋나기 쉬우니, pnpm 모노레포라면 카탈로그로 내부 패키지가 다른 버전을 깔지 못하게 막는 쪽을 함께 권했습니다. 공교롭게도 아이콘을 싱글톤에서 뺀 직후에 터진 일이라 연관돼 보이지만, 아이콘이 싱글톤이었어도 설치 경로가 다른 두 bezier-react는 그대로였을 겁니다. shared는 키마다 사본 하나를 고를 뿐, 여러 벌을 하나로 합쳐 주지 않으니까요.
그래서 '같다'는 건 무슨 뜻인가
네 겹을 모아 보면 자바스크립트에서 두 모듈 객체가 같으려면 이 조건을 다 통과해야 합니다. 같은 런타임의 모듈 캐시에서 실행됐을 것, 같은 요청 경로(키)로 해석됐을 것, 같은 설치 경로의 파일이었을 것, 그리고 그 위에서 같은 버전이 선택됐을 것. singleton: true는 이 중 마지막 하나만 다룹니다. 채널웍스 팀이 마지막에 정리한 체크리스트, 즉 하나로 만들어야 하는가·shared로 다룰 수 있는가·키가 실제 요청 경로와 같은가·한 벌만 설치돼 있는가는 이 조건들을 순서대로 묻는 질문입니다.
이 문제가 고약한 건 거의 언제나 조용하다는 점입니다. 에러가 나는 건 첫 번째 겹, React 두 벌일 때뿐이고 나머지는 전부 null이나 초기값으로 지나갑니다. 오버레이가 엉뚱한 위치에 뜨거나 툴팁이 안 뜨는 식의 증상은 MF를 의심하기 전에 컴포넌트 버그로 오해받기 쉽고요. 그래서 저는 MF를 쓰는 팀이라면 개발 빌드에 '싱글톤이어야 할 모듈이 정말 하나인지' 확인하는 가드를 넣으라고 권합니다. 디자인 시스템 패키지가 처음 로드될 때 전역에 자기 인스턴스를 기록하고, 두 번째 인스턴스가 나타나면 콘솔에 크게 경고하는 정도면 됩니다. 네 겹 중 어디가 깨졌든 결과는 '두 번째 인스턴스의 등장'으로 똑같이 드러나니까요.
마지막으로 조건 하나를 붙이겠습니다. MF의 약속은 팀들이 서로를 기다리지 않고 각자 배포하는 것인데, 이 글이 보여 주듯 그 자유의 대가는 누군가 '하나로 만들기'를 상시로 관리하는 비용입니다. 새 라이브러리를 들일 때, 버전을 올릴 때, remote에 새 의존성이 생길 때마다 이 기준으로 다시 판단해야 한다고 채널웍스 팀도 적었어요. 팀이 이미 다음 고민으로 넘어가 있다는 점도 눈여겨볼 만합니다. host가 이미 가진 기능을 remote가 직접 만들 때의 비용과 host가 제공할 때의 비용을 비교하고, host가 제공한다면 범위를 어디까지 열지, 여러 remote가 함께 쓸 기능은 어떻게 묶을지, host와 remote의 경계를 어디에 그을지. 결국 '무엇을 하나로 둘까'는 '어디까지를 하나의 시스템으로 볼까'라는 아키텍처 질문의 다른 얼굴입니다. 기능 팀이 여럿이고 그들의 배포 대기가 실제 병목인 조직이라면 이 비용을 내고 MF를 들일 만합니다. 팀이 둘셋이고 배포가 하루에 몇 번 겹치는 정도라면, 모노레포의 한 번 빌드가 이 네 겹을 통째로 피해 가는 더 싼 선택일 수 있습니다. MF를 고를지 말지는 결국 '하나로 만들기'를 전담할 사람이 있느냐의 문제입니다.