From fe685ace6fb0ec15fee4697574725df4d6755d21 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:30:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(test):=20=EB=B9=88=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=EB=B6=80=ED=95=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?JWT=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EC=84=B1=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VacancyLoadTestTokenGenerator.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java diff --git a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java new file mode 100644 index 0000000..5d3434c --- /dev/null +++ b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java @@ -0,0 +1,82 @@ +package com.catchtable.loadtest; + +import com.catchtable.global.security.JwtTokenProvider; +import com.catchtable.user.entity.User; +import com.catchtable.user.entity.UserRole; +import com.catchtable.user.entity.UserStatus; +import com.catchtable.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 빈자리 부하 테스트용 JWT 토큰 100개 생성 일회성 태스크. + * + * 실행: + * ./gradlew test --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" -DrunLoadTokenGen=true + * + * 결과: + * build/load-test-tokens.txt (한 줄당 토큰 1개, 총 100줄) + * build/load-test-tokens.csv (콤마 구분 한 줄 - k6 -e TOKENS= 에 바로 주입 가능) + */ +@SpringBootTest +class VacancyLoadTestTokenGenerator { + + private static final int DEFAULT_USER_COUNT = 100; + private static final String EMAIL_PREFIX = "loadtest+"; + private static final String EMAIL_DOMAIN = "@catchtable.test"; + private static final String GOOGLE_ID_PREFIX = "loadtest-google-"; + private static final String NICKNAME_PREFIX = "loadtest_user_"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Test + @Transactional + void generateTokens() throws IOException { + // -DrunLoadTokenGen=true 일 때만 실행. CI/일반 test 실행 시 스킵. + if (!"true".equals(System.getProperty("runLoadTokenGen"))) { + System.out.println("[SKIP] -DrunLoadTokenGen=true 옵션 없음. 건너뜀."); + return; + } + + int userCount = Integer.getInteger("tokenCount", DEFAULT_USER_COUNT); + List tokens = new ArrayList<>(userCount); + + for (int i = 1; i <= userCount; i++) { + String googleId = GOOGLE_ID_PREFIX + i; + User user = userRepository.findByGoogleId(googleId) + .orElseGet(() -> userRepository.save(User.builder() + .googleId(googleId) + .email(EMAIL_PREFIX + i + EMAIL_DOMAIN) + .nickname(NICKNAME_PREFIX + i) + .profileImage(null) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build())); + + String token = jwtTokenProvider.generateAccessToken(user.getId(), user.getRole()); + tokens.add(token); + } + + Path txt = Path.of("build", "load-test-tokens.txt"); + Path csv = Path.of("build", "load-test-tokens.csv"); + Files.createDirectories(txt.getParent()); + Files.write(txt, tokens); + Files.writeString(csv, String.join(",", tokens)); + + System.out.println("[OK] " + userCount + " tokens written:"); + System.out.println(" - " + txt.toAbsolutePath()); + System.out.println(" - " + csv.toAbsolutePath()); + } +} From 2661826deb654b96b0ac06146e40a7f25ae85949 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:30:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EB=B9=88=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20k6=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-Phase 구조로 SADD/SMEMBERS/TTL 각각 측정 - PHASE 1: POST /api/v1/vacancy 100회 호출로 SADD 부하 - PHASE 2: k6/experimental/redis 로 SMEMBERS 응답시간 측정 - PHASE 3: TTL/EXISTS 로 키 자동 삭제 확인 --- k6/11-vacancy-test.js | 263 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 k6/11-vacancy-test.js diff --git a/k6/11-vacancy-test.js b/k6/11-vacancy-test.js new file mode 100644 index 0000000..02b85fc --- /dev/null +++ b/k6/11-vacancy-test.js @@ -0,0 +1,263 @@ +/** + * 빈자리 구독 (Vacancy) 부하테스트 - Redis SADD/SMEMBERS 응답시간 측정 + * 실행: k6 run -e PHASE= ... 11-vacancy-test.js + * + * 시험 절차 (원본 스펙): + * 1) 100명 구독자를 SADD 후 SMEMBERS 평균 응답시간 측정 + * 2) 시간대 + 1시간 후 키가 자동 삭제되는지 확인 + * + * 측정 경로: + * - SADD : POST /api/v1/vacancy 를 통해 백엔드 내부에서 호출 (실제 운영 경로) + * -> backend/src/main/java/com/catchtable/vacancy/service/VacancyService.java:56 + * - SMEMBERS : 백엔드가 HTTP 로 노출하지 않음 (Kafka Consumer 내부 호출). + * k6/experimental/redis 모듈로 Redis 에 직접 붙어서 측정. + * -> backend/src/main/java/com/catchtable/notification/service/NotificationKafkaConsumer.java:155 + * - TTL : k6/experimental/redis 의 TTL/EXISTS 로 직접 확인. + * + * Redis 키 포맷 (백엔드 generateRedisKey 기준): + * wait:store:{storeId}:{remainDate}:{remainTime} + * 예) wait:store:1:2026-05-29:18:00 + * + * ============================================================================= + * 실행 환경 - 1안 vs 2안 + * ============================================================================= + * 본 시나리오는 두 가지 실행 환경을 지원한다. 명령 예시는 각 PHASE 안에서 + * [1안] [2안] 으로 구분해 표기. + * + * [1안] EC2 안에서 k6 실행 (권장) + * - URL = http://localhost:8080 (백엔드와 같은 호스트) + * - REDIS_ADDR = catchtable-redis:6379 (docker network 컨테이너명) + * - 필요 작업 : EC2 접속 (SSH 또는 AWS SSM) + k6 설치 + * - 운영 구성 : 변경 없음 + * - 측정 정확도 : 높음 (RTT 거의 0) + * - PHASE 2/3 는 docker network `catchtable-net` 에 붙어야 하므로 + * `docker run --network catchtable-net grafana/k6 run -` 방식 사용. + * + * [2안] 로컬에서 k6 실행 + * - URL = https://<운영도메인> + * - REDIS_ADDR = :6379 + * - 관리자 작업 : docker-compose.prod.yml 의 redis 섹션 expose -> ports + * 변경 + 컨테이너 재시작 + 보안그룹 6379 허용 + * - 운영 구성 : 변경 (테스트 종료 후 원복 필수) + * - 측정 정확도 : 로컬 ↔ EC2 RTT 노이즈 섞임 + * + * ============================================================================= + * PHASE 1: 100명 구독 (SADD 부하 생성) + * ============================================================================= + * + * [사전 준비] + * 1. 백엔드 코드 임시 수정 (테스트 끝나면 반드시 원복!) + * 파일: backend/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java + * generateTokens() 위의 `@Transactional` 어노테이션을 주석 처리한다. + * ( @Transactional 이 살아있으면 시드 유저가 롤백되어 k6 요청에서 USER_NOT_FOUND 발생 ) + * + * 2. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) + * ./gradlew test \ + * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ + * -DrunLoadTokenGen=true + * + * 3. 테스트 대상 StoreRemain 준비 + * - remainTeam = 0 인 (잔여 0) StoreRemain 의 id 가 필요하다. + * - DB 에서 미리 하나 골라두고, 그 row 의 remainDate / remainTime 으로 Redis 키를 만든다. + * + * [실행] + * [1안] EC2 안에서 (Bash) + * k6 run -e PHASE=subscribe \ + * -e URL=http://localhost:8080 \ + * -e TOKENS=$(cat build/load-test-tokens.csv) \ + * -e REMAIN_ID= -e N=100 \ + * k6/11-vacancy-test.js + * + * [2안] 로컬에서 (PowerShell) + * $tokens = Get-Content backend\build\load-test-tokens.csv -Raw + * k6 run -e PHASE=subscribe ` + * -e URL=https://<운영도메인> ` + * -e TOKENS=$tokens ` + * -e REMAIN_ID= -e N=100 ` + * backend\k6\11-vacancy-test.js + * + * [2안] 로컬에서 (Bash) + * k6 run -e PHASE=subscribe \ + * -e URL=https://<운영도메인> \ + * -e TOKENS=$(cat backend/build/load-test-tokens.csv) \ + * -e REMAIN_ID= -e N=100 \ + * backend/k6/11-vacancy-test.js + * + * ============================================================================= + * PHASE 2: SMEMBERS 평균 응답시간 측정 + * ============================================================================= + * + * [사전 준비] + * 1. PHASE 1 이 끝나서 Redis 키에 100명이 들어있어야 한다. + * 2. 백엔드 로그에서 Redis 키를 확인한다. + * 예) [빈자리 알림 등록] Redis SADD 완료: key=wait:store:1:2026-05-29:18:00, userId=... + * + * [실행] + * [1안] EC2 안에서 (docker 로 k6 컨테이너를 catchtable-net 에 붙임) + * docker run --rm -i --network catchtable-net \ + * -e PHASE=smembers \ + * -e REDIS_ADDR=catchtable-redis:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * -e N=1000 \ + * grafana/k6 run - < k6/11-vacancy-test.js + * + * [2안] 로컬에서 + * k6 run -e PHASE=smembers \ + * -e REDIS_ADDR=:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * -e N=1000 \ + * backend/k6/11-vacancy-test.js + * + * (Redis 에 패스워드가 걸려있다면 -e REDIS_PASSWORD=... 추가. 현재 운영은 미설정) + * N = SMEMBERS 호출 반복 횟수 (1000회 정도면 분포가 안정됨). + * 결과: smembers_duration_ms 의 avg / p95 / p99 가 평균 응답시간. + * + * ============================================================================= + * PHASE 3: TTL 자동 삭제 확인 + * ============================================================================= + * + * [실행 - PHASE 1 직후] + * [1안] EC2 안에서 + * docker run --rm -i --network catchtable-net \ + * -e PHASE=ttl \ + * -e REDIS_ADDR=catchtable-redis:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * grafana/k6 run - < k6/11-vacancy-test.js + * + * [2안] 로컬에서 + * k6 run -e PHASE=ttl \ + * -e REDIS_ADDR=:6379 \ + * -e REDIS_KEY=wait:store:1:2026-05-29:18:00 \ + * backend/k6/11-vacancy-test.js + * + * 확인 사항: + * - exists == 1 + * - ttl 초 단위 값이 (remainDate+remainTime+1h - now) 와 일치 + * + * [실행 - 만료 시각 (remainDate+remainTime+1h) 이후] + * 동일 명령 다시 실행. + * 확인: exists == 0 (자동 삭제 성공) + * + * ============================================================================= + * 테스트 종료 후 정리 (필수) + * ============================================================================= + * - VacancyLoadTestTokenGenerator.java 의 `@Transactional` 주석 원복. + * - 시드 데이터 정리: + * DELETE FROM vacancy WHERE user_id IN (SELECT id FROM users WHERE google_id LIKE 'loadtest-google-%'); + * DELETE FROM users WHERE google_id LIKE 'loadtest-google-%'; + * - Redis 잔존 키 정리: + * redis-cli --scan --pattern 'wait:store:*' | xargs redis-cli DEL + */ + +import http from 'k6/http'; +import redis from 'k6/experimental/redis'; +import { check } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.URL || 'http://localhost:8080'; +const PHASE = __ENV.PHASE || 'subscribe'; +const N = Number(__ENV.N || 100); + +// PHASE=subscribe 용 +const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean); +const REMAIN_ID = Number(__ENV.REMAIN_ID || 0); + +// PHASE=smembers / ttl 용 +const REDIS_ADDR = __ENV.REDIS_ADDR || 'localhost:6379'; +const REDIS_KEY = __ENV.REDIS_KEY || ''; +const REDIS_PASSWORD = __ENV.REDIS_PASSWORD || ''; + +// 메트릭 +const status2xx = new Counter('status_2xx'); +const status4xx = new Counter('status_4xx'); +const status5xx = new Counter('status_5xx'); +const subscribeDuration = new Trend('subscribe_duration_ms', true); +const smembersDuration = new Trend('smembers_duration_ms', true); +const smembersSize = new Trend('smembers_set_size'); + +// Redis 클라이언트 (smembers / ttl phase 에서만 사용) +// URL 형식: redis://[:password@]host:port +const REDIS_URL = (() => { + const auth = REDIS_PASSWORD ? `:${encodeURIComponent(REDIS_PASSWORD)}@` : ''; + return `redis://${auth}${REDIS_ADDR}`; +})(); +const redisClient = new redis.Client(REDIS_URL); + +export const options = (() => { + if (PHASE === 'subscribe') { + return { vus: N, iterations: N }; + } + if (PHASE === 'smembers') { + // 단일 VU 가 N 번 반복 - SMEMBERS 자체 응답시간 분포만 본다. + // 동시성 부하를 보려면 vus 를 늘릴 것. + return { vus: 1, iterations: N }; + } + if (PHASE === 'ttl') { + return { vus: 1, iterations: 1 }; + } + throw new Error(`Unknown PHASE: ${PHASE}`); +})(); + +// ---- PHASE 1: subscribe ---- +function subscribe() { + if (TOKENS.length === 0) throw new Error('TOKENS env required'); + if (!REMAIN_ID) throw new Error('REMAIN_ID env required'); + + const vu = __VU - 1; + const token = TOKENS[vu % TOKENS.length]; + + const res = http.post( + `${BASE_URL}/api/v1/vacancy`, + JSON.stringify({ remainId: REMAIN_ID }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + tags: { endpoint: 'subscribe' }, + } + ); + + subscribeDuration.add(res.timings.duration); + if (res.status >= 200 && res.status < 300) status2xx.add(1); + else if (res.status >= 400 && res.status < 500) status4xx.add(1); + else if (res.status >= 500) status5xx.add(1); + + check(res, { + 'subscribe 2xx': (r) => r.status >= 200 && r.status < 300, + }); +} + +// ---- PHASE 2: smembers ---- +async function smembers() { + if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); + + const start = Date.now(); + const members = await redisClient.smembers(REDIS_KEY); + const duration = Date.now() - start; + + smembersDuration.add(duration); + smembersSize.add(members.length); +} + +// ---- PHASE 3: ttl ---- +async function ttl() { + if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); + + const exists = await redisClient.exists(REDIS_KEY); + const ttlSec = await redisClient.ttl(REDIS_KEY); + + console.log(`[TTL CHECK] key=${REDIS_KEY}`); + console.log(` exists = ${exists} (1 = 키 존재, 0 = 자동 삭제됨)`); + console.log(` ttl = ${ttlSec}s (-1 = TTL 없음, -2 = 키 없음)`); + + check(null, { + 'key state observed': () => true, + }); +} + +export default async function () { + if (PHASE === 'subscribe') subscribe(); + else if (PHASE === 'smembers') await smembers(); + else if (PHASE === 'ttl') await ttl(); +} From 45b1eb4c344f89bc6194f3c8d28a0bebfa60508b Mon Sep 17 00:00:00 2001 From: johe00123 Date: Fri, 29 May 2026 00:41:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20PR=20#103=20Gemini=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Transactional 제거: 자동 롤백으로 시드 유저 미반영 문제 해결 - Redis 클라이언트 조건부 초기화: subscribe phase 단독 실행 가능 - Date.now() -> performance.now(): sub-ms SMEMBERS 측정 정확도 개선 --- k6/11-vacancy-test.js | 21 +++++++++---------- .../VacancyLoadTestTokenGenerator.java | 2 -- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/k6/11-vacancy-test.js b/k6/11-vacancy-test.js index 02b85fc..6306d33 100644 --- a/k6/11-vacancy-test.js +++ b/k6/11-vacancy-test.js @@ -46,17 +46,13 @@ * ============================================================================= * * [사전 준비] - * 1. 백엔드 코드 임시 수정 (테스트 끝나면 반드시 원복!) - * 파일: backend/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java - * generateTokens() 위의 `@Transactional` 어노테이션을 주석 처리한다. - * ( @Transactional 이 살아있으면 시드 유저가 롤백되어 k6 요청에서 USER_NOT_FOUND 발생 ) - * - * 2. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) + * 1. 시드 유저 100명 + 토큰 100개 생성 (backend 디렉토리에서) * ./gradlew test \ * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ * -DrunLoadTokenGen=true + * ( -DrunLoadTokenGen=true 가드가 있어 일반 ./gradlew test 시 자동 스킵됨 ) * - * 3. 테스트 대상 StoreRemain 준비 + * 2. 테스트 대상 StoreRemain 준비 * - remainTeam = 0 인 (잔여 0) StoreRemain 의 id 가 필요하다. * - DB 에서 미리 하나 골라두고, 그 row 의 remainDate / remainTime 으로 Redis 키를 만든다. * @@ -141,7 +137,6 @@ * ============================================================================= * 테스트 종료 후 정리 (필수) * ============================================================================= - * - VacancyLoadTestTokenGenerator.java 의 `@Transactional` 주석 원복. * - 시드 데이터 정리: * DELETE FROM vacancy WHERE user_id IN (SELECT id FROM users WHERE google_id LIKE 'loadtest-google-%'); * DELETE FROM users WHERE google_id LIKE 'loadtest-google-%'; @@ -177,11 +172,13 @@ const smembersSize = new Trend('smembers_set_size'); // Redis 클라이언트 (smembers / ttl phase 에서만 사용) // URL 형식: redis://[:password@]host:port +// subscribe phase 에서는 Redis 접근이 불필요하므로 조건부 초기화 +// (Redis 접근 불가 환경에서도 subscribe phase 단독 실행 가능하게 함) const REDIS_URL = (() => { const auth = REDIS_PASSWORD ? `:${encodeURIComponent(REDIS_PASSWORD)}@` : ''; return `redis://${auth}${REDIS_ADDR}`; })(); -const redisClient = new redis.Client(REDIS_URL); +const redisClient = (PHASE === 'smembers' || PHASE === 'ttl') ? new redis.Client(REDIS_URL) : null; export const options = (() => { if (PHASE === 'subscribe') { @@ -232,9 +229,11 @@ function subscribe() { async function smembers() { if (!REDIS_KEY) throw new Error('REDIS_KEY env required'); - const start = Date.now(); + // performance.now() 는 마이크로초 정밀도 (소수점 ms) 라 sub-ms 측정 가능. + // SMEMBERS 가 1ms 이하인 경우 Date.now() 로는 0 으로 측정되어 분포가 왜곡됨. + const start = performance.now(); const members = await redisClient.smembers(REDIS_KEY); - const duration = Date.now() - start; + const duration = performance.now() - start; smembersDuration.add(duration); smembersSize.add(members.length); diff --git a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java index 5d3434c..1e96a86 100644 --- a/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java +++ b/src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.nio.file.Files; @@ -42,7 +41,6 @@ class VacancyLoadTestTokenGenerator { private JwtTokenProvider jwtTokenProvider; @Test - @Transactional void generateTokens() throws IOException { // -DrunLoadTokenGen=true 일 때만 실행. CI/일반 test 실행 시 스킵. if (!"true".equals(System.getProperty("runLoadTokenGen"))) {