Compare commits

...

4 Commits

7 changed files with 40 additions and 34 deletions

View File

@ -36,14 +36,18 @@ class OrderService(
) { ) {
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
val trial = paymentAttemptRepository.countByReservationId(reservationId) var trial: Long = 0
val paymentKey = paymentConfirmRequest.paymentKey val paymentKey = paymentConfirmRequest.paymentKey
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
try { try {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) { trial = transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
validateAndMarkInProgress(reservationId) getTrialAfterValidateCanConfirm(reservationId).also {
reservationService.markInProgress(reservationId)
}
} ?: run {
log.warn { "[confirm] 모든 paymentAttempts 조회 과정에서의 예상치 못한 null 응답: reservationId=${reservationId}" }
throw OrderException(OrderErrorCode.BOOKING_UNEXPECTED_ERROR)
} }
val paymentClientResponse: PaymentGatewayResponse = val paymentClientResponse: PaymentGatewayResponse =
@ -61,20 +65,32 @@ class OrderService(
} }
} }
private fun validateAndMarkInProgress(reservationId: Long) { private fun getTrialAfterValidateCanConfirm(reservationId: Long): Long {
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
try { try {
orderValidator.validateCanConfirm(reservation, schedule) orderValidator.validateCanConfirm(reservation, schedule)
return getTrialIfSuccessAttemptNotExists(reservationId).also {
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
}
} catch (e: OrderException) { } catch (e: OrderException) {
val errorCode = OrderErrorCode.NOT_CONFIRMABLE val errorCode = OrderErrorCode.NOT_CONFIRMABLE
throw OrderException(errorCode, e.message) throw OrderException(errorCode, e.message)
} }
}
reservationService.markInProgress(reservationId) private fun getTrialIfSuccessAttemptNotExists(reservationId: Long): Long {
val paymentAttempts: List<PaymentAttemptEntity> = paymentAttemptRepository.findAllByReservationId(reservationId)
if (paymentAttempts.any { it.result == AttemptResult.SUCCESS }) {
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservationId}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
}
return paymentAttempts.size.toLong()
} }
private fun requestConfirmPayment( private fun requestConfirmPayment(

View File

@ -16,18 +16,11 @@ import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class OrderValidator( class OrderValidator {
private val paymentAttemptRepository: PaymentAttemptRepository
) {
fun validateCanConfirm( fun validateCanConfirm(
reservation: ReservationStateResponse, reservation: ReservationStateResponse,
schedule: ScheduleStateResponse schedule: ScheduleStateResponse
) { ) {
if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) {
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
}
validateReservationStatus(reservation) validateReservationStatus(reservation)
validateScheduleStatus(schedule) validateScheduleStatus(schedule)
} }

View File

@ -23,4 +23,6 @@ interface PaymentAttemptRepository: JpaRepository<PaymentAttemptEntity, Long> {
""" """
) )
fun isSuccessAttemptExists(reservationId: Long): Boolean fun isSuccessAttemptExists(reservationId: Long): Boolean
fun findAllByReservationId(reservationId: Long): List<PaymentAttemptEntity>
} }

View File

@ -108,6 +108,8 @@ class OrderConcurrencyTest(
} }
} }
delay(10)
async { async {
assertThrows<OrderException> { assertThrows<OrderException> {
orderService.confirm(reservation.id, paymentConfirmRequest) orderService.confirm(reservation.id, paymentConfirmRequest)

View File

@ -32,7 +32,7 @@ export function maxIterations() {
export function fetchUsers() { export function fetchUsers() {
const userCount = Math.round(maxIterations() * 0.5) const userCount = Math.round(maxIterations() * 0.5)
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`) const userAccountRes = http.get(`http://localhost:8080/tests/users?count=${userCount}`)
if (userAccountRes.status !== 200) { if (userAccountRes.status !== 200) {
throw new Error('users 조회 실패') throw new Error('users 조회 실패')
@ -42,7 +42,7 @@ export function fetchUsers() {
} }
export function fetchStores() { export function fetchStores() {
const storeIdListRes = http.get(`${BASE_URL}/tests/stores`) const storeIdListRes = http.get(`http://localhost:8080/tests/stores`)
if (storeIdListRes.status !== 200) { if (storeIdListRes.status !== 200) {
throw new Error('stores 조회 실패') throw new Error('stores 조회 실패')

View File

@ -15,19 +15,10 @@ export const options = {
scenarios: { scenarios: {
user_reservation: { user_reservation: {
executor: 'ramping-vus', executor: 'ramping-vus',
startVUs: 0, startVUs: 1500,
stages: [ stages: [
{ duration: '1m', target: 100 }, { duration: '10m', target: 1500 },
{ duration: '1m', target: 200 }, { duration: '1m', target: 0 }
{ 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 },
] ]
} }
}, },
@ -84,13 +75,13 @@ export default function (data) {
const user = randomItem(users) const user = randomItem(users)
const accessToken = login(user.account, user.password, 'USER').accessToken const accessToken = login(user.account, user.password, 'USER').accessToken
const storeId = randomItem(stores).storeId let storeId = randomItem(stores).storeId
if (!accessToken) { if (!accessToken) {
console.log(`로그인 실패: token=${accessToken}`) console.log(`로그인 실패: token=${accessToken}`)
return return
} }
const targetDate = randomDayBetween(1, 4) let targetDate
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
@ -99,6 +90,8 @@ export default function (data) {
let schedules let schedules
while (searchTrial < 5) { while (searchTrial < 5) {
storeId = randomItem(stores).storeId
targetDate = randomDayBetween(1, 7)
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
const result = check(res, {'일정 조회 성공': (r) => r.status === 200}) const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
if (result !== true) { if (result !== true) {
@ -145,7 +138,7 @@ export default function (data) {
let isScheduleHeld = false let isScheduleHeld = false
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
const body = JSON.parse(holdRes) const body = JSON.parse(holdRes.body)
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) { if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)

View File

@ -13,7 +13,7 @@ export const options = {
executor: 'shared-iterations', executor: 'shared-iterations',
vus: 263, vus: 263,
iterations: TOTAL_ITERATIONS, iterations: TOTAL_ITERATIONS,
maxDuration: '10m', maxDuration: '30m',
}, },
}, },
thresholds: { thresholds: {
@ -113,7 +113,7 @@ function createSchedule(storeId, accessToken, schedule) {
function generateDates(days) { function generateDates(days) {
const dates = []; const dates = [];
const today = new Date(); const today = new Date();
for (let i = 1; i < days; i++) { for (let i = 1; i <= days; i++) {
const date = new Date(today); const date = new Date(today);
date.setDate(today.getDate() + i); date.setDate(today.getDate() + i);
dates.push(date.toISOString().split('T')[0]); dates.push(date.toISOString().split('T')[0]);