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

Merged
pricelees merged 9 commits from refactor/#61 into main 2025-10-14 00:28:45 +00:00
13 changed files with 373 additions and 202 deletions

View File

@ -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}" }

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}
jpa:
open-in-view: false
properties:
hibernate:
jdbc:
batch_size: ${JDBC_BATCH_SIZE:100}
order_inserts: true
management:
endpoints:

View File

@ -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<LoginHistoryEvent>
init {
beforeTest {
slot = slot<LoginHistoryEvent>()
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<LoginHistoryEvent>
beforeTest {
slot = slot<LoginHistoryEvent>()
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<LoginHistoryEvent>())
}
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<LoginHistoryEvent>())
}
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<LoginHistoryEvent>())
}
}
}
@ -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<LoginHistoryEvent>())
}
}
}
}
@ -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<LoginHistoryEvent>())
}
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() {
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) {

View File

@ -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,25 +90,33 @@ 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
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(0, 6)
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;
}
if (schedules && schedules.length > 0) {
group(`일부 테마는 상세 조회`, function () {
const themesByStoreAndDate = schedules.map(s => s.theme)
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
@ -126,28 +136,34 @@ export default function (data) {
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}`)
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}`)
}
})
let isPendingReservationCreated = false
if (!isScheduleHeld || !selectedThemeInfo) {
return
}
let isPendingReservationCreated = false
group(`예약 정보 입력 페이지`, function () {
let userName, userContact
group(`회원 연락처 조회`, function () {
@ -198,14 +214,12 @@ export default function (data) {
})
if (!isPendingReservationCreated) {
console.log("회원의 예약 정보 입력 중 페이지 이탈")
return;
}
group(`결제 및 예약 확정`, function () {
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
if (Math.random() <= 0.2) {
console.log("결제 페이지에서의 이탈")
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:
init:
mode: always
schema-locations: classpath:schema/schema-mysql.sql
schema-locations: classpath:schema/schema-h2.sql
management:
endpoints: