# 설계서: 백엔드 - 통계/대시보드 (#274) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #274 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/StatsService.java`, `backend-java/src/main/java/com/tasteby/controller/StatsController.java` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) 사이트 방문 트래픽을 일별/누적으로 집계하여, 운영자가 서비스 사용량을 즉시 확인 가능한 가벼운 대시보드 데이터를 제공한다. 풋터/관리자 페이지의 "오늘/총 방문수" 표시에 사용된다. ## 2. 범위 (Scope) - **포함**: - 사이트 방문 1건 기록 (`POST /api/stats/visit`) - 오늘 방문수 + 누적 방문수 조회 (`GET /api/stats/visits`) - **제외 (out of scope)**: - 사용자별/세션별 추적, 유니크 방문자(UU) 집계 - 페이지별 PV, 체류 시간, 이탈률 - 트래픽 소스/UTM, 외부 분석 도구 연동(GA, Mixpanel 등) - 채널/식당/리뷰 등 도메인 통계 (#273, #272 영역) - 시계열 차트, 기간 필터 조회 ## 3. 인수조건 (Acceptance Criteria) - [x] `POST /api/stats/visit` 호출 시 DB의 오늘자 카운터가 1 증가하고 `{ok:true}` 반환 - [x] `GET /api/stats/visits`는 `{today, total}` (모두 int) 반환 - [x] 인증 없이 호출 가능 (퍼블릭 엔드포인트) - [x] 데이터 없으면 `today=0, total=0` 반환 (DB null 안전) - [x] 동일 사용자 다중 호출 시 호출 횟수만큼 카운터 증가 (PV 카운트 모델) ## 4. 컨텍스트 & 제약 - **DB**: Oracle 23ai. 테이블 `site_visit_stats` (가정: 일자 PK + count 컬럼 또는 카운터 테이블). - **MyBatis**: `StatsMapper` (`recordVisit`, `getTodayVisits`, `getTotalVisits`). - **권한**: 공개 엔드포인트. 인증 불필요. 어뷰즈 방어 없음 (현재). - **성능**: 매 페이지 로드마다 `POST /api/stats/visit` 호출 → DB write QPS 비례 증가. - **트랜잭션**: `recordVisit`는 단일 INSERT/UPDATE, 자동 커밋 또는 Spring 기본 트랜잭션. - **가정**: - 오늘 일자 기준은 DB 서버 시간(`SYSDATE` / `CURRENT_DATE`). - 카운터는 일별 행 upsert 또는 단일 incremental 행. - 누적은 합계 집계 또는 별도 단일 행. ## 5. 아키텍처 개요 - 모듈/파일 구조: - `controller/StatsController.java` (2개 엔드포인트) - `service/StatsService.java` (얇은 위임) - `mapper/StatsMapper.java` + XML - `domain/SiteVisitStats.java` - I/O ↔ 순수 로직 경계: 비즈니스 로직 거의 없음. Service는 단순 Mapper 위임 + 빌더로 응답 객체 조립. ``` [Browser/Client] │ POST /api/stats/visit (페이지 로드 시) ▼ [StatsController] │ statsService.recordVisit() ▼ [StatsService] ── statsService.getVisits() (대시보드 GET) │ ▼ [StatsMapper] (MyBatis XML) │ INSERT/UPDATE | SELECT today/total ▼ [Oracle 23ai: site_visit_stats] ``` ## 6. 데이터 모델 - **SiteVisitStats** (`domain/SiteVisitStats.java`): - `today: int` — 오늘 방문수 - `total: int` — 누적 방문수 - **응답 JSON**: `{"today": 123, "total": 456789}` - **DB 테이블 (추정)**: `site_visit_stats(visit_date DATE PK, count NUMBER)` 또는 단일 row 카운터. - **경계 검증**: - 음수 카운트 불가 (DB CHECK 권장). - 오버플로: int 범위(2^31-1) → 누적이 21억 도달 시 long으로 확장 필요. ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `StatsService.recordVisit` | 방문 1건 기록 | `void()` | 없음 | 없음 | DB 오류 → 전파 | 단순 | | `StatsService.getVisits` | 오늘/누적 집계 조회 | `SiteVisitStats()` | 없음 | SiteVisitStats(today,total) | DB 오류 → 전파 | 단순 | | `StatsController.recordVisit` | REST: 방문 기록 | `Map()` | 없음 | `{ok:true}` | 500 가능 | 단순 | | `StatsController.getVisits` | REST: 방문 조회 | `SiteVisitStats()` | 없음 | SiteVisitStats | 500 가능 | 단순 | > 모두 단순. 별도 fn 설계서 불필요. ## 8. 흐름 / 알고리즘 1. **방문 기록**: - Client(브라우저/Next.js) → 페이지 마운트 시 `POST /api/stats/visit` 1회 호출. - Controller → Service → Mapper.recordVisit() → DB. - SQL (가정): `MERGE INTO site_visit_stats USING dual ON (visit_date = TRUNC(SYSDATE)) WHEN MATCHED THEN UPDATE SET count = count + 1 WHEN NOT MATCHED THEN INSERT (visit_date, count) VALUES (TRUNC(SYSDATE), 1)` - 응답 `{ok:true}`. 2. **집계 조회**: - Controller → Service.getVisits() → - Mapper.getTodayVisits() (오늘 일자 행 SELECT) + - Mapper.getTotalVisits() (SUM 또는 누적 행 SELECT) → - Lombok Builder로 `SiteVisitStats` 조립 → 응답. ## 9. 엣지케이스 & 에러 처리 - **자정 경계 race**: Mapper가 `TRUNC(SYSDATE)`를 사용한다고 가정 → DB 시간대 일관. 서버 시간대(`Asia/Seoul`) 설정 의존. - **첫 호출/빈 DB**: 오늘 행이 없으면 `getTodayVisits` 0 또는 null 반환 → int 변환 시 NPE 위험. → Mapper SQL에서 `NVL(SUM(count), 0)` 권장. - **봇/크롤러 트래픽**: 인플레이션 가능. UA/IP 필터 부재. - **사용자 빠른 새로고침 어뷰즈**: 동일 IP 다중 호출 모두 +1. 레이트 리밋 없음. - **DB 다운**: 방문 기록/조회 모두 500 전파. 사이트 페이지 로드는 fire-and-forget이면 무영향, 동기 대기면 UX 저하. - **MERGE 동시 INSERT 경합**: 자정 직후 두 트랜잭션 동시 INSERT → 유니크 충돌 시 한쪽 500. 재시도 권장. - **카운터 오버플로**: int 한계 → 향후 long 마이그레이션 + DB NUMBER 확장. ## 10. 테스트 계획 - **단위** - `StatsService.getVisits`: Mapper 모킹, today=10, total=100 → 빌드된 객체 검증. - `StatsService.recordVisit`: Mapper 호출 1회 검증. - **통합** - 신규 DB 상태에서 POST 3회 → GET 시 today=3, total=3 - 다음 날(시뮬레이션) POST 1회 → today=1, total=4 - 빈 DB 상태에서 GET → `{0, 0}` (null 안전) - **부하**: 초당 100회 POST 지속 → 응답 시간 100ms 이하 (성능 SLA). - **모킹**: H2 또는 Testcontainers Oracle. 자정 경계는 JVM 시간 모킹 또는 DB 시퀀스 가짜 주입. - 현재 테스트 디렉토리 없음 → TBD. ## 11. 리스크 & 대안 검토 - **선택**: 모든 페이지 로드마다 동기 POST 1건 + DB INCR. - 대안 A: Redis INCR + 주기 flush → DB write 폭주 방지, 정확도 약간 손실. - 대안 B: 로그 기반 집계 (Nginx access log → 배치) → 실시간성 손실. - 대안 C: 외부 분석(GA) 연동 → 운영 부담 감소, 데이터 주권 손실. - 트레이드오프: 현 트래픽 규모에서 DB 직접 카운트가 단순/충분. 1000 QPS 도달 시 Redis 캐시 ADR 검토. - **유니크 방문자(UU) 미지원**: 현 PV 모델 한계. 쿠키/세션 ID 도입 시 schema 확장 필요. - **봇 필터링 부재**: User-Agent 블랙리스트 또는 robots.txt 준수 봇 제외 로직 후속 작업. - **시간대 의존**: DB 서버 TZ가 KST가 아니면 "오늘" 정의가 사용자 인식과 불일치. 서버 TZ 명시 필요. ## 12. 미해결 질문 (Open Questions) - 봇/크롤러 트래픽 필터링 정책 (UA 화이트리스트? Cloudflare 통계 활용?). - 유니크 방문자(UU) 메트릭이 필요한지? 필요하면 쿠키 기반 vs IP+UA 해시. - 통계 데이터 보존 기간/롤업 정책 (예: 90일 이전은 월 단위 압축). - 인증된 사용자만의 활성 사용자(DAU/MAU) 지표 도입 시점. - 어뷰즈 방어(레이트 리밋) 추가 필요 — Bucket4j 또는 Nginx 단에서 처리? - 관리자 대시보드 UI에서 필요한 차원(채널별 트래픽, 디바이스 등) 범위 확정.