fix(home-following): inbox 중복 insert 처리를 보강한다

This commit is contained in:
2026-06-26 02:49:01 +09:00
parent e598d2058d
commit 670b3d9f54
4 changed files with 158 additions and 39 deletions

View File

@@ -12,6 +12,22 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
sourceKey: String
): Boolean
@Query(
value = """
select member_id
from home_following_news_inbox
where news_type = :newsType
and source_key = :sourceKey
and member_id in :memberIds
""",
nativeQuery = true
)
fun findExistingMemberIds(
@Param("newsType") newsType: String,
@Param("sourceKey") sourceKey: String,
@Param("memberIds") memberIds: Collection<Long>
): List<Long>
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """

View File

@@ -5,23 +5,33 @@ import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInbo
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Repository
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@Repository
class HomeFollowingNewsInboxPersistenceAdapter(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager? = null
) : HomeFollowingNewsInboxPort {
@Transactional
private val transactionTemplate = transactionManager?.let {
TransactionTemplate(it).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
if (records.isEmpty()) {
return 0
}
return records
val distinctRecords = records
.distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) }
.sumOf { record -> insertIgnore(record) }
return insertWithRetry(distinctRecords)
}
@Transactional
@@ -33,30 +43,52 @@ class HomeFollowingNewsInboxPersistenceAdapter(
return repository.findActiveFollowerIds(creatorId)
}
private fun insertIgnore(record: HomeFollowingNewsInboxRecord): Int {
val newsType = FollowingNewsType.valueOf(record.newsType)
if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) {
private fun insertWithRetry(records: List<HomeFollowingNewsInboxRecord>): Int {
var lastFailure: DataIntegrityViolationException? = null
repeat(MAX_INSERT_ATTEMPTS) {
try {
return executeInsertAttempt(records)
} catch (ex: DataIntegrityViolationException) {
lastFailure = ex
entityManager.clear()
}
}
throw requireNotNull(lastFailure)
}
private fun executeInsertAttempt(records: List<HomeFollowingNewsInboxRecord>): Int {
return transactionTemplate?.execute { insertNewRows(records) } ?: insertNewRows(records)
}
private fun insertNewRows(records: List<HomeFollowingNewsInboxRecord>): Int {
val entities = records
.groupBy { SourceKey(newsType = it.newsType, sourceKey = it.sourceKey) }
.flatMap { (sourceKey, sourceRecords) ->
FollowingNewsType.valueOf(sourceKey.newsType)
val existingMemberIds = repository.findExistingMemberIds(
newsType = sourceKey.newsType,
sourceKey = sourceKey.sourceKey,
memberIds = sourceRecords.map { it.memberId }
).toSet()
sourceRecords
.filterNot { it.memberId in existingMemberIds }
.map { it.toEntity() }
}
if (entities.isEmpty()) {
return 0
}
return try {
repository.saveAndFlush(record.toEntity(newsType))
1
} catch (e: DataIntegrityViolationException) {
entityManager.clear()
if (repository.existsByMemberIdAndNewsTypeAndSourceKey(record.memberId, newsType, record.sourceKey)) {
0
} else {
throw e
}
}
repository.saveAll(entities)
repository.flush()
return entities.size
}
private fun HomeFollowingNewsInboxRecord.toEntity(newsType: FollowingNewsType): HomeFollowingNewsInbox {
private fun HomeFollowingNewsInboxRecord.toEntity(): HomeFollowingNewsInbox {
return HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = newsType,
newsType = FollowingNewsType.valueOf(newsType),
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
@@ -70,4 +102,13 @@ class HomeFollowingNewsInboxPersistenceAdapter(
isAdult = isAdult
)
}
private data class SourceKey(
val newsType: String,
val sourceKey: String
)
companion object {
private const val MAX_INSERT_ATTEMPTS = 2
}
}

View File

@@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import javax.persistence.EntityManager
class HomeFollowingNewsInboxPersistenceAdapterRetryTest {
@Test
@DisplayName("insertIgnoreAll은 JPA bulk insert unique 충돌 시 기존 row를 재조회하고 남은 row만 재시도한다")
fun shouldRetryRemainingRowsWhenBulkInsertConflictsWithExistingRow() {
val repository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
val entityManager = Mockito.mock(EntityManager::class.java)
val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
val sourceKey = "CREATOR_RANKING:1:2026-06-25"
Mockito.`when`(
repository.findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
).thenReturn(emptyList()).thenReturn(listOf(10L))
Mockito.`when`(repository.saveAll(Mockito.anyList<HomeFollowingNewsInbox>()))
.thenThrow(DataIntegrityViolationException("duplicate"))
val insertedCount = adapter.insertIgnoreAll(
listOf(record(memberId = 10L, creatorId = 1L, sourceKey = sourceKey))
)
assertEquals(0, insertedCount)
Mockito.verify(repository, Mockito.times(2)).findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
Mockito.verify(repository, Mockito.times(1)).saveAll(Mockito.anyList<HomeFollowingNewsInbox>())
}
private fun record(
memberId: Long,
creatorId: Long,
sourceKey: String
): HomeFollowingNewsInboxRecord {
return HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING.name,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title",
body = "body",
thumbnailImagePath = null,
rank = 1,
isAdult = false
)
}
}

View File

@@ -6,16 +6,17 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import java.time.LocalDateTime
import javax.persistence.EntityManager
@@ -48,26 +49,21 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
}
@Test
@DisplayName("insertIgnoreAll은 exists 확인 이후 발생한 중복 insert 충돌도 예외 없이 무시한다")
fun shouldIgnoreDuplicateInsertRaceAfterExistsCheck() {
val mockRepository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
val mockEntityManager = Mockito.mock(EntityManager::class.java)
val raceAdapter = HomeFollowingNewsInboxPersistenceAdapter(mockRepository, mockEntityManager)
val record = record(sourceKey = "race-source-key")
Mockito.`when`(
mockRepository.existsByMemberIdAndNewsTypeAndSourceKey(
record.memberId,
FollowingNewsType.CREATOR_RANKING,
record.sourceKey
)
).thenReturn(false, true)
Mockito.`when`(mockRepository.saveAndFlush(Mockito.any(HomeFollowingNewsInbox::class.java)))
.thenThrow(DataIntegrityViolationException("duplicate"))
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("실제 unique 중복 무시 이후 insertIgnoreAll을 호출한 트랜잭션은 커밋 가능하다")
fun shouldCommitTransactionAfterRealDuplicateCollisionIsIgnored() {
val sourceKey = "real-duplicate"
adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
entityManager.flush()
entityManager.clear()
val insertCount = raceAdapter.insertIgnoreAll(listOf(record))
val insertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
val rows = repository.findAll()
assertEquals(0, insertCount)
Mockito.verify(mockEntityManager).clear()
assertEquals(1, rows.size)
TestTransaction.flagForCommit()
assertDoesNotThrow { TestTransaction.end() }
}
@Test