홈으로 이동컴포넌트 라이브러리로 이동이모티콘 스토리지로 이동
GitHub 프로필 새 창으로 열기
외부 시스템과 연동된 TOPIA Live 수업 출석 자동화 시스템 구축하기 배너
2025년 10월 17일·읽는 데 약 6분

외부 시스템과 연동된 TOPIA Live 수업 출석 자동화 시스템 구축하기

관련 글

AI로 학생의 성장을 피드백하기까지: TOPIA Growth 시스템 개발기

2026. 6. 21.

유료 라이브러리 PSPDFKit 대체 :: PDF 에디터 내재화 여정

2026. 3. 31.

OpenClaw로 hotfix 대응팀 구축하기 (TOPIA Harness)

2026. 3. 6.

문제 상황

저희 :topia_live: TOPIA Live 서비스는 안정적인 온라인 수업 환경을 제공하기 위해 자체 화상수업 솔루션을 별도로 개발하는 대신, Zoom 서비스를 구독하여 사용하고 있습니다. 또한, 여느 학원 시스템처럼 TOPIA Live에도 출석 관리 프로세스가 존재하며, 기존에는 학생의 출결을 크게 아래 두 가지 방식으로 관리해 왔습니다.

  1. 학부모가 자녀의 지각이나 결석 사유를 홈페이지에 미리 입력
  2. 강사가 수업에서 학생의 실제 출석 여부를 확인한 뒤 시스템에 직접 기록

하지만 온라인 수업의 출석 관리는 학생이 들어왔는지만 체크한다고 끝나는 일이 아닙니다. 온라인에서는 흥미롭게도 학생이 수업 도중 갑자기 이탈하거나, 해외에 있는 강사가 네트워크 문제로 접속이 끊기는 상황도 빈번하게 발생합니다. 이런 문제에 바로 대응하기 위해 TOPIA Live의 매니저들은 수업 시간 내내 Zoom 수업 상황을 관제하며 관리해왔습니다. 학생 수가 많지 않을 때는 이 방식으로도 운영할 수 있었지만, 학생이 늘어나고 그에 따라 반과 강사의 수도 증가하면 모든 수업을 사람이 실시간으로 확인하는 방식은 비효율적이며 지속하기 어렵습니다.

자연스럽게 이 과정을 자동화할 방법을 찾기 시작했고, Zoom에서 API를 통해 참가자의 입장과 퇴장 정보 등을 제공한다는 사실을 알게 되었습니다. 이를 활용하면 사람이 모든 수업을 직접 지켜보지 않아도 출석을 처리하고 수업 중 이탈 상황을 확인할 수 있다고 판단하여 출석 관리 자동화 작업에 착수하게 됩니다.

Zoom에서 어떻게 데이터를 받을 수 있을까?

시작하기에 앞서 Zoom이 어떤 데이터를 제공하고, 이를 어떤 방식으로 받을 수 있는지부터 살펴봅시다. 확인해보니 우리 서버에서 Zoom 쪽에 미팅 상태를 반복해서 조회하는 방식이 아니라, Webhook을 등록해두면 Zoom에서 이벤트가 발생할 때마다 우리 서버로 데이터를 보내주는 방식입니다.

이벤트를 전달받기 위해서는 우리 서버에 Zoom 전용 Webhook 엔드포인트를 만들고, 해당 주소를 Zoom 앱의 이벤트 수신 URL로 등록해야만 합니다. 실제로 Zoom에 등록되는 전체 경로는 다음과 같습니다.

typescript
Copied
1POST /api/v3/attendance/zoom/webhook

하지만 주소를 등록하는 것만으로 바로 이벤트를 받을 수 있는 상태가 되는 것은 아닙니다. Zoom은 등록된 엔드포인트가 정상적으로 동작하는지 확인하기 위해 endpoint.url_validation 이벤트를 전송합니다.

typescript
Copied
1{2  "event": "endpoint.url_validation",3  "payload": {4    "plainToken": "qgg8vlvZRS6UYooatFL8Aw"5  }6}

이후 서버에서는 전달받은 plainToken을 환경변수로 관리하는 Secret Token으로 해싱한 뒤, 원본 토큰과 함께 반환하도록 구현했습니다. 이 검증을 통과한 이후부터 Zoom에서 발생한 이벤트를 우리 서버로 전달받을 수 있습니다.

typescript
Copied
1const encryptedToken = crypto2  .createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN)3  .update(plainToken)4  .digest('hex')56return res.status(200).json({7  plainToken,8  encryptedToken,9})

Zoom에서 어떤 데이터를 받을 수 있을까?

이어서 Zoom에서 제공하는 Meeting 이벤트를 살펴본 결과, 출석과 수업 상태를 확인하는 데 유용한 정보는 다음과 같습니다.

이벤트확인할 수 있는 정보
meeting.started강사가 Zoom 미팅을 실제로 시작한 시각
meeting.endedZoom 미팅이 종료된 시각
meeting.participant_joined_waiting_room참가자가 대기실에 들어온 시각
meeting.participant_admitted참가자가 대기실에서 승인된 시각
meeting.participant_joined참가자가 실제 수업에 들어온 시각
meeting.participant_left참가자가 수업에서 나간 시각과 사유

추가로, 참가자 입장 이벤트의 payload에서는 미팅 ID와 UUID, 참가자의 표시 이름, 참가자 식별값, 입장 시각 등을 확인할 수 있습니다.

typescript
Copied
1{2  event: 'meeting.participant_joined',3  event_ts: 1783818300000,4  payload: {5    object: {6      id: '87330936895',7      uuid: 'FU5el5k3TsWwDjSAuETY/Q==',8      participant: {9        participant_uuid: '...',10        user_name: 'Harry (홍길동)',11        join_time: '2026-07-12T08:05:00Z'12      }13    }14  }15}

여기서 제공받는 event_ts란 Webhook이 서버에 도착한 시각이 아니라 해당 사건이 실제로 발생한 시각입니다. 웹훅의 특성 상 네트워크 상황에 따라 이벤트가 늦게 도착하거나 순서가 달라질 수 있기 때문에, 데이터의 정합성을 고려해서 event_ts는 중요한 지표 역할을 해주는 것 같습니다.

실제 서비스에 도입하면서 마주한 문제들

Zoom에서 필요한 이벤트를 Webhook으로 제공해준 덕분에 데이터를 수집하는 과정은 수월했습니다. 하지만 실제 출석 처리에 사용하려고 여러 케이스를 테스트하자 몇 가지 문제점이 드러나게 됩니다.

우선 Zoom의 참가자와 TOPIA Live의 학생을 연결시키는데 어려움이 있습니다. Zoom은 TOPIA Live에 내장된 서비스가 아니기 때문에 두 서비스의 사용자 정보가 항상 일치할 수가 없습니다. 즉, 학생이 Zoom에 등록한 이메일과 TOPIA Live의 이메일이 다를 수 있고, 미팅에서 사용하는 표시 이름도 참가자가 언제든지 변경할 수 있습니다. 결국 Webhook으로 전달된 이메일이나 이름만으로는 해당 참가자가 서비스의 어떤 학생인지 안정적으로 식별하기 어려웠습니다.

또한 수업 시작 시간 3~5분에 참가 이벤트가 한꺼번에 몰리게 되는 구조라, 이벤트 수신이 불안정할 수 있습니다. 비록 이벤트가 실제로 발생한 시각인 event_ts가 포함되어 내려오지만, 데이터를 받아보는 타이밍이 늦거나 순서가 일치하지 않을 수 있습니다. 하지만, 현업에서는 수업 시작 시각에 학생이 들어오지 않으면 매니저가 바로 연락을 해야될 수도 있고, 출석 기록은 학부모 안내와 수업 운영에도 직접적인 영향을 주게 됩니다. 이런 상황에서 이벤트 처리가 몇 분씩 늦어지거나, 수업 시작과 함께 몰린 트래픽 때문에 데이터가 유실된다면 자동화에 대한 신뢰를 의심할 수 밖에 없습니다.

Trouble Shooting : Zoom 참가자를 TOPIA Live 학생과 연결하기

먼저 Zoom 참가자를 TOPIA Live의 학생과 연결하는 문제부터 생각해 보겠습니다. 앞서 언급되었듯이 Webhook을 안정적으로 받더라도 Zoom의 참가자가 우리 서비스의 어떤 학생인지 알 수 없다면 데이터로 활용하기에 한계가 명확하여 이는 반드시 해결해야 하는 문제입니다.

이를 해결하기 위해 처음 검토한 방법은 registrant API를 사용하는 것입니다. 이 API에 우리 서비스에 등록된 학생의 이름과 이메일을 넣어 보내면 해당 학생은 미팅에 등록이 되며, 등록한 학생마다 고유한 registrant_id와 전용 join_url을 발급받게 됩니다. 이후 학생이 이 링크로 수업에 참여하면 Webhook에도 registrant_id가 함께 찍혀서 내려오기 때문에 고유한 식별자를 기준으로 학생을 연결할 수 있어 괜찮은 선택이라고 생각했습니다.

하지만 실제 적용 과정에서 두 가지 문제에 직면하게 됩니다. 먼저 테스트 환경에서 Zoom에 로그인하지 않은 학생으로 강의를 접근할 때, 기존에 보지 못했었던 Zoom 자체 화면이 노출되었고, 이름과 이메일을 입력해야 Zoom 미팅에 들어갈 수 있었습니다. TOPIA Live는 Zoom 계정 없이도 자사 서비스에 가입된 모든 학생들이 수업에 참여할 수 있어야 하며, 어린 학생들에게 기존에 접해보지 못한 낯선 화면이 등장하면 수업 입장에 차질이 생길 수 밖에 없습니다.

또한 당시에는 반에서 가장 먼저 입장한 학생이 registrant API를 통해 미팅에 등록되었는지 목록을 조회하고, 등록되어 있지 않다면 자신이 속한 반 전체 학생을 미팅에 등록하는 로직을 가지고 있었습니다. 하지만, 비슷한 시간대에 같은 반의 여러 학생이 동시에 입장하게 되면 모두 등록되어 있지 않다고 판단되어 동일한 registrant API를 반복해서 호출하게 되고, 이 상황에서 Zoom 쪽에서 에러가 발생하게 됩니다. 동일한 이메일로 같은 미팅에 등록자를 세 번 넘게 추가하면 Zoom 정책상 제한에 걸리게 되는 거였습니다. 한 명씩 입장시키던 테스트에서는 발견하기 어려운 동시성 문제였습니다. Zoom 미팅 등록 안내

plain text
Copied
1You have exceeded the daily rate limit of (3)2for Add meeting registrant API requests for the registrant.

대안으로 선택한 것은 Zoom의 invite_links API 입니다. 미팅 ID와 참가자의 닉네임을 전달하면, 해당 닉네임이 미리 설정되어 고정된 미팅 전용 입장 링크를 발급받을 수 있는 API 입니다. 학생의 영문 이름과 한글 이름을 조합한 Harry (박주호) 형태의 고정된 닉네임으로 전용 입장 링크를 발급한 후, 이를 zoom_invite_links 테이블에 학생 정보와 함께 기록하는 수순을 가졌습니다. 이후 Webhook으로 전달된 미팅 ID와 참가자 닉네임을 비교해 TOPIA Live 내의 학생과 동일한 지 찾아 입장과 퇴장을 비롯한 출석 정보를 기록하게 됩니다.

plain text
Copied
1TOPIA Live 학생 정보2        ↓3닉네임 생성: Harry (박주호)4        ↓5Zoom invite_links API 호출6        ↓7학생별 join_url 발급8        ↓9zoom_invite_links 저장10meeting_id + display_name + student_user_id + join_url11        ↓12Zoom Webhook 수신13meeting_id + user_name14        ↓15TOPIA Live 학생 식별16        ↓17participant_sessions에 입장·퇴장 기록 저장

또한, 학생이 수업 중 닉네임을 바꾸게 되면 의도한 매칭이 깨질 수도 있었기 때문에 강사 Account API를 통해 allow_participants_to_rename 설정을 비활성화했습니다. 위 방법을 통해서 학생 이름을 고정하면서 출석 여부를 판단하기 위해 매칭하는데 까지는 얼추 성공했습니다.

한꺼번에 들어오는 Webhook을 SQS로 처리하기

이제 남은 고민은 수업 시간대에 갑자기 몰려드는 Webhook을 어떻게 안정적으로 처리할 것인가입니다. 평소 요청량만 놓고 보면 문제가 되지 않지만, 학생들 대부분은 수업 시작 3~5분 전에 한꺼번에 접속하면서 특정 시간대에 요청이 집중됩니다. 실제 피크 시간대에는 약 40개 수업에서 3분 동안 최대 160건의 입장 Webhook이 들어온 적도 있었습니다. 여기에 네트워크 문제로 발생하는 퇴장과 재입장 이벤트까지 겹치면 순간적인 요청량은 더 늘어나게 됩니다. 이벤트 하나마다 학생 식별과 로그 저장, 입장 기록, 출석 반영을 위한 여러 DB 작업이 이어지므로, 요청이 몰리는 순간에는 DB 처리도 함께 밀리면서 출석이 제때 반영되지 않을 가능성이 있습니다.

당시 Webhook을 처리하는 API 서버는 데이터베이스별 커넥션 풀을 최대 10개로 운영하고 있었고, 커넥션을 얻기 위해 대기할 수 있는 시간도 30초로 제한되어 있었습니다. 이럴 경우, 여러 Webhook이 동시에 들어오면 먼저 들어온 요청들이 커넥션을 사용하고, 나머지는 커넥션이 반환될 때까지 서버 메모리에서 기다려야 합니다. 이 상태에서 DB 응답이 조금만 느려져도 대기 요청이 빠르게 늘어나고, 30초 안에 커넥션을 얻지 못한 작업은 실패할 수 있습니다. 출석 데이터는 일시적인 지연이나 오류가 발생했다고 그대로 버릴 수 없기 때문에, Webhook 수신과 실제 출석 처리를 분리하기 위해 두 과정 사이에 SQS FIFO 큐를 두는 선택을 하게 됩니다.

FIFO 큐를 선택한 이유는 같은 수업에서 발생한 입장과 퇴장 이벤트의 순서가 중요하기 때문입니다. 추가로, 미팅 UUID를 기준으로 같은 수업의 이벤트를 하나의 그룹에 묶고, Zoom의 요청 ID를 이용해 동일한 Webhook이 중복으로 처리되는 것도 막았습니다. 다만 큐에 들어온 순서와 이벤트가 실제로 발생한 순서는 다를 수 있으므로, 출석 시간을 계산할 때는 Zoom이 전달한 event_ts를 기준으로 삼았습니다.

typescript
Copied
1type ZoomWebhookBody = {2  event?: string3  event_ts?: number4  payload?: {5    object?: {6      id?: string | number7      uuid?: string8    }9  }10}1112// 값이 없을 때 "undefined"라는 문자열로 변환되는 것을 방지합니다.13const toOptionalString = (value: unknown) => {14  return value == null ? undefined : String(value)15}1617// HTTP 요청을 SQS에 전달할 메시지 형태로 변환합니다.18function createQueueMessage(req: Request) {19  const body = req.body as ZoomWebhookBody20  const meeting = body.payload?.object2122  return {23    event: body.event,24    eventTs: body.event_ts,25    meetingId: toOptionalString(meeting?.id),26    meetingUuid: toOptionalString(meeting?.uuid),2728    // Zoom이 전달한 요청 ID는 중복 이벤트를 구분하는 데 사용합니다.29    requestId: req.get('x-zm-request-id'),3031    // 후속 처리에 필요한 Webhook 원문은 가공하지 않고 전달합니다.32    payload: body,33  }34}3536export async function receiveZoomWebhook(37  req: Request,38  res: Response,39  next: NextFunction,40): Promise<void> {41  try {42    const message = createQueueMessage(req)4344    // SQS 적재가 끝난 뒤에만 Zoom에 정상 수신 응답을 보냅니다.45    await zoomWebhookQueueService.enqueueToFifo(message)4647    res.status(200).json({48      received: true,49    })50  } catch (error) {51    // 적재에 실패하면 200을 반환하지 않아 Zoom의 재전송을 유도합니다.52    next(error)53  }54}

Zoom에서 입장이나 퇴장 이벤트가 도착하면 전달받은 데이터를 바로 SQS로 넣은 뒤 200 OK를 반환하도록 처리했습니다. 이후 큐에 쌓인 이벤트는 별도의 Worker가 하나씩 가져가 일련의 과정을 처리합니다. Worker는 학생 식별부터 출석 반영까지 모두 끝난 메시지만 큐에서 삭제합니다. 처리 도중 오류가 발생하면 메시지를 삭제하지 않아 일정 시간이 지난 뒤 다시 가져오게 됩니다. 이를 통해 순간적으로 몰린 요청을 API 서버의 메모리에 쌓아두는 대신 SQS에 보관하고, Worker가 처리 가능한 속도로 나누어 처리할 수 있었습니다.

plain text
Copied
1Zoom Webhook 수신2        ↓3원본 이벤트를 SQS FIFO에 적재4        ↓5Zoom에 200 OK 응답6        ↓7Worker가 메시지 수신8        ↓9중복 이벤트 확인10        ↓11중복 이벤트인 경우 → 메시지 삭제 후 처리 종료12        ↓13새로운 이벤트인 경우 → Webhook 원본 로그 저장14        ↓15학생 식별16        ↓17입장·퇴장 기록 및 출석 반영18        ↓19처리 결과 확인20        ↓21처리 성공 → 메시지 삭제22        ↓23처리 실패 → 메시지를 삭제하지 않음24        ↓25Visibility Timeout 이후 큐에 다시 노출26        ↓27Worker가 다시 처리

SQS 도입 이후 학생으로 식별된 수업 참여 기록 1만 6천여 건을 확인한 결과, 99.2%가 Zoom 입장 후 3초 안에 생성됐고 10초를 넘긴 경우는 단 7건뿐이었습니다. 피크 시간대에 Webhook이 몰리는 상황에서도 학생의 입장 이벤트가 밀리지 않고 대부분 수초 안에 출석 처리로 이어졌으며, 순간적인 트래픽에 영향을 받지 않는 출석 자동화 흐름을 성공적으로 만들 수 있었습니다.

회고

외부 서비스인 Zoom과 연동하면서 참가자 식별과 API 제약, Webhook 처리 방식까지 함께 고민해야 했다는 점에서 특별한 경험이었습니다. 현재도 자동 출석 시스템은 안정적으로 운영되고 있으며, 학생이 출석하지 않으면 학부모에게 알림톡을 보내는 기능까지 연결되어 매니저가 여러 수업을 오가며 직접 확인하던 과정도 대부분 자동화되었습니다. 앞으로도 사람이 반복적으로 확인하고 처리하던 업무를 꾸준히 시스템으로 옮겨, 운영 인력이 단순 작업보다 판단과 대응이 필요한 일에 집중할 수 있도록 개발로 기여하는 즐거움을 얻고자 합니다.

  • 문제 상황
  • Zoom에서 어떻게 데이터를 받을 수 있을까?
  • Zoom에서 어떤 데이터를 받을 수 있을까?
  • 실제 서비스에 도입하면서 마주한 문제들
  • Trouble Shooting : Zoom 참가자를 TOPIA Live 학생과 연결하기
  • 한꺼번에 들어오는 Webhook을 SQS로 처리하기
  • 회고