From 670b3d9f542a22088ffe2addabb77dd76fb664f5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 26 Jun 2026 02:49:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(home-following):=20inbox=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20insert=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeFollowingNewsInboxJpaRepository.kt | 16 ++++ ...omeFollowingNewsInboxPersistenceAdapter.kt | 81 ++++++++++++++----- ...ingNewsInboxPersistenceAdapterRetryTest.kt | 66 +++++++++++++++ ...ollowingNewsInboxPersistenceAdapterTest.kt | 34 ++++---- 4 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterRetryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt index 4d75d52d..479eedb2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt @@ -12,6 +12,22 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository + ): List + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt index b727b36f..b2c5896b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt @@ -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): 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): 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): Int { + return transactionTemplate?.execute { insertNewRows(records) } ?: insertNewRows(records) + } + + private fun insertNewRows(records: List): 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 + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterRetryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterRetryTest.kt new file mode 100644 index 00000000..a41ec5b9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterRetryTest.kt @@ -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())) + .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()) + } + + 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 + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt index 50aebc10..d4f79f2e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt @@ -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