영상 강의와 스크래치 실습을 모은 온라인 수업 사이트
흩어진 스크래치 수업을 한 화면으로 모은 기록
탭 세 개로 흩어진 수업
대학 SW 교양 과정의 스크래치 수업을 온라인으로 옮기는 일이었어요. 강의 영상은 유튜브에 있고, 실습은 스크래치 공식 편집기에서 이뤄지는 구조였습니다.
요구사항은 세 가지였습니다. 첫째, 아무나 가입할 수 없고 미리 등록된 학번만 계정이 열려야 한다. 그래서 회원가입 화면이 아예 없고, 관리자가 addStudentIds 로 학번을 밀어 넣은 뒤에야 학생이 로그인할 수 있습니다. 둘째, 주차별 정규 강의와 별개로 학생마다 다른 추천 콘텐츠를 붙일 수 있어야 한다. 셋째, 영상과 스크래치 편집기가 한 화면에 있어야 한다.
스크래치 편집기는 외부에서 만든 큰 React 앱이라, 이걸 우리 앱 안에서 띄워야 했습니다.

한 저장소, 세 갈래 배포
EMP CLI(@efox/emp-cli) 위에서 Webpack 5 Module Federation 으로 app, admin, gui 세 모듈을 나눴습니다
학생 앱과 관리자 앱은 디자인 토큰·GraphQL 클라이언트·에러 처리를 그대로 공유하는데, 배포 주기는 달랐어요. 하나의 SPA 로 묶으면 관리자 화면 한 줄 고치는 데 학생 앱까지 다시 배포해야 합니다 2021년 말 기준으로 Vite 에는 쓸 만한 Module Federation 지원이 없었고, 배포는 Vercel 로 고정되어 있었습니다
토큰 대신 쿠키 세션을 쓰고, 로컬에도 mkcert 로 HTTPS 를 깔았습니다
리모트가 서로 다른 오리진에서 로드되는데 토큰을 localStorage 에 두면 오리진 수만큼 사본이 생깁니다. 쿠키 하나로 묶고 credentials: "include" 로 보내는 편이 단순했어요 그 대가로 로컬 개발도 실제 도메인 + HTTPS 여야 했습니다. /etc/hosts 에 local.stg-scratch-tutoring.app 을 추가하고 sh certificate.sh 로 인증서를 까는 절차가 README 맨 위에 올라간 이유입니다
Vercel 에 세 개의 독립 프로젝트로 배포하고, 리모트 URL 을 환경별 규칙으로 계산했습니다
Module Federation 에서 모듈을 잇는 배선은 결국 URL 입니다. local / preview / production / ci 네 갈래로 나눠 getRemoteUrl() 하나가 결정하게 만들면, 브랜치 프리뷰끼리도 서로를 정확히 물고 뜹니다 프리뷰 URL 이 브랜치 이름에서 파생되기 때문에, CI 는 세 프리뷰가 다 뜰 때까지 기다린 다음에야 타입 체크를 할 수 있었습니다

호스트이면서 리모트인 app
브라우저가 처음 받는 것은 app 번들입니다. app 은 호스트이면서 동시에 리모트예요. 테마, GraphQL 클라이언트, 에러 핸들러, 모달, 아이콘을 exposes 로 내보내고, 반대로 admin 이 내보낸 table-shell 과 table-pagination 을 가져다 씁니다. 학생 홈의 강의 목록 표가 실제로 관리자 모듈에서 넘어온 컴포넌트예요. 여기서는 모듈을 어떻게 쪼개고 배선했는지, 그리고 남의 React 앱을 우리 앱 안에 어떻게 얹었는지만 다룹니다. GraphQL 스키마 계약과 코드 생성, 관리자 화면의 표 공통화, 세션 만료 시 재발급 흐름은 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

React 17 앱 안에 React 16짜리 스크래치 편집기를 얹기
보통은 둘 중 하나를 시도합니다. scratch-gui 를 최신으로 올려 호스트의 React 17 에 억지로 맞추거나, iframe 으로 통째로 격리하는 방법이죠. 전자는 scratch-vm, scratch-blocks, scratch-paint 까지 줄줄이 끌려 나와 며칠을 태우고도 렌더가 깨집니다. 후자는 편해 보이지만, 편집기와 영상 목록이 같은 레이아웃 안에서 세로/가로 모드로 재배치되어야 한다는 요구를 못 지킵니다. iframe 은 부모의 flex 계산에 참여하지 않으니까요.
편집기를 packages/gui 라는 별도 워크스페이스로 떼고, 그 패키지 안에서만 react, react-dom 을 16.14.0 으로 못 박았습니다. 그리고 Module Federation shared 설정에서 이 패키지만 singleton: false 로 선언했어요. 호스트인 app 과 admin 은 react: { singleton: true } 를 유지합니다. 프로덕션 빌드에서는 copy 플러그인으로 scratch-gui 의 blocks-media, chunks, extension-worker.js, static/assets 를 호스트의 dist 로 복사해 같은 오리진에서 서빙되게 했습니다. 편집기 컴포넌트 쪽은 AppStateHOC(Gui) 를 IntlProvider locale="ko" 로 감싸고 Gui.setAppElement 로 모달 루트만 지정해 줬고요.
singleton: false 는 "이 의존성은 버전이 갈려도 좋으니 각자 쓰라"는 선언입니다. 그래서 페이지 안에 React 16 인스턴스와 React 17 인스턴스가 동시에 살아 있고, 스크래치는 자기 Redux store 와 자기 React 로 자기 서브트리만 렌더합니다. 두 트리는 props 로 값을 주고받지 않고 DOM 노드 하나를 경계로 만나기 때문에 hooks 규칙이 섞일 일이 없어요. 그러면서도 iframe 이 아니라 진짜 DOM 요소라서, 세로 모드에서는 1024px 폭 박스로, 가로 모드에서는 영상 아래 전폭 박스로 Chakra 레이아웃이 그대로 재배치합니다. 런타임 에셋을 호스트 dist 로 복사한 것도 같은 이유입니다. 블록 아이콘과 확장 워커가 외부 CDN 이 아니라 우리 오리진에서 오면, 오프라인 강의실에서도 편집기가 온전히 뜹니다.
서로를 리모트로 물고 있는 두 모듈
두 앱이 컴포넌트를 공유해야 하면 대개 @company/design-system 같은 사설 npm 패키지를 하나 만들고 양쪽에서 설치합니다.
양방향으로 열었습니다. app 은 core/theme, core/utils/api/client, core/utils/errors/*, password-field 를 exposes 하고 admin 을 리모트로 등록합니다. admin 은 src/routes, table-shell, table-pagination 을 exposes 하고 app 을 리모트로 등록하고요. 순환 참조입니다.
런타임에서 순환이 문제가 되지 않는 이유는 emp.js 리모트 엔트리가 지연 로딩이기 때문입니다. app 이 부팅할 때 admin 전체를 불러오는 게 아니라, 학생 홈이 표를 그리는 순간에야 table-shell 청크 하나를 가져옵니다. 순환은 그래프 위에만 있고 실행 순서에는 없습니다.
유저가 할 수 있는 일
절반에서 멈춘 배선
솔직히 말하면, 화면은 다 그려졌는데 절반은 아직 배선 전이었습니다. 강의 화면의 주차·강 선택 메뉴는 Array(50).fill("") 로 만든 더미이고, 오른쪽 추천 콘텐츠 카드도 여섯 장이 전부 같은 문자열입니다. ModeWork 는 TODO work 한 줄이고, 관리자 재생목록 미리보기 모달은 고정된 유튜브 URL 하나를 재생합니다. 콘텐츠 목록 화면은 검색창과 버튼만 있고 쿼리가 붙어 있지 않아요. 2022년 2월에 손을 뗀 시점의 정직한 상태입니다.
Module Federation 을 양방향으로 연 것도, 편의를 얻은 만큼 대가가 있었습니다. 프리뷰 셋을 기다리는 CI 는 느리고, 신규 인원이 "이 컴포넌트는 어느 패키지 것인가"를 매번 되물었어요. 다시 만든다면 공유 UI 만 세 번째 리모트로 떼어 app, admin 이 나란히 그것을 참조하게 만들어 순환을 없앴을 겁니다. 그리고 테스트가 한 줄도 없습니다. 최소한 useCheckAll 과 isValidYoutubeUrl 같은 순수 함수에는 붙였어야 했어요.














