diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt index 7e16b326..19fe46d7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt @@ -5,47 +5,103 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager @Service class RecommendedCreatorFollowService( private val memberRepository: MemberRepository, private val creatorFollowingRepository: CreatorFollowingRepository ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional fun followCreators(member: Member, creatorIds: List) { - val distinctCreatorIds = creatorIds.distinct() - val creatorById = distinctCreatorIds - .filter { it != member.id } - .associateWith { creatorId -> - memberRepository.findCreatorByIdOrNull(creatorId) - ?: throw SodaException(messageKey = "member.validation.creator_not_found") - } + val startedAt = System.currentTimeMillis() + var savedCount = 0 + var reactivatedCount = 0 + var skippedCount = 0 - distinctCreatorIds.forEach { creatorId -> - if (creatorId == member.id) { - return@forEach - } - - val existingFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( - creatorId = creatorId, - memberId = member.id!! - ) - if (existingFollowing != null) { - if (!existingFollowing.isActive) { - existingFollowing.isNotify = true - existingFollowing.isActive = true + runCatching { + val distinctCreatorIds = creatorIds.distinct() + val creatorById = distinctCreatorIds + .filter { it != member.id } + .associateWith { creatorId -> + memberRepository.findCreatorByIdOrNull(creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") } - return@forEach - } - creatorFollowingRepository.save( - CreatorFollowing().apply { - this.member = member - creator = creatorById.getValue(creatorId) + distinctCreatorIds.forEach { creatorId -> + if (creatorId == member.id) { + skippedCount += 1 + return@forEach } + + val existingFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creatorId, + memberId = member.id!! + ) + if (existingFollowing != null) { + if (!existingFollowing.isActive) { + existingFollowing.isNotify = true + existingFollowing.isActive = true + reactivatedCount += 1 + } else { + skippedCount += 1 + } + return@forEach + } + + creatorFollowingRepository.save( + CreatorFollowing().apply { + this.member = member + creator = creatorById.getValue(creatorId) + } + ) + savedCount += 1 + } + distinctCreatorIds.size + }.onSuccess { distinctCount -> + afterCommit { + log.info( + "event=recommended_creator_follow_success " + + "memberId={} requestedCount={} distinctCount={} savedCount={} " + + "reactivatedCount={} skippedCount={} elapsedMs={}", + member.id, + creatorIds.size, + distinctCount, + savedCount, + reactivatedCount, + skippedCount, + System.currentTimeMillis() - startedAt + ) + } + }.onFailure { ex -> + log.warn( + "event=recommended_creator_follow_failure memberId={} requestedCount={} distinctCount={} elapsedMs={} error={}", + member.id, + creatorIds.size, + creatorIds.distinct().size, + System.currentTimeMillis() - startedAt, + ex.message, + ex ) + throw ex } } + + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() = action() + } + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt index e62de7ba..90909746 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt @@ -14,16 +14,21 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.dao.DataIntegrityViolationException import org.springframework.test.context.ContextConfiguration import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronizationManager import javax.persistence.EntityManager @SpringBootTest @Transactional @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@ExtendWith(OutputCaptureExtension::class) class RecommendedCreatorFollowServiceTest @Autowired constructor( private val service: RecommendedCreatorFollowService, private val memberRepository: MemberRepository, @@ -32,7 +37,7 @@ class RecommendedCreatorFollowServiceTest @Autowired constructor( ) { @Test @DisplayName("신규 크리에이터만 팔로우 저장하고 이미 팔로우/본인 id는 서버 내부에서 제외한다") - fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf() { + fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf(output: CapturedOutput) { val member = saveMember("viewer", MemberRole.USER) val newCreator = saveMember("new-creator", MemberRole.CREATOR) val followedCreator = saveMember("followed-creator", MemberRole.CREATOR) @@ -40,16 +45,39 @@ class RecommendedCreatorFollowServiceTest @Autowired constructor( entityManager.flush() entityManager.clear() + val beforeCount = TransactionSynchronizationManager.getSynchronizations().size service.followCreators( member = member, creatorIds = listOf(newCreator.id!!, followedCreator.id!!, member.id!!) ) entityManager.flush() entityManager.clear() + TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() } assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!)) assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!)) assertEquals(2, creatorFollowingRepository.findAll().size) + assertTrue(output.out.contains("event=recommended_creator_follow_success")) + assertTrue(output.out.contains("requestedCount=3")) + assertTrue(output.out.contains("savedCount=1")) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 성공 로그는 트랜잭션 커밋 후 기록한다") + fun shouldLogFollowSuccessAfterTransactionCommit(output: CapturedOutput) { + val member = saveMember("after-commit-viewer", MemberRole.USER) + val creator = saveMember("after-commit-creator", MemberRole.CREATOR) + entityManager.flush() + entityManager.clear() + + val beforeCount = TransactionSynchronizationManager.getSynchronizations().size + + service.followCreators(member = member, creatorIds = listOf(creator.id!!)) + + assertEquals(false, output.out.contains("event=recommended_creator_follow_success")) + TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() } + + assertTrue(output.out.contains("event=recommended_creator_follow_success")) } @Test @@ -119,7 +147,7 @@ class RecommendedCreatorFollowServiceTest @Autowired constructor( @Test @DisplayName("존재하지 않는 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다") - fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist() { + fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist(output: CapturedOutput) { val member = saveMember("viewer", MemberRole.USER) val validCreator = saveMember("valid-creator", MemberRole.CREATOR) entityManager.flush() @@ -131,6 +159,7 @@ class RecommendedCreatorFollowServiceTest @Autowired constructor( assertEquals("member.validation.creator_not_found", exception.messageKey) assertEquals(0, creatorFollowingRepository.findAll().size) + assertTrue(output.out.contains("event=recommended_creator_follow_failure")) } @Test