-
Notifications
You must be signed in to change notification settings - Fork 1
Test/#184 loadtest #185
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
base: develop
Are you sure you want to change the base?
Test/#184 loadtest #185
Changes from all commits
766a1a4
9441468
49d277a
a7891f4
134f94a
ccd472e
f110cc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // μ§μμ μ μΆ λͺ°λ¦Ό(write burst) λΆν ν μ€νΈ | ||
| // | ||
| // ApplicationServiceImpl.create() λ λ¨μΌ @Transactional μμμ NCP μ λ‘λμ DB μ°κΈ°λ₯Ό | ||
| // ν¨κ» μννλ€. 컀λ₯μ μ μ μκ°μ΄ μ λ‘λ μκ°μ λ¬Άμ΄λ―λ‘ μ²λ¦¬λ μνμ΄ | ||
| // Hikari pool / νΈλμμ μκ° | ||
| // μΌλ‘ κ³ μ λλ€. μ΄ μ€ν¬λ¦½νΈλ κ·Έ μνμ μ°Ύλλ€. | ||
| // | ||
| // μ€ν | ||
| // k6 run -e BASE_URL=https://stg.recruit-withus.co.kr -e SLUG=fdHhU7Mle \ | ||
| // -e PROFILE=smoke k6/application-submit-burst.js | ||
| // | ||
| // PROFILE | ||
| // smoke : VU 2, 30s β νμ΄λ‘λκ° κ³΅κ³ μ€μ κ³Ό λ§λμ§ νμΈ | ||
| // ramp : VU 0β150 β μ²λ¦¬λμ΄ κΊΎμ΄λ μ§μ κ³Ό 첫 5xx μμ | ||
| // soak : VU κ³ μ , 5m β μ§μ λΆν μμ μ± | ||
| // | ||
| // FILE_KB | ||
| // 0 μ΄λ©΄ νμΌν μ§λ¬Έμ νμ΄λ‘λμμ μμ λΉΌκ³ λ³΄λΈλ€(μ λ‘λ μμ). | ||
| // 0 λ³΄λ€ ν¬λ©΄ κ·Έ ν¬κΈ°μ λλ―Έ νμΌμ 첨λΆνλ€. | ||
| // λ κ°μ λΉκ΅νλ©΄ NCP μ λ‘λκ° νΈλμμ μμ μ°¨μ§νλ λΉμ€μ΄ λλ¬λλ€. | ||
| // | ||
| // μ£Όμ | ||
| // - μ€μ μ§μμκ° μμ±λκ³ NCP μ νμΌμ΄ μμΈλ€. λΌμ΄λλ§λ€ μ 리ν΄μΌ | ||
| // ν μ΄λΈ ν¬κΈ°κ° λ¬λΌμ§μ§ μμ λΌμ΄λ κ° λΉκ΅κ° μ ν¨νλ€. | ||
| // - λ©μΌμ mail.provider=noop μΌλ‘ λ§μλ μνμμ λ릴 κ². | ||
|
|
||
| import http from 'k6/http'; | ||
| import { check, sleep, fail } from 'k6'; | ||
| import { Counter, Rate, Trend } from 'k6/metrics'; | ||
|
|
||
| const BASE_URL = __ENV.BASE_URL; | ||
| const SLUG = __ENV.SLUG; | ||
| const PROFILE = __ENV.PROFILE || 'smoke'; | ||
| const FILE_KB = Number(__ENV.FILE_KB || '500'); | ||
| const SLEEP_SECONDS = Number(__ENV.SLEEP_SECONDS || '0'); | ||
|
|
||
| if (!BASE_URL) fail('BASE_URL is required. e.g. -e BASE_URL=https://stg.recruit-withus.co.kr'); | ||
| if (!SLUG) fail('SLUG is required. e.g. -e SLUG=fdHhU7Mle'); | ||
|
|
||
| const PROFILES = { | ||
| smoke: { vus: Number(__ENV.VUS || '2'), duration: __ENV.DURATION || '30s' }, | ||
| ramp: { | ||
| stages: [ | ||
| { duration: '30s', target: 10 }, | ||
| { duration: '1m', target: 30 }, | ||
| { duration: '1m', target: 60 }, | ||
| { duration: '1m', target: 100 }, | ||
| { duration: '1m', target: 150 }, | ||
| { duration: '30s', target: 0 }, | ||
| ], | ||
| }, | ||
| soak: { vus: Number(__ENV.VUS || '20'), duration: __ENV.DURATION || '5m' }, | ||
| }; | ||
|
|
||
| if (!PROFILES[PROFILE]) fail(`Unknown PROFILE: ${PROFILE}. use smoke|ramp|soak`); | ||
|
|
||
| export const options = { | ||
| ...PROFILES[PROFILE], | ||
| // νκ³λ₯Ό μ°Ύλ κ² λͺ©μ μ΄λ―λ‘ μ€ν¨ν΄λ μ€λ¨νμ§ μλλ€. | ||
| thresholds: { | ||
| submit_failed: ['rate<0.05'], | ||
| http_req_duration: ['p(95)<10000'], | ||
| }, | ||
| }; | ||
|
|
||
| const submitFailed = new Rate('submit_failed'); | ||
| const submitDuration = new Trend('submit_duration', true); | ||
| const submitOk = new Counter('submit_ok'); | ||
| const submit5xx = new Counter('submit_5xx'); | ||
| const submit4xx = new Counter('submit_4xx'); | ||
| const submitPoolExhausted = new Counter('submit_pool_exhausted'); | ||
|
|
||
| // VU λΉ ν λ²λ§ λ§λ λ€. λ§€ λ°λ³΅ μμ±νλ©΄ ν΄λΌμ΄μΈνΈ CPU κ° λ³λͺ©μ΄ λλ€. | ||
| const FILLER = FILE_KB > 0 | ||
| ? 'k6-loadtest-filler-'.repeat(Math.ceil((FILE_KB * 1024) / 19)).slice(0, FILE_KB * 1024) | ||
| : ''; | ||
|
|
||
| export function setup() { | ||
| const res = http.get(`${BASE_URL}/api/v1/recruitments/slug/${SLUG}`); | ||
| if (res.status !== 200) { | ||
| fail(`Failed to load recruitment. status=${res.status} body=${String(res.body).slice(0, 300)}`); | ||
| } | ||
|
|
||
| const d = res.json().result; | ||
| if (!d) fail('Recruitment detail is empty.'); | ||
|
|
||
| const questions = (d.applicationQuestions || []).map((q) => ({ | ||
| questionId: q.questionId, | ||
| type: q.type, | ||
| })); | ||
|
|
||
| // "2026.12.24" + "00:30" -> "2026-12-24T00:30:00" | ||
| const availableTimes = (d.availableTimeRanges || []).map((r) => { | ||
| const date = String(r.date).replace(/\./g, '-'); | ||
| const time = String(r.startTime).length === 5 ? `${r.startTime}:00` : r.startTime; | ||
| return `${date}T${time}`; | ||
| }); | ||
|
|
||
| const positions = (d.positions || []).map((p) => p.id ?? p.organizationRoleId); | ||
|
|
||
| const setupData = { | ||
| recruitmentId: d.recruitmentId, | ||
| positionId: positions.length > 0 ? positions[0] : null, | ||
| questions, | ||
| availableTimes, | ||
| needImage: d.needImage, | ||
| needGender: d.needGender, | ||
| needAddress: d.needAddress, | ||
| needSchool: d.needSchool, | ||
| needBirthDate: d.needBirthDate, | ||
| needMajor: d.needMajor, | ||
| needAcademicStatus: d.needAcademicStatus, | ||
| }; | ||
|
|
||
| console.log( | ||
| `[setup] recruitmentId=${setupData.recruitmentId} ` + | ||
| `questions=${questions.length}(file=${questions.filter((q) => q.type === 'FILE').length}) ` + | ||
| `availableTimes=${availableTimes.length} needImage=${d.needImage} ` + | ||
| `deadline=${d.documentDeadline} FILE_KB=${FILE_KB} PROFILE=${PROFILE}` | ||
| ); | ||
|
|
||
| return setupData; | ||
| } | ||
|
|
||
| export default function (data) { | ||
| const suffix = `${__VU}-${__ITER}-${Date.now()}`; | ||
| const attachFile = FILE_KB > 0; | ||
|
|
||
| // ApplicationValidator.validateFileAnswers λ answers μ€ FILE μ§λ¬Έ μμ | ||
| // μ€μ νμΌ κ°μκ° μ νν μΌμΉν΄μΌ ν΅κ³Όνλ€. FILE_KB=0 μ΄λ©΄ FILE μ§λ¬Έμ | ||
| // answers μμ μ μΈν΄ νμΌ μμ΄ λ³΄λΈλ€. | ||
| const answers = []; | ||
| let fileName = null; | ||
|
|
||
| for (const q of data.questions) { | ||
| if (q.type === 'FILE') { | ||
| if (!attachFile) continue; | ||
| fileName = `loadtest-${suffix}.pdf`; | ||
| answers.push({ questionId: q.questionId, answerText: null, fileName }); | ||
| } else { | ||
| answers.push({ | ||
| questionId: q.questionId, | ||
| answerText: `[k6] VU=${__VU} ITER=${__ITER} μλ μμ± λ΅λ³μ λλ€.`, | ||
| fileName: null, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| const request = { | ||
| name: `λΆνν μ€νΈ${__VU}-${__ITER}`, | ||
| email: `loadtest+${suffix}@example.com`, | ||
| phoneNumber: `010${String(Math.floor(Math.random() * 100000000)).padStart(8, '0')}`, | ||
| recruitmentId: data.recruitmentId, | ||
| positionId: data.positionId, | ||
| answers, | ||
| availableTimes: data.availableTimes, | ||
| gender: data.needGender ? 'MALE' : null, | ||
| university: data.needSchool ? 'μλͺ λνκ΅' : null, | ||
| major: data.needMajor ? 'μ»΄ν¨ν°κ³Όνκ³Ό' : null, | ||
| academicStatus: data.needAcademicStatus ? 'ENROLLED' : null, | ||
| birthDate: data.needBirthDate ? '2000-01-01' : null, | ||
| address: data.needAddress ? 'μμΈμ λλ΄κ΅¬ 56λ‘ 501' : null, | ||
| }; | ||
|
|
||
| const payload = { | ||
| request: http.file(JSON.stringify(request), 'request.json', 'application/json'), | ||
| }; | ||
|
|
||
| if (data.needImage) { | ||
| payload.profileImage = http.file(FILLER || 'x', `loadtest-${suffix}.jpg`, 'image/jpeg'); | ||
| } | ||
|
|
||
| if (attachFile) { | ||
| payload.files = http.file(FILLER, fileName, 'application/pdf'); | ||
| } | ||
|
|
||
| const res = http.post(`${BASE_URL}/api/v1/applications`, payload, { | ||
| tags: { name: 'POST /api/v1/applications' }, | ||
| timeout: '60s', | ||
| }); | ||
|
|
||
| submitDuration.add(res.timings.duration); | ||
|
|
||
| const ok = check(res, { 'submit 200': (r) => r.status === 200 }); | ||
| submitFailed.add(!ok); | ||
|
|
||
| if (ok) { | ||
| submitOk.add(1); | ||
| return; | ||
| } | ||
|
|
||
| const body = String(res.body || ''); | ||
|
|
||
| if (res.status >= 500 || res.status === 0) { | ||
| submit5xx.add(1); | ||
| // 컀λ₯μ ν κ³ κ°μ λ°λ‘ μΌλ€. μ΄κ² μ§λ°°μ μ΄λ©΄ νΈλμμ κΈΈμ΄κ° λ³λͺ©μ΄λ€. | ||
| if (/SQLTransientConnection|Connection is not available|HikariPool/i.test(body)) { | ||
| submitPoolExhausted.add(1); | ||
| } | ||
| if (__ITER % 50 === 0) { | ||
| console.error(`5xx status=${res.status} body=${body.slice(0, 200)}`); | ||
| } | ||
| } else { | ||
| submit4xx.add(1); | ||
| // 400 μ΄λ©΄ νμ΄λ‘λκ° κ³΅κ³ μ€μ κ³Ό μ λ§λ κ²μ΄λ―λ‘ μ¦μ λλ¬λμΌ νλ€. | ||
| if (__ITER === 0) { | ||
| console.error(`${res.status} status=${res.status} body=${body.slice(0, 500)}`); | ||
| } | ||
| } | ||
|
|
||
| if (SLEEP_SECONDS > 0) sleep(SLEEP_SECONDS); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,75 @@ | ||||||
| package KUSITMS.WITHUS.global.infra.email.sender; | ||||||
|
|
||||||
| import KUSITMS.WITHUS.global.infra.email.MailProperties; | ||||||
| import lombok.RequiredArgsConstructor; | ||||||
| import lombok.extern.slf4j.Slf4j; | ||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||||||
| import org.springframework.context.annotation.Profile; | ||||||
| import org.springframework.core.io.InputStreamSource; | ||||||
| import org.springframework.stereotype.Component; | ||||||
| import org.springframework.transaction.support.TransactionSynchronization; | ||||||
| import org.springframework.transaction.support.TransactionSynchronizationManager; | ||||||
|
|
||||||
| import java.util.List; | ||||||
|
|
||||||
| /** | ||||||
| * μ€μ λ°μ‘ μμ΄ λ°μ‘ μ§μ°λ§ μ¬ννλ ꡬν체. | ||||||
| * λΆν ν μ€νΈμμ SendGrid/Gmail μΌμΌ νλλ₯Ό μλͺ¨νμ§ μκΈ° μν΄ μ¬μ©νλ€. | ||||||
| * | ||||||
| * <p>{@link SmtpMailSender}, {@link SendGridMailSender} μ λμΌνκ² μ»€λ° μ΄νμ λμνλ€. | ||||||
| * νΈλμμ μμμ μ§μ°μ μ£Όλ©΄ DB 컀λ₯μ μ μ μκ°μ΄ ν¨κ» λμ΄λ μ ν λ€λ₯Έ κ²μ μΈ‘μ νκ² λλ―λ‘ | ||||||
| * afterCommit ꡬ쑰λ₯Ό λ°λμ λ§μΆ°μΌ νλ€. | ||||||
| */ | ||||||
| @Slf4j | ||||||
| @Component | ||||||
| @Profile("!test") | ||||||
| @ConditionalOnProperty(name = "mail.provider", havingValue = "noop") | ||||||
| @RequiredArgsConstructor | ||||||
| public class NoopMailSender implements MailSender { | ||||||
|
|
||||||
| private final MailProperties mailProperties; | ||||||
|
|
||||||
| @Override | ||||||
| public void send(String to, String subject, String text) { | ||||||
| simulateAfterCommit(to, subject); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void sendWithAttachments( | ||||||
| String to, | ||||||
| String subject, | ||||||
| String html, | ||||||
| List<InputStreamSource> attachments | ||||||
| ) { | ||||||
| simulateAfterCommit(to, subject); | ||||||
| } | ||||||
|
|
||||||
| private void simulateAfterCommit(String to, String subject) { | ||||||
| if (!TransactionSynchronizationManager.isSynchronizationActive()) { | ||||||
| simulate(to, subject); | ||||||
| return; | ||||||
| } | ||||||
|
|
||||||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||||||
| @Override | ||||||
| public void afterCommit() { | ||||||
| simulate(to, subject); | ||||||
| } | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| private void simulate(String to, String subject) { | ||||||
| long delayMs = mailProperties.getNoopDelayMs(); | ||||||
|
|
||||||
| if (delayMs > 0) { | ||||||
| try { | ||||||
| Thread.sleep(delayMs); | ||||||
| } catch (InterruptedException e) { | ||||||
| Thread.currentThread().interrupt(); | ||||||
| return; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| log.info("Email skipped by noop provider (simulated {}ms): [{}] subject: {}", delayMs, to, subject); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Security & Privacy | π Major | β‘ Quick win μμ μ μ΄λ©μΌμ INFO λ‘κ·Έμμ μ κ±°νμμμ€.
μμ μμ μ λͺ©μ λ‘κ·Έμμ μ κ±°νκ±°λ λΉμλ³ννμμμ€. μμ μμ- log.info("Email skipped by noop provider (simulated {}ms): [{}] subject: {}", delayMs, to, subject);
+ log.info("Email skipped by noop provider (simulated {}ms)", delayMs);π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,10 +83,14 @@ private void sendMail(String to, String subject, String html, List<InputStreamSo | |
| .POST(HttpRequest.BodyPublishers.ofString(requestBody)) | ||
| .build(); | ||
|
|
||
| long startedAt = System.nanoTime(); | ||
| HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); | ||
| long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L; | ||
|
|
||
| if (response.statusCode() != ACCEPTED) { | ||
| log.error( | ||
| "SendGrid rejected email: status={} to={} subject={} body={}", | ||
| "SendGrid rejected email in {}ms: status={} to={} subject={} body={}", | ||
| elapsedMs, | ||
|
Comment on lines
+86
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Maintainability & Code Quality | π‘ Minor | β‘ Quick win μμΈ κ²½λ‘μλ SendGrid μ μ‘ μκ°μ κΈ°λ‘νμΈμ.
π€ Prompt for AI Agents |
||
| response.statusCode(), | ||
| to, | ||
| subject, | ||
|
|
@@ -96,7 +100,13 @@ private void sendMail(String to, String subject, String html, List<InputStreamSo | |
| } | ||
|
|
||
| String messageId = response.headers().firstValue("X-Message-Id").orElse("unknown"); | ||
| log.info("Email accepted by SendGrid: [{}] subject: {} messageId: {}", to, subject, messageId); | ||
| log.info( | ||
| "Email accepted by SendGrid in {}ms: [{}] subject: {} messageId: {}", | ||
| elapsedMs, | ||
| to, | ||
| subject, | ||
| messageId | ||
| ); | ||
| } catch (CustomException e) { | ||
| throw e; | ||
| } catch (IOException e) { | ||
|
|
||
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.
π― Functional Correctness | π Major | β‘ Quick win
FILE λ΅λ³ μμ μ λ‘λ νμΌ μλ₯Ό λμΌνκ² λ§λμμμ€.
νμ¬
FILE_KB > 0μ΄λ©΄ FILE μ§λ¬Έμ΄ μμ΄λ νμΌ νλλ₯Ό μ μ‘ν©λλ€. FILE μ§λ¬Έμ΄ λ μ΄μμ΄λ©΄answersμλ μ§λ¬Έλ§λ€ FILE λ΅λ³μ μΆκ°νμ§λ§payload.filesμλ νμΌ νλλ§ μ μ‘ν©λλ€.ApplicationValidator.validateFileAnswersλ λ μκ° μ νν κ°μμΌ ν΅κ³Όν©λλ€. FILE μ§λ¬Έ λͺ©λ‘μ λ¨Όμ λ§λ€κ³ ,FILE_KB > 0μΌ λ κ·Έ λͺ©λ‘μ κ° μ§λ¬Έμ λμνλ νμΌμ νλμ©payload.filesμ μΆκ°νμμμ€. FILE μ§λ¬Έμ΄ μμΌλ©΄filesννΈλ₯Ό 보λ΄μ§ λ§μμμ€.π€ Prompt for AI Agents