19 KiB
관리자 대시보드 백엔드 개발 문서
작성일: 2026-05-28
대상: WYPark Blog backend API
1. 목표
프론트엔드의 Apple 스타일 리뉴얼과 함께 관리자 대시보드를 실제 운영 도구로 만들기 위한 백엔드 API를 추가한다. 공개 홈에는 운영 집계를 노출하지 않고, 관리자 권한을 가진 사용자만 /admin에서 하루 조회수, 최근 7일 조회수, 최근 30일 조회수, 인기 글, 카테고리 상태, 답변 필요한 댓글 등을 모니터링할 수 있게 한다.
프론트엔드는 먼저 /api/admin/dashboard 계약을 기준으로 개발할 수 있다. 백엔드는 이 문서의 응답 구조를 맞추면 프론트 변경을 최소화하고 바로 연결할 수 있다.
2. 현재 프론트에서 사용하는 기존 API
현재 프론트엔드는 다음 API를 사용한다.
공개 API:
GET /api/posts
GET /api/posts/{slug}
GET /api/categories
GET /api/profile
GET /api/comments?postSlug={slug}
POST /api/comments
DELETE /api/comments/{id}
관리자 API:
POST /api/admin/posts
PUT /api/admin/posts/{id}
DELETE /api/admin/posts/{id}
GET /api/admin/comments?page={page}&size={size}
DELETE /api/admin/comments/{id}
POST /api/admin/categories
PUT /api/admin/categories/{id}
DELETE /api/admin/categories/{id}
PUT /api/admin/profile
POST /api/admin/images
신규 대시보드 기능은 기존 API를 깨지 않고 관리자 API만 추가한다.
3. 신규 API 요약
MVP에서 반드시 구현할 엔드포인트:
GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul
향후 세부 화면이 커질 때 선택적으로 분리할 수 있는 엔드포인트:
GET /api/admin/dashboard/traffic?range=30d&timezone=Asia/Seoul
GET /api/admin/dashboard/posts/top?range=7d&size=10&timezone=Asia/Seoul
GET /api/admin/dashboard/categories?range=30d&timezone=Asia/Seoul
GET /api/admin/dashboard/action-items?timezone=Asia/Seoul
프론트 1차 구현은 통합 엔드포인트인 /api/admin/dashboard만 사용한다. 분리 엔드포인트는 응답이 커지거나 위젯별 캐싱이 필요할 때 추가한다.
4. 공통 응답 규격
프론트는 기존 ApiResponse<T> 규격을 사용한다.
interface ApiResponse<T> {
code: string;
message: string;
data: T;
}
성공 예:
{
"code": "SUCCESS",
"message": "OK",
"data": {}
}
권장 오류:
{
"code": "FORBIDDEN",
"message": "관리자 권한이 필요합니다.",
"data": null
}
권한:
- 모든
/api/admin/dashboard*엔드포인트는 관리자 전용이다. - 프론트에서 관리자 UI를 숨기더라도 백엔드가 최종 권한 검사를 수행한다.
- 비로그인: 401
- 로그인했지만 관리자 아님: 403
5. 날짜와 집계 기준
기본 timezone:
Asia/Seoul
쿼리 파라미터:
timezone=Asia/Seoul
range=7d | 30d | 90d
기간 정의:
- 오늘 조회수:
timezone기준 오늘 00:00:00부터 23:59:59까지 - 최근 7일 조회수: 오늘 포함 7일, 즉 오늘 + 이전 6일
- 최근 30일 조회수: 오늘 포함 30일, 즉 오늘 + 이전 29일
- 최근 90일 조회수: 오늘 포함 90일, 즉 오늘 + 이전 89일
전 기간 대비:
- 오늘 비교값: 어제
- 최근 7일 비교값: 직전 7일
- 최근 30일 비교값: 직전 30일
- 최근 90일 비교값: 직전 90일
changeRate 계산:
previousValue = 0이고 currentValue > 0이면 changeRate는 null 또는 100으로 고정하지 않는다.
권장: null
previousValue > 0이면:
((currentValue - previousValue) / previousValue) * 100
소수점 둘째 자리까지 반올림
프론트는 changeRate가 없으면 변화율 badge를 숨긴다.
6. 조회수 수집 방식
현재 Post.viewCount는 총 누적 조회수로 사용된다. 대시보드에는 일별 집계가 필요하므로 누적 카운트만으로는 충분하지 않다.
6.1 권장 MVP 방식
글 상세 조회 시 기존 누적 조회수를 증가시키는 흐름에 일별 집계 upsert를 추가한다.
대상 요청:
GET /api/posts/{slug}
처리:
- 게시글 조회
- 조회수 증가 정책에 따라
post.viewCount증가 post_view_daily_stats에서(post_id, stat_date)row upsert- 관리자 대시보드는 이 daily stats를 조회
주의:
- 새로고침마다 증가되는 현재 정책이 있다면 MVP에서는 그대로 따른다.
- 중복 조회 방지는 별도 정책이므로 2차에서 다룬다.
- 검색 봇 제외 정책이 있다면 view 증가 전에 적용한다.
6.2 선택적 고도화
정확한 unique visitor가 필요해지면 다음 중 하나를 선택한다.
- 익명 daily visitor key cookie 발급
- IP + User-Agent + 날짜를 salt와 함께 hash
- 로그인 회원 ID 기준 unique 집계
개인 블로그 운영 통계 목적이라면 MVP에서는 단순 view count로 충분하다.
7. 데이터 모델 제안
기존 테이블 이름은 백엔드 프로젝트 관례에 맞춘다. 아래는 개념 모델이다.
7.1 post_view_daily_stats
글별 일간 조회수 집계 테이블.
CREATE TABLE post_view_daily_stats (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
post_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
view_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_post_view_daily_stats_post_date (post_id, stat_date),
INDEX idx_post_view_daily_stats_date (stat_date),
INDEX idx_post_view_daily_stats_post_id (post_id)
);
설명:
stat_date는timezone기준 날짜다.- 한국 블로그 운영만 고려하면
Asia/Seoul기준 LocalDate를 저장한다. - 다국가 timezone을 엄격히 지원하려면 UTC timestamp event table이 필요하지만 MVP에서는 과하다.
7.2 site_view_daily_stats, 선택
전체 조회수를 빠르게 보여주고 싶다면 별도 site daily table을 둘 수 있다.
CREATE TABLE site_view_daily_stats (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stat_date DATE NOT NULL,
view_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_site_view_daily_stats_date (stat_date)
);
하지만 post_view_daily_stats를 날짜별로 sum해도 되므로 처음에는 생략 가능하다.
7.3 comment reply 계산
답변 필요한 댓글을 계산하려면 댓글 구조에 다음 정보가 필요하다.
필수:
comment.idcomment.post_idcomment.parent_idcomment.member_id또는 작성자 식별 정보comment.created_at
권장:
- 댓글 작성자의 role 또는
isPostAuthor - 삭제 여부
MVP 기준:
- 루트 댓글이고 삭제되지 않음
- 작성자가 관리자/글 작성자가 아님
- 해당 댓글의 자식 댓글 중 관리자/글 작성자 댓글이 없음
이 조건을 만족하는 댓글 수를 actionItems.unansweredComments로 반환한다.
8. 통합 대시보드 API
8.1 Request
GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul
Authorization: Bearer {accessToken}
Query params:
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
range |
7d, 30d, 90d |
아니오 | 30d |
트래픽 차트와 성과 위젯 기준 기간 |
timezone |
string | 아니오 | Asia/Seoul |
날짜 집계 기준 timezone |
Validation:
range가 허용값이 아니면30d로 fallback하거나 400을 반환한다.- 권장: 400보다 fallback이 운영 UI에는 부드럽다.
timezone이 유효하지 않으면Asia/Seoul사용.
8.2 Response Type
type DashboardRange = '7d' | '30d' | '90d';
interface DashboardMetric {
value: number;
previousValue?: number;
changeRate?: number | null;
}
interface DashboardOverview {
todayViews: DashboardMetric;
weekViews: DashboardMetric;
monthViews: DashboardMetric;
totalPosts: number;
totalComments: number;
totalCategories: number;
lastPublishedAt?: string | null;
generatedAt: string;
}
interface DashboardTrafficPoint {
date: string;
views: number;
}
interface DashboardPostStat {
id: number;
title: string;
slug: string;
categoryName: string;
viewCount: number;
rangeViewCount: number;
commentCount?: number;
createdAt: string;
updatedAt?: string | null;
}
interface DashboardCategoryStat {
id: number;
name: string;
parentId?: number | null;
postCount: number;
viewCount: number;
recentViewCount: number;
lastPublishedAt?: string | null;
childrenCount: number;
}
interface DashboardActionItems {
unansweredComments: number;
uncategorizedPosts: number;
stalePopularPosts: number;
}
interface AdminDashboardResponse {
overview: DashboardOverview;
traffic: DashboardTrafficPoint[];
topPosts: DashboardPostStat[];
risingPosts: DashboardPostStat[];
stalePopularPosts: DashboardPostStat[];
recentPosts: PostSummary[];
recentComments: AdminCommentSummary[];
categoryStats: DashboardCategoryStat[];
actionItems: DashboardActionItems;
}
PostSummary는 기존 /api/posts의 Post 응답과 최대한 맞춘다.
interface PostSummary {
id: number;
title: string;
slug: string;
categoryName: string;
viewCount: number;
createdAt: string;
tags: string[];
}
AdminCommentSummary는 현재 프론트의 AdminComment와 맞춘다.
interface AdminCommentSummary {
id: number;
content: string;
author?: string;
guestNickname?: string;
memberNickname?: string;
postSlug?: string;
postTitle?: string;
createdAt: string;
}
8.3 Response Example
{
"code": "SUCCESS",
"message": "OK",
"data": {
"overview": {
"todayViews": {
"value": 311,
"previousValue": 277,
"changeRate": 12.27
},
"weekViews": {
"value": 1830,
"previousValue": 1504,
"changeRate": 21.68
},
"monthViews": {
"value": 7124,
"previousValue": 6302,
"changeRate": 13.04
},
"totalPosts": 102,
"totalComments": 18,
"totalCategories": 14,
"lastPublishedAt": "2026-01-02T12:04:00+09:00",
"generatedAt": "2026-05-28T20:00:00+09:00"
},
"traffic": [
{ "date": "2026-05-22", "views": 210 },
{ "date": "2026-05-23", "views": 245 },
{ "date": "2026-05-24", "views": 199 },
{ "date": "2026-05-25", "views": 287 },
{ "date": "2026-05-26", "views": 301 },
{ "date": "2026-05-27", "views": 277 },
{ "date": "2026-05-28", "views": 311 }
],
"topPosts": [
{
"id": 1,
"title": "RTR (Refresh Token Rotation)",
"slug": "rtr-(refresh-token-rotation)",
"categoryName": "Network",
"viewCount": 126,
"rangeViewCount": 44,
"commentCount": 0,
"createdAt": "2026-01-02T12:04:00+09:00",
"updatedAt": null
}
],
"risingPosts": [
{
"id": 2,
"title": "JWT - 토큰처리 방법",
"slug": "jwt---토큰처리-방법",
"categoryName": "Network",
"viewCount": 500,
"rangeViewCount": 38,
"commentCount": 1,
"createdAt": "2023-07-15T09:00:00+09:00",
"updatedAt": null
}
],
"stalePopularPosts": [],
"recentPosts": [],
"recentComments": [],
"categoryStats": [
{
"id": 10,
"name": "Network",
"parentId": 3,
"postCount": 12,
"viewCount": 1200,
"recentViewCount": 210,
"lastPublishedAt": "2026-01-02T12:04:00+09:00",
"childrenCount": 0
}
],
"actionItems": {
"unansweredComments": 0,
"uncategorizedPosts": 0,
"stalePopularPosts": 0
}
}
}
9. 필드별 계산 방법
9.1 overview
todayViews.value:
SELECT COALESCE(SUM(view_count), 0)
FROM post_view_daily_stats
WHERE stat_date = :today;
todayViews.previousValue:
SELECT COALESCE(SUM(view_count), 0)
FROM post_view_daily_stats
WHERE stat_date = :yesterday;
weekViews.value:
SELECT COALESCE(SUM(view_count), 0)
FROM post_view_daily_stats
WHERE stat_date BETWEEN :todayMinus6 AND :today;
monthViews.value:
SELECT COALESCE(SUM(view_count), 0)
FROM post_view_daily_stats
WHERE stat_date BETWEEN :todayMinus29 AND :today;
totalPosts:
- 삭제되지 않은 공개 글 수
- 비공개/임시저장 개념이 생기면
published글만 count하거나 별도 필드 추가
totalComments:
- 삭제되지 않은 댓글 수
totalCategories:
- 전체 카테고리 수, 하위 포함
9.2 traffic
range 기준 날짜마다 point를 반드시 채운다.
예:
- 조회수가 없는 날짜도
{ "date": "2026-05-23", "views": 0 }포함 - 프론트 차트가 날짜 간격을 안정적으로 그릴 수 있다.
9.3 topPosts
range 기간 내 조회수 기준 상위 글.
SELECT p.id, p.title, p.slug, c.name AS category_name,
p.view_count,
SUM(s.view_count) AS range_view_count,
p.created_at,
p.updated_at
FROM posts p
JOIN post_view_daily_stats s ON s.post_id = p.id
LEFT JOIN categories c ON c.id = p.category_id
WHERE s.stat_date BETWEEN :fromDate AND :toDate
GROUP BY p.id
ORDER BY range_view_count DESC
LIMIT 5;
9.4 risingPosts
최근 기간과 이전 같은 길이 기간을 비교해 증가량이 큰 글.
계산:
currentRangeViews = 이번 기간 조회수
previousRangeViews = 직전 같은 기간 조회수
growth = currentRangeViews - previousRangeViews
정렬:
growth DESCcurrentRangeViews DESC
조회수가 아주 낮은 글이 0에서 1이 되어 상승률 100%처럼 보이는 문제를 피하려면 최소 조회수 기준을 둔다.
권장:
currentRangeViews >= 5
9.5 stalePopularPosts
최근에도 많이 읽히지만 오래 업데이트되지 않은 글.
MVP 기준:
rangeViewCount >= 10updatedAt이 있으면updatedAt <= today - 180 daysupdatedAt이 없으면createdAt <= today - 180 days
정렬:
rangeViewCount DESC- 오래된 순
9.6 categoryStats
카테고리별 글 수, 누적 조회수, 최근 조회수.
하위 카테고리 포함 여부:
- MVP는 해당 카테고리에 직접 속한 글만 계산
- 추후 옵션으로 하위 포함 계산 가능
응답 필드:
postCount: 직접 속한 글 수viewCount: 직접 속한 글의 누적 조회수 합recentViewCount: range 기간 직접 속한 글의 조회수 합lastPublishedAt: 직접 속한 글 중 최신 발행일childrenCount: 바로 아래 하위 카테고리 수
9.7 actionItems
unansweredComments:
- 7.3 기준 참고
uncategorizedPosts:
- category가 null이거나
미분류카테고리에 속한 글 수
stalePopularPosts:
- 9.5 기준으로 계산된 글 수
10. 선택 분리 엔드포인트
통합 대시보드가 무거워지면 다음 엔드포인트를 추가한다. 프론트는 1차에서 쓰지 않아도 된다.
10.1 Traffic
GET /api/admin/dashboard/traffic?range=30d&timezone=Asia/Seoul
응답:
{
"code": "SUCCESS",
"message": "OK",
"data": [
{ "date": "2026-05-22", "views": 210 }
]
}
10.2 Top posts
GET /api/admin/dashboard/posts/top?range=7d&size=10&timezone=Asia/Seoul
응답:
{
"code": "SUCCESS",
"message": "OK",
"data": []
}
10.3 Category stats
GET /api/admin/dashboard/categories?range=30d&timezone=Asia/Seoul
응답:
{
"code": "SUCCESS",
"message": "OK",
"data": []
}
10.4 Action items
GET /api/admin/dashboard/action-items?timezone=Asia/Seoul
응답:
{
"code": "SUCCESS",
"message": "OK",
"data": {
"unansweredComments": 0,
"uncategorizedPosts": 0,
"stalePopularPosts": 0
}
}
11. 성능과 캐싱
관리자 대시보드는 실시간 초단위 정확도가 필요하지 않다.
권장:
/api/admin/dashboard응답은 30-60초 서버 캐싱 가능- 조회수 증가 write는 가볍게 처리
- 일별 통계 upsert는 DB index를 반드시 둔다
- traffic range가 90일이어도 row 수가 작으므로 부담이 낮다
동시성:
- 여러 요청이 동시에 같은 글을 조회할 수 있으므로
(post_id, stat_date)upsert는 atomic해야 한다. - MySQL이면
INSERT ... ON DUPLICATE KEY UPDATE view_count = view_count + 1 - PostgreSQL이면
INSERT ... ON CONFLICT ... DO UPDATE
예:
INSERT INTO post_view_daily_stats (post_id, stat_date, view_count)
VALUES (:postId, :statDate, 1)
ON DUPLICATE KEY UPDATE
view_count = view_count + 1,
updated_at = CURRENT_TIMESTAMP;
12. 보안과 개인정보
- 관리자 대시보드 API는 public cache에 저장하지 않는다.
- access token, refresh token, IP, User-Agent를 로그에 직접 남기지 않는다.
- unique visitor를 도입하기 전에는 개인정보성 식별자를 저장하지 않는다.
- IP 기반 unique 집계를 도입한다면 salt hash와 보관 기간을 명확히 정한다.
- public API에는 오늘/주간/월간 조회수 집계를 추가하지 않는다.
13. 백엔드 구현 순서
Phase 1: 일별 조회수 집계 기반
post_view_daily_statsmigration 추가- 글 상세 조회 시 daily stats upsert 추가
- 기존
Post.viewCount증가 로직 유지 - 인덱스와 unique key 확인
- 기존 글 상세 API 회귀 테스트
Phase 2: /api/admin/dashboard MVP
DashboardOverview계산trafficrange points 계산topPosts계산recentPosts,recentComments는 기존 repository/service 재사용categoryStats계산actionItems계산- 관리자 권한 검사 적용
Phase 3: 고도화
risingPosts계산 추가stalePopularPosts계산 추가- 위젯별 캐싱 추가
- 필요하면 분리 엔드포인트 추가
- unique visitor 정책 검토
14. 테스트 계획
14.1 단위 테스트
changeRate계산- range 날짜 계산
- traffic 날짜 빈 구간 0 채우기
- stale popular 기준
- unanswered comment 기준
14.2 통합 테스트
관리자 권한:
- 비로그인 요청은 401
- 일반 회원 요청은 403
- 관리자 요청은 200
대시보드 응답:
range=7d는 traffic 7개range=30d는 traffic 30개- 조회 없는 날짜는 views 0
todayViews,weekViews,monthViews가 올바른 합계를 반환topPosts가 range 조회수 기준으로 정렬
조회수 집계:
- 글 상세 조회 1회 후 해당 날짜 row 생성
- 같은 글 같은 날짜 추가 조회 시 row 증가
- 다른 날짜는 별도 row
14.3 프론트 연동 확인
프론트에서 호출할 URL:
GET https://blogserver.wypark.me/api/admin/dashboard?range=30d&timezone=Asia/Seoul
확인:
- 응답이
ApiResponse<AdminDashboardResponse>구조와 일치 - CORS, cookie, Authorization 헤더가 기존 admin API와 동일하게 동작
- 프론트
/admin에서 오늘/7일/30일 조회수가 표시 - 공개
/에서는 대시보드 집계가 표시되지 않음
15. 완료 기준
백엔드 MVP 완료 기준:
post_view_daily_stats가 운영 DB에 반영된다.- 글 상세 조회 시 일별 조회수가 누적된다.
- 관리자만
/api/admin/dashboard를 조회할 수 있다. todayViews,weekViews,monthViews,traffic,topPosts,recentPosts,recentComments,categoryStats,actionItems가 응답된다.range=7d,30d,90d가 동작한다.- 프론트가 fallback 없이 실제 API 데이터를 표시할 수 있다.
- 공개 API에는 운영 집계가 노출되지 않는다.