From ebe6357adce9468bc00e40ff589b731ef670c42b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:28:13 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20tosspay-mock=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EC=97=90=EC=84=9C=EC=9D=98=20schema=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tosspay-mock/src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tosspay-mock/src/main/resources/application.yaml b/tosspay-mock/src/main/resources/application.yaml index e0a6467a..32a4a577 100644 --- a/tosspay-mock/src/main/resources/application.yaml +++ b/tosspay-mock/src/main/resources/application.yaml @@ -28,7 +28,7 @@ spring: sql: init: mode: always - schema-locations: classpath:schema/schema-mysql.sql + schema-locations: classpath:schema/schema-h2.sql management: endpoints: -- 2.47.2 From 56023ac543204f40fe33d4ce34a1a7c35c56fe91 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:28:28 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20hibernate=20batch=20insert=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/src/main/resources/application.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/service/src/main/resources/application.yaml b/service/src/main/resources/application.yaml index e935c571..d614867c 100644 --- a/service/src/main/resources/application.yaml +++ b/service/src/main/resources/application.yaml @@ -11,6 +11,11 @@ spring: active: ${ACTIVE_PROFILE:local} jpa: open-in-view: false + properties: + hibernate: + jdbc: + batch_size: ${JDBC_BATCH_SIZE:100} + order_inserts: true management: endpoints: -- 2.47.2 From cfaf83b70c3d09104abfbf911530f9f2bfdb68bf Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:29:33 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API?= =?UTF-8?q?=20=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-scripts/login-performance.js | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test-scripts/login-performance.js diff --git a/test-scripts/login-performance.js b/test-scripts/login-performance.js new file mode 100644 index 00000000..04dad6df --- /dev/null +++ b/test-scripts/login-performance.js @@ -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 +} -- 2.47.2 From 4ec1e0c8133b4c0c19e19db4fda202bf6d8d6ee0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:30:14 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-scripts/common.js | 2 +- test-scripts/create-reservation-scripts.js | 36 ++++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/test-scripts/common.js b/test-scripts/common.js index d841534c..0816cb4a 100644 --- a/test-scripts/common.js +++ b/test-scripts/common.js @@ -31,7 +31,7 @@ export function maxIterations() { } 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}`) if (userAccountRes.status !== 200) { diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js index cd6a7e7f..df99e730 100644 --- a/test-scripts/create-reservation-scripts.js +++ b/test-scripts/create-reservation-scripts.js @@ -17,15 +17,17 @@ export const options = { executor: 'ramping-vus', startVUs: 0, stages: [ - { duration: '1m', target: 5 }, - { duration: '1m', target: 10 }, - { duration: '1m', target: 15 }, - { duration: '1m', target: 20 }, - { duration: '1m', target: 25 }, - { duration: '1m', target: 30 }, - { duration: '1m', target: 35 }, - { duration: '1m', target: 40 }, - { duration: '1m', target: 0 }, + { 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 }, ] } }, @@ -88,7 +90,7 @@ export default function (data) { console.log(`로그인 실패: token=${accessToken}`) return } - const targetDate = randomDayBetween(1, 6) + const targetDate = randomDayBetween(1, 4) let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount @@ -101,9 +103,11 @@ export default function (data) { if (!schedules || schedules.length === 0) { console.log("일정 없음. 1회 재시도") const storeId = randomItem(stores).storeId - const targetDate = randomDayBetween(0, 6) + const targetDate = randomDayBetween(1, 4) const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) - schedules = parseIdToString(res).data.schedules + if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) { + schedules = parseIdToString(res).data.schedules + } } if (schedules && schedules.length > 0) { @@ -134,6 +138,8 @@ export default function (data) { return; } + let isScheduleHeld = false + group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) @@ -143,9 +149,15 @@ export default function (data) { throw new Error("테마 상세 조회 실패") } selectedThemeInfo = parseIdToString(themeInfoRes).data + isScheduleHeld = true } }) + if (!isScheduleHeld) { + console.log("일정 점유 실패") + return + } + let isPendingReservationCreated = false group(`예약 정보 입력 페이지`, function () { -- 2.47.2 From 076470b7abaf6226c3325156253f7e0ca9ad7b62 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:31:13 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/business/domain/LoginHistoryEvent.kt | 19 +++++++++++++++++++ .../auth/mapper/AuthMappingExtensions.kt | 13 +++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/LoginHistoryEvent.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/auth/mapper/AuthMappingExtensions.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/LoginHistoryEvent.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/LoginHistoryEvent.kt new file mode 100644 index 00000000..d5a1a7b7 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/LoginHistoryEvent.kt @@ -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 + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/mapper/AuthMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/mapper/AuthMappingExtensions.kt new file mode 100644 index 00000000..0b63d64d --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/mapper/AuthMappingExtensions.kt @@ -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 +) -- 2.47.2 From 49e00d91e54dd0b9886649907348c4b478df7361 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:31:45 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20ConcurrentLinkedQueue=EC=9D=98=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/LoginHistoryEventListener.kt | 96 +++++++++++++++++++ .../business/LoginHistoryEventListenerTest.kt | 71 ++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListener.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListener.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListener.kt new file mode 100644 index 00000000..5a9f0ca1 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListener.kt @@ -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 = 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() + 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}" } + } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt new file mode 100644 index 00000000..55e993b0 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryEventListenerTest.kt @@ -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 = 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 { + val batch = mutableListOf() + repeat(batchSize) { + val entity: LoginHistoryEntity? = histories.poll() + + if (entity != null) { + batch.add(entity) + } else { + return@repeat + } + } + + return batch + } +} -- 2.47.2 From 0f1ce2a497ee73df41ca439218bd54b251cee9de Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 13:32:44 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20AuthService=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20Transactional=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9D=84=20=EC=9D=B4=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/business/AuthService.kt | 19 +++--- .../auth/business/LoginHistoryService.kt | 63 ------------------- 2 files changed, 12 insertions(+), 70 deletions(-) delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt index 03023e1c..8938a1fd 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.auth.business 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.dto.LoginContext 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 io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional private val log: KLogger = KotlinLogging.logger {} @@ -25,10 +26,9 @@ const val CLAIM_STORE_ID_KEY = "store_id" class AuthService( private val adminService: AdminService, private val userService: UserService, - private val loginHistoryService: LoginHistoryService, private val jwtUtils: JwtUtils, + private val eventPublisher: ApplicationEventPublisher ) { - @Transactional(readOnly = true) fun login( request: LoginRequest, context: LoginContext @@ -36,20 +36,25 @@ class AuthService( log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } val (credentials, extraClaims) = getCredentials(request) + val event = LoginHistoryEvent( + id = credentials.id, + type = request.principalType, + ipAddress = context.ipAddress, + userAgent = context.userAgent + ) + try { verifyPasswordOrThrow(request, credentials) 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 { log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" } } - } catch (e: Exception) { - loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) - + eventPublisher.publishEvent(event.onFailure()) when (e) { is AuthException -> { log.info { "[login] 로그인 실패: account = ${request.account}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt deleted file mode 100644 index 662a81bb..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt +++ /dev/null @@ -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}" } - } - } -} -- 2.47.2 From 0ff0f4e9fc5210cf10bc483a5fe40d258b4830e9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 14:01:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sangdol/roomescape/auth/AuthApiTest.kt | 74 +++++++++++++++---- .../auth/FailOnSaveLoginHistoryTest.kt | 64 ---------------- 2 files changed, 58 insertions(+), 80 deletions(-) delete mode 100644 service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt index 7e9eae99..1cdc6694 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt @@ -1,16 +1,18 @@ package com.sangdol.roomescape.auth +import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.SpykBean import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.admin.exception.AdminErrorCode 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_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.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.FunSpecSpringbootTest 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.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly -import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import io.mockk.every +import io.mockk.* import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.equalTo class AuthApiTest( @SpykBean private val jwtUtils: JwtUtils, - private val loginHistoryRepository: LoginHistoryRepository + @MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener, ) : FunSpecSpringbootTest() { + lateinit var slot: CapturingSlot + init { + beforeTest { + slot = slot() + every { + loginHistoryEventListener.onLoginCompleted(capture(slot)) + } just Runs + } + + afterTest { + clearMocks(jwtUtils, loginHistoryEventListener) + } + context("로그인을 시도한다.") { context("성공 응답") { listOf( @@ -64,6 +78,7 @@ class AuthApiTest( password = user.password, type = PrincipalType.USER, ) { + val token: String = it.extract().path("data.accessToken") jwtUtils.extractSubject(token) shouldBe user.id.toString() } @@ -71,6 +86,16 @@ class AuthApiTest( } context("실패 응답") { + + lateinit var slot: CapturingSlot + + beforeTest { + slot = slot() + every { + loginHistoryEventListener.onLoginCompleted(capture(slot)) + } just Runs + } + context("계정이 맞으면 로그인 실패 이력을 남긴다.") { test("비밀번호가 틀린 경우") { val admin = testAuthUtil.createAdmin(AdminFixture.default) @@ -88,9 +113,14 @@ class AuthApiTest( body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode)) } ).also { - assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + verify(exactly = 1) { + loginHistoryEventListener.onLoginCompleted(any()) + } + + assertSoftly(slot.captured) { + this.id shouldBe admin.id + this.type shouldBe PrincipalType.ADMIN this.success shouldBe false - this.principalType shouldBe PrincipalType.ADMIN } } } @@ -115,9 +145,14 @@ class AuthApiTest( body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode)) } ).also { - assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + verify(exactly = 1) { + loginHistoryEventListener.onLoginCompleted(any()) + } + + assertSoftly(slot.captured) { + this.id shouldBe admin.id + this.type shouldBe PrincipalType.ADMIN this.success shouldBe false - this.principalType shouldBe PrincipalType.ADMIN } } } @@ -144,7 +179,9 @@ class AuthApiTest( body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode)) } ).also { - loginHistoryRepository.findAll() shouldHaveSize 0 + verify(exactly = 0) { + loginHistoryEventListener.onLoginCompleted(any()) + } } } @@ -168,7 +205,9 @@ class AuthApiTest( body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode)) } ).also { - loginHistoryRepository.findAll() shouldHaveSize 0 + verify(exactly = 0) { + loginHistoryEventListener.onLoginCompleted(any()) + } } } } @@ -198,10 +237,13 @@ class AuthApiTest( ).also { extraAssertions?.invoke(it) - assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history -> - history shouldHaveSize (1) - history[0].success shouldBe true - history[0].principalType shouldBe type + verify(exactly = 1) { + loginHistoryEventListener.onLoginCompleted(any()) + } + assertSoftly(slot.captured) { + this.id shouldBe id + this.type shouldBe type + this.success shouldBe true } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt deleted file mode 100644 index 1a766de2..00000000 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt +++ /dev/null @@ -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()) - } - ) - } - } - } -} -- 2.47.2 From da81474ff444cc20f48276d30ff442f7f57b47ab Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 12 Oct 2025 15:49:06 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-scripts/create-reservation-scripts.js | 92 +++++++++++----------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js index df99e730..4f33b6c1 100644 --- a/test-scripts/create-reservation-scripts.js +++ b/test-scripts/create-reservation-scripts.js @@ -95,42 +95,46 @@ export default function (data) { let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () { - const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) - if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) { - sleep(20) - let schedules = parseIdToString(res).data.schedules + let searchTrial = 0 + let schedules - if (!schedules || schedules.length === 0) { - console.log("일정 없음. 1회 재시도") - const storeId = randomItem(stores).storeId - const targetDate = randomDayBetween(1, 4) - const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) - if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) { - schedules = parseIdToString(res).data.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) { - 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 - } + 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 } }) @@ -139,33 +143,33 @@ export default function (data) { } 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 })) { + if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) { const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) - if (themeInfoRes.status !== 200) { - throw new Error("테마 상세 조회 실패") - } selectedThemeInfo = parseIdToString(themeInfoRes).data isScheduleHeld = true + } else { + const errorCode = body.code + const errorMessage = body.message + + console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`) } }) - if (!isScheduleHeld) { - console.log("일정 점유 실패") + 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 })) { + if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) { throw new Error("회원 연락처 조회 과정에서 예외 발생") } @@ -210,14 +214,12 @@ export default function (data) { }) if (!isPendingReservationCreated) { - console.log("회원의 예약 정보 입력 중 페이지 이탈") return; } group(`결제 및 예약 확정`, function () { // 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트 if (Math.random() <= 0.2) { - console.log("결제 페이지에서의 이탈") return } @@ -236,7 +238,7 @@ export default function (data) { sleep(30) 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 break } @@ -253,7 +255,7 @@ export default function (data) { sleep(10) 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("예약 확정 성공") return } -- 2.47.2