<!-- 기능 설계서. design/274-backend-stats/README.md
     작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 통계/대시보드 (#274)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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<String,Object>()` | 없음 | `{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에서 필요한 차원(채널별 트래픽, 디바이스 등) 범위 확정.
