현재 접속자 표시¶
현재 접속자 수와 로그인 사용자를 실시간으로 표시하는 애드온을 구현하는 방법을 학습합니다.
기본 접속자 추적¶
현재 접속자 추적 애드온¶
<?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;
}
}
모범 사례¶
- 성능: 세션 정리 주기적 실행
- 보안: IP 주소 등 민감 정보 보호
- 확장성: 대용량 트래픽 고려한 설계
- 사용자 경험: 실시간 업데이트와 직관적 UI
- 호환성: 다양한 브라우저 환경 고려
다음 단계¶
현재 접속자 표시를 구현했다면, 서명 기능을 학습하세요.