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/k6/lib.js b/infra/loadtest/k6/lib.js index ddcfba08..97bc8383 100644 --- a/infra/loadtest/k6/lib.js +++ b/infra/loadtest/k6/lib.js @@ -18,6 +18,9 @@ export function hosts() { 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)' }; @@ -34,6 +37,11 @@ export function ok(res, name, allowed) { 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; } @@ -149,6 +157,9 @@ export const GROUP_PUBLIC = { mode: 'GROUP', visibility: 'PUBLIC', capacity: 100 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 는 임계값이 걸린 태그만 따로 집계한다). @@ -160,5 +171,10 @@ 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 index 7e8d9713..e6c6bc91 100644 --- a/infra/loadtest/k6/ruleup.js +++ b/infra/loadtest/k6/ruleup.js @@ -19,6 +19,7 @@ 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)); } @@ -67,6 +68,25 @@ function peakMix(scale) { }; } +// 배수(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' }); @@ -74,11 +94,15 @@ function breakpoint() { 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() : { [SC]: scenarios[SC] }; +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, - thresholds: SC === 'breakpoint' ? { http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '1m' }] } : L.thresholds(), + // 한계점은 오류 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'], }; @@ -88,7 +112,8 @@ export function seed() { const n = exec.scenario.iterationInTest; const s = L.session(n); const tpl = TEMPLATES[n % TEMPLATES.length]; - L.createFromTemplate(s, tpl, L.GROUP_PUBLIC); + // 방은 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)) @@ -177,10 +202,18 @@ export function aidraft() { } let hotId = null; -export function joinhot() { +/** 모든 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 최신에서 같은 방을 찾는다. - const s = L.session(USERS + __VU); // 이 시나리오 전용 사용자(이미 가입한 방이 없어야 한다) + // 이미 가입한 사용자로 로그인해 두고, 시작 뒤 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) }); } @@ -196,8 +229,23 @@ export function join() { } 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 = mine(); + 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 }] }, diff --git a/infra/monitoring/tracing/enable_tracing.py b/infra/monitoring/tracing/enable_tracing.py index 0991b98d..fa77d1c2 100644 --- a/infra/monitoring/tracing/enable_tracing.py +++ b/infra/monitoring/tracing/enable_tracing.py @@ -8,9 +8,9 @@ 1) 태스크 역할에 AWSXRayDaemonWriteAccess 2) X-Ray 샘플링 규칙 — 계정 단위라 stg·prod 공통이다. ruleup-skip-health(100) /actuator/* 는 기록하지 않음(ALB 헬스체크). - ruleup-api(200) */api/* 요청은 초당 10건까지 전부, 그 위로는 10%만 기록. 평소 트래픽은 사실상 전부 남고, - 피크(2026-10 부하테스트 350 RPS)에선 100% 기록이 스팬 큐(2048)를 넘쳐 버려지고 - X-Ray 기록 요금이 하루 ~$150 꼴이 된다. + 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 트레이스만 보는 필터(콘솔 트레이스 목록·서비스 맵에서 그룹을 고른다). @@ -74,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=0.1,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 9d975b71..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 @@ -128,8 +128,9 @@ private OAuthLoginResponse login(OAuthProvider provider, OAuthLoginRequest req) 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); } 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 9fa3e61b..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 -> { 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 564781d3..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 @@ -145,19 +145,22 @@ 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); } @@ -291,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 { @@ -309,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 05149c4e..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 @@ -150,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 를 통째로 갈아 끼운다. * @@ -447,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; @@ -479,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/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/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/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/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/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/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/UserRepository.java b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java index af9cba0c..cbd8a732 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java @@ -29,8 +29,21 @@ public interface UserRepository extends JpaRepository { 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/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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}
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 05149c4e..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 @@ -150,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 를 통째로 갈아 끼운다. * @@ -447,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; @@ -479,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/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/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/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/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/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/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/UserRepository.java b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java index af9cba0c..cbd8a732 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java @@ -29,8 +29,21 @@ public interface UserRepository extends JpaRepository { 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/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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}
같은 방은 한 번에 하나만 돌리고, 도는 동안 들어온 요청은 끝난 뒤 한 번으로 합친다. 재계산은 방 행을 + * 잠그고 원천을 다시 세므로, 끝난 뒤의 한 번이 그사이 커밋을 모두 본다. 예전에는 이벤트마다 공용 비동기 + * 실행기(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/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/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/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/UserRepository.java b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java index af9cba0c..cbd8a732 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java @@ -29,8 +29,21 @@ public interface UserRepository extends JpaRepository { 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/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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}
상한이 없으면 급증 때 받을 수 있는 것보다 많이 받아 모두가 느려진다. 부하테스트(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/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/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/UserRepository.java b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java index af9cba0c..cbd8a732 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java @@ -29,8 +29,21 @@ public interface UserRepository extends JpaRepository { 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/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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}
상태를 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/UserRepository.java b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java index af9cba0c..cbd8a732 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserRepository.java @@ -29,8 +29,21 @@ public interface UserRepository extends JpaRepository { 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/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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}
미리 다 읽지 않는 이유: 어제가 이미 확정(또는 대상 아님)인 멤버뿐이면 그저께 하루치를 읽을 일이 없다. + * 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 6d9b5af2..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 @@ -253,14 +253,16 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List 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 만 받으면 마지막 활동일에 늦게 도착한 신호를 반영할 길이 없다. @@ -293,7 +295,7 @@ private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List daySignals.day(today), gaps, consentGapOf, today, now); progressService.updateAfterSync(member, todayStatus, now); if (becameFinal(before, todayStatus) || graceChanged) { @@ -494,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) { @@ -510,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; @@ -530,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) { @@ -544,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 b0b8b863..6beac478 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,9 @@ 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% 였다. @@ -51,10 +54,17 @@ spring: 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: @@ -96,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: @@ -193,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} # 요청·응답 본문까지 찍을지 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/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/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/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/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/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/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"); + } +}