[#61] 커넥션 고갈 해결을 위한 로그인 이력 저장 비동기 처리 (#62)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#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>
This commit is contained in:
이상진 2025-10-14 00:28:44 +00:00 committed by 이상진
parent 135b13a9bf
commit bba3266f3f
13 changed files with 373 additions and 202 deletions

View File

@ -1,6 +1,7 @@
package com.sangdol.roomescape.auth.business package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginContext import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginCredentials import com.sangdol.roomescape.auth.dto.LoginCredentials
@ -12,8 +13,8 @@ import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -25,10 +26,9 @@ const val CLAIM_STORE_ID_KEY = "store_id"
class AuthService( class AuthService(
private val adminService: AdminService, private val adminService: AdminService,
private val userService: UserService, private val userService: UserService,
private val loginHistoryService: LoginHistoryService,
private val jwtUtils: JwtUtils, private val jwtUtils: JwtUtils,
private val eventPublisher: ApplicationEventPublisher
) { ) {
@Transactional(readOnly = true)
fun login( fun login(
request: LoginRequest, request: LoginRequest,
context: LoginContext context: LoginContext
@ -36,20 +36,25 @@ class AuthService(
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request) val (credentials, extraClaims) = getCredentials(request)
val event = LoginHistoryEvent(
id = credentials.id,
type = request.principalType,
ipAddress = context.ipAddress,
userAgent = context.userAgent
)
try { try {
verifyPasswordOrThrow(request, credentials) verifyPasswordOrThrow(request, credentials)
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) eventPublisher.publishEvent(event.onSuccess())
return credentials.toResponse(accessToken).also { return credentials.toResponse(accessToken).also {
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" } log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
} }
} catch (e: Exception) { } catch (e: Exception) {
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) eventPublisher.publishEvent(event.onFailure())
when (e) { when (e) {
is AuthException -> { is AuthException -> {
log.info { "[login] 로그인 실패: account = ${request.account}" } log.info { "[login] 로그인 실패: account = ${request.account}" }

View File

@ -0,0 +1,96 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.mapper.toEntity
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
@EnableAsync
@EnableScheduling
class LoginHistoryEventListener(
private val idGenerator: IDGenerator,
private val loginHistoryRepository: LoginHistoryRepository,
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
) {
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
private var batchSize: Int = 0
@Async
@EventListener(classes = [LoginHistoryEvent::class])
fun onLoginCompleted(event: LoginHistoryEvent) {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
queue.add(event.toEntity(idGenerator.create())).also {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
}
if (queue.size >= batchSize) {
flush()
}
}
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
fun flushScheduled() {
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.info { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
return
}
flush()
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
}
@PreDestroy
fun flushAll() {
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
while (!queue.isEmpty()) {
flush()
}
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
}
private fun flush() {
log.info { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.info { "[flush] 큐에 있는 로그인 이력이 없음." }
return;
}
val batch = mutableListOf<LoginHistoryEntity>()
repeat(batchSize) {
val entity: LoginHistoryEntity? = queue.poll()
if (entity != null) {
batch.add(entity)
} else {
return@repeat
}
}
if (batch.isEmpty()) {
return
}
loginHistoryRepository.saveAll(batch).also {
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
}
}
}

View File

@ -1,63 +0,0 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@Service
class LoginHistoryService(
private val loginHistoryRepository: LoginHistoryRepository,
private val idGenerator: IDGenerator,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createSuccessHistory(
principalId: Long,
principalType: PrincipalType,
context: LoginContext
) {
createHistory(principalId, principalType, success = true, context = context)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createFailureHistory(
principalId: Long,
principalType: PrincipalType,
context: LoginContext
) {
createHistory(principalId, principalType, success = false, context = context)
}
private fun createHistory(
principalId: Long,
principalType: PrincipalType,
success: Boolean,
context: LoginContext
) {
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
runCatching {
LoginHistoryEntity(
id = idGenerator.create(),
principalId = principalId,
principalType = principalType,
success = success,
ipAddress = context.ipAddress,
userAgent = context.userAgent,
).also {
loginHistoryRepository.save(it)
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
}
}.onFailure {
log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
}
}
}

View File

@ -0,0 +1,19 @@
package com.sangdol.roomescape.auth.business.domain
class LoginHistoryEvent(
val id: Long,
val type: PrincipalType,
var success: Boolean = true,
val ipAddress: String,
val userAgent: String
) {
fun onSuccess(): LoginHistoryEvent {
this.success = true
return this
}
fun onFailure(): LoginHistoryEvent {
this.success = false
return this
}
}

View File

@ -0,0 +1,13 @@
package com.sangdol.roomescape.auth.mapper
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
id = id,
principalId = this.id,
principalType = this.type,
success = this.success,
ipAddress = this.ipAddress,
userAgent = this.userAgent
)

View File

@ -11,6 +11,11 @@ spring:
active: ${ACTIVE_PROFILE:local} active: ${ACTIVE_PROFILE:local}
jpa: jpa:
open-in-view: false open-in-view: false
properties:
hibernate:
jdbc:
batch_size: ${JDBC_BATCH_SIZE:100}
order_inserts: true
management: management:
endpoints: endpoints:

View File

@ -1,16 +1,18 @@
package com.sangdol.roomescape.auth package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.admin.exception.AdminErrorCode import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
import com.sangdol.roomescape.auth.business.LoginHistoryEventListener
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.supports.AdminFixture import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.UserFixture import com.sangdol.roomescape.supports.UserFixture
@ -18,19 +20,31 @@ import com.sangdol.roomescape.supports.runTest
import com.sangdol.roomescape.user.exception.UserErrorCode import com.sangdol.roomescape.user.exception.UserErrorCode
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe import io.kotest.matchers.shouldNotBe
import io.mockk.every import io.mockk.*
import io.restassured.response.ValidatableResponse import io.restassured.response.ValidatableResponse
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
class AuthApiTest( class AuthApiTest(
@SpykBean private val jwtUtils: JwtUtils, @SpykBean private val jwtUtils: JwtUtils,
private val loginHistoryRepository: LoginHistoryRepository @MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener,
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
lateinit var slot: CapturingSlot<LoginHistoryEvent>
init { init {
beforeTest {
slot = slot<LoginHistoryEvent>()
every {
loginHistoryEventListener.onLoginCompleted(capture(slot))
} just Runs
}
afterTest {
clearMocks(jwtUtils, loginHistoryEventListener)
}
context("로그인을 시도한다.") { context("로그인을 시도한다.") {
context("성공 응답") { context("성공 응답") {
listOf( listOf(
@ -64,6 +78,7 @@ class AuthApiTest(
password = user.password, password = user.password,
type = PrincipalType.USER, type = PrincipalType.USER,
) { ) {
val token: String = it.extract().path("data.accessToken") val token: String = it.extract().path("data.accessToken")
jwtUtils.extractSubject(token) shouldBe user.id.toString() jwtUtils.extractSubject(token) shouldBe user.id.toString()
} }
@ -71,6 +86,16 @@ class AuthApiTest(
} }
context("실패 응답") { context("실패 응답") {
lateinit var slot: CapturingSlot<LoginHistoryEvent>
beforeTest {
slot = slot<LoginHistoryEvent>()
every {
loginHistoryEventListener.onLoginCompleted(capture(slot))
} just Runs
}
context("계정이 맞으면 로그인 실패 이력을 남긴다.") { context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
test("비밀번호가 틀린 경우") { test("비밀번호가 틀린 경우") {
val admin = testAuthUtil.createAdmin(AdminFixture.default) val admin = testAuthUtil.createAdmin(AdminFixture.default)
@ -88,9 +113,14 @@ class AuthApiTest(
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode)) body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
} }
).also { ).also {
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { verify(exactly = 1) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
assertSoftly(slot.captured) {
this.id shouldBe admin.id
this.type shouldBe PrincipalType.ADMIN
this.success shouldBe false this.success shouldBe false
this.principalType shouldBe PrincipalType.ADMIN
} }
} }
} }
@ -115,9 +145,14 @@ class AuthApiTest(
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode)) body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
} }
).also { ).also {
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { verify(exactly = 1) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
assertSoftly(slot.captured) {
this.id shouldBe admin.id
this.type shouldBe PrincipalType.ADMIN
this.success shouldBe false this.success shouldBe false
this.principalType shouldBe PrincipalType.ADMIN
} }
} }
} }
@ -144,7 +179,9 @@ class AuthApiTest(
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode)) body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
} }
).also { ).also {
loginHistoryRepository.findAll() shouldHaveSize 0 verify(exactly = 0) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
} }
} }
@ -168,7 +205,9 @@ class AuthApiTest(
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode)) body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
} }
).also { ).also {
loginHistoryRepository.findAll() shouldHaveSize 0 verify(exactly = 0) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
} }
} }
} }
@ -198,10 +237,13 @@ class AuthApiTest(
).also { ).also {
extraAssertions?.invoke(it) extraAssertions?.invoke(it)
assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history -> verify(exactly = 1) {
history shouldHaveSize (1) loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
history[0].success shouldBe true }
history[0].principalType shouldBe type assertSoftly(slot.captured) {
this.id shouldBe id
this.type shouldBe type
this.success shouldBe true
} }
} }
} }

View File

@ -1,64 +0,0 @@
package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.UserFixture
import com.sangdol.roomescape.supports.runTest
import io.mockk.clearMocks
import io.mockk.every
class FailOnSaveLoginHistoryTest(
@MockkBean private val loginHistoryRepository: LoginHistoryRepository
) : FunSpecSpringbootTest() {
init {
context("로그인 이력 저장 과정에서 예외가 발생해도 로그인 작업 자체는 정상 처리된다.") {
beforeTest {
clearMocks(loginHistoryRepository)
every {
loginHistoryRepository.save(any())
} throws RuntimeException("intended exception")
}
test("회원") {
val user = testAuthUtil.signup(UserFixture.createRequest)
val request = LoginRequest(user.email, user.password, PrincipalType.USER)
runTest(
using = {
body(request)
},
on = {
post("/auth/login")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
}
test("관리자") {
val admin = testAuthUtil.createAdmin(AdminFixture.default)
val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN)
runTest(
using = {
body(request)
},
on = {
post("/auth/login")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
}
}
}
}

View File

@ -0,0 +1,71 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.supports.IDGenerator
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldNotContainAnyOf
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
class LoginHistoryEventListenerTest : FunSpec() {
val histories: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue((1..1000).map {
LoginHistoryEntity(
id = IDGenerator.create(),
principalId = IDGenerator.create(),
principalType = PrincipalType.USER,
success = true,
ipAddress = "127.0.0.1",
userAgent = "UserAgent"
)
})
init {
test("ConcurrentLinkedQueue에서 데이터를 꺼내는 작업을 여러 스레드에서 동시 호출해도 중복 처리되지 않는다.") {
withContext(Dispatchers.Default) {
val latch = CountDownLatch(2)
val flushJob1 = async {
latch.countDown()
latch.await()
flush()
}
val flushJob2 = async {
latch.countDown()
latch.await()
flush()
}
val flushJob1Result = flushJob1.await()
val flushJob2Result = flushJob2.await()
flushJob2Result shouldNotContainAnyOf flushJob1Result
flushJob1Result shouldNotContainAnyOf flushJob2Result
(flushJob1Result.size + flushJob2Result.size) shouldBe 1000
histories.shouldBeEmpty()
}
}
}
private fun flush(batchSize: Int = 500): MutableList<LoginHistoryEntity> {
val batch = mutableListOf<LoginHistoryEntity>()
repeat(batchSize) {
val entity: LoginHistoryEntity? = histories.poll()
if (entity != null) {
batch.add(entity)
} else {
return@repeat
}
}
return batch
}
}

View File

@ -31,7 +31,7 @@ export function maxIterations() {
} }
export function fetchUsers() { export function fetchUsers() {
const userCount = Math.round(maxIterations() * 1.2) const userCount = Math.round(maxIterations() * 0.5)
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`) const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`)
if (userAccountRes.status !== 200) { if (userAccountRes.status !== 200) {

View File

@ -17,15 +17,17 @@ export const options = {
executor: 'ramping-vus', executor: 'ramping-vus',
startVUs: 0, startVUs: 0,
stages: [ stages: [
{ duration: '1m', target: 5 }, { duration: '1m', target: 100 },
{ duration: '1m', target: 10 }, { duration: '1m', target: 200 },
{ duration: '1m', target: 15 }, { duration: '1m', target: 300 },
{ duration: '1m', target: 20 }, { duration: '1m', target: 300 },
{ duration: '1m', target: 25 }, { duration: '1m', target: 400 },
{ duration: '1m', target: 30 }, { duration: '1m', target: 500 },
{ duration: '1m', target: 35 }, { duration: '2m', target: 500 },
{ duration: '1m', target: 40 }, { duration: '1m', target: 600 },
{ duration: '1m', target: 0 }, { duration: '1m', target: 800 },
{ duration: '1m', target: 1000 },
{ duration: '3m', target: 0 },
] ]
} }
}, },
@ -88,45 +90,51 @@ export default function (data) {
console.log(`로그인 실패: token=${accessToken}`) console.log(`로그인 실패: token=${accessToken}`)
return return
} }
const targetDate = randomDayBetween(1, 6) const targetDate = randomDayBetween(1, 4)
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () { group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) let searchTrial = 0
if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) { let schedules
sleep(20)
let schedules = parseIdToString(res).data.schedules
if (!schedules || schedules.length === 0) { while (searchTrial < 5) {
console.log("일정 없음. 1회 재시도") const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
const storeId = randomItem(stores).storeId const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
const targetDate = randomDayBetween(0, 6) if (result !== true) {
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) continue
schedules = parseIdToString(res).data.schedules
} }
schedules = parseIdToString(res).data.schedules
if (schedules && schedules.length > 0) { if (schedules && schedules.length > 0) {
group(`일부 테마는 상세 조회`, function () { break
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
}
} }
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
} }
}) })
@ -134,26 +142,34 @@ export default function (data) {
return; return;
} }
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)
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}`)
if (themeInfoRes.status !== 200) {
throw new Error("테마 상세 조회 실패")
}
selectedThemeInfo = parseIdToString(themeInfoRes).data selectedThemeInfo = parseIdToString(themeInfoRes).data
isScheduleHeld = true
} else {
const errorCode = body.code
const errorMessage = body.message
console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`)
} }
}) })
let isPendingReservationCreated = false if (!isScheduleHeld || !selectedThemeInfo) {
return
}
let isPendingReservationCreated = false
group(`예약 정보 입력 페이지`, function () { group(`예약 정보 입력 페이지`, function () {
let userName, userContact let userName, userContact
group(`회원 연락처 조회`, function () { group(`회원 연락처 조회`, function () {
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken)) const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
if (!check(userContactRes, { '회원 연락처 조회 성공': (r) => r.status === 200 })) { if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
throw new Error("회원 연락처 조회 과정에서 예외 발생") throw new Error("회원 연락처 조회 과정에서 예외 발생")
} }
@ -198,14 +214,12 @@ export default function (data) {
}) })
if (!isPendingReservationCreated) { if (!isPendingReservationCreated) {
console.log("회원의 예약 정보 입력 중 페이지 이탈")
return; return;
} }
group(`결제 및 예약 확정`, function () { group(`결제 및 예약 확정`, function () {
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트 // 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
if (Math.random() <= 0.2) { if (Math.random() <= 0.2) {
console.log("결제 페이지에서의 이탈")
return return
} }
@ -224,7 +238,7 @@ export default function (data) {
sleep(30) sleep(30)
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken)) const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
if (check(confirmOrderRes, { '예약 확정 성공': (r) => r.status === 200 })) { if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
isConfirmed = true isConfirmed = true
break break
} }
@ -241,7 +255,7 @@ export default function (data) {
sleep(10) sleep(10)
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`) const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
if (check(temporalConfirmRes, { '임시 예약 확정 성공': (r) => r.status === 200})) { if (check(temporalConfirmRes, {'임시 예약 확정 성공': (r) => r.status === 200})) {
console.log("예약 확정 성공") console.log("예약 확정 성공")
return return
} }

View File

@ -0,0 +1,33 @@
import {fetchUsers, login} from "./common.js";
import {randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
export const options = {
scenarios: {
login: {
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: 0 },
]
}
}
}
export function setup() {
const users = fetchUsers()
console.log(`${users.length}명의 회원 준비`)
return { users }
}
export default function (data) {
const user = randomItem(data.users)
const token = login(user.account, user.password, 'USER').accessToken
}

View File

@ -28,7 +28,7 @@ spring:
sql: sql:
init: init:
mode: always mode: always
schema-locations: classpath:schema/schema-mysql.sql schema-locations: classpath:schema/schema-h2.sql
management: management:
endpoints: endpoints: