-
Notifications
You must be signed in to change notification settings - Fork 0
Chore/#101 vacancy subscribe load test #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| /** | ||
| * λΉμ리 ꡬλ (Vacancy) λΆνν μ€νΈ - Redis SADD/SMEMBERS μλ΅μκ° μΈ‘μ | ||
| * μ€ν: k6 run -e PHASE=<subscribe|smembers|ttl> ... 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 = <ec2-public-ip>:6379 | ||
| * - κ΄λ¦¬μ μμ : docker-compose.prod.yml μ redis μΉμ expose -> ports | ||
| * λ³κ²½ + 컨ν μ΄λ μ¬μμ + 보μκ·Έλ£Ή 6379 νμ© | ||
| * - μ΄μ κ΅¬μ± : λ³κ²½ (ν μ€νΈ μ’ λ£ ν μ볡 νμ) | ||
| * - μΈ‘μ μ νλ : λ‘컬 β EC2 RTT λ Έμ΄μ¦ μμ | ||
| * | ||
| * ============================================================================= | ||
| * PHASE 1: 100λͺ ꡬλ (SADD λΆν μμ±) | ||
| * ============================================================================= | ||
| * | ||
| * [μ¬μ μ€λΉ] | ||
| * 1. μλ μ μ 100λͺ + ν ν° 100κ° μμ± (backend λλ ν 리μμ) | ||
| * ./gradlew test \ | ||
| * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ | ||
| * -DrunLoadTokenGen=true | ||
| * ( -DrunLoadTokenGen=true κ°λκ° μμ΄ μΌλ° ./gradlew test μ μλ μ€ν΅λ¨ ) | ||
| * | ||
| * 2. ν μ€νΈ λμ 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=<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=<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=<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=<ec2-public-ip>: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=<ec2-public-ip>: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 (μλ μμ μ±κ³΅) | ||
| * | ||
| * ============================================================================= | ||
| * ν μ€νΈ μ’ λ£ ν μ 리 (νμ) | ||
| * ============================================================================= | ||
| * - μλ λ°μ΄ν° μ 리: | ||
| * 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 | ||
| // subscribe phase μμλ Redis μ κ·Όμ΄ λΆνμνλ―λ‘ μ‘°κ±΄λΆ μ΄κΈ°ν | ||
| // (Redis μ κ·Ό λΆκ° νκ²½μμλ subscribe phase λ¨λ μ€ν κ°λ₯νκ² ν¨) | ||
| const REDIS_URL = (() => { | ||
| const auth = REDIS_PASSWORD ? `:${encodeURIComponent(REDIS_PASSWORD)}@` : ''; | ||
| return `redis://${auth}${REDIS_ADDR}`; | ||
| })(); | ||
| const redisClient = (PHASE === 'smembers' || PHASE === 'ttl') ? new redis.Client(REDIS_URL) : null; | ||
|
|
||
| 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'); | ||
|
|
||
| // performance.now() λ λ§μ΄ν¬λ‘μ΄ μ λ°λ (μμμ ms) λΌ sub-ms μΈ‘μ κ°λ₯. | ||
| // SMEMBERS κ° 1ms μ΄νμΈ κ²½μ° Date.now() λ‘λ 0 μΌλ‘ μΈ‘μ λμ΄ λΆν¬κ° μ곑λ¨. | ||
| const start = performance.now(); | ||
| const members = await redisClient.smembers(REDIS_KEY); | ||
| const duration = performance.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(); | ||
| } |
80 changes: 80 additions & 0 deletions
80
src/test/java/com/catchtable/loadtest/VacancyLoadTestTokenGenerator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| 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 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 | ||
| 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<String> 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()); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Transactionalμ΄λ Έν μ΄μ μ΄ ν μ€νΈ λ©μλμ μ μ©λμ΄ μμΌλ©΄, Springμ ν μ€νΈ 컨ν μ€νΈ νλ μμν¬λ ν μ€νΈ μ’ λ£ ν νΈλμμ μ μλμΌλ‘ λ‘€λ°±ν©λλ€. μ΄λ‘ μΈν΄ μμ±λ μλ μ μ κ° DBμ λ°μλμ§ μμ k6 μ€ν μUSER_NOT_FOUNDμλ¬κ° λ°μνκ² λ©λλ€.κ°μ΄λ μ£Όμμ "ν μ€νΈ μ€ν μ μ
@Transactionalμ μ£Όμ μ²λ¦¬νκ³ μ€ν ν μ볡νλΌ"κ³ μλ΄λμ΄ μμΌλ, μ΄λ λ²κ±°λ‘κ³ μ€μνκΈ° μ½μ΅λλ€.@Transactionalμ΄λ Έν μ΄μ μ μμ μ κ±°νλ©΄ λ³λμ μλ μμ μμ΄λ νμ DBμ μ μμ μΌλ‘ 컀λ°λλ―λ‘, μλ κ°μ΄λ λ¨κ³λ₯Ό μμ κ³ μλνν μ μμ΅λλ€.