Skip to content

Commit 0bc1791

Browse files
authored
Feature: 포인트 충전의 전체 rps를 개선하기위해 캐시 write-through 방식 적용 및 이벤트 브로커 구성 (#19)
* Hotfix: id가 지정된 상태로 save를 수행할경우, 엔티티 매니저가 updatable 엔티티로 인식하는 오류 발생하여 해당 사항 수정 * Feature: 포인트 충전 트래픽이 몰릴경우, rps 감소와 DB의 타임아웃 현상을 피하기위해, 포인트 충전을 write-through 전환시키고, EventQueue를 통해 lazy-write 방식 적용 * Refactor: 데몬스레드의 작업이 밀려 DB 커넥션 타임아웃이 발생하는 현상을 해결하기위해 이벤트 처리 단위수 조정 및 대기시간 조절 * HotFix: 캐시가 만료되면서, 포인트 반영이 덜된 상태로 DB를 조회하게되는 현상을 해결하기위해 만료시간 임시로 상향 - 테스트를 통해, 적절한 만료시간을 구하는 것이 타당하나, 조만간 서비스 고도화를 계획하고 있으니 해당 작업에 더 집중하기로 함.
1 parent 00b8172 commit 0bc1791

File tree

17 files changed

+315
-59
lines changed

17 files changed

+315
-59
lines changed

src/main/kotlin/io/ticketaka/api/common/infrastructure/cache/CaffeineCacheConfig.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class CaffeineCacheConfig {
1616
return Caffeine.newBuilder()
1717
.initialCapacity(10)
1818
.recordStats()
19-
.expireAfterWrite(5, TimeUnit.MINUTES)
20-
.expireAfterAccess(5, TimeUnit.MINUTES)
19+
.expireAfterWrite(30, TimeUnit.MINUTES)
20+
.expireAfterAccess(30, TimeUnit.MINUTES)
2121
.maximumSize(100)
2222
.build()
2323
}

src/main/kotlin/io/ticketaka/api/concert/domain/Concert.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package io.ticketaka.api.concert.domain
33
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
44
import jakarta.persistence.Entity
55
import jakarta.persistence.Id
6+
import jakarta.persistence.PostLoad
7+
import jakarta.persistence.PrePersist
68
import jakarta.persistence.Table
9+
import jakarta.persistence.Transient
10+
import org.springframework.data.domain.Persistable
711
import java.time.LocalDate
812

913
@Entity
@@ -12,7 +16,24 @@ class Concert(
1216
@Id
1317
val id: Long,
1418
val date: LocalDate,
15-
) {
19+
) : Persistable<Long> {
20+
@Transient
21+
private var isNew = true
22+
23+
override fun isNew(): Boolean {
24+
return isNew
25+
}
26+
27+
override fun getId(): Long {
28+
return id
29+
}
30+
31+
@PrePersist
32+
@PostLoad
33+
fun markNotNew() {
34+
isNew = false
35+
}
36+
1637
companion object {
1738
fun newInstance(date: LocalDate): Concert {
1839
return Concert(

src/main/kotlin/io/ticketaka/api/concert/domain/Seat.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import jakarta.persistence.Entity
66
import jakarta.persistence.EnumType
77
import jakarta.persistence.Enumerated
88
import jakarta.persistence.Id
9+
import jakarta.persistence.PostLoad
10+
import jakarta.persistence.PrePersist
911
import jakarta.persistence.Table
12+
import jakarta.persistence.Transient
13+
import org.springframework.data.domain.Persistable
1014
import java.math.BigDecimal
1115
import java.time.LocalDate
1216

@@ -21,7 +25,24 @@ class Seat(
2125
val price: BigDecimal,
2226
val concertId: Long,
2327
val concertDate: LocalDate,
24-
) {
28+
) : Persistable<Long> {
29+
@Transient
30+
private var isNew = true
31+
32+
override fun isNew(): Boolean {
33+
return isNew
34+
}
35+
36+
override fun getId(): Long {
37+
return id
38+
}
39+
40+
@PrePersist
41+
@PostLoad
42+
fun markNotNew() {
43+
isNew = false
44+
}
45+
2546
fun isAvailable(): Boolean {
2647
return this.status == Status.AVAILABLE
2748
}

src/main/kotlin/io/ticketaka/api/point/application/PointQueryService.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.ticketaka.api.point.application
22

3+
import io.ticketaka.api.common.exception.NotFoundException
34
import io.ticketaka.api.point.domain.Point
45
import io.ticketaka.api.point.domain.PointRepository
56
import org.springframework.cache.annotation.Cacheable
@@ -11,8 +12,12 @@ import org.springframework.transaction.annotation.Transactional
1112
class PointQueryService(
1213
private val pointRepository: PointRepository,
1314
) {
14-
@Cacheable(value = ["point"], key = "#pointId")
15+
@Cacheable(value = ["point"], key = "#pointId", sync = true)
1516
fun getPoint(pointId: Long): Point {
16-
return pointRepository.findById(pointId) ?: throw IllegalArgumentException("포인트를 찾을 수 없습니다.")
17+
return pointRepository.findById(pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
18+
}
19+
20+
fun getPointForUpdate(pointId: Long): Point {
21+
return pointRepository.findByIdForUpdate(pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
1722
}
1823
}
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,42 @@
11
package io.ticketaka.api.point.application
22

3+
import io.ticketaka.api.common.exception.NotFoundException
34
import io.ticketaka.api.point.application.dto.BalanceQueryModel
45
import io.ticketaka.api.point.application.dto.RechargeCommand
5-
import io.ticketaka.api.point.domain.PointBalanceUpdater
6+
import io.ticketaka.api.point.domain.PointBalanceCacheUpdater
67
import io.ticketaka.api.point.domain.PointRechargeEvent
8+
import io.ticketaka.api.point.domain.PointRepository
79
import io.ticketaka.api.user.application.TokenUserQueryService
810
import org.springframework.context.ApplicationEventPublisher
9-
import org.springframework.retry.annotation.Backoff
10-
import org.springframework.retry.annotation.Retryable
11-
import org.springframework.scheduling.annotation.Async
1211
import org.springframework.stereotype.Service
1312
import org.springframework.transaction.annotation.Transactional
1413

1514
@Service
16-
@Transactional(readOnly = true)
1715
class PointService(
1816
private val tokenUserQueryService: TokenUserQueryService,
1917
private val pointQueryService: PointQueryService,
20-
private val pointBalanceUpdater: PointBalanceUpdater,
18+
private val pointBalanceCacheUpdater: PointBalanceCacheUpdater,
2119
private val applicationEventPublisher: ApplicationEventPublisher,
20+
private val pointRepository: PointRepository,
2221
) {
23-
@Async
24-
@Retryable(retryFor = [Exception::class], backoff = Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000))
2522
@Transactional
2623
fun recharge(rechargeCommand: RechargeCommand) {
2724
val user = tokenUserQueryService.getUser(rechargeCommand.userId)
28-
val userPoint = pointQueryService.getPoint(user.pointId)
29-
pointBalanceUpdater.recharge(userPoint, rechargeCommand.amount)
30-
applicationEventPublisher.publishEvent(PointRechargeEvent(user.id, userPoint.id, rechargeCommand.amount))
25+
val point = pointQueryService.getPoint(user.pointId)
26+
pointBalanceCacheUpdater.recharge(point.id, rechargeCommand.amount)
27+
applicationEventPublisher.publishEvent(PointRechargeEvent(user.id, point.id, rechargeCommand.amount))
3128
}
3229

3330
fun getBalance(userId: Long): BalanceQueryModel {
3431
val user = tokenUserQueryService.getUser(userId)
3532
val point = pointQueryService.getPoint(user.pointId)
3633
return BalanceQueryModel(user.id, point.balance)
3734
}
35+
36+
@Transactional
37+
fun updateRecharge(event: PointRechargeEvent) {
38+
val point = pointRepository.findById(event.pointId) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
39+
point.recharge(event.amount)
40+
pointRepository.updateBalance(point.id, point.balance)
41+
}
3842
}

src/main/kotlin/io/ticketaka/api/point/domain/Idempotent.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import io.ticketaka.api.common.infrastructure.IdempotentKeyGenerator
44
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
55
import jakarta.persistence.Entity
66
import jakarta.persistence.Id
7+
import jakarta.persistence.PostLoad
8+
import jakarta.persistence.PrePersist
79
import jakarta.persistence.Table
10+
import jakarta.persistence.Transient
11+
import org.springframework.data.domain.Persistable
812
import java.math.BigDecimal
913

1014
@Entity
@@ -13,7 +17,24 @@ class Idempotent(
1317
@Id
1418
var id: Long,
1519
val key: String,
16-
) {
20+
) : Persistable<Long> {
21+
@Transient
22+
private var isNew = true
23+
24+
override fun isNew(): Boolean {
25+
return isNew
26+
}
27+
28+
override fun getId(): Long {
29+
return id
30+
}
31+
32+
@PrePersist
33+
@PostLoad
34+
fun markNotNew() {
35+
isNew = false
36+
}
37+
1738
companion object {
1839
fun newInstance(
1940
userId: Long,

src/main/kotlin/io/ticketaka/api/point/domain/Point.kt

+38-3
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,45 @@
11
package io.ticketaka.api.point.domain
22

3-
import io.ticketaka.api.common.domain.AbstractAggregateRoot
43
import io.ticketaka.api.common.exception.BadClientRequestException
54
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
65
import jakarta.persistence.Entity
76
import jakarta.persistence.Id
7+
import jakarta.persistence.PostLoad
8+
import jakarta.persistence.PrePersist
89
import jakarta.persistence.Table
10+
import jakarta.persistence.Transient
11+
import org.hibernate.annotations.DynamicUpdate
12+
import org.springframework.data.domain.Persistable
913
import java.math.BigDecimal
1014
import java.time.LocalDateTime
1115

1216
@Entity
17+
@DynamicUpdate
1318
@Table(name = "points")
1419
class Point protected constructor(
1520
@Id
1621
val id: Long,
1722
var balance: BigDecimal,
18-
val createTime: LocalDateTime,
23+
val createTime: LocalDateTime?,
1924
var updateTime: LocalDateTime,
20-
) : AbstractAggregateRoot() {
25+
) : Persistable<Long> {
26+
@Transient
27+
private var isNew = true
28+
29+
override fun isNew(): Boolean {
30+
return isNew
31+
}
32+
33+
override fun getId(): Long {
34+
return id
35+
}
36+
37+
@PrePersist
38+
@PostLoad
39+
fun markNotNew() {
40+
isNew = false
41+
}
42+
2143
fun recharge(amount: BigDecimal) {
2244
if (amount < BigDecimal.ZERO) throw BadClientRequestException("충전 금액은 0보다 커야 합니다.")
2345
this.balance = this.balance.plus(amount)
@@ -35,6 +57,19 @@ class Point protected constructor(
3557
}
3658

3759
companion object {
60+
fun newInstance(
61+
id: Long,
62+
balance: BigDecimal,
63+
updateTime: LocalDateTime,
64+
): Point {
65+
return Point(
66+
id = id,
67+
balance = balance,
68+
createTime = null,
69+
updateTime = updateTime,
70+
)
71+
}
72+
3873
fun newInstance(balance: BigDecimal = BigDecimal.ZERO): Point {
3974
val now = LocalDateTime.now()
4075
return Point(

src/main/kotlin/io/ticketaka/api/point/domain/PointBalanceUpdater.kt src/main/kotlin/io/ticketaka/api/point/domain/PointBalanceCacheUpdater.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package io.ticketaka.api.point.domain
22

33
import java.math.BigDecimal
44

5-
interface PointBalanceUpdater {
5+
interface PointBalanceCacheUpdater {
66
fun recharge(
7-
point: Point,
7+
pointId: Long,
88
amount: BigDecimal,
99
)
1010

src/main/kotlin/io/ticketaka/api/point/domain/PointHistory.kt

+23-2
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,43 @@ import jakarta.persistence.Entity
55
import jakarta.persistence.EnumType
66
import jakarta.persistence.Enumerated
77
import jakarta.persistence.Id
8+
import jakarta.persistence.PostLoad
9+
import jakarta.persistence.PrePersist
810
import jakarta.persistence.Table
11+
import jakarta.persistence.Transient
12+
import org.springframework.data.domain.Persistable
913
import java.math.BigDecimal
1014
import java.time.LocalDateTime
1115

1216
@Entity
1317
@Table(name = "point_histories")
1418
class PointHistory(
1519
@Id
16-
val id: Long,
20+
val id: Long = 0,
1721
@Enumerated(EnumType.STRING)
1822
val transactionType: TransactionType,
1923
val userId: Long,
2024
val pointId: Long,
2125
val amount: BigDecimal,
2226
val createTime: LocalDateTime = LocalDateTime.now(),
23-
) {
27+
) : Persistable<Long> {
28+
@Transient
29+
private var isNew = true
30+
31+
override fun isNew(): Boolean {
32+
return isNew
33+
}
34+
35+
override fun getId(): Long {
36+
return id
37+
}
38+
39+
@PrePersist
40+
@PostLoad
41+
fun markNotNew() {
42+
isNew = false
43+
}
44+
2445
enum class TransactionType {
2546
RECHARGE,
2647
CHARGE,

src/main/kotlin/io/ticketaka/api/point/domain/payment/Payment.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import io.ticketaka.api.common.domain.AbstractAggregateRoot
44
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
55
import jakarta.persistence.Entity
66
import jakarta.persistence.Id
7+
import jakarta.persistence.PostLoad
8+
import jakarta.persistence.PrePersist
79
import jakarta.persistence.Table
10+
import jakarta.persistence.Transient
11+
import org.springframework.data.domain.Persistable
812
import java.math.BigDecimal
913
import java.time.LocalDateTime
1014

@@ -17,7 +21,24 @@ class Payment(
1721
val paymentTime: LocalDateTime,
1822
val userId: Long,
1923
val pointId: Long,
20-
) : AbstractAggregateRoot() {
24+
) : AbstractAggregateRoot(), Persistable<Long> {
25+
@Transient
26+
private var isNew = true
27+
28+
override fun isNew(): Boolean {
29+
return isNew
30+
}
31+
32+
override fun getId(): Long {
33+
return id
34+
}
35+
36+
@PrePersist
37+
@PostLoad
38+
fun markNotNew() {
39+
isNew = false
40+
}
41+
2142
init {
2243
registerEvent(PaymentApprovalEvent(this, userId, pointId, amount))
2344
}

src/main/kotlin/io/ticketaka/api/point/infrastructure/InMemoryCachePointBalanceUpdater.kt src/main/kotlin/io/ticketaka/api/point/infrastructure/InMemoryPointBalanceCacheUpdater.kt

+10-8
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
package io.ticketaka.api.point.infrastructure
22

3+
import io.ticketaka.api.common.exception.NotFoundException
34
import io.ticketaka.api.point.domain.Point
4-
import io.ticketaka.api.point.domain.PointBalanceUpdater
5+
import io.ticketaka.api.point.domain.PointBalanceCacheUpdater
56
import org.springframework.cache.caffeine.CaffeineCacheManager
67
import org.springframework.stereotype.Component
78
import java.math.BigDecimal
89

910
@Component
10-
class InMemoryCachePointBalanceUpdater(
11+
class InMemoryPointBalanceCacheUpdater(
1112
private val caffeineCacheManager: CaffeineCacheManager,
12-
) : PointBalanceUpdater {
13+
) : PointBalanceCacheUpdater {
1314
override fun recharge(
14-
point: Point,
15+
pointId: Long,
1516
amount: BigDecimal,
1617
) {
17-
val cache = caffeineCacheManager.getCache("point") ?: throw IllegalStateException("point 캐시가 존재하지 않습니다.")
18-
point.recharge(amount)
19-
synchronized(this) {
20-
cache.put(point.id, point)
18+
val cache = caffeineCacheManager.getCache("point") ?: throw NotFoundException("point 캐시가 존재하지 않습니다.")
19+
synchronized(cache) {
20+
val point = cache.get(pointId, Point::class.java) ?: throw NotFoundException("포인트를 찾을 수 없습니다.")
21+
point.recharge(amount)
22+
cache.put(pointId, point)
2123
}
2224
}
2325

0 commit comments

Comments
 (0)