통계 기간 지표 원장 기준 보정

This commit is contained in:
2026-07-29 14:57:51 +09:00
parent 4e7e12d8fe
commit 378afbe79c
8 changed files with 184 additions and 19 deletions

View File

@@ -1,7 +1,7 @@
# 배포 가이드
> v1.12.4는 Google 로그인/계정 연동과 통계 콘텐츠 세션 원장을 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
> 로컬 기준 v1.12.4에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다.
> v1.12.5는 Google 로그인/계정 연동과 통계 콘텐츠 세션 원장을 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
> 로컬 기준 v1.12.5에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다.
## 빌드 유형
@@ -14,7 +14,7 @@
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
## v1.12.4 배포 전 검증
## v1.12.5 배포 전 검증
Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다.

View File

@@ -1,3 +1,7 @@
## 2026-07-29 v1.12.5 — 관리자 기간 지표는 원장 우선으로 보정한다
콘텐츠 세션 원장은 같은 글이나 페이지를 보는 한 번의 진입을 하나의 상세 기록으로 보존하므로, 평균 체류시간과 스크롤 도달률처럼 중복 이벤트에 민감한 지표를 더 안정적으로 계산할 수 있다. 다만 원장 행은 방문자 해시와 함께 32일만 보관하므로, 전체 누적이나 오래된 기간까지 원장만으로 다시 계산하면 과거 데이터가 빠진 것처럼 보일 수 있다. 따라서 관리자 요약과 게시물·페이지 상세의 최근 기간 지표는 원장 데이터가 보관 범위 안에 있을 때만 우선 사용하고, 그 밖의 범위는 기존 일별 누적 집계를 그대로 사용한다.
## 2026-07-29 v1.12.4 — 통계 집계 기준을 콘텐츠 세션 원장으로 분리한다
기존 통계 수집은 탭 단위 실시간 세션을 현재 접속자 표시와 체류·스크롤 증분 계산에 함께 사용했다. 이 구조에서는 같은 탭에서 여러 게시물이나 페이지를 이동할 때 이전 콘텐츠의 체류시간과 스크롤 비율이 다음 콘텐츠 집계에 영향을 줄 수 있다. 따라서 클라이언트는 탭 세션과 별도로 라우트 진입 단위 `contentSessionId`를 보내고, 서버는 원문 ID 대신 해시된 `analytics_content_sessions` 원장을 기준으로 pageview/read 중복과 heartbeat 증분을 판단한다. `analytics_active_sessions`는 현재 접속자 표시용으로 남겨 실시간 UI 책임과 콘텐츠 분석 책임을 분리한다.

View File

@@ -603,6 +603,7 @@ components/content/
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 활성 페이지 행에 마우스를 올리면 공개 경로 새 창 바로가기 버튼을 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 디바이스는 모바일·PC 같은 상위 기기 기준으로 한 카드에 합산하고, 상위 기기 제목 오른쪽에 방문자 수·조회수·전체 디바이스 방문자 대비 비율을 한 줄로 표시한다. 카드 안의 Android/iOS/Windows 같은 OS별 목록은 방문자 수와 전체 디바이스 방문자 대비 비율만 표시한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
- 관리자 대시보드 요약·선택한 기간 상세와 게시물·페이지별 통계 상세의 기간 요약에서 평균 체류·스크롤·읽음처럼 같은 콘텐츠 세션 중복 제거가 중요한 지표는 선택 기간이 `analytics_content_sessions` 보관 범위 안에 있고 원장 데이터가 있을 때 원장 기준으로 계산한다. 보관 범위 밖 기간이나 원장 데이터가 없는 기간은 기존 일별 누적 집계로 fallback한다.
- 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다.
- 관리자 게시물별 통계 모달과 상세 화면은 누적 조회수, 조회수/방문자 추이, 읽음·평균 체류·스크롤 지표, 게시물별 유입원·디바이스·유입 키워드를 표시한다. 추이는 일간(최근 14일), 주간(최근 12주, 일요일~토요일), 월간(최근 12개월) 단위로 전환한다. 게시물별 유입은 `059_post_analytics_traffic_daily.sql` 적용 이후 새로 수집되는 페이지뷰부터 집계된다.
- 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다.

View File

@@ -16,7 +16,7 @@
## 데이터베이스
- 통계 콘텐츠 세션 원장 기반 후속 개선.
- `analytics_content_sessions` 누적 데이터를 활용해 게시물·페이지별 평균 체류, 읽음 전환율, 스크롤 완료율을 원장 기준으로 재계산하는 관리자 지표 개선 검토.
- 통계 추이 차트와 인기 게시물·페이지 목록의 체류·스크롤·읽음 지표도 원장 기준 보정이 필요한지 검토.
- 콘텐츠 세션 원장 보관 기간을 운영 데이터 증가량 기준으로 32일 유지 또는 별도 기간으로 분리할지 검토.
## 배포

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.12.5
- 관리자 대시보드 요약·선택 기간 상세의 평균 체류와 50% 스크롤 도달 지표를 콘텐츠 세션 원장 우선 계산으로 보정.
- 게시물·페이지별 통계 상세의 기간 요약 지표를 콘텐츠 세션 원장 우선 계산으로 보정.
- 콘텐츠 세션 원장 보관 범위 밖의 기간은 기존 일별 누적 집계로 fallback하도록 정리.
## v1.12.4
- 통계 수집에 콘텐츠 단위 세션 원장 `analytics_content_sessions` 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.12.4",
"version": "1.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.12.4",
"version": "1.12.5",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.12.4",
"version": "1.12.5",
"private": true,
"type": "module",
"imports": {

View File

@@ -703,6 +703,124 @@ const markContentSessionRead = async (sql, input) => {
return rows.length > 0
}
/**
* 콘텐츠 세션 집계 행이 실제 원장 데이터를 포함하는지 확인한다.
* @param {Object | undefined} row - 원장 집계 행
* @returns {boolean} 원장 데이터 존재 여부
*/
const hasContentSessionAggregateRows = (row) => {
return Number(row?.content_sessions || 0) > 0
}
/**
* 선택 기간이 콘텐츠 세션 원장 보관 범위 안에 있는지 확인한다.
* @param {string} startDay - 시작일
* @param {string} today - 오늘 날짜 키
* @returns {boolean} 원장 기준 계산 가능 여부
*/
const canUseContentSessionAggregateForRange = (startDay, today = getAnalyticsDayKey()) => {
const retentionStartDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS)
return String(startDay || '') >= retentionStartDay
}
/**
* 사이트 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getSiteContentSessionAggregate = async (sql, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 게시물 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} postId - 게시물 ID
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getPostContentSessionAggregate = async (sql, postId, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE read_recorded)::int AS reads,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*) FILTER (WHERE scroll_75_recorded)::int AS scroll_75,
COUNT(*) FILTER (WHERE scroll_100_recorded)::int AS scroll_100,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE post_id = ${postId}
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 페이지 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} pageId - 페이지 ID
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getPageContentSessionAggregate = async (sql, pageId, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*) FILTER (WHERE scroll_75_recorded)::int AS scroll_75,
COUNT(*) FILTER (WHERE scroll_100_recorded)::int AS scroll_100,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE page_id = ${pageId}
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 게시물 전체 콘텐츠 세션 스크롤 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getAllPostContentSessionScrollAggregate = async (sql, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE post_id IS NOT NULL
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 날짜 문자열이 통계 조회 형식인지 확인한다.
* @param {string} value - 날짜 문자열
@@ -1285,6 +1403,20 @@ export const getAnalyticsSummary = async (options = {}) => {
WHERE day >= ${rangeStartDay}::date
`
const todayContentRows = await getSiteContentSessionAggregate(sql, today, today)
const contentEngagementRows = await getSiteContentSessionAggregate(sql, rangeStartDay, today)
const contentScrollRows = await getAllPostContentSessionScrollAggregate(sql, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const todayEngagementRow = hasContentSessionAggregateRows(todayContentRows)
? todayContentRows
: todayRows[0]
const engagementRow = shouldUseContentRows && hasContentSessionAggregateRows(contentEngagementRows)
? contentEngagementRows
: engagementRows[0]
const scrollRow = shouldUseContentRows && hasContentSessionAggregateRows(contentScrollRows)
? contentScrollRows
: scrollRows[0]
const onlineRows = await sql`
SELECT
COUNT(DISTINCT CASE
@@ -1519,10 +1651,10 @@ export const getAnalyticsSummary = async (options = {}) => {
GROUP BY day, source_group
`
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
const todayEngagedViews = Number(todayRows[0]?.engaged_views || 0)
const todayTotalEngagedSeconds = Number(todayRows[0]?.total_engaged_seconds || 0)
const engagedViews = Number(engagementRow?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRow?.total_engaged_seconds || 0)
const todayEngagedViews = Number(todayEngagementRow?.engaged_views || 0)
const todayTotalEngagedSeconds = Number(todayEngagementRow?.total_engaged_seconds || 0)
/**
* 버킷 구간의 유입 방문자를 합산한다.
@@ -1573,8 +1705,8 @@ export const getAnalyticsSummary = async (options = {}) => {
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Math.min(
Number(scrollRows[0]?.scroll_50 || 0),
Number(scrollRows[0]?.views || 0)
Number(scrollRow?.scroll_50 || 0),
Number(scrollRow?.views || 0)
),
collectedSince,
trends: trendRows.map((row) => {
@@ -1893,6 +2025,16 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
WHERE post_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date
`
const contentSummaryRows = await getSiteContentSessionAggregate(sql, startDay, endDay)
const contentScrollRows = await getAllPostContentSessionScrollAggregate(sql, startDay, endDay)
const shouldUseContentRows = canUseContentSessionAggregateForRange(startDay, endDay)
const summaryEngagementRow = shouldUseContentRows && hasContentSessionAggregateRows(contentSummaryRows)
? contentSummaryRows
: summaryRows[0]
const summaryScrollRow = shouldUseContentRows && hasContentSessionAggregateRows(contentScrollRows)
? contentScrollRows
: scrollRows[0]
const sourceRows = await sql`
SELECT
source_group,
@@ -1978,8 +2120,8 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
LIMIT ${limit}
`
const engagedViews = Number(summaryRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(summaryRows[0]?.total_engaged_seconds || 0)
const engagedViews = Number(summaryEngagementRow?.engaged_views || 0)
const totalEngagedSeconds = Number(summaryEngagementRow?.total_engaged_seconds || 0)
return {
startDay,
@@ -1992,8 +2134,8 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Math.min(
Number(scrollRows[0]?.scroll_50 || 0),
Number(scrollRows[0]?.views || 0)
Number(summaryScrollRow?.scroll_50 || 0),
Number(summaryScrollRow?.views || 0)
)
},
sources: sourceRows.map((row) => ({
@@ -2145,6 +2287,12 @@ export const getPostAnalyticsDetail = async (options = {}) => {
AND day >= ${rangeStartDay}::date
`
const periodContentRows = await getPostContentSessionAggregate(sql, postId, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const periodSummaryRow = shouldUseContentRows && hasContentSessionAggregateRows(periodContentRows)
? periodContentRows
: periodRows[0]
let trendRows = []
if (trendPeriod === 'weekly') {
@@ -2293,7 +2441,7 @@ export const getPostAnalyticsDetail = async (options = {}) => {
trendPeriod,
updatedAt: new Date().toISOString(),
total: mapSummaryRow(totalRows[0]),
period: mapSummaryRow(periodRows[0]),
period: mapSummaryRow(periodSummaryRow),
trends: trendRows.map((row) => {
const engagementStats = normalizeContentEngagementStats({
views: row.views,
@@ -2403,6 +2551,12 @@ export const getPageAnalyticsDetail = async (options = {}) => {
AND day >= ${rangeStartDay}::date
`
const periodContentRows = await getPageContentSessionAggregate(sql, pageId, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const periodSummaryRow = shouldUseContentRows && hasContentSessionAggregateRows(periodContentRows)
? periodContentRows
: periodRows[0]
let trendRows = []
if (trendPeriod === 'weekly') {
@@ -2505,7 +2659,7 @@ export const getPageAnalyticsDetail = async (options = {}) => {
trendPeriod,
updatedAt: new Date().toISOString(),
total: mapPageSummaryRow(totalRows[0]),
period: mapPageSummaryRow(periodRows[0]),
period: mapPageSummaryRow(periodSummaryRow),
trends: trendRows.map((row) => ({
day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',