Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
262 changes: 262 additions & 0 deletions k6/11-vacancy-test.js
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();
}
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 {
Comment on lines +43 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

@Transactional μ–΄λ…Έν…Œμ΄μ…˜μ΄ ν…ŒμŠ€νŠΈ λ©”μ„œλ“œμ— μ μš©λ˜μ–΄ 있으면, Spring의 ν…ŒμŠ€νŠΈ μ»¨ν…μŠ€νŠΈ ν”„λ ˆμž„μ›Œν¬λŠ” ν…ŒμŠ€νŠΈ μ’…λ£Œ ν›„ νŠΈλžœμž­μ…˜μ„ μžλ™μœΌλ‘œ λ‘€λ°±ν•©λ‹ˆλ‹€. 이둜 인해 μƒμ„±λœ μ‹œλ“œ μœ μ €κ°€ DB에 λ°˜μ˜λ˜μ§€ μ•Šμ•„ k6 μ‹€ν–‰ μ‹œ USER_NOT_FOUND μ—λŸ¬κ°€ λ°œμƒν•˜κ²Œ λ©λ‹ˆλ‹€.

κ°€μ΄λ“œ 주석에 "ν…ŒμŠ€νŠΈ μ‹€ν–‰ 전에 @Transactional을 주석 μ²˜λ¦¬ν•˜κ³  μ‹€ν–‰ ν›„ μ›λ³΅ν•˜λΌ"κ³  μ•ˆλ‚΄λ˜μ–΄ μžˆμœΌλ‚˜, μ΄λŠ” 번거둭고 μ‹€μˆ˜ν•˜κΈ° μ‰½μŠ΅λ‹ˆλ‹€. @Transactional μ–΄λ…Έν…Œμ΄μ…˜μ„ μ•„μ˜ˆ μ œκ±°ν•˜λ©΄ λ³„λ„μ˜ μˆ˜λ™ μž‘μ—… 없이도 항상 DB에 μ •μƒμ μœΌλ‘œ μ»€λ°‹λ˜λ―€λ‘œ, μˆ˜λ™ κ°€μ΄λ“œ 단계λ₯Ό μ—†μ• κ³  μžλ™ν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

    @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());
}
}
Loading