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

@@ -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 검증으로 대체했다.

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