fix(content-preference): 멤버 콘텐츠 선호 신규 생성 정책을 저장값 기준으로 정리한다
This commit is contained in:
46
docs/20260327_멤버콘텐츠선호신규생성정책수정.md
Normal file
46
docs/20260327_멤버콘텐츠선호신규생성정책수정.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 20260327 멤버 콘텐츠 선호 신규 생성 정책 수정
|
||||
|
||||
## 목적
|
||||
- `resolveForQuery` 레거시 파라미터를 기존 row 갱신 용도로 사용하지 않고, **row 미존재 최초 생성 시에만** 제한적으로 사용한다.
|
||||
- 최종 목표인 "MemberContentPreference 저장값만 조회에 사용" 방향으로 정책을 단순화한다.
|
||||
|
||||
## 최종 정책
|
||||
- [x] `MemberContentPreference` 없음 + `member.auth != null`
|
||||
- 요청 파라미터(`isAdultContentVisible`, `contentType`)가 있으면 전달값으로 생성한다.
|
||||
- 요청 파라미터가 없으면 `isAdultContentVisible = true`, `contentType = ContentType.ALL`로 생성한다.
|
||||
- [x] `MemberContentPreference` 없음 + `member.auth == null`
|
||||
- `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 생성한다.
|
||||
- [x] `MemberContentPreference` 있음
|
||||
- `resolveForQuery`로 들어온 요청 파라미터는 무시하고 저장값만 사용한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `MemberContentPreferenceService` 생성 경로(`initializeDefaultPreference`)가 초기값을 정책 기반으로 받을 수 있도록 수정
|
||||
- QA: `resolveForQuery` 호출 시 row 유/무에 따른 생성값이 테스트에서 일치하는지 확인
|
||||
- [x] `resolveForQuery`에서 기존 row에 대한 레거시 파라미터 반영/캐시 무효화 제거
|
||||
- QA: 기존 row + 파라미터 입력 시 저장값 불변 및 캐시 미무효화 테스트 통과
|
||||
- [x] 관련 단위/통합 테스트 갱신
|
||||
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
|
||||
- [x] 회귀 검증 실행
|
||||
- QA: `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build` 성공
|
||||
|
||||
## 구현 완료 후 기록
|
||||
### 1차 구현
|
||||
- 무엇을:
|
||||
- `MemberContentPreferenceService`에 `PreferenceSeed`를 도입해 row 미존재 시 초기 생성값을 호출 목적에 맞게 주입하도록 변경했다.
|
||||
- `resolveForQuery`는 더 이상 기존 row를 요청 파라미터로 갱신하지 않고, 저장값 조회 전용으로 동작하도록 수정했다.
|
||||
- row 미존재 시 seed 정책을 다음과 같이 반영했다.
|
||||
- `member.auth != null` + legacy 파라미터 존재: 전달값 기반 생성
|
||||
- `member.auth != null` + legacy 파라미터 미존재: `true/ALL` 생성
|
||||
- `member.auth == null`: 파라미터와 무관하게 `false/ALL` 생성
|
||||
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책에 맞게 갱신/추가했다.
|
||||
- 왜:
|
||||
- 기존 row를 조회 API 파라미터로 계속 갱신하면 "저장값 단일 기준" 목표와 충돌하므로, 레거시 파라미터 역할을 row 최초 생성 시점으로 한정하기 위해서다.
|
||||
- 기존 회원 중 row 미존재 사용자의 초기 생성 경로를 명시적으로 제어해 운영 일관성을 확보하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 정책 관련 단위/통합 테스트 통과.
|
||||
- 전체 회귀 검증(`test`, `ktlintCheck`, `build`) 통과.
|
||||
- `.kt` 대상 LSP 서버가 현재 환경에 없어 Kotlin LSP 진단은 수행 불가였고, 대신 Gradle 검증으로 대체했다.
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user