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 백엔드 +

+ +

+ Server CI + Java 25 + Spring Boot 4.1 + PostgreSQL 17 + Redis 7 +

+ +

+ 소개 · + 빠른 시작 · + 설정 · + 개발 도구 · + 얼굴 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) { + } +}