Module Federation: singleton이면 당연히 하나겠죠?

따로 빌드된 코드가 하나의 인스턴스를 참조하게 하기

Joon • Core Client Web

  • Frontend

Invalid hook call. Hooks can only be called inside the body of a function component.

React를 사용하다 보면 한 번쯤은 만나게 되는 에러입니다.

이때는 보통 훅을 올바르게 사용했는지부터 의심합니다. 컴포넌트 바깥에서 호출한 곳은 없는지, 핸들러 안에 말려들어간 호출은 없는지 확인합니다. 그런데 아무리 살펴봐도 이상한 곳이 없다면, 마지막으로 한 가지를 의심해볼 수 있습니다. 바로 React가 여러 벌 존재하는 경우입니다.

하나의 React


최근 채널웍스¹에는 Module Federation(모듈 페더레이션; 이하 MF)을 도입했습니다.

¹ 채널웍스는 채널톡 상담과 사내 메신저(팀챗), 데이터 분석 도구(노트북), AI 비서실장 CoS 등을 한 앱에서 제공하는 업무 플랫폼입니다.

다양한 기능들을 사용자에게 제공하는 플랫폼 형태의 제품으로 나아가게 되면서, 각 기능을 만드는 팀이 서로의 배포를 기다리지 않고 기능을 내보낼 수 있는 방법이 필요했습니다.

MF는 간단히 말하면 따로 빌드하고 따로 배포한 번들을 런타임에 불러와 한 앱처럼 조립하는 방식입니다. 번들마다 각자 빌드되기 때문에, 여러 번들이 함께 사용하는 라이브러리는 번들마다 따로따로 포함됩니다. 그래서 이 방식에서는 React가 여러 벌이 되는 것이 당연합니다.

host 번들과 remote 번들이 각각 React를 갖고 한 앱으로 조립되는 구조

대부분의 코드나 라이브러리는 여러 벌이 되더라도 대개는 문제없이 동작합니다. 그저 번들 용량만 그만큼 늘어날 뿐입니다. 다만 React는 그렇지 않습니다. React의 훅은 자기가 속한 React 인스턴스의 내부 상태를 참조합니다. 그래서 컴포넌트를 렌더링하는 React와 훅이 참조하는 React가 서로 다른 인스턴스일 때, 즉 host 번들과 remote 번들에 서로 다른 React 인스턴스가 존재하면 위 에러를 마주하게 됩니다. 이를 해결하기 위해서, React는 싱글톤 인스턴스가 되어야 합니다.

JavaScript

이렇게 shared에 등록한 다음 singleton: true를 켭니다. 이 옵션의 기본값은 false이고, 켜 두면 버전이 어긋나더라도 경고만 남긴 채 한 벌만 사용합니다.

여기까지는 Module Federation 공식 문서에서 소개하는 내용 그대로입니다. 하지만 지금처럼 '하나여야 하는 것이 하나가 아닌' 상황은 계속 생겨났습니다. 어긋나는 지점도, 그것을 하나로 만드는 방법도 매번 달랐습니다. 이 글은 그때마다 무엇을 어떻게 하나로 맞췄는지에 대한 이야기입니다.

React는 하나, Context가 둘


React를 싱글톤으로 만들었으니 훅은 문제없이 동작하리라 생각했습니다.

그런데 host의 context provider 안에 렌더링한 remote에서 같은 컨텍스트에 대한 useContext를 호출하자 이 provider를 찾지 못한 채 값이 초기값 그대로 남아 있었습니다.

이번 문제의 원인은 채널웍스의 MF 도입 방식에 관련이 있습니다. 일반적인 MF 방식과 달리, 채널웍스의 MF는 이미 동작하는 서비스를 최대한 유지한 채 추가로 MF를 통해 별도로 개발된 기능을 얹는 형태의 구조를 선택하였습니다.

그렇다 보니 remote에 expose하는 모듈은 대부분 host에 위치한 코드에서도 사용하는 모듈입니다. 그런데 번들러는 진입점(entry)마다 런타임을 하나씩 만들기 때문에, 기존의 앱 진입점에 더해 MF host 진입점에 대한 런타임이 하나 더 생성되었고, expose된 모듈은 각 런타임의 모듈 캐시에서 총 두 번 실행되면서 그 안의 createContext()도 두 번 호출되었습니다. 그 결과, 앱이 값을 채우는 컨텍스트와 remote가 읽는 컨텍스트가 서로 다른 객체가 된 것이었습니다.

host의 두 entry가 각각 런타임과 모듈 캐시를 가져 같은 파일이 두 번 실행되고 컨텍스트 객체가 둘로 갈리는 흐름

이후 알아보니 webpack#17575로 이미 알려진 문제였고, 해결책 역시 이곳에서 찾을 수 있었습니다. 앞서 지정한 shared["react"].singleton: true로 해결되지 않는 이유는, shared는 host와 remote가 각자 설치한 의존성을 하나로 맞추는 자리이고, host 자신의 모듈은 애초에 그 대상이 아니기 때문입니다.

해결하는 방법은 두 가지입니다. 하나는 문제가 되는 모듈을 별도 패키지로 떼어내 shared에 올리는 것입니다. 다른 하나는 런타임 자체를 하나로 합치는 것입니다. 런타임이 별도 청크로 분리되면서 두 entry가 하나의 런타임을 함께 사용하고, 모듈 캐시도 하나로 유지됩니다.

JavaScript

일반적인 MF 구조를 가정한다면 전자가 이상적일 수 있으나, 앞서 설명드린 MF 도입 방식상 후자의 처리를 하지 않을 이유가 없어 후자로 선택하였습니다.

무엇을 하나로 만들어야 하나요?


이제 하나로 만드는 방법은 알았는데, 정확히 무엇이 하나여야 할지는 잘 모르겠는 상태가 되었습니다.

공식 문서의 설명은 몇 가지 기준을 제시하면서도, '이러한 것들을 싱글톤으로 할 수 있다'는 식으로 서술하고 있어, 우리 상황에 맞는 판단 기준을 세울 필요가 있었습니다.

맥락이 동일해야 하는 경우?


처음에는 이렇게 정했습니다.

양쪽이 공유하는 맥락이 하나로 유지되어야 하는 경우에 공유한다.

여기서 말하는 맥락은 단순히 React의 컨텍스트만을 가리키는 것이 아닌, 스타일시트처럼 모듈 스코프에 놓인 상태도, '모두 같은 버전을 사용한다'는 약속도 포함하는 넓은 의미로 보았습니다.

대표적인 사례가 디자인 시스템입니다. 채널의 디자인 시스템 컴포넌트 라이브러리인 bezier-react는 자체 컨텍스트 헬퍼로 WindowProvider를 만들고, Tooltip이나 Modal, Toast 같은 오버레이 컴포넌트들이 그 값을 읽어 갑니다. 이 패키지의 인스턴스를 각자 따로 만들게 되면 remote 쪽 오버레이는 host가 채워 넣은 값 대신 초기값인 null을 읽게 됩니다.

styled-components도 같은 부류에 속합니다. 스타일시트를 등록하는 동작이 모듈 스코프에서 일어나기 때문에, 여러 벌이 되면 스타일이 서로 다른 시트로 나뉩니다.

아이콘 패키지는 '모두 같은 버전의 아이콘을 사용해야 한다'는 맥락을 지키기 위해 싱글톤으로 두었습니다.

상태 관리 라이브러리 역시 동일한 맥락을 참조해야 할 테니 공유하기로 합니다.

그런데...


곧바로 문제들이 드러나기 시작합니다. 하나로 만들면 오히려 망가지는 경우도 있었고, 애초에 하나일 필요가 없던 것도 있었습니다.

동작이 깨질 수 있는 쪽부터 보겠습니다.

서버 상태 관리 라이브러리인 React Query입니다.

언뜻 생각하기에는 remote도 동일한 서버 데이터를 다루니 쿼리 캐시도 하나여야만 할 것 같습니다. 자연스럽게 캐시를 제공하는 라이브러리도 전체 앱에서 하나여야 자연스러워 보입니다.

문제는, remote가 자체적으로도 쿼리 캐시를 만들어 관리하고자 할 때 생깁니다. host의 쿼리 캐시와 전역 상태 중에는 인증이나 세션에 관련된 정보 등 remote에서 임의로 덮어쓰거나 제거해서는 안 되는 정보들이 존재합니다. 이를 미연에 방지하기 위해 host의 쿼리 캐시를 remote에 직접 열어 주지 않고, host에서 useContext를 감싼 훅을 expose하고 있습니다.

이때 host와 remote가 동일한 인스턴스를 참조하도록 하고 remote가 자기 provider를 마운트하는 순간, host에서 expose한 모든 훅이 remote의 쿼리 캐시를 참조하게 됩니다.

공유하지 않은 경우와 싱글톤으로 합친 경우

다만 하나로 만드는 것 자체가 문제를 일으키는 것은 아닙니다. remote가 자체 Provider를 사용하지 않는다면 합쳐도 무해합니다. 우리는 remote도 같은 라이브러리로 자기 상태를 가져야 해서 이 조건을 만족하지 못했고, 그래서 합치지 않았습니다.

하나일 필요가 없던 쪽은 아이콘이었습니다.

shared에 올린 모듈은 어떤 export가 쓰일지 빌드 시점에 알 수 없기 때문에 tree-shaking이 적용되지 않고, 동기적으로 불러오는 host의 앱 역시 사용해야 하기에 eager: true를 함께 적용해두어² 전부 초기 청크에 들어갑니다.

'모두 같은 버전의 아이콘을 사용해야 한다'는 전제는 강제보다는 권장 사항에 가까웠기 때문에, 미사용 아이콘을 초기에 전부 불러오는 비용을 절감하는 편이 더 낫다는 판단에 기존 결정을 번복하였습니다. shared 대상에도 tree-shaking을 적용하는 옵션이 있기는 하지만, eager와 함께 사용할 수 없고, 싱글톤에 권장되는 방식은 배포 서버가 사용처를 계산해 주는 것을 전제하기 때문에 이 구성에는 해당하지 않습니다.

² eager: true는 항상 권장되는 패턴은 아닙니다. 다만 채널웍스에서는 host 앱의 동작을 보존하기 위하여 shareStrategy: "loaded-first" 설정과 조합하여 지정하고 있습니다.

다시 세운 기준


그래서 기준을 다시 세웠습니다.

remote가 다른 번들이 만든 인스턴스를 직접 써야 하고,

host가 감싼 훅으로도 우회할 수 없는 것만 싱글톤으로 둔다.

React와 React DOM, 디자인 시스템이 여기에 해당합니다. 아이콘은 공유할 인스턴스가 없어서 제외했고, React Query는 감싼 훅으로 우회할 수 있어서 올리지 않습니다.

dexie는 이 기준과 별개로 싱글톤을 유지합니다. 서로 다른 두 버전이 함께 로드되면 dexie가 스스로 예외를 던지기 때문입니다.

다만 기준이 생겼다고 해서 판단할 일이 전부 없어지지는 않습니다. 새 라이브러리를 도입할 때, 버전을 올릴 때, remote에 새 의존성이 생길 때마다 이 기준으로 다시 판단해야 합니다.

왜 하나로 합쳐지지 않나요?


기준은 세웠으니, 이 기준에 맞는 패키지들만 등록해서 하나의 인스턴스를 참조하도록 만들어주기만 하면 됩니다. Runtime chunk도 하나로 합쳐뒀으니 이제는 singleton: true 를 잘 걸어주면 될 거예요. 분명 그래야만 했습니다.

등록했는데 없다고요?


bezier-react는 이미 shared에 싱글톤으로 등록되어 있었습니다. 그런데 일부 컴포넌트에서만 useWindow()가 null을 반환했습니다. 이미 등록한 패키지니까 모든 컴포넌트에서 잘 동작해야 하는데 일부 컴포넌트에서만 동작하지 않는다니, 뭔가 이상했습니다.

원인은 키에 있었습니다. shared의 키는 패키지 이름이 아닌, 'import 요청 경로'와 매칭됩니다. 최근 신규 디자인 시스템 스펙을 반영한 beta 컴포넌트들의 import 요청 경로는 @channel.io/bezier-react/beta였고, @channel.io/bezier-react와는 다른 문자열이기 때문에 등록되지 않은 키라고 인식하게 된 것입니다. 이로 인해 remote는 beta 컴포넌트만 자기 번들에서 가져오면서 WindowProvider가 한 벌 더 생기면서 위 현상이 발생하였습니다.

이 현상은 의도된 동작으로, 트러블슈팅 가이드에 제시된 바와 같이, 서브패스를 별도의 키로 등록해서 해결할 수 있었습니다.

JavaScript

아직도 뭐가 다르다고요?


이제는 정말 될 줄 알았는데, 여전히 useWindow() 는 null을 반환했습니다.

문제의 원인을 파악하기 위해 런타임에서 각 함수의 출처를 추적해보니 분명 하나만 있어야 할 bezier-react가 또 두 벌로 나뉘어 있는 것을 발견했습니다.

원인은 pnpm의 동작 방식과 peer dependency의 버전 차이였습니다. 사실 이 케이스는 MF에서의 문제라기보다는 pnpm의 동작 방식으로 인한 차이가 MF에서도 발견이 되었던 사례지만, '따로 빌드된 코드가 하나의 인스턴스를 참조하게 하기'의 맥락에서 함께 다뤄보겠습니다.

pnpm은 peer 의존성이 다르게 해석되면 같은 버전의 패키지라도 따로 설치하고, 설치 경로에 그 버전을 표기합니다.

Plaintext
node_modules/.pnpm/@channel.io+bezier-react@4.0.0-next.14_@channel.io+bezier-icons@0.59.0_.../
node_modules/.pnpm/@channel.io+bezier-react@4.0.0-next.14_@channel.io+bezier-icons@0.60.0_.../

bezier-react의 peer dependency인 아이콘 패키지가 host 앱에서는 0.60.0, host에 설치된 내부 패키지에서는 0.59.0이 설치되어 있었습니다. 이에 따라 host의 node_modules에는 host 앱의 bezier-react와 내부 패키지의 bezier-react가 각각 위치하게 되었고, 이로 인해 같은 버전의 bezier-react가 host 빌드 안에 두 벌이 있는 상태가 된 것입니다. 편의를 위해 각각 59B와 60B라고 하겠습니다.

shared는 import된 사본을 키마다 모두 등록하고, 런타임은 같은 키와 버전에서 먼저 등록된 하나만 남깁니다.

내부 패키지에서의 bezier-react import는 59B를, host 앱에서의 bezier-react import는 60B를 가리키기 때문에, 이 경우에는 경로 순서상 앞서는 (알파벳순으로 먼저인) 59B가 'bezier-react' 키에 등록됩니다.

그런데 bezier-react/beta import의 경우 내부 패키지에서는 없었기 때문에 host 앱에서의 60B를 가리키는 import만 존재하고, 'bezier-react/beta' 키에는 60B 사본이 등록된 것입니다.

그 결과 bezier-react의 AppProvider는 0.59 쪽 컨텍스트를 채우는데, beta 오버레이는 자기 사본 안의 WindowProvider를 참조하므로 0.60 쪽 컨텍스트를 읽었습니다.

같은 버전이 두 벌 설치된 host 빌드에서 두 shared 키가 서로 다른 사본을 고르는 흐름

이를 해결하기 위해서는, 가장 간단하게는 모든 아이콘 패키지의 버전을 맞추는 방법이 있습니다. 아이콘 패키지의 버전이 다른 것보다는 이로 인해 bezier-react가 두 벌이 설치되어 생긴 문제라 직접적인 해결은 아니지만, 원인은 제거할 수 있습니다. 공교롭게도 host와 remote가 동일한 버전의 아이콘 패키지를 사용하는 것을 강제할 필요가 낮음을 이유로 싱글톤에서 제외한 후 발생한 문제라 묘하게 연관되어 보이지만, 아이콘 패키지가 싱글톤이 되어도 서로 다른 경로의 두 bezier-react는 해결되지 않습니다.

일시적으로는 버전을 맞출 수 있으나 별도의 장치 없이는 시간이 지날수록 일치가 깨지기 쉬우니, pnpm 모노레포라면 카탈로그를 통해 내부 패키지에서 다른 버전을 설치하지 않도록 방지하는 방법을 고려해볼 수 있습니다.

마무리하며

정리하면 이렇습니다. 원했던 것은 '따로 빌드된 코드가 하나의 인스턴스를 참조하게 하기'였습니다. 그런데 singleton: true만으로는 되지 않았고, 여러 자리에서 각각 하나로 맞춰야 했습니다. 어긋난 곳이 생겨도 대부분 에러 없이 지나갔고, 값을 읽는 순간에야 문제가 드러났습니다. 그래서 무언가를 하나로 만들려 할 때 차례로 확인할 질문을 정리해 두었습니다.

  1. 하나로 만들어야 하는가:

    remote가 다른 번들이 만든 인스턴스를 직접 써야 하고, host가 감싼 훅으로 우회할 수 없어야 한다. dexie처럼 서로 다른 버전이 함께 로드되면 스스로 예외를 던지는 패키지도 하나로 둔다

  2. shared로 다룰 수 있는가:

    host가 자기도 쓰면서 expose하는 모듈은 그 대상이 아니다. 하나로 만들 필요는 그대로이므로 런타임을 합쳐서 푼다

  3. 키가 실제 요청 경로와 같은가: 서브패스는 별도의 키다

  4. 한 벌만 설치되어 있는가:

    shared는 키마다 사본 하나를 고를 뿐, 여러 벌을 하나로 합치지 않는다

이미 동작하는 서비스 위에 MF를 얹은 것은, 여러 팀이 한 제품 위에서 서로를 기다리지 않고 각자의 속도로 기능을 내보낼 수 있게 하기 위해서였습니다. 이 구조는 한 번 들이고 끝나지 않고 계속 다듬어야 하는데, '하나로 만들기'는 그 일의 중심에 있는 문제입니다. 공식 문서는 쓸 수 있는 옵션을 알려 주지만, 언제 무엇을 하나로 둘지는 우리 상황에 맞춰 직접 정해야 했습니다.

그래서 제품을 만들어 내보내는 일과 별개로, 구조 자체를 다듬는 일에도 시간을 쓰고 있습니다. 이번에 기준을 세우고 범위를 정한 것도 그 일부입니다. 어떤 기능을 host가 이미 갖고 있다면, 이 기능을 remote에서 직접 구현할 때의 비용을 예상하여 비교하고, host가 제공한다면 범위를 어떻게 설정할지, 여러 remote가 함께 쓸 기능은 어떻게 묶을지, host와 remote의 경계를 어디에 어떻게 그을지와 같은 고민을 합니다. 앞으로 풀어야 할 문제들도 이처럼 정해진 답은 없지만 더 나은 방법을 찾아나가고 있습니다.

더 많은 팀이 더 빠르고 안정적으로 고객에게 가치를 전달할 수 있도록 환경을 만들어나가는 코어웹팀에서는 이러한 문제들을 함께 풀어나갈 동료를 기다리고 있습니다.

We Make a Future Classic Product