fix(content-preference): 멤버 콘텐츠 선호 신규 생성 정책을 저장값 기준으로 정리한다

This commit is contained in:
2026-03-27 21:37:59 +09:00
parent a87bd147dc
commit ae68886bdb
4 changed files with 223 additions and 27 deletions

View File

@@ -23,6 +23,11 @@ class MemberContentPreferenceService(
private val countryContext: CountryContext,
private val cacheManager: CacheManager
) {
private data class PreferenceSeed(
val isAdultContentVisible: Boolean,
val contentType: ContentType
)
companion object {
private const val RECOMMEND_LIVE_CACHE_NAME = "cache_ttl_3_hours"
private const val RECOMMEND_LIVE_CACHE_KEY_PREFIX = "getRecommendLive:"
@@ -32,6 +37,19 @@ class MemberContentPreferenceService(
@Transactional
fun initializeDefaultPreference(member: Member): MemberContentPreference {
return initializeDefaultPreference(
member = member,
seed = PreferenceSeed(
isAdultContentVisible = false,
contentType = ContentType.ALL
)
)
}
private fun initializeDefaultPreference(
member: Member,
seed: PreferenceSeed
): MemberContentPreference {
val memberId = requireMemberId(member)
val existingPreference = repository.findByMemberId(memberId)
@@ -49,8 +67,8 @@ class MemberContentPreferenceService(
val now = LocalDateTime.now()
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdultContentVisible = seed.isAdultContentVisible,
contentType = seed.contentType,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
@@ -69,24 +87,15 @@ class MemberContentPreferenceService(
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
val hasChanged = if (isAdultContentVisible != null || contentType != null) {
applyRequestValues(
preference = preference,
val preference = initializeDefaultPreference(
member = member,
seed = resolvePreferenceSeedForQuery(
member = member,
countryCode = countryCode,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
} else {
false
}
if (hasChanged) {
evictRecommendLiveCacheAfterCommit(requireMemberId(member))
}
)
val countryCode = resolveCountryCode(member)
return toViewerContentPreference(
countryCode = countryCode,
@@ -169,6 +178,24 @@ class MemberContentPreferenceService(
}
}
private fun resolvePreferenceSeedForQuery(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): PreferenceSeed {
if (member.auth == null) {
return PreferenceSeed(
isAdultContentVisible = false,
contentType = ContentType.ALL
)
}
return PreferenceSeed(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL
)
}
private fun applyRequestValues(
preference: MemberContentPreference,
member: Member,

View File

@@ -47,8 +47,8 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
}
@Test
@DisplayName("legacy 파라미터 최초 호출 시 row를 생성하고 같은 흐름에서 저장값 조회에 즉시 반영한다")
fun shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall() {
@DisplayName("미인증 사용자는 row 미존재 시 legacy 파라미터와 무관하게 false/ALL로 생성한다")
fun shouldCreateDefaultPreferenceForUnauthenticatedMemberRegardlessOfLegacyParams() {
val member = saveNonForcedMember("legacy-user")
countryContext.setCountryCode("US")
@@ -61,12 +61,39 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
)
val stored = service.getStoredPreference(member)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
assertFalse(stored.isAdult)
}
@Test
@DisplayName("인증 사용자는 row 미존재 + legacy 파라미터 미전달 시 true/ALL로 생성된다")
fun shouldCreateTrueAndAllWhenAuthenticatedMemberHasNoLegacyParams() {
val member = saveNonForcedMember("auth-no-legacy")
countryContext.setCountryCode("US")
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
assertEquals(null, preferenceRepository.findByMemberId(member.id!!))
val resolved = service.resolveForQuery(
member = reloadedMember,
isAdultContentVisible = null,
contentType = null
)
val stored = service.getStoredPreference(reloadedMember)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.MALE, resolved.contentType)
assertEquals(ContentType.ALL, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.MALE, stored.contentType)
assertEquals(ContentType.ALL, stored.contentType)
assertTrue(stored.isAdult)
}
@@ -131,6 +158,33 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
assertTrue(resolved.isAdult)
}
@Test
@DisplayName("기존 row가 있으면 legacy 파라미터를 보내도 저장값을 그대로 사용한다")
fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {
val member = saveNonForcedMember("existing-pref")
countryContext.setCountryCode("US")
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
service.updatePreference(
member = reloadedMember,
isAdultContentVisible = false,
contentType = ContentType.FEMALE
)
val resolved = service.resolveForQuery(
member = reloadedMember,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
val stored = service.getStoredPreference(reloadedMember)
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.FEMALE, resolved.contentType)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.FEMALE, stored.contentType)
}
@Test
@DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다")
fun shouldMarkAdultVisibleAfterAuthVerify() {

View File

@@ -257,6 +257,75 @@ class MemberContentPreferenceServiceTest {
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("row 미존재 + 인증 사용자의 legacy 조회 파라미터는 초기 생성값으로 반영된다")
fun shouldSeedPreferenceFromLegacyParamsWhenRowMissingAndAuthenticated() {
val member = createMember(id = 2100L, withAuth = true)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2100L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2100L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2100L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(result.isAdult)
}
@Test
@DisplayName("row 미존재 + 인증 사용자는 legacy 파라미터가 없으면 true/ALL로 초기 생성된다")
fun shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedWithoutParams() {
val member = createMember(id = 2101L, withAuth = true)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2101L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2101L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2101L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = null,
contentType = null
)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertTrue(result.isAdult)
}
@Test
@DisplayName("row 미존재 + 미인증 사용자는 legacy 파라미터와 무관하게 false/ALL로 초기 생성된다")
fun shouldSeedPreferenceToFalseAndAllWhenRowMissingAndUnauthenticated() {
val member = createMember(id = 2102L)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2102L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(2102L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(2102L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
assertFalse(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertFalse(result.isAdult)
}
@Test
@DisplayName("초기 row 생성 경쟁 시 잠금 이후 재조회한 row를 반환한다")
fun shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock() {
@@ -349,8 +418,8 @@ class MemberContentPreferenceServiceTest {
}
@Test
@DisplayName("contentType 미전달 조회는 기존 contentType을 유지한다")
fun shouldKeepStoredContentTypeWhenContentTypeIsNotProvided() {
@DisplayName("기존 row가 있으면 legacy 조회 파라미터를 무시하고 저장값을 그대로 사용한다")
fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {
val member = createMember(id = 21L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = false,
@@ -365,16 +434,16 @@ class MemberContentPreferenceServiceTest {
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = null
contentType = ContentType.MALE
)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(result.isAdultContentVisible)
assertFalse(result.isAdultContentVisible)
}
@Test
@DisplayName("legacy 조회 파라미터로 저장값이 바뀌면 추천 라이브 캐시를 무효화")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery() {
@DisplayName("기존 row가 있으면 legacy 조회 파라미터로 캐시를 무효화하지 않는")
fun shouldNotEvictRecommendLiveCacheWhenLegacyResolveForQueryIsIgnored() {
val member = createMember(id = 25L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
@@ -386,7 +455,7 @@ class MemberContentPreferenceServiceTest {
contentType = null
)
verifyRecommendLiveCacheEvicted(25L)
verifyRecommendLiveCacheNotEvicted(25L)
}
@Test