generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #61 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 이력 저장을 비동기 + Batch Insert로 구현하여 기존의 '로그인 완료 - 이력 저장(동기)' 로직, 특히 이력 저장을 별도의 트랜잭션으로 진행하며 발생하던 커넥션 고갈 문제를 해결 - 이벤트를 수신하면 In-Memory Queue에 저장하게 되어, OOM 발생 가능성이 있다고 판단. => 100개가 넘어가는 순간 바로 Batch Insert를 수행하도록 함. ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> - 로컬 환경에서 Login API만 별도로 성능 테스트 => 기존 로직에서는 70VU에서 다운, 개선 후 1000VU, 초당 558번의 요청에서도 정상 동작 - 테스트 결과 메모리 사용량의 큰 변화 없이 커넥션 고갈 문제 해결 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #62 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
265 lines
8.8 KiB
JavaScript
265 lines
8.8 KiB
JavaScript
import {
|
|
BASE_URL,
|
|
fetchStores,
|
|
fetchUsers,
|
|
generateRandomBase64String,
|
|
getHeaders,
|
|
login,
|
|
parseIdToString
|
|
} from "./common.js";
|
|
import {check, group, sleep} from 'k6';
|
|
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
|
import http from 'k6/http';
|
|
|
|
export const options = {
|
|
scenarios: {
|
|
user_reservation: {
|
|
executor: 'ramping-vus',
|
|
startVUs: 0,
|
|
stages: [
|
|
{ duration: '1m', target: 100 },
|
|
{ duration: '1m', target: 200 },
|
|
{ duration: '1m', target: 300 },
|
|
{ duration: '1m', target: 300 },
|
|
{ duration: '1m', target: 400 },
|
|
{ duration: '1m', target: 500 },
|
|
{ duration: '2m', target: 500 },
|
|
{ duration: '1m', target: 600 },
|
|
{ duration: '1m', target: 800 },
|
|
{ duration: '1m', target: 1000 },
|
|
{ duration: '3m', target: 0 },
|
|
]
|
|
}
|
|
},
|
|
|
|
thresholds: {
|
|
http_req_duration: ['p(95)<3000'],
|
|
http_req_failed: ['rate<0.15'],
|
|
},
|
|
};
|
|
|
|
|
|
function randomDayBetween(startFrom, endAt) {
|
|
const random = randomIntBetween(startFrom, endAt)
|
|
const targetDate = new Date()
|
|
targetDate.setDate(targetDate.getDate() + random)
|
|
|
|
return targetDate.toISOString().split('T')[0];
|
|
}
|
|
|
|
function countForFetchDetails(themeCount) {
|
|
const random = Math.random()
|
|
|
|
if (random < 0.5) {
|
|
return Math.min(0, themeCount)
|
|
}
|
|
if (random < 0.75) {
|
|
return Math.min(1, themeCount)
|
|
}
|
|
if (random < 0.9) {
|
|
return Math.min(2, themeCount)
|
|
}
|
|
return Math.min(randomIntBetween(3, themeCount), themeCount)
|
|
}
|
|
|
|
function extractRandomThemeForFetchDetail(themes) {
|
|
const count = countForFetchDetails(themes.length)
|
|
const shuffled = [...themes].sort(() => Math.random() - 0.5)
|
|
|
|
return shuffled.slice(0, count).map(t => t.id)
|
|
}
|
|
|
|
export function setup() {
|
|
const users = fetchUsers()
|
|
const stores = fetchStores()
|
|
|
|
console.log(`회원 수 = ${users.length} 조회 완료`)
|
|
console.log(`매장 수 = ${stores.length} 조회 완료`)
|
|
|
|
return { users, stores }
|
|
}
|
|
|
|
export default function (data) {
|
|
const { users, stores } = data;
|
|
|
|
const user = randomItem(users)
|
|
const accessToken = login(user.account, user.password, 'USER').accessToken
|
|
const storeId = randomItem(stores).storeId
|
|
|
|
if (!accessToken) {
|
|
console.log(`로그인 실패: token=${accessToken}`)
|
|
return
|
|
}
|
|
const targetDate = randomDayBetween(1, 4)
|
|
|
|
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
|
|
|
|
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
|
|
let searchTrial = 0
|
|
let schedules
|
|
|
|
while (searchTrial < 5) {
|
|
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
|
|
const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
|
|
if (result !== true) {
|
|
continue
|
|
}
|
|
schedules = parseIdToString(res).data.schedules
|
|
if (schedules && schedules.length > 0) {
|
|
break
|
|
}
|
|
searchTrial++
|
|
sleep(10)
|
|
}
|
|
|
|
if (schedules.length <= 0) {
|
|
console.log(`5회 시도에도 일정 조회 실패`)
|
|
return;
|
|
}
|
|
|
|
group(`일부 테마는 상세 조회`, function () {
|
|
const themesByStoreAndDate = schedules.map(s => s.theme)
|
|
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
|
|
return
|
|
}
|
|
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
|
|
|
|
randomThemesForFetchDetail.forEach(id => {
|
|
http.get(`${BASE_URL}/themes/${id}`)
|
|
sleep(10)
|
|
})
|
|
})
|
|
|
|
const availableSchedules = schedules.filter((s) => s.schedule.status === 'AVAILABLE')
|
|
if (availableSchedules.length > 0) {
|
|
const availableSchedule = randomItem(availableSchedules)
|
|
availableScheduleId = availableSchedule.schedule.id
|
|
selectedThemeId = availableSchedule.theme.id
|
|
}
|
|
})
|
|
|
|
if (!availableScheduleId) {
|
|
return;
|
|
}
|
|
|
|
let isScheduleHeld = false
|
|
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
|
|
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
|
|
const body = JSON.parse(holdRes)
|
|
|
|
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
|
|
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
|
|
selectedThemeInfo = parseIdToString(themeInfoRes).data
|
|
isScheduleHeld = true
|
|
} else {
|
|
const errorCode = body.code
|
|
const errorMessage = body.message
|
|
|
|
console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`)
|
|
}
|
|
})
|
|
|
|
if (!isScheduleHeld || !selectedThemeInfo) {
|
|
return
|
|
}
|
|
|
|
let isPendingReservationCreated = false
|
|
group(`예약 정보 입력 페이지`, function () {
|
|
let userName, userContact
|
|
group(`회원 연락처 조회`, function () {
|
|
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
|
|
|
|
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
|
|
throw new Error("회원 연락처 조회 과정에서 예외 발생")
|
|
}
|
|
|
|
const responseBody = JSON.parse(userContactRes.body).data
|
|
userName = responseBody.name
|
|
userContact = responseBody.phone
|
|
})
|
|
|
|
// 20%의 유저는 예약 정보 입력창에서 나감 => 배치의 자동 활성화 테스트
|
|
if (Math.random() <= 0.2) {
|
|
return
|
|
}
|
|
|
|
sleep(20)
|
|
|
|
group(`예약 정보 입력 및 Pending 예약 생성`, function () {
|
|
const requirement = `${selectedThemeInfo.name}을 잘부탁드려요!`
|
|
const participants = randomIntBetween(selectedThemeInfo.minParticipants, selectedThemeInfo.maxParticipants)
|
|
totalAmount = Math.round(participants * selectedThemeInfo.price)
|
|
|
|
const payload = JSON.stringify({
|
|
scheduleId: availableScheduleId,
|
|
reserverName: userName,
|
|
reserverContact: userContact,
|
|
participantCount: participants,
|
|
requirement: requirement
|
|
})
|
|
|
|
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken))
|
|
const responseBody = parseIdToString(pendingReservationCreateRes)
|
|
|
|
if (pendingReservationCreateRes.status !== 200) {
|
|
const errorCode = responseBody.code
|
|
const errorMessage = responseBody.message
|
|
|
|
throw new Error(`Pending 예약 중 실패: code=${errorCode}, message=${errorMessage}`)
|
|
}
|
|
|
|
reservationId = responseBody.data.id
|
|
isPendingReservationCreated = true
|
|
})
|
|
})
|
|
|
|
if (!isPendingReservationCreated) {
|
|
return;
|
|
}
|
|
|
|
group(`결제 및 예약 확정`, function () {
|
|
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
|
|
if (Math.random() <= 0.2) {
|
|
return
|
|
}
|
|
|
|
const paymentKey = generateRandomBase64String(64)
|
|
const orderId = generateRandomBase64String(25)
|
|
|
|
const payload = JSON.stringify({
|
|
paymentKey: paymentKey,
|
|
orderId: orderId,
|
|
amount: totalAmount,
|
|
})
|
|
|
|
let trial = 0
|
|
let isConfirmed = false
|
|
while (trial < 2) {
|
|
sleep(30)
|
|
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
|
|
|
|
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
|
|
isConfirmed = true
|
|
break
|
|
}
|
|
|
|
const errorResponse = JSON.parse(confirmOrderRes.body)
|
|
console.log(`예약 확정 실패: message=${errorResponse.message}`)
|
|
trial = errorResponse.trial + 1
|
|
}
|
|
|
|
// 예약이 확정되었으면 종료, 아니면 임시 확정
|
|
if (isConfirmed) {
|
|
return
|
|
}
|
|
|
|
sleep(10)
|
|
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
|
|
if (check(temporalConfirmRes, {'임시 예약 확정 성공': (r) => r.status === 200})) {
|
|
console.log("예약 확정 성공")
|
|
return
|
|
}
|
|
throw new Error('임시 예약 확정 실패')
|
|
})
|
|
}
|