이전 글에서는 WebSocket 서버 서비스 설치 및 구성 과정에 대해 설명했습니다.
이 서비스는 광범위한 기능을 제공하며, 이 가이드에서는 그 중 몇 가지를 살펴보겠습니다. 이전 글에서는 스크립트를 호출하고 WebSocket을 통해 데이터를 지속적으로 보내는 기본적인 구성만 보여드렸습니다. 프로덕션 서버를 설정할 때 이 방법이 항상 효율적인 것은 아닙니다.
1. 데이터 변경 시에만 전송
이 코드에서는 새로운 JSON 문자열이 이전 문자열과 다른 경우에만 클라이언트로 데이터를 보냅니다.
const newJSON = JSON.stringify(live);
if (newJSON !== previousLiveJSON) {
previousLiveJSON = newJSON;
latestLiveData = live;
broadcast();
console.log(`실시간 데이터 업데이트됨 (${live.length}회 방문)`);
}
이 방법은 특히 마이크로컨트롤러로 데이터를 보낼 때 클라이언트 리소스를 크게 절약하며, 이러한 경우 부하를 크게 줄입니다.
2. 두 개의 요청을 하나로 결합
두 요청이 동시에 실행되고 필요한 데이터를 제공하도록 코드를 구성할 수 있습니다. 이 예에서는 Live 및 Summary라는 두 가지 다른 유형의 데이터를 받습니다.
/root/websocket-server/server.js
import express from "express";
import { WebSocketServer } from "ws";
import fetch from "node-fetch";
import https from "https";
import fs from "fs";
import url from "url";
import dotenv from "dotenv";
dotenv.config();
const PORT = process.env.PORT || 8080;
const MATOMO_URL = process.env.MATOMO_URL;
const MATOMO_TOKEN = process.env.MATOMO_TOKEN;
const ID_SITE = process.env.MATOMO_SITE_ID || 1;
const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 1000;
const VALID_TOKENS = process.env.VALID_TOKENS.split(",").map(t => t.trim());
const SSL_KEY = process.env.SSL_KEY;
const SSL_CERT = process.env.SSL_CERT;
const TIMEZONE_OFFSET = 3; // 귀하의 +3 시간대
const options = {
key: fs.readFileSync(SSL_KEY),
cert: fs.readFileSync(SSL_CERT)
};
const app = express();
const server = https.createServer(options, app);
const wss = new WebSocketServer({ server });
// === 전역 변수 ===
let latestLiveData = [];
let latestSummary = {};
let previousLiveJSON = "";
let previousSummaryJSON = "";
// ===== 실시간 통계 가져오기 (최근 방문) =====
async function fetchMatomoLive() {
const apiUrl = `${MATOMO_URL}index.php?module=API&method=Live.getLastVisitsDetails&format=JSON&period=day&date=today&idSite=${ID_SITE}`;
const res = await fetch(apiUrl, {
method: "POST",
body: new URLSearchParams({
token_auth: MATOMO_TOKEN,
filter_limit: "5",
expanded: "1"
}),
headers: { "Content-Type": "application/x-www-form-urlencoded" }
});
const data = await res.json();
const visits = [];
if (Array.isArray(data)) {
for (const visit of data) {
if (
visit.latitude &&
visit.longitude &&
!isNaN(visit.latitude) &&
!isNaN(visit.longitude)
) {
let pageTitle = "";
if (Array.isArray(visit.actionDetails)) {
for (const action of visit.actionDetails) {
if (action.pageTitle) {
pageTitle = action.pageTitle;
break;
}
}
}
let localTime = "";
if (visit.lastActionDateTime) {
try {
const date = new Date(visit.lastActionDateTime.replace(" ", "T") + "Z");
const shifted = new Date(date.getTime() + TIMEZONE_OFFSET * 3600000);
localTime = shifted.toISOString().replace("T", " ").substring(0, 19);
} catch {
localTime = visit.lastActionDateTime;
}
}
visits.push({
latitude: parseFloat(visit.latitude),
longitude: parseFloat(visit.longitude),
city: visit.city || "",
countryCode: visit.countryCode || "",
country: visit.country || "",
actions: visit.actions || "",
visitIp: visit.visitIp || "",
operatingSystem: visit.operatingSystem || "",
browserName: visit.browserName || "",
source: visit.referrerName || "",
lastActionDateTime: localTime
});
}
}
}
return visits;
}
// ===== 요약 통계 가져오기 (VisitsSummary) =====
async function fetchSummaryData() {
async function getMetric(method, date) {
const url = `${MATOMO_URL}index.php?module=API&method=${method}&format=JSON&period=day&date=${date}&idSite=${ID_SITE}`;
const res = await fetch(url, {
method: "POST",
body: new URLSearchParams({ token_auth: MATOMO_TOKEN }),
headers: { "Content-Type": "application/x-www-form-urlencoded" }
});
const data = await res.json();
return data?.value ? parseInt(data.value) : 0;
}
async function getDayData(date) {
const visits = await getMetric("VisitsSummary.getVisits", date);
const timeSec = await getMetric("VisitsSummary.getSumVisitsLength", date);
const h = Math.floor(timeSec / 3600);
const m = Math.floor((timeSec % 3600) / 60);
const s = timeSec % 60;
const formattedTime = `${h.toString().padStart(2, "0")}:${m
.toString()
.padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return { visits, time_spent: formattedTime };
}
const today = new Date(Date.now() + TIMEZONE_OFFSET * 3600000);
const yesterday = new Date(today.getTime() - 86400000);
const dayBefore = new Date(today.getTime() - 2 * 86400000);
function fmt(d) {
return d.toISOString().substring(0, 10);
}
const [todayStats, yStats, dbStats] = await Promise.all([
getDayData("today"),
getDayData(fmt(yesterday)),
getDayData(fmt(dayBefore))
]);
return {
today: todayStats,
yesterday: yStats,
day_before: dbStats
};
}
// ===== 주요 업데이트 기능 =====
async function fetchMatomoData() {
try {
const [live, summary] = await Promise.all([
fetchMatomoLive(),
fetchSummaryData()
]);
// --- 변경 사항 확인 ---
const newLiveJSON = JSON.stringify(live);
const newSummaryJSON = JSON.stringify(summary);
const liveChanged = newLiveJSON !== previousLiveJSON;
const summaryChanged = newSummaryJSON !== previousSummaryJSON;
// --- 캐시 업데이트 ---
if (liveChanged) {
previousLiveJSON = newLiveJSON;
latestLiveData = live;
console.log(`실시간 데이터 업데이트됨 (${live.length}회 방문)`);
} else {
console.log("⏸ 실시간 데이터 변경 없음");
}
if (summaryChanged) {
previousSummaryJSON = newSummaryJSON;
latestSummary = summary;
console.log("요약 업데이트됨");
} else {
console.log("⏸ 요약 변경 없음");
}
// --- 데이터가 변경된 경우에만 클라이언트로 업데이트 전송 ---
if (liveChanged || summaryChanged) {
const combined = JSON.stringify({
visits_live: latestLiveData,
summary: latestSummary
});
wss.clients.forEach(client => {
if (client.readyState === 1) {
client.send(combined);
}
});
console.log("업데이트된 데이터를 클라이언트로 전송함");
} else {
console.log("변경 사항 없음 - 전송 건너뜀");
}
} catch (err) {
console.error("데이터 가져오기 오류:", err.message);
}
}
// ===== 루프 시작 =====
setInterval(fetchMatomoData, FETCH_INTERVAL);
fetchMatomoData();
// ===== WebSocket =====
wss.on("connection", (ws, req) => {
const query = url.parse(req.url, true).query;
const token = query.token;
if (!VALID_TOKENS.includes(token)) {
console.log(`토큰으로 연결 거부됨: ${token}`);
ws.close(4001, "유효하지 않은 토큰");
return;
}
console.log(`클라이언트 연결됨 (${token})`);
if (latestLiveData.length > 0 || Object.keys(latestSummary).length > 0) {
ws.send(JSON.stringify({ visits_live: latestLiveData, summary: latestSummary }));
}
ws.on("close", () => {
console.log(`클라이언트 ${token} 연결 해제됨`);
});
});
// ===== HTTP 디버그 =====
app.get("/debug", (req, res) => {
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.send(
JSON.stringify({ visits_live: latestLiveData, summary: latestSummary }, null, 2)
);
});
server.listen(PORT, () => {
console.log(`HTTPS 서버가 포트 ${PORT}에서 실행 중`);
});
이 예에서는 두 개의 다른 API 호출에서 직접 데이터를 요청하고 PHP 스크립트를 우회하여 단일 응답으로 보냅니다.
3. 안전한 인증 설정
위의 코드에서는 변수를 사용하여 인증 데이터를 저장합니다. 이를 처리하려면 다음 패키지를 설치해야 합니다.
npm install dotenv
구성 파일은 다음과 같습니다.
nano /root/websocket-server/.env
# === API 구성 ===
MATOMO_URL=https://domain.tld/path/
MATOMO_TOKEN=YOUR_API_KEY
MATOMO_SITE_ID=1
# === 서버 구성 ===
PORT=8080
FETCH_INTERVAL=1000
# === WebSocket 액세스 ===
VALID_TOKENS=WSS_CLIENT_TOKEN_1,WSS_CLIENT_TOKEN_2
# === SSL 경로 ===
SSL_KEY=/etc/letsencrypt/live/wss.domain.tld/privkey.pem
SSL_CERT=/etc/letsencrypt/live/wss.domain.tld/fullchain.pem
보시다시피 인증은 API 요청과 WebSocket 서버에 연결할 때 클라이언트 모두에 대해 수행됩니다.
WSS에 연결하려면 토큰이 있는 주소를 사용하십시오.
/?token=WSS_CLIENT_TOKEN_1
4. API 데이터 요청 빈도
구성은 다음 줄에서 수행됩니다.
const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 1000;
매개변수는 밀리초 단위로 지정되며, 여기서 1000 = 1초입니다.
10초의 경우 다음과 같습니다.
const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 10000;
저희 코드에서는 이 매개변수가 .env에 정의되지 않은 경우 기본적으로 설정됩니다.
이미 거기에 지정되어 있으므로 .env 파일에서 직접 편집해야 합니다.
12시간의 경우 .env에서 매개변수를 변경하십시오.
FETCH_INTERVAL=43200000
5. 결론
이 구성을 사용하면 PHP 스크립트를 우회하여 실시간 통계를 수신할 수 있으며, WSS 클라이언트로 보내기 전에 데이터 변경 사항을 고려할 수도 있습니다.
새 설정을 적용하려면 서비스를 다시 시작하는 것을 잊지 마십시오.
cd /root/websocket-server
pm2 restart server.js --name websocket
서비스 상태 확인:
pm2 status server.js --name websocket

