feat(recommend): 추천 크리에이터 동시 팔로우 서비스를 추가한다

This commit is contained in:
2026-06-01 10:19:38 +09:00
parent 82b2eb75d4
commit 8300b1875c
4 changed files with 294 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
-- Phase 5: 추천 크리에이터 동시 팔로우 중복 방지 운영 DB 반영 SQL
-- 목적: creator_following 테이블의 동일 회원/크리에이터 중복 row를 정리하고 유니크 제약을 추가한다.
-- 주의: 운영 반영 전 아래 중복 조회 결과를 검토하고, 삭제 대상 row가 운영 정책상 보존 대상인지 확인한다.
-- 1. 중복 데이터 사전 점검
select
member_id,
creator_id,
count(*) as duplicate_count,
group_concat(id order by id asc) as duplicate_ids
from creator_following
group by member_id, creator_id
having count(*) > 1;
-- 2. 중복 row 정리
-- 동일 member_id/creator_id 조합에서 가장 작은 id 1개만 유지한다.
-- 유지 row는 중복 row 중 하나라도 활성 상태였으면 활성 상태로 보정한다.
update creator_following keep_cf
join (
select
member_id,
creator_id,
min(id) as keep_id,
max(case when is_active = true then 1 else 0 end) as any_active,
max(case when is_notify = true then 1 else 0 end) as any_notify
from creator_following
group by member_id, creator_id
having count(*) > 1
) duplicate_cf on keep_cf.id = duplicate_cf.keep_id
set
keep_cf.is_active = duplicate_cf.any_active = 1,
keep_cf.is_notify = duplicate_cf.any_notify = 1;
delete duplicate_cf
from creator_following duplicate_cf
join (
select
member_id,
creator_id,
min(id) as keep_id
from creator_following
group by member_id, creator_id
having count(*) > 1
) keep_cf on duplicate_cf.member_id = keep_cf.member_id
and duplicate_cf.creator_id = keep_cf.creator_id
and duplicate_cf.id <> keep_cf.keep_id;
-- 3. 중복 정리 결과 재확인: 결과가 없어야 한다.
select
member_id,
creator_id,
count(*) as duplicate_count,
group_concat(id order by id asc) as duplicate_ids
from creator_following
group by member_id, creator_id
having count(*) > 1;
-- 4. 유니크 제약 추가
alter table creator_following
add constraint uk_creator_following_member_creator unique (member_id, creator_id);

View File

@@ -6,9 +6,19 @@ import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
data class CreatorFollowing(
@Table(
uniqueConstraints = [
UniqueConstraint(
name = "uk_creator_following_member_creator",
columnNames = ["member_id", "creator_id"]
)
]
)
class CreatorFollowing(
var isNotify: Boolean = true,
var isActive: Boolean = true
) : BaseEntity() {

View File

@@ -0,0 +1,51 @@
package kr.co.vividnext.sodalive.v2.recommend.application
import kr.co.vividnext.sodalive.common.SodaException
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.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class RecommendedCreatorFollowService(
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository
) {
@Transactional
fun followCreators(member: Member, creatorIds: List<Long>) {
val distinctCreatorIds = creatorIds.distinct()
val creatorById = distinctCreatorIds
.filter { it != member.id }
.associateWith { creatorId ->
memberRepository.findCreatorByIdOrNull(creatorId)
?: throw SodaException(messageKey = "member.validation.creator_not_found")
}
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
}
return@forEach
}
creatorFollowingRepository.save(
CreatorFollowing().apply {
this.member = member
creator = creatorById.getValue(creatorId)
}
)
}
}
}

View File

@@ -0,0 +1,172 @@
package kr.co.vividnext.sodalive.v2.recommend.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class RecommendedCreatorFollowServiceTest @Autowired constructor(
private val service: RecommendedCreatorFollowService,
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager
) {
@Test
@DisplayName("신규 크리에이터만 팔로우 저장하고 이미 팔로우/본인 id는 서버 내부에서 제외한다")
fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf() {
val member = saveMember("viewer", MemberRole.USER)
val newCreator = saveMember("new-creator", MemberRole.CREATOR)
val followedCreator = saveMember("followed-creator", MemberRole.CREATOR)
saveFollowing(member = member, creator = followedCreator)
entityManager.flush()
entityManager.clear()
service.followCreators(
member = member,
creatorIds = listOf(newCreator.id!!, followedCreator.id!!, member.id!!)
)
entityManager.flush()
entityManager.clear()
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!))
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!))
assertEquals(2, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("비활성 팔로우 이력이 있으면 신규 row를 만들지 않고 다시 활성화한다")
fun shouldReactivateInactiveFollowingWithoutCreatingDuplicateRow() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("reactivate-creator", MemberRole.CREATOR)
val inactiveFollowing = saveFollowing(member = member, creator = creator).apply {
isNotify = false
isActive = false
}
entityManager.flush()
entityManager.clear()
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
entityManager.flush()
entityManager.clear()
val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
assertNotNull(reactivatedFollowing)
assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id)
assertTrue(reactivatedFollowing.isNotify)
assertTrue(reactivatedFollowing.isActive)
assertEquals(1, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("이미 활성 팔로우 중이면 알림 설정을 바꾸지 않고 그대로 둔다")
fun shouldKeepActiveExistingFollowingNotificationSetting() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("active-creator", MemberRole.CREATOR)
val existingFollowing = saveFollowing(member = member, creator = creator).apply {
isNotify = false
isActive = true
}
entityManager.flush()
entityManager.clear()
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
entityManager.flush()
entityManager.clear()
val unchangedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
assertNotNull(unchangedFollowing)
assertEquals(existingFollowing.id, unchangedFollowing!!.id)
assertFalse(unchangedFollowing.isNotify)
assertTrue(unchangedFollowing.isActive)
assertEquals(1, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("같은 회원과 크리에이터 팔로우 row는 중복 저장할 수 없다")
fun shouldRejectDuplicateFollowingRowsForSameMemberAndCreator() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("duplicate-creator", MemberRole.CREATOR)
saveFollowing(member = member, creator = creator)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(DataIntegrityViolationException::class.java) {
saveFollowing(member = member, creator = creator)
entityManager.flush()
}
assertNotNull(exception)
}
@Test
@DisplayName("존재하지 않는 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist() {
val member = saveMember("viewer", MemberRole.USER)
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(SodaException::class.java) {
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, 999_999L))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
assertEquals(0, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("크리에이터가 아닌 회원 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
fun shouldFailAllAndSaveNothingWhenAnyMemberIdIsNotCreator() {
val member = saveMember("viewer", MemberRole.USER)
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
val nonCreator = saveMember("non-creator", MemberRole.USER)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(SodaException::class.java) {
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, nonCreator.id!!))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
assertEquals(0, creatorFollowingRepository.findAll().size)
}
private fun saveMember(seed: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
role = role
)
)
}
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
return creatorFollowingRepository.saveAndFlush(
CreatorFollowing().apply {
this.member = member
this.creator = creator
}
)
}
}