Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1197fa3
fix: 요청 전체에 EntityManager 를 걸지 않는다(open-in-view 끔)
selee1012 Oct 16, 2026
312a2b7
fix: ALB 헬스체크가 DB 를 보지 않게 한다
selee1012 Oct 16, 2026
20af89b
perf: 사용자를 필요한 만큼만 읽는다 — 멤버 목록 한 문장, 로그인은 id 만
selee1012 Oct 16, 2026
82e8e1b
perf: 닉네임 점유 확인이 users 전체를 훑지 않는다
selee1012 Oct 16, 2026
f2408a9
perf: 멤버가 나갈 때 외부 랭킹을 방 수와 상관없이 몇 문장으로 다시 매긴다
selee1012 Oct 16, 2026
dd7fc1e
perf: 같은 방의 통계 재계산은 하나만 돌리고 몰린 요청은 한 번으로 합친다
selee1012 Oct 16, 2026
401b1ef
perf: 탐색·인기의 방별 표시값을 Redis 왕복 한 번에 읽는다
selee1012 Oct 16, 2026
1e660f7
perf: sync 가 같은 날의 신호를 두 번 읽지 않는다
selee1012 Oct 16, 2026
fa6b6ee
fix: 요청 스레드의 SQS 보내기·S3 호출에 시간 제한을 둔다
selee1012 Oct 16, 2026
82d6923
perf: 배치가 멤버·가입 사건 표 전체를 훑거나 잠근 채 외부 호출하지 않는다
selee1012 Oct 16, 2026
8aa31b8
test: k6 에 핵심 화면 SLO·업무 성공 건수·단계/급증 시나리오를 더한다
selee1012 Oct 16, 2026
5e501ec
perf: X-Ray API 트레이스를 초당 1건 + 5% 로 줄인다
selee1012 Oct 16, 2026
6c9eac5
perf: 인기 마감 판정은 마감 근처 방만 세고, 1분 생명주기 배치가 방 전체를 잠그지 않는다
selee1012 Oct 16, 2026
05d6465
perf: 인기 점수 배치는 값이 바뀐 방만 쓴다
selee1012 Oct 16, 2026
15a18a5
perf: 가입·로그인에서 새 동의 상태·소셜 토큰을 merge 없이 바로 저장한다
selee1012 Oct 16, 2026
97152c8
test: k6 가 허용한 거절(sync 429 등)도 업무 거절로 센다
selee1012 Oct 16, 2026
0b81b9a
perf: 인기 목록은 저장값으로 이미 찬 방을 다시 세지 않는다
selee1012 Oct 16, 2026
674a2d0
perf: DB 에 닿지 못하면 5초 안에 503 + Retry-After 로 돌려보낸다
selee1012 Oct 16, 2026
e8f431d
fix: Redis 를 잃은 뒤 탐색 재구성이 바로 끝나고, 죽은 태스크의 잠금에 30분 묶이지 않는다
selee1012 Oct 16, 2026
92099f0
fix: 탐색 전수 재구성이 정렬 멤버를 1,000개씩 나눠 읽는다
selee1012 Oct 16, 2026
76878f6
perf: 읽기 전용 트랜잭션마다 접근 모드 SET 두 번을 DB 로 보내지 않는다
selee1012 Oct 16, 2026
684a93f
perf: 방마다 멤버 마지막 변경 시각을 인덱스 끝 한 칸으로 구한다(V16)
selee1012 Oct 16, 2026
04543fa
feat: 태스크당 동시 API 요청 상한을 두고, 넘치면 1초 뒤 503 + Retry-After 로 돌려보낸다(기본 끔)
selee1012 Oct 16, 2026
ecdaca9
chore: prod 사양 조정 스크립트(1 vCPU·헬스체크 관리 포트·메모리 정책 삭제·X-Ray) — main 머지 전…
selee1012 Oct 16, 2026
4b98363
test: joinhot 이 가입된 사용자로 로그인해 두고 같은 순간에 일제히 참여한다
selee1012 Oct 16, 2026
28c9305
fix: 계정 상태 필터에서 DB 를 못 쓰면 401 대신 503 + Retry-After 로 돌려보낸다
selee1012 Oct 16, 2026
d6075fe
test: k6 sync 가 VU 마다 여러 사용자를 돌아가며 써 한 사람에게 신호가 몰리지 않게 한다
selee1012 Oct 16, 2026
294dcd4
chore: prod 사양 스크립트가 CPU 는 --cpu 를 줄 때만 바꾼다
selee1012 Oct 16, 2026
083ca0b
perf: 다른 귀속일 중복 확인이 (userId, dedupKey) 로 키마다 한 번 찾는다(V17)
selee1012 Oct 16, 2026
a5de144
perf: sync 가 사용자 방을 한 번에 읽고, 신호는 판정할 때만 읽는다
selee1012 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions infra/capacity/apply_prod_dau2.py
Original file line number Diff line number Diff line change
@@ -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 가 새 이미지로 같은 태스크 정의를 다시 띄운다)')
16 changes: 16 additions & 0 deletions infra/loadtest/k6/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)' };

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 는 임계값이 걸린 태그만 따로 집계한다).
Expand All @@ -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 || {});
}
60 changes: 54 additions & 6 deletions infra/loadtest/k6/ruleup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)); }
Expand Down Expand Up @@ -67,18 +68,41 @@ 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' });
return { browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, stages },
login: { executor: 'constant-arrival-rate', exec: 'login', rate: 5, timeUnit: '1s', duration: '32m', preAllocatedVUs: 50, maxVUs: 300 } };
}

const chosen = SC === 'peak' ? peakMix(Number(__ENV.SCALE || 1)) : SC === 'breakpoint' ? breakpoint() : { [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'],
};

Expand All @@ -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))
Expand Down Expand Up @@ -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 시점에 <b>일제히</b> 들어간다 — 가입부터 하면 가입이 몰려
// 정원 경합 전에 풀 대기로 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) });
}
Expand All @@ -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 }] },
Expand Down
8 changes: 4 additions & 4 deletions infra/monitoring/tracing/enable_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<env>-api API 트레이스만 보는 필터(콘솔 트레이스 목록·서비스 맵에서 그룹을 고른다).
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading