ryong.logryong.log
Zoom SDK를 활용한 강의 관제 시스템 만들기 배너
2024년 4월 9일·읽는 데 약 9분

Zoom SDK를 활용한 강의 관제 시스템 만들기

관련 글

Moong Trip

2024. 3. 5.

playwright을 통한 e2e 테스트 도입기

2024. 5. 30.

OpenClaw를 활용한 hotfix 대응구축팀 설계

2026. 3. 6.

무엇을 만들 것인가?

회사에서 Zoom으로 학생들 영어 강의를 하는데, 동 시간대에 진행되는 수업들을 한 눈에 보고 관리할 수 있는 관제 페이지를 만들어달라는 요청이 들어왔다. 실시간으로 진행되고 있는 강의에 대한 정보들을 서버에서 받아온 뒤, 프론트에서 Zoom SDK를 사용하여 여러 개의 강의를 동시에 띄워주면 되는 메커니즘을 가진 프로젝트였다.

예시 프로젝트 찾기

맨땅에 헤딩을 하지 않기 위해 공식 문서에서 잘 만들어져 있는 예시 프로젝트를 찾는 것이 우선이었으며, 큰 어려움 없이 좋은 소스를 찾을 수 있었다.

  • 프론트엔드 : https://github.com/zoom/meetingsdk-react-sample
  • 백엔드 : https://github.com/zoom/meetingsdk-auth-endpoint-sample

프론트엔드 코드에서는 서버에서 받아온 미팅 정보들을 사용하여 Zoom SDK를 실행시키는 로직이 주로 담겨있었다. 프로젝트에서 Zoom SDK를 실행시키는 방법 두 가지를 제공하는데, 하나는 최상단 HTML의 리액트 루트와 같은 위계에 <div>를 생성한 후 전체 화면에서 렌더링하는 Client View이며, 다른 하나는 프로젝트 내 원하는 위치를 지정하여 렌더링을 할 수 있는 Component View였다. 우리 프로젝트에서는 기능이 추가되는 것을 고려하고, 자유로운 배치를 위하여 Component View 방식으로 렌더링하는 것을 채택하였다.

또한, 백엔드 코드에서는 SDK 실행에 필요한 값들을 암호화해서 내려주는 로직 정도만 포함되어 있어 상대적으로 가벼웠다.

설정하기

리액트 프로젝트 내 Zoom 미팅을 임베드 시키기 위해서는 몇 가지 선행작업이 필요했다. 우선, 여느 SDK 사용법과 마찬가지로 인가 키 값을 발급받아야 했다.

발급 방법은 아래와 같다.

Zoom App Marketplace 링크에 접속한 후,

App Marketplace

marketplace.zoom.us

↗
Bookmark

오른쪽 상단의 Build App을 통해 App을 하나 생성한다.

앱을 성공적으로 생성하고 나서 Manage 창으로 들어가서 생성한 앱에 대한 상세 페이지로 들어가면 아래와 같이 Client ID와 Client Secret 키가 발급되어진 것을 볼 수 있다. Client ID는 프론트엔드와 백엔드 두 곳 모두 사용되며, Client Secret은 백엔드에서 암호화할 때 사용되는 키 값이다.

추가로, Features > Embed 탭에서 Meeting SDK의 Embed 여부 정도만 활성화시켜주면 세팅은 끝이 난다.

예시 프로젝트 실행하기

설정까지 완료했으니 들뜬 마음으로 앱을 실행시켜 실제로 미팅 임베드가 잘 되는지 테스트 해 볼 차례이다.

강의 관제 시스템의 성격에 맞도록 미팅을 개설한 계정이 아닌 관제만 진행할 계정 하나를 두고, 해당 계정으로 여러 미팅에 참여하도록 아키텍처를 구상했다. 즉 SDK를 발급한 계정을 관제 계정으로 설정하고, SDK를 발급받지 않은 개인 계정으로 개설한 강의에 참여하도록 테스트를 진행하였다.

하지만, 역시나 한 번에 성공하지 못하고 에러와 마주하게 되었다.

트러블 슈팅 1 : 단일 미팅 참여 불가능

Error 6601 refers to a specific scenario.

서치하여 원인을 분석해보니, 아래와 같은 분석을 찾을 수 있었다.

If you did not publish your SDK app, and are trying to join a meeting which is created by a user on an external account, you will get this error.

If you try to join a meeting created by a user, who is using the same account as the SDK, you should not get this error.

즉, SDK Key가 매핑되지 않은 계정의 미팅에 참여하려고 할 때 발생하는 에러였다. 미팅에 참여하기 위해서는 미팅을 호스팅하는 계정에서 SDK Key를 발급받아야 하며, SDK Key를 발급받은 계정과 해당 계정이 호스팅하는 미팅은 서로 페어되어 사용되어야 하는 구조이다.

우리 프로젝트로 예시를 들면, SDK 키를 발급받은 관제만 하기로 했던 계정에서 개설한 미팅에만 참여가 가능한, 즉 내가 개설한 강의에 내 아이디로만 참여가 가능한 상황이며, 가정대로 프로젝트를 실행했더니 정상적으로 렌더링되는 것을 확인하였다.

두 개 이상의 강의 동시에 참여하기

이제 가장 중요한 여러 개의 미팅에 동시에 참여하는 테스트를 진행할 차례이다. 이것이 성공하면 프로젝트는 성공할 확률이 높아지며, 이것이 실패하면 반대로 실패하게 된다.

두 개의 미팅에 참여하는 것이 성공하면 n 개의 참여도 성공할 것이라는 가정을 세우고, 두 개의 계정에서 각각 SDK 키를 발급하여 필요한 정보들을 서버에서 내려주도록 설정하였다. 이어서 SDK 키를 발급한 두 계정에서 각각 하나의 미팅을 개설하여 참여한 후, 리액트 프로젝트에서 두 개에 동시에 참여하도록 ref를 연동한 <div> 두 개에 Component View 렌더링을 시도했다. 결과는 처참했다. 또 실패를 마주하게 되었다.

트러블 슈팅 2 : n개 미팅 동시 참여 불가능

{ "type":"INVALID_OPERATION","reason":"Duplicated join operation" }

하나의 프로젝트 내에 두 개 이상의 Zoom SDK 인스턴스를 실행시킬 수 없다는 내용의 이슈였다. 프로젝트를 계속 이어가야 될 지 말아야 될 지 결정해야 하는 순간이 왔지만, 포기하려던 찰나에 합법적인 꼼수 하나가 머리에 스쳐 지나갔다.

하나의 프로젝트에 두 개 이상의 인스턴스 실행이 불가능하지만, 여러 개의 프로젝트에 인스턴스 하나씩 실행은 가능하다는 가정을 둘 수 있었다. 이에 따라, same-origin의 <iframe>을 여러 개 두고, 각 <iframe>마다 인스턴스 하나씩 실행시키는 방법을 시도해봤으며, 놀랍게도 가정대로 두 개의 미팅을 동시에 참여하는데 성공하였다.

typescript
Copied
1function Page() {2  // ...3  return (4    <div>5      {여러개의미팅정보.map((value) => (6        <iframe7          src={`/meeting/${value.meetingId}`}8          title={`${value.meetingId}'s iframe`}9          name={`${value.meetingId}'s iframe`}10          width='100%'11          height='100%'12          sandbox='allow scripts allow-same-origin'13          style={{ border: 'none' }}14          referrerPolicy='same-origin'>15          iframe을 지원하지 않는 브라우저인 경우 대체정보를 제공16        </iframe>      17      ))}18    </div>19  )20}

위 코드에서는 meetingId만 전달하는 URL을 통해 전달하는 방식으로 되어있지만, 미팅에 필요한 정보들을 query parameter로 모두 전달한 후 파싱하여 사용하는 방법도 생각할 수 있다.

트러블 슈팅 3 : Gallery View 사용 불가

Zoom SDK의 Component View는 아래와 같이 세 가지 타입을 지원해준다.

  • Speaker View : 말하고 있는 사람의 화면만 대표 화면으로 렌더링
  • Gallery View : 전체 미팅 참여자의 화면을 적당한 크기로 분할하여 렌더링
  • Ribbon View : Gallery View의 세로가 긴 버전

아무 설정도 하지 않은 상황에서는 Default로 Speaker View로 렌더링이 된다. 우리가 만들고 있는 강의 관제 시스템의 특성 상 참가자 모두의 화상 화면을 보는 것이 적합하기 때문에 Gallery View 혹은 Ribbon View를 채택하는 편이 적합하였다. init() 함수의 option에 defalutViewType에서 이를 선택할 수 있었기에 Gallery View를 설정한 후, 프로젝트를 실행하였다. 하지만, 예상과 달리 계속 Speaker View로 렌더링되며, Gallery View로 렌더링이 되지 않는 이슈가 발생했다.

스터디를 한 결과, Gallery View와 Ribbon View는 SharedArrayBuffer를 클라이언트에서 사용할 수 있는 상태가 되어야 적용되는 것이었다. 공식 문서에서 친절히 실행하고 있는 클라이언트에서 SharedArrayBuffer를 사용할 수 있는 상태를 만드는 것에 대한 가이드를 제공해주었다.

SharedArrayBuffer - Video SDK - Zoom Developer Docs

Enable SharedArrayBuffer in your Zoom application by configuring cross-origin isolation headers across cloud platforms and web servers to improve performance with WebAssembly.

developers.zoom.us/docs/video-sdk/web/sharedarraybuffer/

↗

클라이언트 사이드 렌더링을 하고 있는 CRA 프로젝트를 다루고 있는 우리의 상황에 맞는 솔루션은 세 번째인 serviceworker를 실행시켜 SharedArrayBuffer를 사용할 수 있는 상태로 만드는 것이었다. 가이드에서 제공하고 있는 깃허브 코드를 우리 프로젝트 최상단 HTML에서 다운로드하여 활성화를 시켜주었다.

html
Copied
1<head>2  <script src="%PUBLIC_URL%/worker/coi-serviceworker.js"></script>3  <!-- ... -->4</head>

그 결과 정상적으로 SharedArrayBuffer를 사용할 수 있는 상태가 되었으며, Gallery View로 렌더링이 되는 모습을 볼 수 있었다.

하지만, 정상적으로 동작하는 줄 알았던 프로젝트가 배포 환경에서는 여전히 Speaker View로 렌더링되는 현상이 발견되었다. 알고보니, 로컬에서 잠시 테스트를 위해 활성화 시켰던 구글의 SharedArrayBuffer 토큰이 html <meta> 태그를 지웠음에도 localhost:3000에서 활성화 되고 있었기 때문에 로컬 환경에서만 Gallery View가 보였던 것이었다.

서버에서 COEP와 관련된 정보를 직접 헤더에 담아서 내려주는 것이 정통한 방법이기 때문에 프론트에서 관련된 로직을 거두고, 서버에서 내려주는 방법을 채택했더니 배포 환경에서도 제대로 동작하는 것을 확인하였다. 추가로, 로컬 환경에서는 배포 서버에서 헤더가 내려오지 않기 때문에 따로 설정을 해줘야 했다.

Enable Shared Array Buffer | Modfy API Docs

TLDR;

docs.modfy.video/docs/sections/guides/sharedArrayBuffer

↗
javascriptcraco.config.js
Copied
1// ...2module.exports = {3  devServer: {4    // ...5    headers: {6      'Access-Control-Allow-Origin': '*',7      'Cross-Origin-Opener-Policy': 'same-origin',8      'Cross-Origin-Embedder-Policy': 'require-corp',9      'Cross-Origin-Resource-Policy': 'cross-origin',10    }11  }12}

트러블 슈팅 4 : 특정 미팅 참여 불가

여러 개의 미팅 정보를 서버에서 내려받은 후, 미팅을 동시에 띄우기까지는 문제없이 성공했으나, 일부 참여가 불가능한 미팅이 존재했다. 에러 메시지가 명확하지 않아서 원인을 파악하기까지 시간이 좀 걸렸으나, 패스워드에 잘못되었다는 에러 메시지를 받게 되었다. 실패한 미팅들에 대한 데이터들을 파악한 결과, 패스워드에 +, %가 포함될 경우 미팅이 실패한다는 가정을 세울 수 있었다. 평소에는 랜덤으로 줌에서 만들어 준 패스워드를 사용했으나, 임의로 미팅을 생성하고 패스워드에 +, %를 포함하여 개설한 결과 가정대로 미팅 참여에 실패한 것을 확인할 수 있었다.

대체로, URL에 포함되면 띄어쓰기나 특수문자 앞에 사용되는 문구로 사용되는 두 문자이기에 프론트엔드 엣지에서 encodeURIComponent, decodeURIComponent를 사용하여 처리를 해봤으나, 서버 Response를 받은 이후, Zoom SDK Join을 하는데까지 잘못 전송되는 케이스는 없으므로, 해당 방법으로 문제 해결이 난항을 겪었다.

결국, Zoom 세팅에 있는 ‘비밀번호에 특수문자 한 개 이상 포함’ 항목의 체크를 해제하면서 문제를 해결하는 방법을 택하게 되었고, 모든 미팅들이 정상적으로 렌더링되는 결과를 얻게 되었다.

UI 작업과 사용성 강화

전체 화면에서 보여야 하는 미팅 화면을 작은 화면으로 압축하여 렌더링 하기 때문에 화면 비율이 깨지는 현상이 발생하였고, 이를 위한 개선작업이 필요하였다.

Meeting SDK에 참여 시 기준이 되는 요소의 width, height를 사이즈로 지정하고, 화면의 크기가 변경되면 변경된 크기에 맞춰 Meeting SDK의 크기도 변경되도록 설정해줬다.

typescript
Copied
1import { useRef, useEffect } from 'react'2import ZoomMtgEmbedded from '@zoom/meetingsdk/embedded'34function Component() {5  const meetingArea = useRef<HTMLDivElement>(null)6  const client = ZoomMtgEmbedded.createClient()7  8  const startMeeting = () => {9    if (meetingArea.current === null) return10    11    try {12      await client.init({13        zoomAppRoot: meetingArea.current as HTMLElement,14        customize: {15          video: {16            viewSizes: {17              default: {18                width: meetingArea.current.clientWidth,19                height: meetingArea.current.clientHeight,20              },21            },22          },23        },24      })25      // ...26    } catch (err: unknown) {27      console.error(err)28    }29  }30  31  useEffect(() => {32    const resize = () => {33      if (meetingArea.current === null) return34      client.updateVideoOptions({35        viewSizes: {36          default: {37            width: meetingArea.current.clientWidth,38            height: meetingArea.current.clientHeight,39          },40        },41      })42    }43    window.addEventListener('resize', resize)44    return () => window.removeEventListener('resize', resize)45  }, [meetingArea.current?.offsetWidth, client])46  47  return <div ref={meetingArea}></div>48}

Meeting SDK 크기 대응

그 외, 연결 상태 변화에 따른 화면 분기, 입장 시 음소거, 필요없는 툴바 버튼 삭제 등의 프로젝트에서 필요한 지엽적인 코드 구현을 하면서 프론트엔드에서 준비할 요소들을 마무리할 수 있었다.

코드

📁 public
📁 worker
📄 coi-serviceworker.js
javascript
Copied
1let coepCredentialless = false;2if (typeof window === 'undefined') {3    self.addEventListener("install", () => self.skipWaiting());4    self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));56    self.addEventListener("message", (ev) => {7        if (!ev.data) {8            return;9        } else if (ev.data.type === "deregister") {10            self.registration11                .unregister()12                .then(() => {13                    return self.clients.matchAll();14                })15                .then(clients => {16                    clients.forEach((client) => client.navigate(client.url));17                });18        } else if (ev.data.type === "coepCredentialless") {19            coepCredentialless = ev.data.value;20        }21    });2223    self.addEventListener("fetch", function (event) {24        const r = event.request;25        if (r.cache === "only-if-cached" && r.mode !== "same-origin") {26            return;27        }2829        const request = (coepCredentialless && r.mode === "no-cors")30            ? new Request(r, {31                credentials: "omit",32            })33            : r;34        event.respondWith(35            fetch(request)36                .then((response) => {37                    if (response.status === 0) {38                        return response;39                    }4041                    const newHeaders = new Headers(response.headers);42                    newHeaders.set("Cross-Origin-Embedder-Policy",43                        coepCredentialless ? "credentialless" : "require-corp"44                    );45                    if (!coepCredentialless) {46                        newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin");47                    }48                    newHeaders.set("Cross-Origin-Opener-Policy", "same-origin");4950                    return new Response(response.body, {51                        status: response.status,52                        statusText: response.statusText,53                        headers: newHeaders,54                    });55                })56                .catch((e) => console.error(e))57        );58    });5960} else {61    (() => {62        const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf");63        window.sessionStorage.removeItem("coiReloadedBySelf");64        const coepDegrading = (reloadedBySelf == "coepdegrade");6566        // You can customize the behavior of this script through a global `coi` variable.67        const coi = {68            shouldRegister: () => !reloadedBySelf,69            shouldDeregister: () => false,70            coepCredentialless: () => true,71            coepDegrade: () => true,72            doReload: () => window.location.reload(),73            quiet: false,74            ...window.coi75        };7677        const n = navigator;78        const controlling = n.serviceWorker && n.serviceWorker.controller;7980        // Record the failure if the page is served by serviceWorker.81        if (controlling && !window.crossOriginIsolated) {82            window.sessionStorage.setItem("coiCoepHasFailed", "true");83        }84        const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed");8586        if (controlling) {87            // Reload only on the first failure.88            const reloadToDegrade = coi.coepDegrade() && !(89                coepDegrading || window.crossOriginIsolated90            );91            n.serviceWorker.controller.postMessage({92                type: "coepCredentialless",93                value: (reloadToDegrade || coepHasFailed && coi.coepDegrade())94                    ? false95                    : coi.coepCredentialless(),96            });97            if (reloadToDegrade) {98                !coi.quiet && console.log("Reloading page to degrade COEP.");99                window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade");100                coi.doReload("coepdegrade");101            }102103            if (coi.shouldDeregister()) {104                n.serviceWorker.controller.postMessage({ type: "deregister" });105            }106        }107108        // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are109        // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here.110        if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return;111112        if (!window.isSecureContext) {113            !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required.");114            return;115        }116117        // In some environments (e.g. Firefox private mode) this won't be available118        if (!n.serviceWorker) {119            !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode.");120            return;121        }122123        n.serviceWorker.register(window.document.currentScript.src).then(124            (registration) => {125                !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope);126127                registration.addEventListener("updatefound", () => {128                    !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker.");129                    window.sessionStorage.setItem("coiReloadedBySelf", "updatefound");130                    coi.doReload();131                });132133                // If the registration is active, but it's not controlling the page134                if (registration.active && !n.serviceWorker.controller) {135                    !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker.");136                    window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling");137                    coi.doReload();138                }139            },140            (err) => {141                !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err);142            }143        );144    })();145}
📄 index.html
javascript
Copied
1<!DOCTYPE html>2<html lang="ko">3  <head>4    <meta charset="utf-8" />5    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />6    <meta7      name="viewport"8      content="width=device-width, initial-scale=1, shrink-to-fit=no"9    />10    <meta name="theme-color" content="#000000" />11    <script src="%PUBLIC_URL%/utils/coi-serviceworker.js"></script>12    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />13    <title>Project</title>14  </head>15  <body>16    <div id="root"></div>17  </body>18</html>19
📁 src
📄 ZoomComponentView.tsx
typescript
Copied
1import React, { memo, useCallback, useEffect, useRef, useState } from 'react'2import ZoomMtgEmbedded from '@zoom/meetingsdk/embedded'3import { makeStyles } from '@material-ui/styles'45import { ConnectionCallback, Meeting } from '../models/meeting'6import '../styles/zoom.css'78function ZoomComponentView({ info }: { info: Meeting }) {9    const styles = useStyles()10    const client = ZoomMtgEmbedded.createClient()11    const mettingArea = useRef<HTMLDivElement>(null)12    const [미팅에성공적으로참여하였는가, set미팅에성공적으로참여하였는가] = useState(false)13    const [미팅이종료되었는가, set미팅이종료되었는가] = useState(false)14    const [미팅을강제종료했는가, set미팅을강제종료했는가] = useState(false)15    const [hasError, setHasError] = useState(false)1617    const { clientId, meetingId, meetingPassword, signature } = info1819    const startMeeting = useCallback(async () => {20        if (mettingArea.current === null) return2122        try {23            await client.init({24                zoomAppRoot: mettingArea.current as HTMLElement,25                language: 'ko-KO',26                patchJsMedia: true,27                customize: {28                    video: {29                        /* 미팅 화면크기 조절 가능 여부 */30                        isResizable: false,31                        popper: {32                            /* 미팅 화면 드래그 이동 가능 여부 */33                            disableDraggable: true,34                        },35                        viewSizes: {36                            default: {37                                width: mettingArea.current.clientWidth - 4,38                                height: mettingArea.current.clientHeight + 20,39                            },40                        },41                        // SuspensionViewType.Gallery를 넣으면 실행이 되지 않음42                        defaultViewType: 'gallery' as any,43                    },44                    chat: {45                        popper: {46                            disableDraggable: true,47                        },48                    },49                },50            })51            await client.join({52                signature,53                sdkKey: clientId,54                meetingNumber: meetingId,55                password: meetingPassword,56                userName: 'TOPIA Live',57                userEmail: 'https://topialive.co.kr/',58                tk: '',59                zak: '',60            })61            set미팅에성공적으로참여하였는가(true)62        } catch (error: unknown) {63            setHasError(true)64            console.error(JSON.stringify(error))65        }66    }, [client, clientId, meetingId, meetingPassword, signature])6768    const leaveMeeting = useCallback(() => {69        const user = client.getCurrentUser()70        if (user != null) {71            client.leaveMeeting(user?.userId ?? 0)72        }73    }, [client])7475    /**76     * 화면 진입 시, 바로 회의에 참여77     */78    useEffect(() => {79        startMeeting()8081        const handleConnectionChange = ({ state, reason }: ConnectionCallback) => {82            /* 대기실에서 호스트의 수락을 받았을 때의 상태 캐치 */83            if (state === 'Reconnecting' && reason === 'on hold') {84                window.location.reload()85            }8687            if (state === 'Closed') {88                if (reason === 'ended by host') {89                    set미팅이종료되었는가(true)90                } else {91                    set미팅을강제종료했는가(true)92                }93            }94        }95        client.on('connection-change', handleConnectionChange)96        return () => {97            client.off('connection-change', handleConnectionChange)98        }99    }, [])100101    /**102     * 미팅에 성공적으로 참여했을 때, 기본 설정 변경103     */104    useEffect(() => {105        const mute = async (userId: number) => {106            await client.mute(true, userId)107        }108109        if (미팅에성공적으로참여하였는가 === true) {110            const user = client.getCurrentUser()111            if (user == null) return112113            /* 오디오가 활성화 된 상태라면 음소거 처리 */114            if (user.audio !== '') {115                mute(user.userId)116            }117        }118    }, [미팅에성공적으로참여하였는가, client])119120    /**121     * 화면 크기가 변경될 때, 미팅 사이즈도 반영122     */123    useEffect(() => {124        const resize = () => {125            if (mettingArea.current === null) return126            client.updateVideoOptions({127                viewSizes: {128                    default: {129                        width: mettingArea.current.clientWidth - 4,130                        height: mettingArea.current.clientHeight + 20,131                    },132                },133            })134        }135        window.addEventListener('resize', resize)136        return () => window.removeEventListener('resize', resize)137    }, [mettingArea.current?.offsetWidth, client])138139    return (140        <>141            {미팅이종료되었는가 === false && 미팅을강제종료했는가 === false ? (142                <div ref={mettingArea} className={styles.container}></div>143            ) : null}144            {미팅이종료되었는가 === true ? (145                <div className={styles.endContainer}>146                    <p className={styles.endText}>수업이 종료되었습니다.</p>147                </div>148            ) : null}149            {미팅을강제종료했는가 === true ? (150                <div className={styles.endContainer}>151                    <button className={styles.retry} onClick={() => window.location.reload()}>152                        다시 참여하기153                    </button>154                </div>155            ) : null}156            {hasError === true ? (157                <div className={styles.endContainer}>158                    <p className={styles.endText}>수업에 참여할 수 없습니다.</p>159                </div>160            ) : null}161        </>162    )163}164165export default memo(ZoomComponentView)166167const useStyles = makeStyles(() => ({168    container: {169        width: '100%',170        height: 'calc(100% - 73px)',171        zIndex: 5,172    },173    endContainer: {174        width: '100%',175        height: '100%',176        display: 'flex',177        justifyContent: 'center',178        alignItems: 'center',179        color: 'white',180    },181    retry: {182        padding: '15px 20px',183        backgroundColor: '#0D72ED',184        borderRadius: 10,185        border: 'none',186        color: 'white',187        fontSize: 14,188        fontWeight: 600,189        cursor: 'pointer',190    },191    endText: {192        fontSize: 20,193        fontWeight: 700,194        color: 'white',195    },196}))197
📄 zoom.css
css
Copied
1.zoommtg-drag-video {2  pointer-events: none;3}45div[aria-label="Zoom Web SDK Widget"] {6  box-shadow: none;7  border-radius: 0 0 8px 8px;8}910/* 미팅 참여 시, "회의 콘텐츠에 액세스하는 앱 확인" 플로팅 툴팁 */11div[role="tooltip"],12/* 비디오 상단 툴바 */13div[aria-label="Zoom Web SDK Widget"] > div:nth-child(1) > div:nth-child(1),14/* 화면 좌하단 닉네임 및 음소거 여부 확인 디자인 */15li[aria-label*="'s Avatar"],16/* 하단 툴바 버튼 */17button[aria-label="화면 공유"],18button[aria-label="화면 공유"] + div, /* 자막 */19button[aria-label="반응"],20button[aria-label="설정"],21button[aria-label="더 보기"],22button[aria-label="보안"],23button[aria-label="소회의실"]24{25  display: none;26}2728button[aria-label="오디오"],29button[aria-label*="비디오"],30button[aria-label="참가자"]31{32  pointer-events: none;33}3435/* 채팅 창 커스텀 */36div[aria-label="채팅"] {37  width: 100%;38  transform: translate(0px, 0px) !important;39  bottom: 0 !important;40}4142/* 채팅 창 bottom 부분 조금 뜨던 현상 수정 */43div[aria-label="채팅"] > div {44  height: 100%;45}46div[aria-label="채팅"] > div > div {47  min-width: inherit;48  height: 100%;49}5051div[aria-label="Floating Chat Message"] {52  display: none;53}5455/* 채팅 창 내 '귀하의 메시지를 볼 수 있는 사람은 누구입니까?' 비활성화 */56div[aria-label="chat-privacy-notice"] {57  display: none;58}59

  • Zoom SDK를 활용한 강의 관제 시스템 만들기
  • 무엇을 만들 것인가?
  • 예시 프로젝트 찾기
  • 설정하기
  • 예시 프로젝트 실행하기
  • 트러블 슈팅 1 : 단일 미팅 참여 불가능
  • 두 개 이상의 강의 동시에 참여하기
  • 트러블 슈팅 2 : n개 미팅 동시 참여 불가능
  • 트러블 슈팅 3 : Gallery View 사용 불가
  • 트러블 슈팅 4 : 특정 미팅 참여 불가
  • UI 작업과 사용성 강화
  • 코드