개인프로젝트/AI_Coding

ZEP.US 위젯 개발의 기술적 도전과제 - Chapter 2

해아's 2025. 8. 26. 22:17

ZEP.US 위젯 개발의 기술적 도전과제 - Chapter 2

ZEP.US 교육 위젯 시리즈 2편: ZEP 플랫폼에서 위젯을 개발하면서 마주한 기술적 제약사항들과 해결 방안을 상세히 분석합니다.

목차


ES5 JavaScript 제약 환경

ZEP 플랫폼의 JavaScript 한계

ZEP.US는 ES5 JavaScript 환경에서만 실행되어, 모던 JavaScript의 편리한 기능들을 전혀 사용할 수 없었습니다.

문제 1: 배열 및 객체 메서드 제한

// ❌ 사용 불가능한 ES6+ 문법들
const admins = players.filter(p => p.role >= 2000);
const names = admins.map(p => p.name);
const playerData = { ...baseData, role: player.role };

// ✅ 실제 사용한 ES5 호환 코드
var admins = [];
var names = [];
for (var i = 0; i < players.length; i++) {
    var player = players[i];
    if (player.role >= 2000) {
        admins.push(player);
        names.push(player.name);
    }
}

// 객체 확장을 위한 유틸리티 함수
function extendObject(target, source) {
    for (var key in source) {
        if (source.hasOwnProperty(key)) {
            target[key] = source[key];
        }
    }
    return target;
}

문제 2: 비동기 처리의 복잡성

// Promise나 async/await 사용 불가로 인한 콜백 지옥 발생
function sendToServerWithRetry(data, retryCount, callback) {
    retryCount = retryCount || 0;

    sendToServer(data, function(error, result) {
        if (error && retryCount < 3) {
            setTimeout(function() {
                sendToServerWithRetry(data, retryCount + 1, callback);
            }, 1000 * (retryCount + 1));
        } else {
            callback(error, result);
        }
    });
}

해결 방안: 유틸리티 함수 라이브러리 구축

// ES5 환경용 유틸리티 함수들
var Utils = {
    // Array.forEach 대체
    forEach: function(array, callback) {
        for (var i = 0; i < array.length; i++) {
            callback(array[i], i, array);
        }
    },

    // Array.find 대체  
    find: function(array, predicate) {
        for (var i = 0; i < array.length; i++) {
            if (predicate(array[i], i, array)) {
                return array[i];
            }
        }
        return undefined;
    },

    // 간단한 템플릿 시스템
    template: function(str, data) {
        return str.replace(/\{\{(\w+)\}\}/g, function(match, key) {
            return data[key] !== undefined ? data[key] : match;
        });
    },

    // 디바운스 함수
    debounce: function(func, delay) {
        var timeoutId;
        return function() {
            var context = this;
            var args = arguments;
            clearTimeout(timeoutId);
            timeoutId = setTimeout(function() {
                func.apply(context, args);
            }, delay);
        };
    }
};

// 실제 사용 예제
var adminPlayers = [];
Utils.forEach(App.players, function(player) {
    if (player.role >= 2000) {
        adminPlayers.push(player);
    }
});

위젯 통신 시스템 구축

postMessage API 기반 통신의 복잡성

ZEP 위젯은 iframe 환경에서 실행되어, 메인 스크립트와의 통신이 오직 postMessage를 통해서만 가능합니다.

통신 구조와 문제점

// 기본 통신 구조
/* 
┌─────────────────┐    postMessage    ┌──────────────────┐
│  Widget (HTML)  │ ─────────────────► │  main.js (ZEP)   │
│  - 사용자 입력  │                   │  - ZEP API 접근  │
│  - UI 렌더링    │ ◄───────────────── │  - 서버 통신     │
└─────────────────┘   sendMessage     └──────────────────┘
*/

// 문제점: 메시지 타입별 분기 처리의 복잡성
widget.onMessage.Add(function(player, data) {
    // 수십 개의 메시지 타입을 하나의 함수에서 처리해야 함
    if (data.type === 'getPlayers') {
        // 플레이어 데이터 수집 로직
    } else if (data.type === 'summonStudent') {
        // 학생 소환 로직
    } else if (data.type === 'sendMessage') {
        // 메시지 전송 로직
    } else if (data.type === 'updateTimer') {
        // 타이머 업데이트 로직
    }
    // ... 계속해서 분기가 늘어남
});

해결책: 메시지 라우터 패턴 구현

// 메시지 라우터 시스템
function MessageRouter() {
    this.handlers = {};
}

MessageRouter.prototype.register = function(messageType, handler) {
    if (!this.handlers[messageType]) {
        this.handlers[messageType] = [];
    }
    this.handlers[messageType].push(handler);
};

MessageRouter.prototype.handle = function(widget, player, data) {
    var handlers = this.handlers[data.type];
    if (handlers) {
        for (var i = 0; i < handlers.length; i++) {
            try {
                handlers[i](widget, player, data);
            } catch (error) {
                console.error('Message handler error:', error);
            }
        }
    } else {
        console.warn('No handler for message type:', data.type);
    }
};

// 글로벌 라우터 인스턴스
var messageRouter = new MessageRouter();

// 핸들러 등록
messageRouter.register('getPlayers', function(widget, player, data) {
    var playersData = collectPlayersData();
    widget.sendMessage({
        type: 'playersData', 
        data: playersData
    });
});

messageRouter.register('summonStudent', function(widget, player, data) {
    if (player.role >= 2000) { // 권한 확인
        var targetPlayer = findPlayerById(data.targetId);
        if (targetPlayer) {
            targetPlayer.teleport(player.tileX, player.tileY + 1);
        }
    }
});

// 실제 메시지 처리에서 라우터 사용
widget.onMessage.Add(function(player, data) {
    messageRouter.handle(widget, player, data);
});

위젯별 전용 통신 핸들러

관리자 위젯 전용 핸들러

// 학생 관리 위젯 전용 메시지 처리
function setupManagementWidgetMessages(widget, player) {
    widget.onMessage.Add(function(player, data) {
        switch (data.type) {
            case 'getStudentList':
                // 학생 목록 수집
                var students = [];
                for (var i = 0; i < App.players.length; i++) {
                    var p = App.players[i];
                    if (p.role < 2000) { // 일반 사용자만
                        students.push({
                            id: p.id,
                            name: p.name,
                            position: { x: p.tileX, y: p.tileY },
                            status: p.away ? 'Away' : 'Available'
                        });
                    }
                }
                widget.sendMessage({
                    type: 'studentListData',
                    data: students
                });
                break;

            case 'summonStudent':
                if (player.role >= 2000) {
                    var target = Utils.find(App.players, function(p) {
                        return p.id === data.studentId;
                    });

                    if (target) {
                        // 관리자 뒤로 소환
                        target.teleport(player.tileX, player.tileY + 1);
                        target.showCenterLabel('관리자가 소환했습니다', 0xFFFFFF, 0x000000, 2000);
                    }
                }
                break;

            case 'sendToStudent':
                if (player.role >= 2000 && data.message) {
                    var target = Utils.find(App.players, function(p) {
                        return p.id === data.studentId;
                    });

                    if (target) {
                        target.showCenterLabel(data.message, 0xFFFF00, 0x000000, 3000);
                    }
                }
                break;
        }
    });
}

타이머 위젯 전용 핸들러

// 타이머 관리 위젯 전용 메시지 처리
function setupTimerWidgetMessages(widget, player) {
    var breakTimer = null;
    var presentationTimer = null;

    widget.onMessage.Add(function(player, data) {
        if (data.type === 'startBreakTimer') {
            if (player.role >= 3001) { // 맵 소유자만
                var minutes = parseInt(data.minutes) || 5;
                var seconds = minutes * 60;

                // 기존 타이머 정리
                if (breakTimer) {
                    clearInterval(breakTimer);
                }

                // 전체 공지
                App.players.forEach(function(p) {
                    p.showCenterLabel('쉬는시간 ' + minutes + '분 시작!', 0x00FF00, 0x000000, 3000);
                });

                // 카운트다운 시작
                breakTimer = setInterval(function() {
                    seconds--;

                    if (seconds <= 0) {
                        clearInterval(breakTimer);
                        breakTimer = null;

                        // 종료 알림
                        App.players.forEach(function(p) {
                            p.showCenterLabel('쉬는시간 종료!', 0xFF0000, 0xFFFFFF, 3000);
                        });
                    } else if (seconds === 60) {
                        // 1분 전 알림
                        App.players.forEach(function(p) {
                            p.showCenterLabel('쉬는시간 1분 남았습니다', 0xFFFF00, 0x000000, 2000);
                        });
                    }
                }, 1000);
            }
        }

        if (data.type === 'startPresentationTimer') {
            if (player.role >= 3001) {
                var minutes = parseInt(data.minutes) || 3;

                // 발표 타이머 위젯 표시
                App.players.forEach(function(p) {
                    var timerWidget = p.showWidget('timer.html', 'bottomright', 200, 100);
                    timerWidget.sendMessage({
                        type: 'startTimer',
                        minutes: minutes
                    });
                });
            }
        }
    });
}

권한 관리 시스템 구현

역할 기반 접근 제어 (RBAC) 설계

권한 레벨 체계

// 권한 상수 정의
var PERMISSIONS = {
    STUDENT: 0,      // 일반 학습자
    EDITOR: 1000,    // 에디터 (메모장 확장 기능)
    STAFF: 2000,     // 교육 보조 (학생 관리)
    ADMIN: 3000,     // 관리자 (모니터링)
    MAP_OWNER: 3001  // 맵 소유자 (모든 기능)
};

// 권한 검증 함수
function hasPermission(player, requiredLevel) {
    var userLevel = player.role || 0;
    return userLevel >= requiredLevel;
}

// 기능별 권한 매핑
var featurePermissions = {
    'student_management': PERMISSIONS.STAFF,
    'monitoring': PERMISSIONS.ADMIN,
    'timer_management': PERMISSIONS.MAP_OWNER,
    'memo': PERMISSIONS.STUDENT // 모든 사용자
};

동적 UI 생성

// 권한에 따른 위젯 선택기 동적 구성
function generateWidgetSelector(player) {
    var availableWidgets = [];

    // 개인 메모장 (모든 사용자)
    availableWidgets.push({
        id: 'memo',
        title: '개인 메모장',
        icon: '📝',
        description: '개인 학습 노트 작성',
        requiredRole: PERMISSIONS.STUDENT
    });

    // 관리 기능들 (권한별)
    if (hasPermission(player, PERMISSIONS.STAFF)) {
        availableWidgets.push({
            id: 'management',
            title: '학생 관리', 
            icon: '👥',
            description: '학생 소환 및 관리',
            requiredRole: PERMISSIONS.STAFF
        });
    }

    if (hasPermission(player, PERMISSIONS.ADMIN)) {
        availableWidgets.push({
            id: 'monitoring',
            title: '실시간 모니터링',
            icon: '📊', 
            description: '접속자 현황 및 채팅 관리',
            requiredRole: PERMISSIONS.ADMIN
        });
    }

    if (hasPermission(player, PERMISSIONS.MAP_OWNER)) {
        availableWidgets.push({
            id: 'timer',
            title: '타이머 관리',
            icon: '⏱️',
            description: '수업 시간 관리 도구',
            requiredRole: PERMISSIONS.MAP_OWNER
        });
    }

    return availableWidgets;
}

권한 기반 메시지 필터링

// 메시지 처리 시 권한 검증
function processWidgetMessage(widget, player, data) {
    // 권한이 필요한 액션들 정의
    var restrictedActions = {
        'summonStudent': PERMISSIONS.STAFF,
        'sendBroadcast': PERMISSIONS.STAFF,
        'clearChat': PERMISSIONS.ADMIN,
        'startTimer': PERMISSIONS.MAP_OWNER
    };

    var requiredPermission = restrictedActions[data.type];

    if (requiredPermission && !hasPermission(player, requiredPermission)) {
        // 권한 없음 응답
        widget.sendMessage({
            type: 'error',
            message: '권한이 없습니다. (필요 권한: ' + requiredPermission + ')'
        });
        return;
    }

    // 권한 확인 통과시 실제 처리
    messageRouter.handle(widget, player, data);
}

서버 API 연동과 데이터 처리

기존 모니터링 서버 API 연동

실시간 모니터링 데이터 전송

// 서버 설정
var SERVER_CONFIG = {
    baseURL: 'https://[교육기관-도메인]',
    endpoints: {
        players: '/api/gather/save_players',
        chat: '/api/gather/save_chat'
    },
    apiKey: '[API-KEY-MASKED]',
    defaultSkId: 'sk_27',
    updateInterval: 5000 // 5초마다 전송
};

// 플레이어 데이터 수집 및 전송
function checkAndSendData(forceUpdate) {
    var currentData = collectCurrentPlayerData();
    var currentHash = generateDataHash(currentData);

    // 변경 감지 (강제 업데이트가 아닐 때만)
    if (!forceUpdate && currentHash === previousPlayersHash) {
        return; // 변경사항 없음
    }

    previousPlayersHash = currentHash;

    // 서버 전송
    sendToServer(SERVER_CONFIG.defaultSkId, currentData.players, currentData.count);
}

// 플레이어 데이터 수집
function collectCurrentPlayerData() {
    var playersData = {};
    var count = 0;
    var mapName = App.mapHashID || 'unknown';

    for (var i = 0; i < App.players.length; i++) {
        var player = App.players[i];
        var playerId = player.id || ('player_' + i);

        playersData[playerId] = {
            id: playerId,
            name: player.name || '익명',
            status: player.away ? 'Away' : 'Available',
            map: mapName,
            x: player.tileX || 0,
            y: player.tileY || 0,
            lastActive: Date.now(),
            isMobile: player.isMobile || false,
            role: player.role || 0,
            title: player.title || ''
        };
        count++;
    }

    return {
        players: playersData,
        count: count,
        mapName: mapName,
        timestamp: Date.now()
    };
}

채팅 로깅 시스템

// 채팅 메시지 감지 및 로깅
App.onSay.Add(function(player, message) {
    if (!ENABLE_CHAT_LOGGING) return;

    var chatData = {
        sk_id: CURRENT_SK_ID,
        player_id: player.id,
        player_name: player.name,
        message: message,
        timestamp: new Date().toISOString(),
        map: App.mapHashID || 'unknown'
    };

    // 서버로 채팅 로그 전송
    sendChatToServer(chatData);
});

// 채팅 데이터 서버 전송
function sendChatToServer(chatData) {
    var xhr = new XMLHttpRequest();
    xhr.open('POST', SERVER_CONFIG.baseURL + SERVER_CONFIG.endpoints.chat, true);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.setRequestHeader('X-API-Key', SERVER_CONFIG.apiKey);

    xhr.onload = function() {
        if (xhr.status === 200) {
            console.log('Chat logged successfully');
        } else {
            console.error('Chat logging failed:', xhr.status, xhr.responseText);
        }
    };

    xhr.onerror = function() {
        console.error('Chat logging network error');
    };

    xhr.send(JSON.stringify(chatData));
}

에러 처리 및 재시도 메커니즘

// 견고한 서버 통신을 위한 재시도 로직
function sendToServerWithRetry(data, maxRetries, currentRetry) {
    maxRetries = maxRetries || 3;
    currentRetry = currentRetry || 0;

    var xhr = new XMLHttpRequest();
    xhr.open('POST', SERVER_CONFIG.baseURL + SERVER_CONFIG.endpoints.players, true);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.setRequestHeader('X-API-Key', SERVER_CONFIG.apiKey);

    xhr.onload = function() {
        if (xhr.status === 200) {
            console.log('Data sent successfully');
        } else if (currentRetry < maxRetries) {
            // 재시도
            setTimeout(function() {
                sendToServerWithRetry(data, maxRetries, currentRetry + 1);
            }, Math.pow(2, currentRetry) * 1000); // 지수 백오프
        } else {
            console.error('Failed to send data after', maxRetries, 'retries');
        }
    };

    xhr.onerror = function() {
        if (currentRetry < maxRetries) {
            setTimeout(function() {
                sendToServerWithRetry(data, maxRetries, currentRetry + 1);
            }, Math.pow(2, currentRetry) * 1000);
        }
    };

    xhr.send(JSON.stringify(data));
}

다음 단계 미리보기

Chapter 3에서는 아키텍처 설계와 구현을 다룹니다. 위젯 간 네비게이션 구조, 상태 관리 패턴, 메모리 최적화 등 확장 가능하고 안정적인 시스템을 구축하기 위한 설계 원칙들을 공유하겠습니다.


관련 자료

Keywords: ZEP.US, JavaScript ES5, 위젯 통신, postMessage API, 권한 관리, 서버 API 연동, RBAC 시스템, 교육 플랫폼 개발


이 글은 ZEP.US 교육용 위젯 시스템 개발 시리즈의 두 번째 편입니다. 실제 개발 과정에서 직면한 기술적 도전과제들과 해결 방안을 실무진 관점에서 정리했습니다.

728x90
반응형