현재 접속자 표시

현재 접속자 표시

현재 접속자 수와 로그인 사용자를 실시간으로 표시하는 애드온을 구현하는 방법을 학습합니다.

기본 접속자 추적

현재 접속자 추적 애드온

<?php
// addons/visitor_tracker/visitor_tracker.addon.php

if(!defined("__XE__")) exit();

/**
 * 현재 접속자 추적 애드온
 */
class VisitorTracker
{
    private $cleanup_interval = 300; // 5분
    private $session_timeout = 1800; // 30분

    public function __construct()
    {
        // 생성자
    }

    // 애드온 실행
    public function triggerDispHTMLHeader()
    {
        // 현재 사용자 정보 수집
        $this->trackVisitor();

        // 오래된 세션 정리
        $this->cleanupOldSessions();

        // 접속자 정보를 JavaScript 변수로 전달
        $this->setVisitorDataToJS();
    }

    // 방문자 추적
    private function trackVisitor()
    {
        $session_id = session_id();
        $current_time = time();
        $user_agent = $_SERVER['HTTP_USER_AGENT'];
        $ip_address = Context::getIpAddress();
        $current_url = Context::getRequestUri();

        // 로그인 사용자 정보
        $logged_info = Context::get('logged_info');
        $member_srl = $logged_info ? $logged_info->member_srl : 0;
        $nick_name = $logged_info ? $logged_info->nick_name : '';

        // 디바이스 정보 추출
        $device_info = $this->getDeviceInfo($user_agent);

        // 브라우저 정보 추출
        $browser_info = $this->getBrowserInfo($user_agent);

        // 세션 데이터 준비
        $session_data = new stdClass();
        $session_data->session_id = $session_id;
        $session_data->member_srl = $member_srl;
        $session_data->nick_name = $nick_name;
        $session_data->ip_address = $ip_address;
        $session_data->user_agent = $user_agent;
        $session_data->device_type = $device_info['type'];
        $session_data->device_name = $device_info['name'];
        $session_data->browser_name = $browser_info['name'];
        $session_data->browser_version = $browser_info['version'];
        $session_data->current_url = $current_url;
        $session_data->last_update = date('YmdHis', $current_time);
        $session_data->start_time = date('YmdHis', $current_time);

        // 기존 세션 확인
        $args = new stdClass();
        $args->session_id = $session_id;
        $output = executeQuery('visitor_tracker.getSession', $args);

        if($output->data) {
            // 기존 세션 업데이트
            $session_data->start_time = $output->data->start_time;
            $this->updateSession($session_data);
        } else {
            // 새 세션 생성
            $this->createSession($session_data);
        }
    }

    // 새 세션 생성
    private function createSession($session_data)
    {
        $output = executeQuery('visitor_tracker.insertSession', $session_data);

        if($output->toBool()) {
            // 입장 로그 기록
            $this->logVisitorAction($session_data->session_id, 'enter');
        }
    }

    // 세션 업데이트
    private function updateSession($session_data)
    {
        executeQuery('visitor_tracker.updateSession', $session_data);
    }

    // 오래된 세션 정리
    private function cleanupOldSessions()
    {
        // 정리 주기 확인
        $last_cleanup = Rhymix\Framework\Cache::get('visitor_tracker_last_cleanup');
        $current_time = time();

        if($last_cleanup && ($current_time - $last_cleanup) < $this->cleanup_interval) {
            return;
        }

        // 타임아웃된 세션 삭제
        $args = new stdClass();
        $args->timeout_time = date('YmdHis', $current_time - $this->session_timeout);

        $output = executeQuery('visitor_tracker.deleteExpiredSessions', $args);

        // 마지막 정리 시간 저장
        Rhymix\Framework\Cache::set('visitor_tracker_last_cleanup', $current_time, 3600);
    }

    // 디바이스 정보 추출
    private function getDeviceInfo($user_agent)
    {
        $device = array('type' => 'desktop', 'name' => 'Unknown');

        // 모바일 체크
        if(preg_match('/Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i', $user_agent)) {
            $device['type'] = 'mobile';

            if(preg_match('/iPhone/i', $user_agent)) {
                $device['name'] = 'iPhone';
            } elseif(preg_match('/iPad/i', $user_agent)) {
                $device['name'] = 'iPad';
            } elseif(preg_match('/Android/i', $user_agent)) {
                $device['name'] = 'Android';
            } elseif(preg_match('/BlackBerry/i', $user_agent)) {
                $device['name'] = 'BlackBerry';
            }
        } elseif(preg_match('/Tablet|iPad/i', $user_agent)) {
            $device['type'] = 'tablet';
            $device['name'] = 'Tablet';
        }

        return $device;
    }

    // 브라우저 정보 추출
    private function getBrowserInfo($user_agent)
    {
        $browser = array('name' => 'Unknown', 'version' => '');

        // 브라우저 패턴 매칭
        $browsers = array(
            'Chrome' => '/Chrome\/([0-9\.]+)/',
            'Firefox' => '/Firefox\/([0-9\.]+)/',
            'Safari' => '/Version\/([0-9\.]+).*Safari/',
            'Edge' => '/Edge\/([0-9\.]+)/',
            'Opera' => '/Opera\/([0-9\.]+)/',
            'Internet Explorer' => '/MSIE ([0-9\.]+)/'
        );

        foreach($browsers as $name => $pattern) {
            if(preg_match($pattern, $user_agent, $matches)) {
                $browser['name'] = $name;
                $browser['version'] = $matches[1];
                break;
            }
        }

        return $browser;
    }

    // 방문자 액션 로그
    private function logVisitorAction($session_id, $action)
    {
        $log_data = new stdClass();
        $log_data->session_id = $session_id;
        $log_data->action = $action;
        $log_data->action_time = date('YmdHis');

        executeQuery('visitor_tracker.insertActionLog', $log_data);
    }

    // JavaScript에 방문자 데이터 전달
    private function setVisitorDataToJS()
    {
        $visitor_stats = $this->getVisitorStats();

        $js_code = sprintf(
            '<script>window.visitorData = %s;</script>',
            json_encode($visitor_stats, JSON_UNESCAPED_UNICODE)
        );

        Context::addHtmlHeader($js_code);
    }

    // 방문자 통계 조회
    public function getVisitorStats()
    {
        // 현재 접속자 수
        $args = new stdClass();
        $args->timeout_time = date('YmdHis', time() - $this->session_timeout);

        $output = executeQuery('visitor_tracker.getCurrentVisitorCount', $args);
        $total_visitors = $output->data ? $output->data->count : 0;

        // 로그인 사용자 수
        $output = executeQuery('visitor_tracker.getLoggedInVisitorCount', $args);
        $logged_in_visitors = $output->data ? $output->data->count : 0;

        // 게스트 수
        $guest_visitors = $total_visitors - $logged_in_visitors;

        // 오늘 방문자 수
        $args->today = date('Ymd') . '000000';
        $output = executeQuery('visitor_tracker.getTodayVisitorCount', $args);
        $today_visitors = $output->data ? $output->data->count : 0;

        return array(
            'total' => $total_visitors,
            'logged_in' => $logged_in_visitors,
            'guest' => $guest_visitors,
            'today' => $today_visitors
        );
    }

    // 현재 접속자 목록 조회
    public function getCurrentVisitorList($limit = 50)
    {
        $args = new stdClass();
        $args->timeout_time = date('YmdHis', time() - $this->session_timeout);
        $args->list_count = $limit;

        $output = executeQuery('visitor_tracker.getCurrentVisitorList', $args);

        return $output->data ?: array();
    }
}

// 애드온 실행
$visitor_tracker = new VisitorTracker();

// 트리거 포인트별 실행
switch($called_position) {
    case 'before_module_proc':
        // 모듈 실행 전
        break;

    case 'after_module_proc':
        // 모듈 실행 후
        break;

    case 'before_display_content':
        // 화면 출력 전
        $visitor_tracker->triggerDispHTMLHeader();
        break;
}
?>

XML 쿼리 정의

visitor_tracker 모듈 쿼리

<!-- queries/getSession.xml -->
<query id="getSession" action="select">
    <tables>
        <table name="visitor_sessions" />
    </tables>
    <columns>
        <column name="*" />
    </columns>
    <conditions>
        <condition operation="equal" column="session_id" var="session_id" notnull="notnull" />
    </conditions>
</query>

<!-- queries/insertSession.xml -->
<query id="insertSession" action="insert">
    <tables>
        <table name="visitor_sessions" />
    </tables>
    <columns>
        <column name="session_id" var="session_id" />
        <column name="member_srl" var="member_srl" />
        <column name="nick_name" var="nick_name" />
        <column name="ip_address" var="ip_address" />
        <column name="user_agent" var="user_agent" />
        <column name="device_type" var="device_type" />
        <column name="device_name" var="device_name" />
        <column name="browser_name" var="browser_name" />
        <column name="browser_version" var="browser_version" />
        <column name="current_url" var="current_url" />
        <column name="start_time" var="start_time" />
        <column name="last_update" var="last_update" />
    </columns>
</query>

<!-- queries/updateSession.xml -->
<query id="updateSession" action="update">
    <tables>
        <table name="visitor_sessions" />
    </tables>
    <columns>
        <column name="current_url" var="current_url" />
        <column name="last_update" var="last_update" />
    </columns>
    <conditions>
        <condition operation="equal" column="session_id" var="session_id" notnull="notnull" />
    </conditions>
</query>

<!-- queries/getCurrentVisitorCount.xml -->
<query id="getCurrentVisitorCount" action="select">
    <tables>
        <table name="visitor_sessions" />
    </tables>
    <columns>
        <column name="COUNT(*)" alias="count" />
    </columns>
    <conditions>
        <condition operation="more" column="last_update" var="timeout_time" />
    </conditions>
</query>

<!-- queries/getCurrentVisitorList.xml -->
<query id="getCurrentVisitorList" action="select">
    <tables>
        <table name="visitor_sessions" />
    </tables>
    <columns>
        <column name="*" />
    </columns>
    <conditions>
        <condition operation="more" column="last_update" var="timeout_time" />
    </conditions>
    <navigation>
        <index var="sort_index" default="last_update" order="order_type" />
        <list_count var="list_count" default="50" />
    </navigation>
</query>

실시간 업데이트

AJAX 실시간 갱신

// 실시간 접속자 표시 JavaScript
(function() {
    var VisitorTracker = {
        updateInterval: 30000, // 30초
        updateTimer: null,

        init: function() {
            this.createWidget();
            this.startAutoUpdate();
            this.bindEvents();
        },

        // 위젯 생성
        createWidget: function() {
            var widget = document.createElement('div');
            widget.id = 'visitor-tracker-widget';
            widget.className = 'visitor-widget';
            widget.innerHTML = this.getWidgetHTML();

            // 위젯 위치 설정
            document.body.appendChild(widget);

            // 초기 데이터 설정
            if(window.visitorData) {
                this.updateDisplay(window.visitorData);
            }
        },

        // 위젯 HTML
        getWidgetHTML: function() {
            return `
                <div class="visitor-widget-header">
                    <h4>현재 접속자</h4>
                    <button type="button" class="btn-toggle">
                        <i class="xi-angle-up"></i>
                    </button>
                </div>
                <div class="visitor-widget-body">
                    <div class="visitor-stats">
                        <div class="stat-item">
                            <span class="label">전체</span>
                            <span class="value" id="total-visitors">0</span>
                        </div>
                        <div class="stat-item">
                            <span class="label">회원</span>
                            <span class="value" id="logged-visitors">0</span>
                        </div>
                        <div class="stat-item">
                            <span class="label">게스트</span>
                            <span class="value" id="guest-visitors">0</span>
                        </div>
                        <div class="stat-item">
                            <span class="label">오늘</span>
                            <span class="value" id="today-visitors">0</span>
                        </div>
                    </div>

                    <div class="visitor-list-container">
                        <h5>접속 중인 회원</h5>
                        <ul class="visitor-list" id="visitor-list">
                            <!-- 동적으로 채워짐 -->
                        </ul>
                    </div>

                    <div class="visitor-actions">
                        <button type="button" class="btn-detail" onclick="VisitorTracker.showDetailModal()">
                            상세보기
                        </button>
                        <button type="button" class="btn-refresh" onclick="VisitorTracker.refreshData()">
                            새로고침
                        </button>
                    </div>
                </div>
            `;
        },

        // 자동 업데이트 시작
        startAutoUpdate: function() {
            var self = this;

            this.updateTimer = setInterval(function() {
                self.updateVisitorData();
            }, this.updateInterval);
        },

        // 접속자 데이터 업데이트
        updateVisitorData: function() {
            var self = this;

            fetch(location.href, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: 'module=addon&act=getVisitorData&addon=visitor_tracker'
            })
            .then(response => response.json())
            .then(data => {
                if(data.error === 0) {
                    self.updateDisplay(data.data);
                }
            })
            .catch(error => {
                console.error('Visitor data update failed:', error);
            });
        },

        // 화면 업데이트
        updateDisplay: function(data) {
            // 통계 업데이트
            document.getElementById('total-visitors').textContent = data.stats.total;
            document.getElementById('logged-visitors').textContent = data.stats.logged_in;
            document.getElementById('guest-visitors').textContent = data.stats.guest;
            document.getElementById('today-visitors').textContent = data.stats.today;

            // 접속자 목록 업데이트
            this.updateVisitorList(data.visitors);

            // 애니메이션 효과
            this.animateUpdate();
        },

        // 접속자 목록 업데이트
        updateVisitorList: function(visitors) {
            var listElement = document.getElementById('visitor-list');
            var html = '';

            if(visitors && visitors.length > 0) {
                visitors.forEach(function(visitor) {
                    if(visitor.member_srl > 0) {
                        var deviceIcon = visitor.device_type === 'mobile' ? 'xi-mobile' : 'xi-desktop';
                        var browserIcon = 'xi-' + visitor.browser_name.toLowerCase();

                        html += `
                            <li class="visitor-item">
                                <div class="visitor-info">
                                    <span class="nickname">${visitor.nick_name}</span>
                                    <span class="device-info">
                                        <i class="${deviceIcon}"></i>
                                        <i class="${browserIcon}"></i>
                                    </span>
                                </div>
                                <div class="visitor-meta">
                                    <span class="duration">${this.formatDuration(visitor.start_time)}</span>
                                    <span class="current-page">${this.formatUrl(visitor.current_url)}</span>
                                </div>
                            </li>
                        `;
                    }
                }.bind(this));
            } else {
                html = '<li class="no-visitors">접속 중인 회원이 없습니다.</li>';
            }

            listElement.innerHTML = html;
        },

        // 접속 시간 포맷
        formatDuration: function(startTime) {
            var start = new Date(
                startTime.substr(0,4),
                startTime.substr(4,2) - 1,
                startTime.substr(6,2),
                startTime.substr(8,2),
                startTime.substr(10,2),
                startTime.substr(12,2)
            );
            var now = new Date();
            var diff = Math.floor((now - start) / 1000 / 60);

            if(diff < 1) return '방금 전';
            if(diff < 60) return diff + '분';
            return Math.floor(diff / 60) + '시간';
        },

        // URL 포맷
        formatUrl: function(url) {
            if(url.length > 30) {
                return url.substr(0, 30) + '...';
            }
            return url;
        },

        // 업데이트 애니메이션
        animateUpdate: function() {
            var widget = document.getElementById('visitor-tracker-widget');
            widget.classList.add('updating');

            setTimeout(function() {
                widget.classList.remove('updating');
            }, 500);
        },

        // 이벤트 바인딩
        bindEvents: function() {
            // 위젯 토글
            document.querySelector('.btn-toggle').addEventListener('click', function() {
                var widget = document.getElementById('visitor-tracker-widget');
                widget.classList.toggle('collapsed');

                var icon = this.querySelector('i');
                icon.classList.toggle('xi-angle-up');
                icon.classList.toggle('xi-angle-down');
            });

            // 위젯 드래그
            this.makeDraggable();
        },

        // 위젯 드래그 가능하게 만들기
        makeDraggable: function() {
            var widget = document.getElementById('visitor-tracker-widget');
            var header = widget.querySelector('.visitor-widget-header');

            var isDragging = false;
            var currentX;
            var currentY;
            var initialX;
            var initialY;
            var xOffset = 0;
            var yOffset = 0;

            header.addEventListener('mousedown', function(e) {
                if(e.target.closest('.btn-toggle')) return;

                initialX = e.clientX - xOffset;
                initialY = e.clientY - yOffset;

                if(e.target === header || header.contains(e.target)) {
                    isDragging = true;
                    widget.classList.add('dragging');
                }
            });

            document.addEventListener('mousemove', function(e) {
                if(isDragging) {
                    e.preventDefault();
                    currentX = e.clientX - initialX;
                    currentY = e.clientY - initialY;

                    xOffset = currentX;
                    yOffset = currentY;

                    widget.style.transform = `translate(${currentX}px, ${currentY}px)`;
                }
            });

            document.addEventListener('mouseup', function() {
                if(isDragging) {
                    isDragging = false;
                    widget.classList.remove('dragging');
                }
            });
        },

        // 상세보기 모달
        showDetailModal: function() {
            // 상세 접속자 정보 모달 구현
            var modal = document.createElement('div');
            modal.className = 'visitor-detail-modal';
            modal.innerHTML = `
                <div class="modal-content">
                    <div class="modal-header">
                        <h3>접속자 상세 정보</h3>
                        <button type="button" class="btn-close" onclick="this.closest('.visitor-detail-modal').remove()">
                            <i class="xi-close"></i>
                        </button>
                    </div>
                    <div class="modal-body">
                        <div class="loading">로딩 중...</div>
                    </div>
                </div>
            `;

            document.body.appendChild(modal);

            // 상세 데이터 로드
            this.loadDetailData(modal);
        },

        // 상세 데이터 로드
        loadDetailData: function(modal) {
            fetch(location.href, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: 'module=addon&act=getDetailVisitorData&addon=visitor_tracker'
            })
            .then(response => response.json())
            .then(data => {
                var html = this.renderDetailData(data.data);
                modal.querySelector('.modal-body').innerHTML = html;
            })
            .catch(error => {
                modal.querySelector('.modal-body').innerHTML = '<div class="error">데이터 로드 실패</div>';
            });
        },

        // 상세 데이터 렌더링
        renderDetailData: function(data) {
            // 상세 데이터 HTML 생성
            return '<div>상세 접속자 정보</div>';
        },

        // 데이터 새로고침
        refreshData: function() {
            this.updateVisitorData();
        }
    };

    // 페이지 로드 완료 후 초기화
    if(document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function() {
            VisitorTracker.init();
        });
    } else {
        VisitorTracker.init();
    }

    // 전역 객체로 등록
    window.VisitorTracker = VisitorTracker;
})();

CSS 스타일

위젯 스타일링

/* 접속자 위젯 스타일 */
#visitor-tracker-widget {
    position: fixed;
    top: 20px;
    right: 20px;
    width: 280px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.15);
    z-index: 9999;
    font-size: 14px;
    transition: all 0.3s ease;
}

#visitor-tracker-widget.collapsed .visitor-widget-body {
    display: none;
}

#visitor-tracker-widget.dragging {
    user-select: none;
    z-index: 10000;
}

#visitor-tracker-widget.updating {
    animation: updatePulse 0.5s ease;
}

@keyframes updatePulse {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.02); }
}

/* 헤더 */
.visitor-widget-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px 15px;
    background: #f8f9fa;
    border-radius: 8px 8px 0 0;
    cursor: move;
    border-bottom: 1px solid #e9ecef;
}

.visitor-widget-header h4 {
    margin: 0;
    font-size: 14px;
    font-weight: 600;
    color: #333;
}

.btn-toggle {
    background: none;
    border: none;
    cursor: pointer;
    color: #666;
    transition: color 0.3s;
}

.btn-toggle:hover {
    color: #007bff;
}

/* 바디 */
.visitor-widget-body {
    padding: 15px;
}

/* 통계 */
.visitor-stats {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    margin-bottom: 15px;
}

.stat-item {
    text-align: center;
    padding: 8px;
    background: #f8f9fa;
    border-radius: 4px;
}

.stat-item .label {
    display: block;
    font-size: 11px;
    color: #666;
    margin-bottom: 2px;
}

.stat-item .value {
    display: block;
    font-size: 16px;
    font-weight: bold;
    color: #007bff;
}

/* 접속자 목록 */
.visitor-list-container h5 {
    margin: 0 0 10px 0;
    font-size: 13px;
    color: #333;
}

.visitor-list {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 200px;
    overflow-y: auto;
}

.visitor-item {
    padding: 8px 0;
    border-bottom: 1px solid #f0f0f0;
}

.visitor-item:last-child {
    border-bottom: none;
}

.visitor-info {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 4px;
}

.nickname {
    font-weight: 500;
    color: #333;
}

.device-info i {
    font-size: 12px;
    color: #666;
    margin-left: 4px;
}

.visitor-meta {
    display: flex;
    justify-content: space-between;
    font-size: 11px;
    color: #999;
}

.no-visitors {
    text-align: center;
    color: #999;
    padding: 20px 0;
}

/* 액션 버튼 */
.visitor-actions {
    display: flex;
    gap: 8px;
    margin-top: 15px;
}

.visitor-actions button {
    flex: 1;
    padding: 6px 12px;
    border: 1px solid #ddd;
    background: #fff;
    border-radius: 4px;
    font-size: 12px;
    cursor: pointer;
    transition: all 0.3s;
}

.visitor-actions button:hover {
    background: #f8f9fa;
    border-color: #007bff;
    color: #007bff;
}

/* 상세보기 모달 */
.visitor-detail-modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.5);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 10001;
}

.visitor-detail-modal .modal-content {
    background: #fff;
    border-radius: 8px;
    width: 90%;
    max-width: 600px;
    max-height: 80%;
    overflow: hidden;
}

.visitor-detail-modal .modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px;
    border-bottom: 1px solid #e9ecef;
}

.visitor-detail-modal .modal-body {
    padding: 20px;
    overflow-y: auto;
    max-height: 400px;
}

/* 반응형 */
@media (max-width: 768px) {
    #visitor-tracker-widget {
        position: relative;
        top: auto;
        right: auto;
        width: 100%;
        margin: 10px 0;
        border-radius: 0;
    }

    .visitor-widget-header {
        border-radius: 0;
    }
}

모범 사례

  1. 성능: 세션 정리 주기적 실행
  2. 보안: IP 주소 등 민감 정보 보호
  3. 확장성: 대용량 트래픽 고려한 설계
  4. 사용자 경험: 실시간 업데이트와 직관적 UI
  5. 호환성: 다양한 브라우저 환경 고려

다음 단계

현재 접속자 표시를 구현했다면, 서명 기능을 학습하세요.