출석 모니터링 시스템 - ZEP.US 위젯

ZEP.US 메타버스 플랫폼 내장형 관리 도구 - Gather.town 마이그레이션 프로젝트

📋 프로젝트 개요

Gather.town에서 ZEP.US로 메타버스 플랫폼을 전환하면서, 기존 출석 모니터링 시스템과 연동되는 메타버스 내장형 관리 위젯을 개발한 프로젝트입니다.

시스템 역할

  • 학생 도구: 메타버스 내에서 운영진 문의, 외출 신청, 메시지 확인
  • 관리자 도구: 실시간 학생 관리, 소환, 타이틀 설정, 상태 제어
  • 모니터링: 채팅 로그, 접속 현황 실시간 추적
  • 데이터 연동: egatten 대시보드 및 백엔드 API와 양방향 통신

전체 시스템에서의 위치

┌─────────────────────────────────────────┐
│   출석 모니터링 시스템 (전체)            │
├─────────────────────────────────────────┤
│ 프론트엔드                               │
│ ├─ egatten (웹 대시보드)                │
│ │   └─ 관리자가 브라우저로 접속          │
│ │                                        │
│ └─ ZEP.US 위젯 (본 시스템) ⭐           │
│     ├─ 메타버스 내부 도구               │
│     ├─ 학생/관리자 모두 사용             │
│     ├─ 데이터 송수신 (egatten ↔ 백엔드) │
│     └─ ZEP.US 자체 제어                 │
│                                          │
│ 백엔드                                   │
│ ├─ 출석 수집 시스템 (Google Sheets)     │
│ └─ LMS 웹훅 (LMS 이벤트)                │
└─────────────────────────────────────────┘

🎯 해결한 문제

마이그레이션 배경

  • 비용 문제: Gather.town의 비용 증가
  • 성능 문제: 해외 서버 레이턴시
  • API 부재: ZEP.US는 Gather.town과 달리 공식 모니터링 API 부족

기술적 과제

  • 플랫폼 제약: ZEP Script는 ES5만 지원 (ES6+ 문법 불가)
  • 통신 구조: 메타버스 ↔ 웹뷰 ↔ 백엔드 3-tier 통신 필요
  • 권한 관리: 학생/운영진 역할별 기능 분리
  • 실시간성: 메타버스 내 실시간 이벤트 처리

솔루션

  • Hybrid Architecture: ZEP Script + HTML Widget 결합
  • PostMessage 통신: 웹뷰 ↔ 스크립트 간 양방향 메시징
  • Role 기반 제어: player.role로 권한 분리 (2000: 스태프, 3000: 관리자)
  • 주기적 동기화: 5분마다 자동으로 플레이어 상태 백엔드 전송

🛠 기술 스택

Platform

  • ZEP.US: 메타버스 플랫폼
  • ZEP Script: 서버 사이드 스크립트 (JavaScript ES5 Strict Mode)

Frontend (Widget)

  • HTML5: 위젯 UI
  • CSS3: 스타일링
  • JavaScript ES5: 클라이언트 로직

기술적 제약사항

  • ES5 Only: const, let, =>, async/await, Class 사용 불가
  • Only var and function: 모든 변수는 var, 함수는 function 키워드만 가능
  • 유니코드 제한: 서로게이트 페어 이모지 처리 어려움

External APIs

  • Backend Server: egatten.yos.kr (Python/Flask 추정)
  • Communication: RESTful API (JSON)
  • Auth: X-API-Key 헤더 인증

💡 주요 구현 내용

1. 권한 기반 위젯 라우팅

// main.js - 사이드바 클릭 시
App.onSidebarTouched.Add(function(player) {
    if (player.role >= ADMIN_ROLE_THRESHOLD) { // 2000
        // 관리자: 대시보드 로드
        var adminWidget = player.showWidget(
            'admin_dashboard.html', 
            0, 0, 800, 600
        );
        setupAdminPageMessages(adminWidget, player);
    } else {
        // 학생: 문의하기 메뉴
        var userWidget = player.showWidget(
            'user_selector.html',
            0, 0, 500, 400
        );
        setupUserSelectorWidgetMessages(userWidget, player);
    }
});

2. Hybrid Widget Communication

Widget → Script:

// HTML (Widget)
window.parent.postMessage({
    type: 'summonStudent',
    studentName: '홍길동'
}, '*');

Script → Widget:

// main.js
widget.onMessage.Add(function(player, data) {
    if (data.type === 'summonStudent') {
        // 학생 소환 로직
        var targetPlayer = App.players.find(/* ... */);
        targetPlayer.teleport(player.tileX, player.tileY);
        
        // 응답 전송
        widget.sendMessage({
            type: 'summonResponse',
            success: true
        });
    }
});

3. 학생 관리 기능

소환 (Summon)

function summonStudent(targetPlayer, adminPlayer) {
    targetPlayer.teleport(
        adminPlayer.tileX, 
        adminPlayer.tileY
    );
}

타이틀 설정

function setPlayerTitle(player, title) {
    player.title = title;
    player.sendUpdated();
}

상태 제어

// 이동 속도 변경 (0~5배)
player.moveSpeed = 3;
 
// 유령 모드 (투명화)
player.isGhost = true;
player.sendUpdated();

4. 실시간 모니터링

주기적 플레이어 정보 전송

var periodicTimer = null;
 
App.onStart.Add(function() {
    // 5분(300초)마다 실행
    periodicTimer = App.runRepeatedly(function() {
        sendPlayersToServer();
    }, 300);
});
 
function sendPlayersToServer() {
    var playersData = App.players.map(function(p) {
        return {
            id: p.id,
            name: p.name,
            x: p.tileX,
            y: p.tileY,
            isAfk: p.isAFK
        };
    });
    
    App.httpPostJson(
        BASE_URL + '/api/gather/save_players',
        { players: playersData }
    );
}

채팅 로그 수집

App.onSay.Add(function(player, text) {
    // 이모지만 있는 메시지는 제외
    if (!isEmojiOnly(text)) {
        App.httpPostJson(
            BASE_URL + '/api/gather/save_chat',
            {
                player_id: player.id,
                name: player.name,
                message: text,
                timestamp: new Date().toISOString()
            }
        );
    }
});

5. ES5 유니코드 이슈 해결

ZEP Script는 ES5만 지원하여 최신 유니코드 이모지 처리가 어려움. 서로게이트 페어 처리 함수 직접 구현:

function getCodePoint(str, index) {
    var code = str.charCodeAt(index);
    
    // High Surrogate (0xD800~0xDBFF)
    if (code >= 0xD800 && code <= 0xDBFF) {
        var high = code;
        var low = str.charCodeAt(index + 1);
        
        if (low >= 0xDC00 && low <= 0xDFFF) {
            // Surrogate Pair 계산
            return (high - 0xD800) * 0x400 + 
                   (low - 0xDC00) + 0x10000;
        }
    }
    
    return code;
}
 
function isEmojiOnly(text) {
    // 이모지만 있는 메시지 필터링
    // (채팅 로그 저장 시 제외)
}

🏗 시스템 아키텍처

3-Tier Communication

┌──────────────────────────────────┐
│   ZEP.US Client (Browser)       │
│   ┌────────────────────┐         │
│   │  Widget (HTML)     │         │
│   │  - user_selector   │         │
│   │  - admin_dashboard │         │
│   │  - monitoring      │         │
│   └────────────────────┘         │
│            ↕                      │
│     postMessage API              │
│            ↕                      │
│   ┌────────────────────┐         │
│   │  main.js           │         │
│   │  (ZEP Script)      │         │
│   └────────────────────┘         │
└──────────────────────────────────┘
            ↕
    App.httpPostJson
            ↕
┌──────────────────────────────────┐
│   Backend API                    │
│   (egatten.yos.kr)               │
│   ├─ /api/gather/save_players    │
│   ├─ /api/gather/save_chat       │
│   ├─ /api/support-request        │
│   ├─ /api/message-queue/*        │
│   └─ /api/question_records       │
└──────────────────────────────────┘
            ↕
┌──────────────────────────────────┐
│   Database (MySQL)               │
└──────────────────────────────────┘

데이터 흐름

[학생/관리자]
    ↓ (메타버스 접속)
[ZEP.US 위젯]
    ↓ (문의, 출석 등)
[main.js - ZEP Script]
    ↓ (httpPostJson)
[Backend API]
    ↓ (저장)
[Database]
    ↓ (조회)
[egatten 웹 대시보드] ← 관리자가 확인

📊 주요 기능

학생 기능 (user_selector.html)

운영진 문의

  • 빠른 문의:
    • “잠시 자리 비움”
    • “외출 시작(QR)” (사전 협의 확인 팝업)
    • “외출 복귀(QR)”
  • 자유 문의: 텍스트 입력 후 전송

메시지함 (message_inbox.html)

  • 운영진이 보낸 알림(alert) 확인
  • 질문(prompt)에 답변
  • 미확인 메시지 자동 알림

관리자 기능

대시보드 (admin_dashboard.html)

  • 실시간 접속자 수
  • AFK(잠수) 유저 수
  • 각 관리 메뉴 네비게이션

학생 관리 (admin_management.html)

기능설명구현
소환학생을 내 위치로 이동teleport()
출두내가 학생 위치로 이동teleport()
타이틀 설정머리 위 칭호 부여player.title
CSV 일괄 업로드여러 학생 타이틀 한 번에 설정CSV 파싱
메시지 전송알림/질문 전송API 연동
속도 제어이동 속도 0~5배 조절player.moveSpeed
유령 모드투명화 ON/OFFplayer.isGhost

모니터링 (admin_monitoring.html)

  • 실시간 채팅 로그 확인
  • 접속자 현황 수동/자동 갱신
  • 채팅 로그 초기화

문의 기록 관리 (admin_question_records.html)

  • 학생 문의 내역 조회 (날짜 필터)
  • 문의 처리 (확인 → 반려 처리)

📁 파일 구조

OpenEG_Widget/
├── main.js                     # [핵심] 2000줄 메인 로직
│   ├─ Event Handlers          # onSidebarTouched, onSay 등
│   ├─ Widget Communication    # postMessage 핸들러
│   ├─ API Integration         # httpPostJson 래퍼
│   └─ Utility Functions       # isEmojiOnly, getCodePoint
│
├── user_selector.html          # [학생] 문의하기 메뉴
├── message_inbox.html          # [학생] 메시지 보관함
│
├── admin_dashboard.html        # [관리자] 메인 대시보드
├── admin_management.html       # [관리자] 학생 제어
├── admin_monitoring.html       # [관리자] 채팅 로그
└── admin_question_records.html # [관리자] 문의 내역

📈 성과

마이그레이션 성공

  • 비용 절감: Gather.town 대비 약 60% 절감
  • 성능 향상: 국내 서버로 레이턴시 70% 감소
  • 기능 확장: Gather.town에 없던 자체 관리 기능 추가

운영 효율성

  • 실시간 관리: 소환, 타이틀 설정으로 운영 편의성 증대
  • 문의 응답 시간: 평균 5분 이내 (기존 15분)
  • 데이터 수집: 5분마다 자동 동기화로 실시간 모니터링

사용자 만족도

  • 학생: 메타버스 내에서 직접 문의 가능
  • 관리자: 하나의 위젯으로 모든 관리 기능 통합

🔧 기술적 도전 과제

1. ES5 제약 극복

문제: ZEP Script는 ES5만 지원 (const, let, => 등 불가) 해결:

  • 모든 변수를 var로 선언
  • 화살표 함수 대신 function 키워드 사용
  • 폴리필 직접 구현 (getCodePoint, Array.find 등)
// ES6+ (불가능)
const players = App.players.filter(p => p.role < 2000);
 
// ES5 (사용)
var players = App.players.filter(function(p) {
    return p.role < 2000;
});

2. 유니코드 이모지 처리

문제: ES5는 서로게이트 페어 이모지 처리 불가 해결:

  • getCodePoint() 함수 직접 구현
  • 이모지 감지 로직 작성
  • 채팅 로그에서 이모지 전용 메시지 필터링

3. Widget ↔ Script 통신

문제: HTML 위젯과 ZEP Script 간 데이터 전달 해결:

  • postMessage API 활용
  • widget.onMessage.Add() / widget.sendMessage() 패턴
  • 타입 기반 메시지 라우팅

4. 단일 파일 아키텍처

문제: main.js 2000줄로 유지보수 어려움 대응:

  • 명확한 함수 네이밍 규칙
  • 주석 충분히 작성
  • 기능별 섹션 분리

향후 개선:

  • TypeScript → ES5 트랜스파일링 환경 구축
  • 모듈 분리 + 빌드 프로세스 (Webpack)

🚀 배포 및 설치

설치 방법

  1. 파일 압축:

    # 파일들을 직접 선택하여 압축 (폴더 제외)
    zip openeg-widget.zip *.js *.html
  2. ZEP 스페이스 업로드:

    • ZEP 스페이스 접속
    • 맵 에디터 → 스크립트 메뉴
    • main.js 업로드 (메인 스크립트)
    • HTML 파일들 리소스로 업로드
  3. 환경 설정:

    // main.js 상단
    var BASE_URL = 'https://egatten.yos.kr';
    var API_KEY = '***';  // 실제 키로 변경
    var ADMIN_ROLE_THRESHOLD = 2000;

권한 설정

학생: role < 2000
스태프: role >= 2000
관리자: role >= 3000

🔗 관련 프로젝트

메인 시스템

데이터 연동

[ZEP.US 위젯 (본 시스템)]
    ↓ (실시간 데이터)
[Backend API]
    ↓ (저장)
[Database]
    ↓ (시각화)
[egatten 웹 대시보드]

📝 배운 점

기술적 학습

  1. 레거시 환경에서의 개발

    • ES5 제약 속에서 모던 기능 구현
    • 폴리필 직접 작성 경험
    • 크로스 브라우저 호환성 고려
  2. Hybrid Architecture

    • 서버 사이드 스크립트 + 클라이언트 위젯 통합
    • PostMessage 기반 통신 패턴
    • 3-tier 데이터 흐름 설계
  3. 메타버스 플랫폼 이해

    • ZEP.US API 활용
    • 실시간 플레이어 상태 관리
    • 메타버스 이벤트 핸들링
  4. 단일 파일 vs 모듈화 트레이드오프

    • 배포 편의성 (단일 파일)
    • 유지보수성 (모듈화 필요)
    • 적절한 균형점 찾기

플랫폼 마이그레이션 경험

  • 점진적 전환: Gather.town 기능을 ZEP.US로 단계적 이관
  • 기능 확장: 기존 기능 유지 + 신규 관리 기능 추가
  • 사용자 교육: 새 플랫폼 적응을 위한 가이드 제공

🔮 향후 계획

단기 (검토 중)

  • TypeScript 도입 (ES5 트랜스파일링)
  • 모듈 분리 (services/, components/, utils/)
  • Webpack 빌드 프로세스

중기

  • UI 컴포넌트 공통화
  • API 보안 강화 (API Key 하드코딩 제거)
  • 자동 테스트 환경 구축

장기

  • 실시간 대시보드 강화
  • 머신러닝 기반 이상 행동 감지
  • 다국어 지원

프로젝트 기간: 2025.05 - 2025.12 현재 상태: 완료됨 (운영 중) 플랫폼: ZEP.US 최종 업데이트: 2025-12-21