From ae68886bdb74ceec915d8d48d2b4b31e065bee81 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 27 Mar 2026 21:37:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(content-preference):=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=84=A0=ED=98=B8=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=83=9D=EC=84=B1=20=EC=A0=95=EC=B1=85=EC=9D=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EA=B0=92=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20260327_멤버콘텐츠선호신규생성정책수정.md | 46 ++++++++++ .../MemberContentPreferenceService.kt | 59 +++++++++---- .../MemberContentPreferenceIntegrationTest.kt | 62 +++++++++++++- .../MemberContentPreferenceServiceTest.kt | 83 +++++++++++++++++-- 4 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 docs/20260327_멤버콘텐츠선호신규생성정책수정.md diff --git a/docs/20260327_멤버콘텐츠선호신규생성정책수정.md b/docs/20260327_멤버콘텐츠선호신규생성정책수정.md new file mode 100644 index 00000000..f5d3ffa9 --- /dev/null +++ b/docs/20260327_멤버콘텐츠선호신규생성정책수정.md @@ -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 검증으로 대체했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt index 857d8e81..e9a90ca8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt index 319e337f..8b765bca 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt @@ -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() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt index b79951e0..bb2c86fe 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt @@ -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