diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31625263..d3b845bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: run: python3 -m unittest discover -s infra/monitoring/lambdas/tests -v - name: Alarm catalog run: python3 -c "import sys,types; sys.modules['boto3']=types.ModuleType('boto3'); sys.path.insert(0,'infra/monitoring/alerting'); import unittest; r=unittest.main(module='tests_catalog', argv=['x','-v'], exit=False); sys.exit(not r.result.wasSuccessful())" + - name: On-call (PagerDuty) + run: python3 -c "import sys,types; sys.modules['boto3']=types.ModuleType('boto3'); sys.path.insert(0,'infra/monitoring/alerting'); import unittest; r=unittest.main(module='tests_pagerduty', argv=['x','-v'], exit=False); sys.exit(not r.result.wasSuccessful())" - name: Deployment preflight run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v diff --git a/Dockerfile.deploy b/Dockerfile.deploy index 33f648d6..3af8cdab 100644 --- a/Dockerfile.deploy +++ b/Dockerfile.deploy @@ -14,5 +14,9 @@ ARG ADOT_JAVA_AGENT_VERSION=v2.31.0 ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/download/${ADOT_JAVA_AGENT_VERSION}/aws-opentelemetry-agent.jar /app/otel/aws-opentelemetry-agent.jar COPY --from=build /src/build/libs/*.jar app.jar COPY infra/monitoring/tracing/entrypoint.sh /app/entrypoint.sh +# glibc 는 스레드마다 64MB 단위 malloc 아레나를 늘려 가며 돌려주지 않는다. stg(1GB 태스크)에서 JVM RSS 800MB 중 +# 힙은 256MB 였고 아레나가 ~200MB 를 차지해, 하루쯤마다 하드 메모리 80% 경보(ECS-06-80)를 다시 울렸다(2026-10-14 smaps 실측). +# 아레나 수를 묶으면 네이티브 할당이 몇 개 아레나를 같이 쓰게 되어 RSS 가 천천히 차오르지 않는다. +ENV MALLOC_ARENA_MAX=2 EXPOSE 8080 ENTRYPOINT ["sh","/app/entrypoint.sh"] diff --git a/infra/capacity/apply_prod_dau2.py b/infra/capacity/apply_prod_dau2.py new file mode 100644 index 00000000..3365d4c5 --- /dev/null +++ b/infra/capacity/apply_prod_dau2.py @@ -0,0 +1,100 @@ +"""prod 사양 조정 — DAU 2만 부하테스트(2026-10-16~17, 노션 「부하테스트 및 성능개선」 9절) 결론. 재실행 안전. +사용자가 직접 실행한다(main 머지 **전에**, 온콜 중지 상태에서): python3 infra/capacity/apply_prod_dau2.py [--cpu 1024] [--max-inflight 64] + +main CD 는 서비스의 현재 태스크 정의를 그대로 두고 이미지만 바꿔 다시 띄운다. 그래서 사양은 여기서 먼저 바꿔 둔다. + 1. (선택 --cpu 1024) ECS 1 vCPU — 0.5 vCPU 는 피크 전에 CPU 100% 로 막힌다(1차 시험). 2026-10-17 결정: 지금 규모(prod CPU + 평균 5%)에선 올리지 않고, 지연·오류가 생기면 **가장 먼저** 이걸로 올린다. 기본은 현재 CPU 를 그대로 둔다. + 2. 헬스체크를 관리 포트(8081)로 분리 — 급증 때 요청 스레드 200개가 다 차면 /actuator/health 도 줄을 서 5초를 넘기고, + ECS 가 멀쩡한 태스크를 연달아 교체했다(2차 급증 시험). 관리 포트는 자기 Tomcat 커넥터를 따로 쓴다. + 끊김 없이 옮기는 순서: ALB→ECS 8081 허용 → 헬스체크 경로를 옛·새 태스크 모두 응답하는 /health/public 으로 + → MANAGEMENT_SERVER_PORT=8081 로 배포 → 헬스체크를 8081 /actuator/health 로. 순서를 바꾸면 전부 unhealthy 다. + 3. 메모리 75% 오토스케일 정책 삭제 — 부하를 한 번 받은 태스크는 힙을 다 커밋한 채 메모리 90% 에 머물러(누수 아님) + CPU 가 한가해도 태스크를 늘렸다. 태스크를 늘리면 RDS 커넥션만 늘어 오히려 느려진다(2차: 4대에서 RDS CPU 96%). + 4. X-Ray API 규칙 초당 1건 + 5% — DAU 2만이면 초당 10건 + 10% 로 월 ~$135(코드 커밋 5e501ec 와 같은 값). + 5. (선택) --max-inflight N: 태스크당 동시 API 요청 상한(APP_HTTP_MAX_INFLIGHT). 넘치면 1초 뒤 503 + Retry-After. +RDS 는 바꾸지 않는다 — t4g.medium 도 2 vCPU 라 CPU 병목에 효과가 없었다(2차 D1·D2).""" +import argparse, time, boto3 + +R = 'ap-northeast-2' +CLUSTER, SERVICE, TG_NAME = 'ruleup-prod-cluster', 'ruleup-prod-api', 'ruleup-prod-tg' +ALB_SG, ECS_SG = 'sg-03f6935fe4c111e77', 'sg-0ce610d9387070705' +MGMT = 8081 + +ap = argparse.ArgumentParser() +ap.add_argument('--cpu', default=None, help='태스크 CPU 단위(예: 1024). 생략하면 현재 값 유지') +ap.add_argument('--max-inflight', type=int, default=None) +args = ap.parse_args() + +ecs = boto3.client('ecs', region_name=R); elb = boto3.client('elbv2', region_name=R) +ec2 = boto3.client('ec2', region_name=R); aas = boto3.client('application-autoscaling', region_name=R) +xray = boto3.client('xray', region_name=R) +tg = elb.describe_target_groups(Names=[TG_NAME])['TargetGroups'][0] +TG = tg['TargetGroupArn'] + + +def wait_healthy(label): + for _ in range(40): + states = [t['TargetHealth']['State'] for t in elb.describe_target_health(TargetGroupArn=TG)['TargetHealthDescriptions']] + if states and all(s == 'healthy' for s in states): + print(f' {label}: healthy {len(states)}대'); return + time.sleep(15) + raise SystemExit(f'중단 — {label} 뒤 대상이 healthy 가 아니다: {states}. 헬스체크를 traffic-port·/actuator/health 로 되돌리고 확인할 것.') + + +# 1) ALB → ECS 8081 +try: + ec2.authorize_security_group_ingress(GroupId=ECS_SG, IpPermissions=[{'IpProtocol': 'tcp', 'FromPort': MGMT, 'ToPort': MGMT, + 'UserIdGroupPairs': [{'GroupId': ALB_SG, 'Description': 'ALB health check (management port)'}]}]) + print('SG: ALB → ECS 8081 허용') +except ec2.exceptions.ClientError as e: + if 'Duplicate' not in str(e): raise + print('SG: 이미 허용됨') + +# 2) 옛·새 태스크 모두 응답하는 경로로 +if str(tg.get('HealthCheckPort')) != str(MGMT): + elb.modify_target_group(TargetGroupArn=TG, HealthCheckPort='traffic-port', HealthCheckPath='/health/public') + print('헬스체크: traffic-port /health/public (전환용)'); time.sleep(40); wait_healthy('전환 경로') + +# 3) 태스크 정의: 관리 포트 · (선택) CPU · (선택) 동시 상한 +svc = ecs.describe_services(cluster=CLUSTER, services=[SERVICE])['services'][0] +td = ecs.describe_task_definition(taskDefinition=svc['taskDefinition'])['taskDefinition'] +c = next(x for x in td['containerDefinitions'] if x['name'] == 'api') +env = {e['name']: e['value'] for e in c.get('environment', [])} +want = {'MANAGEMENT_SERVER_PORT': str(MGMT)} +if args.max_inflight: want['APP_HTTP_MAX_INFLIGHT'] = str(args.max_inflight) +cpu = args.cpu or td['cpu'] +ports = c.get('portMappings', []) +if td['cpu'] != cpu or any(env.get(k) != v for k, v in want.items()) or not any(p['containerPort'] == MGMT for p in ports): + env.update(want); c['environment'] = [{'name': k, 'value': v} for k, v in sorted(env.items())] + if not any(p['containerPort'] == MGMT for p in ports): ports.append({'containerPort': MGMT, 'hostPort': MGMT, 'protocol': 'tcp'}) + c['portMappings'] = ports + if c.get('cpu'): c['cpu'] = int(cpu) + keep = ['family', 'taskRoleArn', 'executionRoleArn', 'networkMode', 'containerDefinitions', 'volumes', 'placementConstraints', + 'requiresCompatibilities', 'runtimePlatform', 'ephemeralStorage', 'pidMode', 'ipcMode', 'proxyConfiguration'] + kw = {k: td[k] for k in keep if td.get(k) not in (None, [])} + new = ecs.register_task_definition(cpu=cpu, memory=td['memory'], **kw)['taskDefinition']['taskDefinitionArn'] + ecs.update_service(cluster=CLUSTER, service=SERVICE, taskDefinition=new) + print(f'ECS: {new.split("/")[-1]} 배포 — CPU {cpu}, 관리 포트 {MGMT}' + (f', 동시 상한 {args.max_inflight}' if args.max_inflight else '')) + ecs.get_waiter('services_stable').wait(cluster=CLUSTER, services=[SERVICE], WaiterConfig={'Delay': 15, 'MaxAttempts': 60}) + wait_healthy('새 태스크(전환 경로)') +else: + print('ECS: 이미 반영됨') + +# 4) 헬스체크를 관리 포트로 +if str(tg.get('HealthCheckPort')) != str(MGMT): + elb.modify_target_group(TargetGroupArn=TG, HealthCheckPort=str(MGMT), HealthCheckPath='/actuator/health') + print(f'헬스체크: {MGMT} /actuator/health'); time.sleep(40); wait_healthy('관리 포트') + +# 5) 메모리 오토스케일 정책 삭제(CPU 60% 는 유지) +for p in aas.describe_scaling_policies(ServiceNamespace='ecs', ResourceId=f'service/{CLUSTER}/{SERVICE}')['ScalingPolicies']: + if p['TargetTrackingScalingPolicyConfiguration']['PredefinedMetricSpecification']['PredefinedMetricType'] == 'ECSServiceAverageMemoryUtilization': + aas.delete_scaling_policy(PolicyName=p['PolicyName'], ServiceNamespace='ecs', ResourceId=p['ResourceId'], + ScalableDimension=p['ScalableDimension']) + print(f'오토스케일: {p["PolicyName"]} 삭제') + +# 6) X-Ray API 규칙 +rule = next((r['SamplingRule'] for r in xray.get_sampling_rules()['SamplingRuleRecords'] if r['SamplingRule']['RuleName'] == 'ruleup-api'), None) +if rule and (rule['FixedRate'] != 0.05 or rule['ReservoirSize'] != 1): + xray.update_sampling_rule(SamplingRuleUpdate={'RuleName': 'ruleup-api', 'FixedRate': 0.05, 'ReservoirSize': 1}) + print('X-Ray: ruleup-api 초당 1건 + 5%') +print('완료 — 이제 main 머지(CD 가 새 이미지로 같은 태스크 정의를 다시 띄운다)') diff --git a/infra/loadtest/README.md b/infra/loadtest/README.md new file mode 100644 index 00000000..7fdc13a3 --- /dev/null +++ b/infra/loadtest/README.md @@ -0,0 +1,48 @@ +# 부하·성능 테스트 (k6) + +2026-10 부하테스트에서 쓴 시나리오. 결과·개선 내역은 노션 「부하테스트 및 성능개선」. + +## 준비 (stg 만) + +| 무엇 | 어떻게 | +|---|---| +| 로그인 우회 | 태스크 정의 env `OAUTH_LOAD_TEST_ENABLED=true` — IdP 호출만 빼고 실제 로그인 트랜잭션을 탄다(`LoadTestOAuthGate`, prod 프로파일엔 빈이 없다). `code=loadtest-`, `codeVerifier=` | +| AI 초안 한도 | `APP_CHALLENGE_DRAFT_RATE_LIMIT_PER_MINUTE` (기본 10) | +| prod 와 같은 조건 | 태스크 0.5 vCPU/2GB·2대(오토스케일 2~4, CPU 60%·메모리 75%), `JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60`, Hikari 15, RDS Multi-AZ, **DB 접속 `sslMode=REQUIRED`**(stg 기본 주소는 `useSSL=false` 라 그대로 재면 prod 보다 좋게 나온다) | +| Cloudflare 건너뛰기 | `-e ALB_IP=` — TLS SNI·Host 는 도메인 그대로. 서버만 잰다 | + +끝나면 반드시 되돌린다 — 우회 스위치 끄기, 스펙·Multi-AZ·오토스케일 원복. + +## 실행 + +```sh +export LT_SECRET=... # stg 시크릿 ruleup-stg/app 의 DEV_TOKEN_SECRET. 저장소에 두지 않는다 +k6 run -e LT_SECRET=$LT_SECRET -e ALB_IP=3.37.221.3 -e RUN_ID=lt2 -e USERS=3000 -e SCENARIO=seed -e VUS=25 infra/loadtest/k6/ruleup.js +k6 run ... -e SCENARIO=seedjoin ... # 가입·방 생성 뒤 남의 방 2곳 가입(사용자당 참여 방 3개 안팎) +k6 run ... -e SCENARIO=peak -e SCALE=1 -e DURATION=5m infra/loadtest/k6/ruleup.js +``` + +| SCENARIO | 내용 | +|---|---| +| `smoke.js` | 주요 API 를 한 번씩 — 계약 오류부터 거른다 | +| `seed` / `seedjoin` | 가짜 사용자 가입·공개 방 생성 / 남의 방 가입 | +| `login` | 아침 몰림 — 로그인 + 홈 + 리프레시, 초당 `RATE` | +| `browse` | 앱 열기·탐색(같은 정렬로 2페이지)·상세·내 방·마이페이지, 동시 `VUS` | +| `create` | 템플릿 초안 → 생성, 초당 `RATE` | +| `aidraft` | AI 초안(실제 LLM), 동시 `VUS` | +| `sync` | 건강정보 동의 → 인증 신호 업로드(backlog=true, 사용자당 10초 간격 — 429 는 정상 거절) | +| `peak` | 위를 실사용 비율로 섞음: 평상시(5분) → 피크(동시 400·로그인 15/s·sync 20/s·생성 3/s·가입 4/s, `DURATION`) → 마무리. `SCALE=2` 면 2배 | +| `breakpoint` | 동시 사용자를 200→2000 까지 2분씩 올리며 오류 5% 에서 멈춘다 | + +임계값은 노션 SLO(로그인 3초·리프레시 1초·탐색/상세 1초·가입 1.5초·sync 3초·오늘/진행률 1초, p95)와 5xx 0건. + +## 함정 + +- 시작일이 「내일」인 방을 대량으로 만들면 **자정에 한꺼번에 ACTIVE** 가 되어 그 뒤 측정의 데이터 모양이 바뀐다. 비교는 같은 데이터 상태에서 연달아 돈다. +- 같은 사용자 묶음으로 피크를 여러 번 돌리면 사용자당 참여 방이 계속 늘어난다(생성·가입). 비교마다 새 `RUN_ID` 로 시드하거나 순서를 붙여 돈다. +- Hikari `connection-timeout` 은 env 로 넣으면 「pool is sealed」로 기동 실패한다(풀이 먼저 시작됨). +- 비교 중에는 오토스케일 최대를 2로 묶어 2대에서 출발시키고, 시험 직전에 4로 푼다(앞 시험이 늘린 태스크가 남으면 출발점이 달라진다). +- 측정 중 로컬에서 gradle·docker 빌드를 돌리지 않는다(부하 발생기와 CPU 를 나눠 쓴다). +- 액세스 토큰은 30분이다. VU 가 오래 쥐는 세션은 `mine()` 이 25분마다 갱신한다 — 안 그러면 30분 넘는 시험 끝물이 전부 401 이다. +- 부하를 한 번 크게 받은 태스크는 힙을 다 커밋한 채 메모리 90% 에 머문다(누수 아님 — 장시간 시험에서 평평). 메모리 75% 오토스케일 정책은 그 뒤 CPU 가 한가해도 태스크를 늘린다. 태스크 수 비교는 새로 뜬 태스크에서 한다. +- 피크에서 X-Ray 전송이 계정 한도(429)에 걸리고 에이전트 스팬 큐가 넘친다 — `ruleup-api` 샘플링 규칙이 100% 면 그렇다. diff --git a/infra/loadtest/k6/lib.js b/infra/loadtest/k6/lib.js new file mode 100644 index 00000000..97bc8383 --- /dev/null +++ b/infra/loadtest/k6/lib.js @@ -0,0 +1,180 @@ +// RuleUp 부하테스트 공통 — 계정·요청 헬퍼. 비밀값은 환경변수로만 받는다(저장소에 두지 않는다). +// BASE_URL 기본 https://staging-api.ruleup.co.kr +// ALB_IP 있으면 BASE_URL 호스트를 이 IP 로 직접 보낸다(Cloudflare 를 건너뛰고 서버만 잰다. TLS SNI 는 호스트 그대로) +// LT_SECRET stg DEV_TOKEN_SECRET — 로그인 우회(OAUTH_LOAD_TEST_ENABLED)·dev 토큰에 쓴다 +// RUN_ID 계정 식별자 접두어(같은 RUN_ID 면 같은 가짜 사용자를 다시 쓴다) +import http from 'k6/http'; +import { check, fail } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +export const BASE = __ENV.BASE_URL || 'https://staging-api.ruleup.co.kr'; +export const SECRET = __ENV.LT_SECRET || ''; +export const RUN_ID = __ENV.RUN_ID || 'lt1'; +const HOST = BASE.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + +export function hosts() { + return __ENV.ALB_IP ? { [HOST]: __ENV.ALB_IP } : {}; +} + +export const apiErrors = new Counter('ruleup_api_errors'); +export const businessRejects = new Counter('ruleup_business_rejects'); +// 업무 성공 — 5xx 0건이어도 429·409 로 거절만 했다면 사용자는 아무것도 못 한 것이다. 실제로 저장·가입된 건수를 따로 센다. +export const businessOk = new Counter('ruleup_business_ok'); +const BUSINESS = ['auth.oauth_kakao', 'auth.signup', 'challenge.create', 'challenge.join', 'verification.sync', 'challenge.draft_ai']; + +const JSON_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'ruleup-loadtest/1.0 (k6)' }; + +export function auth(token, extra) { + return { headers: Object.assign({}, JSON_HEADERS, { Authorization: `Bearer ${token}` }, extra || {}) }; +} + +/** 응답 확인 — 2xx 가 아니면 오류로 세고, 코드·상태를 태그로 남긴다(로그 대조용). */ +export function ok(res, name, allowed) { + const okStatus = (allowed || [200, 201]).includes(res.status); + let code = ''; + if (!okStatus) { + try { code = (res.json('error.code') || res.json('code') || '') + ''; } catch (e) { code = 'non_json'; } + if (res.status >= 500 || res.status === 0) apiErrors.add(1, { api: name, status: `${res.status}`, code }); + else businessRejects.add(1, { api: name, status: `${res.status}`, code }); + } + if (BUSINESS.includes(name)) { + if (res.status === 200 || res.status === 201) businessOk.add(1, { api: name }); + // 허용한 거절(sync 10초 간격 429·가입 409 등)도 업무상 처리되지 않은 건이라 따로 센다 — 빨라질수록 늘 수 있다. + else if (okStatus) businessRejects.add(1, { api: name, status: `${res.status}`, code: 'allowed' }); + } + check(res, { [`${name} ${(allowed || [200, 201]).join('/')}`]: () => okStatus }); + return okStatus; +} + +export function deviceInfo() { + return { platform: 'ANDROID', osVersion: '14', sdkInt: 34, deviceModel: 'SM-S921N', manufacturer: 'samsung', + lowRam: false, versionName: '1.0.0', versionCode: 100, country: 'KR', timeZone: 'Asia/Seoul', ramMb: 8192 }; +} + +function ids(n) { + return { code: `loadtest-${RUN_ID}-${n}`, installationId: `lt-inst-${RUN_ID}-${n}`, deviceId: `lt-dev-${RUN_ID}-${n}` }; +} + +/** 실제 로그인 경로(카카오) — 우회 스위치가 IdP 호출만 뺀다. */ +export function oauthLogin(n) { + const i = ids(n); + const res = http.post(`${BASE}/api/v1/auth/oauth/kakao`, JSON.stringify({ + code: i.code, codeVerifier: SECRET, redirectUri: null, installationId: i.installationId, deviceId: i.deviceId, + deviceInfo: deviceInfo(), permissions: { postNotifications: 'GRANTED', location: 'GRANTED', camera: 'GRANTED', screenTime: 'GRANTED' }, + }), { headers: JSON_HEADERS, tags: { api: 'auth.oauth_kakao' } }); + ok(res, 'auth.oauth_kakao'); + return res; +} + +export function signup(n, signupToken) { + const i = ids(n); + const agreed = { agreed: true }; + const res = http.post(`${BASE}/api/v1/auth/signup`, JSON.stringify({ + signupToken, installationId: i.installationId, deviceId: i.deviceId, deviceInfo: deviceInfo(), + nickname: `lt${RUN_ID.slice(-3)}${n}`.slice(0, 12), interestCategories: ['EXERCISE', 'STUDY'], birthDate: '1998-03-21', gender: 'MALE', + agreements: { termsOfService: agreed, privacyPolicy: agreed, locationService: agreed, marketing: agreed, event: agreed }, + }), { headers: JSON_HEADERS, tags: { api: 'auth.signup' } }); + ok(res, 'auth.signup'); + return res; +} + +/** 로그인(없으면 가입 후 다시 로그인)해서 {access, refresh} 를 돌려준다. */ +export function session(n) { + let res = oauthLogin(n); + if (res.status !== 200) fail(`login ${n} ${res.status} ${res.body && res.body.slice(0, 200)}`); + if (res.json('data.isNewUser')) { + const s = signup(n, res.json('data.signupToken')); + if (s.status !== 200) fail(`signup ${n} ${s.status} ${s.body && s.body.slice(0, 200)}`); + res = oauthLogin(n); + } + return { access: res.json('data.accessToken'), refresh: res.json('data.refreshToken'), userId: res.json('data.user.id'), at: Date.now() }; +} + +export function refresh(s) { + const res = http.post(`${BASE}/api/v1/auth/refresh`, JSON.stringify({ refreshToken: s.refresh }), + { headers: JSON_HEADERS, tags: { api: 'auth.refresh' } }); + if (ok(res, 'auth.refresh')) { + s.access = res.json('data.accessToken'); + s.refresh = res.json('data.refreshToken'); + s.at = Date.now(); + } + return res; +} + +/** 앱 첫 화면 — 플랫폼·버전은 헤더로 보낸다(쿼리로 보내면 400). 로그인 전에도 부른다. */ +export function intro() { + const res = http.get(`${BASE}/api/v1/intro`, { headers: Object.assign({}, JSON_HEADERS, { platform: 'ANDROID', appVersionCode: '100' }), + tags: { api: 'intro' } }); + ok(res, 'intro'); + return res; +} + +// 허용한 4xx(예: 409 이미 가입)는 k6 실패율에 넣지 않는다 — 진짜 실패는 ruleup_api_errors(5xx·연결 실패)로 본다. +function expect(allowed) { + return { responseCallback: http.expectedStatuses(...(allowed || [200, 201])) }; +} + +export function get(s, path, name, allowed) { + const res = http.get(`${BASE}${path}`, Object.assign(auth(s.access), { tags: { api: name } }, expect(allowed))); + ok(res, name, allowed); + return res; +} + +export function post(s, path, body, name, extraHeaders, allowed) { + const res = http.post(`${BASE}${path}`, body == null ? null : JSON.stringify(body), + Object.assign(auth(s.access, extraHeaders), { tags: { api: name } }, expect(allowed))); + ok(res, name, allowed); + return res; +} + +export function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +/** 템플릿 초안 → 생성. 초안을 고치지 않고 그대로 보내면 모더레이션 대상이 아니다(실사용의 대부분). */ +export function createFromTemplate(s, templateId, overrides) { + const d = post(s, '/api/v1/challenges/recommendation/by-template', { templateId }, 'challenge.draft_template'); + if (d.status !== 200) return null; + const draft = d.json('data.draft'); + const body = Object.assign({ + draftId: d.json('data.draftId'), title: draft.title, description: draft.description, category: draft.category, + mode: draft.mode, visibility: draft.visibility, rankingVisible: draft.rankingVisible, capacity: draft.capacity, + minTier: draft.minTier, period: { start: draft.period.start, end: draft.period.end }, weeklyCount: draft.weeklyCount, + params: (draft.params || []).map((p) => ({ key: p.key, value: p.value != null ? `${p.value}` : p.defaultValue != null ? `${p.defaultValue}` : null })), + verification: { type: draft.verification.type, method: draft.verification.method }, + penalties: { watcher: false }, imageUrl: null, + }, overrides || {}); + const c = post(s, '/api/v1/challenges', body, 'challenge.create', { 'Idempotency-Key': uuid() }); + return c.status === 200 || c.status === 201 ? c.json('data.challengeId') || c.json('data.id') : null; +} + +/** 탐색·가입 부하에 쓰는 공개 그룹 방 — 제목·설명은 초안 그대로라 모더레이션 대상이 아니다. */ +export const GROUP_PUBLIC = { mode: 'GROUP', visibility: 'PUBLIC', capacity: 100 }; // 정원은 5·30·100·300·무제한만 + +export const SLO_MS = { // 노션 SLO 지연 목표(p95) — alarm-catalog.yaml operations 와 같다 + 'auth.oauth_kakao': 3000, 'auth.refresh': 1000, 'challenge.explore': 1000, 'challenge.detail': 1000, + 'challenge.join': 1500, 'verification.sync': 3000, 'verification.today': 1000, 'verification.progress': 1000, + // DAU 2만 재측정(2026-10)에서 추가한 핵심 흐름 — 사용자 체감 기준 + 'me.home': 1000, 'room.home': 1000, 'room.ranking': 1000, 'challenge.list': 1000, 'challenge.trending': 1000, + 'challenge.draft_template': 1000, 'challenge.create': 2000, 'auth.signup': 3000, 'challenge.draft_ai': 5000, +}; + +// 요약에 API 별 지연이 나오려면 태그별 임계값이 있어야 한다(k6 는 임계값이 걸린 태그만 따로 집계한다). +const ALL_APIS = ['intro', 'me.home', 'users.me', 'challenge.list', 'notification.list', 'notification.settings', 'challenge.categories', + 'challenge.trending', 'challenge.recommendations', 'challenge.draft_template', 'challenge.create', 'challenge.draft_ai', 'room.home', + 'room.ranking', 'challenge.members', 'verification.setup', 'verification.intro', 'profile', 'me.stats', 'me.tier', 'me.calendar', 'auth.signup']; + +export function thresholds(extra) { + const t = { http_req_failed: ['rate<0.005'], ruleup_api_errors: ['count<1'] }; + for (const [api, ms] of Object.entries(SLO_MS)) t[`http_req_duration{api:${api}}`] = [`p(95)<${ms}`]; + for (const api of ALL_APIS) if (!t[`http_req_duration{api:${api}}`]) t[`http_req_duration{api:${api}}`] = ['p(95)<60000']; + // 요약에 업무 성공·거절이 API·상태별로 나오게(k6 는 임계값이 걸린 태그 조합만 따로 집계한다) — 판정용이 아니다. + for (const api of BUSINESS) t[`ruleup_business_ok{api:${api}}`] = ['count>=0']; + for (const [api, st] of [['verification.sync', '429'], ['challenge.join', '409'], ['challenge.join', '403'], ['auth.refresh', '401'], + ['challenge.create', '409'], ['challenge.create', '400'], ['challenge.draft_ai', '429']]) + t[`ruleup_business_rejects{api:${api},status:${st}}`] = ['count>=0']; + return Object.assign(t, extra || {}); +} diff --git a/infra/loadtest/k6/ruleup.js b/infra/loadtest/k6/ruleup.js new file mode 100644 index 00000000..e6c6bc91 --- /dev/null +++ b/infra/loadtest/k6/ruleup.js @@ -0,0 +1,274 @@ +// RuleUp 부하·성능 시나리오. SCENARIO 로 고른다. +// seed 가짜 사용자 USERS 명 가입 + 각자 공개 그룹 방 1개 생성 + 남의 방 2곳 가입(이후 시나리오의 데이터) +// login 아침 몰림 — 기존 사용자 로그인 + 리프레시를 초당 RATE 건 +// browse 앱 열기·탐색·상세·마이페이지 읽기 묶음, 동시 사용자 VUS 까지 램프 +// create 템플릿 초안 → 생성을 초당 RATE 건(실사용의 대부분) +// aidraft AI 초안(실제 LLM) → 생성, 동시 VUS 명 +// joinhot 인기 방 하나에 동시 가입(참여자 수·정원 경합) +// sync 자동 인증 신호 업로드(아침 8시 몰림) +// peak 실사용 비율로 섞은 피크(평상시 → 피크 → 2배 스트레스) +// breakpoint 오류·지연이 무너질 때까지 단계적으로 올린다 +// 공통: k6 run -e LT_SECRET=... -e RUN_ID=lt1 -e ALB_IP=... -e SCENARIO=browse infra/loadtest/k6/ruleup.js +import { sleep, check } from 'k6'; +import http from 'k6/http'; +import exec from 'k6/execution'; +import * as L from './lib.js'; + +const SC = __ENV.SCENARIO || 'browse'; +const USERS = Number(__ENV.USERS || 3000); +const RATE = Number(__ENV.RATE || 10); +const VUS = Number(__ENV.VUS || 100); +const DUR = __ENV.DURATION || '5m'; +const ROOM_EVERY = Number(__ENV.ROOM_EVERY || 1); +const TEMPLATES = (__ENV.TEMPLATES || '1001,1002,1209,1501').split(',').map(Number); + +function think(min, max) { sleep(min + Math.random() * (max - min)); } +function pick(a) { return a[Math.floor(Math.random() * a.length)]; } +function userNo() { return Math.floor(Math.random() * USERS); } + +// VU 마다 로그인 상태를 들고 간다(실제 앱처럼 매 요청마다 로그인하지 않는다). +let me = null; +function mine() { + if (!me) me = L.session(userNo()); + // 액세스 토큰은 30분짜리다 — 앱처럼 만료 전에 갱신한다(안 하면 30분 넘는 시험 끝물이 전부 401). + else if (Date.now() - me.at > 25 * 60 * 1000) L.refresh(me); + return me; +} + +const scenarios = { + seed: { executor: 'shared-iterations', vus: VUS, iterations: USERS, maxDuration: '60m', exec: 'seed' }, + seedjoin: { executor: 'shared-iterations', vus: VUS, iterations: USERS, maxDuration: '60m', exec: 'seedjoin' }, + login: { executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 400, exec: 'login', + stages: [{ target: RATE, duration: '1m' }, { target: RATE, duration: DUR }, { target: 0, duration: '30s' }] }, + browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, + stages: [{ target: VUS, duration: '2m' }, { target: VUS, duration: DUR }, { target: 0, duration: '30s' }] }, + create: { executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: DUR, preAllocatedVUs: 30, maxVUs: 300, exec: 'create' }, + aidraft: { executor: 'constant-vus', vus: VUS, duration: DUR, exec: 'aidraft' }, + joinhot: { executor: 'per-vu-iterations', vus: VUS, iterations: 1, exec: 'joinhot', maxDuration: '5m' }, + sync: { executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 400, exec: 'sync', + stages: [{ target: RATE, duration: '1m' }, { target: RATE, duration: DUR }] }, +}; + +function peakMix(scale) { + // DAU 1만, 피크 동시 500 기준. scale 로 배수(2 = 스트레스). + const s = (n) => Math.max(1, Math.round(n * scale)); + return { + browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, + stages: [{ target: s(150), duration: '2m' }, { target: s(150), duration: '3m' }, // 평상시 + { target: s(400), duration: '2m' }, { target: s(400), duration: DUR }, // 피크 + { target: 0, duration: '1m' }] }, + login: { executor: 'ramping-arrival-rate', exec: 'login', timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 500, startRate: 1, + stages: [{ target: s(3), duration: '5m' }, { target: s(15), duration: '2m' }, { target: s(15), duration: DUR }, { target: 0, duration: '1m' }] }, + sync: { executor: 'ramping-arrival-rate', exec: 'sync', timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 500, startRate: 1, + stages: [{ target: s(4), duration: '5m' }, { target: s(20), duration: '2m' }, { target: s(20), duration: DUR }, { target: 0, duration: '1m' }] }, + create: { executor: 'ramping-arrival-rate', exec: 'create', timeUnit: '1s', preAllocatedVUs: 20, maxVUs: 200, startRate: 0, + stages: [{ target: s(1), duration: '5m' }, { target: s(3), duration: '2m' }, { target: s(3), duration: DUR }, { target: 0, duration: '1m' }] }, + join: { executor: 'ramping-arrival-rate', exec: 'join', timeUnit: '1s', preAllocatedVUs: 20, maxVUs: 200, startRate: 0, + stages: [{ target: s(1), duration: '5m' }, { target: s(4), duration: '2m' }, { target: s(4), duration: DUR }, { target: 0, duration: '1m' }] }, + }; +} + +// 배수(scale)를 시간에 따라 바꾸는 혼합 — [배수, 걸리는 시간] 목록. 1 = DAU 1만 피크(동시 ~500), 2 = DAU 2만 피크. +// steps 500→1000→1500→500(회복): 오토스케일·RDS 가 단계마다 어떻게 버티는지 +// spike 500→1500 을 30초 안에, 5분 유지 뒤 500 으로: 급증과 복구 시간 +function mixed(plan) { + const st = (base) => plan.map(([scale, d]) => ({ target: Math.max(base === 400 ? 1 : 0, Math.round(base * scale)), duration: d })); + const rate = (base, extra) => Object.assign({ executor: 'ramping-arrival-rate', timeUnit: '1s', startRate: 1, stages: st(base) }, extra); + return { + browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, stages: st(400) }, + login: rate(15, { exec: 'login', preAllocatedVUs: 100, maxVUs: 1500 }), + sync: rate(20, { exec: 'sync', preAllocatedVUs: 100, maxVUs: 1500 }), + create: rate(3, { exec: 'create', preAllocatedVUs: 30, maxVUs: 600 }), + join: rate(4, { exec: 'join', preAllocatedVUs: 30, maxVUs: 600 }), + }; +} +const PLANS = { + steps: [[1, '2m'], [1, '6m'], [2, '2m'], [2, '8m'], [3, '2m'], [3, '8m'], [1, '1m'], [1, '8m']], + spike: [[1, '2m'], [1, '5m'], [3, '30s'], [3, '5m'], [1, '30s'], [1, '8m']], +}; + +function breakpoint() { + const stages = []; + for (const v of [200, 400, 600, 800, 1000, 1300, 1600, 2000]) stages.push({ target: v, duration: '2m' }, { target: v, duration: '2m' }); + return { browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, stages }, + login: { executor: 'constant-arrival-rate', exec: 'login', rate: 5, timeUnit: '1s', duration: '32m', preAllocatedVUs: 50, maxVUs: 300 } }; +} + +const chosen = SC === 'peak' ? peakMix(Number(__ENV.SCALE || 1)) : SC === 'breakpoint' ? breakpoint() + : PLANS[SC] ? mixed(PLANS[SC]) : { [SC]: scenarios[SC] }; + +export const options = { + hosts: L.hosts(), scenarios: chosen, discardResponseBodies: false, + // 한계점은 오류 5% 뿐 아니라 핵심 화면이 SLO 의 3배(3초)를 넘으면 멈춘다 — 5xx 없이 느려지기만 하는 붕괴도 한계다. + thresholds: SC === 'breakpoint' ? { http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '1m' }], + 'http_req_duration{api:me.home}': [{ threshold: 'p(95)<3000', abortOnFail: true, delayAbortEval: '2m' }], + 'http_req_duration{api:challenge.explore}': [{ threshold: 'p(95)<3000', abortOnFail: true, delayAbortEval: '2m' }] } : L.thresholds(), + summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'], +}; + +// ---------------- 시나리오 본문 ---------------- + +export function seed() { + const n = exec.scenario.iterationInTest; + const s = L.session(n); + const tpl = TEMPLATES[n % TEMPLATES.length]; + // 방은 ROOM_EVERY 명 중 1명만 만든다(실사용은 참여자가 방장보다 훨씬 많다). 기본 1 = 모두 만든다. + if (n % ROOM_EVERY === 0) L.createFromTemplate(s, tpl, L.GROUP_PUBLIC); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['RECENT', 'POPULAR', 'PARTICIPANTS'])}&size=20`, 'challenge.explore'); + const items = (ex.status === 200 && ex.json('data.items')) || []; + for (const it of items.filter((x) => !x.joined && !x.isFull && x.eligible).slice(0, 2)) + L.post(s, `/api/v1/challenges/${it.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); +} + +/** 시드 2단계 — 각자 남의 방 2곳에 가입(시드 1단계가 방을 모두 만든 뒤라야 고르게 퍼진다). */ +export function seedjoin() { + const s = L.session(exec.scenario.iterationInTest); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['RECENT', 'POPULAR', 'PARTICIPANTS', 'DEADLINE'])}&size=20&eligibleOnly=true`, 'challenge.explore'); + const items = ((ex.status === 200 && ex.json('data.items')) || []).filter((x) => !x.joined && !x.isFull); + for (let k = 0; k < 2 && items.length; k++) { + const c = items.splice(Math.floor(Math.random() * items.length), 1)[0]; + L.post(s, `/api/v1/challenges/${c.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); + } +} + +export function login() { + const n = userNo(); + const r = L.oauthLogin(n); + if (r.status === 200 && !r.json('data.isNewUser')) { + const s = { access: r.json('data.accessToken'), refresh: r.json('data.refreshToken') }; + L.get(s, '/api/v1/me/home', 'me.home'); + L.refresh(s); + } +} + +export function browse() { + const s = mine(); + // 앱 열기 + L.intro(); + L.get(s, '/api/v1/me/home', 'me.home'); + L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + think(1, 3); + // 탐색 — 정렬·카테고리를 바꿔 가며 2~3 페이지 + L.get(s, '/api/v1/challenge-categories', 'challenge.categories'); + L.get(s, '/api/v1/challenges/trending', 'challenge.trending'); + const sort = pick(['POPULAR', 'RECENT', 'DEADLINE', 'PARTICIPANTS']); + let ex = L.get(s, `/api/v1/challenges/explore?sort=${sort}&size=20`, 'challenge.explore'); + think(1, 3); + let items = (ex.status === 200 && ex.json('data.items')) || []; + const cursor = ex.status === 200 ? ex.json('data.nextCursor') : null; + // 다음 페이지는 같은 정렬로 — 정렬이 다르면 커서가 무효(400 CURSOR_INVALID)다. + if (cursor) L.get(s, `/api/v1/challenges/explore?sort=${sort}&size=20&cursor=${encodeURIComponent(cursor)}`, 'challenge.explore'); + if (items.length) { + const c = pick(items); + L.get(s, `/api/v1/challenges/${c.challengeId}`, 'challenge.detail'); + think(2, 5); + } + // 내 방 + const mineList = L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + const rooms = (mineList.status === 200 && (mineList.json('data.items') || mineList.json('data'))) || []; + if (Array.isArray(rooms) && rooms.length) { + const r = pick(rooms); + const id = r.challengeId || r.id; + if (id) { + L.get(s, `/api/v1/challenges/${id}/room`, 'room.home'); + L.get(s, `/api/v1/challenges/${id}/ranking`, 'room.ranking', [200, 403, 404]); + L.get(s, `/api/v1/challenges/${id}/verifications/today`, 'verification.today', [200, 404, 409]); + } + } + L.get(s, '/api/v1/verifications/progress', 'verification.progress'); + L.get(s, '/api/v1/notifications', 'notification.list'); + think(2, 6); + if (Math.random() < 0.3) { // 마이페이지는 열 명 중 세 명 + L.get(s, '/api/v1/profile', 'profile'); + L.get(s, '/api/v1/me/stats', 'me.stats'); + L.get(s, `/api/v1/me/calendar?month=${new Date().toISOString().slice(0, 7)}`, 'me.calendar'); + } + think(3, 8); +} + +export function create() { + const s = mine(); + L.createFromTemplate(s, pick(TEMPLATES), Math.random() < 0.6 ? L.GROUP_PUBLIC : undefined); +} + +const AI_PROMPTS = ['매일 아침 30분 달리기', '하루 물 2리터 마시기', '주 3회 헬스장 가기', '매일 영어 단어 30개 외우기', + '밤 12시 전에 자기', '하루 만보 걷기', '매일 독서 20쪽', '주 5회 스트레칭 10분']; + +export function aidraft() { + const s = mine(); + const d = L.post(s, '/api/v1/challenges/draft', { description: pick(AI_PROMPTS) }, 'challenge.draft_ai', null, [200, 429]); + check(d, { 'ai draft not fallback': (r) => r.status === 200 && r.json('data.result') !== 'FALLBACK' }); + think(1, 3); +} + +let hotId = null; +/** 모든 VU 가 같은 출발 시각을 보도록 한 번만 정한다(joinhot 전용, 다른 시나리오는 쓰지 않는다). */ +export function setup() { + return { startAt: __ENV.START_DELAY_MS ? Date.now() + Number(__ENV.START_DELAY_MS) : 0 }; +} + +export function joinhot(data) { + // 모두 같은 방 하나에 동시에 들어간다 — 첫 VU 가 방을 만들고 나머지는 explore 최신에서 같은 방을 찾는다. + // 이미 가입한 사용자로 로그인해 두고, 시작 뒤 START_DELAY_MS 시점에 일제히 들어간다 — 가입부터 하면 가입이 몰려 + // 정원 경합 전에 풀 대기로 503 이 난다(2차 J1: 300명 중 46명만 방까지 감). 가입 없는 사용자 번호는 HOT_USER_BASE 부터. + const s = L.session(Number(__ENV.HOT_USER_BASE || USERS) + __VU); + if (!hotId) hotId = __ENV.HOT_ID; + if (data && data.startAt) sleep(Math.max(0, (data.startAt - Date.now()) / 1000)); + const r = L.post(s, `/api/v1/challenges/${hotId}/members`, null, 'challenge.join', null, [200, 201, 409]); + check(r, { 'join ok or full': (x) => [200, 201, 409].includes(x.status) }); +} + +export function join() { + const s = mine(); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['POPULAR', 'RECENT'])}&size=20&eligibleOnly=true`, 'challenge.explore'); + const items = ((ex.status === 200 && ex.json('data.items')) || []).filter((x) => !x.joined && !x.isFull); + if (!items.length) return; + const c = pick(items); + L.get(s, `/api/v1/challenges/${c.challengeId}`, 'challenge.detail'); + L.post(s, `/api/v1/challenges/${c.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); +} + +let healthVersion = null; +// sync 는 VU 하나가 사용자 한 명을 붙잡으면 그 사람 오늘 신호가 시험 내내 쌓인다(2차 B2~B4: 160명이 하루 ~2,400건 → sync 마다 +// 전부 다시 읽어 판정, 요청당 CPU 1.7배). 실제처럼 사용자당 몇십 분에 한 번이 되게 VU 마다 SYNC_POOL 명을 돌아가며 쓰고, +// SYNC_SALT 로 시험마다 다른 사용자 대역을 고른다. +const SYNC_POOL = Number(__ENV.SYNC_POOL || 1); +const SYNC_SALT = Number(__ENV.SYNC_SALT || 0); +const syncers = []; +let syncTurn = 0; +function syncUser() { + if (SYNC_POOL <= 1) return mine(); + const k = syncTurn++ % SYNC_POOL; + if (!syncers[k]) syncers[k] = L.session((SYNC_SALT + __VU * SYNC_POOL + k) % USERS); + else if (Date.now() - syncers[k].at > 25 * 60 * 1000) L.refresh(syncers[k]); + return syncers[k]; +} + +export function sync() { + const s = syncUser(); + if (!s.consented) { // 실사용자는 자동 인증 전에 건강정보 동의를 한다 — 없으면 신호가 저장·판정되지 않는다 + if (!healthVersion) healthVersion = L.intro().json('data.termsVersions.healthInfo'); + L.post(s, '/api/v1/users/me/agreements', { agreements: [{ type: 'HEALTH_INFO', agreed: true, version: healthVersion }] }, + 'agreement.submit', null, [200, 201, 409]); + s.consented = true; + } + if (!s.syncSession) { + const i = L.post(s, '/api/v1/verifications/intro', { deviceId: `lt-dev-${L.RUN_ID}`, appVersion: '1.0.0', + deviceProfile: { sdkInt: 34, model: 'SM-S921N', lowRam: false } }, 'verification.intro'); + s.syncSession = i.status === 200 ? (i.json('data.sessionId') || 'x') : 'x'; + } + const now = Date.now(); + const KST = 9 * 3600 * 1000; // k6 에는 Intl 시간대가 없다 — KST 자정을 직접 계산한다 + const startOfDay = Math.floor((now + KST) / 86400000) * 86400000 - KST; + const from = new Date(now - 10 * 60 * 1000).toISOString(); + const rid = `lt-${L.RUN_ID}-${exec.vu.idInTest}-${now}`; + const body = { + deviceTimeMillis: now, elapsedRealtimeMillis: 1000000 + exec.vu.iterationInScenario, bootSessionId: 'boot-lt', timeZone: 'Asia/Seoul', + coveredFrom: startOfDay, coveredUntil: now, backlog: true, sessionId: s.syncSession, + signals: [{ type: 'HEALTH', recordId: rid, observedAt: new Date(now).toISOString(), readings: [{ recordId: rid, metric: 'STEPS', value: 120, + unit: 'count', startTime: from, endTime: new Date(now).toISOString(), + origin: { dataOrigin: 'com.sec.android.app.shealth', recordingMethod: 'AUTO', device: 'WATCH' } }] }], + }; + // backlog=true 라도 사용자당 10초 간격 — 같은 VU 가 연달아 보내면 429 는 정상 거절이다. + L.post(s, '/api/v1/verifications/sync', body, 'verification.sync', null, [200, 429]); +} diff --git a/infra/loadtest/k6/smoke.js b/infra/loadtest/k6/smoke.js new file mode 100644 index 00000000..f6eaef2e --- /dev/null +++ b/infra/loadtest/k6/smoke.js @@ -0,0 +1,51 @@ +// 스모크 — 사용자 몇 명이 주요 API 를 한 번씩 부른다. 부하 전에 계약 오류(4xx/5xx)를 먼저 걸러 낸다. +// k6 run -e LT_SECRET=... -e RUN_ID=smoke1 infra/loadtest/k6/smoke.js +import { sleep } from 'k6'; +import * as L from './lib.js'; + +export const options = { hosts: L.hosts(), vus: Number(__ENV.VUS || 3), iterations: Number(__ENV.ITER || 3), + thresholds: { ruleup_api_errors: ['count<1'] } }; + +const TEMPLATE = Number(__ENV.TEMPLATE_ID || 1001); + +export default function () { + const s = L.session(__VU * 1000 + __ITER); + // 앱 첫 화면 + L.intro(); + L.get(s, '/api/v1/me/home', 'me.home'); + L.get(s, '/api/v1/users/me', 'users.me'); + L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + L.get(s, '/api/v1/notifications', 'notification.list'); + L.get(s, '/api/v1/users/me/notification-settings', 'notification.settings'); + L.get(s, '/api/v1/verifications/progress', 'verification.progress'); + // 탐색 + L.get(s, '/api/v1/challenge-categories', 'challenge.categories'); + L.get(s, '/api/v1/challenges/trending', 'challenge.trending'); + const ex = L.get(s, '/api/v1/challenges/explore?sort=POPULAR&size=20', 'challenge.explore'); + L.get(s, '/api/v1/challenges/recommendations', 'challenge.recommendations'); + // 생성 + const id = L.createFromTemplate(s, TEMPLATE, L.GROUP_PUBLIC); + if (id) { + L.get(s, `/api/v1/challenges/${id}`, 'challenge.detail'); + L.get(s, `/api/v1/challenges/${id}/room`, 'room.home'); + L.get(s, `/api/v1/challenges/${id}/members`, 'challenge.members'); + L.get(s, `/api/v1/challenges/${id}/setup`, 'verification.setup'); + L.get(s, `/api/v1/challenges/${id}/verifications/today`, 'verification.today'); + } + // 다른 사람 방 상세·가입 + let items = []; + try { items = ex.json('data.items') || ex.json('data.content') || []; } catch (e) { items = []; } + if (items.length) { + const other = items[Math.floor(Math.random() * items.length)]; + const oid = other.challengeId || other.id; + L.get(s, `/api/v1/challenges/${oid}`, 'challenge.detail'); + } + // 마이페이지 + L.get(s, '/api/v1/profile', 'profile'); + L.get(s, '/api/v1/me/stats', 'me.stats'); + L.get(s, '/api/v1/me/tier', 'me.tier'); + L.get(s, `/api/v1/me/calendar?month=${new Date().toISOString().slice(0, 7)}`, 'me.calendar'); + L.refresh(s); + L.get(s, '/api/v1/users/me', 'users.me'); + sleep(1); +} diff --git a/infra/monitoring/ARCHITECTURE.md b/infra/monitoring/ARCHITECTURE.md index 5c6802c9..79ebd13c 100644 --- a/infra/monitoring/ARCHITECTURE.md +++ b/infra/monitoring/ARCHITECTURE.md @@ -36,11 +36,11 @@ flowchart LR sch --> res[λ resource-monitor
ECS·RES-01~04·RDS·백업] probe2[λ edge-probe 버지니아] cwv[CloudWatch RuleUp/SLO·Edge·Observe·Ops CriticalCapacityFlag] - a1{P0: SLO·API-01·EDGE-01·NATIVE-01·RES
P1: 하트비트·TLS·EDGE-02} + a1{P0: SLO·API-01·EDGE-01·NATIVE-01·RES·OBS-04
P1: 하트비트·TLS·EDGE-02} r53[Route 53 헬스체크 /health/public] sns2[SNS us-east-1] end - probe1[λ edge-probe 오리건] -->|ProbeFailure| cwv + probe1[λ edge-probe 오리건
30초 간격 2회] -->|ProbeFailure·ProbeAttemptFailures| cwv probe1 & probe2 -->|GET /health/public| cf r53 --> cf slo -->|GetMetricData 72h| cwm diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index 558976fe..49912535 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -14,9 +14,9 @@ | 앱 원천 | `observability/sli` · `observability/ops` · `observability/metric` | CUJ 10개 Operation 의 `RuleUp/SLI`(Eligible·Bad·LatencyBad·DurationMs, **0 포함 매분**), `RuleUp/Ops`(JVM·Hikari·Tomcat·하드 메모리·공급자 호출·토큰·캐시·불변식), `RuleUp/DB`(연결·락·데드락·장기 트랜잭션), `RuleUp/Traffic`(FLOW·4xx), `RuleUp/Security`. 태스크마다 1분 버퍼 → PutMetricData | | 공개 점검 경로 | `GET /health/public` | 무인증·고정 본문 `ruleup-ok`·no-store. DB·Redis 를 묻지 않는다 | | 감시 Lambda | `lambdas/` | 버지니아 `slo-calculator`(Rule Flag·버짓·이상 탐지) · `resource-monitor`(ECS·RES·RDS·백업), 오리건·버지니아 `edge-probe` | -| 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04 | +| 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04·DB-10/11·OBS-04. 1분 주기 평가, P0 는 충족 후 1~2분 안에 호출(10/15 리뷰) | | 전달 | `alerting/routing.py` · `slack_channels.py` · `p0_mention.py` · `slack_fallback.py` · `security_events.py` | 서울·버지니아 토픽 → Amazon Q Slack, P0 멘션, 전달 실패 → 이메일, 보안 이벤트 | -| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~5) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business` | +| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~6) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business` | | 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) | | 재무장 | `lambdas/rearm.py` | 저트래픽 예외 — 독립 정상 증거가 있을 때만 P0 Flag=0 1회 + 감사 로그 | @@ -44,10 +44,13 @@ - **분 버킷 DynamoDB 없음** — CloudWatch 가 1분 해상도를 15일 보관하고 GetMetricData 는 지표 개수로 과금돼, 72시간을 매분 다시 읽는 비용이 노션 추정(월 약 130만 조회 단위)과 같다. 30일 버짓은 1시간 해상도로 15분마다. - **DB 수집기는 앱 안**(ShedLock 1분) — 서울 전용 VPC Lambda·읽기 계정 대신. 앱과 함께 멈추는 한계는 DB 지표가 전부 P1 이라 받아들인다. `information_schema.innodb_trx`·`INNODB_METRICS` 는 PROCESS 권한이 필요하다 — 없으면 db-collector 하트비트가 빠져 OBS-01 이 운다. +- **30초 간격 점검은 한 실행 안에서 두 번** — Scheduler 최소 주기가 1분이라 edge-probe 가 30초 쉬고 한 번 더 부른다(함수 timeout 50초). EDGE-01 leaf 는 「그 분 두 번 모두 실패」, EDGE-02 는 「2분(4회) 중 2회」 — 노션의 「3회 중 2회」와 같은 민감도. +- **RDS CPU·메모리 P0(DB-10/11)** — 10/15 리뷰 결정. 카탈로그 §4.2 는 「RDS CPU 99% 는 단독 P0 아님」이었다. 메모리는 resource-monitor 가 Freeable ≤RAM 5% 1분 + Swap 증가를 `DbMemoryCritical` Flag 로 낸다(CloudWatch 지연 탓에 실제 호출은 충족 후 2~3분). +- **OBS-04 는 CloudWatch 로 대신** — Healthchecks.io 미계약. slo-calculator·resource-monitor 하트비트 5분 누락이 P0. 버지니아 리전 전체 장애는 못 잡는다. - **외부 점검은 도쿄 대신 오리건(us-west-2)** — 조직 SCP 가 아시아 리전(ap-northeast-1·3, ap-southeast-1)을 전부 막는다. 버지니아와 다른 리전이라 EDGE-01 의 「두 지점 독립 확인」은 그대로다. - **점검 릴레이 함수 없음** — edge-probe 가 결과를 검증하고 버지니아로 직접 PutMetricData. - **하드 메모리는 태스크가 직접 잰다**(ECS 메타데이터 v4) — Container Insights 태스크 지표 대신. TaskId 차원 없이 같은 시계열에 쓰고 Maximum/Minimum/SampleCount 로 「가장 찬·가장 여유 있는·보고한 태스크 수」를 본다. -- **EXT-01·TOKEN-01·EXT-02 는 경보 metric math**(10분 창·표본 게이트) — 별도 Flag 계산기 없이 같은 의미. +- **EXT-01·TOKEN-01·EXT-02 는 경보 metric math**(5분 창·표본 게이트) — 별도 Flag 계산기 없이 같은 의미. - **OR 조건은 행마다 경보를 따로**(Composite 과금 없이). EDGE-01 만 AND Composite. - **사전 점검의 계측 불일치는 ALB 요청 수 대신 앱의 `RuleUp/Traffic ApiRequestCount`**(`/api/**`, 합성 점검 제외)와 비교 — ALB 는 경로를 못 나눠 Route 53·외부 점검의 `/health/public` 호출(분당 십수 건)이 섞이고, 트래픽이 적을 때 늘 「계측 누락」으로 걸렸다. - **OPS-4 Redis 칸에 히트율** — 노션 §4.2 에는 없다. 경보 없이 화면에만. 앱 캐시(`@Cacheable`)는 Caffeine 이라 탐색 ZSET·rate limit 같은 Redis 직접 조회의 적중률이다. @@ -56,8 +59,8 @@ | 항목 | 막는 것 | 그 전까지 | |---|---|---| -| PagerDuty(G01·G03), OBS-03/05/06 | 계약·계정 없음 | P0 = Slack 긴급 채널 멘션. 원장 `sns.pagerduty` 에 토픽 이름을 넣으면 P0 경보에 붙는다 | -| OBS-04 외부 watchdog | Healthchecks.io 없음 | 원장 `healthchecks.obs04_ping_url` 이 생기면 계산기가 바로 ping. OBS-01(slo-calculator) P1 | +| PagerDuty 개통(G01·G03), OBS-03/05/06 | 계약·계정·API 키 없음 | 설정은 `alerting/pagerduty.py` 로 준비됨(1차 → 10분 미ACK → 팀원). 개통 전 P0 = Slack 긴급 채널 멘션 | +| OBS-04 외부 watchdog | Healthchecks.io 없음 | CloudWatch 하트비트 5분 누락 P0 로 대신(같은 리전 장애는 못 잡음). 원장 `healthchecks.obs04_ping_url` 이 생기면 계산기가 외부 ping 도 한다 | | APP-01~04·ERR-01 (G06) | Crashlytics·Sentry 연결 미검증 | SLO·ERROR 로그 패널 | | AUTH-01 | 지점별 격리 시험 계정·Secrets | C01 SLO(실사용 로그인) | | COST-01/02 (G07) | 승인된 일 예산 없음 | — (LLM 호출 실패·지연은 EXT-01/02) | @@ -67,8 +70,8 @@ ## G02·G03 재현 (stg) -- API-01: stg 에 5분간 같은 Operation 으로 실패 ≥10건(예: 계약 회귀 400 을 내는 잘못된 sync 본문) → `ruleup-stg-P0-API-01-sync` ALARM → 정상 요청 20건 이상 → Flag=0 → OK 2회. -- 입력 중단 유지: slo-calculator 스케줄을 끄면 P0 는 ALARM 그대로(ignore), OBS-01(slo-calculator) 이 3분 뒤 운다. +- API-01: stg 에 2분간 같은 Operation 으로 요청 ≥20·실패 ≥10건(예: 계약 회귀 400 을 내는 잘못된 sync 본문) → `ruleup-stg-P0-API-01-sync` ALARM → 정상 요청 20건 이상 → Flag=0 → OK 2회. +- 입력 중단 유지: slo-calculator 스케줄을 끄면 P0 는 ALARM 그대로(ignore), OBS-01(slo-calculator) 이 2분 뒤, OBS-04(P0) 가 5분 뒤 운다. - 저표본 재무장: `lambdas/rearm.py stg --operation sync --rule API-01 --incident … --approver … --evidence …`. - EDGE-01: 한 지점만 실패 → EDGE-02 만, 두 지점 → EDGE-01. diff --git a/infra/monitoring/alarm-catalog.yaml b/infra/monitoring/alarm-catalog.yaml index d46d8061..3d01e407 100644 --- a/infra/monitoring/alarm-catalog.yaml +++ b/infra/monitoring/alarm-catalog.yaml @@ -14,7 +14,7 @@ schema_version: 2 status: proposed resource_registry: infra/monitoring/env/.json -pager: PagerDuty # 미계약 — 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만 +pager: PagerDuty # alerting/pagerduty.py — 1차 담당 → 10분 미ACK → 팀원(2회 반복). 미계약이라 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만 watchdog: Healthchecks.io # 미계약 — 원장 healthchecks.* 가 null slo: window_days: 30 @@ -180,6 +180,14 @@ alarms: - id: DB-08 status: active severity: P1 + - id: DB-10 + status: active + severity: P0 + notes: 10/15 리뷰 — RDS CPU ≥99% 1분(AWS/RDS 네이티브). 노션 카탈로그 §4.2 의 「RDS CPU 99% 는 단독 P0 아님」을 뒤집었다 + - id: DB-11 + status: active + severity: P0 + notes: 10/15 리뷰 — Freeable ≤RAM 5% 1분 + Swap 증가(resource-monitor DbMemoryCritical, missing=ignore). DB-02 는 10% 5분 P1 - id: DB-09 status: followup severity: P1 @@ -309,9 +317,9 @@ alarms: severity: P0 notes: PagerDuty 시험 서비스·webhook 수신기·Healthchecks.io 가 모두 필요 - id: OBS-04 - status: blocked + status: active severity: P0 - notes: 계산기는 원장 healthchecks.obs04_ping_url 이 생기면 바로 ping 한다(lambdas/cwio.ping) + notes: Healthchecks.io 전까지 CloudWatch 대체 — slo-calculator·resource-monitor 하트비트 5분 누락(breaching). 버지니아 전체 장애는 못 잡는다(수용 리스크). 원장 healthchecks.obs04_ping_url 이 생기면 계산기가 외부 ping 도 한다 - id: OBS-05 status: blocked severity: P1 diff --git a/infra/monitoring/alerting/README.md b/infra/monitoring/alerting/README.md index 881b0a6e..623bab08 100644 --- a/infra/monitoring/alerting/README.md +++ b/infra/monitoring/alerting/README.md @@ -20,7 +20,7 @@ Slack 전달은 Amazon Q Developer in chat applications(워크스페이스 `ASM | `catalog.py` · `p0_p1.py ` | 노션 알람 카탈로그의 경보 정의(원본)·적용, 옛 이름 경보 정리. `tests_catalog.py` 가 등급·누락 정책·리전 계약을 시험 | | `routing.py` | 중앙 감시 리전(us-east-1) 토픽·Slack 구성·P0 멘션 규칙 | | `security_events.py` | SEC-02/03/04 EventBridge 규칙 | -| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup--ops` — OPS-1~5 순서(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 | +| `dashboard.py [prod\|stg]` | 서버(운영) 대시보드 `ruleup--ops` — OPS-1~6 순서(OPS-6 JVM 런타임: 힙 영역·GC 정지·스레드·클래스·버퍼)(런북이 패널 번호로 가리킨다), 4xx 로그 표, 최근 ERROR 로그 | | `observe_dashboard.py [prod\|stg]` | 감시망 대시보드 `ruleup--observe`(us-east-1) — OBS-1~4 | | `business_dashboard.py` | 사용자 지표 대시보드 `ruleup-prod-business` — ① 로그인·가입 ② 챌린지 참여(초안 경로·생성·초안 수정률·탐색/초대 가입) ③ 인증(자동 vs 수동). 묶음마다 기간 합계·비율 숫자 칸. prod 전용 | | `p0_mention.py` | P0 → 긴급 채널 커스텀 알림(멘션)·복구 스레드, 긴급 채널 구성을 `ruleup-prod-p0-slack` 으로 | @@ -43,12 +43,14 @@ EventBridge 규칙이 서울·버지니아 모두 `ruleup-prod-P0-` 접두어로 | 등급 | ID | 조건(요약) | 리전 | |---|---|---|---| | P0 | SLO-01 / SLO-02 | BR(1h·5m) ≥14.4 / BR(6h·30m) ≥6, 짧은 창 표본·실패 게이트 | 버지니아 | -| P0 | API-01 | 5분 실패율 ≥50%·요청 ≥20·실패 ≥10 | 버지니아 | -| P0 | EDGE-01 | 오리건·버지니아 공개 점검 둘 다 1분×3 실패 | 버지니아 | -| P0 | NATIVE-01 / NATIVE-02 | Route 53 3분 / ALB Target 5xx ≥50%(5분·요청 ≥20·오류 ≥10) | 버지니아 / 서울 | +| P0 | API-01 | 2분 실패율 ≥50%·요청 ≥20·실패 ≥10 | 버지니아 | +| P0 | EDGE-01 | 오리건·버지니아 공개 점검 둘 다 30초 간격 2회 연속 실패 | 버지니아 | +| P0 | NATIVE-01 / NATIVE-02 | Route 53 1분 / ALB Target 5xx ≥50%(2분·요청 ≥20·오류 ≥10) | 버지니아 / 서울 | +| P0 | DB-10 / DB-11 | RDS CPU ≥99% 1분 / Freeable ≤RAM 5% 1분 + Swap 증가 (10/15 리뷰) | 서울 | +| P0 | OBS-04 | slo-calculator·resource-monitor 하트비트 5분 누락 (Healthchecks.io 전 대체) | 버지니아 | | P0 | RES-01~04 (`res_enabled`) | 마지막 정상 태스크까지 하드 메모리 ≥99% · OOM 반복 + 정상 ≤1 · CPU 99% + 확장 불능 + 포화 · RDS 소진 임박 + 확장 불가 | 버지니아 | -| P1 | SLO-03/04 · LAT-01 · API-02 | 느린 소진 · 지연 BR ≥3 · 실패율 ≥5% | 버지니아 | -| P1 | ECS-01~09 · JVM · POOL · THREAD | 카탈로그 §4·§5 (CPU 80/90/99%, 하드 메모리 80/90%, OOM ≥1 …) | 서울 | +| P1 | SLO-03/04 · LAT-01 · API-02 | 느린 소진 · 지연 BR ≥3 · 3분 실패율 ≥5% | 버지니아 | +| P1 | ECS-01~09 · JVM · POOL · THREAD | 카탈로그 §4·§5 (CPU 80% 3분/90% 1분/99%, 하드 메모리 80% 3분/90% 1분, OOM ≥1 즉시 …) | 서울 | | P1 | DB-01~08 · REDIS · QUEUE · JOB · EXT · TOKEN | 카탈로그 §5·§6 | 서울 | | P1 | SEC-01/05 · INV-01 · BACKUP-01~04 · OBS-01 | 카탈로그 §6.1·§7 | 서울·버지니아 | | P1 | SEC-02/03/04 | EventBridge 규칙(`security_events.py`) | 서울·버지니아 | @@ -60,5 +62,14 @@ EventBridge 규칙이 서울·버지니아 모두 `ruleup-prod-P0-` 접두어로 누락 정책: 커스텀 P0·EDGE leaf = `ignore`(입력이 끊겨도 정상으로 돌아가지 않는다 — 재무장은 정상 Flag=0 또는 `../lambdas/rearm.py`), 하트비트·NATIVE-01 = `breaching`, 나머지 = `notBreaching`. -**온콜**: Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막는다. 노션은 PagerDuty 를 채택했지만 아직 계약 전이라, -지금 P0 의 호출 수단은 긴급 채널 멘션(`p0_mention.py`·`routing.py`)뿐이다. 원장 `sns.pagerduty` 가 생기면 P0 경보 AlarmActions 에 붙는다. +**온콜**(`pagerduty.py`, prod 만): **1차 담당(이성은)에게 먼저 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(2회)**. +PagerDuty 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결 안 되면 다시 울림, 시간 기반 자동 종료 끔. +CloudWatch → 같은 리전 SNS `ruleup-prod-pagerduty`(서울·버지니아) → PagerDuty 공식 CloudWatch 연동. 경보가 OK 로 돌아오면 사건이 닫힌다. +Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막아 쓰지 않는다. 긴급 채널 멘션(`p0_mention.py`)은 보조로 그대로 둔다. + +개통 순서(PagerDuty 계정·API 키가 생긴 뒤): +1. 두 사람 모두 PagerDuty 앱 설치 + 프로필 알림 규칙에 **High-urgency: 즉시 푸시 + 1분 뒤 전화**를 넣는다(서비스가 아니라 사람마다 설정이다). +2. `PAGERDUTY_API_TOKEN=… python3 infra/monitoring/alerting/pagerduty.py setup --primary <내 PD 이메일> --secondary <팀원 PD 이메일>` — 원장 `env/prod.json` 이 채워진다 → 커밋. +3. `python3 infra/monitoring/alerting/p0_p1.py prod` — P0 경보 AlarmActions·OKActions 에 PagerDuty 토픽이 붙는다. +4. 실수신 시험(G01): `pagerduty.py test-trigger` — 시험 경보 `ruleup-prod-P0-ONCALL-TEST` 를 실제 P0 와 같은 경로로 ALARM 으로 바꾼다 → Slack #ruleup-alert·긴급 채널 멘션·PagerDuty 가 함께 오는지, 내 폰을 **ACK 하지 않고** 10분 뒤 팀원 폰이 울리는지 → `pagerduty.py test-resolve`(Slack 복구 알림·사건 종료). + 노션 「대시보드·알림 요약표」 P0 온콜 연결 체크표 12종은 경보별로 `aws cloudwatch set-alarm-state` 로 한 번씩 확인한다(다음 평가에서 OK 로 돌아온다). diff --git a/infra/monitoring/alerting/catalog.py b/infra/monitoring/alerting/catalog.py index 380d4dfd..238e49c2 100644 --- a/infra/monitoring/alerting/catalog.py +++ b/infra/monitoring/alerting/catalog.py @@ -1,9 +1,12 @@ """경보 정의 — 노션 「알람 카탈로그 · SLO 구현 계약」의 ID 그대로. boto3 없이 만들고 시험한다(p0_p1.py 가 적용한다). -등급 규칙(노션 §3·§4.2·「운영 경보 상세」 §2.4) - P0 은 SLO-01/02 · API-01 · EDGE-01 · NATIVE-01/02 · RES-01~04 만이다. CPU·메모리·DB·Redis·SQS 사용률 하나로는 P0 를 만들지 않는다. - 커스텀 P0(SLO·API-01·RES·EDGE leaf)는 missing=ignore — 입력이 끊겼다고 정상으로 돌아가지 않는다. - 상시 하트비트(OBS-01)·NATIVE-01 은 missing=breaching. 나머지 P1·희소 이벤트는 notBreaching. +등급 규칙(노션 §3·§4.2·「운영 경보 상세」 §2.4, 10/15 리뷰) + P0 은 SLO-01/02 · API-01 · EDGE-01 · NATIVE-01/02 · RES-01~04 · DB-10/11 · OBS-04 만이다. + 10/15 리뷰로 RDS CPU ≥99% 1분(DB-10)·RDS 메모리 ≤RAM 5% 1분 + Swap 증가(DB-11)를 P0 로 올렸다(카탈로그 §4.2 의 + 「RDS CPU 99% 는 단독 P0 아님」을 뒤집은 결정). 그 밖의 CPU·메모리·Redis·SQS 사용률 하나로는 P0 를 만들지 않는다. + 커스텀 P0(SLO·API-01·RES·DB-11·EDGE leaf)는 missing=ignore — 입력이 끊겼다고 정상으로 돌아가지 않는다. + 상시 하트비트(OBS-01·OBS-04)·NATIVE-01 은 missing=breaching. 나머지 P1·희소 이벤트는 notBreaching. +평가 타이밍(10/15 리뷰): 모든 경보 1분 주기, P0 는 조건 충족 후 1~2분 안에 호출, P1 은 대부분 5분 이내. SLO 창은 계산식이라 그대로. 이름은 ruleup---[-<대상>] — 긴급 채널 멘션(EventBridge)이 ruleup-prod-P0- 접두어로 잡는다. """ import os @@ -12,7 +15,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lambdas')) import slo_core # noqa: E402 -P0_IDS = {'SLO-01', 'SLO-02', 'API-01', 'EDGE-01', 'NATIVE-01', 'NATIVE-02', 'RES-01', 'RES-02', 'RES-03', 'RES-04'} +P0_IDS = {'SLO-01', 'SLO-02', 'API-01', 'EDGE-01', 'NATIVE-01', 'NATIVE-02', 'RES-01', 'RES-02', 'RES-03', 'RES-04', + 'DB-10', 'DB-11', 'OBS-04'} RB = {'SLO': 'RB-01', 'LAT': 'RB-01', 'API': 'RB-01', 'EDGE': 'RB-01', 'TLS': 'RB-01', 'NATIVE': 'RB-01', 'AUTH': 'RB-04', 'ECS': 'RB-02', 'RES': 'RB-02', 'JVM': 'RB-02', 'POOL': 'RB-02', 'THREAD': 'RB-02', 'DB': 'RB-02', 'REDIS': 'RB-02', 'CACHE': 'RB-02', 'QUEUE': 'RB-03', 'JOB': 'RB-03', 'EXT': 'RB-03', 'TOKEN': 'RB-04', 'FLOW': 'RB-04', 'HTTP': 'RB-04', @@ -95,8 +99,8 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho rules = slo_core.ALL_RULES if reg['rules'].get('slo_rules') == 'all' else tuple(reg['rules']['slo_rules']) texts = {'SLO-01': '가용성 버짓 급속 소진 — BR(1h)·BR(5m) ≥14.4', 'SLO-02': '가용성 버짓 소진 — BR(6h)·BR(30m) ≥6', 'SLO-03': '느린 버짓 소진 — BR(24h)·BR(2h) ≥3', 'SLO-04': '느린 버짓 소진 — BR(72h)·BR(6h) ≥1', - 'LAT-01': '지연 버짓 소진 — 지연 BR(1h)·BR(5m) ≥3', 'API-01': '급성 장애 — 5분 실패율 ≥50%·요청 ≥20·실패 ≥10', - 'API-02': 'API 실패율 ≥5%(5분·요청 ≥20)'} + 'LAT-01': '지연 버짓 소진 — 지연 BR(1h)·BR(5m) ≥3', 'API-01': '급성 장애 — 2분 실패율 ≥50%·요청 ≥20·실패 ≥10', + 'API-02': 'API 실패율 ≥5%(3분·요청 ≥20)'} for op in slo_core.OPERATIONS: for rule in rules: p0 = rule in P0_IDS @@ -110,31 +114,37 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho leaf = f'{p}-edge-leaf-{origin}' # 행동 없는 구성 요소 — P0 접두어를 붙이지 않아 멘션이 따로 울리지 않는다 leaves.append(leaf) out.append((mon, {'kind': 'metric', 'id': 'EDGE-01', 'severity': 'leaf', 'name': leaf, 'params': { - 'AlarmName': leaf, 'AlarmDescription': desc('EDGE-01', f'{origin} 공개 점검 3/3 실패(구성 요소, 단독 알림 없음)'), - **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 3, 3, 1, GE, 'ignore')}})) - add(mon, 'EDGE-02', origin, f'{origin} 한 지점 공개 점검 3회 중 2회 실패 — DNS·TLS·WAF·본문 단계는 edge_probe 로그', - **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 3, 2, 1, GE, 'notBreaching')) + 'AlarmName': leaf, 'AlarmDescription': desc('EDGE-01', f'{origin} 공개 점검 30초 간격 2회 연속 실패(구성 요소, 단독 알림 없음)'), + **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 1, 1, 1, GE, 'ignore')}})) + # 노션은 「최근 3회 중 2회」. 점검이 분당 2회라 2분(4회) 중 2회 실패로 맞췄다 — 한 번 더 기회를 주는 쪽이 아니라 같은 민감도 + add(mon, 'EDGE-02', origin, f'{origin} 한 지점 공개 점검 2분(4회) 중 2회 실패 — DNS·TLS·WAF·본문 단계는 edge_probe 로그', + **metric('RuleUp/Edge', 'ProbeAttemptFailures', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Sum', 120, 1, 1, 2, GE, 'notBreaching')) name = f'{p}-P0-EDGE-01' out.append((mon, {'kind': 'composite', 'id': 'EDGE-01', 'severity': 'P0', 'name': name, 'params': { - 'AlarmName': name, 'AlarmDescription': desc('EDGE-01', '공개 경로 전면 장애 — 두 지점 공개 무인증 점검 모두 1분×3회 실패'), + 'AlarmName': name, 'AlarmDescription': desc('EDGE-01', '공개 경로 전면 장애 — 두 지점 공개 무인증 점검 모두 30초 간격 2회 연속 실패'), 'AlarmRule': ' AND '.join(f'ALARM("{leaf}")' for leaf in leaves)}})) add(mon, 'TLS-01', None, '공개 인증서 잔여 ≤14일', **math('MIN([' + ','.join(f't{i}' for i in range(len(origins))) + '])', [(f't{i}', 'RuleUp/Edge', 'CertificateDaysLeft', {'Env': env, 'Origin': o}, 'Minimum') for i, o in enumerate(origins)], 3600, 14, LE, label='인증서 잔여 일수')) if health_check_id: - add(mon, 'NATIVE-01', None, 'Route 53 공개 헬스체크 비정상 3분 — 계산기·릴레이와 무관한 독립 백스톱', - **metric('AWS/Route53', 'HealthCheckStatus', {'HealthCheckId': health_check_id}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(mon, 'NATIVE-01', None, 'Route 53 공개 헬스체크 비정상 1분 — 계산기·릴레이와 무관한 독립 백스톱', + **metric('AWS/Route53', 'HealthCheckStatus', {'HealthCheckId': health_check_id}, 'Minimum', 60, 1, 1, 1, LT, 'breaching')) if reg['rules'].get('res_enabled'): res_text = {'RES-01': '정상 태스크 전부 하드 메모리 ≥99%(마지막 1대 포함)', 'RES-02': 'OOM ≥2회/5분 + 정상 태스크 ≤1', - 'RES-03': 'CPU ≥99% 3분 + 확장 불가 + 처리 포화', 'RES-04': 'RDS 여유 ≤5% + 15분 내 소진 예상 + 확장 불가'} + 'RES-03': 'CPU ≥99% 1분 + 확장 불가 1분 + 처리 포화', 'RES-04': 'RDS 여유 ≤5% + 15분 내 소진 예상 + 확장 불가'} for rid, text in res_text.items(): add(mon, rid, None, f'임박한 자원 고갈 — {text}. 근거는 resource-monitor 로그 res_eval', **metric('RuleUp/Ops', 'CriticalCapacityFlag', {**base, 'Rule': rid}, 'Maximum', 60, 1, 1, 1, GE, 'ignore')) components = ['slo-calculator', 'resource-monitor'] + [f'edge-{o}' for o in origins] for comp in components: - add(mon, 'OBS-01', comp, f'{comp} 하트비트 3분 누락 — 관측 누락이지 서비스 장애 확정이 아니다', - **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(mon, 'OBS-01', comp, f'{comp} 하트비트 2분 누락 — 관측 누락이지 서비스 장애 확정이 아니다', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 2, 2, 1, LT, 'breaching')) + # 감시망 자체 중단(P0). 노션 설계는 Healthchecks.io 외부 watchdog(OBS-03/04)인데 미계약이라, 그 전까지 같은 리전 + # CloudWatch 로 대신한다 — 버지니아 리전 전체 장애는 이 경보도 함께 죽는다(카탈로그 「수용 리스크」 그대로). + for comp in ('slo-calculator', 'resource-monitor'): + add(mon, 'OBS-04', comp, f'{comp} 핵심 계산 5분 누락 — P0 판정(SLO·API-01·RES)이 멈췄다. 서비스 장애 확정은 아니다', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 5, 5, 1, LT, 'breaching')) if reg['rules'].get('detectors_enabled'): add(mon, 'OBS-01', 'anomaly-detector', '이상 탐지 계산기 하트비트 15분 누락', **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'anomaly-detector'}, 'Minimum', 300, 3, 3, 1, LT, 'breaching')) @@ -146,89 +156,94 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho # ===================== API 리전(서울) ===================== lb, tg = reg['load_balancer'], reg['target_group'] if env == 'prod': - add(api, 'NATIVE-02', None, 'ALB Target 5xx ≥50%(5분·요청 ≥20·오류 ≥10) — 독립 백스톱. ELB 5xx 는 섞지 않는다', + add(api, 'NATIVE-02', None, 'ALB Target 5xx ≥50%(2분·요청 ≥20·오류 ≥10) — 독립 백스톱. ELB 5xx 는 섞지 않는다', **math('IF(r >= 20, IF(FILL(t5, 0) >= 10, 100 * FILL(t5, 0) / r, 0), 0)', [('r', 'AWS/ApplicationELB', 'RequestCount', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Sum'), ('t5', 'AWS/ApplicationELB', 'HTTPCode_Target_5XX_Count', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Sum')], - 300, 50, label='Target 5xx 비율(%)')) + 120, 50, label='Target 5xx 비율(%)')) ecs = {'ClusterName': reg['cluster'], 'ServiceName': reg['service']} add(api, 'ECS-01', None, f'Desired < 승인 최소치({reg["min_expected_tasks"]}) 5분', **metric('RuleUp/Ops', 'DesiredCount', base, 'Minimum', 60, 5, 5, reg['min_expected_tasks'], LT, 'notBreaching')) - add(api, 'ECS-02', None, 'Running < Desired 5분', **metric('RuleUp/Ops', 'TaskShortfall', base, 'Maximum', 60, 5, 5, 1, GE, 'notBreaching')) + add(api, 'ECS-02', None, 'Running < Desired 2분', **metric('RuleUp/Ops', 'TaskShortfall', base, 'Maximum', 60, 2, 2, 1, GE, 'notBreaching')) add(api, 'ECS-03', None, '예상 밖 태스크 종료 ≥3건/5분(기동 실패·크래시 루프)', **metric('RuleUp/Ops', 'UnexpectedStopCount', base, 'Sum', 300, 1, 1, 3, GE, 'notBreaching')) - add(api, 'ECS-04', None, 'OOM 종료 ≥1건/5분(정상 여유가 있으면 P1)', - **metric('RuleUp/Ops', 'OOMStopCount', base, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'ECS-05', '80', 'ECS CPU 평균 ≥80% 5분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'ECS-05', '90', 'ECS CPU 평균 ≥90% 2분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 2, 2, 90, GE, 'notBreaching')) - add(api, 'ECS-06', '80', '가장 찬 태스크 하드 메모리 ≥80% 5분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'ECS-06', '90', '가장 찬 태스크 하드 메모리 ≥90% 2분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 2, 2, 90, GE, 'notBreaching')) - add(api, 'ECS-07', None, '배포 실패 ≥1건/5분 — 자동 롤백 결과 확인', **metric('RuleUp/Ops', 'DeployFailureCount', base, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) + add(api, 'ECS-04', None, 'OOM 종료 ≥1건 즉시(정상 여유가 있으면 P1)', + **metric('RuleUp/Ops', 'OOMStopCount', base, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'ECS-05', '80', 'ECS CPU 평균 ≥80% 3분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'ECS-05', '90', 'ECS CPU 평균 ≥90% 1분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'ECS-06', '80', '가장 찬 태스크 하드 메모리 ≥80% 3분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'ECS-06', '90', '가장 찬 태스크 하드 메모리 ≥90% 1분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'ECS-07', None, '배포 실패 ≥1건 즉시 — 자동 롤백 결과 확인', **metric('RuleUp/Ops', 'DeployFailureCount', base, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) if reg['min_expected_tasks'] >= 2: add(api, 'ECS-08', None, f'정상 타깃 ≤1대 5분(목표 {reg["min_expected_tasks"]}대)', **metric('AWS/ApplicationELB', 'HealthyHostCount', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Maximum', 60, 5, 5, 1, LE, 'notBreaching')) add(api, 'ECS-09', None, 'ECS CPU 평균 ≥99% 1분 — 긴급 P1(CPU 하나로는 P0 아님)', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 1, 1, 99, GE, 'notBreaching')) - add(api, 'JVM-01', None, 'GC 직후 Old 영역 ≥80% 10분', **metric('RuleUp/Ops', 'OldGenAfterGcPct', base, 'Maximum', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'JVM-02', None, 'GC 정지 비율 ≥10%(최근 5분)', **metric('RuleUp/Ops', 'GcPauseRatio5m', base, 'Maximum', 60, 1, 1, 10, GE, 'notBreaching')) + add(api, 'JVM-01', None, 'GC 직후 Old 영역 ≥80% 5분', **metric('RuleUp/Ops', 'OldGenAfterGcPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'JVM-02', None, 'GC 정지 비율(최근 5분 창) ≥10% 3분', **metric('RuleUp/Ops', 'GcPauseRatio5m', base, 'Maximum', 60, 3, 3, 10, GE, 'notBreaching')) pool = {**base, 'Pool': 'ruleup-main'} - add(api, 'POOL-01', None, 'Hikari active/max ≥80% 5분', **metric('RuleUp/Ops', 'MaxPoolActivePct', pool, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'POOL-02', None, '커넥션 대기 스레드 ≥1(5분 중 3분)', **metric('RuleUp/Ops', 'MaxPoolPending', pool, 'Maximum', 60, 5, 3, 1, GE, 'notBreaching')) - add(api, 'POOL-03', None, '커넥션 획득 timeout ≥1건/5분', **metric('RuleUp/Ops', 'PoolTimeoutDelta', pool, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'THREAD-01', None, 'Tomcat busy/max ≥80% 10분', **metric('RuleUp/Ops', 'MaxBusyThreadPct', base, 'Maximum', 60, 10, 10, 80, GE, 'notBreaching')) + add(api, 'POOL-01', None, 'Hikari active/max ≥80% 3분', **metric('RuleUp/Ops', 'MaxPoolActivePct', pool, 'Maximum', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'POOL-02', None, '커넥션 대기 스레드 ≥1(3분 중 2분)', **metric('RuleUp/Ops', 'MaxPoolPending', pool, 'Maximum', 60, 3, 2, 1, GE, 'notBreaching')) + add(api, 'POOL-03', None, '커넥션 획득 timeout ≥1건 즉시', **metric('RuleUp/Ops', 'PoolTimeoutDelta', pool, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'THREAD-01', None, 'Tomcat busy/max ≥80% 5분', **metric('RuleUp/Ops', 'MaxBusyThreadPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) rds = {'DBInstanceIdentifier': reg['rds_instance']} db = {'Env': env, 'Database': reg['rds_instance']} - add(api, 'DB-01', '80', 'RDS CPU ≥80% 10분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'DB-01', '90', 'RDS CPU ≥90% 3분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 3, 3, 90, GE, 'notBreaching')) - add(api, 'DB-02', None, 'Freeable ≤RAM 10% + Swap 증가 10분', **metric('RuleUp/DB', 'DbMemoryPressure', db, 'Maximum', 60, 10, 10, 1, GE, 'notBreaching')) + add(api, 'DB-01', '80', 'RDS CPU ≥80% 5분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'DB-01', '90', 'RDS CPU ≥90% 1분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'DB-10', None, 'RDS CPU ≥99% 1분 — DB 포화로 핵심 API 가 곧 멈춘다(10/15 리뷰로 P0)', + **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 1, 1, 99, GE, 'notBreaching')) + # DbMemoryPressure·DbMemoryCritical 은 자원 감시기가 「N분 내내 + Swap 증가」를 이미 판정한 Flag 라 1/1 로 본다 + add(api, 'DB-02', None, 'Freeable ≤RAM 10% 5분 + Swap 증가', **metric('RuleUp/DB', 'DbMemoryPressure', db, 'Maximum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'DB-11', None, 'Freeable ≤RAM 5% 1분 + Swap 증가 — DB 메모리 고갈 임박(10/15 리뷰로 P0). 근거는 resource-monitor', + **metric('RuleUp/DB', 'DbMemoryCritical', db, 'Maximum', 60, 1, 1, 1, GE, 'ignore')) primary = {'Env': env, 'Database': 'primary'} - add(api, 'DB-03', '75', 'Threads_connected/max_connections ≥75% 10분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 10, 10, 75, GE, 'notBreaching')) - add(api, 'DB-03', '95', 'Threads_connected/max_connections ≥95% 3분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 3, 3, 95, GE, 'notBreaching')) - add(api, 'DB-04', 'read', 'RDS 읽기 지연 ≥100ms 10분', **metric('AWS/RDS', 'ReadLatency', rds, 'Average', 60, 10, 10, 0.1, GE, 'notBreaching')) - add(api, 'DB-04', 'write', 'RDS 쓰기 지연 ≥100ms 10분', **metric('AWS/RDS', 'WriteLatency', rds, 'Average', 60, 10, 10, 0.1, GE, 'notBreaching')) - add(api, 'DB-05', None, 'RDS 여유 저장공간 <15% 15분', **metric('RuleUp/DB', 'FreeStoragePct', db, 'Minimum', 60, 15, 15, 15, LT, 'notBreaching')) + add(api, 'DB-03', '75', 'Threads_connected/max_connections ≥75% 5분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 5, 5, 75, GE, 'notBreaching')) + add(api, 'DB-03', '95', 'Threads_connected/max_connections ≥95% 1분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 1, 1, 95, GE, 'notBreaching')) + add(api, 'DB-04', 'read', 'RDS 읽기 지연 ≥100ms 5분', **metric('AWS/RDS', 'ReadLatency', rds, 'Average', 60, 5, 5, 0.1, GE, 'notBreaching')) + add(api, 'DB-04', 'write', 'RDS 쓰기 지연 ≥100ms 5분', **metric('AWS/RDS', 'WriteLatency', rds, 'Average', 60, 5, 5, 0.1, GE, 'notBreaching')) + add(api, 'DB-05', None, 'RDS 여유 저장공간 <15% 5분', **metric('RuleUp/DB', 'FreeStoragePct', db, 'Minimum', 60, 5, 5, 15, LT, 'notBreaching')) add(api, 'DB-06', None, '최장 락 대기 ≥5초 3분', **metric('RuleUp/DB', 'OldestLockWaitSec', primary, 'Maximum', 60, 3, 3, 5, GE, 'notBreaching')) add(api, 'DB-07', None, '데드락 ≥3건/5분', **metric('RuleUp/DB', 'DeadlockDelta', primary, 'Sum', 300, 1, 1, 3, GE, 'notBreaching')) add(api, 'DB-08', None, '최장 트랜잭션 ≥60초 5분', **metric('RuleUp/DB', 'OldestTransactionSec', primary, 'Maximum', 60, 5, 5, 60, GE, 'notBreaching')) for node in reg['redis_nodes']: n = {'CacheClusterId': node} - add(api, 'REDIS-01', f'{node}-80', 'Redis 엔진 CPU ≥80% 10분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'REDIS-01', f'{node}-90', 'Redis 엔진 CPU ≥90% 3분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 3, 3, 90, GE, 'notBreaching')) - add(api, 'REDIS-02', node, 'Redis used_memory/maxmemory ≥85% 10분 — eviction 정책 확인', - **metric('AWS/ElastiCache', 'DatabaseMemoryUsagePercentage', n, 'Maximum', 60, 10, 10, 85, GE, 'notBreaching')) + add(api, 'REDIS-01', f'{node}-80', 'Redis 엔진 CPU ≥80% 5분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'REDIS-01', f'{node}-90', 'Redis 엔진 CPU ≥90% 1분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'REDIS-02', node, 'Redis used_memory/maxmemory ≥85% 5분 — eviction 정책 확인', + **metric('AWS/ElastiCache', 'DatabaseMemoryUsagePercentage', n, 'Maximum', 60, 5, 5, 85, GE, 'notBreaching')) for q in reg['queues']: - add(api, 'QUEUE-01', q.removeprefix(f'{p}-'), f'{q} 가장 오래된 메시지 >10분 5분 — 소비자 정지·오류', - **metric('AWS/SQS', 'ApproximateAgeOfOldestMessage', {'QueueName': q}, 'Maximum', 60, 5, 5, 600, GT, 'notBreaching')) + add(api, 'QUEUE-01', q.removeprefix(f'{p}-'), f'{q} 가장 오래된 메시지 >10분 1분 — 소비자 정지·오류', + **metric('AWS/SQS', 'ApproximateAgeOfOldestMessage', {'QueueName': q}, 'Maximum', 60, 1, 1, 600, GT, 'notBreaching')) for q in reg['dlqs']: add(api, 'QUEUE-02', q.removeprefix(f'{p}-'), f'{q} 에 재시도 끝에 실패한 메시지 ≥1', **metric('AWS/SQS', 'ApproximateNumberOfMessagesVisible', {'QueueName': q}, 'Maximum', 60, 1, 1, 1, GE, 'notBreaching')) app_ns = f'RuleUp/App/{env}' - add(api, 'JOB-01', 'outbox', 'Outbox 가장 오래된 대기 >10분', - **metric(app_ns, 'outbox.pending.oldest_age_seconds.value', {}, 'Maximum', 60, 1, 1, 600, GT, 'notBreaching')) + add(api, 'JOB-01', 'outbox', 'Outbox(Consumer) 가장 오래된 미처리 >5분', + **metric(app_ns, 'outbox.pending.oldest_age_seconds.value', {}, 'Maximum', 60, 1, 1, 300, GT, 'notBreaching')) add(api, 'JOB-01', 'outbox-dead-lettered', 'Outbox 작업이 재시도 끝에 포기됨(포기 누적 증가)', **math('DIFF(m)', [('m', app_ns, 'outbox.dead_lettered.count.value', {}, 'Maximum')], 300, 0, GT, label='포기 증가')) - add(api, 'JOB-02', 'finalize-overdue', '확정 시각이 1시간 넘게 지난 미확정 판정이 15분째 — 확정 배치 정지·반복 실패', - **metric(app_ns, 'verification.finalize.overdue.value', {}, 'Maximum', 300, 3, 3, 0, GT, 'notBreaching')) + add(api, 'JOB-02', 'finalize-overdue', '확정 시각이 1시간 넘게 지난 미확정 판정이 5분째 — 확정 배치 정지·반복 실패', + **metric(app_ns, 'verification.finalize.overdue.value', {}, 'Maximum', 60, 5, 5, 0, GT, 'notBreaching')) add(api, 'JOB-02', 'finalize-failed', '판정 확정이 건별로 실패해 격리됨(0건이어야 함)', **metric(app_ns, 'verification.finalize.failed.count', {}, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) add(api, 'JOB-02', 'materialize-failed', '무신호 귀속일 채우기 실패 — 그 멤버·날짜의 판정 행이 열리지 않음', **metric(app_ns, 'verification.materialize.failed.count', {}, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) for feature, provider in LLM_PROVIDERS + (('push', 'fcm'),): d = {**base, 'Feature': feature, 'Provider': provider} - add(api, 'EXT-01', f'{feature}-{provider}', f'{provider}({feature}) 기술 실패 ≥10%(10분·호출 ≥20, 429·쿼터 포함)', + add(api, 'EXT-01', f'{feature}-{provider}', f'{provider}({feature}) 기술 실패 ≥10%(5분·호출 ≥20, 429·쿼터 포함)', **math('IF(FILL(c, 0) >= 20, 100 * FILL(f, 0) / c, 0)', [('c', 'RuleUp/Ops', 'ProviderCallCount', d, 'Sum'), ('f', 'RuleUp/Ops', 'ProviderFailureCount', d, 'Sum')], - 600, 10, label='실패율(%)')) + 300, 10, label='실패율(%)')) for feature in ('generation', 'moderation'): d = {**base, 'Feature': feature} - add(api, 'EXT-02', feature, f'LLM({feature}) p95 >15초(10분·표본 ≥20)', + add(api, 'EXT-02', feature, f'LLM({feature}) p95 >15초(5분·표본 ≥20)', **math('IF(FILL(n, 0) >= 20, p95, 0)', [('p95', 'RuleUp/Ops', 'LlmLatencyMs', d, 'p95'), ('n', 'RuleUp/Ops', 'LlmLatencyMs', d, 'SampleCount')], - 600, 15000, GT, label='p95(ms)')) - add(api, 'TOKEN-01', None, '무효 FCM 토큰 ≥20%(10분·발송 ≥20) — 집단 무효화(프로젝트 이전·SENDER_ID_MISMATCH)', + 300, 15000, GT, label='p95(ms)')) + add(api, 'TOKEN-01', None, '무효 FCM 토큰 ≥20%(5분·발송 ≥20) — 집단 무효화(프로젝트 이전·SENDER_ID_MISMATCH)', **math('IF(FILL(a, 0) >= 20, 100 * FILL(i, 0) / a, 0)', [('a', 'RuleUp/Ops', 'ProviderCallCount', {**base, 'Feature': 'push', 'Provider': 'fcm'}, 'Sum'), - ('i', 'RuleUp/Ops', 'InvalidTokenCount', {**base, 'Provider': 'fcm'}, 'Sum')], 600, 20, label='무효 비율(%)')) + ('i', 'RuleUp/Ops', 'InvalidTokenCount', {**base, 'Provider': 'fcm'}, 'Sum')], 300, 20, label='무효 비율(%)')) for service in ('ruleup-admin', 'ruleup-api'): add(api, 'SEC-01', service.removeprefix('ruleup-'), '관리자 로그인 실패 ≥5회/10분 — 침해 확정이 아니다(RB-05 로 조사)', **metric('RuleUp/Security', 'AdminLoginFailedCount', {'Env': env, 'Service': service}, 'Sum', 600, 1, 1, 5, GE, 'notBreaching')) @@ -240,6 +255,6 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho add(api, 'BACKUP-02', None, '복구 가능 시점 지연 >15분(5분×3)', **metric('RuleUp/Backup', 'LatestRestorableLagSec', db, 'Maximum', 300, 3, 3, 900, GT, 'notBreaching')) add(api, 'BACKUP-03', None, '마지막 자동 스냅샷 >26시간', **metric('RuleUp/Backup', 'AutomatedSnapshotAgeSec', db, 'Maximum', 3600, 1, 1, 93600, GT, 'notBreaching')) add(api, 'BACKUP-04', None, '백업 실패 이벤트 ≥1건', **metric('RuleUp/Backup', 'BackupFailureCount', db, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'OBS-01', 'db-collector', 'DB 진단 수집 3분 누락(권한 부족 포함) — 관측 누락', - **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'db-collector'}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(api, 'OBS-01', 'db-collector', 'DB 진단 수집 2분 누락(권한 부족 포함) — 관측 누락', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'db-collector'}, 'Minimum', 60, 2, 2, 1, LT, 'breaching')) return out diff --git a/infra/monitoring/alerting/dashboard.py b/infra/monitoring/alerting/dashboard.py index 706c8ddc..724cdb16 100644 --- a/infra/monitoring/alerting/dashboard.py +++ b/infra/monitoring/alerting/dashboard.py @@ -1,4 +1,4 @@ -"""서버(운영) 대시보드 ruleup--ops — 노션 「운영 경보 상세」 §4.2 패널 순서(OPS-1~5). 재실행 안전. +"""서버(운영) 대시보드 ruleup--ops — 노션 「운영 경보 상세」 §4.2 패널 순서(OPS-1~6, OPS-6 JVM 은 10/15 리뷰). 재실행 안전. prod 는 이 화면 + 사용자 지표(business_dashboard.py) + 감시망(observe_dashboard.py), stg 는 business 없이. 런북(RB-01~03)이 패널 번호로 이 화면을 가리킨다 — 순서를 바꾸면 노션 런북도 고친다. 사용: python dashboard.py [prod|stg]""" @@ -124,7 +124,12 @@ def logs(title, query, width, x): ('종료·OOM·배포 실패', [ops_m('UnexpectedStopCount', 'Sum'), ops_m('OOMStopCount', 'Sum'), ops_m('DeployFailureCount', 'Sum')], None)) rds = lambda m, s='Average': ['AWS/RDS', m, 'DBInstanceIdentifier', REG['rds_instance'], {'stat': s}] dbm = lambda m, d, s='Maximum': ['RuleUp/DB', m, 'Database', d, 'Env', ENV, {'stat': s}] -row(('RDS CPU(%)·연결 사용률(%)', [rds('CPUUtilization'), dbm('DbConnectionPct', 'primary')], {'period': 60}), +row(('RDS CPU(%)·연결 사용률(%)', [rds('CPUUtilization'), dbm('DbConnectionPct', 'primary')], {'period': 60, + 'annotations': {'horizontal': [{'value': 80, 'label': 'DB-01'}, {'value': 99, 'label': 'DB-10 P0'}]}}), + ('RDS 메모리 — 여유(바이트)·Swap·Flag', [rds('FreeableMemory', 'Minimum'), rds('SwapUsage', 'Maximum'), + *[dbm(f, REG['rds_instance'])[:-1] + [{'stat': 'Maximum', 'label': f, 'yAxis': 'right'}] + for f in ('DbMemoryPressure', 'DbMemoryCritical')]], + {'period': 60}), ('RDS 여유 저장공간(%)·소진 예상(분)', [dbm('FreeStoragePct', REG['rds_instance'], 'Minimum'), dbm('StorageMinutesToFull', REG['rds_instance'], 'Minimum') + []], {'period': 60}), ('락 대기·장기 트랜잭션(초)·데드락', [dbm('OldestLockWaitSec', 'primary'), dbm('OldestTransactionSec', 'primary'), @@ -165,6 +170,22 @@ def logs(title, query, width, x): + [sli('AvailabilityBadCount', op, id=f'b{i}', visible=False) for i, op in enumerate(OPS)] + [['RuleUp/Traffic', 'HttpRejectCount', *BASE, 'Status', '400', {'stat': 'Sum', 'label': '400', 'yAxis': 'right'}]], {'period': 60})) +# ---------------- OPS-6 · JVM 런타임 (10/15 리뷰) ---------------- +# 원천은 앱 RuntimeOpsSampler(RuleUp/Ops). 태스크 차원이 없어 Maximum = 가장 나쁜 태스크, ProcessUptimeSec 는 Minimum = 막 재기동한 태스크. +text('## OPS-6 · JVM 런타임 — 힙 영역·GC 정지·스레드·클래스·버퍼 (누수·GC 지연·락 경합)') +row(('힙·Non-heap 사용(MB) — 영역별, 기준선 = 최대 힙', [ops_m('JvmMemoryUsedMb', 'Maximum', 'Area', a, label=a) for a in ('eden', 'survivor', 'old')] + + [ops_m('JvmHeapMaxMb', label='최대 힙')] + + [ops_m('JvmMemoryUsedMb', 'Maximum', 'Area', a, label=a, yAxis='right') for a in ('metaspace', 'codecache')], {'period': 60}), + ('GC — 직후 Old(%)·정지 최대(ms)·정지 횟수', [ops_m('OldGenAfterGcPct', label='Old(GC 후, JVM-01)'), ops_m('GcPauseRatio5m', label='정지 비율 5분(JVM-02)'), + ops_m('JvmGcPauseMaxMs', label='정지 최대(ms)', yAxis='right'), + ops_m('JvmGcPauseCount', 'Sum', label='정지 횟수', yAxis='right')], {'period': 60}), + ('스레드 — 상태별', [ops_m('JvmThreads', 'Maximum', 'State', st, label=st) for st in ('live', 'runnable', 'waiting', 'blocked', 'daemon', 'peak')], + {'period': 60})) +row(('클래스 로딩(개)', [ops_m('JvmClassesLoaded')], {'period': 60}), + ('힙 밖 버퍼(MB) — direct·mapped', [ops_m('JvmBufferUsedMb', 'Maximum', 'Pool', pl, label=pl) for pl in ('direct', 'mapped')], {'period': 60}), + ('프로세스 CPU(%)·업타임(초, 최소)', [ops_m('ProcessCpuPct', label='CPU'), ops_m('ProcessUptimeSec', 'Minimum', label='업타임', yAxis='right')], + {'period': 60})) + text('## 최근 오류 로그') # 대시보드를 열 때마다 Logs Insights 가 이 범위를 훑는다(스캔 GB 과금) — 그래서 ERROR 만, 50줄만. widgets.append({'type': 'log', 'x': 0, 'y': y, 'width': 24, 'height': 8, 'properties': {'title': f'ERROR 로그 ({LOG})', 'region': R, 'view': 'table', diff --git a/infra/monitoring/alerting/observe_dashboard.py b/infra/monitoring/alerting/observe_dashboard.py index 580520d0..131f9a47 100644 --- a/infra/monitoring/alerting/observe_dashboard.py +++ b/infra/monitoring/alerting/observe_dashboard.py @@ -39,7 +39,8 @@ def row(*ws, h=6): hc = REG.get('route53_health_check_id') text('## OBS-1 · native 백스톱·공개 점검') -row(('공개 점검 실패(1=실패)', [['RuleUp/Edge', 'ProbeFailure', 'Env', ENV, 'Probe', 'public', 'Origin', o, {'stat': 'Maximum', 'label': o}] for o in ORIGINS], None), +row(('공개 점검 — 분당 실패 횟수(0~2, 2=EDGE-01 leaf)', [['RuleUp/Edge', 'ProbeAttemptFailures', 'Env', ENV, 'Probe', 'public', 'Origin', o, {'stat': 'Sum', 'label': o}] + for o in ORIGINS], {'period': 60}), ('Route 53 헬스체크(1=정상)' + ('' if hc else ' — 원장 route53_health_check_id 미기록'), [['AWS/Route53', 'HealthCheckStatus', 'HealthCheckId', hc, {'stat': 'Minimum'}]] if hc else [], None), ('인증서 잔여 일수', [['RuleUp/Edge', 'CertificateDaysLeft', 'Env', ENV, 'Origin', o, {'stat': 'Minimum', 'label': o}] for o in ORIGINS], diff --git a/infra/monitoring/alerting/pagerduty.py b/infra/monitoring/alerting/pagerduty.py new file mode 100644 index 00000000..d2e8bd94 --- /dev/null +++ b/infra/monitoring/alerting/pagerduty.py @@ -0,0 +1,197 @@ +"""P0 온콜 — PagerDuty 에스컬레이션과 CloudWatch 연동. 재실행 안전. prod 만(stg 의 P0 는 시험 채널에만 간다). + +호출 순서(2026-10-15 결정): 1차 담당(이성은)에게 바로 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(repeat_loops). +노션 카탈로그 「페이징 도구 결정」의 5분 지원자 통보·10분 재호출을 이 순서로 바꿨다. + + export PAGERDUTY_API_TOKEN=... # PagerDuty REST API 키(User 또는 General Access, 쓰기). 저장소·원장에 넣지 않는다 + python3 infra/monitoring/alerting/pagerduty.py setup --primary <1차 PD 계정 이메일> --secondary <팀원 PD 계정 이메일> + python3 infra/monitoring/alerting/p0_p1.py prod # 원장 sns.pagerduty 가 채워졌으니 P0 경보에 토픽이 붙는다 + python3 infra/monitoring/alerting/pagerduty.py test-trigger # 시험 경보 ALARM — Slack 두 채널 + PagerDuty. ACK 하지 말고 10분 뒤 팀원 폰을 본다 + python3 infra/monitoring/alerting/pagerduty.py test-resolve # 시험 경보 OK — Slack 복구 알림 + PagerDuty 사건 종료 + +시험은 실제 P0 와 같은 길을 탄다: 서울에 시험 경보 ruleup-prod-P0-ONCALL-TEST 를 실제 P0 와 같은 AlarmActions 로 두고 +set-alarm-state 로 상태만 바꾼다 → ruleup-prod-alerts(#ruleup-alert)·EventBridge 멘션(긴급 채널)·ruleup-prod-pagerduty 가 함께 받는다. +시험 경보는 아무도 내지 않는 지표(RuleUp/Observe OnCallTest)를 missing=ignore 로 보므로 다음 평가에서 상태가 되돌아가지 않는다 +(다른 경보에 set-alarm-state 를 쓰면 1분 안에 OK 로 돌아가 사건이 닫혀 10분 에스컬레이션을 볼 수 없다). + +setup 이 하는 일 + 1. 이메일로 PagerDuty 사용자를 찾는다(두 사람 모두 PagerDuty 계정·앱 설치·전화 알림 규칙이 먼저 있어야 한다). + 2. 에스컬레이션 정책 「RuleUp prod P0」 — 규칙 1: 1차 담당, 10분 / 규칙 2: 팀원, 10분 / repeat_loops 회 반복. + 3. 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결이 없으면 다시 울림, 시간 기반 자동 종료 끔(노션 §2). + 4. 서비스에 공식 Amazon CloudWatch 연동을 붙이고, 서울·버지니아에 SNS 토픽 ruleup-prod-pagerduty 를 만들어 HTTPS 로 구독시킨다 + (CloudWatch → 같은 리전 SNS → PagerDuty 직결 — 자체 Lambda·릴레이를 거치지 않는다). 구독 확인은 PagerDuty 가 자동으로 한다. + 5. 원장 env/prod.json 에 사용자·정책·서비스 ID 와 sns.pagerduty 를 적는다(이메일·연동 키는 적지 않는다) → 커밋한다. +경보가 OK 로 돌아오면 같은 토픽(OKActions)으로 PagerDuty 사건이 닫힌다. +""" +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +LEDGER = os.path.join(HERE, '..', 'env', 'prod.json') +API = 'https://api.pagerduty.com' +NAME = 'RuleUp prod P0' +TOPIC = 'ruleup-prod-pagerduty' +TEST_ALARM = 'ruleup-prod-P0-ONCALL-TEST' + + +def escalation_policy(primary_id, secondary_id, delay_minutes, loops): + """1차 담당 → delay 분 미ACK → 팀원 → delay 분 미ACK → 처음부터(loops 회).""" + rule = lambda uid: {'escalation_delay_in_minutes': delay_minutes, 'targets': [{'id': uid, 'type': 'user_reference'}]} + return {'type': 'escalation_policy', 'name': NAME, 'num_loops': loops, + 'description': f'RuleUp prod P0: primary first, teammate after {delay_minutes} min without ACK', + 'escalation_rules': [rule(primary_id), rule(secondary_id)]} + + +def service(policy_id, ack_timeout_minutes): + return {'type': 'service', 'name': NAME, 'description': 'CloudWatch P0 alarms (ruleup-prod-P0-*) via SNS', + 'escalation_policy': {'id': policy_id, 'type': 'escalation_policy_reference'}, + 'incident_urgency_rule': {'type': 'constant', 'urgency': 'high'}, + 'acknowledgement_timeout': ack_timeout_minutes * 60, + 'auto_resolve_timeout': None, + 'alert_creation': 'create_alerts_and_incidents'} + + +def test_alarm(reg, actions): + """실제 P0 와 같은 전달 경로를 쓰는 시험 경보. 지표는 아무도 내지 않고 missing=ignore — set-alarm-state 로만 상태가 바뀐다.""" + return {'AlarmName': TEST_ALARM, 'ActionsEnabled': True, 'AlarmActions': actions, 'OKActions': actions, + 'AlarmDescription': '[ONCALL-TEST] 온콜 시험 경보 — pagerduty.py test-trigger/test-resolve 로만 바뀐다. 실제 장애 아님', + 'Namespace': 'RuleUp/Observe', 'MetricName': 'OnCallTest', 'Dimensions': [{'Name': 'Env', 'Value': reg['env']}], + 'Statistic': 'Maximum', 'Period': 60, 'EvaluationPeriods': 1, 'DatapointsToAlarm': 1, 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', 'TreatMissingData': 'ignore'} + + +class PagerDuty: + def __init__(self, token): + self.token = token + + def call(self, method, path, body=None, query=None): + url = API + path + ('?' + urllib.parse.urlencode(query, doseq=True) if query else '') + req = urllib.request.Request(url, method=method, data=None if body is None else json.dumps(body).encode(), headers={ + 'Authorization': f'Token token={self.token}', 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read() or b'{}') + except urllib.error.HTTPError as e: + sys.exit(f'PagerDuty {method} {path} → {e.code}: {e.read().decode(errors="replace")[:300]}') + + def user_id(self, email): + users = [u for u in self.call('GET', '/users', query={'query': email})['users'] if u['email'].lower() == email.lower()] + if not users: + sys.exit(f'PagerDuty 사용자가 없다 — 먼저 초대·가입시킨다: {email}') + return users[0]['id'] + + def find(self, kind, name): + key = kind.rstrip('s') if kind != 'escalation_policies' else 'escalation_policy' + hits = [x for x in self.call('GET', f'/{kind}', query={'query': name})[kind] if x['name'] == name] + return hits[0]['id'] if hits else None, key + + def upsert(self, kind, body): + found, key = self.find(kind, body['name']) + if found: + return self.call('PUT', f'/{kind}/{found}', {key: body})[key]['id'] + return self.call('POST', f'/{kind}', {key: body})[key]['id'] + + def cloudwatch_integration_key(self, service_id): + svc = self.call('GET', f'/services/{service_id}', query={'include[]': 'integrations'})['service'] + for i in svc.get('integrations', []): + if i.get('type') == 'aws_cloudwatch_inbound_integration': + return self.call('GET', f'/services/{service_id}/integrations/{i["id"]}')['integration']['integration_key'] + vendors = [v for v in self.call('GET', '/vendors', query={'query': 'Amazon CloudWatch'})['vendors'] if v['name'] == 'Amazon CloudWatch'] + if not vendors: + sys.exit('PagerDuty 에서 Amazon CloudWatch 연동(vendor)을 찾지 못했다') + body = {'type': 'aws_cloudwatch_inbound_integration', 'name': 'CloudWatch via SNS', + 'vendor': {'id': vendors[0]['id'], 'type': 'vendor_reference'}} + return self.call('POST', f'/services/{service_id}/integrations', {'integration': body})['integration']['integration_key'] + + +def load(): + with open(LEDGER, encoding='utf-8') as f: + return json.load(f) + + +def save(reg): + with open(LEDGER, 'w', encoding='utf-8') as f: + json.dump(reg, f, ensure_ascii=False, indent=2) + f.write('\n') + + +def regions(reg): + return [reg['api_region'], reg['monitor_region']] + + +def subscribe(reg, endpoint): + import boto3 + sys.path.insert(0, HERE) + import routing # 토픽 정책(계정 소유자 + CloudWatch 발행)을 같은 모양으로 + for region in regions(reg): + sns = boto3.client('sns', region_name=region) + arn = routing.topic(sns, TOPIC, reg['account_id'], ['cloudwatch.amazonaws.com']) + subs = [s for p in sns.get_paginator('list_subscriptions_by_topic').paginate(TopicArn=arn) for s in p['Subscriptions']] + for s in subs: # 연동을 다시 만들어 키가 바뀌었으면 옛 구독을 지운다 + if s['Protocol'] == 'https' and s['Endpoint'] != endpoint and s['SubscriptionArn'].startswith('arn:'): + sns.unsubscribe(SubscriptionArn=s['SubscriptionArn']) + print(' unsubscribed old endpoint', region) + if not any(s['Endpoint'] == endpoint for s in subs): + sns.subscribe(TopicArn=arn, Protocol='https', Endpoint=endpoint) + print(' subscribed', region, TOPIC) + + +def setup(args): + token = os.environ.get('PAGERDUTY_API_TOKEN') or sys.exit('PAGERDUTY_API_TOKEN 이 없다') + reg = load() + pd_cfg = reg['pagerduty'] + pd = PagerDuty(token) + primary, secondary = pd.user_id(args.primary), pd.user_id(args.secondary) + policy_id = pd.upsert('escalation_policies', escalation_policy(primary, secondary, pd_cfg['escalation_delay_minutes'], pd_cfg['repeat_loops'])) + print('escalation policy', policy_id, f"(1차 → {pd_cfg['escalation_delay_minutes']}분 → 팀원, {pd_cfg['repeat_loops']}회 반복)") + service_id = pd.upsert('services', service(policy_id, pd_cfg['ack_timeout_minutes'])) + print('service', service_id) + key = pd.cloudwatch_integration_key(service_id) + subscribe(reg, f'https://events.pagerduty.com/integration/{key}/enqueue') + pd_cfg.update(primary_user_id=primary, secondary_user_id=secondary, escalation_policy_id=policy_id, service_id=service_id) + reg['sns']['pagerduty'] = TOPIC + save(reg) + print('원장 env/prod.json 갱신 — 커밋하고 p0_p1.py prod 를 다시 돌려 P0 경보에 토픽을 붙인다') + + +def set_test_state(state): + import boto3 + sys.path.insert(0, HERE) + import p0_p1 # 실제 P0 와 같은 AlarmActions(운영 Slack·긴급 P0 토픽·PagerDuty) + reg = load() + if not reg['sns'].get('pagerduty'): + sys.exit('원장 sns.pagerduty 가 비어 있다 — setup 먼저') + region = reg['api_region'] + cw = boto3.client('cloudwatch', region_name=region) + cw.put_metric_alarm(**test_alarm(reg, p0_p1.actions(reg, region, 'P0'))) + if state == 'ALARM': # 이미 ALARM 이면 상태 변경이 없어 아무 데도 안 간다 — OK 를 거쳐 다시 울린다 + current = cw.describe_alarms(AlarmNames=[TEST_ALARM])['MetricAlarms'][0]['StateValue'] + if current == 'ALARM': + sys.exit('시험 경보가 이미 ALARM 이다 — test-resolve 로 닫고 1분 뒤 다시') + cw.set_alarm_state(AlarmName=TEST_ALARM, StateValue=state, StateReason=f'pagerduty.py on-call test ({state})') + print(state, TEST_ALARM, region, '→', ', '.join(a.split(':')[-1] for a in p0_p1.actions(reg, region, 'P0'))) + if state == 'ALARM': + print('Slack #ruleup-alert·긴급 채널 멘션·PagerDuty 가 함께 와야 한다. ACK 하지 말 것 — 10분 뒤 팀원, 확인 후 test-resolve', + time.strftime('%H:%M:%S')) + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest='cmd', required=True) + s = sub.add_parser('setup') + s.add_argument('--primary', required=True, help='1차 담당 PagerDuty 계정 이메일') + s.add_argument('--secondary', required=True, help='10분 미응답 시 받을 팀원 PagerDuty 계정 이메일') + sub.add_parser('test-trigger') + sub.add_parser('test-resolve') + a = ap.parse_args() + if a.cmd == 'setup': + setup(a) + else: + set_test_state('ALARM' if a.cmd == 'test-trigger' else 'OK') diff --git a/infra/monitoring/alerting/routing.py b/infra/monitoring/alerting/routing.py index 1e20f6f3..1fd52411 100644 --- a/infra/monitoring/alerting/routing.py +++ b/infra/monitoring/alerting/routing.py @@ -6,7 +6,7 @@ ruleup-prod-p0-alerts (us-east-1) → 경보 AlarmActions 용(구독 없음 — 서울과 같은 구조) ruleup-prod-p0-slack (us-east-1) → EventBridge(us-east-1) 가 P0 상태 변경을 멘션 알림으로 바꿔 보낸다 → ruleup-p0-urgent 서울 쪽 구성(slack_channels.py·p0_mention.py)은 그대로 두고 토픽 목록에 us-east-1 것을 더한다. -SNS 는 전달만 한다 — ACK·에스컬레이션은 PagerDuty 개통 후(원장 sns.pagerduty)에 생긴다. +SNS 는 전달만 한다 — ACK·에스컬레이션(1차 → 10분 미ACK → 팀원)은 pagerduty.py 가 만드는 PagerDuty 서비스가 한다. """ import json import os diff --git a/infra/monitoring/alerting/tests_catalog.py b/infra/monitoring/alerting/tests_catalog.py index 855e3435..687d54ff 100644 --- a/infra/monitoring/alerting/tests_catalog.py +++ b/infra/monitoring/alerting/tests_catalog.py @@ -24,7 +24,7 @@ def test_names_are_unique_and_fit(self): self.assertTrue(all(len(n) <= 255 for n in names)) def test_p0_only_for_contract_ids(self): - """CPU·메모리·DB·Redis·SQS 사용률 하나로 P0 를 만들지 않는다.""" + """CPU·메모리·DB·Redis·SQS 사용률 하나로 P0 를 만들지 않는다 — 10/15 리뷰가 올린 RDS CPU·메모리(DB-10/11)만 예외.""" for _, s in self.specs('prod', res_enabled=True, detectors_enabled=True): if s['severity'] == 'P0': self.assertIn(s['id'], catalog.P0_IDS, s['name']) @@ -37,9 +37,9 @@ def test_missing_data_policy(self): p = s['params'] if s['kind'] == 'composite': self.assertNotIn('TreatMissingData', p, 'Composite 에는 누락 정책을 넣지 않는다') - elif s['id'] in ('SLO-01', 'SLO-02', 'API-01', 'RES-01', 'RES-02', 'RES-03', 'RES-04') or s['severity'] == 'leaf': + elif s['id'] in ('SLO-01', 'SLO-02', 'API-01', 'RES-01', 'RES-02', 'RES-03', 'RES-04', 'DB-11') or s['severity'] == 'leaf': self.assertEqual(p['TreatMissingData'], 'ignore', s['name']) - elif s['id'] in ('OBS-01', 'NATIVE-01'): + elif s['id'] in ('OBS-01', 'OBS-04', 'NATIVE-01'): self.assertEqual(p['TreatMissingData'], 'breaching', s['name']) else: self.assertEqual(p['TreatMissingData'], 'notBreaching', s['name']) @@ -56,9 +56,24 @@ def test_regions(self): r = reg('prod') for region, s in self.specs('prod', res_enabled=True): central = s['id'].split('-')[0] in ('SLO', 'LAT', 'EDGE', 'TLS', 'RES') or s['id'] in ('NATIVE-01',) \ - or (s['id'] == 'OBS-01' and not s['name'].endswith('db-collector')) or s['id'] in ('API-01', 'API-02') + or (s['id'] == 'OBS-01' and not s['name'].endswith('db-collector')) or s['id'] in ('API-01', 'API-02', 'OBS-04') self.assertEqual(region, r['monitor_region'] if central else r['api_region'], s['name']) + def test_p0_pages_within_two_minutes(self): + """10/15 리뷰: P0 는 조건 충족 후 1~2분 안에 호출 — 평가 창(Period×DatapointsToAlarm)이 2분을 넘지 않는다. + OBS-04(5분 누락)는 「5분 누락」 자체가 조건이라 뺀다.""" + for _, s in self.specs('prod', res_enabled=True): + if s['severity'] != 'P0' or s['kind'] == 'composite' or s['id'] == 'OBS-04': + continue + p = s['params'] + period = p['Period'] if 'Period' in p else next(q['MetricStat']['Period'] for q in p['Metrics'] if 'MetricStat' in q) + self.assertLessEqual(period * p['DatapointsToAlarm'], 120, s['name']) + + def test_rds_cpu_and_memory_page(self): + names = {s['name'] for _, s in self.specs('prod')} + self.assertIn('ruleup-prod-P0-DB-10', names) + self.assertIn('ruleup-prod-P0-DB-11', names) + def test_res_and_detectors_are_gated(self): ids = {s['id'] for _, s in self.specs('prod', res_enabled=False, detectors_enabled=False)} self.assertFalse(ids & {'RES-01', 'FLOW-01', 'HTTP-04', 'CACHE-01'}) diff --git a/infra/monitoring/alerting/tests_pagerduty.py b/infra/monitoring/alerting/tests_pagerduty.py new file mode 100644 index 00000000..32613761 --- /dev/null +++ b/infra/monitoring/alerting/tests_pagerduty.py @@ -0,0 +1,44 @@ +"""pagerduty.py 계약 시험 — python3 infra/monitoring/alerting/tests_pagerduty.py (PagerDuty·AWS 호출 없음)""" +import json, os, sys, unittest +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import pagerduty as pd + + +class PagerDutyTest(unittest.TestCase): + + def test_primary_first_then_teammate_after_ten_minutes(self): + """나에게 먼저, 10분 안에 ACK 가 없으면 팀원에게 — 규칙 순서가 곧 호출 순서다.""" + reg = pd.load()['pagerduty'] + body = pd.escalation_policy('PRIMARY', 'TEAMMATE', reg['escalation_delay_minutes'], reg['repeat_loops']) + rules = body['escalation_rules'] + self.assertEqual([r['targets'][0]['id'] for r in rules], ['PRIMARY', 'TEAMMATE']) + self.assertEqual(rules[0]['escalation_delay_in_minutes'], 10) + self.assertGreaterEqual(body['num_loops'], 1, '팀원도 안 받으면 처음부터 다시 울린다') + + def test_service_never_auto_resolves_and_always_high(self): + body = pd.service('POLICY', 15) + self.assertIsNone(body['auto_resolve_timeout'], '시간이 지났다고 사건을 닫지 않는다(노션 §2)') + self.assertEqual(body['acknowledgement_timeout'], 900) + self.assertEqual(body['incident_urgency_rule'], {'type': 'constant', 'urgency': 'high'}) + + def test_test_alarm_follows_real_p0_path(self): + """시험 경보는 실제 P0 와 같은 전달(Slack 운영·긴급 멘션·PagerDuty)을 타고, 다음 평가에서 저절로 OK 가 되지 않는다.""" + import p0_p1 + reg = pd.load() + acts = p0_p1.actions(reg, reg['api_region'], 'P0') + a = pd.test_alarm(reg, acts) + self.assertTrue(a['AlarmName'].startswith('ruleup-prod-P0-'), 'EventBridge 긴급 채널 멘션이 이 접두어로 잡는다') + self.assertEqual(a['TreatMissingData'], 'ignore') + self.assertEqual(a['AlarmActions'], a['OKActions']) + names = {x.split(':')[-1] for x in a['AlarmActions']} + self.assertEqual(names, {reg['sns']['alerts'], reg['sns']['p0'], reg['sns']['pagerduty']}) + + def test_ledger_keeps_no_secrets(self): + text = json.dumps(pd.load(), ensure_ascii=False) + self.assertNotIn('@', text, '원장에 이메일을 넣지 않는다') + self.assertNotIn('integration_key', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/infra/monitoring/env/prod.json b/infra/monitoring/env/prod.json index 8a4b049d..68104264 100644 --- a/infra/monitoring/env/prod.json +++ b/infra/monitoring/env/prod.json @@ -4,7 +4,10 @@ "account_id": "961178969292", "api_region": "ap-northeast-2", "monitor_region": "us-east-1", - "probe_origins": {"oregon": "us-west-2", "virginia": "us-east-1"}, + "probe_origins": { + "oregon": "us-west-2", + "virginia": "us-east-1" + }, "public_domain": "prod.ruleup.co.kr", "public_health_path": "/health/public", "public_health_token": "ruleup-ok", @@ -16,19 +19,44 @@ "load_balancer": "app/ruleup-prod-alb/30526d51ec29a005", "target_group": "targetgroup/ruleup-prod-tg/d3db371c903b8892", "rds_instance": "ruleup-prod-mysql-v2", - "redis_nodes": ["ruleup-prod-redis-001", "ruleup-prod-redis-002"], - "queues": ["ruleup-prod-moderation", "ruleup-prod-notifications"], - "dlqs": ["ruleup-prod-moderation-dlq", "ruleup-prod-notifications-dlq"], + "redis_nodes": [ + "ruleup-prod-redis-001", + "ruleup-prod-redis-002" + ], + "queues": [ + "ruleup-prod-moderation", + "ruleup-prod-notifications" + ], + "dlqs": [ + "ruleup-prod-moderation-dlq", + "ruleup-prod-notifications-dlq" + ], "sns": { "alerts": "ruleup-prod-alerts", "p0": "ruleup-prod-p0-alerts", - "pagerduty": null + "pagerduty": "ruleup-prod-pagerduty" }, "slack_p0_mention_member": "U0AVBAWH05Q", "route53_health_check_id": "26710503-030a-4b93-8df5-e7339b675632", - "healthchecks": {"obs03_ping_url": null, "obs04_ping_url": null}, - "pagerduty": {"service_id": null, "api_key_secret": null}, - "dashboards": {"ops": "ruleup-prod-ops", "observe": "ruleup-prod-observe", "business": "ruleup-prod-business"}, + "healthchecks": { + "obs03_ping_url": null, + "obs04_ping_url": null + }, + "pagerduty": { + "_note": "pagerduty.py setup 이 채운다. 1차 담당 → escalation_delay_minutes 분 미ACK → 팀원 → repeat_loops 회 반복. API 키·연동 키·이메일은 넣지 않는다", + "service_id": "PQ9NRPT", + "escalation_policy_id": "POF6OPH", + "primary_user_id": "PYEDO11", + "secondary_user_id": "P7VNRZB", + "escalation_delay_minutes": 10, + "repeat_loops": 2, + "ack_timeout_minutes": 15 + }, + "dashboards": { + "ops": "ruleup-prod-ops", + "observe": "ruleup-prod-observe", + "business": "ruleup-prod-business" + }, "runbook_url": "https://app.notion.com/p/3f5ad4145fb6811da317e294cf339757", "catalog_url": "https://app.notion.com/p/3f5ad4145fb681bbabccefd603336193", "llm_daily_budget_usd": null, diff --git a/infra/monitoring/env/stg.json b/infra/monitoring/env/stg.json index e4f96d5e..8038fff8 100644 --- a/infra/monitoring/env/stg.json +++ b/infra/monitoring/env/stg.json @@ -27,7 +27,7 @@ "slack_p0_mention_member": null, "route53_health_check_id": "7f7ffb91-fd17-4afa-9f08-85bdf7d13d32", "healthchecks": {"obs03_ping_url": null, "obs04_ping_url": null}, - "pagerduty": {"service_id": null, "api_key_secret": null}, + "pagerduty": {"_note": "stg 는 온콜 없음 — P0 도 시험 채널(ruleup-stg-alerts)만"}, "dashboards": {"ops": "ruleup-stg-ops", "observe": "ruleup-stg-observe", "business": null}, "runbook_url": "https://app.notion.com/p/3f5ad4145fb6811da317e294cf339757", "catalog_url": "https://app.notion.com/p/3f5ad4145fb681bbabccefd603336193", diff --git a/infra/monitoring/lambdas/deploy.py b/infra/monitoring/lambdas/deploy.py index d0a7d917..464da91d 100644 --- a/infra/monitoring/lambdas/deploy.py +++ b/infra/monitoring/lambdas/deploy.py @@ -37,7 +37,7 @@ def functions(reg): out = [dict(name=f'ruleup-{env}-slo-calculator', handler='slo_calculator.handler', region=mon, role='slo', timeout=55, memory=512), dict(name=f'ruleup-{env}-resource-monitor', handler='resource_monitor.handler', region=mon, role='resource', timeout=55, memory=256)] for origin, region in reg['probe_origins'].items(): - out.append(dict(name=f'ruleup-{env}-edge-probe', handler='edge_probe.handler', region=region, role='edge', timeout=20, + out.append(dict(name=f'ruleup-{env}-edge-probe', handler='edge_probe.handler', region=region, role='edge', timeout=50, memory=128, extra_env={'PROBE_ORIGIN': origin})) return out diff --git a/infra/monitoring/lambdas/edge_probe.py b/infra/monitoring/lambdas/edge_probe.py index 6b46627a..ca5f6a3b 100644 --- a/infra/monitoring/lambdas/edge_probe.py +++ b/infra/monitoring/lambdas/edge_probe.py @@ -1,10 +1,16 @@ """외부 공개 점검 — 노션 「알람 카탈로그」 §4 EDGE-01/02·TLS-01. 오리건·버지니아에 같은 코드를 하나씩 둔다(Scheduler 1분). +한 번 실행에 30초 간격으로 두 번 부른다(노션 10/15 리뷰: 「2회 연속 실패, 30초 간격, 약 1분」). Scheduler 의 최소 주기가 +1분이라 30초 점검은 함수 안에서 나눈다. 분 경계를 걸친 연속 실패(앞 분 두 번째 + 이번 분 첫 번째)는 세지 않는다 — +실제 장애는 다음 분 두 번도 실패하므로 늦어져도 1분이다. + 사용자와 같은 길(DNS → Cloudflare → ALB → 태스크)로 무인증 /health/public 을 부르고 DNS → TLS 검증 → HTTP → 본문 단계를 모두 통과해야 성공이다. timeout 5초, 성공은 200 + 고정 본문 토큰. 캐시 응답을 성공으로 오인하지 않게 no-cache 로 묻는다. 결과는 중앙 감시 리전(버지니아)의 RuleUp/Edge 로 직접 보낸다: - ProbeFailure(Env, Probe=public, Origin) 0/1 · CertificateDaysLeft(Env, Origin) · RuleUp/Observe CollectorHeartbeat(Component=edge-) + ProbeFailure(Env, Probe=public, Origin) 0/1 — 이번 분 두 번 모두 실패(EDGE-01 leaf) + ProbeAttemptFailures(Env, Probe=public, Origin) 0~2 — 이번 분 실패 횟수(EDGE-02) + CertificateDaysLeft(Env, Origin) · RuleUp/Observe CollectorHeartbeat(Component=edge-) 노션 설계의 「점검 릴레이」 함수는 두지 않았다 — 결과 검증은 이 함수가 하고, 리전 간 PutMetricData 로 충분해서다. 마지막 실행이 오래되면(하트비트 누락) 그건 실패가 아니라 관측 누락으로 OBS-01 이 본다. """ @@ -19,6 +25,7 @@ import cwio TIMEOUT = 5.0 +ATTEMPT_INTERVAL = 30.0 # 두 점검 사이(초). 함수 timeout 은 이 값 + 점검 두 번 최악(약 2×12초)보다 길어야 한다(deploy.py 50초) def probe(host, path, token, origin): @@ -72,15 +79,24 @@ def handler(event, context): origin = os.environ.get('PROBE_ORIGIN') or _origin_of(reg) now = cwio.event_time(event) stamp = now.replace(second=0, microsecond=0) - ok, stage, days = probe(reg['public_domain'], reg['public_health_path'], reg['public_health_token'], origin) - cwio.log('edge_probe', env=env, origin=origin, ok=ok, stage=stage, cert_days_left=days) + attempts = [] + started = time.monotonic() + for i in range(2): + if i: + time.sleep(max(0.0, ATTEMPT_INTERVAL - (time.monotonic() - started))) + attempts.append(probe(reg['public_domain'], reg['public_health_path'], reg['public_health_token'], origin)) + failures = sum(1 for ok, _, _ in attempts if not ok) + days = next((d for _, _, d in reversed(attempts) if d is not None), None) + cwio.log('edge_probe', env=env, origin=origin, ok=failures == 0, stages=[stage for _, stage, _ in attempts], cert_days_left=days) cw = boto3.client('cloudwatch', region_name=reg['monitor_region']) - data = [('ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 0 if ok else 1, stamp, 'None')] + dims = {'Env': env, 'Probe': 'public', 'Origin': origin} + data = [('ProbeFailure', dims, 1 if failures == len(attempts) else 0, stamp, 'None'), + ('ProbeAttemptFailures', dims, failures, stamp, 'Count')] if days is not None: data.append(('CertificateDaysLeft', {'Env': env, 'Origin': origin}, days, stamp, 'None')) cwio.put(cw, 'RuleUp/Edge', data) cwio.put(cw, 'RuleUp/Observe', [('CollectorHeartbeat', {'Env': env, 'Component': f'edge-{origin}'}, 1, stamp, 'Count')]) - return {'ok': ok, 'stage': stage} + return {'failures': failures, 'stages': [stage for _, stage, _ in attempts]} def _origin_of(reg): diff --git a/infra/monitoring/lambdas/resource_core.py b/infra/monitoring/lambdas/resource_core.py index 20dc3a07..0de03a4e 100644 --- a/infra/monitoring/lambdas/resource_core.py +++ b/infra/monitoring/lambdas/resource_core.py @@ -65,13 +65,12 @@ class Capacity: def res01(c): - """정상 태스크 전부 하드 한도 ≥99%(1분 1/1), 또는 전부 ≥98% 2/2 + 확장 불가.""" - if not c.healthy or len(c.mem_min) < 1 or c.mem_samples[0] is None or c.mem_samples[0] < c.healthy: + """정상 태스크 전부 하드 한도 ≥99% 1분, 또는 전부 ≥98% 1분 + 확장 불가(노션 10/15: 2/2 → 1분).""" + if not c.healthy or len(c.mem_min) < 1 or c.mem_min[0] is None or c.mem_samples[0] is None or c.mem_samples[0] < c.healthy: return None if c.mem_min[0] >= 99: return 1 - if len(c.mem_min) >= 2 and None not in c.mem_min[:2] and min(c.mem_min[:2]) >= 98 \ - and c.mem_samples[1] is not None and c.mem_samples[1] >= c.healthy and c.scale_blocked_history[:1] == [1]: + if c.mem_min[0] >= 98 and c.scale_blocked_history[:1] == [1]: return 1 return 0 @@ -84,12 +83,12 @@ def res02(c): def res03(c): - """CPU 평균 ≥99% 3/3 AND 확장 3분 이상 막힘 AND (Tomcat busy ≥95% 2/2 OR CUJ p95 ≥목표 2배 2/2).""" - if len(c.cpu_avg) < 3 or None in c.cpu_avg[:3] or len(c.scale_blocked_history) < 4: + """CPU 평균 ≥99% 1분 AND 확장 1분 이상 막힘(지금·직전) AND (Tomcat busy ≥95% OR CUJ p95 ≥목표 2배) — 노션 10/15: 3분 → 1분.""" + if len(c.cpu_avg) < 1 or c.cpu_avg[0] is None or len(c.scale_blocked_history) < 2: return None - saturated = (len(c.busy_threads_max) >= 2 and None not in c.busy_threads_max[:2] and min(c.busy_threads_max[:2]) >= 95) \ + saturated = (len(c.busy_threads_max) >= 1 and c.busy_threads_max[0] is not None and c.busy_threads_max[0] >= 95) \ or bool(c.slow_cuj) - hit = min(c.cpu_avg[:3]) >= 99 and all(b == 1 for b in c.scale_blocked_history[:4]) and saturated + hit = c.cpu_avg[0] >= 99 and all(b == 1 for b in c.scale_blocked_history[:2]) and saturated return 1 if hit else 0 @@ -122,11 +121,11 @@ def minutes_to_full(free_bytes): def res04(s): - """여유 ≤5% 2/2 AND 소진 예상 ≤15분 AND 확장 불가.""" - if len(s.free_pct) < 2 or None in s.free_pct[:2] or s.expandable is None: + """여유 ≤5% 1분 AND 소진 예상 ≤15분 AND 확장 불가. 예상은 15분 추세라 여유 판정만 1분으로 줄였다.""" + if len(s.free_pct) < 1 or s.free_pct[0] is None or s.expandable is None: return None eta = minutes_to_full(s.free_bytes) - hit = max(s.free_pct[:2]) <= 5 and eta is not None and eta <= 15 and not s.expandable + hit = s.free_pct[0] <= 5 and eta is not None and eta <= 15 and not s.expandable return 1 if hit else 0 @@ -138,7 +137,7 @@ def expandable(instance): return instance.get('AllocatedStorage', 0) < max_alloc * 0.99 -# RDS 인스턴스 클래스 → RAM(GiB). DB-02 DbMemoryPressure 의 「RAM 의 10%」. 모르는 클래스는 판정하지 않는다. +# RDS 인스턴스 클래스 → RAM(GiB). DB-02(10%)·DB-11(5%) 의 RAM 기준. 모르는 클래스는 판정하지 않는다. RDS_RAM_GIB = { 'db.t4g.micro': 1, 'db.t4g.small': 2, 'db.t4g.medium': 4, 'db.t4g.large': 8, 'db.t4g.xlarge': 16, 'db.t3.micro': 1, 'db.t3.small': 2, 'db.t3.medium': 4, 'db.t3.large': 8, @@ -147,12 +146,19 @@ def expandable(instance): } -def db_memory_pressure(instance_class, freeable, swap): - """최근 10분 내내 Freeable ≤ RAM 10% 이고 그 사이 Swap 이 늘었으면 1. 입력이 모자라면 None.""" +# 메모리 판정 두 단계 — 노션 10/15 리뷰: P1 은 RAM 10% 5분, P0 는 RAM 5% 1분. 둘 다 Swap 이 늘고 있어야 한다. +DB_MEMORY_PRESSURE = (0.10, 5) # DB-02 P1 DbMemoryPressure +DB_MEMORY_CRITICAL = (0.05, 1) # DB-11 P0 DbMemoryCritical +SWAP_TREND_MINUTES = 5 # Swap 증가는 최근 5분 처음·끝으로 본다 — 1분 창에서는 두 점이 안 나온다 + + +def db_memory_flag(instance_class, freeable, swap, level): + """최근 level 분 내내 Freeable ≤ RAM×비율 이고 최근 5분 사이 Swap 이 늘었으면 1. 입력이 모자라면 None.""" + fraction, minutes = level ram = RDS_RAM_GIB.get(instance_class) - f = [v for v in freeable[:10] if v is not None] - w = [v for v in swap[:10] if v is not None] - if ram is None or len(f) < 10 or len(w) < 2: + f = [v for v in freeable[:minutes] if v is not None] + w = [v for v in swap[:max(minutes, SWAP_TREND_MINUTES)] if v is not None] + if ram is None or len(f) < minutes or len(w) < 2: return None - limit = ram * 1024 ** 3 * 0.10 + limit = ram * 1024 ** 3 * fraction return 1 if max(f) <= limit and w[0] > w[-1] else 0 diff --git a/infra/monitoring/lambdas/resource_monitor.py b/infra/monitoring/lambdas/resource_monitor.py index 5f599e2e..a710aac6 100644 --- a/infra/monitoring/lambdas/resource_monitor.py +++ b/infra/monitoring/lambdas/resource_monitor.py @@ -5,7 +5,7 @@ · 멈춘 태스크 → UnexpectedStopCount·OOMStopCount(ECS-03/04), 실패한 배포 → DeployFailureCount(ECS-07) · 오토스케일 상한·최근 실패 → ScaleOutBlocked · RES-01~04 판정 → 버지니아 RuleUp/Ops CriticalCapacityFlag(Env,Service,Rule). 입력이 불완전하면 그 Rule 은 내지 않는다 - · RDS → RuleUp/DB FreeStoragePct(DB-05)·StorageMinutesToFull(DB-09)·DbMemoryPressure(DB-02) + · RDS → RuleUp/DB FreeStoragePct(DB-05)·StorageMinutesToFull(DB-09)·DbMemoryPressure(DB-02 P1)·DbMemoryCritical(DB-11 P0) 5분마다 · RDS 백업 → RuleUp/Backup BackupEnabled·LatestRestorableLagSec·AutomatedSnapshotAgeSec·BackupFailureCount(BACKUP-01~04) 모든 원천을 읽은 실행만 RuleUp/Observe CollectorHeartbeat(Component=resource-monitor) 를 낸다(OBS-01). @@ -124,9 +124,10 @@ def recent(qid, n, start=last): eta = rc.minutes_to_full(free) if eta is not None: db_out.append(('StorageMinutesToFull', db, eta, stamp, 'None')) - pressure = rc.db_memory_pressure(inst['DBInstanceClass'], recent('fmem', 10), recent('swap', 10)) - if pressure is not None: - db_out.append(('DbMemoryPressure', db, pressure, stamp, 'None')) + for metric_name, level in (('DbMemoryPressure', rc.DB_MEMORY_PRESSURE), ('DbMemoryCritical', rc.DB_MEMORY_CRITICAL)): + flag = rc.db_memory_flag(inst['DBInstanceClass'], recent('fmem', 10), recent('swap', 10), level) + if flag is not None: + db_out.append((metric_name, db, flag, stamp, 'None')) if now.minute % 5 == 0: backup(boto3.client('rds', region_name=api), cw_api, inst, env, now, stamp) except Exception as e: # noqa: BLE001 diff --git a/infra/monitoring/lambdas/slo_core.py b/infra/monitoring/lambdas/slo_core.py index 8876022a..1808ea89 100644 --- a/infra/monitoring/lambdas/slo_core.py +++ b/infra/monitoring/lambdas/slo_core.py @@ -101,9 +101,13 @@ def evaluate_burn(rule, series, last_minute): return (1 if hit else 0), {**why, 'state': 'breach' if hit else 'ok'} +# 급성 판정 창(분) — 노션 10/15 리뷰: API-01 2분(P0 1~2분 내 호출), API-02 3분. +ACUTE_WINDOW_MIN = {'API-01': 2, 'API-02': 3} + + def evaluate_acute(rule_id, series, last_minute): - """API-01(P0): 5분 실패율 ≥50%·요청 ≥20·실패 ≥10 / API-02(P1): 실패율 ≥5%·요청 ≥20.""" - w = window(series, last_minute, 5) + """API-01(P0): 2분 실패율 ≥50%·요청 ≥20·실패 ≥10 / API-02(P1): 3분 실패율 ≥5%·요청 ≥20.""" + w = window(series, last_minute, ACUTE_WINDOW_MIN[rule_id]) why = {'rule': rule_id, 'short': _w(w, 'availability')} if not w.valid: return None, {**why, 'state': 'insufficient_coverage'} diff --git a/infra/monitoring/lambdas/tests/test_edge_probe.py b/infra/monitoring/lambdas/tests/test_edge_probe.py index 09a6d907..63aba908 100644 --- a/infra/monitoring/lambdas/tests/test_edge_probe.py +++ b/infra/monitoring/lambdas/tests/test_edge_probe.py @@ -53,5 +53,28 @@ def test_dns_failure(self): self.assertEqual(edge_probe.probe('x', '/', 't', 'tokyo'), (False, 'dns', None)) + +class HandlerTest(unittest.TestCase): + """한 실행에 두 번 점검 — 둘 다 실패해야 EDGE-01 leaf(ProbeFailure=1), 실패 횟수는 EDGE-02 로.""" + + def invoke(self, results): + put = fake_aws.install() + os.environ['RULEUP_REGISTRY'] = os.path.join(os.path.dirname(__file__), '..', '..', 'env', 'stg.json') + os.environ['PROBE_ORIGIN'] = 'oregon' + with mock.patch.object(edge_probe, 'probe', side_effect=results), mock.patch('time.sleep') as sleep: + edge_probe.handler({'time': '2026-10-15T10:15:00Z'}, None) + self.assertEqual(sleep.call_count, 1, '두 점검 사이에 한 번 쉰다') + return {d['MetricName']: d['Value'] for _, ns, data in put if ns == 'RuleUp/Edge' for d in data} + + def test_two_consecutive_failures(self): + m = self.invoke([(False, 'http', None), (False, 'http', None)]) + self.assertEqual((m['ProbeFailure'], m['ProbeAttemptFailures']), (1, 2)) + + def test_one_failure_is_not_edge01(self): + m = self.invoke([(False, 'http', None), (True, 'ok', 30)]) + self.assertEqual((m['ProbeFailure'], m['ProbeAttemptFailures']), (0, 1)) + self.assertEqual(m['CertificateDaysLeft'], 30) + + if __name__ == '__main__': unittest.main() diff --git a/infra/monitoring/lambdas/tests/test_resource_core.py b/infra/monitoring/lambdas/tests/test_resource_core.py index ca1496ce..4ad07409 100644 --- a/infra/monitoring/lambdas/tests/test_resource_core.py +++ b/infra/monitoring/lambdas/tests/test_resource_core.py @@ -50,8 +50,8 @@ def test_res01_missing_report_suspends(self): self.assertIsNone(r.res01(cap(mem_min=[99.5, 99.5], mem_samples=[1, 2]))) def test_res01_98_needs_blocked_scale_out(self): - self.assertEqual(r.res01(cap(mem_min=[98.5, 98.2])), 0) - self.assertEqual(r.res01(cap(mem_min=[98.5, 98.2], scale_blocked_history=[1, 1, 1, 1])), 1) + self.assertEqual(r.res01(cap(mem_min=[98.5, 50])), 0) + self.assertEqual(r.res01(cap(mem_min=[98.5, 50], scale_blocked_history=[1, 0, 0, 0])), 1, '1분이면 충분하다(10/15)') def test_res02(self): self.assertEqual(r.res02(cap(oom_5m=2, healthy=1)), 1) @@ -59,10 +59,13 @@ def test_res02(self): self.assertEqual(r.res02(cap(oom_5m=3, healthy=2)), 0) def test_res03_cpu_alone_is_not_p0(self): - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9])), 0, '확장 가능하면 P1(ECS-09)') - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9], scale_blocked_history=[1, 1, 1, 1])), 0, '처리 포화 증거 없음') - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9], scale_blocked_history=[1, 1, 1, 1], - busy_threads_max=[96, 97])), 1) + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30])), 0, '확장 가능하면 P1(ECS-09)') + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30], scale_blocked_history=[1, 1, 0, 0])), 0, '처리 포화 증거 없음') + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30], scale_blocked_history=[1, 1, 0, 0], busy_threads_max=[96, 10])), 1, + '1분이면 충분하다(10/15)') + + def test_res03_blocked_must_last_a_minute(self): + self.assertEqual(r.res03(cap(cpu_avg=[99.5], scale_blocked_history=[1, 0], busy_threads_max=[96])), 0) def test_scale_out_blocked(self): self.assertEqual(r.scale_out_blocked(6, 6, False), 1) @@ -81,20 +84,27 @@ def test_minutes_to_full_from_15_minute_trend(self): def test_res04_requires_unexpandable(self): free = [1 * self.GB + i * 0.1 * self.GB for i in range(16)] - self.assertEqual(r.res04(r.Storage([4, 4.5], free, expandable=True)), 0) - self.assertEqual(r.res04(r.Storage([4, 4.5], free, expandable=False)), 1) - self.assertIsNone(r.res04(r.Storage([4], free, expandable=False))) + self.assertEqual(r.res04(r.Storage([4, 6], free, expandable=True)), 0) + self.assertEqual(r.res04(r.Storage([4, 6], free, expandable=False)), 1) + self.assertIsNone(r.res04(r.Storage([None, 4], free, expandable=False))) def test_expandable(self): self.assertFalse(r.expandable({'AllocatedStorage': 100})) self.assertFalse(r.expandable({'AllocatedStorage': 200, 'MaxAllocatedStorage': 200})) self.assertTrue(r.expandable({'AllocatedStorage': 100, 'MaxAllocatedStorage': 200})) - def test_db_memory_pressure(self): - low = [0.3 * self.GB] * 10 # 4GiB 의 10% = 0.4GiB 아래 - self.assertEqual(r.db_memory_pressure('db.t4g.medium', low, [300, 200, 100]), 1) - self.assertEqual(r.db_memory_pressure('db.t4g.medium', low, [100, 100]), 0) - self.assertIsNone(r.db_memory_pressure('db.x9.huge', low, [300, 100])) + def test_db_memory_pressure_p1_five_minutes(self): + low = [0.3 * self.GB] * 5 # 4GiB 의 10% = 0.4GiB 아래 + self.assertEqual(r.db_memory_flag('db.t4g.medium', low, [300, 200, 100], r.DB_MEMORY_PRESSURE), 1) + self.assertEqual(r.db_memory_flag('db.t4g.medium', low, [100, 100], r.DB_MEMORY_PRESSURE), 0, 'Swap 이 늘지 않음') + self.assertIsNone(r.db_memory_flag('db.t4g.medium', low[:4], [300, 100], r.DB_MEMORY_PRESSURE), '5분이 안 찼다') + self.assertIsNone(r.db_memory_flag('db.x9.huge', low, [300, 100], r.DB_MEMORY_PRESSURE)) + + def test_db_memory_critical_p0_one_minute(self): + """P0 는 RAM 5% 1분 — 10% 선 아래여도 5% 를 넘으면 P1 만.""" + self.assertEqual(r.db_memory_flag('db.t4g.medium', [0.15 * self.GB], [300, 250, 200], r.DB_MEMORY_CRITICAL), 1) + self.assertEqual(r.db_memory_flag('db.t4g.medium', [0.3 * self.GB], [300, 250, 200], r.DB_MEMORY_CRITICAL), 0) + self.assertIsNone(r.db_memory_flag('db.t4g.medium', [0.15 * self.GB], [300], r.DB_MEMORY_CRITICAL), 'Swap 추세 두 점 필요') if __name__ == '__main__': diff --git a/infra/monitoring/lambdas/tests/test_slo_core.py b/infra/monitoring/lambdas/tests/test_slo_core.py index cfc418c7..68dd548f 100644 --- a/infra/monitoring/lambdas/tests/test_slo_core.py +++ b/infra/monitoring/lambdas/tests/test_slo_core.py @@ -65,15 +65,27 @@ def test_latency_rule_counts_slow_good_requests(self): flag, _ = s.evaluate_burn(s.BURN_RULES[4], series, L) self.assertEqual(flag, 1) - def test_acute_failure_needs_ten_failures(self): + def test_acute_failure_uses_two_minute_window(self): series = flat(4320, 10, 0) - for m in range(L - 4, L + 1): - series[m] = (4, 3, 3) # 20건 중 15건 실패 + for m in range(L - 1, L + 1): + series[m] = (10, 5, 5) # 최근 2분 20건 중 10건 실패 self.assertEqual(s.evaluate_acute('API-01', series, L)[0], 1) - for m in range(L - 4, L + 1): - series[m] = (4, 1, 1) # 20건 중 5건 = 25% 이지만 실패 10건 미만 + for m in range(L - 1, L + 1): + series[m] = (10, 4, 4) # 20건 중 8건 = 40% self.assertEqual(s.evaluate_acute('API-01', series, L)[0], 0) + def test_acute_low_sample_in_two_minutes_is_pending(self): + """2분 창에 요청 20건이 안 되면 판정하지 않는다 — 그 전 분의 실패는 세지 않는다.""" + series = flat(4320, 4, 3) # 분당 4건 → 2분 8건 + self.assertIsNone(s.evaluate_acute('API-01', series, L)[0]) + + def test_api02_uses_three_minute_window(self): + series = flat(4320, 10, 0) + series[L - 3] = (10, 10, 10) # 3분 창 밖의 실패는 보지 않는다 + self.assertEqual(s.evaluate_acute('API-02', series, L)[0], 0) + series[L - 2] = (10, 2, 2) # 30건 중 2건 = 6.7% + self.assertEqual(s.evaluate_acute('API-02', series, L)[0], 1) + class BudgetTest(unittest.TestCase): diff --git a/infra/monitoring/tracing/enable_tracing.py b/infra/monitoring/tracing/enable_tracing.py index 7f9e147a..fa77d1c2 100644 --- a/infra/monitoring/tracing/enable_tracing.py +++ b/infra/monitoring/tracing/enable_tracing.py @@ -8,7 +8,9 @@ 1) 태스크 역할에 AWSXRayDaemonWriteAccess 2) X-Ray 샘플링 규칙 — 계정 단위라 stg·prod 공통이다. ruleup-skip-health(100) /actuator/* 는 기록하지 않음(ALB 헬스체크). - ruleup-api(200) */api/* 요청은 전부 기록. + ruleup-api(200) */api/* 요청은 초당 1건 + 그 위로 5% 기록. 피크(2026-10 부하테스트 350 RPS)에서 100% 기록은 + 스팬 큐(2048)를 넘쳐 버려지고 하루 ~$150 꼴이었다. 초당 10건+10% 도 DAU 2만(평균 ~17 RPS)이면 + 월 ~2,700만 건 ≈ $135(ECS 비용 이상)라 줄였다 — 월 ~470만 건 ≈ $23. 드문 오류·느린 요청은 로그·지표로 본다. Default(10000) 그 밖(배치·고아 DB 쿼리·지표 전송·SQS 폴링)은 1%만. 초당 1건+5% 였을 땐 이것들이 트레이스의 98% 였다 (배치 87% — 분당 ~30건이 초당 1건 예약분에 전부 들어갔다). 그룹 ruleup--api API 트레이스만 보는 필터(콘솔 트레이스 목록·서비스 맵에서 그룹을 고른다). @@ -72,7 +74,7 @@ # 에이전트는 경로가 아니라 전체 URL(http://10.1.1.73:8080/actuator/health)로 맞춰 본다 — '/actuator/*' 는 한 번도 안 걸렸다. rule=dict(RuleName='ruleup-skip-health',Priority=100,FixedRate=0.0,ReservoirSize=0,ServiceName='*',ServiceType='*', Host='*',HTTPMethod='*',URLPath='*/actuator/*',ResourceARN='*') - api=dict(RuleName='ruleup-api',Priority=200,FixedRate=1.0,ReservoirSize=10,ServiceName='*',ServiceType='*', + api=dict(RuleName='ruleup-api',Priority=200,FixedRate=0.05,ReservoirSize=1,ServiceName='*',ServiceType='*', Host='*',HTTPMethod='*',URLPath='*/api/*',ResourceARN='*') names={r['SamplingRule']['RuleName'] for r in xray.get_sampling_rules()['SamplingRuleRecords']} for r in (rule,api): diff --git a/src/main/java/com/ruleup/ruleup_backend/agreement/domain/UserAgreementState.java b/src/main/java/com/ruleup/ruleup_backend/agreement/domain/UserAgreementState.java index e6b562ae..d00e7a39 100644 --- a/src/main/java/com/ruleup/ruleup_backend/agreement/domain/UserAgreementState.java +++ b/src/main/java/com/ruleup/ruleup_backend/agreement/domain/UserAgreementState.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import org.springframework.data.domain.Persistable; import java.io.Serializable; import java.time.Instant; @@ -27,7 +28,22 @@ @IdClass(UserAgreementState.Key.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class UserAgreementState { +public class UserAgreementState implements Persistable { + + /** 새로 만든 행은 save() 가 merge(PK 로 한 번 더 SELECT) 대신 persist 로 바로 INSERT 하게 한다 — 호출부가 이미 없음을 확인했다. */ + @Transient + @Getter(AccessLevel.NONE) + private boolean fresh; + + @Override + public Key getId() { return new Key(userId, agreementType); } + + @Override + public boolean isNew() { return fresh; } + + @PostPersist + @PostLoad + void markStored() { this.fresh = false; } @Id @JdbcTypeCode(SqlTypes.BINARY) @@ -57,6 +73,7 @@ public static UserAgreementState of(UUID userId, AgreementType type, boolean agr s.agreed = agreed; s.version = version; s.agreedAt = agreedAt; + s.fresh = true; return s; } diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/domain/SocialToken.java b/src/main/java/com/ruleup/ruleup_backend/auth/domain/SocialToken.java index 38ebc3a4..1c66550a 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/domain/SocialToken.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/domain/SocialToken.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import org.springframework.data.domain.Persistable; import java.io.Serializable; import java.time.Instant; @@ -22,7 +23,22 @@ @IdClass(SocialToken.Key.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SocialToken { +public class SocialToken implements Persistable { + + /** 새로 만든 행은 save() 가 merge(PK 로 한 번 더 SELECT) 대신 persist 로 바로 INSERT 하게 한다 — 호출부가 이미 없음을 확인했다. */ + @Transient + @Getter(AccessLevel.NONE) + private boolean fresh; + + @Override + public Key getId() { return new Key(userId, provider); } + + @Override + public boolean isNew() { return fresh; } + + @PostPersist + @PostLoad + void markStored() { this.fresh = false; } /** 복합 PK (user_id, provider). */ public record Key(UUID userId, OAuthProvider provider) implements Serializable {} @@ -59,6 +75,7 @@ public static SocialToken of(UUID userId, OAuthProvider provider, t.refreshTokenEnc = refreshTokenEnc; t.encryptionKeyVersion = keyVersion; t.expiresAt = expiresAt; + t.fresh = true; return t; } diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java index 556af9a7..1088eab1 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java @@ -121,15 +121,16 @@ private OAuthLoginResponse login(OAuthProvider provider, OAuthLoginRequest req) requireValidDevice(req.deviceId(), req.deviceInfo()); requireValidOAuthRequest(provider, req); - OAuthClient client = resolver.resolve(provider); + OAuthClient client = resolver.resolve(provider, req.code(), req.codeVerifier()); // 1) 외부 IdP 호출 (트랜잭션 밖) OAuthUserInfo info = client.fetchUserInfo(req.code(), req.codeVerifier(), req.redirectUri()); if (sanctionService.isBanned(provider.name(), info.subject(), req.installationId())) throw new BusinessException(ErrorCode.ACCOUNT_BANNED); - User existing = userRepository.findByOauthProviderAndOauthSubject(provider, info.subject()).orElse(null); - if (existing != null) return loginSessionService.loginExisting(existing.getId(), provider, req, info); + // 기존 회원이면 id 만 알면 된다 — 세션 트랜잭션이 행을 잠그며 다시 읽는다(예전엔 여기서 엔티티 전체를 한 번 더 읽었다). + UUID existingId = userRepository.findIdByOauth(provider, info.subject()).orElse(null); + if (existingId != null) return loginSessionService.loginExisting(existingId, provider, req, info); return issueSignupToken(provider, req, info); } @@ -320,7 +321,7 @@ private SignupResponse register(SignupRequest req, boolean[] tokenConsumed, int user.attachInstallation(req.installationId(), req.deviceId()); applyCountry(user, req.deviceInfo()); // 지오 헤더 → 기기 지역 → Accept-Language → 기기 타임존 → 기본값 userRepository.saveAndFlush(user); - activity.touch(user.getId()); + activity.touchNow(user.getId()); UserScoreSummary summary = scoreService.initialize(user.getId()); // 브론즈 10점 // 친구 초대 기록(선택). 설치 경로로 받은 친구 초대 링크는 코드를 따로 보내지 않아도 연동한다. diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java index 8c854b20..03284cc9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java @@ -101,7 +101,9 @@ public OAuthLoginResponse loginExisting(UUID userId, OAuthProvider provider, // ===== 설치 인계 — uq_users_active_installation_id: 하나의 설치는 한 활성 계정에만 연결 ===== // 탈퇴 행도 installation_id 를 들고 있으므로(승계 근거) 활성 계정만 인계 대상으로 본다. - if (req.installationId() != null && !req.installationId().isBlank()) { + // 활성 계정이 이미 이 설치를 들고 있으면(같은 기기 재로그인 — 대부분) 다른 활성 보유자는 UNIQUE 상 있을 수 없다. + boolean alreadyHolds = !restored && req.installationId() != null && req.installationId().equals(user.getInstallationId()); + if (!alreadyHolds && req.installationId() != null && !req.installationId().isBlank()) { userRepository.findActiveHolderOfInstallation(req.installationId()) .filter(holder -> !holder.getId().equals(user.getId())) .ifPresent(holder -> { @@ -119,9 +121,9 @@ public OAuthLoginResponse loginExisting(UUID userId, OAuthProvider provider, user.attachInstallation(req.installationId(), req.deviceId()); applyCountry(user, req.deviceInfo()); user.touchLastLogin(); - activity.touch(user.getId()); userRepository.saveAndFlush(user); - activity.touch(user.getId()); + // 저장 뒤에 한 번만 — 복원이면 저장 전엔 아직 WITHDRAWN 이라 기록이 무시된다. + activity.touchNow(user.getId()); socialTokenService.upsert(user.getId(), provider, info.idpTokens()); // unlink 근거 최신화 diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java index 01cf13dd..5e43ac2b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java @@ -86,7 +86,12 @@ public class ChallengeDraftService { // ===== 경로 B: 설명 입력(LLM 5-Step) ===== - @Transactional + /** + * 트랜잭션으로 감싸지 않는다. LLM 호출이 수 초(재시도·폴백까지 최악 수십 초) 걸리는데, 트랜잭션이 열리면 + * 시작 시점에 DB 커넥션을 잡아 응답이 올 때까지 쥐고 있다 — 태스크 풀 15개 중 동시 AI 초안 수만큼이 묶여 같은 + * 태스크의 다른 API 가 커넥션을 기다렸다(부하테스트 2026-10). DB 작업은 끝의 등급 조회·초안 저장 둘뿐이고 각자 + * 리포지토리 트랜잭션으로 충분하다(초안 저장은 한 행이라 부분 실패가 없다). + */ public DraftResponse draft(UUID userId, String description) { validateDescription(description); diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/ExploreQueryService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/ExploreQueryService.java index 4e1d9e81..7fce6781 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/ExploreQueryService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/ExploreQueryService.java @@ -309,8 +309,9 @@ private Map fetchRows(Query q, List ids) { */ private Map withDisplayValues(Map byId) { Map merged = new java.util.HashMap<>(byId.size()); + Map> hashes = store.getStats(List.copyOf(byId.keySet())); byId.forEach((id, row) -> { - Map hash = store.getStats(id); + Map hash = hashes.getOrDefault(id, Map.of()); // 정렬 인덱스에는 있는데 표시값이 없다 = 투영이 덜 끝났다. 그 방만 빼고 // 200 을 내면 있는 방이 조용히 사라지고, MySQL 로 채우면 정렬과 숫자의 출처가 // 갈린다. 둘 다 장애를 감추므로 드러낸다(공통 5-4). diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/PopularityRefreshJob.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/PopularityRefreshJob.java index e7ec47f3..98b4cc9a 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/PopularityRefreshJob.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/PopularityRefreshJob.java @@ -82,8 +82,10 @@ private int refreshPopularity() { // 24시간 가입 이력은 배치 전체에서 한 번만 집계한다. 청크마다 같은 GROUP BY를 // 반복하면 1만 방 기준으로 가입 이력 전체를 수십 번 다시 읽게 된다. List targets = jdbc.query( + // 값이 바뀐 방만 쓴다 — 지금 저장된 값과 같이 읽어 비교한다. 예전에는 5분마다 공개 방 전부를 UPDATE 해 + // (부하테스트 2026-10: 수천 행) 통계 재계산의 challenge_stats 잠금과 부딪혔다. 가입이 없던 방은 대부분 0 → 0 이다. "SELECT c.id, COALESCE(j.c, 0) AS recent_joins, j.last_joined_at " + - "FROM challenges c " + + "FROM challenges c JOIN challenge_stats s ON s.challenge_id = c.id " + "LEFT JOIN (SELECT challenge_id, COUNT(*) AS c, MAX(joined_at) AS last_joined_at " + // 멤버십이 아니라 가입 사건을 센다 — 멤버십 한 줄로는 재입장이 // 잡히지 않는다(같은 이유로 인덱서도 이 표를 본다). @@ -91,7 +93,8 @@ private int refreshPopularity() { " WHERE joined_at >= DATE_SUB(NOW(6), INTERVAL 24 HOUR) " + " GROUP BY challenge_id) j ON j.challenge_id = c.id " + "WHERE c.mode = 'GROUP' AND c.visibility = 'PUBLIC' " + - " AND c.status IN ('UPCOMING', 'ACTIVE') AND c.deleted_at IS NULL", + " AND c.status IN ('UPCOMING', 'ACTIVE') AND c.deleted_at IS NULL " + + " AND NOT (s.recent_joins_24h <=> COALESCE(j.c, 0) AND s.last_joined_at_24h <=> j.last_joined_at)", (rs, i) -> new Popularity(rs.getBytes("id"), rs.getInt("recent_joins"), rs.getTimestamp("last_joined_at"))); diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/TrendingService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/TrendingService.java index a0801199..f8d5e96d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/TrendingService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/TrendingService.java @@ -91,7 +91,21 @@ public TrendingResponse getTrending(UUID userId, String category) { .collect(Collectors.toMap(Challenge::getId, Function.identity())); // 정원 판정은 요청 시점 원천이다(인기 API 명세). Redis 참여자 수는 표시용이라 // 지연될 수 있고, 그 값으로 가입 가능 여부를 정하면 마감된 방이 열린 것으로 보인다. - Map activeCounts = activeMemberCounts(ids); + // 마감 판정은 원천으로 하되, 마감 근처인 방만 센다 — 인기 방은 멤버가 많아 20곳을 다 세면 요청마다 + // 수천 행을 읽었다(부하테스트 2026-10 느린 쿼리 1위: 5,869행 × 678회). 정원이 없거나 저장된 참여자 수가 + // 정원보다 NEAR_FULL 넘게 남은 방은 몇 초 늦은 저장값으로도 「마감 아님」이 확실하다(가입 자체는 잠금으로 판정). + Map activeCounts = activeMemberCounts(ids.stream() + .filter(id -> nearFull(byId.get(id)) && !storedFull(byId.get(id))).toList()); + // 표시값은 보일 방만 한 번에(파이프라인) 읽는다 — 방마다 HGETALL 을 따로 보내지 않는다. + // 랭킹을 읽은 뒤에 Redis 가 끊길 수 있다. 감싸지 않으면 그 예외가 전역 + // 핸들러까지 올라가 500 이 된다 — 같은 장애인데 계약된 503 과 다른 답이 나간다. + Map> statsById; + try { + statsById = store.getStats(ids.stream().filter(id -> isCurrentlyVisible(byId.get(id), normalized)).toList()); + } catch (RuntimeException e) { + circuit.recordFailure(e); + throw new BusinessException(ErrorCode.EXPLORE_TEMPORARILY_UNAVAILABLE); + } for (TrendingRankingSource.Entry entry : ranking.entries()) { if (items.size() >= TrendingRankingSource.TOP_N) break; @@ -101,15 +115,7 @@ public TrendingResponse getTrending(UUID userId, String category) { // 인기 ZSET 에 있는데 표시값이 없다 = 투영이 덜 끝났다. 그 방만 빼면 순위가 한 칸씩 // 밀린 목록이 정상인 척 나간다 — 인기는 「서버 간 순위 동일」이 계약이라 더 나쁘다. - // 랭킹을 읽은 뒤에 Redis 가 끊길 수 있다. 감싸지 않으면 그 예외가 전역 - // 핸들러까지 올라가 500 이 된다 — 같은 장애인데 계약된 503 과 다른 답이 나간다. - Map stats; - try { - stats = store.getStats(c.getId()); - } catch (RuntimeException e) { - circuit.recordFailure(e); - throw new BusinessException(ErrorCode.EXPLORE_TEMPORARILY_UNAVAILABLE); - } + Map stats = statsById.getOrDefault(c.getId(), Map.of()); if (stats.isEmpty() || stats.get(ExploreRedisStore.VERSION_FIELD) == null) { throw new BusinessException(ErrorCode.EXPLORE_TEMPORARILY_UNAVAILABLE); } @@ -128,7 +134,8 @@ public TrendingResponse getTrending(UUID userId, String category) { // joinable 은 티어뿐 아니라 정원도 본다 — 티어만 보면 마감된 방이 // 「들어갈 수 있음」으로 보여, 눌러야 막힌다. joinable(myTier, c) - && !isFull(c, activeCounts.getOrDefault(c.getId(), 0L).intValue()), + && !storedFull(c) + && !(nearFull(c) && isFull(c, activeCounts.getOrDefault(c.getId(), 0L).intValue())), myChallengeIds.contains(c.getId()), c.getEndDate() == null ? null : c.getEndDate().toString())); } @@ -231,6 +238,22 @@ private Tier displayTier(UUID userId) { Tier tier = scoreSummaryRepository.findById(userId).map(s -> s.getDisplayTier()).orElse(Tier.BRONZE); return (tier == Tier.UNRANKED) ? Tier.BRONZE : tier; } + private static final int NEAR_FULL = 5; + + private static boolean nearFull(Challenge c) { + return c != null && c.getMaxParticipants() != null && c.getParticipantCount() >= c.getMaxParticipants() - NEAR_FULL; + } + + /** + * 저장된 참여자 수로 이미 정원이 찬 방. 저장값은 가입 뒤 재계산이 따라오므로 늦으면 실제보다 적다 — + * 그 값으로 이미 찼다면 지금도 찼다(탈퇴 직후 몇 초만 「마감」으로 더 보일 수 있고, 가입은 잠금이 판정한다). + * 꽉 찬 인기 방은 Top 20 에 오래 머물러, 세면 요청마다 그 방 멤버 전체를 읽었다(부하테스트 2026-10 단계 시험: + * 5,200행 × 10,483회, 느린 쿼리 1위). + */ + private static boolean storedFull(Challenge c) { + return c != null && c.getMaxParticipants() != null && c.getParticipantCount() >= c.getMaxParticipants(); + } + private static boolean isFull(Challenge c, int participants) { return c.getMaxParticipants() != null && participants >= c.getMaxParticipants(); } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java index 3d8a3721..98b34bdf 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java @@ -54,9 +54,48 @@ public class ExploreIndexJobs { @EventListener(ApplicationReadyEvent.class) public void warmUpOnStartup() { if (!RuntimeRole.valueOf(runtimeRole.toUpperCase()).runsBackgroundWork()) return; + if (!redisReachable()) return; // 끝내 못 붙으면 5분 스윕이 이어서 시도한다 ensureWarmed("기동"); } + /** + * 기동 직후 첫 Redis 연결은 여러 번 시도한다. + * + *

배포·스케일아웃 때마다 워밍업이 「Unable to connect to Redis」로 실패해 회로가 열리고, 5분 스윕이 + * 다시 데울 때까지 탐색이 SQL 폴백으로 돌았다(stg·prod 모두, 부하테스트 2026-10). 네트워크는 정상이었다 — + * 컨테이너에서 연결+PING 이 7ms. 0.5 vCPU 에서 막 뜬 JVM 이 기동 작업과 겹쳐 첫 핸드셰이크를 제때 못 끝낸 + * 것이라, 시간을 두고 다시 붙으면 된다. 스케일아웃은 부하가 몰릴 때 일어나므로 이 5분이 가장 비싼 5분이다. + */ + boolean redisReachable() { + RuntimeException last = null; + for (int attempt = 1; attempt <= STARTUP_REDIS_ATTEMPTS; attempt++) { + try { + store.ping(); + if (attempt > 1) log.info("탐색 Redis 연결 — {}번째 시도에 성공", attempt); + return true; + } catch (RuntimeException e) { + last = e; + log.info("탐색 Redis 연결 대기 {}/{} — {}", attempt, STARTUP_REDIS_ATTEMPTS, rootCause(e)); + sleepQuietly(STARTUP_REDIS_BACKOFF.multipliedBy(attempt)); + } + } + circuit.openManually("워밍업 실패(기동, 연결 " + STARTUP_REDIS_ATTEMPTS + "회): " + rootCause(last)); + return false; + } + + private static final int STARTUP_REDIS_ATTEMPTS = 5; + private static final java.time.Duration STARTUP_REDIS_BACKOFF = java.time.Duration.ofSeconds(2); + + static String rootCause(Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) t = t.getCause(); + return t == e ? e.toString() : e.getClass().getSimpleName() + " ← " + t; + } + + private static void sleepQuietly(java.time.Duration d) { + try { Thread.sleep(d.toMillis()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + } + /** * 준비 상태를 확인하고, 아니면 원천으로 만든다. * @@ -106,24 +145,27 @@ private boolean ensureWarmed(String reason) { // 여러 인스턴스가 동시에 기동하거나 동시에 승격을 감지하면 전수 재구성이 겹친다. String token = store.tryLockRebuild(REBUILD_LOCK_TTL); if (token == null) return false; + Instant startedAt = Instant.now(); try { - // 작업 표를 그대로 옮기지 않는다 — 그 값이 틀어져 있으면 틀린 채로 준비 완료가 된다. - // 통계를 먼저 맞춘다. 실패하면 인덱스를 만들지도 않는다 — 만들어 놓고 - // 나중에 준비 상태를 내리면 그 사이 틀린 값이 정상 응답으로 나간다. - var stats = statsReconciliation.run(); - if (stats.failed() > 0) { - log.error("워밍업 보류 — 통계 재계산 실패 {}건. 다음 회차가 다시 시도한다", stats.failed()); - return false; - } - indexer.reindexAll(); // 끝나면 준비 완료까지 함께 올린다 - store.countRebuild(); - log.info("탐색 인덱스 워밍업 완료 reason={}", reason); - return true; // 방금 원천으로 다시 만들었다 — 유령이 있을 수 없다 + return underRebuildLease(token, () -> { + // 통계는 다시 세지 않고 지금 값으로 바로 만든다. 통계는 MySQL 에 있어 Redis 를 잃어도 + // 그대로이고, 틀어질 수 있는 것은 최근에 유실된 이벤트뿐이다 — 평소에도 5분 보정이 따라잡는 범위다. + // 다 세고 나서 만들면 그동안 탐색이 503 이다: 부하테스트 2026-10 Redis 재부팅에서 진행 중인 방 + // 3.5만 개를 세느라 25분 넘게 503 이었다(sync 가 멤버 행을 계속 고쳐, 시간 창으로 좁혀도 거의 줄지 않는다). + // 대신 다음 보정이 유실 가능 구간을 다시 세도록 기준 시각을 그만큼 앞에 둔다. 전체 재계산은 03:30 대조가 맡는다. + indexer.reindexAll(); // 끝나면 준비 완료까지 함께 올린다 + store.countRebuild(); + store.markCalculatedAt(startedAt); + store.markSweptAt(startedAt.minus(REBUILD_STATS_LOOKBACK)); + log.info("탐색 인덱스 워밍업 완료 reason={} took={}s", reason, + java.time.Duration.between(startedAt, Instant.now()).toSeconds()); + return true; // 방금 원천으로 다시 만들었다 — 유령이 있을 수 없다 + }); } finally { store.unlockRebuild(token); } } catch (RuntimeException e) { - circuit.openManually("워밍업 실패(" + reason + "): " + e); + circuit.openManually("워밍업 실패(" + reason + "): " + rootCause(e)); return false; } } @@ -252,17 +294,7 @@ public void reconcile() { return; } try { - // 먼저 원천에서 통계를 다시 계산한다. 이미 계산된 값을 읽어 옮기기만 하면, - // 그 값이 틀어졌을 때 대조가 틀린 값을 충실히 복사한다 — 그건 복구가 아니다. - // 스펙이 이 배치에 요구하는 것은 「원천으로 재계산하고 비교·보정」이다(백엔드 9). - var stats = statsReconciliation.run(); - if (stats.failed() > 0) { - // 틀린 값으로 인덱스를 다시 만들면 그 값이 「최신」 표를 달고 굳는다. - log.error("대조 보류 — 통계 재계산 실패 {}건. 다음 회차가 다시 시도한다", stats.failed()); - return; - } - indexer.reindexAll(); - store.countRebuild(); + underRebuildLease(token, () -> { reconcileLocked(); return null; }); } catch (RuntimeException e) { log.error("탐색 인덱스 대조 실패 — 다음 회차가 다시 시도한다: {}", e.toString()); } finally { @@ -270,6 +302,59 @@ public void reconcile() { } } - /** 전수 재구성 잠금의 수명. 한 회차가 이보다 오래 걸리면 다음 회차가 겹칠 수 있다. */ - private static final java.time.Duration REBUILD_LOCK_TTL = java.time.Duration.ofMinutes(30); + private void reconcileLocked() { + // 먼저 원천에서 통계를 다시 계산한다. 이미 계산된 값을 읽어 옮기기만 하면, + // 그 값이 틀어졌을 때 대조가 틀린 값을 충실히 복사한다 — 그건 복구가 아니다. + // 스펙이 이 배치에 요구하는 것은 「원천으로 재계산하고 비교·보정」이다(백엔드 9). + var stats = statsReconciliation.run(); + if (stats.failed() > 0) { + // 틀린 값으로 인덱스를 다시 만들면 그 값이 「최신」 표를 달고 굳는다. + log.error("대조 보류 — 통계 재계산 실패 {}건. 다음 회차가 다시 시도한다", stats.failed()); + return; + } + indexer.reindexAll(); + store.countRebuild(); + } + + /** + * 전수 재구성 잠금은 짧게 잡고 도는 동안 늘린다. + * + *

30분짜리를 한 번에 잡으면, 잡은 태스크가 도중에 죽었을 때(배포·스케일인·DB 장애) 그 30분 동안 + * 아무도 다시 만들지 못한다 — 부하테스트 2026-10 에서 Redis 재부팅 뒤 새 태스크가 잠금을 잡고 RDS 장애조치에 + * 걸려 사라지자, 탐색·인기 503 이 25분 넘게 이어졌다. 살아 있는 동안만 잠금이 유지되게 한다. + */ + private static final java.time.Duration REBUILD_LOCK_TTL = java.time.Duration.ofMinutes(2); + private static final java.time.Duration REBUILD_LOCK_RENEW = java.time.Duration.ofSeconds(30); + /** + * 워밍업 뒤 첫 보정이 통계를 다시 셀 범위. Redis 와 함께 마지막 보정 시각도 사라지므로, 그 직전 보정(최대 5분 전)과 + * 재구성 사이에 유실됐을 이벤트를 넉넉히 덮는다. 그보다 오래된 어긋남은 03:30 대조가 잡는다. + */ + private static final java.time.Duration REBUILD_STATS_LOOKBACK = java.time.Duration.ofMinutes(30); + + private final java.util.concurrent.ScheduledExecutorService leaseRenewer = + java.util.concurrent.Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "explore-rebuild-lease"); + t.setDaemon(true); + return t; + }); + + private T underRebuildLease(String token, java.util.function.Supplier work) { + var renewal = leaseRenewer.scheduleAtFixedRate(() -> { + try { + if (!store.extendRebuild(token, REBUILD_LOCK_TTL)) log.warn("탐색 재구성 잠금을 잃었다 — 다른 인스턴스와 겹칠 수 있다"); + } catch (RuntimeException e) { + log.warn("탐색 재구성 잠금 연장 실패: {}", e.toString()); + } + }, REBUILD_LOCK_RENEW.toMillis(), REBUILD_LOCK_RENEW.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + try { + return work.get(); + } finally { + renewal.cancel(false); + } + } + + @jakarta.annotation.PreDestroy + void stopLeaseRenewer() { + leaseRenewer.shutdownNow(); + } } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java index 6fbc3256..bf3f1760 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java @@ -31,6 +31,13 @@ public class ExploreRedisStore { private final StringRedisTemplate redis; + // ===== 연결 ===== + + /** 연결을 맺고 왕복 한 번 — 기동 직후 첫 연결을 워밍업 본작업과 떼어 두려고 쓴다. */ + public void ping() { + redis.execute((org.springframework.data.redis.core.RedisCallback) c -> c.ping()); + } + // ===== 워밍업 플래그 ===== public boolean isWarmed() { @@ -143,6 +150,30 @@ public Map getStats(UUID challengeId) { return redis.opsForHash().entries(ExploreKeys.stats(challengeId)); } + /** + * 여러 방의 표시값 HASH 를 왕복 한 번에 읽는다. 목록 한 페이지가 방마다 HGETALL 을 따로 보내면 + * 후보 수(최대 100)만큼 왕복이 쌓이고, 그동안 요청이 읽기 트랜잭션의 커넥션을 쥐고 있다(부하테스트 2026-10). + * + * @return 방 id → HASH(없으면 빈 맵). {@code ids} 의 순서를 따른다. + */ + public Map> getStats(List ids) { + if (ids.isEmpty()) return Map.of(); + List replies = redis.executePipelined( + (org.springframework.data.redis.core.RedisCallback) connection -> { + for (UUID id : ids) + connection.hashCommands().hGetAll(ExploreKeys.stats(id).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return null; + }); + Map> out = new java.util.LinkedHashMap<>(ids.size()); + for (int i = 0; i < ids.size(); i++) { + Object reply = i < replies.size() ? replies.get(i) : null; + @SuppressWarnings("unchecked") + Map hash = reply instanceof Map m ? (Map) m : Map.of(); + out.put(ids.get(i), hash); + } + return out; + } + /** * 표시값 HASH 를 통째로 갈아 끼운다. * @@ -440,6 +471,15 @@ public void unlockRebuild(String token) { releaseIfOwner(ExploreKeys.REBUILD_LOCK, token); } + /** 내가 잡은 재구성 잠금의 수명을 늘린다. 이미 남의 것(또는 만료)이면 false. */ + public boolean extendRebuild(String token, java.time.Duration ttl) { + Long r = redis.execute(new org.springframework.data.redis.core.script.DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) " + + "else return 0 end", Long.class), + java.util.List.of(ExploreKeys.REBUILD_LOCK), token, String.valueOf(ttl.toMillis())); + return r != null && r == 1L; + } + /** 값이 내 토큰일 때만 지운다 — 비교와 삭제를 한 스크립트로 묶어야 그 사이가 벌어지지 않는다. */ private void releaseIfOwner(String key, String token) { if (token == null) return; @@ -472,21 +512,29 @@ public java.util.Set membersOf(String key) { * * @return 방 id → {@code fields} 와 같은 순서의 멤버 목록(없는 필드는 {@code null}) */ + private static final int PIPELINE_CHUNK = 1000; + public Map> sortMembersOf(List ids, List fields) { if (ids.isEmpty() || fields.isEmpty()) return Map.of(); byte[][] rawFields = fields.stream() .map(f -> f.getBytes(java.nio.charset.StandardCharsets.UTF_8)) .toArray(byte[][]::new); - List replies = redis.executePipelined( - (org.springframework.data.redis.core.RedisCallback) connection -> { - for (UUID id : ids) { - connection.hashCommands().hMGet( - ExploreKeys.stats(id).getBytes(java.nio.charset.StandardCharsets.UTF_8), - rawFields); - } - return null; - }); + // 한 파이프라인에 다 싣지 않는다 — 응답 전체가 명령 제한 시간(2초) 안에 와야 해서, 방 3.5만 개를 + // 한 번에 보내면 타임아웃으로 재구성이 통째로 실패했다(부하테스트 2026-10 Redis 재부팅 복구). + List replies = new ArrayList<>(ids.size()); + for (int from = 0; from < ids.size(); from += PIPELINE_CHUNK) { + List chunk = ids.subList(from, Math.min(ids.size(), from + PIPELINE_CHUNK)); + replies.addAll(redis.executePipelined( + (org.springframework.data.redis.core.RedisCallback) connection -> { + for (UUID id : chunk) { + connection.hashCommands().hMGet( + ExploreKeys.stats(id).getBytes(java.nio.charset.StandardCharsets.UTF_8), + rawFields); + } + return null; + })); + } Map> out = new java.util.HashMap<>(ids.size()); for (int i = 0; i < ids.size() && i < replies.size(); i++) { diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java index b786def2..2e41606b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java @@ -59,11 +59,6 @@ public static VerificationGuideFacts of(Challenge c, RoutineTemplate template) { fallback = "장소를 등록한 뒤 " + freq + " 그곳에 " + (dur != null ? dur + " 이상 " : "") + "머물면 자동 인증됩니다."; } - case "GPS_AVOID" -> { - conditions.add("등록한 장소에 머물지 않기(잠깐 스쳐 지나가는 것은 괜찮다)"); - fallback = "피하고 싶은 장소를 등록하고 그곳에 머물지 않으면 자동 인증됩니다. " - + "잠깐 스쳐 지나가는 것은 괜찮습니다."; - } case "HEALTH" -> { String steps = steps(params.get("steps")); String km = km(params.get("distance_km")); @@ -83,9 +78,9 @@ public static VerificationGuideFacts of(Challenge c, RoutineTemplate template) { } case "WAKE" -> { String t = clock(params.get("target_time")); - conditions.add((t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금 해제하기"); + conditions.add((t != null ? t : "목표 시각") + "쯤 일어나 걷기(걸음 기록 기준)"); if (t != null) must.add(t); - fallback = freq + " " + (t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금을 해제하면 자동 인증됩니다."; + fallback = freq + " " + (t != null ? t : "목표 시각") + "에 일어나 조금 걸으면 걸음 기록으로 자동 인증됩니다."; } case "SLEEP" -> { String bed = clock(params.get("bedtime_before")); diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java index b1831aca..af28e2b1 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java @@ -119,7 +119,7 @@ static boolean acceptable(VerificationGuideFacts f, String guide) { for (String must : f.mustContain()) { if (!guide.contains(must)) return false; // 판정 기준 값이 바뀌거나 빠졌다 } - // 「매일」은 장소 피하기처럼 문장에서 자연스럽게 빠질 수 있다. 「주 3회」는 빠지면 조건이 바뀐다 + // 「매일」은 문장에서 자연스럽게 빠질 수 있다. 「주 3회」는 빠지면 조건이 바뀐다 if (!"매일".equals(f.frequency()) && !guide.contains(f.frequency())) return false; return f.auto() ? guide.contains("자동 인증") : guide.contains("직접 인증"); } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java index f6a192fe..5252e3d3 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java @@ -18,7 +18,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; @@ -42,17 +41,38 @@ public class ChallengeActivationService { private static final Logger log = LoggerFactory.getLogger(ChallengeActivationService.class); private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final int CLAIM_LIMIT = 200; + /** 한 번 실행에서 쓰는 최대 시간 — 락(lockAtMostFor 10분) 안에서 끝나야 다른 태스크가 겹쳐 잡지 않는다. */ + private static final java.time.Duration RUN_BUDGET = java.time.Duration.ofMinutes(4); private final ChallengeRepository challengeRepository; private final ChallengeMemberRepository memberRepository; private final NotificationPublisher notificationPublisher; private final ApplicationEventPublisher eventPublisher; + private final org.springframework.transaction.PlatformTransactionManager transactionManager; - /** 1분마다: 시작일이 도달한 시작 전(UPCOMING)·모더레이션 통과 챌린지를 ACTIVE 로 전환한다. */ + /** + * 1분마다: 시작일이 도달한 시작 전(UPCOMING)·모더레이션 통과 챌린지를 ACTIVE 로 전환한다. + * + *

남은 것이 없을 때까지 200건씩 끊어 돈다(한 덩어리 = 한 트랜잭션). 예전엔 한 번에 200건만 하고 끝나 + * 1분에 200건이 상한이었다 — 생성 기본값이 「내일 시작」이라 시작일이 자정에 몰리는데, 부하테스트(2026-10)에서 + * 4,870건이 다 ACTIVE 가 되기까지 22분 걸렸다. 그동안 그 방들은 오늘 시작인데 「시작 전」이고 인증도 평가되지 않는다. + * 덩어리마다 트랜잭션을 끊어 잠금·커밋을 짧게 두고, 실행 한 번은 {@link #RUN_BUDGET} 안에서 멈춘다. + */ @SchedulerLock(name = "ChallengeActivationService.activateDueChallenges", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") @Scheduled(fixedDelay = 60_000) - @Transactional public void activateDueChallenges() { + var tx = new org.springframework.transaction.support.TransactionTemplate(transactionManager); + java.time.Instant deadline = java.time.Instant.now().plus(RUN_BUDGET); + int total = 0; + while (true) { + Integer done = tx.execute(status -> activateChunk()); + total += (done == null ? 0 : done); + if (done == null || done < CLAIM_LIMIT || java.time.Instant.now().isAfter(deadline)) break; + } + if (total > CLAIM_LIMIT) log.info("시작일 도달 전환 합계 {}건(한 번 실행)", total); + } + + private int activateChunk() { LocalDate today = LocalDate.now(KST); List due = challengeRepository.findUpcomingDueForActivationForUpdate(today, CLAIM_LIMIT); for (Challenge c : due) { @@ -66,6 +86,7 @@ public void activateDueChallenges() { eventPublisher.publishEvent(ChallengeGridChanged.of("CHALLENGE_ACTIVATED")); log.info("시작일 도달로 ACTIVE 전환한 챌린지 {}건", due.size()); } + return due.size(); } /** diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java b/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java index b8c4836f..a47d1618 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import software.amazon.awssdk.services.sqs.SqsClient; @@ -27,6 +29,7 @@ public record MessageBody(UUID challengeId, List targets) {} private final boolean local; private final ChallengeModerationStore store; private final ChallengeModerationService service; + private volatile boolean stopping; public ChallengeModerationQueue(@Qualifier("challengeModerationSqsClient") ObjectProvider sqs, @Value("${app.moderation.queue.url:}") String queueUrl, @@ -60,9 +63,19 @@ public boolean configured() { return local || (!queueUrl.isEmpty() && sqs.getIfAvailable() != null); } + /** + * 종료가 시작되면 새 수신을 멈춘다. 컨텍스트 종료 이벤트는 빈 파괴보다 먼저 온다 — SqsClient 가 + * 닫힌 뒤에도 5초 폴러가 돌아 배포 때마다 옛 태스크가 「Connection pool shut down」· + * RejectedExecutionException 을 ERROR 로 남겼다. 받아 둔 메시지는 가시성 만료 후 새 태스크가 받는다. + */ + @EventListener(ContextClosedEvent.class) + public void stop() { + stopping = true; + } + @Scheduled(fixedDelayString = "${app.moderation.queue.poll-delay-ms:5000}") public void poll() { - if (local || queueUrl.isEmpty()) return; + if (local || queueUrl.isEmpty() || stopping) return; try { for (Message message : sqs.getObject().receiveMessage(ReceiveMessageRequest.builder() .queueUrl(queueUrl).maxNumberOfMessages(1).waitTimeSeconds(1).visibilityTimeout(120) @@ -70,6 +83,8 @@ public void poll() { handle(message); } } catch (RuntimeException failure) { + // 종료 신호와 같은 순간에 시작된 수신은 닫히는 클라이언트에 걸릴 수 있다 — 장애가 아니다. + if (stopping) return; // 클래스 이름만 남기면 enqueue 때처럼 원인이 가려진다 — 메시지까지 남긴다. log.error("moderation_receive_failed error={}", failure.toString()); } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiter.java b/src/main/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiter.java index 5f347134..5a3c9428 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiter.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiter.java @@ -7,6 +7,7 @@ import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; @@ -14,7 +15,7 @@ import java.time.Duration; import java.util.List; -/** 사용자별 60초 fixed window. Redis 장애 동안만 Caffeine으로 제한한다. */ +/** 사용자별 60초 fixed window. Redis 장애 동안만 Caffeine으로 제한한다. 한도는 {@code app.challenge.draft.rate-limit-per-minute}(기본 10). */ @Component public class RecommendationRateLimiter { private static final Logger log = LoggerFactory.getLogger(RecommendationRateLimiter.class); @@ -28,10 +29,13 @@ public class RecommendationRateLimiter { private final Cache windows = Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(1)).build(); private volatile long retryRedisAt; + private final int limitPerMinute; - public RecommendationRateLimiter(StringRedisTemplate redis, MeterRegistry metrics) { + public RecommendationRateLimiter(StringRedisTemplate redis, MeterRegistry metrics, + @Value("${app.challenge.draft.rate-limit-per-minute:10}") int limitPerMinute) { this.redis = redis; this.metrics = metrics; + this.limitPerMinute = limitPerMinute; } public void check(String userId) { @@ -60,8 +64,8 @@ public void check(String userId) { } } - private static void rejectIfLimited(long count, long ttl) { - if (count > 10) throw BusinessException.rateLimited(ErrorCode.RECOMMENDATION_RATE_LIMITED, ttl); + private void rejectIfLimited(long count, long ttl) { + if (count > limitPerMinute) throw BusinessException.rateLimited(ErrorCode.RECOMMENDATION_RATE_LIMITED, ttl); } private static class Window { diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/reminder/GhostPushSetupReminderService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/reminder/GhostPushSetupReminderService.java index 19075b9e..906ec56d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/reminder/GhostPushSetupReminderService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/reminder/GhostPushSetupReminderService.java @@ -17,10 +17,12 @@ import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -51,32 +53,45 @@ public class GhostPushSetupReminderService { private final ChallengeRepository challengeRepository; private final PushSender pushSender; private final NotificationPublisher notificationPublisher; + private final PlatformTransactionManager transactionManager; /** 1시간마다: 셋업 미완료(권한 없음) AUTO 멤버를 깨우는 무음 푸시를 보낸다. */ @SchedulerLock(name = "GhostPushSetupReminderService.nudgeSetupPendingMembers", lockAtMostFor = "PT30M", lockAtLeastFor = "PT30M") @Scheduled(fixedDelay = 3_600_000L) - @Transactional public void nudgeSetupPendingMembers() { Instant now = Instant.now(); Instant cooldownThreshold = now.minus(COOLDOWN); - List claimed = - memberRepository.findSetupPendingForGhostPushForUpdate(cooldownThreshold, CLAIM_LIMIT); - - int sent = 0; - for (ChallengeMember m : claimed) { - Challenge c = challengeRepository.findByIdAndDeletedAtIsNull(m.getChallengeId()).orElse(null); - // 소프트삭제됐거나 AUTO 인증이 아니면(수동은 즉시형 카메라뿐 → 권한 유도 대상 아님) 건너뛴다. - if (c == null || c.getVerificationConfig() == null - || c.getVerificationConfig().selectedMethod() != SelectedMethod.AUTO) { - continue; + // 선점·쿨다운 기록·알림함 적재까지만 트랜잭션에서 한다. 무음 푸시(FCM, 건당 수백 ms·최대 5초)는 커밋 뒤에 보낸다 — + // 예전에는 선점한 멤버 행 잠금과 커넥션을 쥔 채 최대 200건을 순차로 보냈다(부하테스트 2026-10 점검). + // 발송이 실패해도 다음 쿨다운 뒤 다시 보낸다(알림함 고지는 이미 남는다). + List sent = new ArrayList<>(); + List challengeIds = new ArrayList<>(); + int claimedCount = new TransactionTemplate(transactionManager).execute(tx -> { + List claimed = + memberRepository.findSetupPendingForGhostPushForUpdate(cooldownThreshold, CLAIM_LIMIT); + for (ChallengeMember m : claimed) { + Challenge c = challengeRepository.findByIdAndDeletedAtIsNull(m.getChallengeId()).orElse(null); + // 소프트삭제됐거나 AUTO 인증이 아니면(수동은 즉시형 카메라뿐 → 권한 유도 대상 아님) 건너뛴다. + if (c == null || c.getVerificationConfig() == null + || c.getVerificationConfig().selectedMethod() != SelectedMethod.AUTO) { + continue; + } + notifyPermissionRegrant(m.getUserId(), c); + m.markGhostPushed(now); // 쿨다운 기준 갱신(선점 락 유지 중) + sent.add(m); + challengeIds.add(c.getId().toString()); + } + return claimed.size(); + }); + for (int i = 0; i < sent.size(); i++) { + try { + pushSender.sendSilent(sent.get(i).getUserId(), SilentPush.setupRequired(challengeIds.get(i))); + } catch (RuntimeException e) { // 한 명의 전송 실패가 나머지 발송을 막지 않게 + log.warn("ghost_push_failed userId={} error={}", sent.get(i).getUserId(), e.getClass().getSimpleName()); } - pushSender.sendSilent(m.getUserId(), SilentPush.setupRequired(c.getId().toString())); - notifyPermissionRegrant(m.getUserId(), c); - m.markGhostPushed(now); // 쿨다운 기준 갱신(같은 트랜잭션에서 선점 락 유지 중) - sent++; } - if (sent > 0) { - log.info("셋업 미완료 멤버 고스트 푸시 {}건 발송(대상 선점 {}건)", sent, claimed.size()); + if (!sent.isEmpty()) { + log.info("셋업 미완료 멤버 고스트 푸시 {}건 발송(대상 선점 {}건)", sent.size(), claimedCount); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java b/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java index 61cd10fe..2111f519 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/repository/ChallengeRepository.java @@ -24,6 +24,9 @@ public interface ChallengeRepository extends JpaRepository { /** 삭제되지 않은 챌린지 1건 */ Optional findByIdAndDeletedAtIsNull(UUID id); + /** 삭제되지 않은 챌린지 여러 건 — sync 가 사용자 방들을 한 번에 읽는다. */ + List findAllByIdInAndDeletedAtIsNull(java.util.Collection ids); + /** * 삭제되지 않은 챌린지 1건을 비관적 쓰기 잠금으로 로드(가입 정원 경합·삭제 0명 판정 직렬화, §5·§8). * 잠금 하에서 참여자 수를 세고 삽입/삭제해 "마지막 1자리 동시 가입"·"0명 판정 vs 신규 가입"을 차단한다. diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java index 25ac0968..260a8727 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java @@ -219,6 +219,7 @@ public JoinBlockReason previewBlockReason(UUID userId, Challenge c, boolean invi /** * 챌린지 탈퇴(본인). 언제든 나갈 수 있다 — 구 "재참여 영구 불가"·"방장 탈퇴 불가"는 둘 다 폐기. * - 방장이 권한을 넘기지 않고 나가면 즉시 봇방장 체제로 전환하고 잔류 멤버에게 승계 알림을 보낸다(§11.2). + * 단 남는 멤버가 없으면 봇방장으로 넘기지 않고 방을 닫는다 — {@link #closeIfLastMember}. * - 감점 면제: 1년 이상 성공(LONG_SUCCESS) / 승계 3일 면책(SUCCESSION_GRACE — 모든 멤버 기준, §11.3). * - 재입장은 1주 대기. */ @@ -240,9 +241,10 @@ public LeaveResponse leave(UUID userId, UUID challengeId) { // 면제 판정은 상태를 바꾸기 전에 — 나가면서 스스로 만든 봇방장 전환으로 자기 감점을 면제받으면 안 된다. String exemptReason = resolveExemptReason(c, me, now); - // 방장이 넘기지 않고 나감 → 즉시 봇방장 체제 + 잔류 멤버에게 승계 알림 + // 마지막 멤버가 나가면 방을 닫는다. 아니면 방장이 넘기지 않고 나감 → 즉시 봇방장 체제 + 승계 알림 + boolean closed = closeIfLastMember(c); boolean botOwnerActivated = false; - if (c.isOwner(userId)) { + if (!closed && c.isOwner(userId)) { c.convertToBotOwner(now); botOwnerActivated = true; notifyBotOwnerActivated(c, userId); @@ -274,8 +276,8 @@ public LeaveResponse leave(UUID userId, UUID challengeId) { source, completedWeeks, "AUTO", scoreDelta, now), source); outboxDispatcher.requestFlush(); } - log.info("challenge_leave challengeId={} userId={} penalty={} exempt={} beforeStart={} botOwner={}", - challengeId, userId, scoreDelta, exemptReason, beforeStart, botOwnerActivated); + log.info("challenge_leave challengeId={} userId={} penalty={} exempt={} beforeStart={} botOwner={} closed={}", + challengeId, userId, scoreDelta, exemptReason, beforeStart, botOwnerActivated, closed); return new LeaveResponse(true, scoreDelta, exemptReason, rejoinAt.toString(), botOwnerActivated); } @@ -322,7 +324,7 @@ public int leaveAllExternally(UUID userId, String reason) { } - if (c.isOwner(userId)) { + if (!closeIfLastMember(c) && c.isOwner(userId)) { c.convertToBotOwner(now); notifyBotOwnerActivated(c, userId); } @@ -340,6 +342,22 @@ public int leaveAllExternally(UUID userId, String reason) { return left; } + /** + * 나가는 사람이 마지막 활성 멤버면 방을 닫는다(삭제 표시). 호출 시점에는 아직 그 사람이 ACTIVE 다. + * + *

0명 방은 봇방장으로 넘길 대상이 없다 — 넘기면 아무도 없는 방이 탐색·인기에 그대로 남아, + * 하루 1번 도는 유령방 삭제 배치가 지울 때까지(정산 대기가 있으면 1주 넘게) 노출되고 남이 가입해 + * 되살릴 수도 있었다. 삭제 표시만 하면 탐색·인기·상세·가입이 모두 즉시 막히고(커밋 뒤 탐색 인덱스 + * 재계산이 빼낸다), 이력 스냅샷과 실제 삭제는 같은 배치가 정산이 끝난 뒤 이어서 한다. + * + * @return 방을 닫았으면 true — 이때는 봇방장 전환·승계 알림을 하지 않는다 + */ + private boolean closeIfLastMember(Challenge c) { + if (memberRepository.countByChallengeIdAndStatus(c.getId(), MemberStatus.ACTIVE) > 1) return false; + c.softDelete(); + return true; + } + /** * 감점 계산에는 판정이 끝난 유효 주간만 센다. 경과 시간이나 미완료/중립 주간은 포함하지 않는다. */ diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeQueryService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeQueryService.java index 68816c2f..3a7a4151 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeQueryService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeQueryService.java @@ -39,6 +39,14 @@ public Optional findActiveChallenge(UUID challengeId) { return challengeRepository.findByIdAndDeletedAtIsNull(challengeId); } + /** 챌린지 여러 건(소프트 삭제 제외)을 한 번에 — id → 챌린지. 없는 id 는 빠진다. */ + public java.util.Map findActiveChallenges(java.util.Collection challengeIds) { + if (challengeIds.isEmpty()) return java.util.Map.of(); + java.util.Map byId = new java.util.HashMap<>(); + for (Challenge c : challengeRepository.findAllByIdInAndDeletedAtIsNull(challengeIds)) byId.put(c.getId(), c); + return byId; + } + /** 멤버십 1건(PK). */ public Optional findMember(UUID memberId) { return memberRepository.findById(memberId); diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java index e7b340d1..01a09fbb 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java @@ -104,7 +104,9 @@ private String liveSelect(MyChallengeFilter filter, UUID userId, List ar " ELSE m.success_days / (m.success_days + m.fail_days) END " + " AS DECIMAL(6,4)) AS success_rate " + "FROM challenge_members m JOIN challenges c ON c.id = m.challenge_id " + - "WHERE m.user_id = ? AND c.deleted_at IS NULL AND " + memberAndChallenge; + // 이탈 탭은 내가 나가서 닫힌(삭제 표시된) 방도 보여 준다 — 실제 삭제 뒤에는 이력에서 같은 행이 나온다. + "WHERE m.user_id = ? AND " + (filter == MyChallengeFilter.LEFT ? "" : "c.deleted_at IS NULL AND ") + + memberAndChallenge; } // ===== 원천 2: 삭제된 방의 이력 스냅샷 ===== diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsEventListener.java b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsEventListener.java index 9625d6a8..14c2e026 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsEventListener.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsEventListener.java @@ -4,14 +4,20 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + /** * 통계 재계산 이벤트 수신 (탐색 백엔드 테크스펙 §6-5). * @@ -19,12 +25,16 @@ * 이미 성공한 가입·탈퇴를 통계 때문에 되돌릴 이유가 없기 때문이다. 대신 로그를 남기고 * 다음 이벤트 또는 일 1회 reconciliation 이 복구한다. * + *

같은 방은 한 번에 하나만 돌리고, 도는 동안 들어온 요청은 끝난 뒤 한 번으로 합친다. 재계산은 방 행을 + * 잠그고 원천을 다시 세므로, 끝난 뒤의 한 번이 그사이 커밋을 모두 본다. 예전에는 이벤트마다 공용 비동기 + * 실행기(8스레드·대기열 무제한)에 따로 쌓여, 인기 방 가입·확정이 몰리면 같은 방 재계산 여럿이 커넥션을 쥔 채 + * 방 행 잠금을 줄 서 기다렸다. 전용 스레드 4개로 따로 돌려 다른 비동기 작업과 커넥션을 다투지 않게 한다. + * *

탐색 파생 인덱스도 여기서 밀어 넣는다. 순서가 중요하다 — {@code challenge_stats} 를 * 먼저 갱신하고 그 결과를 Redis 로 투영해야 두 저장소가 같은 값을 본다. 반대로 두면 Redis 가 * 직전 통계로 정렬돼 SQL 폴백과 순서가 어긋난다. */ @Component -@RequiredArgsConstructor public class ChallengeStatsEventListener { private static final Logger log = LoggerFactory.getLogger(ChallengeStatsEventListener.class); @@ -32,10 +42,37 @@ public class ChallengeStatsEventListener { private final ChallengeStatsProjectionService projectionService; private final ExploreIndexer exploreIndexer; private final MeterRegistry meterRegistry; + /** 재계산 중이거나 대기 중인 방 → 그사이 다시 요청이 왔는가(한 번 더 돌려야 하는가). */ + private final Map pending = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newFixedThreadPool(4, Thread.ofPlatform().name("stats-refresh-", 0).daemon().factory()); + + public ChallengeStatsEventListener(ChallengeStatsProjectionService projectionService, ExploreIndexer exploreIndexer, + MeterRegistry meterRegistry) { + this.projectionService = projectionService; + this.exploreIndexer = exploreIndexer; + this.meterRegistry = meterRegistry; + } - @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRefreshRequested(ChallengeStatsRefreshRequested event) { + boolean[] start = {false}; + pending.compute(event.challengeId(), (id, again) -> { + if (again == null) { start[0] = true; return Boolean.FALSE; } + return Boolean.TRUE; // 이미 돌고 있다 — 끝난 뒤 한 번 더 + }); + if (start[0]) executor.execute(() -> drain(event)); + else meterRegistry.counter("challenge_stats_refresh_coalesced_total").increment(); + } + + private void drain(ChallengeStatsRefreshRequested event) { + while (true) { + refresh(event); + // 그사이 요청이 왔으면 표시를 지우고 한 번 더, 아니면 빠진다(없애는 것과 확인이 한 번에 일어난다). + if (pending.computeIfPresent(event.challengeId(), (id, again) -> again ? Boolean.FALSE : null) == null) return; + } + } + + private void refresh(ChallengeStatsRefreshRequested event) { Timer.Sample sample = Timer.start(meterRegistry); try { projectionService.refresh(event.challengeId()); @@ -55,4 +92,10 @@ public void onRefreshRequested(ChallengeStatsRefreshRequested event) { .register(meterRegistry)); } } + + @PreDestroy + void shutdown() throws InterruptedException { + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java index 21c54c03..dc93a955 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java @@ -60,6 +60,12 @@ public void createRow(UUID challengeId) { public void refresh(UUID challengeId) { byte[] id = toBytes(challengeId); + // 세기 전에 방 행부터 잠근다. InnoDB 는 첫 일반 SELECT 에서 REPEATABLE READ 스냅샷을 + // 고정한다. 먼저 세고 나서 락을 기다리면, 기다리는 동안 커밋된 가입·판정은 못 본 채 앞서 끝난 + // 재계산의 최신 값을 옛 값으로 덮는다(부하테스트 2026-10: 2배 피크에서 5분 보정이 43건을 고쳤다). + // 잠금 읽기는 스냅샷을 만들지 않으므로, 락을 얻은 뒤의 첫 SELECT 가 그 사이 커밋을 모두 본다. + jdbc.queryForList("SELECT id FROM challenges WHERE id = ? FOR UPDATE", (Object) id); + // 참여자 수는 원천에서 다시 센다. 가입·탈퇴 트랜잭션은 더 이상 표시값을 올리고 // 내리지 않는다(탐색 백엔드 5-2·12) — 같은 방의 모든 가입이 그 한 행에서 직렬화되기 // 때문이다. 세어 둔 값을 challenges 에 되써 주는 이유는 다른 모듈(자동 삭제·추천 집계· @@ -69,7 +75,7 @@ public void refresh(UUID challengeId) { Integer.class, id); if (participantCount == null) participantCount = 0; - // challenges 를 먼저, 그리고 배타로 잡는다. 순서를 뒤집으면 락을 승격하게 된다 — + // challenges 를 먼저, 그리고 배타로 잡는다(위 FOR UPDATE). 순서를 뒤집으면 락을 승격하게 된다 — // challenge_stats 에 INSERT 하는 순간 외래키 검사가 부모 행에 공유 락을 걸고, 그 뒤의 // 이 UPDATE 가 같은 행을 배타 락으로 올린다. 그사이 다른 트랜잭션(강퇴·설정 변경처럼 // challenges 를 처음부터 배타로 잡는 경로)이 같은 행의 배타 락을 기다리고 있으면, diff --git a/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java b/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java index b7ad8f06..a2456053 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java @@ -326,6 +326,8 @@ public enum ErrorCode { // ===== 공통 ===== TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + // DB 에 잠시 닿지 못함(장애조치·풀 포화). 요청 자체는 멀쩡하니 잠시 뒤 다시 보내면 된다. + TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "잠시 후 다시 시도해 주세요."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다."); diff --git a/src/main/java/com/ruleup/ruleup_backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/ruleup/ruleup_backend/common/error/GlobalExceptionHandler.java index bac5c8ca..cf71e148 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/error/GlobalExceptionHandler.java @@ -82,9 +82,33 @@ public ResponseEntity> handleUnexpected(Exception e) { ClientErrorLog.record(tooLarge); return ResponseEntity.status(tooLarge.getStatus()).body(ApiResponse.fail(ErrorResponse.of(tooLarge))); } + if (causedByDbUnavailable(e)) { + // 장애조치 동안 요청마다 스택을 남기면 로그만 수백 건이 쌓인다 — 한 줄로 남기고 503 으로 돌려보낸다. + log.warn("DB 일시 불가 — 503: {}", e.getMessage()); + ErrorCode code = ErrorCode.TEMPORARILY_UNAVAILABLE; + ClientErrorLog.record(code); + return ResponseEntity.status(code.getStatus()).header("Retry-After", "2") + .body(ApiResponse.fail(ErrorResponse.of(code))); + } return handleTrulyUnexpected(e); } + /** + * 커넥션을 못 얻었거나(풀 대기 초과) DB 연결이 끊겼다 — 서버 버그가 아니라 일시 장애다. + * 500 이면 클라는 재시도하지 않고 경보는 코드 결함으로 읽는다(부하테스트 2026-10 RDS 장애조치: 55초간 500 수백 건). + */ + public static boolean causedByDbUnavailable(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof java.sql.SQLTransientConnectionException + || t instanceof org.springframework.transaction.CannotCreateTransactionException + || t instanceof org.springframework.jdbc.CannotGetJdbcConnectionException + || t instanceof java.sql.SQLNonTransientConnectionException + || "com.mysql.cj.jdbc.exceptions.CommunicationsException".equals(t.getClass().getName())) return true; + if (t.getCause() == t) break; + } + return false; + } + private ResponseEntity> handleTrulyUnexpected(Exception e) { log.error("Unexpected error", e); ErrorCode code = ErrorCode.INTERNAL_SERVER_ERROR; diff --git a/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitConfig.java b/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitConfig.java new file mode 100644 index 00000000..9358630b --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitConfig.java @@ -0,0 +1,29 @@ +package com.ruleup.ruleup_backend.common.web; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** 동시 처리 상한. {@code app.http.max-inflight} 가 0(기본)이면 켜지지 않는다. */ +@Configuration +@ConditionalOnExpression("${app.http.max-inflight:0} > 0") +public class InflightLimitConfig { + + @Bean + public FilterRegistrationBean inflightLimitFilter( + @Value("${app.http.max-inflight}") int maxInflight, + @Value("${app.http.inflight-wait-ms:1000}") long waitMillis, + MeterRegistry registry) { + FilterRegistrationBean reg = + new FilterRegistrationBean<>(new InflightLimitFilter(maxInflight, waitMillis, registry)); + // 요청 id·요청 로그(+1·+2) 뒤라 거절도 같은 id 로 남고, 시큐리티(-100)보다 앞이라 거절할 요청에 + // 토큰 검증·계정 상태 조회(DB)를 쓰지 않는다. + reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 3); + reg.addUrlPatterns("/api/*"); + return reg; + } +} diff --git a/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilter.java b/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilter.java new file mode 100644 index 00000000..33371dd2 --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilter.java @@ -0,0 +1,76 @@ +package com.ruleup.ruleup_backend.common.web; + +import com.ruleup.ruleup_backend.common.error.ClientErrorLog; +import com.ruleup.ruleup_backend.common.error.ErrorCode; +import com.ruleup.ruleup_backend.common.error.ErrorResponse; +import com.ruleup.ruleup_backend.common.response.ApiResponse; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * 태스크 한 대가 동시에 처리하는 API 요청 수의 상한 — 넘치면 잠깐 기다리고, 그래도 자리가 없으면 503 으로 돌려보낸다. + * + *

상한이 없으면 급증 때 받을 수 있는 것보다 많이 받아 모두가 느려진다. 부하테스트(2026-10, 500→1,500명 급증)에서 + * 요청 스레드 200개가 커넥션 15개를 기다리며 쌓여 쓰기 API p95 가 50초를 넘었고, ALB 가 60초에 끊어 504 가 났다. + * 일찍 거절하면 받은 요청은 제 속도로 끝나고, 앱은 Retry-After 를 보고 다시 보낸다. + * + *

헬스체크는 관리 포트(MANAGEMENT_SERVER_PORT)라 여기를 지나지 않는다. {@code /api/} 만 센다. + */ +public class InflightLimitFilter extends OncePerRequestFilter { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final Semaphore permits; + private final long waitMillis; + private final Counter rejected; + + public InflightLimitFilter(int maxInflight, long waitMillis, MeterRegistry registry) { + this.permits = new Semaphore(maxInflight, true); + this.waitMillis = waitMillis; + this.rejected = Counter.builder("http_inflight_rejected_total") + .description("동시 처리 상한에 걸려 503 으로 돌려보낸 요청 수").register(registry); + registry.gauge("http_inflight_available", permits, Semaphore::availablePermits); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + boolean acquired; + try { + acquired = permits.tryAcquire(waitMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + acquired = false; + } + if (!acquired) { + rejected.increment(); + ErrorCode code = ErrorCode.TEMPORARILY_UNAVAILABLE; + ClientErrorLog.record(request, code); + response.setStatus(code.getStatus().value()); + response.setHeader("Retry-After", "2"); + response.setContentType("application/json;charset=UTF-8"); + JSON.writeValue(response.getOutputStream(), ApiResponse.fail(ErrorResponse.of(code))); + return; + } + try { + chain.doFilter(request, response); + } finally { + permits.release(); + } + } +} diff --git a/src/main/java/com/ruleup/ruleup_backend/config/S3Config.java b/src/main/java/com/ruleup/ruleup_backend/config/S3Config.java index e2d7928d..9750adc6 100644 --- a/src/main/java/com/ruleup/ruleup_backend/config/S3Config.java +++ b/src/main/java/com/ruleup/ruleup_backend/config/S3Config.java @@ -30,7 +30,10 @@ public class S3Config { @Bean(destroyMethod = "close") public S3Client s3Client(@Value("${app.upload.s3.region:}") String region, @Value("${app.upload.s3.endpoint:}") String endpoint) { - S3ClientBuilder builder = S3Client.builder().region(resolveRegion(region)); + // 업로드·삭제는 요청 스레드에서 돈다 — SDK 기본은 호출 시간 제한이 없어 S3 가 느리면 요청(과 쥔 커넥션)이 끝없이 붙잡힌다. + S3ClientBuilder builder = S3Client.builder().region(resolveRegion(region)) + .overrideConfiguration(c -> c.apiCallTimeout(java.time.Duration.ofSeconds(15)) + .apiCallAttemptTimeout(java.time.Duration.ofSeconds(8))); applyEndpoint(endpoint, builder::endpointOverride, builder::serviceConfiguration); return builder.build(); } diff --git a/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java b/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java index 00f0fb3b..0fd619de 100644 --- a/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java +++ b/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; import org.springframework.jdbc.core.JdbcTemplate; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; @@ -27,6 +29,7 @@ public class UserModerationQueue { private final String url; private final boolean local; private final ObjectMapper json = new ObjectMapper(); + private volatile boolean stopping; public UserModerationQueue(JdbcTemplate jdbc, UserModerationService moderation, @Qualifier("challengeModerationSqsClient") ObjectProvider clients, @@ -64,9 +67,15 @@ else if (!url.isBlank()) { } } + /** 종료가 시작되면 새 수신을 멈춘다 — 닫힌 SqsClient 로 폴링하지 않게(ChallengeModerationQueue#stop 과 같은 이유). */ + @EventListener(ContextClosedEvent.class) + public void stop() { + stopping = true; + } + @Scheduled(fixedDelayString = "${app.moderation.poll-ms:5000}") public void poll() { - if (local || url.isBlank()) return; + if (local || url.isBlank() || stopping) return; try { SqsClient sqs = clients.getObject(); for (var message : sqs.receiveMessage(b -> b.queueUrl(url).maxNumberOfMessages(1) @@ -76,7 +85,9 @@ public void poll() { // Pending on provider failure remains eligible for tomorrow's sweep. sqs.deleteMessage(b -> b.queueUrl(url).receiptHandle(message.receiptHandle())); } - } catch (Exception failure) { log.warn("user_moderation_consume_failed error={}", failure.getClass().getSimpleName()); } + } catch (Exception failure) { + if (!stopping) log.warn("user_moderation_consume_failed error={}", failure.getClass().getSimpleName()); + } } public record MessageBody(UUID userId, List targets) {} diff --git a/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java b/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java index 2dd3a8ae..cbd1c81b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java +++ b/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java @@ -177,9 +177,14 @@ public enum NotificationType { * *

딥링크는 {@code ruleup://challenge/{id}} (단수) 다 — 2026-09-19 Android 와 합의한 계약값이다 * (QA NOTI-15). 다른 챌린지 그룹 타입의 {@code challenges/} 는 아직 정합 전이다. + * + *

멱등 키는 {@code (verification_id, variant)} 다. 한 판정이 결과를 바꿀 수 있어서다 — 수동 체크 + * (MANUAL_SUCCESS)를 당일 취소하면 같은 행이 이틀 뒤 CONFIRMED_FAILURE 로 굳는다. 키가 + * {@code verification_id} 하나였을 때는 실패 확정이 앞선 성공 알림의 UNIQUE 에 막혀 조용히 사라졌다. + * 같은 변형의 재발행(재 sync·취소 후 재체크·배치 재실행)은 그대로 한 번이다. */ VERIFICATION_RESULT(NotificationToggleGroup.CHALLENGE, "ruleup://challenge/{challenge_id}", - new String[]{VERIFICATION_ID}), + new String[]{VERIFICATION_ID, VARIANT}), /** * 실패 예정 진입 — 「이의제기가 필요해요」(QA NOTI-16). 탭하면 이의를 낼 수 있는 화면으로 간다. @@ -193,8 +198,8 @@ public enum NotificationType { *

그래서 {@code date}(귀속일) 가 파라미터에 들어간다. 멱등 키는 {@code verification_id} 그대로다 — * 키에 날짜를 더하면 같은 건에 두 번 적재될 길이 열린다. * - *

{@link #VERIFICATION_RESULT} 의 변형으로 두지 않는다. 멱등 키가 {@code verification_id} 하나라 - * 같은 건에 실패 예정 알림이 먼저 적재되면 이틀 뒤 「실패로 확정됐어요」가 UNIQUE 에 막혀 사라진다. + *

{@link #VERIFICATION_RESULT} 의 변형으로 두지 않는다. 타입이 다르면 앱이 목적지를 타입으로 가를 수 + * 있고, 처음 이렇게 나눈 이유였던 멱등 키 충돌(실패 예정이 이틀 뒤 「실패로 확정됐어요」를 막음)도 따로 막힌다. */ VERIFICATION_FAIL_EXPECTED(NotificationToggleGroup.CHALLENGE, "ruleup://me/calendar/{date}", new String[]{VERIFICATION_ID}), diff --git a/src/main/java/com/ruleup/ruleup_backend/notification/queue/SqsNotificationQueue.java b/src/main/java/com/ruleup/ruleup_backend/notification/queue/SqsNotificationQueue.java index b522bf78..dc3efb6c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/notification/queue/SqsNotificationQueue.java +++ b/src/main/java/com/ruleup/ruleup_backend/notification/queue/SqsNotificationQueue.java @@ -27,6 +27,7 @@ public class SqsNotificationQueue implements NotificationQueue { /** SQS 메시지 본문 상한 256KB. 100건 ≈ 40KB 라 여유가 있지만 넘으면 쪼갠다. */ private static final int MAX_BODY_BYTES = 256 * 1024; + private static final java.time.Duration SEND_TIMEOUT = java.time.Duration.ofSeconds(3); /** * 큐 본문 전용 매퍼 — 웹 계층의 {@code ObjectMapper} 를 빌려 쓰지 않는다. @@ -60,8 +61,11 @@ public void enqueue(List messages) { enqueue(messages.subList(half, messages.size())); return; } + // 요청 스레드에서 커밋 직후 동기로 보낸다 — SDK 기본은 호출 시간 제한이 없어, SQS 가 느려지면 요청이 그만큼 붙잡힌다. + // 클라이언트 전체에 걸면 소비자의 20초 롱폴링이 끊기므로 보내기에만 건다(3초, 재시도 포함). sqs.sendMessage(SendMessageRequest.builder() - .queueUrl(queueUrl).messageBody(body).build()); + .queueUrl(queueUrl).messageBody(body) + .overrideConfiguration(o -> o.apiCallTimeout(SEND_TIMEOUT)).build()); } private String serialize(List messages) { diff --git a/src/main/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGate.java b/src/main/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGate.java new file mode 100644 index 00000000..5736962b --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGate.java @@ -0,0 +1,50 @@ +package com.ruleup.ruleup_backend.oauth; + +import com.ruleup.ruleup_backend.user.domain.OAuthProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * 부하테스트 전용 로그인 우회 — stg 에서 잠깐 켜고 끈다. prod 프로파일에는 빈 자체가 없다. + * + *

실제 구글·카카오 인가 코드는 1회용이라 같은 로그인을 수천 번 반복할 수 없다. 이 문은 IdP 호출만 빼고 + * 그 뒤의 실제 로그인 트랜잭션(제재 확인·행 잠금·활동 기록·소셜 토큰·리프레시 토큰 발급)을 그대로 태운다. + * 조건 두 가지가 모두 맞을 때만 열린다. + *

    + *
  • {@code code} 가 {@value #CODE_PREFIX} 로 시작 — 나머지가 가짜 사용자의 식별자다
  • + *
  • {@code codeVerifier} 가 stg 전용 {@code DEV_TOKEN_SECRET} 과 같다 — 비밀값은 저장되지 않는다
  • + *
+ * 그 밖의 요청은 평소처럼 실제 IdP 로 간다(켜 둔 동안에도 앱의 실제 로그인은 그대로 된다). + */ +@Slf4j +@Component +@Profile("!prod") +@ConditionalOnProperty(name = "app.oauth.load-test.enabled", havingValue = "true") +public class LoadTestOAuthGate { + + public static final String CODE_PREFIX = "loadtest-"; + + private final byte[] secret; + + public LoadTestOAuthGate(@Value("${app.dev-tokens.secret:}") String secret) { + if (secret == null || secret.isBlank()) + throw new IllegalStateException("app.oauth.load-test.enabled 는 DEV_TOKEN_SECRET 이 있어야 켤 수 있다"); + this.secret = secret.getBytes(StandardCharsets.UTF_8); + log.warn("load_test_oauth_enabled — 부하테스트용 로그인 우회가 켜져 있다. 테스트가 끝나면 끈다"); + } + + public boolean admits(String code, String codeVerifier) { + return code != null && code.startsWith(CODE_PREFIX) && code.length() > CODE_PREFIX.length() && codeVerifier != null + && MessageDigest.isEqual(secret, codeVerifier.getBytes(StandardCharsets.UTF_8)); + } + + public OAuthClient client(OAuthProvider provider) { + return new MockOAuthClient(provider); + } +} diff --git a/src/main/java/com/ruleup/ruleup_backend/oauth/OAuthClientResolver.java b/src/main/java/com/ruleup/ruleup_backend/oauth/OAuthClientResolver.java index 51138f80..6e6d4f5d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/oauth/OAuthClientResolver.java +++ b/src/main/java/com/ruleup/ruleup_backend/oauth/OAuthClientResolver.java @@ -3,6 +3,7 @@ import com.ruleup.ruleup_backend.common.error.BusinessException; import com.ruleup.ruleup_backend.common.error.ErrorCode; import com.ruleup.ruleup_backend.user.domain.OAuthProvider; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import java.util.List; @@ -15,10 +16,18 @@ public class OAuthClientResolver { private final Map clients; + private final LoadTestOAuthGate loadTest; // stg 부하테스트 동안만 있다(prod 에는 빈이 없다) - public OAuthClientResolver(List clientList) { + public OAuthClientResolver(List clientList, ObjectProvider loadTest) { this.clients = clientList.stream() .collect(Collectors.toMap(OAuthClient::provider, Function.identity())); + this.loadTest = loadTest.getIfAvailable(); + } + + /** 부하테스트 우회가 켜져 있고 그 요청이 조건을 맞추면 IdP 호출 없는 클라이언트, 아니면 실제 클라이언트. */ + public OAuthClient resolve(OAuthProvider provider, String code, String codeVerifier) { + if (loadTest != null && loadTest.admits(code, codeVerifier)) return loadTest.client(provider); + return resolve(provider); } public OAuthClient resolve(OAuthProvider provider) { diff --git a/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java b/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java index 4b358e8e..d0475f78 100644 --- a/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java +++ b/src/main/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSampler.java @@ -16,15 +16,23 @@ import org.springframework.stereotype.Component; import javax.sql.DataSource; + +import java.lang.management.BufferPoolMXBean; import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; import java.lang.management.MemoryPoolMXBean; import java.lang.management.MemoryType; import java.lang.management.MemoryUsage; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; import java.util.ArrayDeque; import java.util.Deque; +import java.util.EnumMap; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; /** * JVM·커넥션 풀·요청 스레드의 순간값을 30초마다({@link com.ruleup.ruleup_backend.observability.metric.MinuteMetricPublisher} 틱) 재 {@code RuleUp/Ops}(Env, Service[, Pool]) 로 보낸다 — @@ -40,6 +48,15 @@ *
  • MaxPoolActivePct·MaxPoolPending·PoolTimeoutDelta — Hikari active/max, 커넥션 대기 스레드, 획득 timeout 증가분
  • *
  • MaxBusyThreadPct — Tomcat 요청 스레드 busy/max
  • * + * + *

    대시보드 OPS-6(JVM 런타임, 노션 10/15 리뷰) 용 — 경보 없이 화면에만 쓴다. + * Micrometer {@code jvm.*} 를 통째로 내보내면 풀·원인별 태그 조합이 70개를 넘어(이름×차원 하나가 월 과금 단위) 필요한 것만 직접 잰다. + *

      + *
    • JvmMemoryUsedMb(Area=eden·survivor·old·metaspace·codecache) · JvmHeapMaxMb
    • + *
    • JvmGcPauseMaxMs — Micrometer {@code jvm.gc.pause} 의 최근 최대(백분위는 설정이 없어 최대·횟수로 본다) · JvmGcPauseCount — 정지 수집 횟수 증가분
    • + *
    • JvmThreads(State=live·daemon·peak·runnable·blocked·waiting) · JvmClassesLoaded · JvmBufferUsedMb(Pool=direct·mapped)
    • + *
    • ProcessCpuPct · ProcessUptimeSec — 업타임이 내려가면 재기동(Minimum 으로 본다)
    • + *
    */ @Slf4j @Component @@ -56,6 +73,7 @@ public class RuntimeOpsSampler implements MinuteSampler { private final Deque gcSamples = new ArrayDeque<>(); // [채집 시각 ms, 누적 GC 정지 ms] private final Map lastTimeouts = new HashMap<>(); + private long lastGcCount = -1; public RuntimeOpsSampler(MinuteMetricBuffer buffer, MetricContext ctx, ObjectProvider dataSources, MeterRegistry registry, ApplicationContext context) { @@ -71,6 +89,7 @@ public void sample() { try { jvm(); } catch (RuntimeException e) { log.debug("jvm sample failed: {}", e.toString()); } try { pools(); } catch (RuntimeException e) { log.debug("pool sample failed: {}", e.toString()); } try { threads(); } catch (RuntimeException e) { log.debug("thread sample failed: {}", e.toString()); } + try { jvmPanel(); } catch (RuntimeException e) { log.debug("jvm panel sample failed: {}", e.toString()); } } private void jvm() { @@ -133,8 +152,66 @@ private void threads() { } } - private void gauge(String name, MetricKey.Unit unit, double value) { - buffer.gauge(new MetricKey(OpsSignals.OPS, name, ctx.dims(), unit), value); + private void jvmPanel() { + Map areas = new TreeMap<>(); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + String area = memoryArea(pool.getName()); + if (area != null) areas.merge(area, pool.getUsage().getUsed(), Long::sum); + } + areas.forEach((area, used) -> gauge("JvmMemoryUsedMb", MetricKey.Unit.NONE, mb(used), "Area", area)); + long heapMax = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax(); + if (heapMax > 0) gauge("JvmHeapMaxMb", MetricKey.Unit.NONE, mb(heapMax)); + + double pauseMax = registry.find("jvm.gc.pause").timers().stream() + .mapToDouble(t -> t.max(TimeUnit.MILLISECONDS)).max().orElse(Double.NaN); + if (!Double.isNaN(pauseMax)) gauge("JvmGcPauseMaxMs", MetricKey.Unit.MILLISECONDS, pauseMax); + long gcCount = 0; + for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { + if (!isConcurrent(gc.getName())) gcCount += Math.max(0, gc.getCollectionCount()); + } + if (lastGcCount >= 0) buffer.add(MetricKey.count(OpsSignals.OPS, "JvmGcPauseCount", ctx.dims()), Math.max(0, gcCount - lastGcCount)); + lastGcCount = gcCount; + + ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + Map states = new EnumMap<>(Thread.State.class); + for (ThreadInfo info : threads.getThreadInfo(threads.getAllThreadIds(), 0)) { + if (info != null) states.merge(info.getThreadState(), 1, Integer::sum); + } + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getThreadCount(), "State", "live"); + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getDaemonThreadCount(), "State", "daemon"); + gauge("JvmThreads", MetricKey.Unit.COUNT, threads.getPeakThreadCount(), "State", "peak"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.RUNNABLE, 0), "State", "runnable"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.BLOCKED, 0), "State", "blocked"); + gauge("JvmThreads", MetricKey.Unit.COUNT, states.getOrDefault(Thread.State.WAITING, 0) + + states.getOrDefault(Thread.State.TIMED_WAITING, 0), "State", "waiting"); + + gauge("JvmClassesLoaded", MetricKey.Unit.COUNT, ManagementFactory.getClassLoadingMXBean().getLoadedClassCount()); + for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) { + String name = pool.getName().toLowerCase(Locale.ROOT); + if (name.equals("direct") || name.equals("mapped")) gauge("JvmBufferUsedMb", MetricKey.Unit.NONE, mb(pool.getMemoryUsed()), "Pool", name); + } + if (ManagementFactory.getOperatingSystemMXBean() instanceof com.sun.management.OperatingSystemMXBean os && os.getProcessCpuLoad() >= 0) { + gauge("ProcessCpuPct", MetricKey.Unit.PERCENT, 100.0 * os.getProcessCpuLoad()); + } + gauge("ProcessUptimeSec", MetricKey.Unit.SECONDS, ManagementFactory.getRuntimeMXBean().getUptime() / 1000.0); + } + + private void gauge(String name, MetricKey.Unit unit, double value, String... extraDims) { + buffer.gauge(new MetricKey(OpsSignals.OPS, name, ctx.dims(extraDims), unit), value); + } + + private static double mb(long bytes) { + return bytes / (1024.0 * 1024.0); + } + + /** 메모리 풀 이름 → 대시보드 영역. G1·Parallel·Serial·ZGC 이름을 묶는다. 모르는 풀(Compressed Class 등)은 null — 내보내지 않는다. */ + static String memoryArea(String poolName) { + if (poolName.contains("Eden") || poolName.equals("ZGC Young Generation")) return "eden"; + if (poolName.contains("Survivor")) return "survivor"; + if (isOldGen(poolName)) return "old"; + if (poolName.equals("Metaspace")) return "metaspace"; + if (poolName.startsWith("CodeHeap") || poolName.equals("Code Cache")) return "codecache"; + return null; } static boolean isOldGen(String poolName) { diff --git a/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java b/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java index 7130196a..15259b6f 100644 --- a/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java +++ b/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java @@ -86,7 +86,10 @@ public boolean enforce(UUID challengeId, UUID userId, Reason reason, UUID source "kick_count=VALUES(kick_count),available_at=VALUES(available_at)", bytes(challengeId), bytes(userId), count + 1, DbTime.utc(availableAt)); if (member != null && member.isActive() && challenge != null) { - if (challenge.isOwner(userId)) challenge.convertToBotOwner(now); + // 자진 탈퇴와 같은 규칙 — 마지막 멤버가 빠지면 봇방장으로 넘기지 않고 방을 닫는다. + if (members.countByChallengeIdAndStatus(challengeId, + com.ruleup.ruleup_backend.challenge.domain.MemberStatus.ACTIVE) <= 1) challenge.softDelete(); + else if (challenge.isOwner(userId)) challenge.convertToBotOwner(now); if (permanent) member.kickPermanently(reason.name(), now); else member.kick(reason.name(), now, availableAt); challenge.bumpVersion(); diff --git a/src/main/java/com/ruleup/ruleup_backend/room/service/CrossRankingSnapshotService.java b/src/main/java/com/ruleup/ruleup_backend/room/service/CrossRankingSnapshotService.java index 1880d72f..d1472195 100644 --- a/src/main/java/com/ruleup/ruleup_backend/room/service/CrossRankingSnapshotService.java +++ b/src/main/java/com/ruleup/ruleup_backend/room/service/CrossRankingSnapshotService.java @@ -95,6 +95,10 @@ public void refreshAfterVerification() { public void removeDeparted(ChallengeStatsRefreshRequested event) { if (!java.util.Set.of("LEAVE", "WITHDRAW", "KICK", "SANCTION", "DORMANT", "TIER_GATE", "ADMIN").contains(event.reason())) return; em.flush(); + // 스냅샷에 없는 방(시작 전 방·랭킹 비공개 SOLO 등)이면 전역 락을 잡을 일이 없다 — 모든 이탈이 한 줄로 서지 않게. + // 잠금 읽기라 스냅샷 시점과 상관없이 커밋된 최신 행을 본다(일일 refresh 가 막 넣은 행도 놓치지 않는다). + if (jdbc.queryForList("SELECT 1 FROM challenge_cross_ranking_members WHERE challenge_id=? LIMIT 1 FOR SHARE", + bytes(event.challengeId())).isEmpty()) return; lockJob(); int removed = jdbc.update("DELETE r FROM challenge_cross_ranking_members r LEFT JOIN challenge_members m " + "ON m.challenge_id=r.challenge_id AND m.user_id=r.user_id WHERE r.challenge_id=? " + @@ -115,13 +119,20 @@ public void removeChallenge(UUID challengeId) { rerank(); } + /** + * 순위를 다시 매긴다 — 모드마다 두 문장. 예전에는 방마다 UPDATE 를 따로 보내, 멤버 한 명이 나갈 때마다 + * 진행 중인 방 수(수천)만큼의 문장이 그 사용자의 트랜잭션 안에서 전역 락을 쥔 채 돌았다(부하테스트 2026-10). + */ private void rerank() { for (String mode : List.of("GROUP", "SOLO")) { int minimum = "GROUP".equals(mode) ? 50 : 10; - List eligible = jdbc.query("SELECT challenge_id FROM challenge_cross_ranking_snapshot WHERE mode=? AND total_count>=? " + - "ORDER BY success_rate DESC,success_count DESC,challenge_id FOR UPDATE", (rs,n) -> rs.getBytes(1), mode, minimum); - jdbc.update("UPDATE challenge_cross_ranking_snapshot SET rank_no=NULL WHERE mode=?", mode); - for (int n=0;n=?) x ON x.challenge_id=s.challenge_id SET s.rank_no=x.rn " + + "WHERE s.mode=? AND NOT (s.rank_no <=> x.rn)", mode, minimum, mode); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/room/service/RankingService.java b/src/main/java/com/ruleup/ruleup_backend/room/service/RankingService.java index 76879499..68a734b9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/room/service/RankingService.java +++ b/src/main/java/com/ruleup/ruleup_backend/room/service/RankingService.java @@ -35,8 +35,13 @@ public record Ranked(Integer rank, boolean ranked, UUID userId, String nickname, int successCount, int participations) {} public List rank(Challenge challenge, UUID viewerId) { - List members = new ArrayList<>(memberRepository + return rank(challenge, viewerId, memberRepository .findByChallengeIdAndStatusOrderByJoinedAtAsc(challenge.getId(), MemberStatus.ACTIVE)); + } + + /** 호출부가 이미 읽은 ACTIVE 멤버(가입순)로 순위를 낸다 — 방 홈이 같은 목록을 두 번 읽지 않게. */ + public List rank(Challenge challenge, UUID viewerId, List activeMembers) { + List members = new ArrayList<>(activeMembers); members.sort(Comparator .comparing((ChallengeMember m) -> m.getSuccessDays() + m.getFailDays() >= MIN_PARTICIPATIONS) .reversed() diff --git a/src/main/java/com/ruleup/ruleup_backend/room/service/RoomService.java b/src/main/java/com/ruleup/ruleup_backend/room/service/RoomService.java index 75832bda..87763531 100644 --- a/src/main/java/com/ruleup/ruleup_backend/room/service/RoomService.java +++ b/src/main/java/com/ruleup/ruleup_backend/room/service/RoomService.java @@ -69,7 +69,7 @@ public RoomDtos.RoomResponse room(UUID userId, UUID challengeId) { RoomDtos.RoomResponse.Summary summary = new RoomDtos.RoomResponse.Summary( title, challenge.getWeeklyCount(), roomRate(active), remainingDays(challenge, now.toLocalDate()), active.size(), challenge.getMaxParticipants()); - List top = rankingService.rank(challenge, userId).stream() + List top = rankingService.rank(challenge, userId, active).stream() .filter(RankingService.Ranked::ranked).limit(3) .map(r -> new RoomDtos.RoomResponse.TopRank(r.rank(), r.userId().toString(), r.nickname(), r.profileImageUrl(), r.successRate(), r.blocked())).toList(); diff --git a/src/main/java/com/ruleup/ruleup_backend/security/AccountStatusFilter.java b/src/main/java/com/ruleup/ruleup_backend/security/AccountStatusFilter.java index b36cd5f4..f76842b8 100644 --- a/src/main/java/com/ruleup/ruleup_backend/security/AccountStatusFilter.java +++ b/src/main/java/com/ruleup/ruleup_backend/security/AccountStatusFilter.java @@ -1,7 +1,9 @@ package com.ruleup.ruleup_backend.security; +import com.ruleup.ruleup_backend.common.error.ClientErrorLog; import com.ruleup.ruleup_backend.common.error.ErrorCode; import com.ruleup.ruleup_backend.common.error.ErrorResponse; +import com.ruleup.ruleup_backend.common.error.GlobalExceptionHandler; import com.ruleup.ruleup_backend.common.response.ApiResponse; import com.ruleup.ruleup_backend.sanction.SanctionService; import com.ruleup.ruleup_backend.sanction.domain.FeatureCode; @@ -14,6 +16,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -52,6 +55,7 @@ *

    상태를 JWT 클레임이 아니라 DB 에서 읽는 이유: 제재는 운영자가 언제든 걸 수 있는데 클레임에 * 넣으면 액세스 토큰 수명(30분)만큼 우회가 생긴다. */ +@Slf4j @RequiredArgsConstructor public class AccountStatusFilter extends OncePerRequestFilter { @@ -104,10 +108,23 @@ public class AccountStatusFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { UUID userId = currentUserId(); - if (userId != null) activity.touch(userId); - Blocked blocked = blockedReason(request); + Blocked blocked; + try { + if (userId != null) activity.touch(userId); + blocked = blockedReason(request); + } catch (RuntimeException e) { + // 필터에서 난 예외는 GlobalExceptionHandler 를 거치지 않고 /error 로 가는데, 거기엔 인증이 없어 401 + // LOGIN_REQUIRED 가 된다 — 풀 대기 초과 한 번에 앱이 로그아웃 화면으로 간다(부하테스트 2026-10 J2: 49건). + // 컨트롤러 안과 같게 503 + Retry-After 로 돌려보낸다. + if (!GlobalExceptionHandler.causedByDbUnavailable(e)) throw e; + log.warn("DB 일시 불가(계정 상태 확인) — 503: {}", e.getMessage()); + ClientErrorLog.record(request, ErrorCode.TEMPORARILY_UNAVAILABLE); + response.setHeader("Retry-After", "2"); + writeError(response, new Blocked(ErrorCode.TEMPORARILY_UNAVAILABLE, null)); + return; + } if (blocked != null) { - com.ruleup.ruleup_backend.common.error.ClientErrorLog.record(request, blocked.code()); + ClientErrorLog.record(request, blocked.code()); writeError(response, blocked); return; } diff --git a/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java b/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java index bb3611e6..8a228cff 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java @@ -1,27 +1,56 @@ package com.ruleup.ruleup_backend.user; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.nio.ByteBuffer; +import java.time.Duration; import java.time.LocalDate; import java.time.ZoneId; import java.util.UUID; +/** + * 마지막 활동일 기록 — 휴면 판정의 근거({@code user_activity.last_active_on}). + * + *

    값이 날짜라 같은 날 두 번째부터의 기록은 결과가 같다. 그런데 인증된 요청마다 부르므로 + * 그대로 두면 GET 하나가 곧 DB 쓰기 하나였다(부하테스트 2026-10: 읽기 요청마다 upsert 1건 + + * 커넥션 대여 1회). 그래서 태스크마다 「오늘 이미 썼다」를 기억하고 같은 날은 건너뛴다. + * 태스크가 여럿이면 태스크마다 하루 한 번씩 쓴다 — 결과는 같다. + * + *

    로그인·가입처럼 계정 상태가 막 바뀐 직후는 {@link #touchNow} 로 반드시 쓴다 — 같은 날 앞선 기록이 + * 탈퇴 상태라 무시됐을 수 있다(INSERT 가 {@code status<>'WITHDRAWN'} 일 때만 들어간다). + */ @Service @RequiredArgsConstructor public class UserActivityService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final JdbcTemplate jdbc; + private final Cache recordedOn = Caffeine.newBuilder() + .maximumSize(200_000).expireAfterWrite(Duration.ofHours(25)).build(); - @Transactional + /** 요청마다 부른다. 오늘 이미 기록했으면 DB 를 건드리지 않는다(트랜잭션·커넥션도 열지 않는다). */ public void touch(UUID userId) { - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalDate today = LocalDate.now(KST); + if (today.equals(recordedOn.getIfPresent(userId))) return; + write(userId, today); + } + + /** 로그인·가입·복원 직후 — 기억과 상관없이 기록한다. 호출한 쪽 트랜잭션이 있으면 거기에 합류한다. */ + public void touchNow(UUID userId) { + write(userId, LocalDate.now(KST)); + } + + private void write(UUID userId, LocalDate today) { byte[] id = ByteBuffer.allocate(16).putLong(userId.getMostSignificantBits()).putLong(userId.getLeastSignificantBits()).array(); jdbc.update("INSERT INTO user_activity(user_id,last_active_on) SELECT id,? FROM users " + "WHERE id=? AND status<>'WITHDRAWN' ON DUPLICATE KEY UPDATE " + "notified_stage=IF(last_active_on { Optional findByOauthProviderAndOauthSubject(OAuthProvider provider, String oauthSubject); + /** 로그인 분기용 — 기존 회원인지와 id 만. 엔티티(개인정보·관심사 EAGER 포함)를 통째로 읽지 않는다. */ + @Query("select u.id from User u where u.oauthProvider = :provider and u.oauthSubject = :subject") + Optional findIdByOauth(@Param("provider") OAuthProvider provider, @Param("subject") String oauthSubject); + Optional findByIdAndDeletedAtIsNull(UUID id); + /** + * 여러 사용자를 한 번에 — 개인정보·관심사(EAGER)까지 같은 문장으로 가져온다. + * 기본 구현은 사용자마다 user_information·user_interests 를 따로 읽어 방 홈·랭킹·멤버 목록이 + * 멤버 수 × 2 만큼 SQL 을 더 냈다(부하테스트 2026-10 트레이스: 멤버 3명 방 홈 16문장). + */ + @Override + @Query("select distinct u from User u left join fetch u.information left join fetch u.interestCategories where u.id in :ids") + List findAllById(@Param("ids") Iterable ids); + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) @Query("select u from User u where u.id=:id") Optional findByIdForUpdate(@Param("id") UUID id); @@ -43,13 +56,11 @@ public interface UserRepository extends JpaRepository { Optional findStatusById(@Param("id") UUID id); /** 닉네임 점유 여부 — excludeId(본인) 제외. 신규 가입 검사면 excludeId=null. */ + // 위 규칙을 생성 열(active_requested_nickname·active_approved_nickname)로 건다 — 두 UNIQUE 인덱스로 바로 찾는다. @Query(""" select count(u) > 0 from User u where (:excludeId is null or u.id <> :excludeId) - and u.status <> com.ruleup.ruleup_backend.user.domain.UserStatus.WITHDRAWN - and ((u.nicknameStatus = com.ruleup.ruleup_backend.user.domain.NicknameStatus.PENDING - and u.nickname = :nickname) - or u.approvedNickname = :nickname) + and (u.activeRequestedNickname = :nickname or u.activeApprovedNickname = :nickname) """) boolean isNicknameTaken(@Param("nickname") String nickname, @Param("excludeId") UUID excludeId); diff --git a/src/main/java/com/ruleup/ruleup_backend/user/domain/User.java b/src/main/java/com/ruleup/ruleup_backend/user/domain/User.java index 631465f6..d6518eeb 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/domain/User.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/domain/User.java @@ -124,6 +124,17 @@ public class User extends AssignedIdEntity { @Column(name = "device_id") private String deviceId; + /** + * 닉네임 점유 판정용 생성 열(DB 가 계산, 읽기 전용) — 탈퇴하지 않은 사용자의 심사 중 신청 닉네임 / 승인 닉네임. + * 각각 UNIQUE 인덱스가 있어 점유 확인이 인덱스 한 번으로 끝난다. 원래 열로 조건을 걸면 users 전체를 훑었다 + * (부하테스트 2026-10 느린 쿼리 로그: 가입·닉네임 확인마다 사용자 수만큼 검사). + */ + @Column(name = "active_requested_nickname", insertable = false, updatable = false) + private String activeRequestedNickname; + + @Column(name = "active_approved_nickname", insertable = false, updatable = false) + private String activeApprovedNickname; + /** 사용자 개인정보(생일·성별·이메일) — user_information 1:1. 조회 편의를 위해 EAGER. */ @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private UserInformation information; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java b/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java index c2fde29b..130febf3 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java @@ -11,9 +11,10 @@ * @param syncMinIntervalSec 평상시 sync 최소 간격(초). 기기 스펙 기반 flushIntervalSec 보다 짧게 잡아 * 정상 주기 전송이 걸리지 않게 한다 * @param syncBacklogMinIntervalSec 복구 전송(backlog=true) 최소 간격(초). 밀린 구간을 나눠 올려야 해서 더 짧다 - * @param avoidGraceMinutes 장소 피하기의 "스침" 허용 시간(분). 이 시간 안에 나오면 위반이 아니다 — - * 금지 장소 앞을 지나가기만 해도 지오펜스는 ENTER 를 쏘기 때문이다. - * 실기기 테스트로 조절할 값이라 코드 상수로 두지 않는다 + * @param wakeGraceMinutes 기상 판정에서 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나 + * 걸음 기록이 조금 늦게 잡혀도 인정한다. 기본 30 + * @param wakeMinSteps 기상으로 인정할 걸음 수(창 안 합계). 휴대폰을 집어 드는 정도의 오탐 걸음과 + * 실제로 일어나 움직인 것을 가르는 문턱이라 실기기 테스트로 찾는다. 기본 10 * @param signalRetentionDays 판정 원본을 유지할 일수. raw 는 현재 귀속일과 직전 유예 * 귀속일만 필요한 hot storage 이고, D일 신호는 D+2 00:00 KST 확정이 * 끝나면 목적이 끝난다. 정확히 이 일수를 남긴다 — 기본 3이면 @@ -57,22 +58,24 @@ @ConfigurationProperties(prefix = "app.verification") public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayloadBytes, Integer syncMinIntervalSec, Integer syncBacklogMinIntervalSec, - Integer avoidGraceMinutes, Integer signalRetentionDays, + Integer signalRetentionDays, Integer signalPartitionLookaheadDays, Integer gpsRetentionDays, Integer anomalyRetentionDays, Integer materializeCatchupDays, - Boolean requireActiveDevice, Boolean requireSignalOrigin) { + Boolean requireActiveDevice, Boolean requireSignalOrigin, + Integer wakeGraceMinutes, Integer wakeMinSteps) { private static final int DEFAULT_GEOFENCE_RADIUS_M = 200; private static final int DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576; private static final int DEFAULT_SYNC_MIN_INTERVAL_SEC = 300; private static final int DEFAULT_SYNC_BACKLOG_MIN_INTERVAL_SEC = 10; - private static final int DEFAULT_AVOID_GRACE_MINUTES = 5; private static final int DEFAULT_SIGNAL_RETENTION_DAYS = 3; private static final int DEFAULT_SIGNAL_PARTITION_LOOKAHEAD_DAYS = 10; private static final int DEFAULT_GPS_RETENTION_DAYS = 0; private static final int DEFAULT_ANOMALY_RETENTION_DAYS = 30; private static final int DEFAULT_MATERIALIZE_CATCHUP_DAYS = 7; + private static final int DEFAULT_WAKE_GRACE_MINUTES = 30; + private static final int DEFAULT_WAKE_MIN_STEPS = 10; public VerificationProperties { geofenceRadiusM = positiveOrDefault(geofenceRadiusM, DEFAULT_GEOFENCE_RADIUS_M); @@ -80,7 +83,6 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload // 0 은 "제한 없음"으로 인정한다(테스트·부하 시나리오). syncMinIntervalSec = nonNegativeOrDefault(syncMinIntervalSec, DEFAULT_SYNC_MIN_INTERVAL_SEC); syncBacklogMinIntervalSec = nonNegativeOrDefault(syncBacklogMinIntervalSec, DEFAULT_SYNC_BACKLOG_MIN_INTERVAL_SEC); - avoidGraceMinutes = nonNegativeOrDefault(avoidGraceMinutes, DEFAULT_AVOID_GRACE_MINUTES); signalRetentionDays = positiveOrDefault(signalRetentionDays, DEFAULT_SIGNAL_RETENTION_DAYS); signalPartitionLookaheadDays = positiveOrDefault( signalPartitionLookaheadDays, DEFAULT_SIGNAL_PARTITION_LOOKAHEAD_DAYS); @@ -100,6 +102,9 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload // 관측으로 안전을 확인한 뒤 켜야 한다. requireActiveDevice = requireActiveDevice != null && requireActiveDevice; requireSignalOrigin = requireSignalOrigin != null && requireSignalOrigin; + // 0 은 「목표 시각까지만」이라는 유효한 값이다. + wakeGraceMinutes = nonNegativeOrDefault(wakeGraceMinutes, DEFAULT_WAKE_GRACE_MINUTES); + wakeMinSteps = positiveOrDefault(wakeMinSteps, DEFAULT_WAKE_MIN_STEPS); } private static int positiveOrDefault(Integer value, int fallback) { diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java index ba298aef..9fd98280 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java @@ -10,7 +10,7 @@ * *

    저장 상태가 아니라 계산 상태다. 두 경우에 실패 예정이 된다. *

      - *
    • 위반이 확인됐다 — 장소 피하기 진입, 앱 최대 사용 초과처럼 되돌릴 수 없는 사유가 이미 잡혔다. + *
    • 위반이 확인됐다 — 앱 최대 사용 초과처럼 되돌릴 수 없는 사유가 이미 잡혔다. * 귀속일 중이라도 실패 예정이다.
    • *
    • 귀속일이 끝났는데 목표 달성형이 아직 미달이다 — 더 채울 기회가 없으니 이대로면 실패다. * 규칙 지키기형은 반대다. 귀속일이 끝나도록 위반이 없었으면 오히려 성공 쪽이라 실패 예정이 아니다.
    • diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java index e5a3c0e2..708fa6aa 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java @@ -27,7 +27,7 @@ public record FailureEvidence(String summary, * 숫자가 들어간다 — 기록하지 않는 타입은 기준값 없이 사유 코드만 남는다. */ private static final List EXPECTED_KEYS = List.of( - "goal", "goalKm", "goalMinutes", "beforeTime", "bedtimeBefore", "graceMinutes", + "goal", "goalKm", "goalMinutes", "beforeTime", "deadline", "bedtimeBefore", "graceMinutes", "unit", "metric", "mode"); /** 실제 측정값 키. 나머지(내부 이월 상태·중복 제거 키)는 설명에 쓰지 않는다. */ @@ -57,10 +57,9 @@ private static String summary(String reasonCode, Map ev) { case "INSUFFICIENT_STEPS" -> pair(ev, "걸음", "value", "걸음", "goal", "걸음"); case "INSUFFICIENT_USAGE" -> pair(ev, "사용", "usageMinutes", "분", "goalMinutes", "분"); case "USAGE_EXCEEDED" -> limit(ev, "사용", "usageMinutes", "goalMinutes"); - case "WOKE_UP_LATE" -> before(ev, "첫 잠금 해제", "firstUnlockAt", "beforeTime"); + case "WOKE_UP_LATE" -> wake(ev); case "SLEPT_LATE" -> before(ev, "취침", "bedtime", "bedtimeBefore"); case "INSUFFICIENT_SLEEP" -> pair(ev, "수면", "sleepHours", "시간", "goal", "시간"); - case "ENTERED_AVOID_ZONE" -> "피해야 하는 장소에 들어간 기록이 있어요."; case "PERMISSION_MISSING" -> "인증에 필요한 권한이 꺼져 있어 측정하지 못했어요."; case "NO_SIGNAL_RECEIVED" -> "판정에 쓸 신호가 도착하지 않았어요."; case "UNTRUSTED_HEALTH_SOURCE" -> "직접 입력했거나 신뢰할 수 없는 출처의 기록만 있었어요."; @@ -99,7 +98,16 @@ private static String limit(Map ev, String label, String actualK return "%s %s분 / 상한 %s분".formatted(label, actual, goal); } - /** 「첫 잠금 해제 07:20 / 목표 07:00 이전」. */ + /** 「07:30까지 걸음 3걸음 / 목표 10걸음」 — 기상은 목표 시각 + 유예 안에 걸었는지로 본다. */ + private static String wake(Map ev) { + Object deadline = value(ev, "deadline"); + Object steps = value(ev, "value"); + Object goal = value(ev, "goal"); + if (deadline == null || steps == null || goal == null) return null; + return "%s까지 걸음 %s걸음 / 목표 %s걸음".formatted(deadline, steps, goal); + } + + /** 「취침 01:20 / 목표 00:00 이전」. */ private static String before(Map ev, String label, String actualKey, String goalKey) { Object actual = value(ev, actualKey); Object goal = value(ev, goalKey); diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsConfig.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsConfig.java index a7d8ea46..d827f9b0 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsConfig.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsConfig.java @@ -7,7 +7,6 @@ /** * GPS 인증 파라미터(테크스펙 v2 §5, §12.3). PRESENCE(지오펜스 체류) 주경로. * v2 변경: - * - presence(VISIT/AVOID) 추가 → AVOID는 제약형(창 내 ENTER 없으면 SUCCESS). * - 앵커 좌표(anchors[])는 config가 아니라 challenge_members.anchors(PER_MEMBER)에 저장. * config의 GPS는 반경·dwell·fillMode 등 "규칙"만 보유(lat/lng는 단일앵커 레거시·fallback용으로만 잔존). * - DISTANCE(커스텀 세션)는 Phase 2 — 평가 라우팅 안 함. goalKm는 Phase 2 보존. @@ -15,7 +14,6 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record GpsConfig( GpsMode mode, - GpsPresence presence, // VISIT / AVOID (v2) AnchorFillMode anchorFillMode, // MANUAL / NEARBY_BRAND — 생성 기본 프리셋(v2) String brandKeyword, // NEARBY_BRAND일 때 검색어(v2) Integer nearbyRadiusM, // NEARBY_BRAND 검색 반경(기본 1500, v2) @@ -26,11 +24,9 @@ public record GpsConfig( Integer loiteringDelayMin, BigDecimal goalKm, // DISTANCE(Phase 2 보존) String timeWindow, // "HH:mm-HH:mm" (없으면 null) - Polarity polarity, // VISIT=ACHIEVEMENT / AVOID=CONSTRAINT + Polarity polarity, // ACHIEVEMENT(방문형) int maxSignalLagHours, Integer accuracyMaxM, Integer outlierThreshold, List wifiAnchors -) { - public boolean isAvoid() { return presence == GpsPresence.AVOID; } -} +) {} diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsPresence.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsPresence.java deleted file mode 100644 index 747782d6..00000000 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsPresence.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.ruleup.ruleup_backend.verification.domain; - -/** GPS PRESENCE 방향(테크스펙 v2 §3, §7.1). VISIT=도달형(체류) / AVOID=제약형(회피). */ -public enum GpsPresence { VISIT, AVOID } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java index a660b779..089b1152 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java @@ -2,13 +2,27 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + /** - * 기상 인증 파라미터(§4.3). "beforeTime(예 07:00) 이전 첫 잠금해제"면 성공. - * 창은 [하루 시작, beforeTime] — beforeTime에 창이 닫힘(자정 아님, §2.11). + * 기상 인증 파라미터(§4.3). 「하루 시작부터 beforeTime + graceMinutes 까지 minSteps 걸음 이상」이면 성공. + * + *

      예전에는 하루 첫 잠금 해제 시각으로 기상을 봤다. 잠금 해제는 누워서 알람만 끄고 다시 자도 + * 잡히고, 반대로 일어나서 휴대폰을 안 보면 실패한다 — 「일어났다」가 아니라 「휴대폰을 봤다」를 + * 쟀다. 2026-10-15 부터 일어나서 움직인 흔적(걸음)으로 본다. + * + * @param beforeTime 목표 기상 시각 "HH:mm"(루틴의 target_time) + * @param graceMinutes 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나 걸음 기록이 + * 조금 늦게 잡혀도 인정한다 + * @param minSteps 창 안에서 필요한 걸음 수 + * @param trustedOrigins 걸음 기록을 인정하는 출처(비면 게이트 미적용) */ @JsonIgnoreProperties(ignoreUnknown = true) public record WakeConfig( - String beforeTime, // "HH:mm" + String beforeTime, + int graceMinutes, + int minSteps, + List trustedOrigins, Polarity polarity, // ACHIEVEMENT int maxSignalLagHours ) {} diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/GpsPresenceEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/GpsPresenceEvaluator.java index 72abe531..96e9df82 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/GpsPresenceEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/GpsPresenceEvaluator.java @@ -11,9 +11,9 @@ /** * GPS PRESENCE 평가기(테크스펙 v2 §5, §7.2). - * - VISIT(도달형): 지오펜스 체류 ≥ dwellMinutes. DWELL 트랜지션(OS가 loiteringDelay 체류 확정)→즉시 SUCCESS. + * - 방문(도달형): 지오펜스 체류 ≥ dwellMinutes. DWELL 트랜지션(OS가 loiteringDelay 체류 확정)→즉시 SUCCESS. * DWELL 없으면 ENTER/EXIT 페어 누적 ≥ 목표. fallback LOCATION: 멤버 앵커(OR) 반경 내 측위 시간폭 근사(§7.2, 부록 A). - * - AVOID(제약형, v2): 창/하루 내 ENTER가 하나라도 있으면 즉시 FAILED(ENTERED_AVOID_ZONE). 없으면 마감 배치가 SUCCESS. + * - 장소 피하기(AVOID)는 2026-10-15 유형째 제거했다 — 방문형만 남는다. * * 앵커는 PER_MEMBER(challenge_members.anchors). 트랜지션은 geofenceId=challengeMemberId라 * 좌표 없이도 처리되고, 좌표는 LOCATION fallback 반경 판정에서만 쓴다(멤버 앵커 OR, 없으면 config 레거시 단일앵커). @@ -45,10 +45,7 @@ public EvaluationOutcome evaluate(DayContext ctx) { List trans = collectTransitions(ctx); trans.sort(Comparator.comparing(t -> nz(safe(t.at())))); - // ===== AVOID(제약형): 유효한 진입만 위반. 허용 시간 안에 나왔으면 "스침"이다 ===== - if (cfg.isAvoid()) return evaluateAvoid(ctx, cfg, trans, windowClose); - - // ===== VISIT(도달형): dwell 누적 ===== + // ===== 도달형: dwell 누적 ===== int goalMin = (cfg.dwellMinutes() != null) ? cfg.dwellMinutes() : 0; long dwellSec = 0; Instant openEnter = null; @@ -105,67 +102,10 @@ public EvaluationOutcome evaluate(DayContext ctx) { : EvaluationOutcome.pending(ev, windowClose); } - /** - * AVOID(장소 피하기) 판정 — 진입했다고 바로 위반이 아니다. - * - *

      정책은 "허용 시간 안에 이탈한 것이 확인되면 스침으로 처리"다. 금지 장소 앞을 지나가기만 해도 - * 지오펜스는 ENTER 를 쏘기 때문에, 그대로 위반 처리하면 편의점 앞을 지난 사람이 실패한다. - * - *

        - *
      • ENTER→EXIT 쌍의 체류가 허용 시간 이하 → 스침(위반 아님)
      • - *
      • 체류가 허용 시간 초과 → 위반
      • - *
      • 아직 EXIT 이 안 왔고 허용 시간도 안 지났으면 판단 보류 — 이탈 신호가 늦게 올 수 있다
      • - *
      • OS 가 체류를 확정한 DWELL 은 유예 없이 위반 — 이미 "머물렀다"는 판정이다
      • - *
      - * 허용 시간은 서버 설정({@code loiteringDelayMin})이라 실기기 테스트로 조절할 수 있다. - */ - private EvaluationOutcome evaluateAvoid(DayContext ctx, GpsConfig cfg, - List trans, Instant windowClose) { - long graceSec = 60L * ((cfg.loiteringDelayMin() != null) ? cfg.loiteringDelayMin() : 0); - boolean violated = false; - Instant openEnter = null; - long longestStaySec = 0; - - for (GeofenceTransition t : trans) { - Instant at = safe(t.at()); - if (at == null) continue; - switch (nzStr(t.transition())) { - case "DWELL" -> violated = true; - case "ENTER" -> { if (openEnter == null) openEnter = at; } - case "EXIT" -> { - if (openEnter != null) { - long staySec = Math.max(at.getEpochSecond() - openEnter.getEpochSecond(), 0); - longestStaySec = Math.max(longestStaySec, staySec); - if (staySec > graceSec) violated = true; - openEnter = null; - } - } - default -> { } - } - } - // 아직 안 나온 진입: 허용 시간을 이미 넘겼으면 이탈 신호를 기다릴 것 없이 위반이다. - if (!violated && openEnter != null) { - long stayedSec = Math.max(ctx.now().getEpochSecond() - openEnter.getEpochSecond(), 0); - longestStaySec = Math.max(longestStaySec, stayedSec); - if (stayedSec > graceSec) violated = true; - } - - Map ev = new HashMap<>(); - ev.put("avoid", true); - ev.put("entered", violated); - ev.put("graceMinutes", graceSec / 60); - ev.put("dwellSeconds", longestStaySec); // 가장 오래 머문 시간 - if (openEnter != null) ev.put("enterAt", openEnter.toString()); - putHygiene(ev, ctx, cfg); - return violated - ? EvaluationOutcome.violated("ENTERED_AVOID_ZONE", ev, windowClose) - : EvaluationOutcome.pending(ev, windowClose); // 무위반은 확정 배치가 SUCCESS 로 잠근다 - } - /** * 이 멤버(=memberId)의 지오펜스 전환만 수집한다. * geofenceId=challengeMemberId 계약(§6.2)상 sync에 여러 챌린지 전환이 섞여 오므로, 여기서 memberId로 - * 필터하지 않으면 다른 챌린지 지오펜스의 ENTER/DWELL이 이 챌린지를 인증(또는 AVOID 위반)시킨다(교차 인증 버그). + * 필터하지 않으면 다른 챌린지 지오펜스의 ENTER/DWELL이 이 챌린지를 인증시킨다(교차 인증 버그). * memberId가 없으면(레거시/단일 멤버 컨텍스트) 필터하지 않는다. */ private List collectTransitions(DayContext ctx) { diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java index 7c86507a..d5fafe8c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java @@ -53,7 +53,7 @@ public EvaluationOutcome evaluate(DayContext ctx) { boolean anyReading = false; boolean anyRejected = false; List intervals = new ArrayList<>(); // 구간이 있는 레코드 - Set seenRecords = new HashSet<>(); + Map latestByRecord = new LinkedHashMap<>(); double cumulativeMax = 0; // 구간이 없는 레코드(그날 누적값) if (ctx.signals() != null) { @@ -66,7 +66,7 @@ public EvaluationOutcome evaluate(DayContext ctx) { if (cfg.exerciseType() != null && r.exerciseType() != null && !cfg.exerciseType().equalsIgnoreCase(r.exerciseType())) continue; // 운동종류 불일치 제외 - String gate = gateReason(r, cfg); + String gate = gateReason(r, cfg.rejectManualRecording(), cfg.trustedOrigins()); if (gate != null) { anyRejected = true; HealthOrigin o = r.origin(); @@ -80,13 +80,15 @@ public EvaluationOutcome evaluate(DayContext ctx) { cumulativeMax = Math.max(cumulativeMax, value); // 구간 없음 → 누적값으로 본다 continue; } - // 같은 레코드가 두 번 남아 있으면(재전송) 한 번만 센다. - if (r.recordId() != null && !seenRecords.add(r.recordId())) continue; - intervals.add(new Measured(from, to, value)); + // 같은 레코드가 여러 번 왔으면(재전송·기록 앱의 갱신) 마지막에 받은 것 하나만 센다. + // 처음 받은 값을 남기면 걸음이 늘어난 갱신이 버려져 목표에 영영 못 닿는다. + if (r.recordId() != null) latestByRecord.put(r.recordId(), new Measured(from, to, value)); + else intervals.add(new Measured(from, to, value)); } } } + intervals.addAll(latestByRecord.values()); double best = Math.max(cumulativeMax, dedupedSum(intervals)); Map ev = new HashMap<>(); @@ -105,8 +107,8 @@ public EvaluationOutcome evaluate(DayContext ctx) { return EvaluationOutcome.pending(ev, windowClose(ctx)); } - /** 구간을 가진 측정값 1건. */ - private record Measured(Instant from, Instant to, double value) {} + /** 구간을 가진 측정값 1건. 기상 판정도 같은 방식으로 겹침을 걷어낸다. */ + record Measured(Instant from, Instant to, double value) {} /** * 겹치는 구간을 걷어낸 합계. @@ -115,7 +117,7 @@ private record Measured(Instant from, Instant to, double value) {} * 비례해 더한다. 값이 구간에 고르게 분포한다고 보는 근사지만, 겹친 만큼을 통째로 두 번 세거나 * 통째로 버리는 것보다 실제에 가깝다. Health Connect 는 같은 활동을 기기별로 따로 기록한다. */ - private double dedupedSum(List readings) { + static double dedupedSum(List readings) { if (readings.isEmpty()) return 0; List sorted = new ArrayList<>(readings); sorted.sort(Comparator.comparing(Measured::from).thenComparing(Measured::to)); @@ -134,12 +136,11 @@ private double dedupedSum(List readings) { return sum; } - /** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. */ - private String gateReason(HealthReading r, HealthConfig cfg) { + /** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. 기상 판정의 걸음 기록에도 같은 게이트를 쓴다. */ + static String gateReason(HealthReading r, boolean rejectManualRecording, List trusted) { HealthOrigin o = r.origin(); if (o == null) return "NO_ORIGIN"; // §6.3 메타데이터 누락 - if (cfg.rejectManualRecording() && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL"; - List trusted = cfg.trustedOrigins(); + if (rejectManualRecording && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL"; if (trusted != null && !trusted.isEmpty()) { boolean ok = o.dataOrigin() != null && trusted.contains(o.dataOrigin()); if (!ok) return "UNTRUSTED_ORIGIN"; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java index cff54fe5..60bbccd9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java @@ -45,9 +45,14 @@ public EvaluationOutcome evaluate(DayContext ctx) { Map open = new HashMap<>(); long accSec = processEvents(ctx.signals(), targets, window, open); - // 창 닫힘 이후면 남은 open 세션을 창 끝에서 종료(꼬리 시간 반영) + // 창 닫힘 이후면 남은 open 세션을 닫는다(꼬리 시간 반영). 닫는 시각은 창 끝이 아니라 그 기기에서 마지막으로 + // 받은 시각까지다 — 앱은 화면 꺼짐을 보내지 않아서, 대상 앱이 앞에 뜬 채 배터리가 꺼지거나 그 뒤로 sync 가 + // 없으면 세션이 자정까지 늘어나 최대 사용형이 쓰지 않은 시간으로 실패했다. 그 뒤에 받은 기록이 없으니 + // 그 뒤에 썼다는 근거도 없다. 받은 시각을 모르면(옛 행) 예전처럼 창 끝에서 닫는다. if (!ctx.now().isBefore(window.end())) { - accSec += closeOpenAt(window.end(), window, open); + Instant lastSeen = lastReceivedAt(ctx.signals()); + Instant closeAt = (lastSeen != null && lastSeen.isBefore(window.end())) ? lastSeen : window.end(); + accSec += closeOpenAt(closeAt, window, open); open.clear(); } @@ -86,6 +91,24 @@ public EvaluationOutcome evaluate(DayContext ctx) { private static final Set CLOSE_EVENTS = Set.of("PAUSED", "ACTIVITY_PAUSED", "STOPPED", "ACTIVITY_STOPPED", "MOVE_TO_BACKGROUND"); + /** 앞 화면의 정지로 보고 넘기는 간격 — 화면 전환 뒤 앞 화면 STOPPED 는 보통 1초 안에 온다. */ + private static final java.time.Duration STOP_AFTER_SWITCH = java.time.Duration.ofSeconds(5); + + private static boolean isStop(String type) { + return "STOPPED".equals(type) || "ACTIVITY_STOPPED".equals(type); + } + + /** 사용 기록을 실은 신호를 마지막으로 받은 시각. 모르면 null. */ + private Instant lastReceivedAt(List signals) { + Instant last = null; + if (signals == null) return null; + for (SyncSignal s : signals) { + if (!carriesUsage(s.type()) || s.receivedAt() == null) continue; + if (last == null || s.receivedAt().isAfter(last)) last = s.receivedAt(); + } + return last; + } + /** 화면이 꺼지면 어떤 앱도 쓰이고 있지 않다 — 열린 세션 전부를 닫는 시스템 이벤트다. */ private static final Set SCREEN_OFF_EVENTS = Set.of("SCREEN_OFF", "SCREEN_NON_INTERACTIVE", "DEVICE_SHUTDOWN"); @@ -130,7 +153,12 @@ private long processEvents(List signals, Set targets, Window if (OPEN_EVENTS.contains(type)) { open.putIfAbsent(pkg, at); // 이미 열려있으면 유지(가장 이른 것) } else if (CLOSE_EVENTS.contains(type)) { - Instant opened = open.remove(pkg); + Instant opened = open.get(pkg); + // 같은 앱 안에서 화면을 옮기면 PAUSED(A) → RESUMED(B) → STOPPED(A) 순으로 온다. 이벤트에 화면(액티비티) + // 구분이 없어 STOPPED(A) 가 방금 연 B 를 닫으면 B 를 쓴 시간이 통째로 빠진다. 열린 직후의 정지는 앞 화면의 + // 것으로 보고 넘긴다 — 강제 종료로 PAUSED 없이 STOPPED 만 남는 경우는 그보다 늦게 온다. + if (opened != null && isStop(type) && at.isBefore(opened.plus(STOP_AFTER_SWITCH))) continue; + open.remove(pkg); if (opened != null) addedSec += overlapSeconds(opened, at, window); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java index 30f16284..46c91331 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java @@ -1,29 +1,33 @@ package com.ruleup.ruleup_backend.verification.evaluator; +import com.ruleup.ruleup_backend.verification.domain.HealthMetric; import com.ruleup.ruleup_backend.verification.domain.VerificationMethod; import com.ruleup.ruleup_backend.verification.domain.WakeConfig; -import com.ruleup.ruleup_backend.verification.signal.ScreenEvent; +import com.ruleup.ruleup_backend.verification.signal.HealthReading; import com.ruleup.ruleup_backend.verification.signal.SignalType; import com.ruleup.ruleup_backend.verification.signal.SyncSignal; import org.springframework.stereotype.Component; import java.time.Instant; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.*; /** - * WAKE(기상) 평가기 — 도달형(§2.6, §2.11). - * - 신호: WAKE·SCREEN_TIME 의 screenEvents(UNLOCK/SCREEN_ON) 중 당일 첫 발생 = firstUnlockAt. - * 두 타입을 모두 읽는다 — 기상만 보내는 기기는 WAKE 로, 앱 사용과 함께 보내는 기기는 - * SCREEN_TIME 에 실어 보낸다. 한쪽만 읽으면 기기에 따라 기상이 영영 안 잡힌다. - * - 창: [하루 시작, beforeTime]. beforeTime에 창이 닫힌다(자정 아님). - * - 판정: firstUnlockAt ≤ beforeTime → SUCCESS / 창 닫힘·미발생 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING. - * - 그날 원본 전부에서 가장 이른 잠금해제를 매번 다시 고른다 — 이월 상태가 없어 도착 순서와 무관하다. + * WAKE(기상) 평가기 — 도달형(§2.6, §2.11). 일어나서 걸었는가로 본다. + * - 신호: HEALTH 의 STEPS 기록(Health Connect StepsRecord). 구간(startTime~endTime)이 있는 기록만 쓴다 — + * 「언제 걸었는지」가 판정의 전부라, 구간 없는 하루 누적값은 근거가 되지 못한다. + * - 창: [하루 시작, beforeTime + graceMinutes]. 창에 걸친 기록은 겹친 비율만큼만 센다. + * - 판정: 창 안 걸음 ≥ minSteps → SUCCESS / 창 닫힘·미달 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING. + * - 출처 게이트는 움직임(HEALTH)과 같다 — 손입력·신뢰 밖 출처는 뺀다. 폰과 워치가 같은 걸음을 각자 + * 기록하면 겹친 구간은 한 번만 센다. + * - 그날 원본 전부를 매번 다시 읽는다 — 이월 상태가 없어 도착 순서와 무관하다. */ @Component public class WakeEvaluator implements MethodEvaluator { + private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm"); + @Override public VerificationMethod method() { return VerificationMethod.WAKE; } @@ -34,47 +38,65 @@ public EvaluationOutcome evaluate(DayContext ctx) { return EvaluationOutcome.pending(null, null); // 잘못된 config는 무시(PENDING 유지) } - Instant windowCloses = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone()); - Instant dayStart = TimeWindows.startOfDay(ctx.targetDate(), ctx.zone()); - Window window = new Window(dayStart, windowCloses); + Instant target = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone()); + Instant windowCloses = target.plusSeconds(60L * Math.max(cfg.graceMinutes(), 0)); + Window window = new Window(TimeWindows.startOfDay(ctx.targetDate(), ctx.zone()), windowCloses); - // 그날 원본 중 창 안에서 가장 이른 잠금해제 - Instant firstUnlock = earliestUnlockInWindow(ctx.signals(), window); + Set rejected = new LinkedHashSet<>(); + double steps = stepsInWindow(ctx.signals(), window, cfg, rejected); Map evidence = new HashMap<>(); evidence.put("beforeTime", cfg.beforeTime()); - if (firstUnlock != null) evidence.put("firstUnlockAt", firstUnlock.toString()); + evidence.put("graceMinutes", cfg.graceMinutes()); + evidence.put("deadline", LocalTime.ofInstant(windowCloses, ctx.zone()).format(HHMM)); + evidence.put("metric", HealthMetric.STEPS.name()); + evidence.put("unit", "count"); + evidence.put("value", Math.round(steps)); + evidence.put("goal", cfg.minSteps()); + if (!rejected.isEmpty()) evidence.put("rejectedOrigins", new ArrayList<>(rejected)); - EvaluationOutcome outcome; - if (firstUnlock != null) { - outcome = EvaluationOutcome.success(evidence, windowCloses); // 창 내 기상 → 즉시 성공 - } else if (window.isClosed(ctx.now())) { - outcome = EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses); // 창 닫힘·미발생 - } else { - outcome = EvaluationOutcome.pending(evidence, windowCloses); // 아직 대기 + if (cfg.minSteps() > 0 && steps >= cfg.minSteps()) { + return EvaluationOutcome.success(evidence, windowCloses); // 창 안에서 움직였다 → 즉시 성공 } - return outcome; - } - - /** 화면 이벤트를 실어 오는 입력 타입. 기상 전용(WAKE)과 앱 사용과 함께 오는 것(SCREEN_TIME) 둘 다다. */ - private boolean carriesScreenEvents(String type) { - return SignalType.WAKE.name().equals(type) || SignalType.SCREEN_TIME.name().equals(type); + if (window.isClosed(ctx.now())) { + return EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses); + } + return EvaluationOutcome.pending(evidence, windowCloses); } - private Instant earliestUnlockInWindow(List signals, Window window) { - if (signals == null) return null; - Instant earliest = null; + /** 창 안 걸음 합 — 출처 게이트를 통과한 구간 기록만, 창에 겹친 비율만큼, 겹친 기록은 한 번만. */ + private double stepsInWindow(List signals, Window window, WakeConfig cfg, Set rejected) { + if (signals == null) return 0; + // 같은 기록은 마지막에 받은 것으로 센다. 앱은 sync 마다 그날 기록을 다시 보내고, 기록 앱은 같은 기록의 + // 걸음 수·끝 시각을 키워 가며 고친다 — 처음 받은 값(예: 3걸음)에 묶이면 그날은 끝내 문턱을 못 넘는다. + // 신호는 받은 순서(행 id = 시간순)로 오므로 뒤에 온 것이 덮는다. + Map byRecord = new LinkedHashMap<>(); + List clipped = new ArrayList<>(); for (SyncSignal s : signals) { - if (!carriesScreenEvents(s.type()) || s.screenEvents() == null) continue; - for (ScreenEvent e : s.screenEvents()) { - // 기상 판정은 "당일 첫 잠금 해제"다(인증 정책 §1.1). 화면만 켜지는 일은 알림 확인으로도 - // 흔해서, SCREEN_ON 을 기상으로 세면 자는 사람이 깬 것으로 잡힌다. - if (!"UNLOCK".equalsIgnoreCase(e.event())) continue; - Instant at = TimeWindows.parseInstant(e.at()); - if (at == null || !window.contains(at)) continue; - if (earliest == null || at.isBefore(earliest)) earliest = at; + if (!SignalType.HEALTH.name().equals(s.type()) || s.readings() == null) continue; + for (HealthReading r : s.readings()) { + if (r == null || !HealthMetric.STEPS.name().equalsIgnoreCase(r.metric())) continue; + String gate = HealthEvaluator.gateReason(r, true, cfg.trustedOrigins()); + if (gate != null) { + String origin = (r.origin() != null && r.origin().dataOrigin() != null) ? r.origin().dataOrigin() : "?"; + rejected.add(origin + ":" + gate); + continue; + } + Instant from = TimeWindows.parseInstant(r.startTime()); + Instant to = TimeWindows.parseInstant(r.endTime()); + if (from == null || to == null || !to.isAfter(from) || r.value() == null) continue; + + Instant start = from.isBefore(window.start()) ? window.start() : from; + Instant end = to.isAfter(window.end()) ? window.end() : to; + if (!end.isAfter(start)) continue; // 창 밖 기록 + double share = (double) (end.getEpochSecond() - start.getEpochSecond()) + / (to.getEpochSecond() - from.getEpochSecond()); + HealthEvaluator.Measured m = new HealthEvaluator.Measured(start, end, r.value().doubleValue() * share); + if (r.recordId() != null) byRecord.put(r.recordId(), m); + else clipped.add(m); } } - return earliest; + clipped.addAll(byRecord.values()); + return HealthEvaluator.dedupedSum(clipped); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java index 99af72c4..28926cc2 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java @@ -124,7 +124,7 @@ private static String signalTypeOf(VerificationMethod method) { case GPS_PRESENCE, GPS_DISTANCE -> "LOCATION"; case HEALTH -> "HEALTH"; case SCREEN_TIME -> "SCREEN_TIME"; - case WAKE -> "WAKE"; + case WAKE -> "HEALTH"; case SLEEP -> "SLEEP"; default -> "UNKNOWN"; }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java index dbb58a78..a4aef480 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java @@ -26,7 +26,7 @@ public static Set of(VerificationMethod method) { Set.of("GEOFENCE", "GEOFENCE_TRANSITION", "LOCATION", "RUNNING_SESSION"); case HEALTH -> Set.of("HEALTH"); case SCREEN_TIME -> Set.of("SCREEN_TIME", "USAGE", "APP_USAGE"); - case WAKE -> Set.of("SCREEN_TIME", "WAKE", "UNLOCK", "USAGE"); + case WAKE -> Set.of("HEALTH"); // 기상은 일어나 걸은 걸음으로 본다(2026-10-15) case SLEEP -> Set.of("SLEEP"); default -> Set.of(); }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java index 5bae7ac4..0b0ca20c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java @@ -57,7 +57,7 @@ public record Decision(List accepted, List rejectedTypes, List consentRequired) {} /** - * 이 판정 방식이 요구하는 개별 동의 — 없으면 {@code null}(앱 사용·기상은 대상이 아니다). + * 이 판정 방식이 요구하는 개별 동의 — 없으면 {@code null}(앱 사용은 대상이 아니다. 기상은 걸음을 읽으므로 건강정보 동의가 필요하다). * *

      신호가 한 건도 오지 않아도 물을 수 있어야 한다. 동의가 없으면 그 방은 앞으로도 * 자동 인증이 불가능한 상태이고, 그건 권한이 꺼진 것과 같은 사건이다 — 신호가 실려 온 diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java index b1e10ca1..2cf8e873 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java @@ -150,7 +150,7 @@ private static String signalTypeOf(VerificationMethod method) { case GPS_PRESENCE, GPS_DISTANCE -> "LOCATION"; case HEALTH -> "HEALTH"; case SCREEN_TIME -> "SCREEN_TIME"; - case WAKE -> "WAKE"; + case WAKE -> "HEALTH"; case SLEEP -> "SLEEP"; default -> "UNKNOWN"; }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java index 05952196..469de2b9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java @@ -21,7 +21,7 @@ * v2 변경: * - 움직임 태그(HEALTH / 레거시 GPS_DISTANCE·ACTIVITY) → VerificationMethod.HEALTH 로 라우팅 + HealthConfig 조립. * (커스텀 세션 GPS_DISTANCE는 보존하되 라우팅 안 함, §3) - * - GPS_PRESENCE에 presence(VISIT/AVOID)·anchorFillMode·brandKeyword·nearbyRadiusM 반영. 앵커 좌표는 멤버 바인딩. + * - GPS_PRESENCE에 anchorFillMode·brandKeyword·nearbyRadiusM 반영. 앵커 좌표는 멤버 바인딩. */ @Component @RequiredArgsConstructor @@ -30,6 +30,17 @@ public class VerificationConfigFactory { private static final List DEFAULT_TRUSTED_ORIGINS = List.of("com.sec.android.app.shealth", "com.google.android.apps.fitness"); + /** + * 기상 걸음의 신뢰 writer. 걸음 목표 판정(HEALTH)보다 넓다 — 기상은 「일어나 움직였는가」만 보는 + * 10걸음 문턱이라 위조해서 얻을 것이 적고, 삼성헬스·구글 피트니스가 없는 폰은 Health Connect 플랫폼이나 + * 시스템 자체가 걸음을 기록한다. 두 앱만 믿으면 그런 폰의 기상은 매일 실패한다. 손입력은 여전히 뺀다. + */ + private static final List DEFAULT_TRUSTED_WAKE_ORIGINS = List.of( + "com.sec.android.app.shealth", // 삼성헬스 + "com.google.android.apps.fitness", // 구글 피트니스 + "com.google.android.apps.healthdata", // Health Connect 플랫폼 + "android"); // 기기 자체 걸음 센서 + /** * 수면 기록의 신뢰 writer. 걸음·거리보다 넓다 — 수면은 Health Connect 플랫폼 자신과 * Play 서비스의 Sleep API 도 기록자로 올라오기 때문이다. 목록 밖 출처는 AUTO 로 적혀 있어도 @@ -66,14 +77,16 @@ public VerificationConfig build(Challenge challenge) { switch (tag) { case "WAKE" -> wake = new WakeConfig( - timeParam(params, "target_time", "07:00"), Polarity.ACHIEVEMENT, 1); + timeParam(params, "target_time", "07:00"), + properties.wakeGraceMinutes(), properties.wakeMinSteps(), + DEFAULT_TRUSTED_WAKE_ORIGINS, Polarity.ACHIEVEMENT, 1); case "SCREEN_TIME_MIN" -> screenTime = new ScreenTimeConfig( ScreenTimeMode.MIN, Polarity.ACHIEVEMENT, packages(params), intParam(params, "duration_min", 30), timeWindow(params), 1); case "SCREEN_TIME_MAX" -> screenTime = new ScreenTimeConfig( ScreenTimeMode.MAX, Polarity.CONSTRAINT, packages(params), intParam(params, "duration_min", 30), timeWindow(params), 1); - case "GPS_PRESENCE", "GPS_AVOID" -> gps = buildGps(params, "GPS_AVOID".equals(tag)); + case "GPS_PRESENCE" -> gps = buildGps(params); // 움직임: 레거시 GPS_DISTANCE/ACTIVITY 태그도 v2에서는 HEALTH로 라우팅. case "HEALTH", "GPS_DISTANCE", "ACTIVITY" -> health = buildHealth(params); case "SLEEP" -> sleep = new SleepConfig( @@ -88,29 +101,18 @@ public VerificationConfig build(Challenge challenge) { gps, health, screenTime, wake, sleep, perms); } - // ===== GPS_PRESENCE (VISIT/AVOID) ===== - /** - * @param avoidByTemplate 루틴 템플릿이 "장소 피하기"({@code GPS_AVOID})로 정의된 경우. - * 방문/회피는 방 단위 인증 방식이라 사용자가 고치는 목표값(params)이 아니라 - * 템플릿 쪽에 둔다. 레거시 {@code gps_presence} 파라미터도 계속 인정한다. - */ - private GpsConfig buildGps(Map params, boolean avoidByTemplate) { - boolean avoid = avoidByTemplate || "AVOID".equalsIgnoreCase(strParam(params, "gps_presence")); - GpsPresence presence = avoid ? GpsPresence.AVOID : GpsPresence.VISIT; - Polarity polarity = avoid ? Polarity.CONSTRAINT : Polarity.ACHIEVEMENT; + // ===== GPS_PRESENCE (방문) ===== + private GpsConfig buildGps(Map params) { AnchorFillMode fillMode = "NEARBY_BRAND".equalsIgnoreCase(strParam(params, "anchor_fill_mode")) ? AnchorFillMode.NEARBY_BRAND : AnchorFillMode.MANUAL; int dwell = intParam(params, "duration_min", 60); - // 방문형의 loiteringDelay 는 "이만큼 머물러야 인정"(=dwell)이지만, - // 회피형에서는 "이만큼 안에 나오면 스침"이라 의미가 정반대다. 회피는 서버 설정값을 쓴다. - int loitering = avoid ? properties.avoidGraceMinutes() : dwell; return new GpsConfig( - GpsMode.PRESENCE, presence, fillMode, + GpsMode.PRESENCE, fillMode, strParam(params, "brand_keyword"), intParam(params, "nearby_radius_m", 1500), dbl(params, "lat"), dbl(params, "lng"), // 레거시 단일앵커(보통 null) — 평가는 멤버 anchors[] 우선 - intParam(params, "radius_m", 80), dwell, loitering, + intParam(params, "radius_m", 80), dwell, dwell, null, timeWindow(params), - polarity, 1, GPS_ACCURACY_MAX_M, 50, List.of()); + Polarity.ACHIEVEMENT, 1, GPS_ACCURACY_MAX_M, 50, List.of()); } /** @@ -172,8 +174,7 @@ private VerificationMethod methodOf(String tag) { return switch (tag) { case "WAKE" -> VerificationMethod.WAKE; case "SCREEN_TIME_MIN", "SCREEN_TIME_MAX" -> VerificationMethod.SCREEN_TIME; - // 방문(VISIT)·회피(AVOID) 는 같은 평가기가 처리한다 — 방향만 GpsConfig 에 실린다. - case "GPS_PRESENCE", "GPS_AVOID" -> VerificationMethod.GPS_PRESENCE; + case "GPS_PRESENCE" -> VerificationMethod.GPS_PRESENCE; // v2: 움직임은 전부 HEALTH로. 커스텀 세션(GPS_DISTANCE 평가기)은 보존하되 라우팅 제외. case "HEALTH", "GPS_DISTANCE", "ACTIVITY" -> VerificationMethod.HEALTH; case "SLEEP" -> VerificationMethod.SLEEP; @@ -193,7 +194,9 @@ private String heuristicTag(Object snapObj, Map params) { if (src == null) return "SELF_CHECK"; return switch (src) { case GEOFENCE -> "GPS_PRESENCE"; - case GPS, ACTIVITY, HC_RECORD -> "HEALTH"; // v2: 움직임은 Health Connect + // 기상도 걸음(Health Connect)으로 본다 — 목표 시각이 있으면 기상이다. + case GPS, ACTIVITY, HC_RECORD -> + (params != null && params.containsKey("target_time")) ? "WAKE" : "HEALTH"; case SLEEP -> "SLEEP"; case USAGE -> (params != null && params.containsKey("target_time")) ? "WAKE" : "SCREEN_TIME_MIN"; default -> "SELF_CHECK"; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java index 2a25a4a1..240d579d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java @@ -509,8 +509,8 @@ private boolean finalizeOne(VerificationDaily daily, Instant now) { // 판정 결과 고지 — 성공·실패 둘 다. 확정 시각이 귀속일 이틀 뒤 00:00 이라 그때 유저는 // 앱을 보고 있지 않다. 알림함이 「그날이 어떻게 끝났는지」를 확인할 유일한 자리다. - // 판정 하나에 확정은 하나뿐이라 verification_id 가 곧 멱등 키이고, 배치가 재실행돼도 - // 두 번 적재되지 않는다. + // 판정 하나에 확정은 하나뿐이라 (verification_id, variant) 멱등 키로 배치가 재실행돼도 + // 두 번 적재되지 않는다. 변형이 키에 들어가 있어 당일 취소된 수동 체크의 성공 알림에 막히지 않는다. if (member != null) { notificationPublisher.publish(NotificationEvent.forChallenge(member.getUserId(), NotificationType.VERIFICATION_RESULT, diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java index 14d7108a..195bef79 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java @@ -131,7 +131,8 @@ public ManualVerificationResponse submit(UUID userId, UUID challengeId, ManualVe // 수동 체크 성공도 판정 결과다. 확정 배치는 미확정 건만 집어가고 즉시 확정된 건은 // finalizeOne 초입의 isTerminal() 에서 걸러지므로, 여기서 고지하지 않으면 성공한 날은 - // 알림이 영영 없다. verification_id 가 멱등 키라 취소 후 다시 체크해도 한 번만 적재된다. + // 알림이 영영 없다. (verification_id, variant) 가 멱등 키라 취소 후 다시 체크해도 한 번만 적재되고, + // 취소한 채 하루가 지나면 이틀 뒤 「실패로 확정됐어요」는 따로 쌓인다. // 수동 체크는 당일만 되므로 늘 「오늘」이다 — 오늘 해야 할 게 이것 하나였다면 「모두 완료」가 대신한다. if (!completionNotice.afterSuccess(userId, daily.getTargetDate()).replacesIndividual()) { notificationPublisher.publish(NotificationEvent.forChallenge(userId, diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java index 1669bf87..497d3877 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java @@ -196,7 +196,8 @@ private String windowLabel(VerificationConfig config) { case GPS_PRESENCE -> (config.gps() != null) ? config.gps().timeWindow() : null; case SCREEN_TIME -> (config.screenTime() != null) ? config.screenTime().timeWindow() : null; case WAKE -> (config.wake() != null && config.wake().beforeTime() != null) - ? "~" + config.wake().beforeTime() : null; + ? "~" + java.time.LocalTime.parse(config.wake().beforeTime()) + .plusMinutes(config.wake().graceMinutes()) : null; case SLEEP -> (config.sleep() != null && config.sleep().bedtimeBefore() != null) ? "~" + config.sleep().bedtimeBefore() : null; default -> null; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSetupService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSetupService.java index 404af6ea..7d72c9bd 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSetupService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSetupService.java @@ -174,7 +174,7 @@ public MemberLocationResponse getMyLocation(UUID userId, UUID challengeId) { * 보낸 목록으로 앵커 세트 전체를 갈아끼운다(부분 수정 아님). 월 1회, 평상시엔 즉시 적용하고 * 인증 윈도우 중이면 익일 00:00부터 적용한다. 모더레이션은 타지 않는다(앵커는 심사 대상이 아님). * - *

      윈도우 중 변경을 거부하면 하루 종일 열린 윈도우(예: 회피형)에서는 장소를 영영 바꿀 수 없다. + *

      윈도우 중 변경을 거부하면 하루 종일 열린 윈도우에서는 장소를 영영 바꿀 수 없다. * 막는 대신 적용을 미뤄 그날 판정만 이전 장소로 지킨다. */ @Transactional diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java index 568e89ba..6e6ab9a5 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSignalReader.java @@ -93,26 +93,57 @@ public List forDay(UUID userId, LocalDate targetDate) { } /** - * 여러 귀속일을 한 번에. sync 는 오늘과 유예 중인 어제를 함께 평가한다. + * sync 한 번 동안 쓰는 읽기 창. 귀속일을 판정이 실제로 필요할 때 처음 읽고, 이웃한 귀속일(어제·오늘)이 겹쳐 + * 읽는 날은 한 번만 읽어 나눠 쓴다 — sync 한 번이 18문장에서 12문장이 됐다(부하테스트 2026-10 트레이스). + * + *

      미리 다 읽지 않는 이유: 어제가 이미 확정(또는 대상 아님)인 멤버뿐이면 그저께 하루치를 읽을 일이 없다. + * sync 는 DB 를 가장 많이 쓰는 API 다(2차 P0 재측정 스레드 덤프: 일하던 요청 스레드의 72%). * *

      전량을 못 읽은 날은 예외 대신 표시로 돌려준다 — sync 요청 하나를 통째로 실패시키면 * 그 유저의 다른 날짜·다른 챌린지 판정까지 함께 멈춘다. 호출부가 그 날만 건너뛴다. + * 호출부 트랜잭션 안에서 쓴다. */ - @Transactional(readOnly = true) - public Map forDays(UUID userId, List targetDates) { - Map byDate = new HashMap<>(); - for (LocalDate date : targetDates) byDate.put(date, read(userId, date)); - return byDate; + public Window window(UUID userId) { + return new Window(userId); + } + + public final class Window { + private final UUID userId; + private final Map loaded = new HashMap<>(); + private final Map byDate = new HashMap<>(); + + private Window(UUID userId) { + this.userId = userId; + } + + public DaySignalSet day(LocalDate targetDate) { + DaySignalSet set = byDate.get(targetDate); + if (set == null) { + set = read(userId, targetDate, loaded); + byDate.put(targetDate, set); + } + return set; + } } private DaySignalSet read(UUID userId, LocalDate targetDate) { + return read(userId, targetDate, new HashMap<>()); + } + + private DaySignalSet read(UUID userId, LocalDate targetDate, Map loaded) { List out = new ArrayList<>(); boolean complete = true; for (SignalDomain domain : SignalDomain.values()) { // 항목이 날짜 경계를 걸치므로 앞뒤 하루를 함께 읽는다. 하루씩 따로 질의해 // 매 질의가 파티션 하나로 좁혀진다. for (LocalDate date : List.of(targetDate.minusDays(1), targetDate, targetDate.plusDays(1))) { - complete &= readDay(userId, domain, date, out); + DaySignalSet day = loaded.computeIfAbsent(domain.name() + '@' + date, k -> { + List rows = new ArrayList<>(); + boolean whole = readDay(userId, domain, date, rows); + return whole ? DaySignalSet.complete(rows) : DaySignalSet.truncated(rows); + }); + out.addAll(day.signals()); + complete &= day.complete(); } } return complete ? DaySignalSet.complete(out) : DaySignalSet.truncated(out); diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java index 30fe6b2b..206794b2 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java @@ -36,6 +36,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.dao.PessimisticLockingFailureException; import java.math.BigDecimal; import java.math.RoundingMode; @@ -160,7 +163,26 @@ public VerificationSyncService(DeviceSyncPolicyService syncPolicy, ChallengeQuer .collect(Collectors.toMap(MethodEvaluator::method, e -> e, (a, b) -> a)); } - @Transactional + /** 데드락 재시도 — 트랜잭션 본문만 다시 돈다. 생성자가 길어 세터로 받는다. */ + private TransactionTemplate tx; + + @org.springframework.beans.factory.annotation.Autowired + void setTransactionManager(PlatformTransactionManager transactionManager) { + this.tx = new TransactionTemplate(transactionManager); + } + + /** 데드락으로 진 트랜잭션은 이 횟수까지 처음부터 다시 돈다(MySQL 1213 「try restarting transaction」). */ + static final int DEADLOCK_ATTEMPTS = 3; + + /** + * 봉투 검증·빈도 제한은 한 번만 하고, 저장·판정 트랜잭션만 데드락이면 다시 돈다. + * + *

      sync 한 건은 세션 갱신·신호 INSERT IGNORE·판정 갱신을 한 트랜잭션에서 한다. 같은 사용자의 sync 가 겹치면 + * (재전송·백로그, 빈도 제한기가 태스크마다 메모리에 있어 두 태스크로 나뉘면 막지 못한다) 서로의 잠금을 잡아 + * 한쪽이 데드락으로 진다 — 부하테스트(2026-10) 피크에서 5xx 11건이 전부 이것이었다. 진 쪽은 아무것도 남기지 않고 + * 롤백되므로 처음부터 다시 하면 된다. 재시도를 트랜잭션 밖에 두는 이유: 안에서 잡으면 이미 롤백 표시가 된 뒤다. + * 빈도 제한을 재시도에 다시 태우면 자기 자신의 첫 시도에 막혀 429 가 된다. + */ public SyncResponse sync(UUID userId, SyncRequest req) { long startedAt = System.nanoTime(); if (req == null) throw rejectEnvelope(userId, null, EnvelopeRejection.MISSING_BODY); @@ -183,6 +205,24 @@ public SyncResponse sync(UUID userId, SyncRequest req) { .distinct().toList(); List gaps = (req.gaps() != null) ? req.gaps() : List.of(); // §8.5 권한 공백 소비 입력 + for (int attempt = 1; ; attempt++) { + try { + return tx.execute(status -> syncInTransaction(userId, req, signals, ignored, gaps, startedAt)); + } catch (PessimisticLockingFailureException e) { + if (attempt >= DEADLOCK_ATTEMPTS) throw e; + log.warn("sync_deadlock_retry userId={} attempt={} cause={}", userId, attempt, e.getMostSpecificCause().getMessage()); + try { + Thread.sleep(java.util.concurrent.ThreadLocalRandom.current().nextLong(20, 80) * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List signals, List ignored, + List gaps, long startedAt) { Instant now = clock.instant(); LocalDate today = LocalDate.ofInstant(now, KST); @@ -213,14 +253,16 @@ public SyncResponse sync(UUID userId, SyncRequest req) { // 판정 입력은 저장된 원본이다. 오늘과 유예 중인 어제를 한 번씩만 읽어 멤버들이 나눠 쓴다 — // 같은 사용자 신호를 챌린지별로 복제해 읽지 않는다(백엔드 4-1-1 「사용자 신호 1회 저장」). LocalDate yesterday = today.minusDays(1); - Map daySignals = - signalReader.forDays(userId, List.of(yesterday, today)); + VerificationSignalReader.Window daySignals = signalReader.window(userId); List members = challengeQuery.findActiveMemberships(userId); + // 방마다 따로 읽지 않고 한 번에 — 방 수만큼 PK 조회가 sync 스레드 덤프 1위였다(2차 P0 재측정). + Map challenges = challengeQuery.findActiveChallenges( + members.stream().map(ChallengeMember::getChallengeId).toList()); List updated = new ArrayList<>(); for (ChallengeMember member : members) { - Challenge challenge = challengeQuery.findActiveChallenge(member.getChallengeId()).orElse(null); + Challenge challenge = challenges.get(member.getChallengeId()); if (challenge == null) continue; // 끝난 방도 유예 구간 동안은 열어 둔다. 종료 전환이 endDate 다음 날 일어나므로 // ACTIVE 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -253,7 +295,7 @@ public SyncResponse sync(UUID userId, SyncRequest req) { VerificationDaily daily = loadOrCreateDaily(member, challenge, today); VerificationStatus before = daily.getStatus(); VerificationStatus todayStatus = processMember(member, challenge, config, daily, - daySignals.get(today), gaps, consentGapOf, today, now); + () -> daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -454,7 +496,7 @@ private boolean withinGraceOfCompleted(Challenge challenge, Instant now) { * @return 이 재평가로 어제 건이 확정됐으면 true */ private boolean evaluateGraceDay(ChallengeMember member, Challenge challenge, VerificationConfig config, - Map daySignals, + VerificationSignalReader.Window daySignals, List gaps, java.util.function.Predicate consentGapOf, LocalDate today, Instant now) { @@ -470,7 +512,7 @@ private boolean evaluateGraceDay(ChallengeMember member, Challenge challenge, Ve VerificationStatus before = daily.getStatus(); VerificationStatus after = processMember(member, challenge, config, daily, - daySignals.get(yesterday), gaps, consentGapOf, yesterday, now); + () -> daySignals.day(yesterday), gaps, consentGapOf, yesterday, now); if (!becameFinal(before, after)) return false; progressService.recount(member); return true; @@ -490,7 +532,7 @@ private VerificationDaily loadOrCreateDaily(ChallengeMember member, Challenge ch private VerificationStatus processMember(ChallengeMember member, Challenge challenge, VerificationConfig config, VerificationDaily daily, - VerificationSignalReader.DaySignalSet daySignals, + java.util.function.Supplier signalsOfDay, List gaps, java.util.function.Predicate consentGapOf, LocalDate today, Instant now) { @@ -504,6 +546,8 @@ private VerificationStatus processMember(ChallengeMember member, Challenge chall } // 원본을 전부 읽지 못한 날은 평가하지 않는다. 잘린 값으로 「실패 예정」이나 성공을 찍으면 // 사용자에게 잘못된 결과가 그대로 보인다 — 판정을 미루는 편이 낫다. + // 확정된 날은 위에서 끝났다 — 신호는 판정할 때만 읽는다. + VerificationSignalReader.DaySignalSet daySignals = signalsOfDay.get(); if (daySignals == null || !daySignals.complete()) { log.warn("원본을 전부 읽지 못해 이 날 평가를 건너뛴다 userId={} targetDate={}", member.getUserId(), today); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index df37f1a4..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,6 +15,11 @@ spring: task: scheduling: + # 예약 작업이 50개가 넘는데 기본 스레드는 1개다 — 긴 작업(자정 활성화 등) 뒤에 다른 작업이 줄줄이 밀렸고, + # 밀린 cron 작업은 다른 태스크의 ShedLock 최소 잠금이 풀린 뒤 시작해 같은 날 중복 실행됐다(부하테스트 2026-10: + # 00:40 일별 집계가 태스크 3대에서 00:40·00:41·00:43 에 각각). 같은 작업의 중복은 여전히 ShedLock 이 막는다. + pool: + size: ${SCHEDULER_POOL_SIZE:4} # 확정 배치·정산·파기처럼 @Scheduled 로 도는 작업이 많다. 종료 시 스케줄러를 곧바로 # 끊으면 그 작업이 중간에 죽는데, 대부분 청크 트랜잭션이라 롤백은 깨끗해도 그만큼 # 다음 주기까지 처리가 밀린다. 짧게 기다려 진행 중인 한 사이클만 마치게 한다. @@ -36,8 +41,30 @@ spring: minimum-idle: 5 # 운영 지표 RuleUp/Ops 의 Pool 차원 — POOL-01~03 경보가 이 이름을 가리킨다(기본값 HikariPool-1 은 기동 순서에 따라 바뀔 수 있다). pool-name: ruleup-main + # 커넥션을 기다리는 상한. 기본 30초면 DB 장애조치(stg 실측 ~50초)·풀 포화 때 요청 스레드 200개가 30초씩 묶여 + # 장애가 끝난 뒤에도 밀린 요청을 한참 소화한다. 짧게 끊고 503 + Retry-After 로 돌려보낸다(GlobalExceptionHandler). + connection-timeout: ${DB_CONNECTION_TIMEOUT_MS:5000} + # MySQL 드라이버 성능 옵션 — 접속 주소(env)와 상관없이 늘 적용된다(부하테스트 2026-10). + # 주소에만 두면 env 가 기본 주소를 통째로 덮어 빠진다: stg·prod 주소에 rewriteBatchedStatements 가 없어 + # 신호 일괄 적재가 행마다 따로 나갔다. 프로파일에서 드라이버가 CPU 의 16% 였다. + data-source-properties: + rewriteBatchedStatements: true # batchUpdate 를 다중 행 INSERT 하나로 + cachePrepStmts: true # 같은 SQL 의 파싱 결과를 커넥션마다 재사용 + prepStmtCacheSize: 250 + prepStmtCacheSqlLimit: 2048 + useLocalSessionState: true # autocommit·읽기전용 상태를 드라이버가 기억 — 매 트랜잭션 확인 왕복 생략 + elideSetAutoCommits: true + # 읽기 전용 트랜잭션마다 SET SESSION TRANSACTION READ ONLY / READ WRITE 두 번을 서버로 보내지 않는다. + # 트레이스 감사(2026-10)에서 DB 왕복의 ~10% 였다. 쓰기 막기는 JPA 쪽(readOnly → flush 끔)이 계속 한다. + readOnlyPropagatesToServer: false + cacheServerConfiguration: true + maintainTimeStats: false jpa: + # 요청 전체에 EntityManager 하나를 걸어 두지 않는다. 켜 두면(기본값) 첫 트랜잭션이 잡은 커넥션을 요청이 + # 끝날 때까지 쥐어, 트랜잭션을 쪼개 둔 외부 호출(가입 닉네임 심사 등) 동안에도 풀을 차지한다 — 부하테스트 + # (2026-10)에서 가입 60건이 몰리자 풀 15개가 Gemini 대기로 다 차 다른 API 까지 500 이 났다. + open-in-view: false hibernate: ddl-auto: none naming: @@ -56,9 +83,11 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - # 명령 타임아웃은 짧게 — 탐색 조회가 Redis 를 기다리며 늘어지면 그건 장애를 지연으로 - # 바꾸는 것뿐이다. 느리면 빨리 포기하고 회로를 열어 503 으로 드러낸다. - timeout: ${REDIS_TIMEOUT:500ms} + # 명령 타임아웃 — 느리면 포기하고 회로를 열어 503 으로 드러낸다. 다만 500ms 는 너무 짧았다(부하테스트 2026-10): + # 앱 CPU 가 포화되면 Redis 는 한가해도(엔진 CPU 5%) 응답을 받을 이벤트 루프가 늦게 돌아 500ms 를 넘기고, + # 3번 연속이면 그 태스크의 탐색 전체가 30초간 503 이 됐다(시드 가입 몰림에서 145건). 진짜 장애(연결 끊김· + # 무응답)는 2초로도 충분히 빨리 걸린다. + timeout: ${REDIS_TIMEOUT:2000ms} # 연결 타임아웃은 그럴 수 없다. 기동 직후 워밍업은 JVM 이 아직 차가운 상태에서 # DNS 조회·TCP·Lettuce 핸드셰이크를 한꺼번에 하는데, 500ms 로는 그 한 번을 못 넘긴다 — # 실제로 배포할 때마다 워밍업이 여기서 실패해 회로가 열렸고, 5분 스윕이 다시 시도할 @@ -77,6 +106,12 @@ management: health: redis: enabled: ${REDIS_HEALTH_CHECK:false} + # DB 도 같은 이유로 뺀다. ALB 헬스체크(/actuator/health, 5초·3회)가 DB 검사를 하면, 커넥션 풀이 잠깐 + # 차기만 해도(대기 30초) 모든 태스크가 동시에 unhealthy 로 교체돼 전면 장애가 된다 — 부하테스트(2026-10) + # 가입 몰림에서 실제로 태스크 2대가 함께 내려가 502 가 났다. 태스크를 재시작해도 DB 문제는 낫지 않는다. + # DB 상태는 RDS·풀 지표 경보(POOL-01~03 등)로 본다. + db: + enabled: ${DB_HEALTH_CHECK:false} # 우리 앱 전용 설정 (외부에서 받는 값은 전부 .env에서 주입) app: @@ -174,6 +209,11 @@ app: # 시연·개발 확인용 요청 로그(devlog 패키지). 요청 한 건을 콘솔에 한 덩어리로 찍는다. # 임시 코드다 — 필요 없어지면 끄지 말고 devlog 패키지와 이 블록을 함께 지운다. + http: + # 태스크당 동시에 처리하는 /api 요청 상한(0 = 끔). 넘치면 inflight-wait-ms 만큼 기다렸다가 503 + Retry-After. + # 급증 때 모두가 느려지는 대신 받은 요청은 제 속도로 끝낸다(부하테스트 2026-10). 헬스체크는 관리 포트라 영향 없다. + max-inflight: ${APP_HTTP_MAX_INFLIGHT:0} + inflight-wait-ms: ${APP_HTTP_INFLIGHT_WAIT_MS:1000} request-log: enabled: ${APP_REQUEST_LOG_ENABLED:true} body: ${APP_REQUEST_LOG_BODY:true} # 요청·응답 본문까지 찍을지 @@ -185,6 +225,9 @@ app: sample-interval-ms: ${SYS_METRICS_INTERVAL_MS:60000} # 수집 주기(기본 60초) retention-days: ${SYS_METRICS_RETENTION_DAYS:14} # 스냅샷 보관 일수(초과분 정리) oauth: + # stg 부하테스트 동안만 true — IdP 호출만 빼고 실제 로그인 트랜잭션을 태운다(LoadTestOAuthGate, prod 에는 없다) + load-test: + enabled: ${OAUTH_LOAD_TEST_ENABLED:false} http: # OAuth 로그인은 토큰 교환 + 사용자 조회 2회 직렬 호출이다. 풀 고갈/IdP 지연에 무한정 매달리지 않도록 # 각 단계 제한 시간을 분리한다. Duration 형식(예: 500ms, 3s)으로 운영 환경에서 조정 가능하다. diff --git a/src/main/resources/db/migration/V12__remove_place_avoid.sql b/src/main/resources/db/migration/V12__remove_place_avoid.sql new file mode 100644 index 00000000..915347e7 --- /dev/null +++ b/src/main/resources/db/migration/V12__remove_place_avoid.sql @@ -0,0 +1,32 @@ +-- ====================================================================== +-- 장소 피하기(GPS_AVOID) 유형 제거 — 템플릿 1101~1110 +-- +-- 「등록한 장소에 머물지 않았다」는 판정은 성립하지 않는다. 피할 장소를 사용자가 직접 등록하므로 +-- 안 가는 곳을 등록하면 매일 저절로 성공하고, 휴대폰을 두고 가도 성공한다. 유형째 뺀다. +-- +-- 이미 그 유형으로 만든 방은 지우지 않는다 — 참여자·기록이 붙어 있다. 대신 판정 엔진이 다시 +-- 고를 수 없게 템플릿 연결을 끊고 직접 인증(SELF_CHECK)으로 바꾼다. 템플릿이 없어진 채로 +-- 자동 인증으로 두면 휴리스틱이 신호원(GEOFENCE)만 보고 「장소 방문」으로 판정해, 피하던 +-- 장소에 가야 성공하는 정반대의 방이 된다. +-- ====================================================================== + +UPDATE `challenges` + SET `verification_config` = JSON_OBJECT( + 'selectedMethod', 'MANUAL', + 'verificationType', 'MANUAL', + 'signalSource', 'SELF_CHECK', + 'wearableReq', 'NONE', + 'requiredPermissions', JSON_ARRAY(), + 'externalService', NULL), + `verification_type` = 'MANUAL', + `verification_guide` = NULL, + `template_id` = NULL + WHERE `template_id` BETWEEN 1101 AND 1110; + +-- 확정 전 초안이 지워진 템플릿을 가리키면 생성 때 자동 인증 정의를 찾지 못한다. +UPDATE `challenge_drafts` SET `template_id` = NULL WHERE `template_id` BETWEEN 1101 AND 1110; + +DELETE FROM `TemplateSegmentScore` WHERE `templateId` BETWEEN 1101 AND 1110; +DELETE FROM `TemplateStats` WHERE `templateId` BETWEEN 1101 AND 1110; +DELETE FROM `RoutineVerification` WHERE `templateId` BETWEEN 1101 AND 1110; +DELETE FROM `RoutineTemplate` WHERE `id` BETWEEN 1101 AND 1110; diff --git a/src/main/resources/db/migration/V13__wake_by_steps.sql b/src/main/resources/db/migration/V13__wake_by_steps.sql new file mode 100644 index 00000000..6775f53a --- /dev/null +++ b/src/main/resources/db/migration/V13__wake_by_steps.sql @@ -0,0 +1,31 @@ +-- ====================================================================== +-- 기상 루틴(1501~1507)을 첫 잠금 해제가 아니라 걸음으로 판정한다 +-- +-- 잠금 해제는 누워서 알람만 끄고 다시 자도 잡히고, 일어나서 휴대폰을 안 보면 실패한다. +-- 「하루 시작 ~ 목표 시각 + 유예」 안에 걸음이 잡히면 일어난 것으로 본다(유예·걸음 수는 서버 설정). +-- +-- 앱은 챌린지의 requiredPermissions 로 Health Connect 수집 지표를 정한다 — READ_STEPS 가 있어야 +-- 걸음 기록을 보낸다. 그래서 템플릿뿐 아니라 이미 만든 방의 스냅샷도 같이 바꾼다. 바꾸지 않으면 +-- 그 방은 판정기만 걸음을 기다리고 앱은 걸음을 보내지 않아 매일 무신호로 실패한다. +-- ====================================================================== + +UPDATE `RoutineTemplate` + SET `description` = '목표 시각쯤 일어나 걸은 걸음 기록으로 기상을 확인해요.', + `rationale` = '목표 시각 + 유예 안의 걸음으로 기상을 판정' + WHERE `id` BETWEEN 1501 AND 1507; + +UPDATE `RoutineVerification` + SET `autoVerificationType` = 'HEALTH_CONNECT', + `autoSignalSource` = 'HC_RECORD', + `autoRequiredPermissions` = JSON_ARRAY('android.permission.health.READ_STEPS') + WHERE `templateId` BETWEEN 1501 AND 1507; + +-- 자동 인증으로 만든 방만 — 직접 인증으로 바꾼 방은 신호를 쓰지 않는다. +UPDATE `challenges` + SET `verification_config` = JSON_SET(`verification_config`, + '$.verificationType', 'HEALTH_CONNECT', + '$.signalSource', 'HC_RECORD', + '$.requiredPermissions', JSON_ARRAY('android.permission.health.READ_STEPS')), + `verification_guide` = NULL + WHERE `template_id` BETWEEN 1501 AND 1507 + AND JSON_UNQUOTE(JSON_EXTRACT(`verification_config`, '$.selectedMethod')) = 'AUTO'; diff --git a/src/main/resources/db/migration/V14__close_empty_rooms.sql b/src/main/resources/db/migration/V14__close_empty_rooms.sql new file mode 100644 index 00000000..ed79953b --- /dev/null +++ b/src/main/resources/db/migration/V14__close_empty_rooms.sql @@ -0,0 +1,14 @@ +-- ====================================================================== +-- 활성 멤버가 0명인데 닫히지 않은 방을 닫는다(삭제 표시) +-- +-- 지금까지는 마지막 멤버가 나가도 봇방장으로 넘기기만 해서, 아무도 없는 방이 하루 1번 도는 +-- 유령방 삭제 배치가 지울 때까지(정산 대기가 있으면 1주 넘게) 탐색·인기에 남았다. 이제는 나가는 +-- 순간 닫는다(ChallengeMemberService#closeIfLastMember). 이미 그렇게 남아 있는 방도 같은 상태로 +-- 맞춘다 — 탐색 인덱스는 주기 보정이 빼내고, 이력과 실제 삭제는 같은 배치가 이어서 한다. +-- ====================================================================== + +UPDATE `challenges` c + SET c.`deleted_at` = UTC_TIMESTAMP(6) + WHERE c.`deleted_at` IS NULL + AND NOT EXISTS (SELECT 1 FROM `challenge_members` m + WHERE m.`challenge_id` = c.`id` AND m.`status` = 'ACTIVE'); diff --git a/src/main/resources/db/migration/V15__perf_indexes.sql b/src/main/resources/db/migration/V15__perf_indexes.sql new file mode 100644 index 00000000..c96a14bd --- /dev/null +++ b/src/main/resources/db/migration/V15__perf_indexes.sql @@ -0,0 +1,21 @@ +-- 부하테스트(2026-10, DAU 2만 기준) 점검에서 찾은, 배치가 표 전체를 훑는 조회 두 곳. + +-- 고스트 푸시 선점(1시간마다): status·setup_status 로 거른 뒤 joined_at 순서로 FOR UPDATE SKIP LOCKED. +-- 지금은 joined_at 인덱스로 멤버 전체를 가입순으로 훑으며 거르는데, REPEATABLE READ 에서는 훑은 행이 모두 잠겨 +-- 그동안 sync·가입이 멤버 행을 고치지 못한다. 셋업 미완료 멤버만 가입순으로 읽게 한다. +ALTER TABLE challenge_members + ADD KEY ix_member_setup_pending (status, setup_status, joined_at), + ALGORITHM = INPLACE, LOCK = NONE; + +-- 인기 점수 배치(5분마다)·일별 지표: 최근 24시간 가입 사건을 시각 범위로 센다. (challenge_id, joined_at) 인덱스로는 +-- 시각만으로 범위를 좁힐 수 없어 가입 사건 전체(누적, 계속 는다)를 훑었다. +ALTER TABLE challenge_join_events + ADD KEY ix_join_events_time (joined_at, challenge_id), + ALGORITHM = INPLACE, LOCK = NONE; + +-- 시작일 도달 활성화·종료일 지난 완료(1분마다): status + 날짜로 FOR UPDATE SKIP LOCKED 선점. 맞는 인덱스가 없어 +-- 방 전체(부하테스트 3.5만 행)를 훑었고, REPEATABLE READ 라 훑은 모든 방 행이 그 트랜잭션 동안 잠겨 가입·통계 재계산이 기다렸다. +ALTER TABLE challenges + ADD KEY ix_challenges_status_start (status, start_date), + ADD KEY ix_challenges_status_end (status, end_date), + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/main/resources/db/migration/V16__member_revision_index.sql b/src/main/resources/db/migration/V16__member_revision_index.sql new file mode 100644 index 00000000..6b878d14 --- /dev/null +++ b/src/main/resources/db/migration/V16__member_revision_index.sql @@ -0,0 +1,7 @@ +-- 부하테스트(2026-10, DAU 2만 기준) 2차: 방마다 「멤버가 마지막으로 움직인 시각」을 구하는 두 쿼리가 방의 멤버 전체를 훑었다. +-- · 탐색 투영의 원천 리비전 MAX(m.updated_at) WHERE m.challenge_id = c.id (전수 재구성·값 대조: 방 5만 개에서 56만 행, 최대 74초) +-- · 5분 통계 보정 대상 고르기 EXISTS(... m.challenge_id = c.id AND m.updated_at >= ?) +-- (challenge_id, updated_at) 이면 둘 다 방마다 인덱스 끝 한 칸만 본다. updated_at 단독 인덱스는 「그 사이 움직인 멤버」 범위 조회가 계속 쓴다. +ALTER TABLE challenge_members + ADD KEY ix_member_challenge_updated (challenge_id, updated_at), + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/main/resources/db/migration/V17__signal_dedup_lookup_index.sql b/src/main/resources/db/migration/V17__signal_dedup_lookup_index.sql new file mode 100644 index 00000000..a19887ab --- /dev/null +++ b/src/main/resources/db/migration/V17__signal_dedup_lookup_index.sql @@ -0,0 +1,13 @@ +-- 부하테스트(2026-10, DAU 2만 기준) 2차 P0 재측정: sync 가 신호를 저장할 때 「다른 귀속일에 이미 받은 기록인가」를 +-- 보관 기간 전체 날짜 범위로 묻는다(observedDate BETWEEN ? AND ? AND userId = ? AND dedupKey IN (...)). +-- 기존 인덱스는 (observedDate, userId, dedupKey) — 앞 칸이 범위라 userId·dedupKey 를 못 쓰고, (userId, occurredAt) 로 +-- 그 사용자 신호 전체를 훑었다(느린 쿼리 4,358건/30분). (userId, dedupKey) 면 키마다 한 번 찾는다. +ALTER TABLE verification_health_connect_signals + ADD KEY idx_health_connect_signal_user_dedup (userId, dedupKey), + ALGORITHM = INPLACE, LOCK = NONE; +ALTER TABLE verification_location_signals + ADD KEY idx_location_signal_user_dedup (userId, dedupKey), + ALGORITHM = INPLACE, LOCK = NONE; +ALTER TABLE verification_device_usage_signals + ADD KEY idx_device_usage_signal_user_dedup (userId, dedupKey), + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/main/resources/prompts/verification-guide.txt b/src/main/resources/prompts/verification-guide.txt index 36ca1442..f7f06d81 100644 --- a/src/main/resources/prompts/verification-guide.txt +++ b/src/main/resources/prompts/verification-guide.txt @@ -26,7 +26,7 @@ description: 챌린지가 만들어진 뒤, 그 방의 인증 조건(서버가 인증하라는 뜻으로 "직접 인증해 주세요."로 끝낸다. 5. **길이.** 한두 문장, 80자 이내. 존댓말("~됩니다", "~해 주세요"). 이모지·따옴표·마크다운 금지. 6. 판정 방식의 내부 용어(Health Connect, 지오펜스, 잠금 해제 이벤트 등)는 쓰지 않는다. - 단, 수면은 "수면 기록으로"라고 써도 된다. + 단, 수면은 "수면 기록으로", 기상은 "걸음 기록으로"라고 써도 된다. ## 예시 @@ -34,12 +34,10 @@ description: 챌린지가 만들어진 뒤, 그 방의 인증 조건(서버가 → `{"guide":"매일 10,000걸음 이상 걸으면 자동 인증됩니다."}` - 위치 / 루틴 "스터디 카페 가기" / 주 5회 / 등록한 장소에 2시간 이상 머물기 → `{"guide":"스터디 카페 장소를 등록한 뒤 주 5회 그곳에 2시간 이상 머물면 자동 인증됩니다."}` -- 장소 피하기 / 루틴 "술집 안 가기" / 매일 - → `{"guide":"자주 가던 술집을 등록하고 그곳에 머물지 않으면 자동 인증됩니다. 잠깐 스쳐 지나가는 것은 괜찮습니다."}` - 앱 줄이기 / 루틴 "유튜브 줄이기" / 매일 / 고른 앱을 하루 1시간 이하로 쓰기 → `{"guide":"매일 유튜브를 1시간 이하로 보면 자동 인증됩니다."}` -- 기상 / 매일 / 오전 7시 전에 처음 휴대폰 잠금 해제하기 - → `{"guide":"매일 오전 7시 전에 휴대폰 잠금을 처음 해제하면 자동 인증됩니다."}` +- 기상 / 매일 / 오전 7시쯤 일어나 걷기(걸음 기록 기준) + → `{"guide":"매일 오전 7시에 일어나 조금 걸으면 걸음 기록으로 자동 인증됩니다."}` - 수면 / 매일 / 밤 11시 전에 잠들기(수면 기록 기준) → `{"guide":"매일 밤 11시 전에 잠들면 수면 기록으로 자동 인증됩니다."}` - 수동 / 제목 "매일 코딩 문제 2개 풀기" / 매일 diff --git a/src/test/java/com/ruleup/ruleup_backend/auth/OnboardingSpecIT.java b/src/test/java/com/ruleup/ruleup_backend/auth/OnboardingSpecIT.java index 358b0cfe..8c9cdd09 100644 --- a/src/test/java/com/ruleup/ruleup_backend/auth/OnboardingSpecIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/auth/OnboardingSpecIT.java @@ -135,8 +135,9 @@ class OnboardingSpecIT extends ChallengeApiSupport { var user = member(uniq("activity")); jdbc.update("UPDATE user_activity SET last_active_on=DATE_SUB(CURDATE(),INTERVAL 24 DAY),notified_stage='D7' WHERE user_id=?", bytes(user.id())); Object updated = jdbc.queryForObject("SELECT updated_at FROM users WHERE id=?", Object.class, bytes(user.id())); - activity.touch(user.id()); - activity.touch(user.id()); + // 날짜를 DB 에서 되돌려 「다음 날의 첫 활동」을 흉내 낸다 — 같은 날 기억(touch)은 건너뛰므로 기록을 강제한다. + activity.touchNow(user.id()); + activity.touchNow(user.id()); assertThat(jdbc.queryForObject("SELECT notified_stage FROM user_activity WHERE user_id=?", String.class, bytes(user.id()))).isEqualTo("NONE"); assertThat(jdbc.queryForObject("SELECT updated_at FROM users WHERE id=?", Object.class, bytes(user.id()))).isEqualTo(updated); } diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java index 042cbf4c..3c9f871d 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java @@ -85,6 +85,20 @@ void activateEvenWhenInReview() throws Exception { assertThat(status(id)).isEqualTo("ACTIVE"); } + @Test + @DisplayName("한 번 실행에 200건을 넘는 시작 도래 방도 모두 ACTIVE — 자정에 몰려도 1분에 200건으로 밀리지 않는다") + void activatesBeyondOneChunkInOneRun() throws Exception { + Member owner = member(uniq("lc-bulk")); + java.util.List ids = new java.util.ArrayList<>(); + for (int i = 0; i < 450; i++) ids.add(insertChallenge(owner.id(), "EXERCISE", "UPCOMING", "SOLO")); + jdbcTemplate.update("UPDATE challenges SET start_date = DATE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', '+09:00')) " + + "WHERE owner_id = ? AND status = 'UPCOMING'", bytes(owner.id())); + + activationService.activateDueChallenges(); + + assertThat(ids).allSatisfy(id -> assertThat(status(id)).isEqualTo("ACTIVE")); + } + @Test @DisplayName("종료일 경과 ACTIVE → COMPLETED") void completeEnded() throws Exception { diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java index 79a7ca65..49757afd 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java @@ -94,6 +94,47 @@ class ChallengeLifecycleSpecIT extends ChallengeApiSupport { assertThat(jdbc.queryForObject("SELECT close_reason FROM challenge_history WHERE challenge_id=?",String.class,bytes(id))).isEqualTo("EMPTY"); } + private org.springframework.test.web.servlet.MvcResult leave(UUID id,String token) throws Exception { + return mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/v1/challenges/"+id+"/members/me").header("Authorization","Bearer "+token)).andReturn(); + } + + /** 마지막 멤버가 나가면 봇방장으로 넘기지 않고 방을 닫는다 — 탐색·상세·가입에서 바로 사라진다. */ + @Test void lastMemberLeavingClosesTheRoomInsteadOfBotOwner() throws Exception { + Member owner=member(uniq("last-leave"));Member other=member(uniq("last-leave-other")); + UUID id=insertChallenge(owner.id(),"EXERCISE","ACTIVE","GROUP"); + insertActiveMembership(id,owner.id(),"OWNER"); + + var left=leave(id,owner.token()); + assertThat(left.getResponse().getStatus()).isEqualTo(200); + assertThat((Boolean)read(left,"$.data.botOwnerActivated")).isFalse(); + + var row=jdbc.queryForMap("SELECT owner_type,deleted_at FROM challenges WHERE id=?",bytes(id)); + assertThat(row.get("owner_type")).as("아무도 없는 방을 봇방장으로 넘기지 않는다").isEqualTo("USER"); + assertThat(row.get("deleted_at")).as("0명이 되는 순간 닫힌다").isNotNull(); + assertThat(getAuth("/api/v1/challenges/"+id,other.token()).getResponse().getStatus()).isEqualTo(404); + assertThat(postJsonAuth("/api/v1/challenges/"+id+"/members",other.token(),Map.of()).getResponse().getStatus()) + .as("닫힌 방에 남이 가입해 되살릴 수 없다").isEqualTo(404); + assertThat((List)read(getAuth("/api/v1/challenges?filter=IN_PROGRESS",owner.token()),"$.data.challenges[*].challengeId")) + .doesNotContain(id.toString()); + assertThat((List)read(getAuth("/api/v1/challenges?filter=LEFT",owner.token()),"$.data.challenges[*].challengeId")) + .as("마이페이지 이탈 탭에는 기록으로 남는다").contains(id.toString()); + } + + /** 다른 멤버가 남아 있으면 지금처럼 봇방장 체제 — 방은 닫히지 않는다. */ + @Test void ownerLeavingWithOthersRemainingHandsOverToBot() throws Exception { + Member owner=member(uniq("bot-leave"));Member stay=member(uniq("bot-leave-stay")); + UUID id=insertChallenge(owner.id(),"EXERCISE","ACTIVE","GROUP"); + insertActiveMembership(id,owner.id(),"OWNER");insertActiveMembership(id,stay.id(),"MEMBER"); + + var left=leave(id,owner.token()); + assertThat((Boolean)read(left,"$.data.botOwnerActivated")).isTrue(); + var row=jdbc.queryForMap("SELECT owner_type,deleted_at FROM challenges WHERE id=?",bytes(id)); + assertThat(row.get("owner_type")).isEqualTo("BOT"); + assertThat(row.get("deleted_at")).isNull(); + assertThat(getAuth("/api/v1/challenges/"+id,stay.token()).getResponse().getStatus()).isEqualTo(200); + } + /** 관리자가 폐쇄한 방은 「완료」가 아니다 — 이탈 탭에 AUTO_CLOSED 로, 지워졌든 삭제가 미뤄졌든 같다. */ @Test void adminClosedRoomsAreStoppedNotCompleted() throws Exception { Member member=member(uniq("closed"));Member owner=member(uniq("closed-owner")); diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsCoalescingIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsCoalescingIT.java new file mode 100644 index 00000000..85488c39 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsCoalescingIT.java @@ -0,0 +1,83 @@ +package com.ruleup.ruleup_backend.challenge; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.stats.ChallengeStatsProjectionService; +import com.ruleup.ruleup_backend.challenge.stats.ChallengeStatsRefreshRequested; +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.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.context.WebApplicationContext; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** 같은 방의 통계 재계산 요청이 몰리면, 아직 시작하지 않은 재계산 한 번으로 합친다. */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class ChallengeStatsCoalescingIT extends ChallengeApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired ApplicationEventPublisher events; + @Autowired PlatformTransactionManager txManager; + @MockitoSpyBean ChallengeStatsProjectionService projection; + MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + @Test + @DisplayName("재계산이 도는 동안 들어온 요청 20건은 다음 재계산 한 번으로 합쳐지고 그 한 번이 마지막 가입까지 센다") + void burstForOneChallengeCoalesces() throws Exception { + Member owner = member(uniq("coal-owner")); + UUID challengeId = insertChallenge(owner.id(), "EXERCISE", "ACTIVE", "GROUP"); + insertActiveMembership(challengeId, owner.id(), "OWNER"); + + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(inv -> { + if (started.getCount() > 0) { started.countDown(); release.await(10, TimeUnit.SECONDS); } + return inv.callRealMethod(); + }).when(projection).refresh(eq(challengeId)); + + publish(challengeId); // 첫 재계산 — 붙잡아 둔다 + assertThat(started.await(10, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 20; i++) { // 그동안 가입 20건이 커밋된다 + insertActiveMembership(challengeId, member(uniq("coal-m")).id(), "MEMBER"); + publish(challengeId); + } + release.countDown(); + + verify(projection, timeout(10_000).times(2)).refresh(challengeId); + Thread.sleep(500); + verify(projection, times(2)).refresh(challengeId); + Integer count = jdbcTemplate.queryForObject("SELECT participant_count FROM challenges WHERE id=?", Integer.class, bytes(challengeId)); + assertThat(count).isEqualTo(21); + } + + private void publish(UUID challengeId) { + new TransactionTemplate(txManager).executeWithoutResult(s -> + events.publishEvent(ChallengeStatsRefreshRequested.of(challengeId, "JOIN"))); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java index 82be0def..5fbc303f 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java @@ -19,10 +19,15 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.context.WebApplicationContext; import java.math.BigDecimal; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @@ -46,6 +51,7 @@ class ChallengeStatsProjectionIT extends ChallengeApiSupport { @Autowired VerificationManualService manualVerificationService; @Autowired ChallengeActivationService activationService; @Autowired ApplicationEvents applicationEvents; + @Autowired PlatformTransactionManager txManager; MockMvc mvc; @Override protected MockMvc mvc() { return mvc; } @@ -306,4 +312,54 @@ void reconciliationCountsMissingRow() throws Exception { assertThat(rows).isEqualTo(1); } } + + // ===================================================================== + @Nested + @DisplayName("동시 재계산 — 락을 기다린 쪽이 옛 스냅샷으로 덮어쓰지 않는다") + class ConcurrentRefresh { + + @Test + @DisplayName("방 행을 다른 트랜잭션이 쥔 사이 들어온 가입도 기다렸다 센다") + void refreshWaitingOnLockSeesCommitsBeforeIt() throws Exception { + // 부하테스트(2026-10, 2배 피크)에서 5분 보정이 43건을 고쳤다. 재계산이 락을 잡기 전에 먼저 + // 세면 그 순간 REPEATABLE READ 스냅샷이 고정되고, 락을 기다리는 동안 커밋된 가입은 못 본 채 + // 마지막에 쓰게 된다 — 앞서 끝난 최신 재계산을 옛 값으로 덮는다. + UUID owner = member(uniq("race-owner")).id(); + UUID challengeId = activeChallenge(owner); + insertMemberWithProgress(challengeId, 0, 0, 0); + projectionService.refresh(challengeId); + UUID joiner = member(uniq("race-joiner")).id(); + + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture holder = CompletableFuture.runAsync(() -> + new TransactionTemplate(txManager).executeWithoutResult(tx -> { + // 다른 재계산·가입이 방 행을 쥐고, 그 안에서 가입이 하나 더 들어온다 + jdbcTemplate.queryForList("SELECT id FROM challenges WHERE id = ? FOR UPDATE", + (Object) bytes(challengeId)); + jdbcTemplate.update("INSERT INTO challenge_members " + + "(id, challenge_id, user_id, role, status, success_days, fail_days, target_days) " + + "VALUES (?, ?, ?, 'MEMBER', 'ACTIVE', 0, 0, 0)", + bytes(UUID.randomUUID()), bytes(challengeId), bytes(joiner)); + locked.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + + CompletableFuture refresh = CompletableFuture.runAsync(() -> projectionService.refresh(challengeId)); + Thread.sleep(1_000); // 재계산이 방 행 락에 걸려 기다리게 둔다 + assertThat(refresh).isNotDone(); + release.countDown(); + holder.get(10, TimeUnit.SECONDS); + refresh.get(10, TimeUnit.SECONDS); + + Integer participants = jdbcTemplate.queryForObject( + "SELECT participant_count FROM challenges WHERE id = ?", Integer.class, bytes(challengeId)); + assertThat(participants).isEqualTo(2); + } + } } diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeTrendingCategoryIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeTrendingCategoryIT.java index 55a863e8..c49ece8d 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeTrendingCategoryIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeTrendingCategoryIT.java @@ -229,6 +229,41 @@ void tierIsShownNotFiltered() throws Exception { assertThat((Boolean) read(res, "$.data.items[0].joinable")).isFalse(); } + @Test + @DisplayName("정원이 찬 방은 joinable=false, 정원이 넉넉한 방은 true — 마감 근처 방만 원천에서 센다") + void fullRoomIsNotJoinable() throws Exception { + String token = memberToken(uniq("tr-full")); + UUID full = publicGroup("EXERCISE", "ACTIVE", 6, 30); + jdbcTemplate.update("UPDATE challenges SET capacity = 5, participant_count = 4 WHERE id = ?", (Object) bytes(full)); // 저장값이 늦어도 + UUID roomy = publicGroup("EXERCISE", "ACTIVE", 5, 30); + jdbcTemplate.update("UPDATE challenges SET capacity = 100 WHERE id = ?", (Object) bytes(roomy)); + + popularityRefreshJob.runOnce(); + evictCaches(); + + MvcResult res = trending(token, null); + List ids = read(res, "$.data.items[*].challengeId"); + List joinable = read(res, "$.data.items[*].joinable"); + assertThat(joinable.get(ids.indexOf(full.toString()))).isFalse(); + assertThat(joinable.get(ids.indexOf(roomy.toString()))).isTrue(); + } + + @Test + @DisplayName("저장값으로 이미 찬 방은 다시 세지 않고 마감으로 본다") + void storedFullRoomIsNotJoinable() throws Exception { + String token = memberToken(uniq("tr-stfull")); + UUID full = publicGroup("EXERCISE", "ACTIVE", 5, 30); + jdbcTemplate.update("UPDATE challenges SET capacity = 5, participant_count = 5 WHERE id = ?", (Object) bytes(full)); + + popularityRefreshJob.runOnce(); + evictCaches(); + + MvcResult res = trending(token, null); + List ids = read(res, "$.data.items[*].challengeId"); + List joinable = read(res, "$.data.items[*].joinable"); + assertThat(joinable.get(ids.indexOf(full.toString()))).isFalse(); + } + @Test @DisplayName("category를 주면 그 카테고리의 인기만 내려준다") void perCategory() throws Exception { diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/CrossRankingSnapshotIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/CrossRankingSnapshotIT.java index 22f80d35..bec6b463 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/CrossRankingSnapshotIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/CrossRankingSnapshotIT.java @@ -2,30 +2,39 @@ import com.fasterxml.jackson.databind.JsonNode; import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.stats.ChallengeStatsRefreshRequested; +import com.ruleup.ruleup_backend.perf.SqlRecorder; import com.ruleup.ruleup_backend.room.service.CrossRankingSnapshotService; 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.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Import; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import java.util.List; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @SpringBootTest -@Import(TestcontainersConfiguration.class) +@Import({TestcontainersConfiguration.class, SqlRecorder.Config.class}) class CrossRankingSnapshotIT extends ChallengeApiSupport { @Autowired WebApplicationContext wac; @Autowired JdbcTemplate jdbcTemplate; @Autowired CrossRankingSnapshotService snapshotService; + @Autowired ApplicationEventPublisher events; + @Autowired PlatformTransactionManager txManager; MockMvc mvc; @Override protected MockMvc mvc() { return mvc; } @@ -74,6 +83,49 @@ void rankingRemainsStableUntilNextSnapshot() throws Exception { .isEqualByComparingTo("0.0000"); } + @Test + @DisplayName("멤버가 나가면 그 방 집계를 고치고 순위를 방 수와 상관없이 몇 문장으로 다시 매긴다") + void departureReranksInConstantStatements() throws Exception { + Member owner = member(uniq("cross-dep")); + Member leaver = member(uniq("cross-leaver")); + UUID a = insertChallenge(owner.id(), "EXERCISE", "ACTIVE", "GROUP"); + insertActiveMembership(a, owner.id(), "OWNER"); + insertActiveMembership(a, leaver.id(), "MEMBER"); + setDays(a, owner.id(), 45, 5); + setDays(a, leaver.id(), 50, 0); // a: 95/100 = 0.95 + UUID b = insertChallenge(owner.id(), "STUDY", "ACTIVE", "GROUP"); + insertActiveMembership(b, owner.id(), "OWNER"); + setDays(b, owner.id(), 46, 4); // b: 0.92 + UUID c = insertChallenge(owner.id(), "STUDY", "ACTIVE", "GROUP"); + insertActiveMembership(c, owner.id(), "OWNER"); + setDays(c, owner.id(), 40, 10); // c: 0.80 + snapshotService.refresh(); + assertThat(rank(a)).isLessThan(rank(b)); + assertThat(rank(b)).isLessThan(rank(c)); + + SqlRecorder.start(); + new TransactionTemplate(txManager).executeWithoutResult(s -> { + jdbcTemplate.update("UPDATE challenge_members SET status='LEFT',left_at=UTC_TIMESTAMP(6) WHERE challenge_id=? AND user_id=?", + bytes(a), bytes(leaver.id())); + events.publishEvent(ChallengeStatsRefreshRequested.of(a, "LEAVE")); + }); + var sql = SqlRecorder.stop(); + + assertThat(rank(b)).isLessThan(rank(a)); // a: 45/50 = 0.90 + assertThat(rank(a)).isLessThan(rank(c)); + List> rows = jdbcTemplate.queryForList( + "SELECT rank_no FROM challenge_cross_ranking_snapshot WHERE mode='GROUP' AND rank_no IS NOT NULL ORDER BY rank_no"); + for (int i = 0; i < rows.size(); i++) assertThat(((Number) rows.get(i).get("rank_no")).intValue()).isEqualTo(i + 1); + long rankWrites = sql.stream().filter(e -> e.sql().toLowerCase().contains("rank_no") + && e.sql().toLowerCase().startsWith("update")).count(); + assertThat(rankWrites).as(sql.toString()).isLessThanOrEqualTo(4); + } + + private int rank(UUID challengeId) { + return jdbcTemplate.queryForObject("SELECT rank_no FROM challenge_cross_ranking_snapshot WHERE challenge_id=?", + Integer.class, bytes(challengeId)); + } + private JsonNode ranking(Member viewer, UUID challengeId) throws Exception { MvcResult result = getAuth("/api/v1/rankings/challenges?mode=GROUP&size=50&challengeId=" + challengeId, viewer.token()); diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ExploreRedisFallbackIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ExploreRedisFallbackIT.java index faba33b2..6132e1cc 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ExploreRedisFallbackIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ExploreRedisFallbackIT.java @@ -239,6 +239,34 @@ void exploreIsUnavailableBeforeWarmUp() throws Exception { .isEqualTo(ErrorCode.EXPLORE_TEMPORARILY_UNAVAILABLE); } + @Autowired com.ruleup.ruleup_backend.challenge.explore.store.ExploreIndexJobs indexJobs; + @Autowired org.springframework.data.redis.core.StringRedisTemplate redisTemplate; + + @Test + @DisplayName("Redis 가 비면 다음 5분 보정이 다시 만든다 — 죽은 태스크의 재구성 잠금은 2분 안에 풀린다") + void sweepRebuildsAfterRedisLossAndDeadLockExpiresQuickly() throws Exception { + Member viewer = member(uniq("relost")); + seedChallenges(viewer.id(), 3); + + store.flushDerived(); + // 재구성 도중 사라진 태스크가 남긴 잠금 — 수명이 짧아야 다음 태스크가 곧 이어받는다. + String dead = store.tryLockRebuild(java.time.Duration.ofMinutes(2)); + assertThat(dead).isNotNull(); + assertThat(redisTemplate.getExpire( + com.ruleup.ruleup_backend.challenge.explore.store.ExploreKeys.REBUILD_LOCK)).isLessThanOrEqualTo(120L); + assertThat(store.extendRebuild("someone-else", java.time.Duration.ofMinutes(2))).isFalse(); + assertThat(store.extendRebuild(dead, java.time.Duration.ofMinutes(2))).isTrue(); + + indexJobs.sweepPopularityDecay(); + assertThat(store.isWarmed()).as("잠금이 남아 있는 동안은 기다린다").isFalse(); + + redisTemplate.delete(com.ruleup.ruleup_backend.challenge.explore.store.ExploreKeys.REBUILD_LOCK); // 만료 + indexJobs.sweepPopularityDecay(); + assertThat(store.isWarmed()).isTrue(); + assertThat(store.lastSweepAt()).as("다음 보정이 전체를 다시 세지 않게 기준 시각을 남긴다").isPresent(); + assertThat(exploreQueryService.explore(viewer.id(), null, null, false, "POPULAR", null, 3).items()).isNotEmpty(); + } + @Test @DisplayName("[P1] 다음 페이지를 받기 전에 인덱스가 내려가면 503 — 반쪽 목록을 이어 붙이지 않는다") void pagingStopsWithUnavailableWhenTheIndexGoesDown() throws Exception { diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java b/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java new file mode 100644 index 00000000..322be66d --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java @@ -0,0 +1,37 @@ +package com.ruleup.ruleup_backend.challenge.explore.store; + +import com.ruleup.ruleup_backend.challenge.stats.ChallengeStatsReconciliationService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** 배포 직후 첫 Redis 연결 실패가 곧바로 5분 폴백이 되지 않게 — 부하테스트 2026-10. */ +class ExploreWarmupRetryTest { + + @Test + @DisplayName("첫 연결이 실패해도 다시 붙으면 회로를 열지 않는다") + void retriesFirstConnection() { + ExploreRedisStore store = mock(ExploreRedisStore.class); + doThrow(new RedisConnectionFailureException("Unable to connect to Redis", new IllegalStateException("handshake timed out"))) + .doNothing().when(store).ping(); + ExploreCircuitBreaker circuit = new ExploreCircuitBreaker(new SimpleMeterRegistry()); + org.springframework.test.util.ReflectionTestUtils.setField(circuit, "enabled", true); + org.springframework.test.util.ReflectionTestUtils.setField(circuit, "openDurationMs", 30_000L); + ExploreIndexJobs jobs = new ExploreIndexJobs(mock(ExploreIndexer.class), store, circuit, mock(ChallengeStatsReconciliationService.class)); + + assertThat(jobs.redisReachable()).isTrue(); + verify(store, times(2)).ping(); + assertThat(circuit.isOpen()).isFalse(); + } + + @Test + @DisplayName("실패 원인은 감싼 예외가 아니라 맨 안쪽 원인까지 남긴다") + void logsRootCause() { + var e = new RedisConnectionFailureException("Unable to connect to Redis", new IllegalStateException("handshake timed out")); + assertThat(ExploreIndexJobs.rootCause(e)).contains("RedisConnectionFailureException").contains("handshake timed out"); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java b/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java index 0d5cea33..1f3fd183 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java @@ -50,4 +50,19 @@ ChallengeModerationQueue queue() { queue().handle(message); verify(sqs).deleteMessage(any(DeleteMessageRequest.class)); } + @Test void stopsPollingOnceShutdownBegins() { + // 배포 때 옛 태스크는 SqsClient 가 닫힌 뒤에도 폴러가 돌아 ERROR 를 남겼다 — 종료 신호 뒤엔 수신하지 않는다. + var queue=queue(); + queue.stop(); + queue.poll(); + verifyNoInteractions(sqs); + } + @Test void receiveFailureDuringShutdownIsSwallowed() { + var queue=queue(); + when(sqs.receiveMessage(any(ReceiveMessageRequest.class))).thenAnswer(inv -> { + queue.stop(); + throw new IllegalStateException("Connection pool shut down"); + }); + assertThatCode(queue::poll).doesNotThrowAnyException(); + } } diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterRedisIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterRedisIT.java index 81cd0f4d..52b4cd10 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterRedisIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterRedisIT.java @@ -23,8 +23,8 @@ class RecommendationRateLimiterRedisIT { connection.afterPropertiesSet();connection.start(); try { var template=new StringRedisTemplate(connection); - var first=new RecommendationRateLimiter(template,new SimpleMeterRegistry()); - var second=new RecommendationRateLimiter(template,new SimpleMeterRegistry()); + var first=new RecommendationRateLimiter(template,new SimpleMeterRegistry(),10); + var second=new RecommendationRateLimiter(template,new SimpleMeterRegistry(),10); String user=UUID.randomUUID().toString(),key="rate:challenge:draft:"+user; first.check(user); assertThat(template.getExpire(key,TimeUnit.SECONDS)).isBetween(59L,60L); diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterTest.java b/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterTest.java index 39aec5d4..04d86459 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/recommendation/RecommendationRateLimiterTest.java @@ -19,7 +19,7 @@ class RecommendationRateLimiterTest { StringRedisTemplate redis=mock(StringRedisTemplate.class); when(redis.execute(any(),anyList())).thenReturn(List.of(11L,42L)); var metrics=new SimpleMeterRegistry(); - var limiter=new RecommendationRateLimiter(redis,metrics); + var limiter=new RecommendationRateLimiter(redis,metrics,10); assertThatThrownBy(()->limiter.check("user")).isInstanceOf(BusinessException.class); assertThat(metrics.find("challenge.draft.rate_limiter.fallback").counter()).isNull(); } @@ -28,7 +28,7 @@ class RecommendationRateLimiterTest { @Test void limitedResponseCarriesRemainingSecondsAsNumber() { StringRedisTemplate redis=mock(StringRedisTemplate.class); when(redis.execute(any(),anyList())).thenReturn(List.of(11L,42L)); - var limiter=new RecommendationRateLimiter(redis,new SimpleMeterRegistry()); + var limiter=new RecommendationRateLimiter(redis,new SimpleMeterRegistry(),10); assertThatThrownBy(()->limiter.check("user")) .isInstanceOfSatisfying(BusinessException.class,limited->{ assertThat(limited.getRetryAfterSeconds()).isEqualTo(42); @@ -40,7 +40,7 @@ class RecommendationRateLimiterTest { @Test void remainingSecondsIsAtLeastOne() { StringRedisTemplate redis=mock(StringRedisTemplate.class); when(redis.execute(any(),anyList())).thenReturn(List.of(11L,-1L)); - var limiter=new RecommendationRateLimiter(redis,new SimpleMeterRegistry()); + var limiter=new RecommendationRateLimiter(redis,new SimpleMeterRegistry(),10); assertThatThrownBy(()->limiter.check("user")) .isInstanceOfSatisfying(BusinessException.class, limited->assertThat(limited.getRetryAfterSeconds()).isEqualTo(1)); @@ -50,7 +50,7 @@ class RecommendationRateLimiterTest { StringRedisTemplate redis=mock(StringRedisTemplate.class); when(redis.execute(any(),anyList())).thenThrow(new IllegalStateException("offline")); var metrics=new SimpleMeterRegistry(); - var limiter=new RecommendationRateLimiter(redis,metrics); + var limiter=new RecommendationRateLimiter(redis,metrics,10); try(var pool=Executors.newVirtualThreadPerTaskExecutor()) { var calls=IntStream.range(0,40).mapToObj(i->(Callable)()->{ try{limiter.check("same-user");return true;}catch(BusinessException limited){return false;} diff --git a/src/test/java/com/ruleup/ruleup_backend/common/error/DbUnavailableResponseTest.java b/src/test/java/com/ruleup/ruleup_backend/common/error/DbUnavailableResponseTest.java new file mode 100644 index 00000000..17c4c6b8 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/common/error/DbUnavailableResponseTest.java @@ -0,0 +1,32 @@ +package com.ruleup.ruleup_backend.common.error; + +import org.junit.jupiter.api.Test; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.CannotCreateTransactionException; + +import java.sql.SQLTransientConnectionException; + +import static org.assertj.core.api.Assertions.assertThat; + +class DbUnavailableResponseTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void poolTimeoutIs503WithRetryAfter() { + // Hikari 대기 초과가 트랜잭션 시작 실패로 감싸여 올라오는 모양 그대로 + var e = new CannotCreateTransactionException("Could not open JPA EntityManager", + new JpaSystemException(new RuntimeException( + new SQLTransientConnectionException("ruleup-main - Connection is not available, request timed out after 5000ms")))); + var res = handler.handleUnexpected(e); + assertThat(res.getStatusCode().value()).isEqualTo(503); + assertThat(res.getHeaders().getFirst("Retry-After")).isEqualTo("2"); + assertThat(res.getBody().error().code()).isEqualTo("TEMPORARILY_UNAVAILABLE"); + } + + @Test + void ordinaryBugStays500() { + var res = handler.handleUnexpected(new IllegalStateException("boom")); + assertThat(res.getStatusCode().value()).isEqualTo(500); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilterTest.java b/src/test/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilterTest.java new file mode 100644 index 00000000..897c42cb --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/common/web/InflightLimitFilterTest.java @@ -0,0 +1,57 @@ +package com.ruleup.ruleup_backend.common.web; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class InflightLimitFilterTest { + + @Test + void overLimitIsRejectedWith503AfterShortWait() throws Exception { + var registry = new SimpleMeterRegistry(); + var filter = new InflightLimitFilter(1, 50, registry); + CountDownLatch inside = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + Thread holder = new Thread(() -> { + try { + filter.doFilter(new MockHttpServletRequest("GET", "/api/v1/me/home"), new MockHttpServletResponse(), + (req, res) -> { + inside.countDown(); + try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + holder.start(); + assertThat(inside.await(5, TimeUnit.SECONDS)).isTrue(); + + var rejected = new MockHttpServletResponse(); + filter.doFilter(new MockHttpServletRequest("GET", "/api/v1/challenges/explore"), rejected, + (req, res) -> { throw new AssertionError("자리가 없으면 본문까지 가지 않는다"); }); + assertThat(rejected.getStatus()).isEqualTo(503); + assertThat(rejected.getHeader("Retry-After")).isEqualTo("2"); + assertThat(rejected.getContentAsString()).contains("TEMPORARILY_UNAVAILABLE"); + assertThat(registry.counter("http_inflight_rejected_total").count()).isEqualTo(1.0); + + // 비-API 경로는 세지 않는다 + var other = new MockHttpServletResponse(); + boolean[] passed = {false}; + filter.doFilter(new MockHttpServletRequest("GET", "/health/public"), other, (req, res) -> passed[0] = true); + assertThat(passed[0]).isTrue(); + + release.countDown(); + holder.join(5000); + var after = new MockHttpServletResponse(); + boolean[] ran = {false}; + filter.doFilter(new MockHttpServletRequest("GET", "/api/v1/me/home"), after, (req, res) -> ran[0] = true); + assertThat(ran[0]).as("끝난 요청이 자리를 돌려준다").isTrue(); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/me/MyPageContractIT.java b/src/test/java/com/ruleup/ruleup_backend/me/MyPageContractIT.java index d542f8c8..255b4268 100644 --- a/src/test/java/com/ruleup/ruleup_backend/me/MyPageContractIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/me/MyPageContractIT.java @@ -464,7 +464,7 @@ void achievement_grace_day_is_appealable() throws Exception { void constraint_grace_day_without_violation_is_not_appealable() throws Exception { Member me = member("cc-grace-constraint"); UUID ch = insertChallenge(me.id(), "EXERCISE", "ACTIVE", "SOLO"); - makeAvoidChallenge(ch); + makeConstraintChallenge(ch); UUID daily = insertDaily(ch, me.id(), 1, "PENDING", true); assertThat(firstDay(me, ch)).containsEntry("status", "IN_PROGRESS") @@ -478,21 +478,28 @@ void constraint_grace_day_without_violation_is_not_appealable() throws Exception void constraint_violation_is_appealable() throws Exception { Member me = member("cc-grace-violation"); UUID ch = insertChallenge(me.id(), "EXERCISE", "ACTIVE", "SOLO"); - makeAvoidChallenge(ch); + makeConstraintChallenge(ch); UUID daily = insertDaily(ch, me.id(), 1, "PENDING", true); - jdbc().update("UPDATE VerificationDaily SET failureReason = 'ENTERED_AVOID_AREA' WHERE id = ?", + jdbc().update("UPDATE VerificationDaily SET failureReason = 'USAGE_EXCEEDED' WHERE id = ?", bytes(daily)); assertThat(firstDay(me, ch)).containsEntry("status", "FAIL_EXPECTED") .containsEntry("appealable", true); } - /** 장소 피하기(규칙 지키기형)로 바꾼다 — 템플릿 없이 신호원·파라미터로 판정 방향이 정해진다. */ - private void makeAvoidChallenge(UUID challengeId) { - jdbc().update("UPDATE challenges SET verification_config = ?, params = ? WHERE id = ?", - "{\"selectedMethod\":\"AUTO\",\"verificationType\":\"PHONE\",\"signalSource\":\"GEOFENCE\"," + private static final java.util.concurrent.atomic.AtomicLong CONSTRAINT_TEMPLATE_SEQ = + new java.util.concurrent.atomic.AtomicLong(1_900_000); + + /** 앱 최대 사용(규칙 지키기형)으로 바꾼다 — 판정 방향은 템플릿의 판정 방식이 정한다. */ + private void makeConstraintChallenge(UUID challengeId) { + long templateId = CONSTRAINT_TEMPLATE_SEQ.incrementAndGet(); + insertAutoTemplate(templateId, "앱 줄이기-" + templateId, "설명", "DETOX", + "{}", "SCREEN_TIME_MAX", "[]"); + jdbc().update("UPDATE challenges SET template_id = ?, verification_config = ?, params = ? WHERE id = ?", + templateId, + "{\"selectedMethod\":\"AUTO\",\"verificationType\":\"PHONE\",\"signalSource\":\"USAGE\"," + "\"wearableReq\":\"NONE\",\"requiredPermissions\":[]}", - "{\"gps_presence\":\"AVOID\",\"radius_m\":100}", bytes(challengeId)); + "{\"duration_min\":30}", bytes(challengeId)); } @Test diff --git a/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java b/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java index 5592b27b..33ea0420 100644 --- a/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java @@ -306,8 +306,9 @@ void shape() { @Test @DisplayName("타입별 식별자 구성이 스펙 표와 일치한다") void perTypeIdentifiers() { + // 변형이 키에 들어간다 — 당일 취소된 수동 체크의 성공 알림이 이틀 뒤 실패 확정을 막지 않게. assertThat(NotificationType.VERIFICATION_RESULT.dedupKey(user, params())) - .endsWith(":v-12"); + .endsWith(":v-12:CONFIRMED_FAILURE"); assertThat(NotificationType.CHALLENGE_LIFECYCLE.dedupKey(user, params())) .endsWith(":c-301:STARTED"); assertThat(NotificationType.WATCHER_INVITATION_EXPIRED.dedupKey(user, params())) @@ -430,6 +431,7 @@ private static Map params() { p.put(NotificationParams.CAMPAIGN_ID, "cp-1"); p.put(NotificationParams.DATE, "2026-09-08"); p.put(NotificationParams.SLOT, "MORNING"); + p.put(NotificationParams.VARIANT, "CONFIRMED_FAILURE"); return p; } } diff --git a/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java b/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java index 5dc6ebac..db72c2b1 100644 --- a/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java @@ -47,6 +47,8 @@ class VerificationResultNotificationIT extends VerificationApiSupport { @Autowired NotificationRepository notificationRepository; @Autowired VerificationManualService manualService; @Autowired com.ruleup.ruleup_backend.verification.service.FailExpectedNoticeJob failExpectedJob; + @Autowired com.ruleup.ruleup_backend.verification.service.VerificationFinalizeService finalizeService; + @Autowired TestcontainersConfiguration.MutableClock clock; private MockMvc mvc; @@ -55,6 +57,11 @@ void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + @org.junit.jupiter.api.AfterEach + void resetClock() { + clock.reset(); + } + @Override protected MockMvc mvc() { return mvc; } @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } @@ -127,6 +134,35 @@ void manualSuccessNotifies() throws Exception { } } + // ===================================================================== + @Nested + @DisplayName("결과가 바뀐 판정") + class ChangedOutcome { + + @Test + @DisplayName("수동 체크를 당일 취소하면 이틀 뒤 실패 확정 알림이 앞선 성공 알림에 막히지 않는다") + void canceledManualCheckStillGetsConfirmedFailure() throws Exception { + Member me = member(uniq("vrc")); + UUID challengeId = insertChallenge(me.id(), "EXERCISE", "ACTIVE", "GROUP"); + insertActiveMembership(challengeId, me.id(), "OWNER"); + anotherPendingRoutine(me); + + String verificationId = manualService.submit(me.id(), challengeId, + new ManualVerificationRequest(null, null)).verificationId(); + manualService.cancel(me.id(), UUID.fromString(verificationId)); + manualService.submit(me.id(), challengeId, new ManualVerificationRequest(null, null)); + manualService.cancel(me.id(), UUID.fromString(verificationId)); + assertThat(resultsOf(me.id())).as("취소 후 다시 체크해도 성공 알림은 한 번").hasSize(1); + + clock.advance(java.time.Duration.ofDays(2).plusMinutes(1)); + finalizeService.finalizeDue(); + + assertThat(resultsOf(me.id())).extracting(Notification::getTitle).containsExactly( + "[테스트 챌린지] 인증이 실패로 확정됐어요", + "[테스트 챌린지] 인증이 완료됐어요"); + } + } + /** * 오늘 아직 체크하지 않은 수동 방 하나를 더 둔다. 오늘 해야 할 루틴이 하나뿐이면 「모두 완료」가 개별 성공 * 알림을 대신하므로(DailyRoutineNoticeIT), 개별 알림을 보는 시험은 남은 루틴이 있는 상태에서 본다. @@ -242,9 +278,8 @@ void unmetGoalDuringTargetDayDoesNotNotify() throws Exception { @DisplayName("규칙 지키기형은 위반 없이 하루가 끝나면 실패 예정이 아니다 — 알리지 않는다") void constraintWithoutViolationIsQuiet() throws Exception { Member me = member(uniq("vfc")); - UUID challengeId = insertAutoChallenge(me.id(), "GPS_PRESENCE", "GEOFENCE", - "{\"duration_min\":30,\"radius_m\":100,\"gps_presence\":\"AVOID\"}"); - UUID memberId = insertReadyMember(challengeId, me.id(), anchor(GYM_LAT, GYM_LNG, 100, "편의점"), null); + UUID challengeId = insertAutoChallenge(me.id(), "SCREEN_TIME_MAX", "USAGE", "{\"duration_min\":30}"); + UUID memberId = insertReadyMember(challengeId, me.id(), null, screenApps("com.instagram.android")); pendingYesterday(me, challengeId, memberId, null); failExpectedJob.notifyFor(java.time.LocalDate.now(KST).minusDays(1)); @@ -253,16 +288,14 @@ void constraintWithoutViolationIsQuiet() throws Exception { } @Test - @DisplayName("장소 피하기 위반이 잡힌 sync 에서 곧바로 알린다 — 확정 알림과 따로 쌓인다") + @DisplayName("앱 최대 사용 위반이 잡힌 sync 에서 곧바로 알린다 — 확정 알림과 따로 쌓인다") void violationDuringSync() throws Exception { Member me = member(uniq("vfv")); - UUID challengeId = insertAutoChallenge(me.id(), "GPS_PRESENCE", "GEOFENCE", - "{\"duration_min\":30,\"radius_m\":100,\"gps_presence\":\"AVOID\"}"); - UUID memberId = insertReadyMember(challengeId, me.id(), anchor(GYM_LAT, GYM_LNG, 100, "편의점"), null); + UUID challengeId = insertAutoChallenge(me.id(), "SCREEN_TIME_MAX", "USAGE", "{\"duration_min\":30}"); + insertReadyMember(challengeId, me.id(), null, screenApps("com.instagram.android")); postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of( - geofenceSignal(memberId, "ENTER", todayAt(0, 5)), - geofenceSignal(memberId, "EXIT", todayAt(0, 45))))); + usageSignal("com.instagram.android", todayAt(0, 5), todayAt(0, 45))))); assertThat(failExpectedOf(me.id())).singleElement().satisfies(n -> assertThat(n.getDeeplink()) diff --git a/src/test/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGateTest.java b/src/test/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGateTest.java new file mode 100644 index 00000000..d4dde084 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/oauth/LoadTestOAuthGateTest.java @@ -0,0 +1,59 @@ +package com.ruleup.ruleup_backend.oauth; + +import com.ruleup.ruleup_backend.user.domain.OAuthProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadTestOAuthGateTest { + + private final OAuthClient realKakao = new MockOAuthClient(OAuthProvider.KAKAO) { + @Override public OAuthUserInfo fetchUserInfo(String c, String v, String r) { throw new AssertionError("실제 IdP 로 가면 안 된다"); } + }; + + @Test + @DisplayName("prod 프로파일에는 빈이 없고, 플래그를 켜야만 등록된다") + void neverOnProd() { + assertThat(LoadTestOAuthGate.class.getAnnotation(Profile.class).value()).containsExactly("!prod"); + assertThat(LoadTestOAuthGate.class.getAnnotation(ConditionalOnProperty.class).havingValue()).isEqualTo("true"); + } + + @Test + @DisplayName("비밀값 없이 켜면 기동에서 실패한다") + void requiresSecret() { + assertThatThrownBy(() -> new LoadTestOAuthGate("")).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("접두어와 비밀값이 모두 맞을 때만 IdP 를 건너뛰고, 아니면 실제 클라이언트로 간다") + @SuppressWarnings("unchecked") + void admitsOnlyWithPrefixAndSecret() { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(new LoadTestOAuthGate("s3cret")); + OAuthClientResolver resolver = new OAuthClientResolver(List.of(realKakao), provider); + + assertThat(resolver.resolve(OAuthProvider.KAKAO, "loadtest-42", "s3cret").fetchUserInfo("loadtest-42", "s3cret", null).subject()) + .isEqualTo("mock-kakao-loadtest-42"); + assertThat(resolver.resolve(OAuthProvider.KAKAO, "loadtest-42", "wrong")).isSameAs(realKakao); + assertThat(resolver.resolve(OAuthProvider.KAKAO, "real-code", "s3cret")).isSameAs(realKakao); + assertThat(resolver.resolve(OAuthProvider.KAKAO, "loadtest-", "s3cret")).isSameAs(realKakao); + } + + @Test + @DisplayName("플래그가 꺼져 있으면(빈 없음) 항상 실제 클라이언트") + @SuppressWarnings("unchecked") + void offMeansReal() { + ObjectProvider provider = mock(ObjectProvider.class); + OAuthClientResolver resolver = new OAuthClientResolver(List.of(realKakao), provider); + assertThat(resolver.resolve(OAuthProvider.KAKAO, "loadtest-42", "s3cret")).isSameAs(realKakao); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java b/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java new file mode 100644 index 00000000..1988b911 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/observability/ops/RuntimeOpsSamplerJvmPanelTest.java @@ -0,0 +1,68 @@ +package com.ruleup.ruleup_backend.observability.ops; + +import com.ruleup.ruleup_backend.config.runtime.RuntimeRole; +import com.ruleup.ruleup_backend.config.runtime.RuntimeRoleProperties; +import com.ruleup.ruleup_backend.observability.metric.MetricContext; +import com.ruleup.ruleup_backend.observability.metric.MinuteMetricBuffer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** 대시보드 OPS-6(JVM 런타임) 지표 — 이름·차원이 대시보드와 어긋나면 위젯이 빈 칸이 된다. */ +class RuntimeOpsSamplerJvmPanelTest { + + @Test + @DisplayName("메모리 풀 이름을 대시보드 영역으로 묶고, 모르는 풀은 내보내지 않는다") + void memoryArea() { + assertThat(RuntimeOpsSampler.memoryArea("G1 Eden Space")).isEqualTo("eden"); + assertThat(RuntimeOpsSampler.memoryArea("G1 Survivor Space")).isEqualTo("survivor"); + assertThat(RuntimeOpsSampler.memoryArea("G1 Old Gen")).isEqualTo("old"); + assertThat(RuntimeOpsSampler.memoryArea("Metaspace")).isEqualTo("metaspace"); + assertThat(RuntimeOpsSampler.memoryArea("CodeHeap 'non-nmethods'")).isEqualTo("codecache"); + assertThat(RuntimeOpsSampler.memoryArea("Compressed Class Space")).isNull(); + } + + @Test + @DisplayName("한 번 채집하면 힙 영역·스레드 상태·클래스·업타임이 RuleUp/Ops 로 쌓이고, GC 횟수는 두 번째부터 증가분으로 낸다") + @SuppressWarnings("unchecked") + void samplesPanelMetrics() { + MutableClock clock = new MutableClock(); + MinuteMetricBuffer buffer = new MinuteMetricBuffer(clock); + RuntimeOpsSampler sampler = new RuntimeOpsSampler(buffer, new MetricContext("stg", new RuntimeRoleProperties(RuntimeRole.API, false)), + mock(ObjectProvider.class), new SimpleMeterRegistry(), mock(ApplicationContext.class)); + + sampler.sample(); + sampler.sample(); + clock.now = clock.now.plusSeconds(60); + var data = buffer.drainCompleted(); + + Set names = data.stream().map(d -> d.key().name()).collect(Collectors.toSet()); + assertThat(names).contains("JvmMemoryUsedMb", "JvmHeapMaxMb", "JvmGcPauseCount", "JvmThreads", "JvmClassesLoaded", "ProcessUptimeSec"); + Set states = data.stream().filter(d -> d.key().name().equals("JvmThreads")) + .map(d -> d.key().dimensions().get("State")).collect(Collectors.toSet()); + assertThat(states).containsExactlyInAnyOrder("live", "daemon", "peak", "runnable", "blocked", "waiting"); + Map heapDims = data.stream().filter(d -> d.key().name().equals("JvmMemoryUsedMb")).findFirst().orElseThrow().key().dimensions(); + assertThat(heapDims).containsEntry("Env", "stg").containsKey("Area"); + assertThat(data).allSatisfy(d -> assertThat(d.key().namespace()).isEqualTo(OpsSignals.OPS)); + } + + private static final class MutableClock extends Clock { + Instant now = Instant.parse("2026-10-15T10:00:05Z"); + + @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/ExternalCallConnectionIT.java b/src/test/java/com/ruleup/ruleup_backend/perf/ExternalCallConnectionIT.java new file mode 100644 index 00000000..d56fada5 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/ExternalCallConnectionIT.java @@ -0,0 +1,74 @@ +package com.ruleup.ruleup_backend.perf; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.auth.AuthApiSupport; +import com.ruleup.ruleup_backend.moderation.ContentModerationClient; +import com.ruleup.ruleup_backend.moderation.ModerationResult; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.engine.spi.SessionImplementor; +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.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.context.WebApplicationContext; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 외부 호출(LLM 심사 등)을 기다리는 동안 요청이 DB 커넥션을 쥐고 있지 않은지 본다. + * 부하테스트(2026-10, DAU 2만 시드): 가입 60건이 동시에 몰리자 닉네임 심사(Gemini ~1초) 내내 커넥션이 묶여 + * 풀 15개가 다 찼고, RDS CPU 16% 인데도 다른 모든 API 가 30초 대기 뒤 500 이 됐다 — open-in-view 가 켜져 있으면 + * 요청 전체가 EntityManager 하나를 쓰고, 그 EntityManager 가 처음 잡은 커넥션을 요청 끝까지 놓지 않는다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class ExternalCallConnectionIT extends AuthApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired EntityManagerFactory emf; + @MockitoBean ContentModerationClient moderationClient; + private MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + @Test + @DisplayName("가입 직후 닉네임 심사를 기다리는 동안 DB 커넥션을 쥐지 않는다") + void signupModerationDoesNotHoldConnection() throws Exception { + AtomicReference during = new AtomicReference<>("not-called"); + when(moderationClient.moderateNickname(anyString())).thenAnswer(inv -> { + during.set(connectionState()); + return ModerationResult.APPROVED; + }); + + String tag = uniq("osiv"); + var res = signup(tag, "커넥션" + seq()); + + assertThat(res.getResponse().getStatus()).isEqualTo(200); + assertThat(during.get()).isEqualTo("no-connection"); + } + + /** 이 스레드에 묶인 EntityManager 가 물리 커넥션을 쥐고 있는지. */ + private String connectionState() { + var holder = (EntityManagerHolder) TransactionSynchronizationManager.getResource(emf); + if (holder == null) return "no-connection"; + EntityManager em = holder.getEntityManager(); + boolean connected = em.unwrap(SessionImplementor.class).getJdbcCoordinator().getLogicalConnection().isPhysicallyConnected(); + return connected ? "holding-connection" : "no-connection"; + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java b/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java new file mode 100644 index 00000000..382b6c4a --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java @@ -0,0 +1,113 @@ +package com.ruleup.ruleup_backend.perf; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.auth.AuthApiSupport; +import com.ruleup.ruleup_backend.moderation.ContentModerationClient; +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.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * 요청 하나가 DB 에 하는 일을 센다 — 부하테스트(2026-10) 에서 로그인·생성이 과한지 보려고 만들었다. + * 숫자가 늘면 이 시험이 먼저 알려 준다(상한은 개선 후 실측값). + */ +@SpringBootTest +@Import({TestcontainersConfiguration.class, SqlRecorder.Config.class}) +class RequestDbProfileIT extends AuthApiSupport { + + @Autowired WebApplicationContext wac; + @MockitoBean ContentModerationClient moderationClient; + private MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + record Profile(int connections, int commits, List sql) { + long count(String verb) { return sql.stream().filter(e -> e.sql().regionMatches(true, 0, verb, 0, verb.length())).count(); } + void print(String title) { + System.out.printf("%n==== %s — 커넥션 %d · 커밋 %d · SQL %d (SELECT %d · INSERT %d · UPDATE %d · DELETE %d)%n", title, connections, commits, + sql.size(), count("select"), count("insert"), count("update"), count("delete")); + sql.forEach(e -> System.out.printf(" [%s] %s%n", e.thread(), e.sql().length() > 220 ? e.sql().substring(0, 220) + "…" : e.sql())); + } + } + + private Profile profile(ThrowingRunnable r) throws Exception { + SqlRecorder.start(); + r.run(); + Thread.sleep(1500); // 커밋 뒤 비동기 리스너까지 기다린다 + List sql = SqlRecorder.stop(); + return new Profile(SqlRecorder.connections(), SqlRecorder.commits(), sql); + } + + interface ThrowingRunnable { void run() throws Exception; } + + @Test + @DisplayName("기존 회원 로그인 · 리프레시 · 템플릿 초안 · 생성의 DB 작업") + void loginRefreshCreate() throws Exception { + String tag = uniq("perf"); + signup(tag, "성능" + seq()); + Map login = loginBody(tag, "inst-" + tag, "dev-" + tag); + postJson("/api/v1/auth/oauth/kakao", login); // 첫 로그인은 캐시 워밍(동기화 정책 등) + + MvcResult[] res = new MvcResult[1]; + Profile loginP = profile(() -> res[0] = postJson("/api/v1/auth/oauth/kakao", login)); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + loginP.print("기존 회원 로그인(같은 기기)"); + String access = read(res[0], "$.data.accessToken"); + String refresh = read(res[0], "$.data.refreshToken"); + + Profile refreshP = profile(() -> res[0] = postJson("/api/v1/auth/refresh", Map.of("refreshToken", refresh))); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + refreshP.print("리프레시"); + + Profile draftP = profile(() -> res[0] = postJsonAuth("/api/v1/challenges/recommendation/by-template", access, Map.of("templateId", 1001))); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + draftP.print("템플릿 초안"); + Map body = createBody(res[0]); + + Profile createP = profile(() -> res[0] = mvc.perform(post("/api/v1/challenges").header("Authorization", "Bearer " + access) + .header("Idempotency-Key", UUID.randomUUID().toString()).contentType("application/json") + .content(OM.writeValueAsString(body))).andReturn()); + assertThat(res[0].getResponse().getStatus()).as(res[0].getResponse().getContentAsString()).isIn(200, 201); + createP.print("챌린지 생성(공개 그룹)"); + + Profile homeP = profile(() -> mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/me/home") + .header("Authorization", "Bearer " + access)).andReturn()); + homeP.print("홈(GET /me/home)"); + } + + @SuppressWarnings("unchecked") + private Map createBody(MvcResult draft) throws Exception { + Map d = read(draft, "$.data.draft"); + Map b = new LinkedHashMap<>(); + b.put("draftId", read(draft, "$.data.draftId")); + for (String k : List.of("title", "description", "category", "minTier", "period", "weeklyCount")) b.put(k, d.get(k)); + b.put("mode", "GROUP"); b.put("visibility", "PUBLIC"); b.put("rankingVisible", true); b.put("capacity", 100); + b.put("params", ((List>) d.get("params")).stream() + .map(p -> Map.of("key", p.get("key"), "value", String.valueOf(p.get("value")))).collect(Collectors.toList())); + Map v = (Map) d.get("verification"); + b.put("verification", Map.of("type", v.get("type"), "method", v.get("method"))); + b.put("penalties", Map.of("watcher", false)); + return b; + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java b/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java new file mode 100644 index 00000000..f730e754 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java @@ -0,0 +1,89 @@ +package com.ruleup.ruleup_backend.perf; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.datasource.DelegatingDataSource; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 시험 전용 — 요청 하나가 DB 에 무엇을 몇 번 하는지 센다(커넥션 대여·트랜잭션·SQL 문장). + * 데이터소스를 감싸 Connection·Statement 호출을 기록한다. 운영 코드에는 없다. + */ +public final class SqlRecorder { + + public record Entry(String thread, String sql) {} + + private static final List SQL = new ArrayList<>(); + private static final AtomicInteger CONNECTIONS = new AtomicInteger(); + private static final AtomicInteger COMMITS = new AtomicInteger(); + private static volatile boolean on; + + private SqlRecorder() {} + + public static synchronized void start() { SQL.clear(); CONNECTIONS.set(0); COMMITS.set(0); on = true; } + public static synchronized List stop() { on = false; return List.copyOf(SQL); } + public static int connections() { return CONNECTIONS.get(); } + public static int commits() { return COMMITS.get(); } + + private static synchronized void record(String sql) { + if (on) SQL.add(new Entry(Thread.currentThread().getName(), sql.replaceAll("\\s+", " ").trim())); + } + + @TestConfiguration + public static class Config { + @Bean + static BeanPostProcessor sqlRecordingDataSource() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String name) { + if (bean instanceof DataSource ds && !(bean instanceof Recording)) return new Recording(ds); + return bean; + } + }; + } + } + + static final class Recording extends DelegatingDataSource { + Recording(DataSource target) { super(target); } + + @Override public Connection getConnection() throws SQLException { return wrap(super.getConnection()); } + @Override public Connection getConnection(String u, String p) throws SQLException { return wrap(super.getConnection(u, p)); } + + private static Connection wrap(Connection c) { + if (on) CONNECTIONS.incrementAndGet(); + InvocationHandler h = (proxy, m, args) -> { + String n = m.getName(); + if (("prepareStatement".equals(n) || "prepareCall".equals(n)) && args != null && args[0] instanceof String sql) record(sql); + if ("commit".equals(n) && on) COMMITS.incrementAndGet(); + Object r = invoke(m, c, args); + if (r instanceof Statement st && "createStatement".equals(n)) return statement(st); + return r; + }; + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, h); + } + + private static Statement statement(Statement st) { + Set exec = Set.of("execute", "executeQuery", "executeUpdate", "executeLargeUpdate", "addBatch"); + return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, (p, m, a) -> { + if (exec.contains(m.getName()) && a != null && a.length > 0 && a[0] instanceof String sql) record(sql); + return invoke(m, st, a); + }); + } + + private static Object invoke(java.lang.reflect.Method m, Object target, Object[] args) throws Throwable { + try { return m.invoke(target, args); } catch (InvocationTargetException e) { throw e.getCause(); } + } + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/UserBulkLoadIT.java b/src/test/java/com/ruleup/ruleup_backend/perf/UserBulkLoadIT.java new file mode 100644 index 00000000..99273363 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/UserBulkLoadIT.java @@ -0,0 +1,61 @@ +package com.ruleup.ruleup_backend.perf; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.auth.AuthApiSupport; +import com.ruleup.ruleup_backend.moderation.ContentModerationClient; +import com.ruleup.ruleup_backend.user.UserRepository; +import com.ruleup.ruleup_backend.user.domain.User; +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.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** 멤버 목록 화면(방 홈·랭킹·멤버·스레드)이 쓰는 사용자 일괄 조회가 사용자 수와 상관없이 한 문장인지. */ +@SpringBootTest +@Import({TestcontainersConfiguration.class, SqlRecorder.Config.class}) +class UserBulkLoadIT extends AuthApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired UserRepository users; + @MockitoBean ContentModerationClient moderationClient; + private MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + @Test + @DisplayName("사용자 5명을 개인정보·관심사까지 한 문장으로 읽는다") + void findAllByIdIsOneStatement() throws Exception { + List ids = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + var res = signup(uniq("bulk"), "일괄" + seq()); + ids.add(UUID.fromString(read(res, "$.data.user.id"))); + } + + SqlRecorder.start(); + List loaded = users.findAllById(ids); + var sql = SqlRecorder.stop(); + + assertThat(loaded).hasSize(5); + assertThat(loaded).allSatisfy(u -> { + assertThat(u.getInformation()).isNotNull(); + assertThat(u.getInterestCategories()).isNotEmpty(); + }); + assertThat(sql).as(sql.toString()).hasSize(1); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java b/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java index 64b26416..23a270cb 100644 --- a/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java @@ -37,7 +37,6 @@ class RoutineCatalogSeedIT { /** verificationMethod → 그 판정기가 실제로 읽는 목표값 키(VerificationConfigFactory 기준). */ private static final Map> EXPECTED_PARAM_KEYS = Map.of( "GPS_PRESENCE", Set.of("duration_min"), - "GPS_AVOID", Set.of("duration_min"), "SCREEN_TIME_MAX", Set.of("duration_min"), "SCREEN_TIME_MIN", Set.of("duration_min"), "WAKE", Set.of("target_time"), @@ -53,10 +52,9 @@ class RoutineCatalogSeedIT { */ private static final Map> ALLOWED_PERMISSIONS = Map.of( "GPS_PRESENCE", Set.of("ACCESS_FINE_LOCATION", "ACCESS_BACKGROUND_LOCATION"), - "GPS_AVOID", Set.of("ACCESS_FINE_LOCATION", "ACCESS_BACKGROUND_LOCATION"), "SCREEN_TIME_MAX", Set.of("PACKAGE_USAGE_STATS"), "SCREEN_TIME_MIN", Set.of("PACKAGE_USAGE_STATS"), - "WAKE", Set.of("PACKAGE_USAGE_STATS"), + "WAKE", Set.of("android.permission.health.READ_STEPS"), "HEALTH", Set.of("android.permission.health.READ_STEPS", "android.permission.health.READ_DISTANCE"), "SLEEP", Set.of("android.permission.health.READ_SLEEP")); @@ -71,10 +69,10 @@ private List seeded() { @Test @Transactional(readOnly = true) - @DisplayName("문서 §1~§7 의 74건이 전부 자동 인증 가능한 상태로 들어간다") + @DisplayName("문서 §1~§7 에서 장소 피하기를 뺀 64건이 전부 자동 인증 가능한 상태로 들어간다") void seedIsAllAutoVerifiable() { List seeded = seeded(); - assertThat(seeded).hasSize(74); + assertThat(seeded).hasSize(64); assertThat(seeded).allSatisfy(t -> { assertThat(t.supportsAuto()) .withFailMessage("자동 인증 정의가 없는 루틴이 시드에 섞였다: %s", t.getName()) diff --git a/src/test/java/com/ruleup/ruleup_backend/security/AccountStatusFilterDbUnavailableTest.java b/src/test/java/com/ruleup/ruleup_backend/security/AccountStatusFilterDbUnavailableTest.java new file mode 100644 index 00000000..8b1b4efb --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/security/AccountStatusFilterDbUnavailableTest.java @@ -0,0 +1,70 @@ +package com.ruleup.ruleup_backend.security; + +import com.ruleup.ruleup_backend.sanction.SanctionService; +import com.ruleup.ruleup_backend.user.UserActivityService; +import com.ruleup.ruleup_backend.user.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.sql.SQLTransientConnectionException; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** 필터에서 DB 를 못 쓰면 /error 로 넘어가 401 이 되던 것 — 503 + Retry-After 여야 앱이 로그아웃하지 않는다. */ +class AccountStatusFilterDbUnavailableTest { + + private final UserRepository users = mock(UserRepository.class); + private final UserActivityService activity = mock(UserActivityService.class); + private final AccountStatusFilter filter = new AccountStatusFilter(users, mock(SanctionService.class), activity); + private final UUID userId = UUID.randomUUID(); + + @BeforeEach + void login() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userId.toString(), null, AuthorityUtils.NO_AUTHORITIES)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void poolTimeoutIs503NotLoginRequired() throws Exception { + doThrow(new CannotGetJdbcConnectionException("Failed to obtain JDBC Connection", + new SQLTransientConnectionException("ruleup-main - Connection is not available, request timed out after 5000ms"))) + .when(activity).touch(any()); + var res = new MockHttpServletResponse(); + var chain = new MockFilterChain(); + + filter.doFilter(new MockHttpServletRequest("GET", "/api/v1/users/me"), res, chain); + + assertThat(res.getStatus()).isEqualTo(503); + assertThat(res.getHeader("Retry-After")).isEqualTo("2"); + assertThat(res.getContentAsString()).contains("TEMPORARILY_UNAVAILABLE"); + assertThat(chain.getRequest()).isNull(); // 컨트롤러까지 가지 않는다 + } + + @Test + void otherFailuresStillPropagate() { + when(users.findStatusById(any())).thenThrow(new IllegalStateException("boom")); + + assertThatThrownBy(() -> filter.doFilter(new MockHttpServletRequest("GET", "/api/v1/users/me"), + new MockHttpServletResponse(), new MockFilterChain())) + .hasMessageContaining("boom"); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java index 5ef313e2..c5743557 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java @@ -98,7 +98,7 @@ void sleepSessionsAreEvaluated() { List.of()); EvaluationOutcome outcome = new SleepEvaluator( new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, null, false, false)) + null, null, null, null, null, null, null, null, null, false, false, null, null)) .evaluate(new DayContext(LocalDate.of(2026, 9, 17), KST, morning, config, List.of(wire.withReceivedAt(morning)), List.of(), List.of(), "member")); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java index 1ce7d52f..ea148223 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java @@ -23,7 +23,7 @@ * 평가기가 문서에 적힌 판정 기준을 실제로 지키는지 (인증 정책 §1.1 · 테크 스펙 §5-1 판정 유형). * *

      여기 모인 네 가지는 전부 "문서에는 있는데 코드에 없던" 게이트다. 없으면 조용히 틀린 판정이 나간다 — - * 스쳐 지나간 편의점이 위반이 되고, 조작된 위치가 인증을 통과하고, 알림 확인이 기상으로 잡힌다. + * 스쳐 지나간 편의점이 위반이 되고, 조작된 위치가 인증을 통과하고, 누워서 알람만 끈 것이 기상으로 잡힌다. */ class EvaluatorAccuracyTest { @@ -34,13 +34,13 @@ class EvaluatorAccuracyTest { /** 게이트 강화를 끈 기본 설정. 나머지 값은 record 가 기본값으로 채운다. */ private static com.ruleup.ruleup_backend.verification.config.VerificationProperties lenientProperties() { return new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, null, false, false); + null, null, null, null, null, null, null, null, null, false, false, null, null); } /** 출처 없는 기록을 거부하는 설정. */ private static com.ruleup.ruleup_backend.verification.config.VerificationProperties strictOriginProperties() { return new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, null, false, true); + null, null, null, null, null, null, null, null, null, false, true, null, null); } private static Instant at(int hour, int minute) { @@ -54,13 +54,11 @@ private DayContext ctx(VerificationConfig config, List signals, // ===== 설정 조립 ===== - private VerificationConfig gpsConfig(GpsPresence presence, int dwellMinutes, int graceMinutes, - int accuracyMaxM) { + private VerificationConfig gpsConfig(int dwellMinutes, int graceMinutes, int accuracyMaxM) { GpsConfig gps = new GpsConfig( - GpsMode.PRESENCE, presence, AnchorFillMode.MANUAL, null, 1500, + GpsMode.PRESENCE, AnchorFillMode.MANUAL, null, 1500, null, null, 200, dwellMinutes, graceMinutes, null, null, - presence == GpsPresence.AVOID ? Polarity.CONSTRAINT : Polarity.ACHIEVEMENT, - 1, accuracyMaxM, 50, List.of()); + Polarity.ACHIEVEMENT, 1, accuracyMaxM, 50, List.of()); return new VerificationConfig(ScheduleType.FIXED_DAYS, null, MethodCombine.AND, List.of(VerificationMethod.GPS_PRESENCE), gps, null, null, null, null, List.of()); } @@ -68,7 +66,8 @@ private VerificationConfig gpsConfig(GpsPresence presence, int dwellMinutes, int private VerificationConfig wakeConfig(String beforeTime) { return new VerificationConfig(ScheduleType.FIXED_DAYS, null, MethodCombine.AND, List.of(VerificationMethod.WAKE), null, null, null, - new WakeConfig(beforeTime, Polarity.ACHIEVEMENT, 1), null, List.of()); + new WakeConfig(beforeTime, 30, 10, List.of("com.sec.android.app.shealth"), + Polarity.ACHIEVEMENT, 1), null, List.of()); } private VerificationConfig sleepConfig(BigDecimal minHours) { @@ -101,85 +100,43 @@ private static SyncSignal screen(String event, Instant when) { List.of(new ScreenEvent(event, when.toString())), null, null); } - private static SyncSignal sleep(Instant start, Instant end, HealthOrigin origin) { - return new SyncSignal("SLEEP", null, end.toString(), null, null, null, null, - null, null, null, null, null, null, - List.of(new SleepSegment(start.toString(), end.toString(), "ASLEEP", origin)), null); - } - - @Nested - @DisplayName("장소 피하기 — 스침 유예") - class AvoidGrace { - - private final GpsPresenceEvaluator evaluator = new GpsPresenceEvaluator(); - - @Test - @DisplayName("허용 시간 안에 나오면 스침으로 보고 위반이 아니다") - void briefPassThroughIsNotAViolation() { - // 5분 유예. 2분 머물다 나왔다 — 편의점 앞을 지나친 정도다. - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("ENTER", at(19, 0), false), geofence("EXIT", at(19, 2), false)), - List.of(), at(20, 0))); - - assertThat(outcome.failureReason()).as("스침은 위반이 아니다").isNull(); - assertThat(outcome.status()).isEqualTo(VerificationStatus.PENDING); - } - - @Test - @DisplayName("허용 시간을 넘겨 머물면 위반이다") - void stayingBeyondGraceIsAViolation() { - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("ENTER", at(19, 0), false), geofence("EXIT", at(19, 30), false)), - List.of(), at(20, 0))); - - assertThat(outcome.failureReason()).isEqualTo("ENTERED_AVOID_ZONE"); - } - - @Test - @DisplayName("아직 안 나왔고 허용 시간도 안 지났으면 판단을 미룬다 — 이탈 신호가 늦게 올 수 있다") - void openStayWithinGraceWaits() { - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("ENTER", at(19, 0), false)), - List.of(), at(19, 3))); - - assertThat(outcome.failureReason()).isNull(); - } + private static final HealthOrigin WATCH = new HealthOrigin("com.sec.android.app.shealth", "AUTO", "WATCH"); - @Test - @DisplayName("안 나온 채 허용 시간이 지나면 위반이다") - void openStayBeyondGraceIsAViolation() { - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("ENTER", at(19, 0), false)), - List.of(), at(19, 30))); + private static SyncSignal steps(double count, Instant start, Instant end, HealthOrigin origin) { + HealthReading reading = new HealthReading(null, "STEPS", BigDecimal.valueOf(count), "count", + start.toString(), end.toString(), null, origin); + return new SyncSignal("HEALTH", null, end.toString(), null, null, null, List.of(reading), + null, null, null, TARGET.toString(), null, null, null, null); + } - assertThat(outcome.failureReason()).isEqualTo("ENTERED_AVOID_ZONE"); - } + private static SyncSignal stepsRecord(String recordId, double count, Instant start, Instant end, Instant receivedAt) { + HealthReading reading = new HealthReading(recordId, "STEPS", BigDecimal.valueOf(count), "count", + start.toString(), end.toString(), null, WATCH); + return new SyncSignal("HEALTH", null, end.toString(), null, null, null, List.of(reading), + null, null, null, TARGET.toString(), null, null, null, receivedAt); + } - @Test - @DisplayName("OS 가 체류를 확정한 DWELL 은 유예 없이 위반이다") - void dwellIsImmediateViolation() { - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("DWELL", at(19, 0), false)), - List.of(), at(19, 1))); + private static UsageEvent use(String pkg, String type, Instant when) { + return new UsageEvent(pkg, type, when.toString()); + } - assertThat(outcome.failureReason()).isEqualTo("ENTERED_AVOID_ZONE"); - } + private static SyncSignal usage(List events, Instant receivedAt) { + return new SyncSignal("SCREEN_TIME", null, receivedAt.toString(), null, null, null, null, + null, null, null, TARGET.toString(), events, null, null, receivedAt); + } - @Test - @DisplayName("조작된 위치 신호는 위반 근거가 되지 않는다") - void mockLocationIsNotEvidence() { - EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.AVOID, 0, 5, 100), - List.of(geofence("DWELL", at(19, 0), true)), - List.of(), at(20, 0))); + private VerificationConfig screenTimeConfig(ScreenTimeMode mode, int goalMinutes) { + return new VerificationConfig(ScheduleType.FIXED_DAYS, null, MethodCombine.AND, + List.of(VerificationMethod.SCREEN_TIME), null, null, + new ScreenTimeConfig(mode, mode == ScreenTimeMode.MAX ? Polarity.CONSTRAINT : Polarity.ACHIEVEMENT, + List.of("com.instagram.android"), goalMinutes, null, 1), + null, null, List.of()); + } - assertThat(outcome.failureReason()).isNull(); - } + private static SyncSignal sleep(Instant start, Instant end, HealthOrigin origin) { + return new SyncSignal("SLEEP", null, end.toString(), null, null, null, null, + null, null, null, null, null, null, + List.of(new SleepSegment(start.toString(), end.toString(), "ASLEEP", origin)), null); } @Nested @@ -197,7 +154,7 @@ private List fortyMinutes() { @DisplayName("정확도가 좋은 측위는 체류로 인정된다") void accuratePointsCount() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 30, 30, 100), + gpsConfig(30, 30, 100), List.of(location(37.4979, 127.0276, 10.0, false, fortyMinutes())), anchors, at(10, 0))); @@ -209,7 +166,7 @@ void accuratePointsCount() { void inaccuratePointsAreExcluded() { // accuracy 500m > 허용 100m — 이 좌표가 정말 반경 안인지 알 수 없다. EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 30, 30, 100), + gpsConfig(30, 30, 100), List.of(location(37.4979, 127.0276, 500.0, false, fortyMinutes())), anchors, at(10, 0))); @@ -220,7 +177,7 @@ void inaccuratePointsAreExcluded() { @DisplayName("앱 수집 주기(약 30분)로 한 번씩 찍힌 측위도 체류로 이어진다 — QA SIG-24") void thirtyMinuteSamplingCounts() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 60, 60, 150), + gpsConfig(60, 60, 150), List.of(location(37.4979, 127.0276, 120.0, false, List.of(at(9, 0))), location(37.4979, 127.0276, 90.0, false, List.of(at(9, 32))), location(37.4979, 127.0276, 110.0, false, List.of(at(10, 1)))), @@ -233,7 +190,7 @@ void thirtyMinuteSamplingCounts() { @DisplayName("사이에 반경 밖 측위가 끼면 그 구간은 체류가 아니다") void leavingBreaksContinuity() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 30, 30, 150), + gpsConfig(30, 30, 150), List.of(location(37.4979, 127.0276, 20.0, false, List.of(at(9, 0))), location(37.5200, 127.0600, 20.0, false, List.of(at(9, 15))), location(37.4979, 127.0276, 20.0, false, List.of(at(9, 40)))), @@ -247,7 +204,7 @@ void leavingBreaksContinuity() { @DisplayName("45분을 넘는 공백은 잇지 않는다") void longGapIsNotBridged() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 30, 30, 150), + gpsConfig(30, 30, 150), List.of(location(37.4979, 127.0276, 20.0, false, List.of(at(9, 0), at(9, 50)))), anchors, at(10, 0))); @@ -258,7 +215,7 @@ void longGapIsNotBridged() { @DisplayName("조작된 측위는 체류 계산에서 빠진다") void mockPointsAreExcluded() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - gpsConfig(GpsPresence.VISIT, 30, 30, 100), + gpsConfig(30, 30, 100), List.of(location(37.4979, 127.0276, 10.0, true, fortyMinutes())), anchors, at(10, 0))); @@ -267,27 +224,126 @@ void mockPointsAreExcluded() { } @Nested - @DisplayName("기상 — 잠금 해제만 인정") - class WakeUnlockOnly { + @DisplayName("기상 — 목표 시각 + 30분 안의 걸음") + class WakeBySteps { private final WakeEvaluator evaluator = new WakeEvaluator(); @Test - @DisplayName("잠금 해제는 기상으로 인정된다") - void unlockCounts() { + @DisplayName("목표 시각 뒤 유예 안에 걸으면 기상으로 인정된다") + void stepsInsideWindowCount() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - wakeConfig("07:00"), List.of(screen("UNLOCK", at(6, 30))), List.of(), at(8, 0))); + wakeConfig("07:00"), List.of(steps(40, at(7, 10), at(7, 15), WATCH)), List.of(), at(7, 20))); assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); } @Test - @DisplayName("화면만 켜진 것은 기상이 아니다 — 알림 확인으로도 켜진다") - void screenOnAloneIsNotWakingUp() { + @DisplayName("잠금 해제만으로는 기상이 아니다 — 누워서 알람만 꺼도 잡힌다") + void unlockAloneIsNotWakingUp() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - wakeConfig("07:00"), List.of(screen("SCREEN_ON", at(6, 30))), List.of(), at(8, 0))); + wakeConfig("07:00"), List.of(screen("UNLOCK", at(6, 30))), List.of(), at(8, 0))); assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.failureReason()).isEqualTo("WOKE_UP_LATE"); + } + + @Test + @DisplayName("유예가 지난 뒤의 걸음은 늦잠이다") + void stepsAfterDeadlineAreLate() { + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(500, at(8, 0), at(8, 10), WATCH)), List.of(), at(9, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.failureReason()).isEqualTo("WOKE_UP_LATE"); + assertThat(outcome.evidence()).containsEntry("value", 0L).containsEntry("deadline", "07:30"); + } + + @Test + @DisplayName("마감에 걸친 기록은 창 안에 든 비율만큼만 센다") + void straddlingRecordIsProrated() { + // 07:20~07:40 의 16걸음 중 마감(07:30) 전은 절반, 8걸음 — 기준 10걸음에 못 미친다. + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(16, at(7, 20), at(7, 40), WATCH)), List.of(), at(9, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("value", 8L); + } + + @Test + @DisplayName("손으로 입력한 걸음은 기상 근거가 아니다") + void manualStepsAreExcluded() { + HealthOrigin handWritten = new HealthOrigin("com.sec.android.app.shealth", "MANUAL", "PHONE"); + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(100, at(7, 0), at(7, 5), handWritten)), List.of(), at(8, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + } + + @Test + @DisplayName("같은 기록이 갱신돼 다시 오면 마지막 값으로 센다 — 처음 받은 3걸음에 묶이지 않는다") + void updatedRecordUsesLatestValue() { + EvaluationOutcome outcome = evaluator.evaluate(ctx(wakeConfig("07:00"), List.of( + stepsRecord("hc-1", 3, at(7, 0), at(7, 2), at(7, 3)), + stepsRecord("hc-1", 24, at(7, 0), at(7, 8), at(7, 9))), List.of(), at(7, 10))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("value", 24L); + } + + @Test + @DisplayName("창이 열려 있고 아직 걸음이 없으면 기다린다") + void waitsWhileWindowOpen() { + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(), List.of(), at(7, 10))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.PENDING); + assertThat(outcome.failureReason()).isNull(); + } + } + + @Nested + @DisplayName("스크린타임 — 앱 형식 이벤트 짝짓기") + class ScreenTimePairing { + + private final ScreenTimeEvaluator evaluator = new ScreenTimeEvaluator(); + private static final String IG = "com.instagram.android"; + + @Test + @DisplayName("같은 앱 안에서 화면을 옮겨도 사용 시간이 끊기지 않는다 — 앞 화면 STOPPED 가 새 화면을 닫지 않는다") + void activitySwitchKeepsSession() { + Instant t = at(21, 0); + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MIN, 15), List.of(usage(List.of( + use(IG, "RESUMED", t), + use(IG, "PAUSED", t.plusSeconds(60)), + use(IG, "RESUMED", t.plusSeconds(60)), + use(IG, "STOPPED", t.plusSeconds(61)), + use(IG, "PAUSED", t.plusSeconds(60 + 20 * 60))), at(21, 30))), List.of(), at(21, 30))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 21L); + } + + @Test + @DisplayName("닫힘 없이 끊긴 세션은 자정이 아니라 마지막으로 받은 시각에서 닫는다") + void openSessionClosesAtLastReceipt() { + // 20:00 에 열고 20:10 에 받은 뒤 기록이 끊겼다(배터리 꺼짐) — 자정까지 4시간을 쓴 것으로 보면 30분 한도 실패가 된다. + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MAX, 30), List.of(usage(List.of( + use(IG, "RESUMED", at(20, 0))), at(20, 10))), List.of(), TARGET.plusDays(1).atStartOfDay(KST).toInstant().plusSeconds(60))); + + assertThat(outcome.failureReason()).isNull(); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 10L); + } + + @Test + @DisplayName("PAUSED 없이 늦게 온 STOPPED(강제 종료)는 그대로 세션을 닫는다") + void lateStopStillCloses() { + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MAX, 30), List.of(usage(List.of( + use(IG, "RESUMED", at(20, 0)), + use(IG, "STOPPED", at(20, 40))), at(20, 45))), List.of(), at(20, 50))); + + assertThat(outcome.failureReason()).isEqualTo("USAGE_EXCEEDED"); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 40L); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java index 18606947..5ccc1cd4 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java @@ -29,8 +29,8 @@ class GpsRetentionCoherenceTest { /** 지정하지 않은 값은 record 가 기본값으로 채운다. */ private VerificationProperties properties(Integer gpsRetentionDays, Integer signalRetentionDays) { - return new VerificationProperties(null, null, null, null, null, - signalRetentionDays, null, gpsRetentionDays, null, null, false, false); + return new VerificationProperties(null, null, null, null, + signalRetentionDays, null, gpsRetentionDays, null, null, false, false, null, null); } @Test diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java index 9230d424..0887951f 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java @@ -34,7 +34,7 @@ class SleepUnionTest { private final SleepEvaluator evaluator = new SleepEvaluator( new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, null, false, false)); + null, null, null, null, null, null, null, null, null, false, false, null, null)); private static Instant at(int day, int hour, int minute) { return LocalDate.of(2026, 9, day).atTime(hour, minute).atZone(KST).toInstant(); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java index 3b049db1..a7a507f3 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java @@ -136,23 +136,6 @@ void measurementResolvesPermissionWait() throws Exception { "WHERE challenge_id=? AND user_id=?", Boolean.class, bytes(ch), bytes(me.id()))).isTrue(); } - @Test - @DisplayName("장소 피하기 — 앱 형식 ENTER 가 위반으로 잡힌다(거짓 통과 없음)") - void avoidWithAndroidGeofence() throws Exception { - Member me = member(uniq("wire-avoid")); - UUID ch = insertAutoChallenge(me.id(), "GPS_AVOID", "GEOFENCE", "{\"duration_min\":0,\"radius_m\":100}"); - UUID memberId = insertReadyMember(ch, me.id(), anchor(CAFE_LAT, CAFE_LNG, 100, "편의점"), null); - - // 이탈 없는 진입은 「스침」 허용 시간이 지나야 위반이다. 그래서 진입은 허용 시간보다 앞이면서 - // 오늘(KST) 안이어야 한다 — 예전의 todayAt(9, 0) 은 00~09시 KST 에 돌면 미래 시각이라 보류로 남았다. - Instant enteredAt = Instant.now().minus(Duration.ofMinutes(properties.avoidGraceMinutes() + 2L)); - assumeTrue(enteredAt.isAfter(LocalDate.now(KST).atStartOfDay(KST).toInstant()), - "자정 직후 몇 분은 오늘 안에 허용 시간을 넘긴 진입을 만들 수 없다"); - sync(me.token(), List.of(androidGeofence(anchorId(me.id(), ch), "ENTER", enteredAt))); - - assertThat(failureReasonOf(memberId)).isEqualTo("ENTERED_AVOID_ZONE"); - } - @Test @DisplayName("다른 계정이 등록한 지오펜스 id 는 같은 방이어도 내 전환으로 세지 않는다") void anchorOfAnotherUserIsIgnored() throws Exception { @@ -211,17 +194,24 @@ void healthWithAndroidShape() throws Exception { } @Test - @DisplayName("기상 — 앱 형식 firstUnlock(epoch millis)으로 인증된다") + @DisplayName("기상 — 앱 형식(epoch millis) 걸음 기록으로 인증된다") void wakeWithAndroidShape() throws Exception { Member me = member(uniq("wire-wake")); - UUID ch = insertAutoChallenge(me.id(), "WAKE", "USAGE", "{\"target_time\":\"07:00\"}"); + UUID ch = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); UUID memberId = insertReadyMember(ch, me.id(), null, null); + Map reading = new LinkedHashMap<>(); + reading.put("recordId", "hc-wake-1"); + reading.put("value", 24.0); + reading.put("startTime", todayAt(7, 3).toEpochMilli()); + reading.put("endTime", todayAt(7, 6).toEpochMilli()); + reading.put("recordingMethod", "AUTO"); + reading.put("originPackage", "com.sec.android.app.shealth"); Map signal = new LinkedHashMap<>(); - signal.put("type", "WAKE"); - signal.put("firstUnlock", todayAt(6, 30).toEpochMilli()); - signal.put("firstScreenOn", todayAt(6, 28).toEpochMilli()); - signal.put("deviceSecure", true); + signal.put("type", "HEALTH"); + signal.put("date", java.time.LocalDate.now(KST).toString()); + signal.put("metric", "STEPS"); + signal.put("readings", List.of(reading)); sync(me.token(), List.of(signal)); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationApiSupport.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationApiSupport.java index 84a8715f..99826ea8 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationApiSupport.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationApiSupport.java @@ -58,7 +58,7 @@ protected void grantSignalConsents(UUID userId) { // ===== 챌린지 픽스처 ===== /** - * 자동 인증 챌린지 1건. verificationMethod 로 평가기가 갈린다(GPS_PRESENCE / GPS_AVOID / SCREEN_TIME_MIN ...). + * 자동 인증 챌린지 1건. verificationMethod 로 평가기가 갈린다(GPS_PRESENCE / SCREEN_TIME_MAX / SCREEN_TIME_MIN ...). * * @param paramsJson 챌린지 params — 평가 기준값(dwell 분·목표 시간 등) * @return challengeId diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationCrossChallengeIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationCrossChallengeIT.java index 8442e31a..c1de609d 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationCrossChallengeIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationCrossChallengeIT.java @@ -140,31 +140,6 @@ void threeChallengesOnlyOneVerified() throws Exception { assertThat(todayStatusOf(m1)).isIn(null, "PENDING"); assertThat(todayStatusOf(m3)).isIn(null, "PENDING"); } - - @Test - @DisplayName("장소 방문 신호가 '장소 피하기' 챌린지를 위반으로 만들지 않는다") - void visitDoesNotViolateAnotherChallengesAvoidZone() throws Exception { - Member me = member(uniq("cross-avoid")); - - UUID gymChallenge = insertAutoChallenge(me.id(), "GPS_PRESENCE", "GEOFENCE", visitParams()); - UUID gymMember = insertReadyMember(gymChallenge, me.id(), - anchor(GYM_LAT, GYM_LNG, 100, "헬스장"), null); - - UUID avoidChallenge = insertAutoChallenge(me.id(), "GPS_AVOID", "GEOFENCE", - "{\"duration_min\":0,\"radius_m\":100}"); - UUID avoidMember = insertReadyMember(avoidChallenge, me.id(), - anchor(LIBRARY_LAT, LIBRARY_LNG, 100, "편의점"), null); - - // 헬스장(다른 챌린지의 장소)에 들어갔다. 편의점 근처에는 가지 않았다. - sync(me.token(), List.of( - geofenceSignal(gymMember, "ENTER", todayAt(9, 0)), - geofenceSignal(gymMember, "EXIT", todayAt(10, 0)))); - - assertThat(todayStatusOf(gymMember)).isEqualTo("SUCCESS"); - assertThat(todayStatusOf(avoidMember)) - .as("남의 장소 진입이 내 회피 챌린지의 위반이 되면 안 된다") - .isIn(null, "PENDING"); - } } @Nested diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java index e35c63d6..f7c249ea 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java @@ -112,17 +112,27 @@ private static Map healthSignal(String recordId, double value, class InputTypes { @Test - @DisplayName("[P1] WAKE 로 올라온 잠금해제가 기상 판정에 쓰인다") - void wakeSignalDrivesWakeVerification() throws Exception { + @DisplayName("[P1] 기상은 목표 시각 + 유예 안의 걸음으로 판정된다") + void stepsDriveWakeVerification() throws Exception { Member me = member(uniq("contract-wake")); - UUID challenge = insertAutoChallenge(me.id(), "WAKE", "USAGE", "{\"target_time\":\"07:00\"}"); + UUID challenge = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); + UUID memberId = insertReadyMember(challenge, me.id(), null, null); + + syncOk(me.token(), List.of(healthSignal("wake-steps-1", 30, todayAt(7, 5), todayAt(7, 10)))); + + assertThat(statusToday(memberId)).isEqualTo("SUCCESS"); + } + + @Test + @DisplayName("[P1] 잠금 해제만으로는 기상으로 인정하지 않는다") + void unlockAloneDoesNotVerifyWake() throws Exception { + Member me = member(uniq("contract-wake-unlock")); + UUID challenge = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); UUID memberId = insertReadyMember(challenge, me.id(), null, null); syncOk(me.token(), List.of(wakeSignal("UNLOCK", todayAt(6, 30)))); - assertThat(statusToday(memberId)) - .as("SCREEN_TIME 만 읽으면 기상만 보내는 기기는 영영 인증되지 않는다") - .isEqualTo("SUCCESS"); + assertThat(statusToday(memberId)).isNotEqualTo("SUCCESS"); } @Test diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java new file mode 100644 index 00000000..c1f7c2f9 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java @@ -0,0 +1,88 @@ +package com.ruleup.ruleup_backend.verification; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.verification.service.VerificationSignalIngestService; +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.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 같은 사용자의 sync 가 겹쳐 데드락으로 진 트랜잭션은 처음부터 다시 돈다 — 부하테스트(2026-10) 피크 5xx 11건의 원인. + * 빈도 제한은 다시 태우지 않으므로 재시도가 자기 첫 시도에 막혀 429 가 되지 않는다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class VerificationSyncDeadlockRetryIT extends VerificationApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + @MockitoSpyBean VerificationSignalIngestService ingest; + private MockMvc mvc; + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + @Override protected MockMvc mvc() { return mvc; } + @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @Test + @DisplayName("신호 적재가 데드락으로 한 번 지면 다시 돌아 200 으로 끝난다") + void retriesDeadlockedTransaction() throws Exception { + Member me = member(uniq("deadlock")); + doThrow(new CannotAcquireLockException("Deadlock found when trying to get lock; try restarting transaction")) + .doCallRealMethod() + .when(ingest).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + + MvcResult res = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-1")))); + + assertThat(res.getResponse().getStatus()).as(res.getResponse().getContentAsString()).isEqualTo(200); + verify(ingest, times(2)).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + } + + @Test + @DisplayName("계속 지면 정해진 횟수까지만 돈다") + void givesUpAfterLimit() throws Exception { + Member me = member(uniq("deadlock-limit")); + doThrow(new CannotAcquireLockException("Deadlock found when trying to get lock")) + .when(ingest).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + + MvcResult res = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-2")))); + + assertThat(res.getResponse().getStatus()).isGreaterThanOrEqualTo(500); + verify(ingest, times(3)).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + } + + private static Map steps(String recordId) { + Instant to = Instant.now(), from = to.minusSeconds(600); + Map reading = new LinkedHashMap<>(); + reading.put("recordId", recordId); + reading.put("metric", "STEPS"); + reading.put("value", 100); + reading.put("unit", "count"); + reading.put("startTime", from.toString()); + reading.put("endTime", to.toString()); + reading.put("origin", Map.of("dataOrigin", "com.sec.android.app.shealth", "recordingMethod", "AUTO", "device", "WATCH")); + return Map.of("type", "HEALTH", "recordId", recordId, "observedAt", to.toString(), "readings", List.of(reading)); + } +}