diff --git a/.env.example b/.env.example
index 251c3126..b3192c46 100644
--- a/.env.example
+++ b/.env.example
@@ -32,11 +32,28 @@ FACE_AI_BASE_URL=
CHECKUP_ADMIN_DATAGSM_IDS=
# 세션
+# https로 서비스하면 true(운영). http면 브라우저가 SESSION 쿠키를 저장하지 않아 로그인 직후부터 401이므로 false로 둔다.
SESSION_COOKIE_SECURE=true
+# 로그인 유지 기간. 서버 세션과 SESSION 쿠키 max-age에 같이 쓴다. 비우면 30d(예: 14d, 30d).
SESSION_TIMEOUT=
-# API 문서
-SWAGGER_ENABLED=true
+# API 문서(/swagger-ui/index.html). 운영 기본은 비공개(false)다. 로그인 없이 API 목록·오류 코드가 보이므로 프론트 연동 기간처럼 필요할 때만 true로 켜고 끝나면 다시 끈다.
+# compose.prod.yaml 없이 로컬에서 직접 실행하면 application.yaml 기본값(true)이라 문서가 열린다.
+SWAGGER_ENABLED=false
# 요청별 DB 쿼리 수·걸린 시간 로그. 느린 API를 찾을 때만 true로 둔다.
-QUERY_LOG_ENABLED=false
\ No newline at end of file
+QUERY_LOG_ENABLED=false
+
+# DB 커넥션 풀. 비워 두면 기본값(크기 10, 가득 찼을 때 10초 대기)을 쓴다.
+# 서버 대수 × 풀 크기가 Postgres max_connections보다 작아야 한다.
+DB_POOL_SIZE=
+DB_POOL_TIMEOUT_MS=
+
+# 서버 JVM 옵션. 비워 두면 -XX:+ExitOnOutOfMemoryError만 적용한다. 값을 넣으면 이 값으로 바뀌므로 그 옵션도 함께 쓴다.
+# 예: -XX:+ExitOnOutOfMemoryError -XX:MaxRAMPercentage=50
+SERVER_JAVA_OPTS=
+
+# 종료 신호(재시작·배포) 뒤 처리 중인 요청을 기다리는 시간과 docker가 강제 종료하기까지의 유예 시간.
+# 비워 두면 30s / 45s다. 유예 시간이 대기 시간보다 길어야 한다. 얼굴 AI 응답 대기(FACE_AI_RESPONSE_TIMEOUT, 기본 30s)보다 짧게 줄이지 않는다.
+SHUTDOWN_TIMEOUT=
+SHUTDOWN_GRACE_PERIOD=
diff --git a/README.md b/README.md
index f45cd502..21c9d77f 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,71 @@
-# CheckUp Server
-
-광주소프트웨어마이스터고등학교 기숙사 출석 관리 서비스 **CheckUp**의 Spring Boot 백엔드입니다.
-
-기숙사 자치위원이 학생을 한 명씩 확인하던 입소·자습실 출석을 얼굴 인식과 QR 인증으로 대신합니다.
+
CheckUp Server
+
+
+ 광주소프트웨어마이스터고등학교 기숙사 출석 관리 서비스 CheckUp 의 Spring Boot 백엔드
+
+
+
+
+
+
+
+
+
+
+
+ 소개 ·
+ 빠른 시작 ·
+ 설정 ·
+ 개발 도구 ·
+ 얼굴 AI 연결 ·
+ 명세·문서
+
+
+---
+
+## 소개
+
+기숙사 자치위원이 학생을 한 명씩 확인하던 입소·자습실 출석을 **얼굴 인식**과 **QR 인증**으로 대신합니다.
기숙사생 약 200명, 출입구 3곳을 대상으로 하며 학생과 관리자 모두 웹으로 사용합니다.
> 기능별 구현 상태는 `docs-harness/docs/plans/`를 확인하세요. 얼굴 AI의 현재 연결 상태와 남은 검증은 `face-recognition.md`에 기록합니다.
## 서버가 맡는 일
-- **인증·권한**: DataGSM OAuth 로그인, 개인정보 동의, 관리자·본인·본인 호실 범위를 서버에서 검증
-- **출석**: 얼굴 인식·QR 결과를 받아 학생·용도(기숙사 입소 / 자습실)·운영일 단위로 출석 확정, 중복 방지, 관리자 수동 수정
-- **QR 세션**: 관리자 페이지별 독립 세션과 15분마다 교체되는 토큰
-- **운영일**: 매일 08:00(KST) 기준 당일 기록 정리
-- **공지·봉사**: 공지와 학생 알림, 봉사 횟수 관리
-
-얼굴 검출·임베딩 대조는 FastAPI(AI 서버)가 맡고, 출석 확정은 이 서버가 합니다.
-
-```text
-브라우저 (Next.js)
- │
- ▼
-Spring API (이 레포) ── DataGSM OAuth
- ├── PostgreSQL: 학생·동의·출석·공지·봉사
- ├── Redis: 세션·QR·일시 상태
- └── FastAPI: 얼굴 검출·신원 대조
+| 영역 | 내용 |
+| --- | --- |
+| 인증·권한 | DataGSM OAuth 로그인, 개인정보 동의, 관리자·본인·본인 호실 범위를 서버에서 검증 |
+| 출석 | 얼굴 인식·QR 결과를 받아 학생·용도(기숙사 입소 / 자습실)·운영일 단위로 출석 확정, 중복 방지, 관리자 수동 수정 |
+| QR 세션 | 관리자 페이지별 독립 세션과 15분마다 교체되는 토큰 |
+| 운영일 | 매일 08:00(KST) 기준 당일 기록 정리 |
+| 공지·봉사 | 공지와 학생 알림, 봉사 횟수 관리 |
+
+얼굴 검출·임베딩 대조는 FastAPI(AI 서버)가 맡고, **출석 확정은 이 서버가 합니다.**
+
+## 아키텍처
+
+```mermaid
+flowchart LR
+ B["브라우저 Next.js"] --> S["Spring API 이 레포"]
+ S --> D["DataGSM OAuth"]
+ S --> P[("PostgreSQL 학생·동의·출석·공지·봉사")]
+ S --> R[("Redis 세션·QR·일시 상태")]
+ S --> F["FastAPI 얼굴 검출·신원 대조"]
```
## 기술 스택
-- Java 25, Spring Boot 4.1
-- Spring Web MVC, Spring Security (OAuth2 Client), Spring Data JPA, Spring Data Redis
-- PostgreSQL 17, Redis 7, Flyway
-- 테스트: JUnit 5
-- CI: GitHub Actions
+| 구분 | 사용 기술 |
+| --- | --- |
+| 언어·프레임워크 | Java 25, Spring Boot 4.1 |
+| 웹·보안 | Spring Web MVC, Spring Security (OAuth2 Client) |
+| 데이터 | Spring Data JPA, Spring Data Redis, PostgreSQL 17, Redis 7, Flyway |
+| 테스트 | JUnit 5 |
+| CI | GitHub Actions |
-## 시작하기
+## 빠른 시작
+
+### 1. 저장소 받기
```bash
git clone --recurse-submodules https://github.com/Start-Up-10th/CheckUp-server.git
@@ -46,7 +76,7 @@ git submodule update --init
`docs-harness/`가 비어 있다면 서브모듈을 받지 않은 상태입니다. 위 명령을 실행하세요.
-### 로컬 DB 띄우기
+### 2. 로컬 DB 띄우기
서버 실행과 테스트 모두 Postgres·Redis가 필요합니다. `compose.yaml`로 띄웁니다.
@@ -56,27 +86,11 @@ docker compose up -d
`application.yaml`의 기본값이 `compose.yaml`과 맞춰져 있어 별도 설정 없이 접속됩니다.
-### 빌드·테스트·실행
-
-- Java 25
-- 로컬 DB가 떠 있어야 합니다.
+### 3. 개인 설정 넣기
-```bash
-./gradlew build
-./gradlew bootRun
-```
-
-### 개인 설정
-
-값을 바꾸고 싶으면 `src/main/resources/application-local.yaml`을 만들어 덮어씁니다.
+`src/main/resources/application-local.yaml`을 만들어 값을 넣습니다.
이 파일은 커밋되지 않습니다. OAuth Secret 같은 비밀값도 여기에 둡니다.
-```yaml
-spring:
- datasource:
- url: jdbc:postgresql://localhost:5432/checkup
-```
-
로컬에서도 기본값이 없는 값은 반드시 넣어야 서버와 테스트가 뜹니다.
```yaml
@@ -90,11 +104,118 @@ checkup:
base-url: http://localhost:3000
```
-운영 서버에서는 `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `REDIS_HOST`, `REDIS_PORT` 환경변수로 값을 넣습니다.
-`PUBLIC_ORIGIN`(웹 주소)도 반드시 넣습니다. QR 링크와 로그인 후 웹으로 돌아가는 주소에 쓰이며, 비어 있으면 서버가 기동하지 않습니다.
-로그인 콜백은 성공하면 `{PUBLIC_ORIGIN}/login/complete`(또는 `GET /api/v1/auth/login?redirect=/경로`로 정한 경로), 실패하면 `{PUBLIC_ORIGIN}/login?error=<오류 코드>`로 302 리다이렉트합니다.
-DataGSM 호출은 연결 3초·응답 5초까지만 기다립니다. `DATAGSM_CONNECT_TIMEOUT`, `DATAGSM_RESPONSE_TIMEOUT`(예: `5s`)으로 바꿀 수 있고, 시간 안에 응답이 없으면 `DATAGSM_UNAVAILABLE`로 응답합니다.
-운영 Redis는 `maxmemory-policy noeviction`으로 설정합니다. 로그인 세션·QR 세션·QR 토큰 기록이 Redis에 있어, 메모리가 부족할 때 키를 먼저 지우는 정책(`allkeys-lru` 등)이면 로그인이 풀리거나 만료된 QR이 `INVALID`로 잘못 안내됩니다.
+기본값을 바꾸고 싶을 때도 같은 파일에서 덮어씁니다.
+
+```yaml
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/checkup
+```
+
+### 4. 빌드·테스트·실행
+
+Java 25가 필요하고, 로컬 DB가 떠 있어야 합니다.
+
+```bash
+./gradlew build
+./gradlew bootRun
+```
+
+서버가 뜨면 API 문서를 http://localhost:8080/swagger-ui/index.html 에서 볼 수 있습니다.
+
+## 프로젝트 구조
+
+```text
+src/main/java/com/checkup/checkup
+├── domain
+│ ├── attendance 출석 저장·조회
+│ ├── auth DataGSM OAuth 로그인·세션
+│ ├── consent 개인정보 동의
+│ ├── face 얼굴 등록·인식 세션, AI 서버 연동
+│ ├── member 회원·학생
+│ ├── notification 학생 알림
+│ ├── qr QR 세션·스캔 출석
+│ ├── room 호실 명단·전개도 출석
+│ ├── user 학생 정보·봉사 내역 조회
+│ ├── volunteer 봉사 횟수
+│ └── webhook DataGSM 웹훅·학생 동기화
+└── global
+ ├── config 공통 설정
+ ├── exception 오류 코드·응답
+ ├── querylog 요청별 쿼리 수 로그
+ ├── ratelimit 요청 횟수 제한
+ ├── security Spring Security·출처 확인
+ └── time Clock·운영일 계산
+```
+
+## 설정
+
+운영 서버에서는 환경변수로 값을 넣습니다. 항목과 설명은 `.env.example`에도 있습니다.
+
+### 주요 환경변수
+
+| 환경변수 | 기본값 | 설명 |
+| --- | --- | --- |
+| `DB_URL`, `DB_USERNAME`, `DB_PASSWORD` | 로컬 `compose.yaml` 값 | PostgreSQL 접속 정보 |
+| `REDIS_HOST`, `REDIS_PORT` | `localhost`, `6379` | Redis 접속 정보 |
+| `PUBLIC_ORIGIN` | 없음(필수) | 웹 주소. QR 링크와 로그인 후 웹으로 돌아가는 주소에 쓰이며, 비어 있으면 서버가 기동하지 않습니다 |
+| `DATAGSM_CONNECT_TIMEOUT`, `DATAGSM_RESPONSE_TIMEOUT` | `3s`, `5s` | DataGSM 호출 대기 시간. 시간 안에 응답이 없으면 `DATAGSM_UNAVAILABLE`로 응답합니다 |
+| `DB_POOL_SIZE`, `DB_POOL_TIMEOUT_MS` | `10`, `10000` | DB 커넥션 풀 크기와, 풀이 가득 찼을 때 실패하기까지 기다리는 시간 |
+| `SESSION_TIMEOUT` | `7d` | 요청이 없을 때 로그인 세션 유지 시간 |
+| `SESSION_COOKIE_SECURE` | `false` | HTTPS에서만 세션 쿠키 전송. 운영에서는 `true` |
+| `SWAGGER_ENABLED` | `true` | API 문서 공개 여부 |
+| `SHUTDOWN_TIMEOUT` | `30s` | 종료 신호 뒤 처리 중인 요청을 기다리는 시간 |
+| `SHUTDOWN_GRACE_PERIOD` | `45s` | docker가 컨테이너를 강제 종료하기까지의 시간 |
+| `SERVER_JAVA_OPTS` | `-XX:+ExitOnOutOfMemoryError` | 서버 JVM 옵션 |
+| `QUERY_LOG_ENABLED` | `false` | 요청별 DB 쿼리 수·걸린 시간 로그 ([개발 도구](#개발-도구)) |
+| `METRICS_ENABLED` | `false` | Prometheus 지표 내보내기. 로컬 전용 ([개발 도구](#개발-도구)) |
+
+얼굴 AI 관련 값은 [얼굴 AI 서비스 연결](#얼굴-ai-서비스-연결)을 보세요.
+
+### 운영 시 알아 둘 점
+
+- **로그인 콜백**: 성공하면 `{PUBLIC_ORIGIN}/login/complete`(또는 `GET /api/v1/auth/login?redirect=/경로`로 정한 경로), 실패하면 `{PUBLIC_ORIGIN}/login?error=<오류 코드>`로 302 리다이렉트합니다.
+- **DB 커넥션 풀**: 서버 대수 × 풀 크기는 Postgres `max_connections`보다 작아야 합니다.
+- **종료**: 재시작·배포 때 종료 신호를 받으면 새 요청을 받지 않고 처리 중인 요청을 `SHUTDOWN_TIMEOUT`까지 마친 뒤 종료합니다. docker는 `SHUTDOWN_GRACE_PERIOD`가 지나면 강제 종료하므로 이 값이 더 커야 합니다. 서버가 한 대라 배포 중 잠깐의 중단은 남습니다.
+- **메모리**: 서버 JVM은 `OutOfMemoryError`가 나면 종료하고 컨테이너 restart 정책이 다시 띄웁니다. 힙 크기와 컨테이너 메모리 제한은 운영 VM 메모리를 확인하기 전까지 정하지 않아 JVM 기본값(호스트 메모리의 25%)입니다.
+- **Redis**: 운영 Redis는 `maxmemory-policy noeviction`으로 설정합니다. 로그인 세션·QR 세션·QR 토큰 기록이 Redis에 있어, 메모리가 부족할 때 키를 먼저 지우는 정책(`allkeys-lru` 등)이면 로그인이 풀리거나 만료된 QR이 `INVALID`로 잘못 안내됩니다.
+
+## 개발 도구
+
+성능을 고치기 전후를 비교할 때 쓰는 도구입니다.
+
+| 도구 | 켜는 방법 | 알 수 있는 것 |
+| --- | --- | --- |
+| [쿼리 수 로그](#쿼리-수-로그) | `QUERY_LOG_ENABLED=true` | 요청 하나의 DB 쿼리 수와 걸린 시간 |
+| [부하 기준값 측정](#부하-기준값-측정-로컬-전용) | `LOADTEST=true` | 동시 호출 시 응답 시간과 호출당 쿼리 수 |
+| [지표 대시보드](#지표-대시보드-로컬-전용) | `METRICS_ENABLED=true` | 시간에 따른 응답 시간·쿼리 수·연결 풀·메모리 |
+
+### 쿼리 수 로그
+
+요청마다 응답 상태, DB 쿼리 수, 걸린 시간을 로그 한 줄로 남깁니다. 기본은 꺼져 있습니다.
+
+```bash
+QUERY_LOG_ENABLED=true ./gradlew bootRun
+```
+
+```text
+GET /api/v1/room/floor status=200 queries=2 elapsedMs=14
+```
+
+경로만 남기고 쿼리 문자열·헤더·본문은 남기지 않습니다. `/actuator` 아래 요청은 남기지 않습니다.
+
+### 부하 기준값 측정 (로컬 전용)
+
+출석 시간대에 몰리는 요청(QR 스캔 폭주, 학생 홈 조회, 관리자 호실·층 조회)을 서비스 계층에서 동시에 호출해 응답 시간과 호출당 DB 쿼리 수를 잽니다. 로컬 Postgres·Redis(`docker compose up -d postgres redis`)가 필요하고, 학생 200명을 임시로 만들었다 지웁니다.
+
+```bash
+LOADTEST=true ./gradlew test --rerun --tests "*ServiceLoadTest"
+# 결과: build/loadtest/report.md (동시 호출 수는 LOADTEST_CONCURRENCY, 기본 50)
+```
+
+- 평소 `./gradlew build`에서는 `LOADTEST`가 없어 건너뜁니다.
+- HTTP·로그인·직렬화와 DataGSM·얼굴 AI 호출은 포함하지 않습니다. 절대값이 아니라 변경 전후 비교와 호출당 쿼리 수를 보는 용도입니다.
+- 실행마다 20% 안팎 흔들리니 같은 조건에서 두세 번 돌려 비교하세요.
### 지표 대시보드 (로컬 전용)
@@ -105,14 +226,17 @@ docker compose --profile metrics up -d
METRICS_ENABLED=true ./gradlew bootRun
```
-- Grafana: http://localhost:3001 (로그인 없이 보기 전용. `CheckUp 서버` 대시보드가 바로 열립니다)
-- Prometheus: http://localhost:9090 (`Status > Target health`에서 서버가 `UP`인지 확인)
+| 주소 | 용도 |
+| --- | --- |
+| http://localhost:3001 | Grafana. 로그인 없이 보기 전용이며 `CheckUp 서버` 대시보드가 바로 열립니다 |
+| http://localhost:9090 | Prometheus. `Status > Target health`에서 서버가 `UP`인지 확인합니다 |
+
- `METRICS_ENABLED=true`이면 `/actuator/prometheus`가 로그인 없이 열립니다. 기본은 꺼져 있고 그때는 경로 자체가 없습니다.
- `docker compose up -d`만 실행하면 Prometheus와 Grafana는 뜨지 않습니다.
- 대시보드는 `monitoring/grafana/dashboards/checkup-server.json`, 수집 설정은 `monitoring/prometheus.yml`입니다.
- 끌 때는 `docker compose --profile metrics stop prometheus grafana`입니다.
-### 얼굴 AI 서비스 연결
+## 얼굴 AI 서비스 연결
Spring은 얼굴 등록·프레임 추론 요청을 AI 서비스에 위임하고 브라우저는 Spring API만 호출합니다. 로컬에서는 `application-local.yaml`, 운영에서는 Spring 프로세스 환경변수로 설정합니다.
@@ -123,10 +247,18 @@ checkup:
service-token: ${FACE_SERVICE_TOKEN}
```
-`FACE_AI_BASE_URL`에는 서비스 origin을 설정합니다. readiness 주소가 `http://service.gsmsv.site:32200/health/ready`라면 base URL은 `http://service.gsmsv.site:32200`입니다. Spring이 `/health/ready`와 `/internal/v1/face/*` 경로를 붙입니다.
-Spring 컨테이너에서 호스트의 8000번 포트로 노출된 AI에 연결할 때 기본값 `http://host.docker.internal:8000/`을 사용합니다. Linux Docker에서는 Spring 컨테이너에 `host.docker.internal:host-gateway` 매핑이 필요할 수 있습니다. Spring을 호스트에서 직접 실행한다면 `FACE_AI_BASE_URL=http://localhost:8000`으로 지정할 수 있습니다. 끝의 `/`는 Spring에서 제거합니다.
-`FACE_SERVICE_TOKEN`은 AI와 Spring에 동일하게 설정하는 서버 간 secret이며 브라우저에 보내지 않습니다. 이 토큰이 포함된 요청은 HTTPS 또는 신뢰된 사설망으로만 전송하고 AI 포트의 접근을 Spring 서버로 제한합니다.
-AI 인식 세션은 프로세스 메모리에 저장되므로 AI 인스턴스 하나를 사용하거나 `session_id` 기준으로 같은 인스턴스에 라우팅해야 합니다.
+| 환경변수 | 설명 |
+| --- | --- |
+| `FACE_AI_BASE_URL` | AI 서비스 origin. Spring이 `/health/ready`와 `/internal/v1/face/*` 경로를 붙입니다 |
+| `FACE_SERVICE_TOKEN` | AI와 Spring에 동일하게 설정하는 서버 간 secret. 브라우저에 보내지 않습니다 |
+
+- **주소 형식**: readiness 주소가 `http://service.gsmsv.site:32200/health/ready`라면 base URL은 `http://service.gsmsv.site:32200`입니다. 끝의 `/`는 Spring에서 제거합니다.
+- **컨테이너에서 연결**: Spring 컨테이너에서 호스트의 8000번 포트로 노출된 AI에 연결할 때 기본값 `http://host.docker.internal:8000/`을 사용합니다. Linux Docker에서는 Spring 컨테이너에 `host.docker.internal:host-gateway` 매핑이 필요할 수 있습니다.
+- **호스트에서 직접 실행**: `FACE_AI_BASE_URL=http://localhost:8000`으로 지정할 수 있습니다.
+- **토큰 보호**: 토큰이 포함된 요청은 HTTPS 또는 신뢰된 사설망으로만 전송하고 AI 포트의 접근을 Spring 서버로 제한합니다.
+- **AI 인스턴스 수**: AI 인식 세션은 프로세스 메모리에 저장되므로 AI 인스턴스 하나를 사용하거나 `session_id` 기준으로 같은 인스턴스에 라우팅해야 합니다.
+
+### 배포 AI 연결 확인
배포 AI의 인증 경로 스모크 테스트는 기본 테스트에서 실행되지 않습니다. HTTPS 또는 신뢰된 사설망에서만 실제 설정값으로 옵트인합니다.
@@ -143,9 +275,11 @@ $env:FACE_AI_LIVE_TESTS = 'true'
공통 명세·지침·스킬은 [Start-Up-harness](https://github.com/Start-Up-10th/Start-Up-harness)에 있으며, 이 레포에는 `docs-harness/` 서브모듈로 연결되어 있습니다.
-- 제품 명세: `docs-harness/docs/spec/`
-- 아키텍처·기술 결정: `docs-harness/docs/architecture.md`, `docs-harness/docs/decisions.md`
-- 백엔드 작업 지침: `docs-harness/server/AGENTS.md`
+| 문서 | 위치 |
+| --- | --- |
+| 제품 명세 | `docs-harness/docs/spec/` |
+| 아키텍처·기술 결정 | `docs-harness/docs/architecture.md`, `docs-harness/docs/decisions.md` |
+| 백엔드 작업 지침 | `docs-harness/server/AGENTS.md` |
명세·계획·API 계약 수정은 이 레포가 아니라 Start-Up-harness 레포에서 커밋합니다.
diff --git a/build.gradle b/build.gradle
index c43d00cf..d6aca48e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -32,6 +32,7 @@ dependencies {
implementation 'com.github.themoment-team:datagsm-oauth-sdk-java:1.6.0'
implementation 'com.github.themoment-team:datagsm-openapi-sdk-java:1.7.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+ implementation 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-session-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
compileOnly 'org.projectlombok:lombok'
diff --git a/compose.prod.yaml b/compose.prod.yaml
index 66ef972a..258c47f9 100644
--- a/compose.prod.yaml
+++ b/compose.prod.yaml
@@ -17,6 +17,8 @@ services:
DB_URL: jdbc:postgresql://postgres:5432/checkup
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
+ DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
+ DB_POOL_TIMEOUT_MS: ${DB_POOL_TIMEOUT_MS:-10000}
REDIS_HOST: redis
REDIS_PORT: 6379
DATAGSM_CLIENT_ID: ${DATAGSM_CLIENT_ID}
@@ -26,13 +28,21 @@ services:
DATAGSM_WEBHOOK_SECRET: ${DATAGSM_WEBHOOK_SECRET}
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE}
- # .env에 빈 값으로 두면 application.yaml의 기본값(7d)이 적용되지 않으므로 여기서 기본값을 준다(#156).
- SESSION_TIMEOUT: ${SESSION_TIMEOUT:-7d}
- SWAGGER_ENABLED: ${SWAGGER_ENABLED}
+ # .env에 빈 값으로 두면 application.yaml의 기본값(30d)이 적용되지 않으므로 여기서 기본값을 준다(#156).
+ SESSION_TIMEOUT: ${SESSION_TIMEOUT:-30d}
+ # 운영은 기본 비공개다. .env에 빈 값으로 두어도 false가 되도록 여기서 기본값을 준다(#100).
+ SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
QUERY_LOG_ENABLED: ${QUERY_LOG_ENABLED:-false}
FACE_SERVICE_TOKEN: ${FACE_SERVICE_TOKEN}
FACE_AI_BASE_URL: ${FACE_AI_BASE_URL}
CHECKUP_ADMIN_DATAGSM_IDS: ${CHECKUP_ADMIN_DATAGSM_IDS:-}
+ # 메모리가 모자라 OutOfMemoryError가 나면 반쯤 죽은 채 응답하지 않고 종료해, restart 정책이 다시 띄우게 한다.
+ # 힙 크기는 정하지 않는다(기본: 호스트 메모리의 25%). 필요하면 SERVER_JAVA_OPTS에 -XX:MaxRAMPercentage=... 를 함께 넣는다.
+ JAVA_TOOL_OPTIONS: ${SERVER_JAVA_OPTS:--XX:+ExitOnOutOfMemoryError}
+ # 종료 신호 뒤 처리 중인 요청을 기다리는 시간(#233). 비워 두어도 기본값이 적용되도록 여기서 기본값을 준다.
+ SHUTDOWN_TIMEOUT: ${SHUTDOWN_TIMEOUT:-30s}
+ # docker가 SIGKILL로 강제 종료하기 전까지 기다리는 시간. SHUTDOWN_TIMEOUT보다 길어야 한다(기본 10초면 얼굴 요청이 끊긴다).
+ stop_grace_period: ${SHUTDOWN_GRACE_PERIOD:-45s}
# DB·Redis까지 닿아야 healthy다(REQ-OPS-001). 로그인 없이 열린 경로는 /actuator/health뿐이다.
healthcheck:
test: ["CMD", "curl", "-fsS", "-m", "5", "http://127.0.0.1:8080/actuator/health"]
diff --git a/monitoring/grafana/dashboards/checkup-server.json b/monitoring/grafana/dashboards/checkup-server.json
index 50c4a7d5..22e71b4b 100644
--- a/monitoring/grafana/dashboards/checkup-server.json
+++ b/monitoring/grafana/dashboards/checkup-server.json
@@ -6,7 +6,7 @@
],
"timezone": "Asia/Seoul",
"schemaVersion": 39,
- "version": 1,
+ "version": 2,
"refresh": "15s",
"time": {
"from": "now-30m",
@@ -359,6 +359,254 @@
}
]
},
+ {
+ "id": 11,
+ "type": "timeseries",
+ "title": "얼굴 프레임 처리 시간: 전체와 AI",
+ "description": "프레임 한 장의 평균 처리 시간. 전체는 Spring이 요청을 받아 응답할 때까지, AI는 그중 AI 서버 응답을 기다린 시간이다. 나머지는 Spring의 DB 작업 등이다.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 40,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum(rate(http_server_requests_seconds_sum{uri=\"/api/v1/face/sessions/{sessionId}/frames\"}[1m])) / sum(rate(http_server_requests_seconds_count{uri=\"/api/v1/face/sessions/{sessionId}/frames\"}[1m]))",
+ "legendFormat": "전체"
+ },
+ {
+ "refId": "B",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum(rate(http_client_requests_seconds_sum{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m])) / sum(rate(http_client_requests_seconds_count{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m]))",
+ "legendFormat": "AI 서버 호출"
+ },
+ {
+ "refId": "C",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "(sum(rate(http_server_requests_seconds_sum{uri=\"/api/v1/face/sessions/{sessionId}/frames\"}[1m])) / sum(rate(http_server_requests_seconds_count{uri=\"/api/v1/face/sessions/{sessionId}/frames\"}[1m]))) - (sum(rate(http_client_requests_seconds_sum{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m])) / sum(rate(http_client_requests_seconds_count{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m])))",
+ "legendFormat": "나머지 (Spring·DB)"
+ }
+ ]
+ },
+ {
+ "id": 12,
+ "type": "timeseries",
+ "title": "얼굴 프레임 AI 호출 시간 (p50·p95)",
+ "description": "AI 서버가 프레임 한 장에 응답하는 시간. 절반이 p50 안에, 100개 중 95개가 p95 안에 끝난다.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 40,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "histogram_quantile(0.5, sum by (le) (rate(http_client_requests_seconds_bucket{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m])))",
+ "legendFormat": "p50"
+ },
+ {
+ "refId": "B",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(http_client_requests_seconds_bucket{uri=\"/internal/v1/face/sessions/{sessionId}/frames\"}[1m])))",
+ "legendFormat": "p95"
+ }
+ ]
+ },
+ {
+ "id": 13,
+ "type": "timeseries",
+ "title": "AI 서버 호출별 평균 시간",
+ "description": "준비 확인, 세션 생성·삭제, 프레임 인식, 등록 영상 처리별 평균 응답 시간.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 48,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum by (method, uri) (rate(http_client_requests_seconds_sum{uri=~\"/internal/v1/face/.*|/health/ready\"}[1m])) / sum by (method, uri) (rate(http_client_requests_seconds_count{uri=~\"/internal/v1/face/.*|/health/ready\"}[1m]))",
+ "legendFormat": "{{method}} {{uri}}"
+ }
+ ]
+ },
+ {
+ "id": 14,
+ "type": "timeseries",
+ "title": "얼굴 프레임 수와 AI 오류",
+ "description": "초당 처리한 프레임 수와 AI 서버가 오류로 답한 호출 수.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 48,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum(rate(http_server_requests_seconds_count{uri=\"/api/v1/face/sessions/{sessionId}/frames\"}[1m]))",
+ "legendFormat": "초당 프레임"
+ },
+ {
+ "refId": "B",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum by (status) (rate(http_client_requests_seconds_count{uri=~\"/internal/v1/face/.*\",outcome!=\"SUCCESS\"}[1m]))",
+ "legendFormat": "AI 오류 {{status}}"
+ }
+ ]
+ },
{
"id": 7,
"type": "timeseries",
@@ -482,6 +730,112 @@
"legendFormat": "호스트 전체"
}
]
+ },
+ {
+ "id": 9,
+ "type": "timeseries",
+ "title": "학생 id 캐시 적중률",
+ "description": "회원 id로 학생 id를 찾을 때 DB를 조회하지 않고 기억한 값으로 답한 비율.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 32,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum(rate(cache_gets_total{cache=\"studentIdByMember\",result=\"hit\"}[1m])) / sum(rate(cache_gets_total{cache=\"studentIdByMember\"}[1m]))",
+ "legendFormat": "적중률"
+ }
+ ]
+ },
+ {
+ "id": 10,
+ "type": "timeseries",
+ "title": "학생 id 캐시 조회 수",
+ "description": "초당 조회 수. 실패(miss)는 DB를 조회한 횟수다.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 32,
+ "w": 12,
+ "h": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops",
+ "min": 0,
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "calcs": [
+ "mean",
+ "max"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "expr": "sum by (result) (rate(cache_gets_total{cache=\"studentIdByMember\"}[1m]))",
+ "legendFormat": "{{result}}"
+ }
+ ]
}
]
}
diff --git a/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceService.java b/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceService.java
index 2206caef..8b046f71 100644
--- a/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceService.java
+++ b/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceService.java
@@ -17,6 +17,7 @@
import com.checkup.checkup.domain.attendance.repository.AttendanceRepository;
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.checkup.checkup.domain.member.service.StudentIdCache;
import com.checkup.checkup.domain.notification.entity.NotificationType;
import com.checkup.checkup.domain.notification.service.NotificationService;
import com.checkup.checkup.global.exception.CustomException;
@@ -40,6 +41,7 @@ public class AttendanceService {
private final Clock clock;
private final NotificationService notificationService;
private final StudentRepository studentRepository;
+ private final StudentIdCache studentIdCache;
/**
* 자동 인증 성공을 출석으로 기록한다. 동시에 여러 요청이 와도 한 번만 기록된다.
@@ -69,6 +71,33 @@ public AttendanceRecordResult markAttended(
AttendancePurpose purpose,
Instant verifiedAt,
AttendanceMethod method
+ ) {
+ return mark(studentId, null, purpose, verifiedAt, method);
+ }
+
+ /**
+ * {@link #markAttended}와 같지만, 호출하는 쪽이 방금 읽은 학생을 넘겨 학생을 DB에서 다시 읽지 않는다(#211).
+ * 넘겨받은 학생의 필수 동의·호실은 메모리에서 다시 확인하므로 마지막 방어선은 그대로다.
+ *
+ * @param student 출석할 학생. 호출하는 쪽이 방금 DB에서 읽은 값이어야 한다
+ * @throws CustomException 필수 동의가 없거나 호실이 없으면 {@link ErrorCode#FORBIDDEN}(403)
+ */
+ @Transactional
+ public AttendanceRecordResult markAttendedFor(
+ Student student,
+ AttendancePurpose purpose,
+ Instant verifiedAt,
+ AttendanceMethod method
+ ) {
+ return mark(student.getId(), student, purpose, verifiedAt, method);
+ }
+
+ private AttendanceRecordResult mark(
+ Long studentId,
+ Student known,
+ AttendancePurpose purpose,
+ Instant verifiedAt,
+ AttendanceMethod method
) {
Instant now = clock.instant();
if (verifiedAt.isAfter(now.plus(MAX_CLOCK_SKEW))) {
@@ -80,7 +109,11 @@ public AttendanceRecordResult markAttended(
return AttendanceRecordResult.STALE;
}
- verifyEligible(studentId);
+ if (known == null) {
+ verifyEligible(studentId);
+ } else {
+ verifyEligible(known);
+ }
int changed = attendanceRepository.markAttended(
studentId, purpose.name(), operatingDay, recordedAt, method.name());
@@ -97,8 +130,11 @@ public AttendanceRecordResult markAttended(
}
private void verifyEligible(Long studentId) {
- Student student = studentRepository.findById(studentId)
- .orElseThrow(() -> new CustomException(ErrorCode.STUDENT_NOT_FOUND));
+ verifyEligible(studentRepository.findById(studentId)
+ .orElseThrow(() -> new CustomException(ErrorCode.STUDENT_NOT_FOUND)));
+ }
+
+ private void verifyEligible(Student student) {
if (!student.isAttendanceEligible()) {
throw new CustomException(ErrorCode.FORBIDDEN);
}
@@ -149,11 +185,11 @@ public void saveManually(Map attendedByStudentId, AttendancePurpo
*/
@Transactional(readOnly = true)
public MyAttendanceResponse getMyToday(Long memberId) {
- Student student = studentRepository.findByMemberId(memberId)
+ Long studentId = studentIdCache.findStudentId(memberId)
.orElseThrow(() -> new CustomException(ErrorCode.MISSING_STUDENT_INFO));
LocalDate operatingDay = operatingDayCalculator.today();
return MyAttendanceResponse.of(operatingDay,
- attendanceRepository.findAllByStudentIdAndOperatingDay(student.getId(), operatingDay));
+ attendanceRepository.findAllByStudentIdAndOperatingDay(studentId, operatingDay));
}
/** 출석 완료 알림 문구. */
diff --git a/src/main/java/com/checkup/checkup/domain/face/config/FaceProperties.java b/src/main/java/com/checkup/checkup/domain/face/config/FaceProperties.java
index 1413237a..0ce75ba5 100644
--- a/src/main/java/com/checkup/checkup/domain/face/config/FaceProperties.java
+++ b/src/main/java/com/checkup/checkup/domain/face/config/FaceProperties.java
@@ -4,6 +4,7 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.ConstructorBinding;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
@@ -24,8 +25,34 @@ public record FaceProperties(
@NotNull Duration enrollmentWaitTimeout,
@NotNull Duration sessionIdleTimeout,
@Positive long cleanupIntervalMs,
- @NotBlank String consentVersion
+ @NotBlank String consentVersion,
+ @Positive int maxCandidates
) {
+ /** 얼굴 인식 세션 후보 상한의 기본값. 기숙사생 약 200명(scope.md)과 같다. 계약은 "설정된 최대치"를 넘으면 422라고 한다. */
+ public static final int DEFAULT_MAX_CANDIDATES = 200;
+
+ /** 후보 상한을 따로 정하지 않는 호출용. {@link #DEFAULT_MAX_CANDIDATES}를 쓴다. */
+ public FaceProperties(
+ String aiBaseUrl,
+ String serviceToken,
+ Duration connectTimeout,
+ Duration responseTimeout,
+ long maxUploadBytes,
+ long maxFrameBytes,
+ Duration minFrameInterval,
+ int maxConcurrentFrames,
+ int maxConcurrentEnrollments,
+ Duration enrollmentWaitTimeout,
+ Duration sessionIdleTimeout,
+ long cleanupIntervalMs,
+ String consentVersion
+ ) {
+ this(aiBaseUrl, serviceToken, connectTimeout, responseTimeout, maxUploadBytes, maxFrameBytes,
+ minFrameInterval, maxConcurrentFrames, maxConcurrentEnrollments, enrollmentWaitTimeout,
+ sessionIdleTimeout, cleanupIntervalMs, consentVersion, DEFAULT_MAX_CANDIDATES);
+ }
+
+ @ConstructorBinding
public FaceProperties {
if (aiBaseUrl != null) {
aiBaseUrl = aiBaseUrl.replaceFirst("/+$", "");
diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionCandidateRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionCandidateRepository.java
index 7f294165..2da9a0e6 100644
--- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionCandidateRepository.java
+++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionCandidateRepository.java
@@ -7,6 +7,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import java.util.Collection;
import java.util.List;
import java.util.UUID;
@@ -22,4 +23,17 @@ public interface FaceRecognitionSessionCandidateRepository
void deleteAllForSession(@Param("sessionId") UUID sessionId);
List findAllByStudent_Id(Long studentId);
+
+ /**
+ * 주어진 학생 모두를 세션의 후보로 한 문장에 저장한다. 후보 수만큼 insert하지 않는다(#216).
+ * 저장되지 않은 학생 id는 건너뛰므로 반환값이 id 수보다 작으면 그런 id가 있다는 뜻이다.
+ *
+ * @return 저장한 후보 수
+ */
+ @Modifying(flushAutomatically = true)
+ @Query(value = """
+ INSERT INTO face_recognition_session_candidate (session_id, student_id)
+ SELECT :sessionId, s.id FROM student s WHERE s.id IN (:studentIds)
+ """, nativeQuery = true)
+ int insertAll(@Param("sessionId") UUID sessionId, @Param("studentIds") Collection studentIds);
}
diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java
index c91bf488..243b63b4 100644
--- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java
+++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java
@@ -17,6 +17,8 @@ public interface FaceRecognitionSessionRepository extends JpaRepository findByIdAndAdminMemberIdAndActiveTrue(UUID id, Long adminMemberId);
+ boolean existsByIdAndAdminMemberIdAndActiveTrue(UUID id, Long adminMemberId);
+
List findAllByAdminMemberId(Long adminMemberId);
List findAllByIdIn(List ids);
diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java
index 10833ea9..a90c8f95 100644
--- a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java
+++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java
@@ -43,7 +43,6 @@
@Slf4j
@Service
public class FaceRecognitionService {
- private static final int MAX_CANDIDATES = 200;
private static final int VECTOR_DIMENSION = 256;
private final FaceTemplateRepository faceTemplateRepository;
@@ -91,7 +90,7 @@ public FaceRecognitionService(
* @return 새 세션 id와 용도
* @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403),
* 후보 학생이 없으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS}(409),
- * 후보가 200명을 넘으면 {@link ErrorCode#FACE_TOO_MANY_CANDIDATES}(422),
+ * 후보가 설정된 최대(기본 200명)를 넘으면 {@link ErrorCode#FACE_TOO_MANY_CANDIDATES}(422),
* 저장된 템플릿의 모델이 서로 다르거나 벡터가 깨졌으면 {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502).
* AI 서버 오류는 {@link com.checkup.checkup.domain.face.ai.AiFaceException}으로 던지고
* {@code GlobalExceptionHandler}가 {@link ErrorCode#FACE_AI_UNAVAILABLE}(503),
@@ -103,7 +102,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose)
if (templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS);
}
- if (templates.size() > MAX_CANDIDATES) {
+ if (templates.size() > properties.maxCandidates()) {
throw new CustomException(ErrorCode.FACE_TOO_MANY_CANDIDATES);
}
AiFaceSessionRequest request = sessionRequest(templates);
@@ -215,15 +214,13 @@ public FaceFrameResponse recognize(
throw new CustomException(ErrorCode.FACE_AI_BAD_GATEWAY);
}
result.faces().forEach(FaceRecognitionService::validateFace);
- FaceSessionView currentSession;
- try {
- currentSession = faceSessionStore.findOwned(sessionId, adminMemberId);
- } catch (CustomException closed) {
+ // AI를 기다리는 동안 세션이 닫혔는지만 본다. 후보 목록은 처음 읽은 값을 쓴다(세션이 열려 있는 동안 바뀌지 않는다).
+ if (!faceSessionStore.isOwnedActive(sessionId, adminMemberId)) {
deleteAiSessionQuietly(sessionId);
- throw closed;
+ throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND);
}
return new FaceFrameResponse(frameId, result.faces().stream()
- .map(face -> toPublicFace(currentSession, face, now)).toList());
+ .map(face -> toPublicFace(session, face, now)).toList());
} catch (AiFaceException e) {
if (e.getStatus() >= 500 || e.getStatus() == 404) {
faceSessionStore.findOwnedIfPresent(sessionId, adminMemberId).ifPresent(this::closeOwnedQuietly);
@@ -300,8 +297,8 @@ private FaceFrameResponse.Face toPublicFace(
&& student.get().isAttendanceEligible()) {
studentName = student.get().getName();
studentNumber = student.get().getStudentNumber();
- AttendanceRecordResult recorded = attendanceService.markAttended(
- student.get().getId(), session.purpose(), verifiedAt, AttendanceMethod.FACE);
+ AttendanceRecordResult recorded = attendanceService.markAttendedFor(
+ student.get(), session.purpose(), verifiedAt, AttendanceMethod.FACE);
attendance = attendanceResult(recorded);
switch (attendance) {
case "RECORDED" -> recordQuietly(session, face.trackId(), FaceRecognitionResult.SUCCESS,
@@ -421,7 +418,7 @@ private AiFaceSessionRequest candidateRequest(Set studentIds) {
List templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream()
.filter(template -> eligibleStudent(template.getStudent()))
.toList();
- if (templates.size() != studentIds.size() || templates.size() > MAX_CANDIDATES || templates.isEmpty()) {
+ if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND);
}
return sessionRequest(templates);
diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java
index 7881142d..76a32bce 100644
--- a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java
+++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java
@@ -5,8 +5,6 @@
import com.checkup.checkup.domain.face.entity.FaceRecognitionSessionCandidate;
import com.checkup.checkup.domain.face.repository.FaceRecognitionSessionCandidateRepository;
import com.checkup.checkup.domain.face.repository.FaceRecognitionSessionRepository;
-import com.checkup.checkup.domain.member.entity.Student;
-import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
@@ -27,7 +25,6 @@
public class FaceSessionStore {
private final FaceRecognitionSessionRepository sessionRepository;
private final FaceRecognitionSessionCandidateRepository candidateRepository;
- private final StudentRepository studentRepository;
/**
* 관리자 얼굴 인식 세션과 후보 학생 목록을 저장한다. AI 세션을 만들기 전에 먼저 저장해, AI 쪽 정리가 실패해도 다시 시도할 수 있게 한다.
@@ -38,15 +35,12 @@ public class FaceSessionStore {
@Transactional
public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose, List studentIds,
Instant createdAt, Instant initialLastActivityAt) {
- FaceRecognitionSession session = sessionRepository.save(
+ sessionRepository.save(
FaceRecognitionSession.create(sessionId, adminMemberId, purpose, createdAt, initialLastActivityAt));
- List students = studentRepository.findAllById(studentIds);
- if (students.size() != studentIds.size()) {
+ // 후보 수만큼 insert하지 않고 한 문장으로 저장한다. 저장되지 않은 학생이 있으면 개수가 모자라 트랜잭션째 취소된다.
+ if (candidateRepository.insertAll(sessionId, studentIds) != studentIds.size()) {
throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS);
}
- candidateRepository.saveAll(students.stream()
- .map(student -> FaceRecognitionSessionCandidate.create(session, student))
- .toList());
}
/**
@@ -70,6 +64,15 @@ public FaceSessionView findOwned(UUID sessionId, Long adminMemberId) {
*
* @return 없거나 다른 관리자의 세션이면 비어 있다
*/
+ /**
+ * 세션이 아직 이 관리자의 활성 세션인지만 확인한다. 후보 목록은 읽지 않는다.
+ * AI 응답을 기다리는 동안 세션이 닫혔는지 보는 데 쓴다(#210). 후보는 세션이 열려 있는 동안 바뀌지 않으므로 처음 읽은 값을 그대로 쓴다.
+ */
+ @Transactional(readOnly = true)
+ public boolean isOwnedActive(UUID sessionId, Long adminMemberId) {
+ return sessionRepository.existsByIdAndAdminMemberIdAndActiveTrue(sessionId, adminMemberId);
+ }
+
@Transactional(readOnly = true)
public Optional findOwnedIfPresent(UUID sessionId, Long adminMemberId) {
return sessionRepository.findByIdAndAdminMemberId(sessionId, adminMemberId)
diff --git a/src/main/java/com/checkup/checkup/domain/member/repository/StudentRepository.java b/src/main/java/com/checkup/checkup/domain/member/repository/StudentRepository.java
index 22aa07d6..c707b668 100644
--- a/src/main/java/com/checkup/checkup/domain/member/repository/StudentRepository.java
+++ b/src/main/java/com/checkup/checkup/domain/member/repository/StudentRepository.java
@@ -18,7 +18,23 @@ public interface StudentRepository extends JpaRepository {
Optional findByMemberId(Long memberId);
- @EntityGraph(attributePaths = "member")
+ /**
+ * 회원 id에 연결된 학생의 id만 읽는다. 학생 행 전체를 읽지 않는다.
+ *
+ * @return 학생 id. 학생이 아닌 회원이면 비어 있다
+ */
+ @Query("SELECT s.id FROM Student s WHERE s.member.id = :memberId")
+ Optional findIdByMemberId(@Param("memberId") Long memberId);
+
+ /**
+ * 회원 id에 연결된 학생의 DataGSM 학생 id만 읽는다. 본인 확인에 쓰며 학생 행 전체를 읽지 않는다.
+ *
+ * @return DataGSM 학생 id. 학생이 아니거나 DataGSM 학생 id가 없으면 비어 있다
+ */
+ @Query("SELECT s.datagsmStudentId FROM Student s WHERE s.member.id = :memberId")
+ Optional findDatagsmStudentIdByMemberId(@Param("memberId") Long memberId);
+
+ /** DataGSM 학생 id로 학생 한 명을 조회한다. 회원은 읽지 않는다(필요하면 지연 로딩). */
Optional findByDatagsmStudentId(Long datagsmStudentId);
/**
@@ -58,17 +74,15 @@ int insertIfAbsent(
long countByIdInAndDormitoryRoomIsNotNull(Collection ids);
/**
- * 한 호실의 학생을 회원 정보와 함께 조회한다. 순서는 정하지 않는다.
+ * 한 호실의 학생을 조회한다. 순서는 정하지 않는다. 회원은 읽지 않는다.
* 이름 정렬은 DB 정렬 규칙에 따라 가나다순이 아닐 수 있어 쓰는 쪽에서 {@link Student#NAME_ORDER}로 한다(#150).
*/
- @EntityGraph(attributePaths = "member")
List findAllByDormitoryRoom(Integer dormitoryRoom);
/**
- * 한 호실에서 DataGSM 학생 id가 있는 학생을 회원 정보와 함께 조회한다. 순서는 정하지 않는다.
+ * 한 호실에서 DataGSM 학생 id가 있는 학생을 조회한다. 순서는 정하지 않는다. 회원은 읽지 않는다.
* 이름 정렬은 쓰는 쪽에서 {@link Student#NAME_ORDER}로 한다(#150).
*/
- @EntityGraph(attributePaths = "member")
List findAllByDormitoryRoomAndDatagsmStudentIdIsNotNull(Integer dormitoryRoom);
/**
@@ -81,10 +95,9 @@ int insertIfAbsent(
List findAllByDatagsmStudentIdIn(Collection datagsmStudentIds);
/**
- * DataGSM 학생 id가 있는 모든 학생을 회원 정보와 함께 조회한다. 봉사 관리 명단에 쓴다.
+ * DataGSM 학생 id가 있는 모든 학생을 조회한다. 봉사 관리 명단에 쓴다. 회원은 읽지 않는다.
* 명단의 조정 API 경로가 DataGSM 학생 id라서 그 값이 없는 학생은 뺀다. 정렬은 쓰는 쪽에서 한다.
*/
- @EntityGraph(attributePaths = "member")
List findAllByDatagsmStudentIdIsNotNull();
/**
diff --git a/src/main/java/com/checkup/checkup/domain/member/service/MemberService.java b/src/main/java/com/checkup/checkup/domain/member/service/MemberService.java
index 9e132c0d..313d875a 100644
--- a/src/main/java/com/checkup/checkup/domain/member/service/MemberService.java
+++ b/src/main/java/com/checkup/checkup/domain/member/service/MemberService.java
@@ -7,6 +7,7 @@
import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
+import com.checkup.checkup.global.security.AdminRoleCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +22,7 @@ public class MemberService {
private final MemberRepository memberRepository;
private final StudentRepository studentRepository;
+ private final AdminRoleCache adminRoleCache;
/**
* DataGSM 사용자 정보로 회원을 저장하거나 갱신한다. 학생이면 학생 정보도 함께 저장·갱신한다.
@@ -42,6 +44,7 @@ public Member saveOrUpdate(UserInfo userInfo, MemberRole role) {
Member member = memberRepository.findByDatagsmId(userInfo.getId())
.orElseGet(() -> memberRepository.save(Member.create(userInfo.getId(), name, role)));
member.update(name, role);
+ adminRoleCache.evictAfterCommit(member.getId());
if (dataGsmStudent != null) {
Student student = studentRepository.findByMember(member)
diff --git a/src/main/java/com/checkup/checkup/domain/member/service/StudentIdCache.java b/src/main/java/com/checkup/checkup/domain/member/service/StudentIdCache.java
new file mode 100644
index 00000000..3a956f2e
--- /dev/null
+++ b/src/main/java/com/checkup/checkup/domain/member/service/StudentIdCache.java
@@ -0,0 +1,74 @@
+package com.checkup.checkup.domain.member.service;
+
+import java.time.Duration;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Ticker;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tags;
+import io.micrometer.core.instrument.binder.MeterBinder;
+import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics;
+
+/**
+ * 회원 id로 학생 id를 찾고, 찾은 값을 서버 메모리에 잠시 기억해 같은 회원의 다음 요청에서는 DB를 조회하지 않는다.
+ *
+ * - 학생이 보내는 요청마다 세션의 회원 id로 학생을 찾는다. 이 연결은 첫 로그인 때 만들어진 뒤 바뀌지 않아 기억해도 틀릴 일이 거의 없다.
+ * - 학생 id만 기억한다. 호실·동의·봉사 횟수처럼 바뀌는 값은 기억하지 않으므로 그런 값이 필요한 곳은 계속 DB에서 읽는다.
+ * - 학생이 없는 회원(교사, 아직 연결되지 않은 계정)은 기억하지 않는다. 나중에 연결되면 바로 찾을 수 있어야 한다.
+ * - 기억한 값은 10분 뒤 버린다. 학생이 새 DataGSM 계정으로 다시 연결되면 예전 계정은 그동안 같은 학생으로 조회될 수 있다.
+ * - 서버 프로세스 안에만 있다. 서버를 여러 대로 늘려도 값이 바뀌지 않는 연결이라 서버마다 따로 기억해도 된다.
+ * - 적중·실패 횟수를 {@code cache.gets} 지표(cache=studentIdByMember)로 내보낸다. 지표 수집기가 있으면 Spring Boot가 {@link #bindTo}를 부른다.
+ */
+@Component
+public class StudentIdCache implements MeterBinder {
+
+ private static final Duration TTL = Duration.ofMinutes(10);
+ private static final long MAX_SIZE = 5_000;
+ private static final String CACHE_NAME = "studentIdByMember";
+
+ private final StudentRepository studentRepository;
+ private final Cache studentIdByMemberId;
+
+ @Autowired
+ public StudentIdCache(StudentRepository studentRepository) {
+ this(studentRepository, Ticker.systemTicker());
+ }
+
+ StudentIdCache(StudentRepository studentRepository, Ticker ticker) {
+ this.studentRepository = studentRepository;
+ this.studentIdByMemberId = Caffeine.newBuilder()
+ .expireAfterWrite(TTL)
+ .maximumSize(MAX_SIZE)
+ .ticker(ticker)
+ .recordStats()
+ .build();
+ }
+
+ @Override
+ public void bindTo(MeterRegistry registry) {
+ new CaffeineCacheMetrics<>(studentIdByMemberId, CACHE_NAME, Tags.empty()).bindTo(registry);
+ }
+
+ /**
+ * 회원 id에 연결된 학생 id를 돌려준다. 기억한 값이 있으면 DB를 조회하지 않는다.
+ *
+ * @param memberId 세션의 회원 id
+ * @return 학생 id. 학생이 아닌 회원이면 비어 있다
+ */
+ public Optional findStudentId(Long memberId) {
+ Long cached = studentIdByMemberId.getIfPresent(memberId);
+ if (cached != null) {
+ return Optional.of(cached);
+ }
+ Optional found = studentRepository.findIdByMemberId(memberId);
+ found.ifPresent(studentId -> studentIdByMemberId.put(memberId, studentId));
+ return found;
+ }
+}
diff --git a/src/main/java/com/checkup/checkup/domain/notification/service/NotificationService.java b/src/main/java/com/checkup/checkup/domain/notification/service/NotificationService.java
index e6277b3c..6d928d79 100644
--- a/src/main/java/com/checkup/checkup/domain/notification/service/NotificationService.java
+++ b/src/main/java/com/checkup/checkup/domain/notification/service/NotificationService.java
@@ -1,7 +1,6 @@
package com.checkup.checkup.domain.notification.service;
-import com.checkup.checkup.domain.member.entity.Student;
-import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.checkup.checkup.domain.member.service.StudentIdCache;
import com.checkup.checkup.domain.notification.dto.response.NotificationListResponse;
import com.checkup.checkup.domain.notification.dto.response.NotificationResponse;
import com.checkup.checkup.domain.notification.dto.response.UnreadResponse;
@@ -29,7 +28,7 @@
public class NotificationService {
private final NotificationRepository notificationRepository;
- private final StudentRepository studentRepository;
+ private final StudentIdCache studentIdCache;
private final Clock clock;
private final OperatingDayCalculator operatingDayCalculator;
@@ -112,8 +111,7 @@ public int deleteExpiredAttendance() {
}
private Long studentIdOf(Long memberId) {
- Student student = studentRepository.findByMemberId(memberId)
+ return studentIdCache.findStudentId(memberId)
.orElseThrow(() -> new CustomException(ErrorCode.MISSING_STUDENT_INFO));
- return student.getId();
}
}
diff --git a/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java b/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java
index 097d4dd6..0a1fb878 100644
--- a/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java
+++ b/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java
@@ -51,6 +51,30 @@ public class QrSessionRepository {
return 1
""", Long.class);
+ /** 토큰 해시와 그 토큰이 가리키는 세션 해시를 Redis 한 번에 읽는다. 토큰이 없으면 둘 다 빈 목록이다. */
+ private static final RedisScript FIND_TOKEN_WITH_SESSION = new DefaultRedisScript<>("""
+ local token = redis.call('HGETALL', KEYS[1])
+ local sessionId
+ for i = 1, #token, 2 do
+ if token[i] == 'sessionId' then
+ sessionId = token[i + 1]
+ end
+ end
+ if not sessionId then
+ return {token, {}}
+ end
+ return {token, redis.call('HGETALL', ARGV[1] .. sessionId)}
+ """, List.class);
+
+ /**
+ * 토큰과 그 토큰의 세션을 함께 읽은 결과.
+ *
+ * @param token 토큰. 없으면 비어 있다
+ * @param session 토큰이 가리키는 세션. 토큰이 없거나 세션 키가 이미 사라졌으면 비어 있다
+ */
+ public record TokenLookup(Optional token, Optional session) {
+ }
+
private final StringRedisTemplate redisTemplate;
/**
@@ -96,7 +120,10 @@ public boolean saveIfPresent(QrSession session, Duration ttl) {
* 세션을 조회한다.
*/
public Optional findById(String id) {
- Map hash = redisTemplate.opsForHash().entries(SESSION_KEY + id);
+ return toSession(id, redisTemplate.opsForHash().entries(SESSION_KEY + id));
+ }
+
+ private static Optional toSession(String id, Map, ?> hash) {
if (hash.isEmpty()) {
return Optional.empty();
}
@@ -156,7 +183,34 @@ public void saveToken(QrToken token, Duration ttl) {
* 토큰을 조회한다.
*/
public Optional findToken(String token) {
- Map hash = redisTemplate.opsForHash().entries(TOKEN_KEY + token);
+ return toToken(token, redisTemplate.opsForHash().entries(TOKEN_KEY + token));
+ }
+
+ /**
+ * 토큰과 그 토큰의 세션을 Redis 한 번(Lua)으로 읽는다. QR 스캔이 토큰 조회와 세션 조회를 차례로 왕복하지 않게 한다(#211).
+ * 두 키는 한 스크립트 안에서 읽으므로 그 사이에 세션이 바뀌어 어긋난 값을 보지 않는다.
+ */
+ public TokenLookup findTokenWithSession(String token) {
+ List> result = redisTemplate.execute(FIND_TOKEN_WITH_SESSION, List.of(TOKEN_KEY + token), SESSION_KEY);
+ if (result == null || result.size() != 2) {
+ return new TokenLookup(Optional.empty(), Optional.empty());
+ }
+ Optional qrToken = toToken(token, toHash(result.get(0)));
+ Optional session = qrToken.flatMap(found -> toSession(found.sessionId(), toHash(result.get(1))));
+ return new TokenLookup(qrToken, session);
+ }
+
+ private static Map toHash(Object flat) {
+ Map hash = new java.util.HashMap<>();
+ if (flat instanceof List> values) {
+ for (int i = 0; i + 1 < values.size(); i += 2) {
+ hash.put((String) values.get(i), (String) values.get(i + 1));
+ }
+ }
+ return hash;
+ }
+
+ private static Optional toToken(String token, Map, ?> hash) {
if (hash.isEmpty()) {
return Optional.empty();
}
diff --git a/src/main/java/com/checkup/checkup/domain/qr/service/QrScanService.java b/src/main/java/com/checkup/checkup/domain/qr/service/QrScanService.java
index 111e9ec4..657b3544 100644
--- a/src/main/java/com/checkup/checkup/domain/qr/service/QrScanService.java
+++ b/src/main/java/com/checkup/checkup/domain/qr/service/QrScanService.java
@@ -56,9 +56,11 @@ public QrScanResult scan(Long memberId, String token) {
}
Instant now = clock.instant();
- Optional qrToken = QrTokenGenerator.isWellFormed(token)
- ? qrSessionRepository.findToken(token)
- : Optional.empty();
+ // 토큰과 세션을 Redis 한 번에 읽는다. 판정 순서(INVALID → EXPIRED → CLOSED)는 읽은 값으로 그대로 따른다.
+ QrSessionRepository.TokenLookup lookup = QrTokenGenerator.isWellFormed(token)
+ ? qrSessionRepository.findTokenWithSession(token)
+ : new QrSessionRepository.TokenLookup(Optional.empty(), Optional.empty());
+ Optional qrToken = lookup.token();
if (qrToken.isEmpty()) {
return QrScanResult.INVALID;
}
@@ -66,14 +68,13 @@ public QrScanResult scan(Long memberId, String token) {
return QrScanResult.EXPIRED;
}
- Optional session = qrSessionRepository.findById(qrToken.get().sessionId())
- .filter(found -> found.isLeaseActive(now));
+ Optional session = lookup.session().filter(found -> found.isLeaseActive(now));
if (session.isEmpty()) {
return QrScanResult.CLOSED;
}
- AttendanceRecordResult recorded = attendanceService.markAttended(
- student.getId(),
+ AttendanceRecordResult recorded = attendanceService.markAttendedFor(
+ student,
session.get().purpose(),
now,
AttendanceMethod.QR
diff --git a/src/main/java/com/checkup/checkup/domain/room/service/RoomService.java b/src/main/java/com/checkup/checkup/domain/room/service/RoomService.java
index ba8aa6a7..32ba6410 100644
--- a/src/main/java/com/checkup/checkup/domain/room/service/RoomService.java
+++ b/src/main/java/com/checkup/checkup/domain/room/service/RoomService.java
@@ -3,11 +3,8 @@
import com.checkup.checkup.domain.attendance.entity.AttendancePurpose;
import com.checkup.checkup.domain.attendance.repository.AttendanceRepository;
import com.checkup.checkup.domain.attendance.service.AttendanceService;
-import com.checkup.checkup.domain.member.entity.Member;
-import com.checkup.checkup.domain.member.entity.MemberRole;
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
-import com.checkup.checkup.domain.member.service.MemberService;
import com.checkup.checkup.domain.room.dto.request.RoomAttendanceRequest;
import com.checkup.checkup.domain.room.dto.response.RoomFloorResponse;
import com.checkup.checkup.domain.room.dto.response.RoomStudentResponse;
@@ -31,7 +28,6 @@ public class RoomService {
private static final int ROOMS_PER_FLOOR = 100;
- private final MemberService memberService;
private final StudentRepository studentRepository;
private final AttendanceRepository attendanceRepository;
private final OperatingDayCalculator operatingDayCalculator;
@@ -48,10 +44,8 @@ public class RoomService {
*/
@Transactional(readOnly = true)
public List getStudents(Long memberId, Integer dormitoryRoom, AttendancePurpose purpose) {
- Member member = memberService.getById(memberId);
-
- if (member.getRole() != MemberRole.ADMIN) {
- Student currentStudent = studentRepository.findByMember(member)
+ if (!adminVerifier.isAdmin(memberId)) {
+ Student currentStudent = studentRepository.findByMemberId(memberId)
.orElseThrow(() -> new CustomException(ErrorCode.MISSING_STUDENT_INFO));
Integer currentRoom = currentStudent.getDormitoryRoom();
if (currentRoom == null || !currentRoom.equals(dormitoryRoom)) {
diff --git a/src/main/java/com/checkup/checkup/domain/user/service/UserAccessVerifier.java b/src/main/java/com/checkup/checkup/domain/user/service/UserAccessVerifier.java
index 5e3b9045..4fd0e5a6 100644
--- a/src/main/java/com/checkup/checkup/domain/user/service/UserAccessVerifier.java
+++ b/src/main/java/com/checkup/checkup/domain/user/service/UserAccessVerifier.java
@@ -1,33 +1,35 @@
package com.checkup.checkup.domain.user.service;
-import com.checkup.checkup.domain.member.entity.Member;
-import com.checkup.checkup.domain.member.entity.MemberRole;
import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
+import com.checkup.checkup.global.security.AdminVerifier;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* DataGSM 학생 id 기준 조회 API의 접근 권한을 확인한다. 관리자이거나 본인 학생 id일 때만 허용한다.
+ * 역할은 {@link AdminVerifier}가 짧게 기억한 값을 쓰고, 본인 확인에는 DataGSM 학생 id 한 값만 읽는다(#232).
*/
@Component
@RequiredArgsConstructor
public class UserAccessVerifier {
+ private final AdminVerifier adminVerifier;
private final StudentRepository studentRepository;
/**
- * @param requester 세션의 로그인 회원
+ * @param memberId 세션의 로그인 회원 id
* @param studentId 조회할 DataGSM 학생 id
- * @throws CustomException 관리자도 본인도 아니면 {@link ErrorCode#FORBIDDEN}(403)
+ * @throws CustomException 회원이 없으면 {@link ErrorCode#MEMBER_NOT_FOUND}(401),
+ * 관리자도 본인도 아니면 {@link ErrorCode#FORBIDDEN}(403)
*/
- public void verify(Member requester, Long studentId) {
- if (requester.getRole() == MemberRole.ADMIN) {
+ public void verify(Long memberId, Long studentId) {
+ if (adminVerifier.isAdmin(memberId)) {
return;
}
- boolean self = studentRepository.findByMember(requester)
- .map(s -> studentId.equals(s.getDatagsmStudentId()))
+ boolean self = studentRepository.findDatagsmStudentIdByMemberId(memberId)
+ .map(studentId::equals)
.orElse(false);
if (!self) {
throw new CustomException(ErrorCode.FORBIDDEN);
diff --git a/src/main/java/com/checkup/checkup/domain/user/service/UserSearchService.java b/src/main/java/com/checkup/checkup/domain/user/service/UserSearchService.java
index b87bd57d..c5537407 100644
--- a/src/main/java/com/checkup/checkup/domain/user/service/UserSearchService.java
+++ b/src/main/java/com/checkup/checkup/domain/user/service/UserSearchService.java
@@ -1,7 +1,5 @@
package com.checkup.checkup.domain.user.service;
-import com.checkup.checkup.domain.member.entity.Member;
-import com.checkup.checkup.domain.member.service.MemberService;
import com.checkup.checkup.domain.user.dto.response.UserSearchResponse;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.DataGsmErrorCodes;
@@ -27,7 +25,6 @@ public class UserSearchService {
private static final int NOT_FOUND = 404;
private final DataGsmOpenApiClient dataGsmOpenApiClient;
- private final MemberService memberService;
private final UserAccessVerifier userAccessVerifier;
private final UserSearchCache userSearchCache;
@@ -40,8 +37,7 @@ public class UserSearchService {
* 그 밖의 DataGSM 호출 실패는 {@link ErrorCode#DATAGSM_ERROR}(502)
*/
public UserSearchResponse findUser(Long memberId, Long studentId) {
- Member requester = memberService.getById(memberId);
- userAccessVerifier.verify(requester, studentId);
+ userAccessVerifier.verify(memberId, studentId);
Optional cached = userSearchCache.get(studentId);
if (cached.isPresent()) {
diff --git a/src/main/java/com/checkup/checkup/domain/user/service/UserVolunteerService.java b/src/main/java/com/checkup/checkup/domain/user/service/UserVolunteerService.java
index 99299919..b7a1aef3 100644
--- a/src/main/java/com/checkup/checkup/domain/user/service/UserVolunteerService.java
+++ b/src/main/java/com/checkup/checkup/domain/user/service/UserVolunteerService.java
@@ -1,9 +1,7 @@
package com.checkup.checkup.domain.user.service;
-import com.checkup.checkup.domain.member.entity.Member;
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
-import com.checkup.checkup.domain.member.service.MemberService;
import com.checkup.checkup.domain.user.dto.response.UserVolunteerHistoryResponse;
import com.checkup.checkup.domain.user.dto.response.UserVolunteerResponse;
import com.checkup.checkup.domain.volunteer.entity.DutyStatus;
@@ -21,7 +19,6 @@
@RequiredArgsConstructor
public class UserVolunteerService {
- private final MemberService memberService;
private final StudentRepository studentRepository;
private final UserAccessVerifier userAccessVerifier;
private final VolunteerDutyRepository volunteerDutyRepository;
@@ -33,8 +30,7 @@ public class UserVolunteerService {
*/
@Transactional(readOnly = true)
public UserVolunteerResponse findVolunteer(Long memberId, Long studentId) {
- Member requester = memberService.getById(memberId);
- userAccessVerifier.verify(requester, studentId);
+ userAccessVerifier.verify(memberId, studentId);
Student student = studentRepository.findByDatagsmStudentId(studentId)
.orElseThrow(() -> new CustomException(ErrorCode.STUDENT_NOT_FOUND));
@@ -50,8 +46,7 @@ public UserVolunteerResponse findVolunteer(Long memberId, Long studentId) {
*/
@Transactional(readOnly = true)
public UserVolunteerHistoryResponse findVolunteerHistory(Long memberId, Long studentId) {
- Member requester = memberService.getById(memberId);
- userAccessVerifier.verify(requester, studentId);
+ userAccessVerifier.verify(memberId, studentId);
Student student = studentRepository.findByDatagsmStudentId(studentId)
.orElseThrow(() -> new CustomException(ErrorCode.STUDENT_NOT_FOUND));
diff --git a/src/main/java/com/checkup/checkup/domain/webhook/service/StudentSyncService.java b/src/main/java/com/checkup/checkup/domain/webhook/service/StudentSyncService.java
index 0ac5aa6f..b17fc8c1 100644
--- a/src/main/java/com/checkup/checkup/domain/webhook/service/StudentSyncService.java
+++ b/src/main/java/com/checkup/checkup/domain/webhook/service/StudentSyncService.java
@@ -7,6 +7,7 @@
import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.domain.webhook.dto.StudentSyncData;
import com.checkup.checkup.global.config.AdminProperties;
+import com.checkup.checkup.global.security.AdminRoleCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
@@ -36,6 +37,7 @@ public class StudentSyncService {
private final StudentRepository studentRepository;
private final ApplicationEventPublisher eventPublisher;
private final AdminProperties adminProperties;
+ private final AdminRoleCache adminRoleCache;
/**
* 받은 학생들을 반영한다. {@code syncedAt}보다 새로운 정보를 이미 반영한 학생은 건너뛴다.
@@ -111,6 +113,7 @@ private boolean sync(Student student, StudentSyncData changed) {
if (GRADUATE.equals(changed.role()) || WITHDRAWN.equals(changed.role())) {
if (member != null) {
member.update(member.getName(), roleFor(member, MemberRole.STUDENT));
+ adminRoleCache.evictAfterCommit(member.getId());
}
student.leaveDormitory();
eventPublisher.publishEvent(new StudentLeftEvent(student.getId(), changed.role()));
@@ -125,6 +128,7 @@ private boolean sync(Student student, StudentSyncData changed) {
if (member != null) {
member.update(changed.name(), roleFor(member, role));
+ adminRoleCache.evictAfterCommit(member.getId());
}
student.update(
changed.datagsmStudentId(),
diff --git a/src/main/java/com/checkup/checkup/global/security/AdminRoleCache.java b/src/main/java/com/checkup/checkup/global/security/AdminRoleCache.java
new file mode 100644
index 00000000..f506f6d5
--- /dev/null
+++ b/src/main/java/com/checkup/checkup/global/security/AdminRoleCache.java
@@ -0,0 +1,83 @@
+package com.checkup.checkup.global.security;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import com.checkup.checkup.domain.member.entity.MemberRole;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 회원 id별 역할을 짧게 서버 메모리에 보관해, 관리자 API마다 회원 행을 DB에서 읽지 않게 한다(#212).
+ * 역할은 로그인과 DataGSM 동기화에서만 바뀌므로, 바꾸는 쪽이 커밋 뒤에 {@link #evictAfterCommit}으로 항목을 지운다.
+ * 그래서 서버가 한 대면 권한 회수가 바로 반영된다. 지우지 못하는 경우(다른 서버 인스턴스)도 {@link #TTL}이 지나면 반영된다.
+ * 없는 회원은 보관하지 않는다. DB·Redis에는 저장하지 않는다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdminRoleCache {
+
+ static final Duration TTL = Duration.ofSeconds(30);
+ static final int MAX_ENTRIES = 1000;
+
+ private record Entry(MemberRole role, Instant expiresAt) {
+ }
+
+ private final Clock clock;
+ private final ConcurrentMap entries = new ConcurrentHashMap<>();
+
+ /** 보관한 역할을 돌려준다. 없거나 {@link #TTL}이 지났으면 비어 있다. */
+ public Optional get(Long memberId) {
+ Entry entry = entries.get(memberId);
+ if (entry == null) {
+ return Optional.empty();
+ }
+ if (!clock.instant().isBefore(entry.expiresAt())) {
+ entries.remove(memberId, entry);
+ return Optional.empty();
+ }
+ return Optional.of(entry.role());
+ }
+
+ /** 역할을 보관한다. 가득 차면 만료분을 먼저 지우고, 그래도 가득 차 있으면 보관하지 않는다. */
+ public void put(Long memberId, MemberRole role) {
+ Instant now = clock.instant();
+ if (entries.size() >= MAX_ENTRIES) {
+ entries.values().removeIf(entry -> !now.isBefore(entry.expiresAt()));
+ if (entries.size() >= MAX_ENTRIES) {
+ return;
+ }
+ }
+ entries.put(memberId, new Entry(role, now.plus(TTL)));
+ }
+
+ /** 항목을 바로 지운다. */
+ public void evict(Long memberId) {
+ entries.remove(memberId);
+ }
+
+ /**
+ * 트랜잭션이 커밋된 뒤 항목을 지운다. 트랜잭션 밖이면 바로 지운다.
+ * 커밋 전에 지우면 그 사이 다른 요청이 옛 역할을 다시 보관할 수 있어 커밋 뒤에 지운다.
+ */
+ public void evictAfterCommit(Long memberId) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ evict(memberId);
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ evict(memberId);
+ }
+ });
+ }
+}
diff --git a/src/main/java/com/checkup/checkup/global/security/AdminVerifier.java b/src/main/java/com/checkup/checkup/global/security/AdminVerifier.java
index 380b646e..0a9f5ffd 100644
--- a/src/main/java/com/checkup/checkup/global/security/AdminVerifier.java
+++ b/src/main/java/com/checkup/checkup/global/security/AdminVerifier.java
@@ -12,13 +12,15 @@
/**
* 관리자 전용 API에서 현재 회원이 관리자인지 확인한다.
*
- * 세션에 저장된 권한은 로그인 시점 값이라, 권한이 바뀌어도 바로 반영되도록 요청마다 DB의 역할을 본다.
+ * 세션에 저장된 권한은 로그인 시점 값이라, 권한이 바뀌어도 바로 반영되도록 세션이 아닌 DB의 역할을 본다.
+ * 요청마다 DB를 읽지 않도록 역할을 {@link AdminRoleCache}에 짧게 두고, 역할을 바꾸는 로그인·동기화가 커밋 뒤에 그 항목을 지운다(#212).
*/
@Component
@RequiredArgsConstructor
public class AdminVerifier {
private final MemberService memberService;
+ private final AdminRoleCache adminRoleCache;
/**
* 관리자가 아니면 예외를 던진다.
@@ -28,8 +30,25 @@ public class AdminVerifier {
* 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403)
*/
public void verify(Long memberId) {
- if (memberService.getById(memberId).getRole() != MemberRole.ADMIN) {
+ if (!isAdmin(memberId)) {
throw new CustomException(ErrorCode.ADMIN_ONLY);
}
}
+
+ /**
+ * 관리자인지 알려준다. 학생과 관리자가 함께 쓰는 API에서 관리자면 범위 검사를 건너뛸 때 쓴다.
+ *
+ * @param memberId 세션의 회원 id
+ * @return 관리자면 true, 학생이면 false
+ * @throws CustomException 회원이 없으면 {@link ErrorCode#MEMBER_NOT_FOUND}(401)
+ */
+ public boolean isAdmin(Long memberId) {
+ return adminRoleCache.get(memberId).orElseGet(() -> load(memberId)) == MemberRole.ADMIN;
+ }
+
+ private MemberRole load(Long memberId) {
+ MemberRole role = memberService.getById(memberId).getRole();
+ adminRoleCache.put(memberId, role);
+ return role;
+ }
}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index a4d4d33a..42be0aea 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -1,4 +1,7 @@
spring:
+ # 종료 신호를 받으면 새 요청은 받지 않고 처리 중인 요청을 이 시간까지 기다린다. 얼굴 AI 응답 대기(FACE_AI_RESPONSE_TIMEOUT 30s)보다 짧으면 얼굴 요청이 끊긴다. compose의 stop_grace_period보다 짧아야 한다.
+ lifecycle:
+ timeout-per-shutdown-phase: ${SHUTDOWN_TIMEOUT:30s}
# @Scheduled 작업이 서로 기다리지 않도록 스레드 2개
task:
scheduling:
@@ -14,6 +17,11 @@ spring:
url: ${DB_URL:jdbc:postgresql://localhost:5433/checkup}
username: ${DB_USERNAME:checkup}
password: ${DB_PASSWORD:checkup}
+ # 풀 크기는 기본 10이다. 서버 1대·학생 약 200명 규모에서 쿼리 하나가 수 ms라 충분하다. 서버 대수 × 풀 크기가 Postgres max_connections(기본 100)보다 작아야 한다.
+ # 풀이 가득 찼을 때 기본 30초 대신 10초만 기다리고 실패시켜, 요청이 오래 쌓이지 않게 한다.
+ hikari:
+ maximum-pool-size: ${DB_POOL_SIZE:10}
+ connection-timeout: ${DB_POOL_TIMEOUT_MS:10000}
jpa:
open-in-view: false
hibernate:
@@ -48,10 +56,13 @@ datagsm:
response-timeout: ${DATAGSM_RESPONSE_TIMEOUT:5s} # DataGSM 응답 대기 시간. 로그인 콜백은 DataGSM을 두 번 불러 최대 (연결+응답)×2 동안 기다린다
server:
+ # 재시작·배포 때 처리 중인 요청(얼굴 등록, 세션 생성, 출석 저장)을 끝까지 마친 뒤 종료한다(#233)
+ shutdown: graceful
servlet:
session:
- timeout: ${SESSION_TIMEOUT:7d} # 요청이 없을 때 세션 유지 시간
+ timeout: ${SESSION_TIMEOUT:30d} # 요청이 없을 때 세션 유지 시간
cookie:
+ max-age: ${SESSION_TIMEOUT:30d} # 브라우저·PWA를 닫아도 SESSION 쿠키 유지. 세션 유지 시간과 같게 둔다
http-only: true # JS가 쿠키 못 읽음, XSS 방지
same-site: lax # 다른 사이트에서 보내는 POST 요청에는 쿠키X -> CSRF 1차 방어
secure: ${SESSION_COOKIE_SECURE:false} # HTTPS에서만 쿠키 전송, 로컬은 http라 false, 운영에서는 환경변수로 true
@@ -76,6 +87,8 @@ management:
# API 응답 시간의 p95 같은 분위수를 Prometheus에서 계산할 수 있게 구간별 횟수를 남긴다
percentiles-histogram:
http.server.requests: true
+ # 얼굴 AI 서버 호출 시간. Spring Boot가 RestClient 호출을 자동으로 잰다
+ http.client.requests: true
# Hibernate 통계를 켜면 트랜잭션마다 찍히는 요약 로그를 끈다. 값은 지표로 본다.
logging:
@@ -123,3 +136,5 @@ checkup:
session-idle-timeout: ${FACE_SESSION_IDLE_TIMEOUT:5m}
cleanup-interval-ms: ${FACE_CLEANUP_INTERVAL_MS:60000}
consent-version: ${FACE_CONSENT_VERSION:v1}
+ # 얼굴 인식 세션 후보(등록 학생) 상한. 넘으면 세션을 만들지 못한다(422). 기숙사생 약 200명이라 기본 200이다. AI 서버가 감당하는 후보 수를 확인한 뒤 올린다
+ max-candidates: ${FACE_MAX_CANDIDATES:200}
diff --git a/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceServiceTest.java b/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceServiceTest.java
index 2dcaf582..c0ee403a 100644
--- a/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceServiceTest.java
@@ -34,6 +34,9 @@
import com.checkup.checkup.domain.attendance.entity.AttendanceMethod;
import com.checkup.checkup.domain.attendance.entity.AttendancePurpose;
import com.checkup.checkup.domain.attendance.entity.AttendanceRecordResult;
+import com.checkup.checkup.domain.member.entity.Student;
+import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.checkup.checkup.domain.member.service.StudentIdCache;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
import com.checkup.checkup.global.time.OperatingDayCalculator;
@@ -50,7 +53,7 @@
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
-@Import({AttendanceService.class, NotificationService.class, OperatingDayCalculator.class,
+@Import({AttendanceService.class, NotificationService.class, StudentIdCache.class, OperatingDayCalculator.class,
AttendanceServiceTest.ClockTestConfig.class})
class AttendanceServiceTest {
@@ -72,6 +75,9 @@ MutableClock clock() {
@Autowired
private AttendanceService attendanceService;
+ @Autowired
+ private StudentRepository studentRepository;
+
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -142,6 +148,31 @@ void unknownStudentIsNotFound() {
e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.STUDENT_NOT_FOUND));
}
+ @Test
+ @DisplayName("방금 읽은 학생을 넘기면 학생을 다시 읽지 않고 출석으로 기록한다")
+ void markAttendedForRecordsWithKnownStudent() {
+ Student student = studentRepository.findById(studentId).orElseThrow();
+
+ AttendanceRecordResult result = attendanceService.markAttendedFor(
+ student, AttendancePurpose.DORMITORY, AT, AttendanceMethod.QR);
+
+ assertThat(result).isEqualTo(AttendanceRecordResult.RECORDED);
+ assertThat(attended(AttendancePurpose.DORMITORY, DAY)).isTrue();
+ }
+
+ @Test
+ @DisplayName("넘겨받은 학생이 동의나 호실이 없으면 기록하지 않고 403이다")
+ void markAttendedForRejectsIneligibleStudent() {
+ jdbcTemplate.update("UPDATE student SET dormitory_room = NULL WHERE id = ?", studentId);
+ Student student = studentRepository.findById(studentId).orElseThrow();
+
+ assertThatThrownBy(() -> attendanceService.markAttendedFor(
+ student, AttendancePurpose.DORMITORY, AT, AttendanceMethod.QR))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
+ assertThat(attendanceCount()).isZero();
+ }
+
@Test
@DisplayName("이미 출석이면 다시 기록하지 않고 최초 시각을 유지한다")
void alreadyAttendedKeepsFirstVerifiedAt() {
diff --git a/src/test/java/com/checkup/checkup/domain/face/ai/AiFaceClientMetricsTest.java b/src/test/java/com/checkup/checkup/domain/face/ai/AiFaceClientMetricsTest.java
new file mode 100644
index 00000000..fb2ed679
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/domain/face/ai/AiFaceClientMetricsTest.java
@@ -0,0 +1,97 @@
+package com.checkup.checkup.domain.face.ai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+
+/**
+ * 얼굴 AI 서버 호출 시간이 호출 종류별로 지표에 남고, 지표의 경로에 세션 id가 들어가지 않는지 검증한다.
+ */
+@SpringBootTest
+class AiFaceClientMetricsTest {
+
+ private static final String FRAME_URI = "/internal/v1/face/sessions/{sessionId}/frames";
+ private static final HttpServer FAKE_AI = startFakeAi();
+
+ @Autowired
+ private AiFaceClient aiFaceClient;
+
+ @Autowired
+ private MeterRegistry meterRegistry;
+
+ @DynamicPropertySource
+ static void aiBaseUrl(DynamicPropertyRegistry registry) {
+ registry.add("checkup.face.ai-base-url", () -> "http://127.0.0.1:" + FAKE_AI.getAddress().getPort());
+ }
+
+ @AfterAll
+ static void stopFakeAi() {
+ FAKE_AI.stop(0);
+ }
+
+ @Test
+ @DisplayName("프레임 인식 호출 시간이 세션 id 없는 경로로 지표에 남는다")
+ void frameCallIsTimedByUriTemplate() {
+ UUID sessionId = UUID.randomUUID();
+
+ aiFaceClient.recognize(sessionId, "frame-1", new byte[] {1, 2, 3}, MediaType.IMAGE_JPEG);
+
+ Timer timer = meterRegistry.get("http.client.requests")
+ .tag("uri", FRAME_URI)
+ .tag("method", "POST")
+ .tag("status", "200")
+ .timer();
+ assertThat(timer.count()).isEqualTo(1);
+ assertThat(meterRegistry.find("http.client.requests").timers())
+ .noneMatch(found -> found.getId().getTag("uri").contains(sessionId.toString()));
+ }
+
+ @Test
+ @DisplayName("준비 상태 확인 호출은 프레임 인식과 다른 경로로 따로 남는다")
+ void readinessCallIsTimedSeparately() {
+ aiFaceClient.ensureReady();
+
+ assertThat(meterRegistry.get("http.client.requests").tag("uri", "/health/ready").timer().count())
+ .isGreaterThanOrEqualTo(1);
+ }
+
+ private static HttpServer startFakeAi() {
+ try {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/health/ready", exchange -> respond(exchange, "{\"status\":\"ready\"}"));
+ server.createContext("/internal/v1/face/sessions/", exchange -> respond(exchange,
+ "{\"frameId\":\"frame-1\",\"frame_id\":\"frame-1\",\"faces\":[]}"));
+ server.start();
+ return server;
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static void respond(HttpExchange exchange, String json) throws IOException {
+ exchange.getRequestBody().readAllBytes();
+ byte[] body = json.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java
index 0930ecb5..ddb952aa 100644
--- a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java
@@ -6,6 +6,7 @@
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
@@ -26,6 +27,7 @@
import com.checkup.checkup.domain.face.repository.FaceTemplateRepository;
import com.checkup.checkup.global.security.AdminVerifier;
import com.checkup.checkup.support.MutableClock;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -55,6 +57,8 @@ class FaceRecognitionServiceTest {
private final FaceRecognitionLogService logService = mock(FaceRecognitionLogService.class);
private final AdminVerifier adminVerifier = mock(AdminVerifier.class);
private final MutableClock clock = new MutableClock(NOW);
+ /** {@link #givenEligibleStudent}가 만든 학생. */
+ private Student eligibleStudent;
private final FaceProperties properties = new FaceProperties(
"http://face-ai.test", "secret", Duration.ofSeconds(2), Duration.ofSeconds(30),
1024, 512, Duration.ofMillis(200), 2, 2, Duration.ofSeconds(1), Duration.ofMinutes(5), 60_000, "v1");
@@ -63,6 +67,11 @@ class FaceRecognitionServiceTest {
attendanceService, logService, adminVerifier, properties,
tools.jackson.databind.json.JsonMapper.builder().build(), clock);
+ @BeforeEach
+ void sessionStaysActiveDuringAiCall() {
+ given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true);
+ }
+
@Test
@DisplayName("KNOWN 후보만 현재 출석 대상으로 기록한다")
void recordsOnlyKnownCandidates() {
@@ -83,7 +92,7 @@ void recordsOnlyKnownCandidates() {
given(student.getStudentNumber()).willReturn(15);
given(studentRepository.findByDatagsmStudentId(DATAGSM_STUDENT_ID)).willReturn(Optional.of(student));
given(studentRepository.countByIdInAndDormitoryRoomIsNotNull(Set.of(STUDENT_DB_ID))).willReturn(1L);
- given(attendanceService.markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ given(attendanceService.markAttendedFor(student, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
.willReturn(AttendanceRecordResult.RECORDED);
var response = service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2});
@@ -93,7 +102,7 @@ void recordsOnlyKnownCandidates() {
assertThat(response.faces().getFirst().recognition().studentName()).isEqualTo("Student Name");
assertThat(response.faces().getFirst().recognition().studentNumber()).isEqualTo(15);
assertThat(response.faces().getFirst().recognition().attendance()).isEqualTo("RECORDED");
- verify(attendanceService).markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE);
+ verify(attendanceService).markAttendedFor(student, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE);
}
@Test
@@ -115,7 +124,7 @@ void nonCandidateFromAiBecomesUnknown() {
assertThat(response.faces().getFirst().recognition().status()).isEqualTo("UNKNOWN");
assertThat(response.faces().getFirst().recognition().studentName()).isNull();
- verify(attendanceService, never()).markAttended(any(), any(), any(), any());
+ verify(attendanceService, never()).markAttendedFor(any(), any(), any(), any());
}
@Test
@@ -133,7 +142,7 @@ void unknownAndNotAttemptedAreNotRecorded() {
assertThat(response.faces()).extracting(f -> f.recognition().status())
.containsExactly("UNKNOWN", "NOT_ATTEMPTED");
- verify(attendanceService, never()).markAttended(any(), any(), any(), any());
+ verify(attendanceService, never()).markAttendedFor(any(), any(), any(), any());
}
@Test
@@ -156,7 +165,7 @@ void anyInvalidFaceRecordsNoAttendance() {
error -> assertThat(error.getErrorCode()).isEqualTo(
com.checkup.checkup.global.exception.ErrorCode.FACE_AI_BAD_GATEWAY));
- verify(attendanceService, never()).markAttended(any(), any(), any(), any());
+ verify(attendanceService, never()).markAttendedFor(any(), any(), any(), any());
}
@Test
@@ -235,6 +244,49 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception {
verify(sessionStore, never()).delete(any());
}
+ @Test
+ @DisplayName("후보가 설정된 최대를 넘으면 세션을 만들지 않고 422 FACE_TOO_MANY_CANDIDATES다")
+ void tooManyCandidatesAreRejectedByConfiguredMaximum() {
+ FaceProperties limited = new FaceProperties(
+ "http://face-ai.test", "secret", Duration.ofSeconds(2), Duration.ofSeconds(30),
+ 1024, 512, Duration.ofMillis(200), 2, 2, Duration.ofSeconds(1), Duration.ofMinutes(5), 60_000, "v1",
+ 1);
+ FaceRecognitionService limitedService = new FaceRecognitionService(
+ templateRepository, sessionStore, aiFaceClient, studentRepository,
+ attendanceService, logService, adminVerifier, limited,
+ tools.jackson.databind.json.JsonMapper.builder().build(), clock);
+ FaceTemplate first = eligibleTemplate(1L);
+ FaceTemplate second = eligibleTemplate(2L);
+ given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(first, second));
+
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> limitedService.create(ADMIN_ID,
+ com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY))
+ .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class,
+ error -> assertThat(error.getErrorCode()).isEqualTo(
+ com.checkup.checkup.global.exception.ErrorCode.FACE_TOO_MANY_CANDIDATES));
+
+ verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any());
+ verify(aiFaceClient, never()).createSession(any(), any());
+ }
+
+ @Test
+ @DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다")
+ void defaultMaximumIs200() {
+ assertThat(properties.maxCandidates()).isEqualTo(200);
+ }
+
+ private FaceTemplate eligibleTemplate(Long studentId) {
+ Student student = mock(Student.class);
+ given(student.getId()).willReturn(studentId);
+ given(student.getDatagsmStudentId()).willReturn(900L + studentId);
+ given(student.getDormitoryRoom()).willReturn(301);
+ given(student.isAttendanceEligible()).willReturn(true);
+ given(student.hasRequiredConsent()).willReturn(true);
+ FaceTemplate template = mock(FaceTemplate.class);
+ given(template.getStudent()).willReturn(student);
+ return template;
+ }
+
private static String toVectorsJson() throws Exception {
List vector = new java.util.ArrayList<>(java.util.Collections.nCopies(256, 0.0));
vector.set(0, 1.0);
@@ -264,7 +316,7 @@ void candidateWithoutRoomClosesSession() {
void recordedFaceIsLoggedAsSuccess() {
FaceSessionView session = givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
givenEligibleStudent();
- given(attendanceService.markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
.willReturn(AttendanceRecordResult.RECORDED);
service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2});
@@ -277,7 +329,7 @@ void recordedFaceIsLoggedAsSuccess() {
void staleFaceIsLoggedAsFailed() {
FaceSessionView session = givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
givenEligibleStudent();
- given(attendanceService.markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
.willReturn(AttendanceRecordResult.STALE);
service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2});
@@ -290,7 +342,7 @@ void staleFaceIsLoggedAsFailed() {
void duplicateFaceIsNotLogged() {
givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
givenEligibleStudent();
- given(attendanceService.markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
.willReturn(AttendanceRecordResult.ALREADY_ATTENDED);
service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2});
@@ -313,12 +365,44 @@ void unknownFaceIsLoggedOnlyWhenQrRecommended() {
verify(logService).record(session, "track-1", FaceRecognitionResult.FAILED, null, NOW);
}
+ @Test
+ @DisplayName("프레임 하나에 세션·후보는 한 번만 읽고, AI 응답 뒤에는 세션이 열려 있는지만 확인한다")
+ void readsSessionOncePerFrame() {
+ givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
+ givenEligibleStudent();
+ given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ .willReturn(AttendanceRecordResult.RECORDED);
+
+ service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2});
+
+ verify(sessionStore, times(1)).findOwned(SESSION_ID, ADMIN_ID);
+ verify(sessionStore, times(1)).isOwnedActive(SESSION_ID, ADMIN_ID);
+ verify(studentRepository, never()).findById(any());
+ }
+
+ @Test
+ @DisplayName("AI를 기다리는 동안 세션이 닫히면 출석을 기록하지 않고 404 FACE_SESSION_NOT_FOUND다")
+ void sessionClosedDuringAiCallIsNotRecorded() {
+ givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
+ givenEligibleStudent();
+ given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(false);
+
+ org.assertj.core.api.Assertions.assertThatThrownBy(() ->
+ service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2}))
+ .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class,
+ error -> assertThat(error.getErrorCode()).isEqualTo(
+ com.checkup.checkup.global.exception.ErrorCode.FACE_SESSION_NOT_FOUND));
+
+ verify(attendanceService, never()).markAttendedFor(any(), any(), any(), any());
+ verify(aiFaceClient).deleteSession(SESSION_ID);
+ }
+
@Test
@DisplayName("최근 인식 기록에 실패해도 인식·출석 응답은 그대로 돌려준다")
void logFailureDoesNotBreakRecognition() {
givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString()));
givenEligibleStudent();
- given(attendanceService.markAttended(STUDENT_DB_ID, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
+ given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE))
.willReturn(AttendanceRecordResult.RECORDED);
willThrow(new IllegalStateException("db down")).given(logService).record(any(), any(), any(), any(), any());
@@ -339,6 +423,7 @@ private FaceSessionView givenFrame(AiFaceFrameResponse frame) {
private void givenEligibleStudent() {
Student student = mock(Student.class);
+ eligibleStudent = student;
given(student.getId()).willReturn(STUDENT_DB_ID);
given(student.isAttendanceEligible()).willReturn(true);
given(student.getName()).willReturn("Student Name");
diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java
new file mode 100644
index 00000000..7843080f
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java
@@ -0,0 +1,108 @@
+package com.checkup.checkup.domain.face.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.checkup.checkup.domain.attendance.entity.AttendancePurpose;
+import com.checkup.checkup.global.exception.CustomException;
+import com.checkup.checkup.global.exception.ErrorCode;
+
+/**
+ * 실제 PostgreSQL에서 얼굴 인식 세션 저장이 후보 학생을 한 번에 저장하고,
+ * 저장되지 않은 학생이 섞이면 세션째 취소되는지 검증한다(#216).
+ */
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Transactional(propagation = Propagation.NOT_SUPPORTED)
+@Import(FaceSessionStore.class)
+class FaceSessionStoreTest {
+
+ @Autowired
+ private FaceSessionStore sessionStore;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private Long adminMemberId;
+ private final List memberIds = new ArrayList<>();
+ private final List studentIds = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ long base = ThreadLocalRandom.current().nextLong(1_000_000_000L, Long.MAX_VALUE / 2);
+ adminMemberId = jdbcTemplate.queryForObject(
+ "INSERT INTO member (datagsm_id, name, role) VALUES (?, '관리자', 'ADMIN') RETURNING id",
+ Long.class, base);
+ for (int i = 0; i < 3; i++) {
+ Long memberId = jdbcTemplate.queryForObject(
+ "INSERT INTO member (datagsm_id, name, role) VALUES (?, ?, 'STUDENT') RETURNING id",
+ Long.class, base + 1 + i, "학생" + i);
+ Long studentId = jdbcTemplate.queryForObject(
+ "INSERT INTO student (member_id, name, number, grade, class_number, student_number, "
+ + "dormitory_room, privacy_agreed_at, face_agreed_at) "
+ + "VALUES (?, ?, 1, 1, 1, ?, 301, now(), now()) RETURNING id",
+ Long.class, memberId, "학생" + i, 1100 + i);
+ memberIds.add(memberId);
+ studentIds.add(studentId);
+ }
+ }
+
+ @AfterEach
+ void tearDown() {
+ jdbcTemplate.update("DELETE FROM face_recognition_session WHERE admin_member_id = ?", adminMemberId);
+ studentIds.forEach(id -> jdbcTemplate.update("DELETE FROM student WHERE id = ?", id));
+ memberIds.forEach(id -> jdbcTemplate.update("DELETE FROM member WHERE id = ?", id));
+ jdbcTemplate.update("DELETE FROM member WHERE id = ?", adminMemberId);
+ }
+
+ @Test
+ @DisplayName("세션을 저장하면 후보 학생 모두가 한 번에 저장되고 같은 세션으로 조회된다")
+ void savesAllCandidates() {
+ UUID sessionId = UUID.randomUUID();
+
+ sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY, studentIds,
+ Instant.now(), Instant.now());
+
+ assertThat(sessionStore.findOwned(sessionId, adminMemberId).candidateStudentIds())
+ .containsExactlyInAnyOrderElementsOf(studentIds);
+ assertThat(sessionStore.isOwnedActive(sessionId, adminMemberId)).isTrue();
+ }
+
+ @Test
+ @DisplayName("저장되지 않은 학생이 섞이면 FACE_NO_ENROLLED_STUDENTS이고 세션과 후보를 하나도 남기지 않는다")
+ void unknownStudentCancelsWholeSession() {
+ UUID sessionId = UUID.randomUUID();
+ List withMissing = new ArrayList<>(studentIds);
+ withMissing.add(-1L);
+
+ assertThatThrownBy(() -> sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY,
+ withMissing, Instant.now(), Instant.now()))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FACE_NO_ENROLLED_STUDENTS));
+
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM face_recognition_session WHERE session_id = ?", Integer.class, sessionId))
+ .isZero();
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM face_recognition_session_candidate WHERE session_id = ?",
+ Integer.class, sessionId)).isZero();
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/domain/member/repository/StudentRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/member/repository/StudentRepositoryTest.java
index 5a94b3d1..a37a7586 100644
--- a/src/test/java/com/checkup/checkup/domain/member/repository/StudentRepositoryTest.java
+++ b/src/test/java/com/checkup/checkup/domain/member/repository/StudentRepositoryTest.java
@@ -5,6 +5,7 @@
import com.checkup.checkup.domain.member.entity.Member;
import com.checkup.checkup.domain.member.entity.MemberRole;
import com.checkup.checkup.domain.member.entity.Student;
+import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.util.List;
@@ -15,7 +16,7 @@
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
/**
- * 실제 PostgreSQL에서 학생 조회 쿼리의 필터와 회원 정보 함께 조회, 없을 때만 저장을 검증한다(#152).
+ * 실제 PostgreSQL에서 학생 조회 쿼리의 필터·회원 조인 범위(#214)와 없을 때만 저장을 검증한다(#152).
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@@ -30,18 +31,24 @@ class StudentRepositoryTest {
@Autowired
private EntityManagerFactory entityManagerFactory;
+ @Autowired
+ private EntityManager entityManager;
+
@Test
- @DisplayName("같은 호실 학생만 회원 정보와 함께 조회한다")
- void findsRoomStudentsWithMember() {
+ @DisplayName("같은 호실 학생만 조회하고 회원은 읽지 않는다")
+ void findsRoomStudentsWithoutMember() {
saveStudent("홍길동", 1102, 301);
saveStudent("김학생", 1103, 301);
saveStudent("다른 방", 1201, 401);
+ studentRepository.flush();
+ entityManager.clear();
+
List students = studentRepository.findAllByDormitoryRoom(301);
assertThat(students).extracting(Student::getName).containsExactlyInAnyOrder("김학생", "홍길동");
assertThat(students).allSatisfy(student -> assertThat(entityManagerFactory.getPersistenceUnitUtil()
- .isLoaded(student, "member")).isTrue());
+ .isLoaded(student, "member")).isFalse());
}
@Test
@@ -64,18 +71,33 @@ void findsByDatagsmStudentIdWithZeroVolunteerCount() {
}
@Test
- @DisplayName("DataGSM 학생 id가 있는 학생만 회원 정보와 함께 조회한다")
+ @DisplayName("DataGSM 학생 id가 있는 학생만 조회하고 회원은 읽지 않는다")
void findsOnlyStudentsWithDatagsmStudentId() {
saveStudent("홍길동", 1102, 301);
saveStudent("김학생", 1103, null);
Member noId = memberRepository.save(Member.create(88_888L, "아이디 없음", MemberRole.STUDENT));
studentRepository.saveAndFlush(Student.create(noId, null, "아이디 없음", 1, 1, 1, 1104, 301));
+ entityManager.clear();
+
List students = studentRepository.findAllByDatagsmStudentIdIsNotNull();
assertThat(students).extracting(Student::getStudentNumber).contains(1102, 1103).doesNotContain(1104);
assertThat(students).allSatisfy(student -> assertThat(entityManagerFactory.getPersistenceUnitUtil()
- .isLoaded(student, "member")).isTrue());
+ .isLoaded(student, "member")).isFalse());
+ }
+
+ @Test
+ @DisplayName("DataGSM 학생 id 목록 조회(동기화 경로)는 회원을 함께 읽는다")
+ void findsByDatagsmStudentIdsWithMember() {
+ saveStudent("홍길동", 1102, 301);
+ studentRepository.flush();
+ entityManager.clear();
+
+ List students = studentRepository.findAllByDatagsmStudentIdIn(List.of(1102L));
+
+ assertThat(students).hasSize(1);
+ assertThat(entityManagerFactory.getPersistenceUnitUtil().isLoaded(students.getFirst(), "member")).isTrue();
}
@Test
diff --git a/src/test/java/com/checkup/checkup/domain/member/service/MemberServiceTest.java b/src/test/java/com/checkup/checkup/domain/member/service/MemberServiceTest.java
index f80bcb55..54c649d1 100644
--- a/src/test/java/com/checkup/checkup/domain/member/service/MemberServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/member/service/MemberServiceTest.java
@@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -11,6 +12,7 @@
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.MemberRepository;
import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.checkup.checkup.global.security.AdminRoleCache;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -36,11 +38,13 @@ class MemberServiceTest {
@Mock
private StudentRepository studentRepository;
+ private final AdminRoleCache adminRoleCache = mock(AdminRoleCache.class);
+
private MemberService memberService;
@BeforeEach
void setUp() {
- memberService = new MemberService(memberRepository, studentRepository);
+ memberService = new MemberService(memberRepository, studentRepository, adminRoleCache);
}
@Test
@@ -56,6 +60,7 @@ void insertsMissingStudentAndLinksMember() {
Member member = memberService.saveOrUpdate(studentUser(301), MemberRole.STUDENT);
assertThat(inserted.getMember()).isSameAs(member);
+ verify(adminRoleCache).evictAfterCommit(member.getId());
assertThat(inserted.getDormitoryRoom()).isEqualTo(301);
assertThat(inserted.getDormitoryFloor()).isEqualTo(3);
verify(studentRepository, never()).save(any(Student.class));
diff --git a/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheIntegrationTest.java b/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheIntegrationTest.java
new file mode 100644
index 00000000..a405a4c7
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheIntegrationTest.java
@@ -0,0 +1,104 @@
+package com.checkup.checkup.domain.member.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 학생 본인 출석 조회와 미확인 알림 조회가 같은 회원의 두 번째 요청부터 실제 PostgreSQL 쿼리를 한 번만 보내는지 검증한다.
+ */
+@SpringBootTest(properties = "checkup.query-log.enabled=true")
+@AutoConfigureMockMvc
+@Transactional
+@ExtendWith(OutputCaptureExtension.class)
+class StudentIdCacheIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private Long studentMemberId;
+ private Long teacherMemberId;
+
+ @BeforeEach
+ void setUp() {
+ studentMemberId = insertMember("학생", "STUDENT");
+ jdbcTemplate.update("""
+ INSERT INTO student
+ (member_id, datagsm_student_id, name, number, grade, class_number, student_number, dormitory_room)
+ VALUES (?, ?, '학생', 1, 1, 1, 1101, 301)
+ """, studentMemberId, nextDataGsmId());
+ teacherMemberId = insertMember("교사", "TEACHER");
+ }
+
+ @Test
+ @DisplayName("본인 출석 조회는 처음에 쿼리 2번, 다음부터 1번이다")
+ void myAttendanceRunsOneQueryAfterFirstRequest(CapturedOutput output) throws Exception {
+ mockMvc.perform(get("/api/v1/attendance/me").with(loginAs(studentMemberId)))
+ .andExpect(status().isOk());
+ assertThat(output).contains("GET /api/v1/attendance/me status=200 queries=2 elapsedMs=");
+
+ mockMvc.perform(get("/api/v1/attendance/me").with(loginAs(studentMemberId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.dormitory.attended").value(false));
+ assertThat(output).contains("GET /api/v1/attendance/me status=200 queries=1 elapsedMs=");
+ }
+
+ @Test
+ @DisplayName("기억한 학생 id는 다른 API에서도 써서 미확인 알림 조회가 쿼리 1번이다")
+ void unreadReusesStudentIdFoundByAnotherApi(CapturedOutput output) throws Exception {
+ mockMvc.perform(get("/api/v1/attendance/me").with(loginAs(studentMemberId)))
+ .andExpect(status().isOk());
+
+ mockMvc.perform(get("/api/v1/notifications/unread").with(loginAs(studentMemberId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.hasUnread").value(false));
+ assertThat(output).contains("GET /api/v1/notifications/unread status=200 queries=1 elapsedMs=");
+ }
+
+ @Test
+ @DisplayName("학생이 아닌 회원은 다시 요청해도 403 MISSING_STUDENT_INFO다")
+ void nonStudentStaysRejected() throws Exception {
+ for (int i = 0; i < 2; i++) {
+ mockMvc.perform(get("/api/v1/attendance/me").with(loginAs(teacherMemberId)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("MISSING_STUDENT_INFO"));
+ }
+ }
+
+ private Long insertMember(String name, String role) {
+ return jdbcTemplate.queryForObject(
+ "INSERT INTO member (datagsm_id, name, role) VALUES (?, ?, ?) RETURNING id",
+ Long.class, nextDataGsmId(), name, role);
+ }
+
+ private static Long nextDataGsmId() {
+ return ThreadLocalRandom.current().nextLong(1_000_000_000L, Long.MAX_VALUE);
+ }
+
+ private static RequestPostProcessor loginAs(Long memberId) {
+ return authentication(UsernamePasswordAuthenticationToken.authenticated(memberId, null, List.of()));
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheTest.java b/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheTest.java
new file mode 100644
index 00000000..daadaa9e
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/domain/member/service/StudentIdCacheTest.java
@@ -0,0 +1,95 @@
+package com.checkup.checkup.domain.member.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import com.checkup.checkup.domain.member.repository.StudentRepository;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+/**
+ * StudentIdCache가 찾은 학생 id만 10분 동안 기억하고, 학생이 없는 회원은 기억하지 않는지 검증한다.
+ */
+class StudentIdCacheTest {
+
+ private static final Long MEMBER_ID = 1L;
+ private static final Long STUDENT_ID = 10L;
+
+ private final StudentRepository studentRepository = mock(StudentRepository.class);
+ private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
+ private final AtomicLong nanos = new AtomicLong();
+ private final StudentIdCache cache = new StudentIdCache(studentRepository, nanos::get);
+
+ @Test
+ @DisplayName("같은 회원을 다시 찾으면 DB를 조회하지 않는다")
+ void secondLookupSkipsDatabase() {
+ given(studentRepository.findIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
+
+ assertThat(cache.findStudentId(MEMBER_ID)).contains(STUDENT_ID);
+ assertThat(cache.findStudentId(MEMBER_ID)).contains(STUDENT_ID);
+
+ verify(studentRepository, times(1)).findIdByMemberId(MEMBER_ID);
+ }
+
+ @Test
+ @DisplayName("학생이 없는 회원은 기억하지 않아 나중에 연결되면 바로 찾는다")
+ void missingStudentIsNotRemembered() {
+ given(studentRepository.findIdByMemberId(MEMBER_ID))
+ .willReturn(Optional.empty(), Optional.of(STUDENT_ID));
+
+ assertThat(cache.findStudentId(MEMBER_ID)).isEmpty();
+ assertThat(cache.findStudentId(MEMBER_ID)).contains(STUDENT_ID);
+ }
+
+ @Test
+ @DisplayName("10분이 지나면 DB에서 다시 읽는다")
+ void expiresAfterTenMinutes() {
+ given(studentRepository.findIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
+ cache.findStudentId(MEMBER_ID);
+
+ nanos.addAndGet(Duration.ofMinutes(9).toNanos());
+ cache.findStudentId(MEMBER_ID);
+ verify(studentRepository, times(1)).findIdByMemberId(MEMBER_ID);
+
+ nanos.addAndGet(Duration.ofMinutes(2).toNanos());
+ cache.findStudentId(MEMBER_ID);
+ verify(studentRepository, times(2)).findIdByMemberId(MEMBER_ID);
+ }
+
+ @Test
+ @DisplayName("회원마다 따로 기억한다")
+ void remembersPerMember() {
+ given(studentRepository.findIdByMemberId(1L)).willReturn(Optional.of(10L));
+ given(studentRepository.findIdByMemberId(2L)).willReturn(Optional.of(20L));
+
+ assertThat(cache.findStudentId(1L)).contains(10L);
+ assertThat(cache.findStudentId(2L)).contains(20L);
+ assertThat(cache.findStudentId(1L)).contains(10L);
+ }
+
+ @Test
+ @DisplayName("적중과 실패 횟수를 지표로 내보낸다")
+ void exportsHitAndMissMetrics() {
+ given(studentRepository.findIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
+ cache.bindTo(meterRegistry);
+
+ cache.findStudentId(MEMBER_ID);
+ cache.findStudentId(MEMBER_ID);
+ cache.findStudentId(MEMBER_ID);
+
+ assertThat(meterRegistry.get("cache.gets").tag("cache", "studentIdByMember").tag("result", "hit")
+ .functionCounter().count()).isEqualTo(2);
+ assertThat(meterRegistry.get("cache.gets").tag("cache", "studentIdByMember").tag("result", "miss")
+ .functionCounter().count()).isEqualTo(1);
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/checkup/checkup/domain/notification/service/NotificationServiceTest.java
index 658a812d..3176e034 100644
--- a/src/test/java/com/checkup/checkup/domain/notification/service/NotificationServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/notification/service/NotificationServiceTest.java
@@ -9,10 +9,7 @@
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
-import com.checkup.checkup.domain.member.entity.Member;
-import com.checkup.checkup.domain.member.entity.MemberRole;
-import com.checkup.checkup.domain.member.entity.Student;
-import com.checkup.checkup.domain.member.repository.StudentRepository;
+import com.checkup.checkup.domain.member.service.StudentIdCache;
import com.checkup.checkup.domain.notification.dto.response.NotificationListResponse;
import com.checkup.checkup.domain.notification.dto.response.NotificationResponse;
import com.checkup.checkup.domain.notification.entity.Notification;
@@ -41,10 +38,10 @@ class NotificationServiceTest {
private static final Instant NOW = Instant.parse("2026-09-30T03:00:00Z");
private final NotificationRepository notificationRepository = mock(NotificationRepository.class);
- private final StudentRepository studentRepository = mock(StudentRepository.class);
+ private final StudentIdCache studentIdCache = mock(StudentIdCache.class);
private final MutableClock clock = new MutableClock(NOW);
private final NotificationService service = new NotificationService(
- notificationRepository, studentRepository, clock, new OperatingDayCalculator(clock));
+ notificationRepository, studentIdCache, clock, new OperatingDayCalculator(clock));
@Test
@DisplayName("본인 학생 id로 최근 알림과 미확인 여부를 조회해 응답으로 바꾼다")
@@ -86,7 +83,7 @@ void readAllUsesOwnStudentIdAndNow() {
@Test
@DisplayName("학생 정보가 없는 회원은 403 MISSING_STUDENT_INFO이고 알림을 조회·변경하지 않는다")
void nonStudentIsRejected() {
- given(studentRepository.findByMemberId(MEMBER_ID)).willReturn(Optional.empty());
+ given(studentIdCache.findStudentId(MEMBER_ID)).willReturn(Optional.empty());
assertThatThrownBy(() -> service.getNotifications(MEMBER_ID))
.isInstanceOfSatisfying(CustomException.class,
@@ -129,9 +126,6 @@ void deleteBeforeEightUsesPreviousBoundary() {
}
private void givenStudent() {
- Student student = Student.create(
- Member.create(100L, "학생", MemberRole.STUDENT), 200L, "학생", 2, 1, 5, 2105, 301);
- ReflectionTestUtils.setField(student, "id", STUDENT_ID);
- given(studentRepository.findByMemberId(MEMBER_ID)).willReturn(Optional.of(student));
+ given(studentIdCache.findStudentId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
}
}
diff --git a/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java
index 451eda98..ef7532cd 100644
--- a/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java
+++ b/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java
@@ -17,9 +17,10 @@
import com.checkup.checkup.domain.attendance.entity.AttendancePurpose;
import com.checkup.checkup.domain.qr.entity.QrSession;
+import com.checkup.checkup.domain.qr.entity.QrToken;
/**
- * 실제 Redis에서 QR 세션 갱신이 종료된 세션을 되살리지 않는지 검증한다.
+ * 실제 Redis에서 QR 세션 갱신이 종료된 세션을 되살리지 않는지, 토큰·세션 한 번 조회가 따로 읽은 값과 같은지 검증한다(#211).
*/
@DataRedisTest
@Import(QrSessionRepository.class)
@@ -64,6 +65,43 @@ void existingSessionIsUpdated() {
assertThat(qrSessionRepository.findSessionIdsByAdmin(1L)).containsExactly("session-1");
}
+ @Test
+ @DisplayName("토큰과 세션을 한 번에 읽으면 따로 읽은 값과 같다")
+ void findsTokenWithSessionInOneCall() {
+ qrSessionRepository.save(session, TTL);
+ QrToken token = new QrToken("token-1", "session-1", Instant.parse("2026-09-27T03:15:00Z"));
+ qrSessionRepository.saveToken(token, TTL);
+
+ QrSessionRepository.TokenLookup lookup = qrSessionRepository.findTokenWithSession("token-1");
+
+ assertThat(lookup.token()).contains(token).isEqualTo(qrSessionRepository.findToken("token-1"));
+ assertThat(lookup.session()).contains(session).isEqualTo(qrSessionRepository.findById("session-1"));
+ }
+
+ @Test
+ @DisplayName("세션이 종료됐으면 토큰만 있고 세션은 비어 있다")
+ void tokenWithoutSessionHasEmptySession() {
+ qrSessionRepository.save(session, TTL);
+ qrSessionRepository.saveToken(new QrToken("token-1", "session-1", Instant.parse("2026-09-27T03:15:00Z")), TTL);
+ qrSessionRepository.delete(session);
+
+ QrSessionRepository.TokenLookup lookup = qrSessionRepository.findTokenWithSession("token-1");
+
+ assertThat(lookup.token()).isPresent();
+ assertThat(lookup.session()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("발급하지 않은 토큰은 토큰도 세션도 비어 있다")
+ void unknownTokenHasNothing() {
+ qrSessionRepository.save(session, TTL);
+
+ QrSessionRepository.TokenLookup lookup = qrSessionRepository.findTokenWithSession("unknown");
+
+ assertThat(lookup.token()).isEmpty();
+ assertThat(lookup.session()).isEmpty();
+ }
+
@Test
@DisplayName("heartbeat가 읽은 뒤 close되면 세션을 되살리지 않는다")
void closeAfterHeartbeatReadDoesNotRevive() {
diff --git a/src/test/java/com/checkup/checkup/domain/qr/service/QrScanServiceTest.java b/src/test/java/com/checkup/checkup/domain/qr/service/QrScanServiceTest.java
index 83f75662..d1d095c1 100644
--- a/src/test/java/com/checkup/checkup/domain/qr/service/QrScanServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/qr/service/QrScanServiceTest.java
@@ -59,9 +59,11 @@ class QrScanServiceTest {
clock
);
+ private Student student;
+
@BeforeEach
void setUp() {
- Student student = mock(Student.class);
+ student = mock(Student.class);
given(student.getId()).willReturn(STUDENT_ID);
given(student.isAttendanceEligible()).willReturn(true);
given(studentRepository.findByMemberId(MEMBER_ID)).willReturn(Optional.of(student));
@@ -129,13 +131,13 @@ void malformedTokenIsInvalidWithoutLookup() {
assertThat(qrScanService.scan(MEMBER_ID, "not-a-token")).isEqualTo(QrScanResult.INVALID);
assertThat(qrScanService.scan(MEMBER_ID, "a".repeat(44))).isEqualTo(QrScanResult.INVALID);
- verify(qrSessionRepository, never()).findToken(anyString());
+ verify(qrSessionRepository, never()).findTokenWithSession(anyString());
}
@Test
@DisplayName("발급하지 않은 토큰은 INVALID다")
void unknownTokenIsInvalid() {
- given(qrSessionRepository.findToken(TOKEN)).willReturn(Optional.empty());
+ given(qrSessionRepository.findTokenWithSession(TOKEN)).willReturn(lookup(Optional.empty(), Optional.empty()));
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.INVALID);
verifyNoInteractions(attendanceService);
@@ -144,7 +146,8 @@ void unknownTokenIsInvalid() {
@Test
@DisplayName("만료 시각이 지난 토큰은 EXPIRED다")
void expiredTokenIsExpired() {
- given(qrSessionRepository.findToken(TOKEN)).willReturn(Optional.of(token(clock.instant())));
+ given(qrSessionRepository.findTokenWithSession(TOKEN))
+ .willReturn(lookup(Optional.of(token(clock.instant())), Optional.empty()));
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.EXPIRED);
verifyNoInteractions(attendanceService);
@@ -153,8 +156,8 @@ void expiredTokenIsExpired() {
@Test
@DisplayName("세션이 종료됐으면 CLOSED다")
void closedSessionIsClosed() {
- given(qrSessionRepository.findToken(TOKEN)).willReturn(Optional.of(token(clock.instant().plusSeconds(600))));
- given(qrSessionRepository.findById(SESSION_ID)).willReturn(Optional.empty());
+ given(qrSessionRepository.findTokenWithSession(TOKEN))
+ .willReturn(lookup(Optional.of(token(clock.instant().plusSeconds(600))), Optional.empty()));
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.CLOSED);
verifyNoInteractions(attendanceService);
@@ -163,9 +166,9 @@ void closedSessionIsClosed() {
@Test
@DisplayName("세션의 lease가 끝났으면 CLOSED다")
void expiredLeaseIsClosed() {
- given(qrSessionRepository.findToken(TOKEN)).willReturn(Optional.of(token(clock.instant().plusSeconds(600))));
- given(qrSessionRepository.findById(SESSION_ID))
- .willReturn(Optional.of(session(AttendancePurpose.DORMITORY, clock.instant())));
+ given(qrSessionRepository.findTokenWithSession(TOKEN)).willReturn(lookup(
+ Optional.of(token(clock.instant().plusSeconds(600))),
+ Optional.of(session(AttendancePurpose.DORMITORY, clock.instant()))));
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.CLOSED);
verifyNoInteractions(attendanceService);
@@ -175,20 +178,20 @@ void expiredLeaseIsClosed() {
@DisplayName("처음 출석하면 세션 용도와 현재 운영일로 기록하고 APPROVED다")
void firstAttendanceIsApproved() {
activeToken(AttendancePurpose.STUDY_ROOM);
- given(attendanceService.markAttended(any(), any(), any(), any())).willReturn(AttendanceRecordResult.RECORDED);
+ given(attendanceService.markAttendedFor(any(), any(), any(), any())).willReturn(AttendanceRecordResult.RECORDED);
QrScanResult result = qrScanService.scan(MEMBER_ID, TOKEN);
assertThat(result).isEqualTo(QrScanResult.APPROVED);
- verify(attendanceService).markAttended(
- STUDENT_ID, AttendancePurpose.STUDY_ROOM, clock.instant(), AttendanceMethod.QR);
+ verify(attendanceService).markAttendedFor(
+ student, AttendancePurpose.STUDY_ROOM, clock.instant(), AttendanceMethod.QR);
}
@Test
@DisplayName("이미 출석했으면 DUPLICATE다")
void alreadyAttendedIsDuplicate() {
activeToken(AttendancePurpose.DORMITORY);
- given(attendanceService.markAttended(any(), any(), any(), any())).willReturn(AttendanceRecordResult.ALREADY_ATTENDED);
+ given(attendanceService.markAttendedFor(any(), any(), any(), any())).willReturn(AttendanceRecordResult.ALREADY_ATTENDED);
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.DUPLICATE);
}
@@ -197,7 +200,7 @@ void alreadyAttendedIsDuplicate() {
@DisplayName("수동 수정에 밀린 인증은 DUPLICATE다")
void supersededByManualIsDuplicate() {
activeToken(AttendancePurpose.DORMITORY);
- given(attendanceService.markAttended(any(), any(), any(), any()))
+ given(attendanceService.markAttendedFor(any(), any(), any(), any()))
.willReturn(AttendanceRecordResult.SUPERSEDED_BY_MANUAL);
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.DUPLICATE);
@@ -207,7 +210,7 @@ void supersededByManualIsDuplicate() {
@DisplayName("지난 운영일로 판정되면 EXPIRED다")
void staleIsExpired() {
activeToken(AttendancePurpose.DORMITORY);
- given(attendanceService.markAttended(any(), any(), any(), any())).willReturn(AttendanceRecordResult.STALE);
+ given(attendanceService.markAttendedFor(any(), any(), any(), any())).willReturn(AttendanceRecordResult.STALE);
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.EXPIRED);
}
@@ -216,15 +219,19 @@ void staleIsExpired() {
@DisplayName("미래 시각으로 판정되면 INVALID다")
void futureIsInvalid() {
activeToken(AttendancePurpose.DORMITORY);
- given(attendanceService.markAttended(any(), any(), any(), any())).willReturn(AttendanceRecordResult.FUTURE);
+ given(attendanceService.markAttendedFor(any(), any(), any(), any())).willReturn(AttendanceRecordResult.FUTURE);
assertThat(qrScanService.scan(MEMBER_ID, TOKEN)).isEqualTo(QrScanResult.INVALID);
}
private void activeToken(AttendancePurpose purpose) {
- given(qrSessionRepository.findToken(TOKEN)).willReturn(Optional.of(token(clock.instant().plusSeconds(60))));
- given(qrSessionRepository.findById(SESSION_ID))
- .willReturn(Optional.of(session(purpose, clock.instant().plusSeconds(60))));
+ given(qrSessionRepository.findTokenWithSession(TOKEN)).willReturn(lookup(
+ Optional.of(token(clock.instant().plusSeconds(60))),
+ Optional.of(session(purpose, clock.instant().plusSeconds(60)))));
+ }
+
+ private static QrSessionRepository.TokenLookup lookup(Optional token, Optional session) {
+ return new QrSessionRepository.TokenLookup(token, session);
}
private static QrToken token(Instant expiresAt) {
diff --git a/src/test/java/com/checkup/checkup/domain/room/service/RoomServiceTest.java b/src/test/java/com/checkup/checkup/domain/room/service/RoomServiceTest.java
index 24a88825..3e90fb6e 100644
--- a/src/test/java/com/checkup/checkup/domain/room/service/RoomServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/room/service/RoomServiceTest.java
@@ -16,7 +16,6 @@
import com.checkup.checkup.domain.member.entity.MemberRole;
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
-import com.checkup.checkup.domain.member.service.MemberService;
import com.checkup.checkup.domain.room.dto.request.RoomAttendanceRequest;
import com.checkup.checkup.domain.room.dto.response.RoomFloorResponse;
import com.checkup.checkup.domain.room.dto.response.RoomStudentResponse;
@@ -50,9 +49,6 @@ class RoomServiceTest {
private static final LocalDate TODAY = LocalDate.of(2026, 10, 4);
private static final AttendancePurpose DORMITORY = AttendancePurpose.DORMITORY;
- @Mock
- private MemberService memberService;
-
@Mock
private StudentRepository studentRepository;
@@ -73,16 +69,15 @@ class RoomServiceTest {
@BeforeEach
void setUp() {
roomService = new RoomService(
- memberService, studentRepository, attendanceRepository, operatingDayCalculator, adminVerifier,
+ studentRepository, attendanceRepository, operatingDayCalculator, adminVerifier,
attendanceService);
}
@Test
@DisplayName("학생 정보가 없는 교사 관리자도 호실 명단을 조회할 수 있다")
void teacherAdminCanReadAnyRoom() {
- Member admin = Member.create(10L, "사감", MemberRole.ADMIN);
Student rosterStudent = student(20L, "학생", 1101, ROOM);
- given(memberService.getById(ADMIN_ID)).willReturn(admin);
+ given(adminVerifier.isAdmin(ADMIN_ID)).willReturn(true);
given(studentRepository.findAllByDormitoryRoom(ROOM))
.willReturn(List.of(rosterStudent));
@@ -91,17 +86,16 @@ void teacherAdminCanReadAnyRoom() {
List response = roomService.getStudents(ADMIN_ID, ROOM, DORMITORY);
assertThat(response).containsExactly(new RoomStudentResponse(20L, "학생", 1, 1, 1101, false));
- verify(studentRepository, never()).findByMember(admin);
+ verify(studentRepository, never()).findByMemberId(ADMIN_ID);
}
@Test
@DisplayName("학생은 본인에게 배정된 호실의 명단을 조회할 수 있다")
void studentCanReadOwnRoom() {
- Member currentMember = Member.create(20L, "학생", MemberRole.STUDENT);
Student currentStudent = student(20L, "학생", 1101, ROOM);
Student roommate = student(21L, "룸메이트", 1102, ROOM);
- given(memberService.getById(STUDENT_ID)).willReturn(currentMember);
- given(studentRepository.findByMember(currentMember)).willReturn(Optional.of(currentStudent));
+ given(adminVerifier.isAdmin(STUDENT_ID)).willReturn(false);
+ given(studentRepository.findByMemberId(STUDENT_ID)).willReturn(Optional.of(currentStudent));
given(studentRepository.findAllByDormitoryRoom(ROOM))
.willReturn(List.of(roommate, currentStudent));
@@ -115,9 +109,8 @@ void studentCanReadOwnRoom() {
@Test
@DisplayName("학생의 다른 호실 접근은 명단 조회 전에 거부한다")
void studentIsRejectedForOtherRoom() {
- Member currentMember = Member.create(20L, "학생", MemberRole.STUDENT);
- given(memberService.getById(STUDENT_ID)).willReturn(currentMember);
- given(studentRepository.findByMember(currentMember))
+ given(adminVerifier.isAdmin(STUDENT_ID)).willReturn(false);
+ given(studentRepository.findByMemberId(STUDENT_ID))
.willReturn(Optional.of(student(20L, "학생", 1101, ROOM)));
assertThatThrownBy(() -> roomService.getStudents(STUDENT_ID, 401, DORMITORY))
@@ -131,9 +124,8 @@ void studentIsRejectedForOtherRoom() {
@Test
@DisplayName("학생 정보가 없으면 403을 반환한다")
void missingStudentReturnsForbidden() {
- Member currentMember = Member.create(20L, "학생", MemberRole.STUDENT);
- given(memberService.getById(STUDENT_ID)).willReturn(currentMember);
- given(studentRepository.findByMember(currentMember)).willReturn(Optional.empty());
+ given(adminVerifier.isAdmin(STUDENT_ID)).willReturn(false);
+ given(studentRepository.findByMemberId(STUDENT_ID)).willReturn(Optional.empty());
assertThatThrownBy(() -> roomService.getStudents(STUDENT_ID, ROOM, DORMITORY))
.isInstanceOfSatisfying(CustomException.class,
@@ -146,9 +138,8 @@ void missingStudentReturnsForbidden() {
@Test
@DisplayName("호실이 미배정된 학생에게 403을 반환한다")
void studentWithoutRoomReturnsForbidden() {
- Member currentMember = Member.create(20L, "학생", MemberRole.STUDENT);
- given(memberService.getById(STUDENT_ID)).willReturn(currentMember);
- given(studentRepository.findByMember(currentMember))
+ given(adminVerifier.isAdmin(STUDENT_ID)).willReturn(false);
+ given(studentRepository.findByMemberId(STUDENT_ID))
.willReturn(Optional.of(student(20L, "학생", 1101, null)));
assertThatThrownBy(() -> roomService.getStudents(STUDENT_ID, ROOM, DORMITORY))
@@ -159,7 +150,7 @@ void studentWithoutRoomReturnsForbidden() {
@Test
@DisplayName("관리자가 학생이 없는 호실을 조회하면 빈 목록을 반환하고 출석을 조회하지 않는다")
void emptyRoomReturnsEmptyListForAdmin() {
- given(memberService.getById(ADMIN_ID)).willReturn(Member.create(10L, "사감", MemberRole.ADMIN));
+ given(adminVerifier.isAdmin(ADMIN_ID)).willReturn(true);
given(studentRepository.findAllByDormitoryRoom(ROOM))
.willReturn(List.of());
@@ -170,7 +161,7 @@ void emptyRoomReturnsEmptyListForAdmin() {
@Test
@DisplayName("삭제된 회원의 요청은 401을 반환한다")
void deletedMemberReturnsUnauthorized() {
- given(memberService.getById(STUDENT_ID))
+ given(adminVerifier.isAdmin(STUDENT_ID))
.willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND));
assertThatThrownBy(() -> roomService.getStudents(STUDENT_ID, ROOM, DORMITORY))
@@ -184,10 +175,9 @@ void deletedMemberReturnsUnauthorized() {
@Test
@DisplayName("요청한 용도의 오늘 운영일에 출석한 학생만 attended가 true다")
void marksStudentsAttendedForPurposeToday() {
- Member admin = Member.create(10L, "사감", MemberRole.ADMIN);
Student attendedStudent = withId(student(20L, "출석", 1101, ROOM), 100L);
Student absentStudent = withId(student(21L, "미출석", 1102, ROOM), 101L);
- given(memberService.getById(ADMIN_ID)).willReturn(admin);
+ given(adminVerifier.isAdmin(ADMIN_ID)).willReturn(true);
given(studentRepository.findAllByDormitoryRoom(ROOM))
.willReturn(List.of(absentStudent, attendedStudent));
given(operatingDayCalculator.today()).willReturn(TODAY);
@@ -326,7 +316,7 @@ private static Student withId(Student student, long id) {
@Test
@DisplayName("호실 명단은 DB가 돌려준 순서와 상관없이 이름 가나다순, 같은 이름은 학번순이다")
void studentsAreSortedInKoreanOrder() {
- given(memberService.getById(ADMIN_ID)).willReturn(Member.create(10L, "사감", MemberRole.ADMIN));
+ given(adminVerifier.isAdmin(ADMIN_ID)).willReturn(true);
given(studentRepository.findAllByDormitoryRoom(ROOM)).willReturn(List.of(
student(31L, "홍길동", 1102, ROOM),
student(32L, "계정", 1103, ROOM),
diff --git a/src/test/java/com/checkup/checkup/domain/user/service/UserAccessVerifierTest.java b/src/test/java/com/checkup/checkup/domain/user/service/UserAccessVerifierTest.java
index 22be30ba..d8cb5f1c 100644
--- a/src/test/java/com/checkup/checkup/domain/user/service/UserAccessVerifierTest.java
+++ b/src/test/java/com/checkup/checkup/domain/user/service/UserAccessVerifierTest.java
@@ -12,27 +12,29 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
-import com.checkup.checkup.domain.member.entity.Member;
-import com.checkup.checkup.domain.member.entity.MemberRole;
-import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
+import com.checkup.checkup.global.security.AdminVerifier;
/**
* 학생 id 기준 조회의 접근 권한(관리자·본인)을 검증한다.
*/
class UserAccessVerifierTest {
+ private static final Long MEMBER_ID = 2L;
private static final Long STUDENT_ID = 100L;
+ private final AdminVerifier adminVerifier = mock(AdminVerifier.class);
private final StudentRepository studentRepository = mock(StudentRepository.class);
- private final UserAccessVerifier verifier = new UserAccessVerifier(studentRepository);
+ private final UserAccessVerifier verifier = new UserAccessVerifier(adminVerifier, studentRepository);
@Test
@DisplayName("관리자는 학생 저장소를 보지 않고 통과한다")
void adminPasses() {
- verifier.verify(Member.create(1L, "관리자", MemberRole.ADMIN), STUDENT_ID);
+ given(adminVerifier.isAdmin(1L)).willReturn(true);
+
+ verifier.verify(1L, STUDENT_ID);
verifyNoInteractions(studentRepository);
}
@@ -40,38 +42,41 @@ void adminPasses() {
@Test
@DisplayName("본인 학생 id면 통과한다")
void selfPasses() {
- Member member = Member.create(2L, "학생", MemberRole.STUDENT);
- givenStudentOf(member, STUDENT_ID);
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
- assertThatCode(() -> verifier.verify(member, STUDENT_ID)).doesNotThrowAnyException();
+ assertThatCode(() -> verifier.verify(MEMBER_ID, STUDENT_ID)).doesNotThrowAnyException();
}
@Test
@DisplayName("다른 학생 id면 FORBIDDEN이다")
void otherStudentIsForbidden() {
- Member member = Member.create(2L, "학생", MemberRole.STUDENT);
- givenStudentOf(member, 999L);
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(999L));
- assertForbidden(member);
+ assertForbidden();
}
@Test
@DisplayName("학생 정보가 없는 회원은 FORBIDDEN이다")
void memberWithoutStudentIsForbidden() {
- Member member = Member.create(2L, "학생", MemberRole.STUDENT);
- given(studentRepository.findByMember(member)).willReturn(Optional.empty());
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.empty());
- assertForbidden(member);
+ assertForbidden();
}
- private void assertForbidden(Member member) {
- assertThatThrownBy(() -> verifier.verify(member, STUDENT_ID))
+ @Test
+ @DisplayName("없는 회원은 401이고 학생 저장소를 보지 않는다")
+ void unknownMemberIsUnauthorized() {
+ given(adminVerifier.isAdmin(MEMBER_ID)).willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND));
+
+ assertThatThrownBy(() -> verifier.verify(MEMBER_ID, STUDENT_ID))
.isInstanceOfSatisfying(CustomException.class,
- e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.MEMBER_NOT_FOUND));
+ verifyNoInteractions(studentRepository);
}
- private void givenStudentOf(Member member, Long datagsmStudentId) {
- Student student = Student.create(member, datagsmStudentId, "홍길동", 2, 3, 4, 2304, 301);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(student));
+ private void assertForbidden() {
+ assertThatThrownBy(() -> verifier.verify(MEMBER_ID, STUDENT_ID))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
}
}
diff --git a/src/test/java/com/checkup/checkup/domain/user/service/UserServiceTest.java b/src/test/java/com/checkup/checkup/domain/user/service/UserServiceTest.java
index 96eea8bf..349f1f41 100644
--- a/src/test/java/com/checkup/checkup/domain/user/service/UserServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/user/service/UserServiceTest.java
@@ -31,6 +31,8 @@
import team.themoment.datagsm.sdk.openapi.exception.DataGsmException;
import team.themoment.datagsm.sdk.openapi.exception.ServerErrorException;
+import com.checkup.checkup.global.security.AdminRoleCache;
+import com.checkup.checkup.global.security.AdminVerifier;
import com.checkup.checkup.support.MutableClock;
import java.net.SocketTimeoutException;
import java.time.Duration;
@@ -65,7 +67,8 @@ class UserServiceTest {
@BeforeEach
void setUp() {
userSearchService = new UserSearchService(
- dataGsmOpenApiClient, memberService, new UserAccessVerifier(studentRepository),
+ dataGsmOpenApiClient,
+ new UserAccessVerifier(new AdminVerifier(memberService, new AdminRoleCache(clock)), studentRepository),
new UserSearchCache(clock));
}
@@ -91,8 +94,8 @@ void adminCanFindAnyStudent() {
@Test
@DisplayName("학생은 본인 정보를 조회할 수 있다")
void studentCanFindSelf() {
- Member member = givenMember(MemberRole.STUDENT);
- givenOwnStudent(member, STUDENT_ID);
+ givenMember(MemberRole.STUDENT);
+ givenOwnStudent(MEMBER_ID, STUDENT_ID);
givenDataGsmStudent(sdkStudent());
UserSearchResponse response = userSearchService.findUser(MEMBER_ID, STUDENT_ID);
@@ -103,8 +106,8 @@ void studentCanFindSelf() {
@Test
@DisplayName("학생이 다른 학생을 조회하면 FORBIDDEN이고 DataGSM을 호출하지 않는다")
void studentCannotFindOthers() {
- Member member = givenMember(MemberRole.STUDENT);
- givenOwnStudent(member, 999L);
+ givenMember(MemberRole.STUDENT);
+ givenOwnStudent(MEMBER_ID, 999L);
assertThatThrownBy(() -> userSearchService.findUser(MEMBER_ID, STUDENT_ID))
.isInstanceOfSatisfying(CustomException.class,
@@ -115,8 +118,8 @@ void studentCannotFindOthers() {
@Test
@DisplayName("학생 정보가 없는 STUDENT 회원은 FORBIDDEN이다")
void studentWithoutStudentRecordIsForbidden() {
- Member member = givenMember(MemberRole.STUDENT);
- given(studentRepository.findByMember(member)).willReturn(Optional.empty());
+ givenMember(MemberRole.STUDENT);
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.empty());
assertThatThrownBy(() -> userSearchService.findUser(MEMBER_ID, STUDENT_ID))
.isInstanceOfSatisfying(CustomException.class,
@@ -222,7 +225,7 @@ void cachedResultIsNotServedWithoutAccess() {
Member student = Member.create(11L, "학생", MemberRole.STUDENT);
given(memberService.getById(1L)).willReturn(admin);
given(memberService.getById(2L)).willReturn(student);
- givenOwnStudent(student, 999L);
+ givenOwnStudent(2L, 999L);
givenDataGsmStudent(sdkStudent());
userSearchService.findUser(1L, STUDENT_ID);
@@ -237,9 +240,8 @@ private Member givenMember(MemberRole role) {
return member;
}
- private void givenOwnStudent(Member member, Long datagsmStudentId) {
- Student student = Student.create(member, datagsmStudentId, "홍길동", 2, 3, 4, 2304, 301);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(student));
+ private void givenOwnStudent(Long memberId, Long datagsmStudentId) {
+ given(studentRepository.findDatagsmStudentIdByMemberId(memberId)).willReturn(Optional.of(datagsmStudentId));
}
private void givenDataGsmStudent(team.themoment.datagsm.sdk.openapi.model.Student student) {
diff --git a/src/test/java/com/checkup/checkup/domain/user/service/UserVolunteerServiceTest.java b/src/test/java/com/checkup/checkup/domain/user/service/UserVolunteerServiceTest.java
index 21fd25b0..a704cec0 100644
--- a/src/test/java/com/checkup/checkup/domain/user/service/UserVolunteerServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/user/service/UserVolunteerServiceTest.java
@@ -18,6 +18,8 @@
import com.checkup.checkup.domain.volunteer.repository.VolunteerDutyRepository;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
+import com.checkup.checkup.global.security.AdminRoleCache;
+import com.checkup.checkup.global.security.AdminVerifier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -25,6 +27,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -54,8 +57,9 @@ class UserVolunteerServiceTest {
@BeforeEach
void setUp() {
- service = new UserVolunteerService(memberService, studentRepository,
- new UserAccessVerifier(studentRepository), volunteerDutyRepository);
+ AdminVerifier adminVerifier = new AdminVerifier(memberService, new AdminRoleCache(Clock.systemUTC()));
+ service = new UserVolunteerService(studentRepository,
+ new UserAccessVerifier(adminVerifier, studentRepository), volunteerDutyRepository);
}
@Test
@@ -77,7 +81,7 @@ void adminCanRead() {
void studentCanReadSelf() {
Member member = givenMember(MemberRole.STUDENT);
Student own = student(member, STUDENT_ID);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(own));
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
given(studentRepository.findByDatagsmStudentId(STUDENT_ID)).willReturn(Optional.of(own));
assertThat(service.findVolunteer(MEMBER_ID, STUDENT_ID).volunteerCount()).isZero();
@@ -86,8 +90,8 @@ void studentCanReadSelf() {
@Test
@DisplayName("학생이 다른 학생의 봉사 횟수를 조회하면 403이다")
void studentCannotReadOthers() {
- Member member = givenMember(MemberRole.STUDENT);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(student(member, 999L)));
+ givenMember(MemberRole.STUDENT);
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(999L));
assertThatThrownBy(() -> service.findVolunteer(MEMBER_ID, STUDENT_ID))
.isInstanceOfSatisfying(CustomException.class,
@@ -112,7 +116,7 @@ void studentCanReadOwnHistory() {
Member member = givenMember(MemberRole.STUDENT);
Student own = student(member, STUDENT_ID);
ReflectionTestUtils.setField(own, "id", 7L);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(own));
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(STUDENT_ID));
given(studentRepository.findByDatagsmStudentId(STUDENT_ID)).willReturn(Optional.of(own));
Instant completedAt = Instant.parse("2026-10-03T09:00:00Z");
given(volunteerDutyRepository.findAllByStudentIdAndStatusOrderByOperatingDayDescIdDesc(7L, DutyStatus.COMPLETED))
@@ -128,8 +132,8 @@ void studentCanReadOwnHistory() {
@Test
@DisplayName("학생이 다른 학생의 봉사 완료 내역을 조회하면 403이다")
void studentCannotReadOthersHistory() {
- Member member = givenMember(MemberRole.STUDENT);
- given(studentRepository.findByMember(member)).willReturn(Optional.of(student(member, 999L)));
+ givenMember(MemberRole.STUDENT);
+ given(studentRepository.findDatagsmStudentIdByMemberId(MEMBER_ID)).willReturn(Optional.of(999L));
assertThatThrownBy(() -> service.findVolunteerHistory(MEMBER_ID, STUDENT_ID))
.isInstanceOfSatisfying(CustomException.class,
diff --git a/src/test/java/com/checkup/checkup/domain/webhook/service/StudentSyncServiceTest.java b/src/test/java/com/checkup/checkup/domain/webhook/service/StudentSyncServiceTest.java
index ee6ea8af..c400339f 100644
--- a/src/test/java/com/checkup/checkup/domain/webhook/service/StudentSyncServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/webhook/service/StudentSyncServiceTest.java
@@ -5,6 +5,7 @@
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -14,6 +15,7 @@
import com.checkup.checkup.domain.member.repository.StudentRepository;
import com.checkup.checkup.domain.webhook.dto.StudentSyncData;
import com.checkup.checkup.global.config.AdminProperties;
+import com.checkup.checkup.global.security.AdminRoleCache;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@@ -33,8 +35,9 @@ class StudentSyncServiceTest {
private final StudentRepository studentRepository = mock(StudentRepository.class);
private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);
/** 허용 목록: DataGSM 계정 id 1060(학생 60의 회원). */
+ private final AdminRoleCache adminRoleCache = mock(AdminRoleCache.class);
private final StudentSyncService service = new StudentSyncService(studentRepository, eventPublisher,
- new AdminProperties(Set.of(1060L)));
+ new AdminProperties(Set.of(1060L)), adminRoleCache);
@Test
@DisplayName("목록에 없는 저장된 학생은 조회하지도 바꾸지도 않는다")
@@ -114,6 +117,31 @@ void unsavedLeftOrIncompleteStudentIsNotCreated() {
verify(studentRepository, never()).insertIfAbsent(any(), any(), any(), any(), any(), any(), any(), any());
}
+ @Test
+ @DisplayName("계정이 있는 학생의 역할을 갱신하거나 졸업 처리하면 관리자 역할 기억을 지운다")
+ void evictsAdminRoleCacheWhenMemberRoleIsUpdated() {
+ Student updated = student(70L, MemberRole.STUDENT, 301);
+ Student graduated = student(71L, MemberRole.ADMIN, 301);
+ given(studentRepository.findAllByDatagsmStudentIdIn(any())).willReturn(List.of(updated, graduated));
+
+ service.syncAll(List.of(
+ enrolled(70L, 302),
+ new StudentSyncData(71L, "학생", null, null, null, null, null, "GRADUATE")), SYNCED_AT);
+
+ verify(adminRoleCache, times(2)).evictAfterCommit(any());
+ }
+
+ @Test
+ @DisplayName("계정이 없는 학생은 관리자 역할 기억을 지우지 않는다")
+ void doesNotEvictAdminRoleCacheForStudentWithoutMember() {
+ Student student = Student.createWithoutMember(50L, "옛이름", 2, 1, 5, 2105, 301);
+ given(studentRepository.findAllByDatagsmStudentIdIn(any())).willReturn(List.of(student));
+
+ service.syncAll(List.of(enrolled(50L, 302)), SYNCED_AT);
+
+ verify(adminRoleCache, never()).evictAfterCommit(any());
+ }
+
@Test
@DisplayName("계정이 없는 저장된 학생도 이름과 호실을 갱신한다")
void studentWithoutMemberIsUpdated() {
diff --git a/src/test/java/com/checkup/checkup/domain/webhook/service/WebhookServiceTest.java b/src/test/java/com/checkup/checkup/domain/webhook/service/WebhookServiceTest.java
index 38f0b67c..4eaa6ba7 100644
--- a/src/test/java/com/checkup/checkup/domain/webhook/service/WebhookServiceTest.java
+++ b/src/test/java/com/checkup/checkup/domain/webhook/service/WebhookServiceTest.java
@@ -20,6 +20,7 @@
import com.checkup.checkup.domain.webhook.dto.request.WebhookStudent;
import com.checkup.checkup.domain.webhook.repository.WebhookEventLogRepository;
import com.checkup.checkup.global.config.AdminProperties;
+import com.checkup.checkup.global.security.AdminRoleCache;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
import java.nio.charset.StandardCharsets;
@@ -92,7 +93,8 @@ class WebhookServiceTest {
private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);
private final WebhookService webhookService = new WebhookService(
objectMapper, webhookEventLogRepository, Clock.fixed(NOW, ZoneOffset.UTC),
- new StudentSyncService(studentRepository, eventPublisher, new AdminProperties(Set.of())));
+ new StudentSyncService(studentRepository, eventPublisher, new AdminProperties(Set.of()),
+ mock(AdminRoleCache.class)));
@BeforeEach
void setUp() {
diff --git a/src/test/java/com/checkup/checkup/global/config/GracefulShutdownSettingsTest.java b/src/test/java/com/checkup/checkup/global/config/GracefulShutdownSettingsTest.java
new file mode 100644
index 00000000..1fcc9a19
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/global/config/GracefulShutdownSettingsTest.java
@@ -0,0 +1,43 @@
+package com.checkup.checkup.global.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+/**
+ * 종료 신호를 받았을 때 처리 중인 요청을 기다리는 설정이 기본값으로 켜져 있고,
+ * 환경변수로 대기 시간을 바꿀 수 있는지 DB 없이 검증한다(#233).
+ */
+class GracefulShutdownSettingsTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer());
+
+ @Test
+ @DisplayName("우아한 종료가 켜져 있고 처리 중인 요청을 30초까지 기다린다")
+ void gracefulShutdownIsEnabledByDefault() {
+ contextRunner.run(context -> {
+ Binder binder = Binder.get(context.getEnvironment());
+
+ assertThat(binder.bind("server.shutdown", String.class).get()).isEqualToIgnoringCase("graceful");
+ assertThat(binder.bind("spring.lifecycle.timeout-per-shutdown-phase", Duration.class).get())
+ .isEqualTo(Duration.ofSeconds(30));
+ });
+ }
+
+ @Test
+ @DisplayName("SHUTDOWN_TIMEOUT으로 대기 시간을 바꾼다")
+ void timeoutCanBeOverriddenByEnvironment() {
+ contextRunner
+ .withPropertyValues("SHUTDOWN_TIMEOUT=45s")
+ .run(context -> assertThat(Binder.get(context.getEnvironment())
+ .bind("spring.lifecycle.timeout-per-shutdown-phase", Duration.class).get())
+ .isEqualTo(Duration.ofSeconds(45)));
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/global/config/HikariPoolSettingsTest.java b/src/test/java/com/checkup/checkup/global/config/HikariPoolSettingsTest.java
new file mode 100644
index 00000000..5da645b0
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/global/config/HikariPoolSettingsTest.java
@@ -0,0 +1,47 @@
+package com.checkup.checkup.global.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+
+import com.zaxxer.hikari.HikariDataSource;
+
+/**
+ * DB 커넥션 풀 크기와 대기 시간이 application.yaml 기본값(10개·10초)으로 적용되고,
+ * 환경변수로 덮어쓸 수 있는지 DB 없이 검증한다(#217).
+ */
+class HikariPoolSettingsTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer())
+ .withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class));
+
+ @Test
+ @DisplayName("커넥션 풀은 기본 10개이고 가득 차면 10초 뒤 실패한다")
+ void poolUsesConfiguredDefaults() {
+ contextRunner.run(context -> {
+ HikariDataSource hikari = context.getBean(HikariDataSource.class);
+
+ assertThat(hikari.getMaximumPoolSize()).isEqualTo(10);
+ assertThat(hikari.getConnectionTimeout()).isEqualTo(10_000L);
+ });
+ }
+
+ @Test
+ @DisplayName("DB_POOL_SIZE와 DB_POOL_TIMEOUT_MS로 풀 크기와 대기 시간을 바꾼다")
+ void poolSettingsCanBeOverriddenByEnvironment() {
+ contextRunner
+ .withPropertyValues("DB_POOL_SIZE=20", "DB_POOL_TIMEOUT_MS=3000")
+ .run(context -> {
+ HikariDataSource hikari = context.getBean(HikariDataSource.class);
+
+ assertThat(hikari.getMaximumPoolSize()).isEqualTo(20);
+ assertThat(hikari.getConnectionTimeout()).isEqualTo(3_000L);
+ });
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/global/security/AdminRoleCacheTest.java b/src/test/java/com/checkup/checkup/global/security/AdminRoleCacheTest.java
new file mode 100644
index 00000000..804e1a5a
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/global/security/AdminRoleCacheTest.java
@@ -0,0 +1,96 @@
+package com.checkup.checkup.global.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import com.checkup.checkup.domain.member.entity.MemberRole;
+import com.checkup.checkup.support.MutableClock;
+
+/**
+ * 관리자 역할 기억이 만료·가득 참·커밋 뒤 삭제에서 옛 역할을 오래 남기지 않는지 검증한다(#212).
+ */
+class AdminRoleCacheTest {
+
+ private MutableClock clock;
+ private AdminRoleCache cache;
+
+ @BeforeEach
+ void setUp() {
+ clock = new MutableClock(Instant.parse("2026-10-07T00:00:00Z"));
+ cache = new AdminRoleCache(clock);
+ }
+
+ @Test
+ @DisplayName("보관한 역할을 기억 시간 동안 돌려주고 시간이 지나면 비운다")
+ void expiresAfterTtl() {
+ cache.put(1L, MemberRole.ADMIN);
+
+ assertThat(cache.get(1L)).contains(MemberRole.ADMIN);
+ clock.advance(AdminRoleCache.TTL);
+ assertThat(cache.get(1L)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("가득 차면 새 항목을 보관하지 않고, 기존 항목이 만료되면 만료분을 치우고 보관한다")
+ void fullCacheRefusesNewUntilExpiredEntriesAreDropped() {
+ for (long id = 0; id < AdminRoleCache.MAX_ENTRIES; id++) {
+ cache.put(id, MemberRole.STUDENT);
+ }
+ cache.put(-1L, MemberRole.ADMIN);
+ assertThat(cache.get(-1L)).isEmpty();
+
+ clock.advance(AdminRoleCache.TTL);
+ cache.put(-1L, MemberRole.ADMIN);
+ assertThat(cache.get(-1L)).contains(MemberRole.ADMIN);
+ }
+
+ @Test
+ @DisplayName("트랜잭션 안에서는 커밋 뒤에야 항목을 지운다")
+ void evictsOnlyAfterCommitInsideTransaction() {
+ cache.put(1L, MemberRole.ADMIN);
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ cache.evictAfterCommit(1L);
+ assertThat(cache.get(1L)).contains(MemberRole.ADMIN);
+
+ TransactionSynchronizationManager.getSynchronizations()
+ .forEach(TransactionSynchronization::afterCommit);
+ assertThat(cache.get(1L)).isEmpty();
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+ }
+
+ @Test
+ @DisplayName("롤백되면 항목을 지우지 않는다")
+ void doesNotEvictOnRollback() {
+ cache.put(1L, MemberRole.ADMIN);
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ cache.evictAfterCommit(1L);
+ TransactionSynchronizationManager.getSynchronizations()
+ .forEach(sync -> sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+
+ assertThat(cache.get(1L)).contains(MemberRole.ADMIN);
+ }
+
+ @Test
+ @DisplayName("트랜잭션 밖에서는 바로 지운다")
+ void evictsImmediatelyOutsideTransaction() {
+ cache.put(1L, MemberRole.ADMIN);
+
+ cache.evictAfterCommit(1L);
+
+ assertThat(cache.get(1L)).isEmpty();
+ }
+}
diff --git a/src/test/java/com/checkup/checkup/global/security/AdminVerifierTest.java b/src/test/java/com/checkup/checkup/global/security/AdminVerifierTest.java
index e53ae365..9cd91a06 100644
--- a/src/test/java/com/checkup/checkup/global/security/AdminVerifierTest.java
+++ b/src/test/java/com/checkup/checkup/global/security/AdminVerifierTest.java
@@ -4,11 +4,15 @@
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import java.time.Instant;
+
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -17,9 +21,11 @@
import com.checkup.checkup.domain.member.service.MemberService;
import com.checkup.checkup.global.exception.CustomException;
import com.checkup.checkup.global.exception.ErrorCode;
+import com.checkup.checkup.support.MutableClock;
/**
- * 관리자 확인이 요청마다 DB 역할로 관리자만 통과시키는지 검증한다.
+ * 관리자 확인이 DB 역할로 관리자만 통과시키고, 역할을 짧게 기억해 DB를 매번 읽지 않으면서
+ * 역할이 바뀌면 바로(또는 기억 시간이 지나면) 반영되는지 검증한다(#212).
*/
@ExtendWith(MockitoExtension.class)
class AdminVerifierTest {
@@ -27,9 +33,17 @@ class AdminVerifierTest {
@Mock
private MemberService memberService;
- @InjectMocks
+ private MutableClock clock;
+ private AdminRoleCache cache;
private AdminVerifier adminVerifier;
+ @BeforeEach
+ void setUp() {
+ clock = new MutableClock(Instant.parse("2026-10-07T00:00:00Z"));
+ cache = new AdminRoleCache(clock);
+ adminVerifier = new AdminVerifier(memberService, cache);
+ }
+
@Test
@DisplayName("관리자는 통과한다")
void adminPasses() {
@@ -49,13 +63,96 @@ void studentIsForbidden() {
}
@Test
- @DisplayName("없는 회원은 401이다")
- void unknownMemberIsUnauthorized() {
+ @DisplayName("없는 회원은 401이고 기억하지 않아 다음 요청도 DB를 본다")
+ void unknownMemberIsUnauthorizedAndNotCached() {
given(memberService.getById(3L))
.willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND));
assertThatThrownBy(() -> adminVerifier.verify(3L))
.isInstanceOfSatisfying(CustomException.class,
e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.MEMBER_NOT_FOUND));
+ assertThatThrownBy(() -> adminVerifier.verify(3L)).isInstanceOf(CustomException.class);
+
+ verify(memberService, times(2)).getById(3L);
+ }
+
+ @Test
+ @DisplayName("기억 시간 안의 두 번째 요청은 DB를 읽지 않는다")
+ void secondRequestWithinTtlSkipsDatabase() {
+ given(memberService.getById(1L)).willReturn(Member.create(100L, "관리자", MemberRole.ADMIN));
+
+ adminVerifier.verify(1L);
+ clock.advance(AdminRoleCache.TTL.minusSeconds(1));
+ adminVerifier.verify(1L);
+
+ verify(memberService, times(1)).getById(1L);
+ }
+
+ @Test
+ @DisplayName("기억 시간이 지나면 DB에서 다시 읽어, 관리자에서 내려간 회원을 막는다")
+ void demotedMemberIsRejectedAfterTtl() {
+ given(memberService.getById(1L))
+ .willReturn(Member.create(100L, "관리자", MemberRole.ADMIN))
+ .willReturn(Member.create(100L, "관리자", MemberRole.STUDENT));
+
+ adminVerifier.verify(1L);
+ clock.advance(AdminRoleCache.TTL);
+
+ assertThatThrownBy(() -> adminVerifier.verify(1L))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ADMIN_ONLY));
+ }
+
+ @Test
+ @DisplayName("역할을 바꾸며 항목을 지우면 기억 시간 안에도 바로 새 역할로 판정한다")
+ void evictionAppliesRoleChangeImmediately() {
+ given(memberService.getById(1L))
+ .willReturn(Member.create(100L, "관리자", MemberRole.ADMIN))
+ .willReturn(Member.create(100L, "관리자", MemberRole.STUDENT));
+
+ adminVerifier.verify(1L);
+ cache.evict(1L);
+
+ assertThatThrownBy(() -> adminVerifier.verify(1L))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ADMIN_ONLY));
+ }
+
+ @Test
+ @DisplayName("관리자 여부를 알려주고, 기억 시간 안에는 DB를 다시 읽지 않는다")
+ void isAdminReportsRoleAndUsesCache() {
+ given(memberService.getById(1L)).willReturn(Member.create(100L, "관리자", MemberRole.ADMIN));
+ given(memberService.getById(2L)).willReturn(Member.create(200L, "학생", MemberRole.STUDENT));
+
+ assertThat(adminVerifier.isAdmin(1L)).isTrue();
+ assertThat(adminVerifier.isAdmin(2L)).isFalse();
+ assertThat(adminVerifier.isAdmin(1L)).isTrue();
+ assertThat(adminVerifier.isAdmin(2L)).isFalse();
+
+ verify(memberService, times(1)).getById(1L);
+ verify(memberService, times(1)).getById(2L);
+ }
+
+ @Test
+ @DisplayName("관리자 여부를 묻는 경우에도 없는 회원은 401이다")
+ void isAdminRejectsUnknownMember() {
+ given(memberService.getById(3L)).willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND));
+
+ assertThatThrownBy(() -> adminVerifier.isAdmin(3L))
+ .isInstanceOfSatisfying(CustomException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("학생으로 기억된 회원이 관리자가 되면 항목을 지운 뒤 바로 관리자로 판정한다")
+ void promotedMemberIsAdminAfterEviction() {
+ given(memberService.getById(1L))
+ .willReturn(Member.create(100L, "학생", MemberRole.STUDENT))
+ .willReturn(Member.create(100L, "학생", MemberRole.ADMIN));
+
+ assertThat(adminVerifier.isAdmin(1L)).isFalse();
+ cache.evict(1L);
+
+ assertThat(adminVerifier.isAdmin(1L)).isTrue();
}
}
diff --git a/src/test/java/com/checkup/checkup/loadtest/ServiceLoadTest.java b/src/test/java/com/checkup/checkup/loadtest/ServiceLoadTest.java
new file mode 100644
index 00000000..8d3ce15b
--- /dev/null
+++ b/src/test/java/com/checkup/checkup/loadtest/ServiceLoadTest.java
@@ -0,0 +1,229 @@
+package com.checkup.checkup.loadtest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import com.checkup.checkup.domain.attendance.entity.AttendancePurpose;
+import com.checkup.checkup.domain.attendance.service.AttendanceService;
+import com.checkup.checkup.domain.notification.service.NotificationService;
+import com.checkup.checkup.domain.qr.dto.QrSessionIssue;
+import com.checkup.checkup.domain.qr.entity.QrScanResult;
+import com.checkup.checkup.domain.qr.service.QrScanService;
+import com.checkup.checkup.domain.qr.service.QrSessionService;
+import com.checkup.checkup.domain.room.service.RoomService;
+import com.checkup.checkup.global.querylog.QueryCounter;
+
+/**
+ * 출석 시간대에 몰리는 요청을 서비스 계층에서 동시에 호출해 응답 시간과 요청당 DB 쿼리 수를 잰다(#218).
+ *
+ * 평소 빌드에서는 돌지 않는다. 환경변수 {@code LOADTEST=true}일 때만 실행하며, 실제 Postgres·Redis(로컬 compose)가 필요하다.
+ * 학생 {@value #STUDENTS}명을 임시로 만들고 끝나면 지운다. 결과는 {@code build/loadtest/report.md}에 쓴다.
+ *
+ * 한계: HTTP·로그인·보안 필터·JSON 직렬화와 DataGSM·얼굴 AI 호출은 포함하지 않는다. 같은 PC의 DB·Redis라 네트워크 지연이 없다.
+ * 절대값이 아니라 변경 전후 비교와 요청당 쿼리 수를 보는 용도다. 동시 호출 수는 {@code LOADTEST_CONCURRENCY}(기본 50)로 바꾼다.
+ */
+@SpringBootTest(properties = "checkup.query-log.enabled=true")
+@EnabledIfEnvironmentVariable(named = "LOADTEST", matches = "true")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ServiceLoadTest {
+
+ static final int STUDENTS = 200;
+ private static final int WARMUP_STUDENTS = 20;
+ private static final int FIRST_ROOM = 301;
+ private static final int STUDENTS_PER_ROOM = 4;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private QueryCounter queryCounter;
+
+ @Autowired
+ private QrSessionService qrSessionService;
+
+ @Autowired
+ private QrScanService qrScanService;
+
+ @Autowired
+ private AttendanceService attendanceService;
+
+ @Autowired
+ private NotificationService notificationService;
+
+ @Autowired
+ private RoomService roomService;
+
+ private final int concurrency = Integer.parseInt(System.getenv().getOrDefault("LOADTEST_CONCURRENCY", "50"));
+ private final List memberIds = new ArrayList<>();
+ private final List studentIds = new ArrayList<>();
+ private final List report = new ArrayList<>();
+ private Long adminMemberId;
+
+ @BeforeAll
+ void seed() {
+ long base = ThreadLocalRandom.current().nextLong(1_000_000_000L, Long.MAX_VALUE / 2);
+ adminMemberId = jdbcTemplate.queryForObject(
+ "INSERT INTO member (datagsm_id, name, role) VALUES (?, '부하관리자', 'ADMIN') RETURNING id",
+ Long.class, base);
+ for (int i = 0; i < STUDENTS + WARMUP_STUDENTS; i++) {
+ Long memberId = jdbcTemplate.queryForObject(
+ "INSERT INTO member (datagsm_id, name, role) VALUES (?, ?, 'STUDENT') RETURNING id",
+ Long.class, base + 1 + i, "부하학생" + i);
+ Long studentId = jdbcTemplate.queryForObject(
+ "INSERT INTO student (member_id, name, number, grade, class_number, student_number, "
+ + "dormitory_room, privacy_agreed_at, face_agreed_at, datagsm_student_id) "
+ + "VALUES (?, ?, 1, 1, 1, ?, ?, now(), now(), ?) RETURNING id",
+ Long.class, memberId, "부하학생" + i, 1100 + i, FIRST_ROOM + i / STUDENTS_PER_ROOM, base + 1 + i);
+ memberIds.add(memberId);
+ studentIds.add(studentId);
+ }
+ }
+
+ @AfterAll
+ void cleanUp() throws IOException {
+ for (Long studentId : studentIds) {
+ jdbcTemplate.update("DELETE FROM notification WHERE student_id = ?", studentId);
+ jdbcTemplate.update("DELETE FROM attendance WHERE student_id = ?", studentId);
+ jdbcTemplate.update("DELETE FROM student WHERE id = ?", studentId);
+ }
+ for (Long memberId : memberIds) {
+ jdbcTemplate.update("DELETE FROM member WHERE id = ?", memberId);
+ }
+ jdbcTemplate.update("DELETE FROM member WHERE id = ?", adminMemberId);
+
+ Path out = Path.of("build", "loadtest", "report.md");
+ Files.createDirectories(out.getParent());
+ Files.writeString(out, String.join("\n", report) + "\n");
+ System.out.println(String.join("\n", report));
+ }
+
+ @Test
+ @DisplayName("출석 시간대 시나리오: QR 스캔 폭주 → 학생 홈 조회 → 관리자 호실·층 조회")
+ void attendanceRush() throws Exception {
+ report.add("# 서비스 계층 부하 기준값 (#218)");
+ report.add("");
+ report.add("학생 " + STUDENTS + "명, 동시 호출 " + concurrency + "개, 실제 Postgres·Redis(로컬). HTTP·로그인·직렬화 제외.");
+ report.add("");
+ report.add("| 시나리오 | 호출 수 | 총 시간(ms) | 처리량(건/초) | p50(ms) | p95(ms) | 최대(ms) | 호출당 쿼리(평균/최대) |");
+ report.add("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
+
+ // 첫 호출의 JIT·커넥션 준비 비용이 섞이지 않도록, 측정 대상이 아닌 학생으로 먼저 한 번 돌린다.
+ QrSessionIssue warmupSession = qrSessionService.create(adminMemberId, AttendancePurpose.DORMITORY);
+ run("warmup", slice(STUDENTS, STUDENTS + WARMUP_STUDENTS),
+ memberId -> qrScanService.scan(memberId, warmupSession.token()));
+ qrSessionService.close(adminMemberId, warmupSession.sessionId());
+
+ QrSessionIssue session = qrSessionService.create(adminMemberId, AttendancePurpose.STUDY_ROOM);
+ Result scan = run("QR 스캔(첫 출석)", slice(0, STUDENTS),
+ memberId -> qrScanService.scan(memberId, session.token()));
+ assertThat(scan.results()).containsOnly(QrScanResult.APPROVED);
+
+ Result duplicate = run("QR 스캔(중복)", slice(0, STUDENTS),
+ memberId -> qrScanService.scan(memberId, session.token()));
+ assertThat(duplicate.results()).containsOnly(QrScanResult.DUPLICATE);
+
+ run("학생 홈: 본인 출석 조회", slice(0, STUDENTS), attendanceService::getMyToday);
+ run("학생 홈: 미확인 알림 조회", slice(0, STUDENTS), notificationService::hasUnread);
+
+ List adminCalls = new ArrayList<>();
+ for (int i = 0; i < STUDENTS; i++) {
+ adminCalls.add(adminMemberId);
+ }
+ run("관리자: 호실 명단 조회", adminCalls, memberId -> roomService.getStudents(
+ memberId, FIRST_ROOM + ThreadLocalRandom.current().nextInt(STUDENTS / STUDENTS_PER_ROOM),
+ AttendancePurpose.STUDY_ROOM));
+ run("관리자: 층 출석 현황", adminCalls.subList(0, 50),
+ memberId -> roomService.getFloor(memberId, 3, AttendancePurpose.STUDY_ROOM));
+
+ qrSessionService.close(adminMemberId, session.sessionId());
+ report.add("");
+ report.add("얼굴 인식 프레임은 AI 서버 호출이 있어 이 측정에 포함하지 않았다.");
+ }
+
+ private List slice(int from, int to) {
+ return memberIds.subList(from, to);
+ }
+
+ /** 호출 목록을 동시 호출 수만큼의 스레드로 한꺼번에 실행하고, 호출마다 걸린 시간과 쿼리 수를 모아 보고서에 한 줄 더한다. */
+ private Result run(String name, List callers, CallerAction action) throws Exception {
+ ExecutorService executor = Executors.newFixedThreadPool(concurrency);
+ try {
+ List>> futures = new ArrayList<>();
+ long startedAt = System.nanoTime();
+ for (Long caller : callers) {
+ Callable> task = () -> {
+ queryCounter.start();
+ long begin = System.nanoTime();
+ T result = action.apply(caller);
+ long elapsed = System.nanoTime() - begin;
+ return new Sample<>(elapsed, queryCounter.stop(), result);
+ };
+ futures.add(executor.submit(task));
+ }
+ long[] latencies = new long[callers.size()];
+ int[] queries = new int[callers.size()];
+ List results = new ArrayList<>();
+ for (int i = 0; i < futures.size(); i++) {
+ Sample sample = futures.get(i).get(2, TimeUnit.MINUTES);
+ latencies[i] = sample.nanos;
+ queries[i] = sample.queries;
+ results.add(sample.result);
+ }
+ long wallNanos = System.nanoTime() - startedAt;
+ if (!name.equals("warmup")) {
+ report.add(row(name, latencies, queries, wallNanos));
+ }
+ return new Result(new ArrayList(results));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private static String row(String name, long[] latencies, int[] queries, long wallNanos) {
+ long[] sorted = latencies.clone();
+ Arrays.sort(sorted);
+ double wallSeconds = wallNanos / 1e9;
+ return String.format("| %s | %d | %.0f | %.0f | %.1f | %.1f | %.1f | %.1f / %d |",
+ name, latencies.length, wallNanos / 1e6, latencies.length / wallSeconds,
+ percentile(sorted, 50) / 1e6, percentile(sorted, 95) / 1e6, sorted[sorted.length - 1] / 1e6,
+ Arrays.stream(queries).average().orElse(0), Arrays.stream(queries).max().orElse(0));
+ }
+
+ private static long percentile(long[] sorted, int percent) {
+ int index = (int) Math.ceil(percent / 100.0 * sorted.length) - 1;
+ return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
+ }
+
+ @FunctionalInterface
+ private interface CallerAction {
+ T apply(Long caller) throws Exception;
+ }
+
+ private record Sample(long nanos, int queries, T result) {
+ }
+
+ private record Result(List results) {
+ }
+}