사용자 활동 로그를 시각화하면 페이지·이벤트별 트래픽 흐름, 피크 타임, 기능 사용률을 한눈에 파악할 수 있습니다. 본 글은 실무에서 바로 쓸 수 있는 최소 구현으로, 로그 스키마 정의부터 전처리(타임존/버킷팅), Recharts 기반 대시보드 구성, 성능/접근성/프라이버시 팁까지 다룹니다.
1. 로그 스키마와 수집 포인트
가장 작은 단위로 이벤트 로그는 userId, type, path, ts(ISO 시간) 정도가 있으면 충분합니다. 프런트엔드에선 라우트 전환, CTA 클릭, 폼 제출 등을 후킹해서 전송합니다. 초기에는 PII를 포함하지 않고, 사용자 식별은 익명 ID(예: UUID)로 대체합니다.
2. 전처리 핵심: 타임존, 버킷팅, 필터
시계열 집계 전 타임존을 일관화해야 합니다. 서버가 UTC를 기록했다면, 시각화에서는 비즈니스 타임존(예: Asia/Seoul, +540분)을 적용해 시간/일 단위 버킷을 만듭니다. 또한 기간 필터(시작-종료), 이벤트 타입 필터, 경로 필터를 적용해 필요한 데이터만 그립니다.
3. 설치
본 예시는 Recharts를 사용합니다. 가볍고 React 친화적이며, 라인/바/스택드 차트를 빠르게 구현할 수 있습니다.
npm i recharts4. 구현: 활동 로그 대시보드 (라인 + 스택 바)
아래 코드는 샘플 이벤트를 시간/일 단위로 집계하고, 전체 트래픽 추이를 라인 차트로, 이벤트 타입별 분포를 스택 바 차트로 시각화합니다. 타임존, 집계 단위, 기간을 UI에서 제어합니다.
import React, { useMemo, useState } from "react";
import {
ResponsiveContainer,
LineChart, Line,
XAxis, YAxis, Tooltip, Legend, CartesianGrid,
BarChart, Bar
} from "recharts";
// 1) 샘플 이벤트 데이터 (실서비스에서는 API로 가져오세요)
const sampleEvents = [
{ userId: "u1", type: "page_view", path: "/", ts: "2026-07-01T00:05:00Z" },
{ userId: "u2", type: "signup", path: "/signup", ts: "2026-07-01T00:10:00Z" },
{ userId: "u1", type: "click_cta", path: "/pricing", ts: "2026-07-01T01:20:00Z" },
{ userId: "u3", type: "page_view", path: "/docs", ts: "2026-07-01T02:40:00Z" },
{ userId: "u4", type: "page_view", path: "/", ts: "2026-07-01T05:10:00Z" },
{ userId: "u1", type: "page_view", path: "/dashboard", ts: "2026-07-02T03:00:00Z" },
{ userId: "u2", type: "click_cta", path: "/", ts: "2026-07-02T03:05:00Z" },
{ userId: "u5", type: "page_view", path: "/pricing", ts: "2026-07-02T15:15:00Z" },
{ userId: "u6", type: "signup", path: "/signup", ts: "2026-07-03T09:45:00Z" },
{ userId: "u6", type: "page_view", path: "/dashboard", ts: "2026-07-03T10:00:00Z" },
];
// 2) 유틸: 타임존 보정 및 버킷 라벨 생성
const pad2 = (n) => String(n).padStart(2, "0");
const toTz = (date, tzOffsetMinutes) => new Date(date.getTime() + tzOffsetMinutes * 60 * 1000);
const labelFor = (date, interval) => {
const y = date.getUTCFullYear();
const m = pad2(date.getUTCMonth() + 1);
const d = pad2(date.getUTCDate());
if (interval === "day") return `${y}-${m}-${d}`;
const h = pad2(date.getUTCHours());
return `${y}-${m}-${d} ${h}:00`;
};
const parseLabelToDate = (label) => {
// "YYYY-MM-DD" or "YYYY-MM-DD HH:00" -> Date (UTC)
if (label.length === 10) return new Date(`${label}T00:00:00Z`);
const [date, time] = label.split(" ");
const [hour] = time.split(":");
return new Date(`${date}T${hour.padStart(2, "0")}:00:00Z`);
};
// 3) 집계: 기간/타입/타임존/버킷 단위 적용
function aggregateEvents(events, {
interval = "day", // 'day' | 'hour'
tzOffsetMinutes = 540, // Asia/Seoul 기본
start, // Date | undefined (비즈니스 타임존 기준)
end, // Date | undefined (비즈니스 타임존 기준)
typeFilter, // Set<string> | undefined
} = {}) {
const buckets = new Map(); // label -> { time, total, [type]: count }
const typeSet = new Set();
for (const e of events) {
if (!e.ts) continue;
const utc = new Date(e.ts);
const local = toTz(utc, tzOffsetMinutes);
// 기간 필터 (로컬 기준)
if (start && local < start) continue;
if (end && local > end) continue;
if (typeFilter && !typeFilter.has(e.type)) continue;
// 버킷 라벨 생성: labelFor는 UTC 기준으로 만들지만 이미 local을 인위적 UTC로 간주
// local을 다시 UTC로 인식시키기 위해 getUTC* 사용
const pseudoUTC = new Date(Date.UTC(
local.getFullYear(),
local.getMonth(),
local.getDate(),
interval === "hour" ? local.getHours() : 0,
0, 0, 0
));
const label = labelFor(pseudoUTC, interval);
const prev = buckets.get(label) || { time: label, total: 0 };
prev.total += 1;
prev[e.type] = (prev[e.type] || 0) + 1;
buckets.set(label, prev);
typeSet.add(e.type);
}
const data = Array.from(buckets.values()).sort((a, b) => parseLabelToDate(a.time) - parseLabelToDate(b.time));
const types = Array.from(typeSet);
return { data, types };
}
// 4) 팔레트: 타입별 색상
const palette = ["#4F46E5", "#06B6D4", "#F59E0B", "#10B981", "#EF4444", "#8B5CF6", "#22C55E"];
const colorFor = (idx) => palette[idx % palette.length];
// 5) 메인 컴포넌트
export default function ActivityLogsDashboard({ events = sampleEvents }) {
const [interval, setInterval] = useState("day");
const [tzOffset, setTzOffset] = useState(540); // Asia/Seoul
const [startStr, setStartStr] = useState("");
const [endStr, setEndStr] = useState("");
const start = useMemo(() => (startStr ? new Date(startStr) : undefined), [startStr]);
const end = useMemo(() => (endStr ? new Date(endStr) : undefined), [endStr]);
const { data, types } = useMemo(() => {
return aggregateEvents(events, { interval, tzOffsetMinutes: tzOffset, start, end });
}, [events, interval, tzOffset, start, end]);
return (
<div style={{ display: "grid", gap: 16 }}>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<label>
단위
<select value={interval} onChange={(e) => setInterval(e.target.value)}>
<option value="day">일간</option>
<option value="hour">시간별</option>
</select>
</label>
<label>
타임존 오프셋(분)
<input type="number" value={tzOffset} onChange={(e) => setTzOffset(Number(e.target.value))} style={{ width: 100 }} />
</label>
<label>
시작(YYYY-MM-DD)
<input type="date" value={startStr} onChange={(e) => setStartStr(e.target.value)} />
</label>
<label>
종료(YYYY-MM-DD)
<input type="date" value={endStr} onChange={(e) => setEndStr(e.target.value)} />
</label>
</div>
<div role="img" aria-label="시간대별 전체 이벤트 추이 라인 차트">
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" minTickGap={24} />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="total" name="전체 이벤트" stroke="#111827" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div role="img" aria-label="이벤트 타입별 스택 바 차트">
<ResponsiveContainer width="100%" height={320}>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" minTickGap={24} />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
{types.map((t, i) => (
<Bar key={t} dataKey={t} stackId="types" name={t} fill={colorFor(i)} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
5. 정확도를 위한 실무 체크리스트
타임존을 일관화합니다. 서버는 UTC, 시각화는 비즈니스 타임존을 적용합니다. 일/시간 버킷의 경계가 바뀌지 않도록 프런트와 백엔드 모두 기준을 문서화합니다.
버킷 공백을 명시적으로 0으로 채우면 스파이크와 트렌드 대비가 쉬워집니다. 위 예시는 관측된 버킷만 출력하므로 필요 시 누락 구간을 생성해 넣으세요.
중복 이벤트를 방지합니다. 재시도/중복 전송 대비해 이벤트 ID를 생성하고 서버에서 멱등성을 보장합니다.
스키마를 버전 관리합니다. type, path 외에 campaign, referrer, device 같은 태그를 확장할 때도 기존 쿼리가 깨지지 않게 버전/옵셔널 필드를 도입합니다.
6. 성능 최적화 팁
큰 데이터셋은 Web Worker에서 집계합니다. 메인 스레드의 스크롤/입력 성능을 지키려면 postMessage로 청크 처리하고, UI에는 useMemo와 React.memo로 리렌더를 줄입니다.
가벼운 다운샘플링을 적용합니다. 초단위 원시 로그는 서버에서 시간 버킷으로 집계해 내려받거나, 클라이언트에서 최대 포인트 수를 제한합니다.
가상화 목록을 사용합니다. 이벤트 원본 목록을 함께 보여줄 때 react-window 같은 라이브러리로 렌더 비용을 줄입니다.
7. 접근성, 국제화, 프라이버시
차트 컨테이너에 role과 aria-label을 부여하고, 중요한 지표는 텍스트 요약도 함께 제공합니다. 색약 대비를 고려해 색상 대비와 패턴(스택/라인)을 병행합니다.
국제화는 타임존 오프셋과 로케일 포맷을 옵션으로 노출합니다. 다국가 서비스는 사용자 프로필이나 브라우저 Intl API를 통해 기본값을 설정합니다.
PII를 수집하지 않습니다. 이메일, 이름 등은 로그에 포함하지 말고 익명 식별자만 사용합니다. 조합 가능한 민감 경로(path 변수)도 해시/마스킹합니다.
8. 운영 확장 아이디어
백엔드 집계를 도입합니다. BigQuery/ClickHouse/Timescale로 일/시간 집계를 미리 생성해 API로 서빙하면 프런트는 시각화에 집중할 수 있습니다.
경보와 베이스라인을 설정합니다. 전주 대비 증감률, 표준편차 기반 알림을 Hook으로 노출하고, Slack/Webhook 연동으로 이상 징후를 빠르게 포착합니다.
대시보드 공유를 지원합니다. URL 쿼리로 필터 상태를 직렬화해 팀 간 같은 뷰를 재현 가능하게 합니다.
이 글의 코드를 출발점으로, 히트맵(요일×시간), 퍼널(뷰→클릭→가입), 경로 전환 그래프(Sankey) 등으로 확장해 보시기 바랍니다.
'React' 카테고리의 다른 글
| React 앱에서 스마트폰 센서 데이터 통합 처리 (0) | 2026.07.12 |
|---|---|
| React에서 애플리케이션 Configuration 파일 동적 로드하기 (0) | 2026.07.10 |
| React에서 브라우저 Speech Synthesis API 활용하여 음성 출력하기 (0) | 2026.07.09 |
| React 앱에서 블루투스 디바이스 연동하기 (0) | 2026.07.09 |
| React에서 오프라인 모드 전환 UI 구성하기 (0) | 2026.07.08 |