Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/EN

Online Class Site for Video Lectures and Scratch Practice

영상 강의와 스크래치 실습을 모은 온라인 수업 사이트

흩어진 스크래치 수업을 한 화면으로 모은 기록

탭 세 개로 흩어진 수업

대학 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 청크 하나를 가져옵니다. 순환은 그래프 위에만 있고 실행 순서에는 없습니다.

유저가 할 수 있는 일

학번과 비밀번호로 로그인한다
학번과 비밀번호로 로그인한다

홈에서 주차별 진도와 강의 목록을 확인한다
홈에서 주차별 진도와 강의 목록을 확인한다

학습 이어하기를 눌러 마지막 강의로 들어간다
학습 이어하기를 눌러 마지막 강의로 들어간다

강의 화면에서 영상과 스크래치 편집기의 배치를 바꾼다
강의 화면에서 영상과 스크래치 편집기의 배치를 바꾼다

강의 자료를 내려받는다
강의 자료를 내려받는다

비밀번호를 바꾸고 다시 로그인한다
비밀번호를 바꾸고 다시 로그인한다

관리자에게 계정 삭제를 요청한다
관리자에게 계정 삭제를 요청한다

관리자가 로그인해 대시보드로 들어간다
관리자가 로그인해 대시보드로 들어간다

주차를 골라 정규 강의 재생목록을 만든다
주차를 골라 정규 강의 재생목록을 만든다

유튜브 링크를 붙여넣어 강의를 등록한다
유튜브 링크를 붙여넣어 강의를 등록한다

추천 강의 목록을 검색하고 미리보기하고 정리한다
추천 강의 목록을 검색하고 미리보기하고 정리한다

학번 여러 개를 한 번에 등록한다
학번 여러 개를 한 번에 등록한다

학생 계정을 골라 삭제한다
학생 계정을 골라 삭제한다

학생의 계정 삭제 요청을 승인하거나 거절한다
학생의 계정 삭제 요청을 승인하거나 거절한다

관리자가 로그아웃한다
관리자가 로그아웃한다

1 / 1

절반에서 멈춘 배선

솔직히 말하면, 화면은 다 그려졌는데 절반은 아직 배선 전이었습니다. 강의 화면의 주차·강 선택 메뉴는 Array(50).fill("") 로 만든 더미이고, 오른쪽 추천 콘텐츠 카드도 여섯 장이 전부 같은 문자열입니다. ModeWork 는 TODO work 한 줄이고, 관리자 재생목록 미리보기 모달은 고정된 유튜브 URL 하나를 재생합니다. 콘텐츠 목록 화면은 검색창과 버튼만 있고 쿼리가 붙어 있지 않아요. 2022년 2월에 손을 뗀 시점의 정직한 상태입니다.

Module Federation 을 양방향으로 연 것도, 편의를 얻은 만큼 대가가 있었습니다. 프리뷰 셋을 기다리는 CI 는 느리고, 신규 인원이 "이 컴포넌트는 어느 패키지 것인가"를 매번 되물었어요. 다시 만든다면 공유 UI 만 세 번째 리모트로 떼어 app, admin 이 나란히 그것을 참조하게 만들어 순환을 없앴을 겁니다. 그리고 테스트가 한 줄도 없습니다. 최소한 useCheckAll 과 isValidYoutubeUrl 같은 순수 함수에는 붙였어야 했어요.

Read next

iOS·안드로이드 웹뷰 컴포넌트 라이브러리

Banksalad BPL — 2020