Skip to content

Feature: 포인트 충전의 전체 rps를 개선하기위해 캐시 write-through 방식 적용 및 이벤트 브로커 구성 #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 2, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ class CaffeineCacheConfig {
return Caffeine.newBuilder()
.initialCapacity(10)
.recordStats()
.expireAfterWrite(5, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
.expireAfterAccess(30, TimeUnit.MINUTES)
.maximumSize(100)
.build()
}
Expand Down
23 changes: 22 additions & 1 deletion src/main/kotlin/io/ticketaka/api/concert/domain/Concert.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package io.ticketaka.api.concert.domain
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
import java.time.LocalDate

@Entity
Expand All @@ -12,7 +16,24 @@ class Concert(
@Id
val id: Long,
val date: LocalDate,
) {
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

companion object {
fun newInstance(date: LocalDate): Concert {
return Concert(
Expand Down
23 changes: 22 additions & 1 deletion src/main/kotlin/io/ticketaka/api/concert/domain/Seat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
import java.math.BigDecimal
import java.time.LocalDate

Expand All @@ -21,7 +25,24 @@ class Seat(
val price: BigDecimal,
val concertId: Long,
val concertDate: LocalDate,
) {
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

fun isAvailable(): Boolean {
return this.status == Status.AVAILABLE
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.ticketaka.api.point.application

import io.ticketaka.api.common.exception.NotFoundException
import io.ticketaka.api.point.domain.Point
import io.ticketaka.api.point.domain.PointRepository
import org.springframework.cache.annotation.Cacheable
Expand All @@ -11,8 +12,12 @@ import org.springframework.transaction.annotation.Transactional
class PointQueryService(
private val pointRepository: PointRepository,
) {
@Cacheable(value = ["point"], key = "#pointId")
@Cacheable(value = ["point"], key = "#pointId", sync = true)
fun getPoint(pointId: Long): Point {
return pointRepository.findById(pointId) ?: throw IllegalArgumentException("포인트를 찾을 수 없습니다.")
return pointRepository.findById(pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
}

fun getPointForUpdate(pointId: Long): Point {
return pointRepository.findByIdForUpdate(pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
}
}
26 changes: 15 additions & 11 deletions src/main/kotlin/io/ticketaka/api/point/application/PointService.kt
Original file line number Diff line number Diff line change
@@ -1,38 +1,42 @@
package io.ticketaka.api.point.application

import io.ticketaka.api.common.exception.NotFoundException
import io.ticketaka.api.point.application.dto.BalanceQueryModel
import io.ticketaka.api.point.application.dto.RechargeCommand
import io.ticketaka.api.point.domain.PointBalanceUpdater
import io.ticketaka.api.point.domain.PointBalanceCacheUpdater
import io.ticketaka.api.point.domain.PointRechargeEvent
import io.ticketaka.api.point.domain.PointRepository
import io.ticketaka.api.user.application.TokenUserQueryService
import org.springframework.context.ApplicationEventPublisher
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class PointService(
private val tokenUserQueryService: TokenUserQueryService,
private val pointQueryService: PointQueryService,
private val pointBalanceUpdater: PointBalanceUpdater,
private val pointBalanceCacheUpdater: PointBalanceCacheUpdater,
private val applicationEventPublisher: ApplicationEventPublisher,
private val pointRepository: PointRepository,
) {
@Async
@Retryable(retryFor = [Exception::class], backoff = Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000))
@Transactional
fun recharge(rechargeCommand: RechargeCommand) {
val user = tokenUserQueryService.getUser(rechargeCommand.userId)
val userPoint = pointQueryService.getPoint(user.pointId)
pointBalanceUpdater.recharge(userPoint, rechargeCommand.amount)
applicationEventPublisher.publishEvent(PointRechargeEvent(user.id, userPoint.id, rechargeCommand.amount))
val point = pointQueryService.getPoint(user.pointId)
pointBalanceCacheUpdater.recharge(point.id, rechargeCommand.amount)
applicationEventPublisher.publishEvent(PointRechargeEvent(user.id, point.id, rechargeCommand.amount))
}

fun getBalance(userId: Long): BalanceQueryModel {
val user = tokenUserQueryService.getUser(userId)
val point = pointQueryService.getPoint(user.pointId)
return BalanceQueryModel(user.id, point.balance)
}

@Transactional
fun updateRecharge(event: PointRechargeEvent) {
val point = pointRepository.findById(event.pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
point.recharge(event.amount)
pointRepository.updateBalance(point.id, point.balance)
}
}
23 changes: 22 additions & 1 deletion src/main/kotlin/io/ticketaka/api/point/domain/Idempotent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import io.ticketaka.api.common.infrastructure.IdempotentKeyGenerator
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
import java.math.BigDecimal

@Entity
Expand All @@ -13,7 +17,24 @@ class Idempotent(
@Id
var id: Long,
val key: String,
) {
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

companion object {
fun newInstance(
userId: Long,
Expand Down
41 changes: 38 additions & 3 deletions src/main/kotlin/io/ticketaka/api/point/domain/Point.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
package io.ticketaka.api.point.domain

import io.ticketaka.api.common.domain.AbstractAggregateRoot
import io.ticketaka.api.common.exception.BadClientRequestException
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.hibernate.annotations.DynamicUpdate
import org.springframework.data.domain.Persistable
import java.math.BigDecimal
import java.time.LocalDateTime

@Entity
@DynamicUpdate
@Table(name = "points")
class Point protected constructor(
@Id
val id: Long,
var balance: BigDecimal,
val createTime: LocalDateTime,
val createTime: LocalDateTime?,
var updateTime: LocalDateTime,
) : AbstractAggregateRoot() {
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

fun recharge(amount: BigDecimal) {
if (amount < BigDecimal.ZERO) throw BadClientRequestException("충전 금액은 0보다 커야 합니다.")
this.balance = this.balance.plus(amount)
Expand All @@ -35,6 +57,19 @@ class Point protected constructor(
}

companion object {
fun newInstance(
id: Long,
balance: BigDecimal,
updateTime: LocalDateTime,
): Point {
return Point(
id = id,
balance = balance,
createTime = null,
updateTime = updateTime,
)
}

fun newInstance(balance: BigDecimal = BigDecimal.ZERO): Point {
val now = LocalDateTime.now()
return Point(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package io.ticketaka.api.point.domain

import java.math.BigDecimal

interface PointBalanceUpdater {
interface PointBalanceCacheUpdater {
fun recharge(
point: Point,
pointId: Long,
amount: BigDecimal,
)

Expand Down
25 changes: 23 additions & 2 deletions src/main/kotlin/io/ticketaka/api/point/domain/PointHistory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,43 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
import java.math.BigDecimal
import java.time.LocalDateTime

@Entity
@Table(name = "point_histories")
class PointHistory(
@Id
val id: Long,
val id: Long = 0,
@Enumerated(EnumType.STRING)
val transactionType: TransactionType,
val userId: Long,
val pointId: Long,
val amount: BigDecimal,
val createTime: LocalDateTime = LocalDateTime.now(),
) {
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

enum class TransactionType {
RECHARGE,
CHARGE,
Expand Down
23 changes: 22 additions & 1 deletion src/main/kotlin/io/ticketaka/api/point/domain/payment/Payment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import io.ticketaka.api.common.domain.AbstractAggregateRoot
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.Table
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
import java.math.BigDecimal
import java.time.LocalDateTime

Expand All @@ -17,7 +21,24 @@ class Payment(
val paymentTime: LocalDateTime,
val userId: Long,
val pointId: Long,
) : AbstractAggregateRoot() {
) : AbstractAggregateRoot(), Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}

override fun getId(): Long {
return id
}

@PrePersist
@PostLoad
fun markNotNew() {
isNew = false
}

init {
registerEvent(PaymentApprovalEvent(this, userId, pointId, amount))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
package io.ticketaka.api.point.infrastructure

import io.ticketaka.api.common.exception.NotFoundException
import io.ticketaka.api.point.domain.Point
import io.ticketaka.api.point.domain.PointBalanceUpdater
import io.ticketaka.api.point.domain.PointBalanceCacheUpdater
import org.springframework.cache.caffeine.CaffeineCacheManager
import org.springframework.stereotype.Component
import java.math.BigDecimal

@Component
class InMemoryCachePointBalanceUpdater(
class InMemoryPointBalanceCacheUpdater(
private val caffeineCacheManager: CaffeineCacheManager,
) : PointBalanceUpdater {
) : PointBalanceCacheUpdater {
override fun recharge(
point: Point,
pointId: Long,
amount: BigDecimal,
) {
val cache = caffeineCacheManager.getCache("point") ?: throw IllegalStateException("point 캐시가 존재하지 않습니다.")
point.recharge(amount)
synchronized(this) {
cache.put(point.id, point)
val cache = caffeineCacheManager.getCache("point") ?: throw NotFoundException("point 캐시가 존재하지 않습니다.")
synchronized(cache) {
val point = cache.get(pointId, Point::class.java) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
point.recharge(amount)
cache.put(pointId, point)
}
}

Expand Down
Loading
Loading