Files
sodalive-backend-spring-boot/docs/20260325_콘텐츠조회설정서버저장전환.md

59 KiB

20260325 콘텐츠 조회 설정 서버 저장 전환

목적

  • 클라이언트 요청 파라미터(isAdultContentVisible, contentType) 중심 조회 방식을 서버 저장값 중심 조회 방식으로 전환한다.
  • 국가별(한국/해외) 성인 노출 정책을 분리해 적용한다.
  • 구버전 클라이언트 호환을 위해 기존 isAdultContentVisible 파라미터를 받는 API 전체에서 전달 파라미터를 저장한다.
  • 신규 회원은 회원가입 시 기본값을 선저장하고, 기존 회원은 호환 대상 API 호출 시 저장(row 생성/갱신) 후 저장값 기반으로 조회한다.
  • 설정 변경 시각을 관리해 추적 가능성을 확보한다.

핵심 요구사항 정리

  • isAdultContentVisible 기본값은 false로 변경한다. (현재 다수 컨트롤러에서 true 기본)
  • contentType은 콘텐츠 조회 성향값으로 사용한다. (ALL, FEMALE, MALE)
  • 남성향(MALE)여성 크리에이터(auth.gender=0) 콘텐츠만 조회한다.
  • 여성향(FEMALE)남성 크리에이터(auth.gender=1) 콘텐츠만 조회한다.
  • 호환 API 저장과 별도로 직접 설정 API(가칭 PATCH /member/content-preference)를 생성한다.
  • 국가 판별 우선순위:
    1. 회원 ID 강제 매핑 우선 적용
      • member.id in [16, 17]countryCode = "KR"
      • member.id in [2, 29721, 32050, 40850]countryCode = "JP"
    2. 그 외 회원은 CloudFront-Viewer-Country 기반으로 결정
    3. 헤더 누락/오작동 시 countryCode = "KR" fallback 적용
  • 한국(countryCode == "KR") 정책:
    • 저장 시: member.auth != null일 때만 전달값 반영
    • 조회 시: isAdult = isAdultContentVisible && (member.auth != null)로 계산하고, contentType 필터를 함께 적용
  • 해외(한국 외) 정책:
    • 저장 시: 전달받은 값 그대로 저장
    • 조회 시: isAdult = isAdultContentVisible로 계산하고, contentType 필터를 함께 적용
  • AuthController.authVerify 본인인증 성공 시 isAdultContentVisible = true로 즉시 저장한다.
  • 주의: 조회 판단은 서버 저장값 기준으로 수행하며, 구버전 호환 구간에서는 기존 파라미터 수신 후 저장값을 갱신해 동일 정책을 적용한다.
  • 기존 회원(설정 row 미존재)은 호환 대상 API 호출 시 저장 조건에 따라 row를 생성/갱신하고, 생성 즉시 저장값 기준 조회를 적용한다.
  • /member/info 응답에 아래 필드를 추가한다.
    • countryCode
    • isAdultContentVisible
    • contentType

네이밍 정책 결정 (이번 작업에서 확정)

  • 외부 API 파라미터명은 유지: isAdultContentVisible, contentType
    • 이유: 기존 클라이언트 호환성과 현재 코드베이스 전역 사용량이 매우 큼.
    • 적용: isAdultContentVisible 파라미터 수신 API 전체에서 기존 키 그대로 수신/저장.
  • 내부 도메인 캡슐화 객체를 추가: (예시) ViewerContentPreference
    • 필드명은 기존과 동일(isAdultContentVisible, contentType)로 유지해 해석 혼선을 최소화.
    • 이유: 필드명 변경으로 발생하는 전역 대규모 리네임 리스크를 피하면서도, 도메인 객체로 의미를 명확화.
  • 장기적으로 파라미터명 변경이 필요하면 alias 전략으로 단계적 전환(이번 범위에서는 미적용).
  • 최종 결정: 이번 변경 범위에서는 리네임을 하지 않는다.

생성 시점 결정 (회원가입 시 선저장 vs 필요시 생성)

  • 신규 회원가입 시 선저장(Eager) 채택
    • 이유:
      • 서버 저장값 기반 조회로 전환 시 null/미생성 분기 제거로 일관성 향상
      • 조회 경로에서 동적 생성(Lazy) 경쟁 조건/추가 트랜잭션 복잡도 감소
      • /member/info 즉시 응답 가능
  • 기존 회원 데이터는 마이그레이션 또는 최초 조회 시 안전한 보정 로직(백필)으로 누락 방지

변경 대상 상세 맵

1) 저장 모델/도메인 계층

  • 사용자 조회설정 저장 엔티티 신설 (예: MemberContentPreference)
    • 후보 경로: src/main/kotlin/kr/co/vividnext/sodalive/member/...
    • 필드(안):
      • member (1:1, unique)
      • isAdultContentVisible: Boolean = false
      • contentType: ContentType = ContentType.ALL
      • adultContentVisibilityChangedAt: LocalDateTime?
      • contentTypeChangedAt: LocalDateTime?
      • createdAt, updatedAt (BaseEntity)
  • Repository/QueryRepository/Service 추가
    • 저장/조회/업데이트 정책 캡슐화
    • 국가별 저장 정책/조회 정책 계산 함수 제공

2) 회원가입/소셜가입 기본값 선저장

  • 일반 가입
    • MemberService.signUpV2 (MemberService.kt:126)
    • MemberService.signUp (MemberService.kt:175)
  • 소셜 가입
    • MemberService.findOrRegister(...) 오버로드 4개
    • Google/Kakao/Apple/Line 각 신규 회원 생성 지점
  • 기본값 저장
    • isAdultContentVisible = false
    • contentType = ContentType.ALL
    • changedAt 초기값 = 생성 시각

3) 기존 isAdultContentVisible 파라미터 수신 API 전체 호환 저장

  • 호환 대상 API(4-1, 4-2 목록)에서 기존 파라미터 수신 후 저장 처리
  • 대표 진입점 구현/검증
    • src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt
    • src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt
    • src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
    • src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
  • contentType를 받지 않는 API 처리 규칙
    • 대상: LiveRoomController.kt, ExplorerController.kt
    • isAdultContentVisible만 저장하고 contentType은 기존 저장값 유지(미존재 시 ContentType.ALL)
  • 기존 회원 누락 row 보정 규칙
    • 호환 대상 API 호출 시 row 미존재이면 기본값 row 생성 후 저장 정책 적용
  • 저장 정책 구현
    • 한국: member.auth != null일 때만 전달값 반영
    • 해외: 전달값 그대로 반영
  • 파라미터 미전달 시 저장값을 조회해 사용

3-1) 직접 설정 API 신설 (호환 저장과 분리)

  • 현행 점검: 직접 설정 API 부재 확인
    • 점검 결과: MemberController, AuthController, 조회 컨트롤러에 isAdultContentVisible+contentType를 직접 저장하는 전용 엔드포인트가 없다.
    • 현재는 조회 API 파라미터 전달 방식(legacy 호환)만 존재한다.
  • 직접 설정 API 추가
    • 가칭: PATCH /member/content-preference
    • Request: isAdultContentVisible, contentType (둘 중 하나 이상 필수)
    • Response: 저장 후 최신 isAdultContentVisible, contentType
    • countryCode는 직접 설정 API가 아닌 /member/info 응답에서 제공한다.
    • changedAt은 변경 추적용 내부 필드이며 직접 설정 API 응답에는 포함하지 않는다.
    • 메서드 선택 근거(PATCH):
      • 기존 member 갱신 API는 PUT/POST 위주이지만, 본 API는 "두 필드 중 일부만 변경" 계약을 URL/메서드 수준에서 명확히 드러내기 위해 PATCH를 사용한다.
      • isAdultContentVisible/contentType 중 일부만 변경하는 부분 업데이트가 기본 시나리오다.
      • 전송되지 않은 필드는 기존 저장값을 유지해야 하므로 전체 교체(PUT)보다 부분 갱신 의미가 명확하다.
      • 요청은 "전달된 필드만 대입"으로 설계해 동일 payload 재요청 시 동일 상태를 보장한다.
  • 직접 설정 API 저장 규칙
    • 회원 설정 row가 없으면 기본값(false, ALL)으로 생성 후 요청값 반영
    • 국가 결정은 강제 매핑(KR/JP) → 접속 국가 헤더 → KR fallback 순서를 따른다.
    • isAdultContentVisible/contentType 변경 시 changedAt 갱신 규칙(동일값 재저장 미갱신)을 동일 적용한다.

3-2) 본인인증 성공 연동 저장

  • AuthController.authVerify 성공 시 isAdultContentVisible = true 저장
    • 대상: src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt
    • 구현: service.authenticate(...) 성공 직후 선호 저장 서비스 호출
  • 저장 시나리오
    • 설정 row 미존재 시 기본 row 생성 후 isAdultContentVisible = true 반영
    • contentType은 기존 저장값 유지(미존재 시 ALL)
    • adultContentVisibilityChangedAt 갱신, 동일값이면 미갱신
  • 실패/차단 시나리오
    • isBlockAuth(...)로 차단되어 예외가 발생한 경우 저장하지 않는다.
    • 본인인증 실패 예외 흐름에서는 저장하지 않는다.

4) 콘텐츠/라이브/채팅 조회 경로를 저장값 기반으로 전환

4-1. 홈/라이브 진입점

  • /api/home 계열
    • HomeController.kt, HomeService.kt
  • /api/live
    • LiveApiController.kt, LiveApiService.kt
    • 연계 추천 경로: LiveRecommendService.kt, LiveRecommendRepository.kt
  • /live/room
    • LiveRoomController.kt, LiveRoomService.kt

4-2. 파라미터 수신 컨트롤러 전수 목록(저장값 기반 전환 대상)

  • 참고: /api/home, /api/live, /live/room은 4-1에서 별도 관리하며, 아래는 그 외 컨트롤러 전수 목록
  • isAdultContentVisible + contentType둘 다 받는 컨트롤러
  • AudioContentController.kt
  • AudioContentMainController.kt
  • AudioContentCurationController.kt
  • AudioContentThemeController.kt
  • SearchController.kt
  • ContentSeriesController.kt
  • SeriesMainController.kt
  • AudioContentMainTabHomeController.kt
  • AudioContentMainTabContentController.kt
  • AudioContentMainTabFreeController.kt
  • AudioContentMainTabAsmrController.kt
  • AudioContentMainTabAlarmController.kt
  • AudioContentMainTabLiveReplayController.kt
  • AudioContentMainTabSeriesController.kt
  • isAdultContentVisible만 받는 컨트롤러(동일 저장값 정책 연계 필요)
    • ExplorerController.kt (/explorer/profile/{id})
    • LiveRoomController.kt (/live/room)
  • 컨트롤러 레벨에서 member.auth != null && (isAdultContentVisible ?: true)를 직접 계산하는 구간도 함께 전환
    • AudioContentController.kt, AudioContentMainController.kt, AudioContentThemeController.kt
    • SeriesMainController.kt, AudioContentMainTabContentController.kt, AudioContentMainTabFreeController.kt
    • AudioContentMainTabHomeController.kt, AudioContentMainTabAsmrController.kt, AudioContentMainTabSeriesController.kt, AudioContentMainTabLiveReplayController.kt

4-3. 서비스/쿼리 계층 (실제 필터 적용)

  • member.auth != null && isAdultContentVisible 계산식을 사용하는 서비스 전수 수정
    • HomeService.kt, LiveApiService.kt, LiveRoomService.kt, LiveRecommendService.kt
    • AudioContentService.kt, AudioContentMainService.kt
    • AudioContentMainTabHomeService.kt, AudioContentMainTabContentService.kt, AudioContentMainTabFreeService.kt
    • AudioContentMainTabAsmrService.kt, AudioContentMainTabAlarmService.kt, AudioContentMainTabLiveReplayService.kt, AudioContentMainTabSeriesService.kt
    • AudioContentCurationService.kt, AudioContentThemeService.kt
    • ContentSeriesService.kt, SearchService.kt, ExplorerService.kt
  • AudioContentRepository.kt 및 아래 쿼리 레이어의 contentType/성인 필터 검증
    • RankingRepository.kt
    • SearchRepository.kt
    • ContentSeriesRepository.kt
    • ContentSeriesContentRepository.kt
    • AudioContentThemeQueryRepository.kt
    • AudioContentCurationQueryRepository.kt
    • AudioContentMainTabRepository.kt
    • RecommendSeriesRepository.kt
    • ContentMainTabTagCurationRepository.kt
    • RecommendChannelQueryRepository.kt
  • member.auth == null 직접 분기 기반 성인 제어 로직 점검(정책 일관화)
    • AudioContentService.kt (isMosaic 계산)
    • LiveRoomService.kt (성인 라이브 입장/조회 가드)
    • LiveRecommendRepository.kt (추천 라이브/채널에서 성인 라이브 제외 조건)
    • ExplorerQueryRepository.kt (인증 미완료 시 성인 라이브 제외)
    • CreatorCommunityController.kt / CreatorCommunityService.kt (커뮤니티 성인글 조회에서 인증 여부 분기)
    • LiveTagRepository.kt (성인 태그 조회 가드)

4-4. 채팅 캐릭터 조회

  • ChatCharacterController.kt
    • 현재 member.auth == null 강제 체크(common.error.adult_verification_required)가 있어 국가별 정책 반영 지점 설계 필요
    • 저장값 + 국가 정책으로 19금 캐릭터 노출 제한 로직을 통합
  • ChatCharacterService.kt / Repository 레벨에서 19금 캐릭터 필터가 필요한지 점검 후 반영
  • 연관 채널(캐릭터 이미지/댓글)도 동일 정책 적용 여부 검토
    • CharacterImageController.kt
    • CharacterCommentController.kt

5) /member/info 응답 확장

  • DTO 확장
    • src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt
    • 추가: countryCode, isAdultContentVisible, contentType
  • 서비스 확장
    • MemberService.getMemberInfo(...)에서 저장값 조회 후 응답 주입
    • countryCodemember.countryCode가 아닌 요청 시점 국가 결정값으로 반환
    • 국가 결정 우선순위:
      1. member.id 강제 매핑 (KR: 16, 17 / JP: 2, 29721, 32050, 40850)
      2. CountryContext.countryCode (CloudFront-Viewer-Country)
      3. 헤더 누락/오작동 시 KR
    • 인프라 전제: CloudFront에서 CloudFront-Viewer-Country 헤더를 오리진으로 전달하도록 설정되어 있어야 한다.
    • 캐시 주의: 국가별 응답이 달라지는 구간은 캐시 키에 국가 헤더를 포함하는지 함께 점검한다.

6) 기본값 true → false 전환

  • 기존 ?: true 기본값 사용 지점 제거 또는 서버 저장값 fallback으로 대체
    • 전수 대상(18개):
      • HomeController.kt, LiveApiController.kt, LiveRoomController.kt, ExplorerController.kt
      • AudioContentController.kt, AudioContentMainController.kt, AudioContentCurationController.kt, AudioContentThemeController.kt
      • SearchController.kt, ContentSeriesController.kt, SeriesMainController.kt
      • AudioContentMainTabHomeController.kt, AudioContentMainTabContentController.kt, AudioContentMainTabFreeController.kt
      • AudioContentMainTabAsmrController.kt, AudioContentMainTabAlarmController.kt, AudioContentMainTabLiveReplayController.kt, AudioContentMainTabSeriesController.kt
  • fallback 규칙 표준화:
    1. 저장값 존재 시 저장값 사용
    2. 저장값 미존재 시 신규 기본값(false, ContentType.ALL) 사용 및 보정 저장

7) 변경 시각 관리

  • isAdultContentVisible 변경 시 adultContentVisibilityChangedAt 갱신
  • contentType 변경 시 contentTypeChangedAt 갱신
  • 전체 변경 추적은 updatedAt으로도 확인 가능하게 유지
  • row 최초 생성 시 adultContentVisibilityChangedAt, contentTypeChangedAt 초기값을 생성 시각으로 기록
  • 동일값 재저장 요청 시 changedAt은 갱신하지 않도록 정책 정의(노이즈 업데이트 방지)

데이터 마이그레이션/릴리스 계획

  • DDL 문서 작성 (docs/*_ddl.sql 패턴 준수)
    • 신규 테이블 생성 또는 기존 member 컬럼 추가 중 1안 확정
    • DDL 생성 시 컬럼 타입 규칙
      • created_at, updated_at처럼 날짜/시간 저장 필드는 timestamp로 생성
      • boolean 저장 필드는 tinyint(1)로 생성
  • 기존 회원 백필 전략 수립
    • 기본값: false + ALL
    • 적용 대상: 기존에 isAdultContentVisible, contentType를 받던 API 호출 시점
    • 범위: 기존 회원 누락 row 보정 전용 규칙 (정상 운영 저장 정책은 3) 전체 API 호환 저장 정책을 따름)
    • 처리 순서:
      1. 회원 설정 테이블에 해당 member row 존재 여부 확인
      2. row가 없으면 기본값(isAdultContentVisible=false, contentType=ALL)으로 생성
      3. member.auth != null이면 요청으로 받은 값으로 갱신
      4. member.auth == null이면 기본값을 그대로 유지(요청값으로 갱신하지 않음)
    • 필요 시 배치/스크립트 실행
  • 단계적 배포
    1. 저장 모델 배포 + 백필
    2. 직접 설정 API 배포 + authVerify 성공 연동 배포
    3. 호환 파라미터 수신 저장 전환(기존 isAdultContentVisible 파라미터 수신 API 전체)
    4. 조회 경로 저장값 전환 + /member/info 확장 배포
    5. 호환 파라미터 종료 조건 문서화(구버전 비율/공지/제거 시점)

1차 배포 구현 우선순위 (실행 순서 재정렬)

  • 0단계: 정책 고정
    • 국가 판별 우선순위 확정: member.id 강제 매핑(KR: 16,17 / JP: 2,29721,32050,40850) → 접속 국가 헤더 → KR fallback
    • 기존 회원 row 미존재 보정 규칙 확정: member.auth 여부 기반 기본값 저장/보정
    • changedAt 갱신 규칙 확정: 최초 생성 시 초기화, 동일값 재저장 시 미갱신
    • 직접 설정 API 계약 확정: endpoint, request/response, validation(둘 중 하나 이상 입력)
  • 1단계: 저장 모델/DDL 선반영
    • MemberContentPreference(가칭) 엔티티/리포지토리/서비스 추가
    • DDL 작성(timestamp, tinyint(1) 규칙 준수)
  • 2단계: 가입 경로 선저장
    • signUpV2, signUp, findOrRegister(Google/Kakao/Apple/Line)에서 기본값(false, ALL) 저장
  • 3단계: 직접 설정 API 우선 구현
    • PATCH /member/content-preference 추가(호환 API 저장 로직과 분리)
    • 설정 row 생성/갱신 + 응답 DTO + validation/예외 처리
  • 4단계: 본인인증 성공 연동
    • AuthController.authVerify 성공 시 isAdultContentVisible = true 저장
    • 차단/실패 예외 흐름에서 저장되지 않음을 보장
  • 5단계: 호환 저장 진입점 우선 전환(트래픽 핵심)
    • /api/home, /api/live, /live/room, /explorer/profile/{id}에서 파라미터 수신 후 저장
    • row 미존재 시 생성 + 정책 반영(국가/인증 분기)
  • 6단계: 파라미터 수신 컨트롤러 전수 전환(4-2)
    • 콘텐츠/검색/시리즈/메인탭 컨트롤러 전체 저장값 연동
    • contentType 미수신 API는 isAdultContentVisible만 저장하고 contentType은 기존값 유지
  • 7단계: 조회 경로 저장값 기준 전환(4-3, 4-4)
    • 서비스/쿼리 계층 ?: true 및 직접 계산식 제거 후 저장값 기반 계산으로 통일
    • 채팅 캐릭터/이미지/댓글 경로를 국가+저장값 정책으로 통합
  • 8단계: /member/info 확장
    • 응답 필드 countryCode, isAdultContentVisible, contentType 추가
    • countryCode는 회원 ID 강제 매핑 우선 적용 후 접속 국가/KR fallback 적용
  • 9단계: 기본값 true → false 전수 치환
    • 컨트롤러 18개 isAdultContentVisible ?: true 제거
    • 저장값 우선 + 미존재 시 false/ALL 보정 저장으로 표준화
  • 10단계: 테스트/검증
    • 테스트 작성 원칙: @SpringBootTest를 사용하지 않고 단위 테스트(JUnit5 + Mockito) 중심으로 작성
    • 단위: 국가 분기/강제 매핑, auth 분기, changedAt, row 보정, 가입 선저장, 직접 설정 API, authVerify 연동, /member/info 반환
    • 통합: 직접 설정 API 저장 반영, authVerify 성공 자동 true 저장, 호환 API 저장 반영, 헤더 누락(KR) fallback
    • 회귀: ./gradlew test, ./gradlew build, ./gradlew ktlintCheck

테스트/검증 계획

  • 테스트 작성 원칙
    • @SpringBootTest를 사용하지 않는다.
    • 서비스/정책 로직은 JUnit5 + Mockito 기반 단위 테스트로 작성한다.
  • 단위 테스트
    • 국가 결정 우선순위 테스트
      • member.id=16,17은 헤더와 무관하게 KR
      • member.id=2,29721,32050,40850은 헤더와 무관하게 JP
      • 그 외 회원은 CloudFront-Viewer-Country 사용, 누락 시 KR fallback
    • 한국/해외 저장 정책 분기 테스트
    • 한국 + member.auth == null에서 호환 API 호출 시 요청값으로 갱신되지 않고 기본값 유지되는지 테스트
    • 해외 + member.auth == null에서 호환 API 호출 시 요청값이 저장되는지 테스트
    • 한국/해외 조회 정책 분기 테스트
    • 직접 설정 API 테스트
      • isAdultContentVisible/contentType를 각각 단독/동시 변경할 때 저장 반영 및 응답(isAdultContentVisible, contentType)이 기대값인지 테스트
      • 둘 다 누락된 요청을 validation 에러로 처리하는지 테스트
    • isAdultContentVisible 값 변경 시 adultContentVisibilityChangedAt만 갱신되는지 테스트
    • contentType 값 변경 시 contentTypeChangedAt만 갱신되는지 테스트
    • 동일값 재저장 시 changedAt이 갱신되지 않는지 테스트
    • contentType(ALL/FEMALE/MALE) 성별 필터 기대값 테스트
    • AuthController.authVerify 성공 시 isAdultContentVisible=true로 저장되는지 테스트
    • AuthController.authVerify 실패/차단 시 저장이 발생하지 않는지 테스트
    • contentType 미수신 API(LiveRoom, Explorer profile)에서 isAdultContentVisible만 저장되는지 테스트
    • 기존 회원 row 미존재 시 API 호출로 row 생성/갱신되는지 테스트
    • 신규 회원가입 직후 기본값(false/ALL) 선저장 검증 테스트
    • /member/info 필드 노출 테스트(countryCode는 회원 ID 강제 매핑 우선 + 비대상 회원은 접속 국가 기준 반환 검증 포함)
  • 통합 테스트
    • 직접 설정 API(PATCH /member/content-preference) 호출 시 저장 후 즉시 조회 경로에 반영되는지 확인
    • authVerify 성공 호출 시 isAdultContentVisible=true 자동 저장 반영 확인
    • 호환 대상 API(/api/home, /api/live, /live/room, explorer/profile, 콘텐츠/검색/시리즈 계열) 파라미터 전달 → 저장 반영 확인
    • 기존 회원(설정 row 없음) 첫 호출 시 저장 생성 + 같은 요청에서 저장값 기반 조회 적용 확인
    • 한국/해외 각각에서 동일 API 호출 시 저장 결과와 조회 결과가 정책대로 달라지는지 확인
    • /member/info 호출 시 강제 매핑 회원은 헤더 변경과 무관하게 고정 국가를 반환하는지 확인
    • /member/info 호출 시 강제 매핑 대상이 아닌 회원은 헤더 변경(KR/US 등)에 따라 국가 응답이 변경되는지 확인
    • CloudFront-Viewer-Country 헤더 누락 시 /member/info.countryCode가 fallback(KR)으로 반환되는지 확인
    • 콘텐츠/라이브/채팅 캐릭터 조회 결과 정책 반영 확인
  • 회귀 검증 명령
    • ./gradlew test
    • ./gradlew build
    • ./gradlew ktlintCheck

리스크 및 대응

  • 리스크: 파라미터 제거 시 구버전 앱 동작 불일치
    • 대응: 초기에는 구/신 정책을 공존 운영하고, 기존 회원 중 저장값이 없으면 member.auth 여부에 따라 기본값을 저장/보정해 조회 기준을 단일화한다.
    • 판정: 대응 가능(공존 기간의 잔여 리스크는 운영으로 관리).
  • 리스크: 기존 회원 저장값 미존재
    • 대응: isAdultContentVisible를 받는 API에서 설정 row 존재 여부를 확인하고, 없으면 즉시 생성/저장한다.
    • 판정: 대응 가능(런타임 백필로 해소).
  • 리스크: 한국 인증 전 사용자 성인값 처리 혼선
    • 대응: 한국은 member.auth == null이면 저장값을 기본값으로 저장/유지하고, member.auth != null && isAdultContentVisible == true일 때만 성인 처리한다.
    • 판정: 대응 가능(정책 명시로 혼선 축소).
  • 리스크: CloudFront-Viewer-Country 헤더 미전달/오작동으로 현재 접속 국가 판별 실패
    • 대응: 국가 판별 실패 시 한국(KR)으로 판단한다.
    • 판정: 대응 가능(보수적 안전 기준 적용), 단 해외 사용자의 과차단 가능성은 모니터링한다.
  • 리스크: 호환 파라미터(legacy fallback) 장기 존치로 정책 복잡도 증가
    • 대응: 앱 배포 상태(버전 점유율) 기반으로 제거 일자를 결정하고 단계적으로 삭제한다.
    • 판정: 대응 가능(종료 기준·일정 관리 필요).
  • 리스크: 직접 설정 API가 없으면 호환 API 호출 여부에 따라 저장 타이밍이 불안정해짐
    • 대응: 1차 배포에 직접 설정 API를 포함하고, 호환 저장은 구버전 공존 목적의 보조 경로로 제한한다.
    • 판정: 대응 가능(명시적 설정 진입점 도입으로 안정화).
  • 리스크: 회원 ID 강제 국가 매핑 하드코딩이 운영 중 누락/충돌을 유발할 수 있음
    • 대응: 강제 매핑 목록을 정책 상수로 단일화하고 테스트 케이스(각 ID별 기대 국가)를 고정한다.
    • 판정: 대응 가능(목록 변경 절차와 테스트 동반 시 관리 가능).

구현 완료 후 기록 섹션 (구현 단계에서 작성)

사전 점검 (2026-03-25)

  • 무엇을:
    • 상단 목적(서버 저장값 전환/국가별 정책 분리/호환 저장/선저장/변경시각) 기준으로 변경 대상 체크리스트의 누락 여부를 점검했다.
  • 왜:
    • 구현 전 문서 범위 누락을 제거해 실제 작업 시 정책 누락/회귀를 방지하기 위해서다.
  • 어떻게:
    • 명령:
      • grep(include=*Controller.kt, pattern=isAdultContentVisible)
      • ast-grep(lang=kotlin, pattern=member.auth != null && $X)
      • grep(pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)
      • Read(ExplorerController.kt, ExplorerService.kt, MemberService.kt, GetMemberInfoResponse.kt)
      • Explore/Librarian 병렬 점검(bg_db6e2179, bg_525f613e, bg_908b86f6, bg_7bad3593, bg_3736f748)
    • 결과:
      • ExplorerService.kt가 서비스 전수 수정 목록(4-3)에 빠져 있어 추가했다.
      • /member/info.countryCode에 대해 CloudFront 헤더 전달 전제, fallback(KR), 캐시 키 점검 항목을 추가했다.
      • changedAt 정책(초기값/동일값 재저장)과 단위 테스트 항목을 보강했다.
      • legacy fallback 장기 존치 리스크 및 종료 조건 문서화 항목을 추가했다.

1차 구현

  • 무엇을:
    • MemberContentPreference 저장 모델/리포지토리/정책 서비스를 추가하고, 강제 국가 매핑(KR/JP) + 헤더 + KR fallback 규칙을 서비스 단일 경로로 구현했다.
    • 회원가입/소셜가입(signUpV2, signUp, findOrRegister 4종) 직후 기본값(false, ALL) 선저장을 연동했다.
    • PATCH /member/content-preference를 추가하고, 요청값(둘 중 하나 이상) 갱신 및 최신 설정 응답을 구현했다.
    • AuthController.authVerify 성공 직후 isAdultContentVisible=true 저장 연동을 추가했다.
    • 핵심 트래픽 진입점(/api/home, /api/live, /live/room, /explorer/profile/{id})을 저장값 기반으로 전환하고, /member/infocountryCode, isAdultContentVisible, contentType를 확장했다.
    • 서비스 계층의 member.auth != null && isAdultContentVisible 계산식을 정책 유틸(isAdultVisibleByPolicy) 기반으로 전환해 한국/해외 분기를 통합했다.
    • DDL 문서 docs/20260326_member_content_preference_ddl.sql을 추가했다.
  • 왜:
    • 구버전 클라이언트 호환을 유지하면서도, 조회 정책 판단의 단일 기준을 서버 저장값으로 전환해 국가/인증 분기 불일치를 줄이기 위해서다.
    • 본인인증 성공 이후 성인 노출 상태를 자동 동기화하고, 사용자 설정 변경 진입점을 명시적으로 제공하기 위해서다.
  • 어떻게:
    • 명령:
      • ./gradlew test
      • ./gradlew build
      • ./gradlew ktlintCheck
    • 결과:
      • 단위 테스트 추가: MemberContentPreferenceServiceTest, AuthControllerTest 작성 및 기존 테스트(MemberServiceCacheEvictionTest, LiveRecommendServiceTest) 의존성 갱신 완료.
      • 회귀 검증 결과: test, build, ktlintCheck 모두 성공.
      • 참고: .kt 대상 LSP 서버가 환경에 없어 LSP 진단은 실행 불가였고, 대신 Gradle 컴파일/테스트/린트 통과로 검증했다.
    • 남은 항목:
      • 4-2 전수 컨트롤러(콘텐츠/검색/시리즈/메인탭)와 4-4 채팅 캐릭터 경로는 후속 단계에서 동일 정책으로 확장 적용이 필요하다.

2차 문서 보강 (2026-03-26)

  • 무엇을:
    • 회원 ID 강제 국가 매핑 정책(KR: 16,17 / JP: 2,29721,32050,40850)과 authVerify 성공 시 isAdultContentVisible=true 저장 요구사항을 문서 전반에 반영했다.
    • 호환 저장과 별개의 직접 설정 API(가칭 PATCH /member/content-preference) 필요성을 명시하고, 1차 배포 우선순위와 테스트 계획을 재정렬했다.
  • 왜:
    • 현재 코드는 조회 파라미터 기반(legacy) 흐름만 존재해 사용자 설정을 명시적으로 저장/관리하는 진입점이 없고, 본인인증 성공 이후 성인 노출 상태를 자동 동기화해야 정책 일관성을 유지할 수 있기 때문이다.
  • 어떻게:
    • 명령:
      • grep(include=*Controller.kt, pattern=isAdultContentVisible|contentType)
      • grep(path=src/main/kotlin, pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)
      • ast-grep(lang=kotlin, pattern=member.auth != null && $X)
      • Read(MemberController.kt, AuthController.kt, CountryInterceptor.kt, CountryContext.kt, MemberService.kt)
      • Explore/Librarian 병렬 점검(bg_9725b309, bg_7d18bd4d, bg_5be1625e, bg_234021df)
    • 결과:
      • 직접 설정 API 부재(MemberController에 전용 엔드포인트 없음) 확인 결과를 문서에 반영했다.
      • 국가 결정 우선순위(회원 ID 강제 매핑 > 접속 국가 헤더 > KR fallback)를 핵심 요구사항, /member/info, 테스트 항목에 일관 반영했다.
      • AuthController.authVerify 성공 시 isAdultContentVisible=true 저장 항목을 구현 범위/우선순위/테스트에 추가했다.

3차 구현 (2026-03-26)

  • 무엇을:
    • 4-2 전수 대상 컨트롤러(AudioContent*, SearchController, ContentSeriesController, SeriesMainController, 메인탭 7종)에서 MemberContentPreferenceService.resolveForQuery(...)를 사용하도록 변경했다.
    • 컨트롤러 단의 isAdultContentVisible ?: true, member.auth != null && (isAdultContentVisible ?: true) 계산식을 제거하고, 저장값 기반 preference.isAdultContentVisible / preference.contentType / preference.isAdult를 사용하도록 통일했다.
    • 4-4 범위로 ChatCharacterController, CharacterImageController, CharacterCommentControllermember.auth 강제 분기를 MemberContentPreferenceService.getStoredPreference(member).isAdult 기반 정책 가드로 전환했다.
  • 왜:
    • legacy 파라미터 기본값(true) 의존을 제거해 국가/인증 정책이 컨트롤러별로 분산되는 문제를 없애고, 저장값 기준 단일 정책으로 수렴하기 위해서다.
    • 채팅 캐릭터 연관 경로까지 동일 정책을 적용해 도메인별 예외 분기를 줄이고 운영 일관성을 확보하기 위해서다.
  • 어떻게:
    • 명령:
      • grep(pattern=isAdultContentVisible\s*\?:\s*true|member\??\.auth\s*!=\s*null\s*&&\s*\(isAdultContentVisible\s*\?:\s*true\), path=src/main/kotlin, output_mode=content)
      • grep(pattern=isAdultContentVisible\s*\?:\s*true, path=src/main/kotlin, output_mode=count)
      • ./gradlew test
      • ./gradlew ktlintCheck
      • ./gradlew build
    • 결과:
      • src/main/kotlin 기준 isAdultContentVisible ?: true 패턴 0건 확인.
      • 회귀 검증(test, ktlintCheck, build) 모두 성공.
      • 참고: Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

정정 (2026-03-26)

  • 무엇을:
    • 1차 구현 섹션의 "남은 항목"에 기재된 4-2/4-4 미완 상태를 최신 구현 상태(완료)와 맞춰 정정한다.
  • 왜:
    • 3차 구현에서 해당 범위가 실제로 완료되어, 과거 시점의 미완 표기가 현재 상태와 달라졌기 때문이다.
  • 어떻게:
    • 4-2 체크리스트 전 항목, 4-4 체크리스트 전 항목, 1차 배포 우선순위 6/7/9단계를 완료 상태([x])로 동기화했다.

4차 구현 (2026-03-26)

  • 무엇을:
    • 4-3 잔여 항목 중 성인 제어의 member.auth 직접 분기를 정책 기반으로 재정렬했다.
      • AudioContentService 상세 조회의 연관 콘텐츠/모자이크 판단을 저장 선호 정책(isAdult) 기준으로 통일했다.
      • ExplorerQueryRepository#getLiveRoomList는 성인 라이브 필터를 호출부 정책값(isAdult)만 사용하도록 변경했다.
      • CreatorCommunityController/Service, LiveTagService/Repository는 저장 선호 기반 성인 필터를 사용하도록 정리했다.
    • 태그 큐레이션/시리즈 조회의 누락 필터를 보완했다.
      • ContentMainTabTagCurationRepository에 비성인 조회 시 audioContent.isAdult.isFalse를 추가했다.
      • ContentSeriesRepository#getGenreList에 비성인 조회 시 audioContent.isAdult.isFalse를 추가했다.
    • 단위 테스트를 보강했다.
      • MemberContentPreferenceServiceTest, MemberControllerTest, MemberServiceContentPreferenceTest, CreatorCommunityServiceTest, LiveTagServiceTest를 추가/확장했다.
    • 사용자 요청에 따라 정책 분기 의도를 설명하는 주석을 변경 코드의 핵심 분기 지점에 보강했다.
  • 왜:
    • 동일 기능 내에서 member.auth 직접 분기와 저장 선호 분기가 혼재하면 국가/인증 정책 일관성이 깨질 수 있어, 조회/필터 기준을 저장 선호 정책으로 단일화할 필요가 있었다.
    • 누락된 성인 필터는 비성인 조회에서 의도치 않은 노출을 만들 수 있어 쿼리 레이어 보완이 필요했다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest"
      • ./gradlew test
      • ./gradlew ktlintCheck
      • ./gradlew build
    • 결과:
      • 초기 test에서 MemberServiceContentPreferenceTest 2건 실패를 확인했고, Mockito matcher null 이슈를 테스트 코드에서 수정했다.
      • 수정 후 대상 테스트/전체 테스트/ktlint/build를 재실행해 모두 성공했다.
      • Kotlin LSP 미구성으로 LSP 진단은 불가했으며, Gradle 검증으로 대체했다.

4차 후속 보완 (Oracle 점검 반영, 2026-03-26)

  • 무엇을:
    • AudioContentService#getDetail에 비성인 정책 사용자의 성인 콘텐츠 직접 상세 진입 차단(common.error.adult_verification_required)을 추가했다.
    • CreatorCommunity 댓글/답글 경로(createCommunityPostComment, getCommunityPostCommentList, getCommentReplyList)에 저장 선호 기반 isAdult 검증을 추가해 성인 게시물 우회 접근을 차단했다.
    • 관련 단위 테스트(CreatorCommunityServiceTest)에 비성인 정책에서의 댓글 작성/댓글 목록/답글 목록 차단 케이스를 추가했다.
  • 왜:
    • 목록/상세/구매 경로는 정책이 적용되어도 댓글 경로와 직접 상세 진입이 열려 있으면 정책 우회가 가능해, 성인 노출 정책 일관성이 깨질 수 있기 때문이다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"
      • ./gradlew test && ./gradlew ktlintCheck && ./gradlew build
    • 결과:
      • 커뮤니티 서비스 단위 테스트 통과.
      • 전체 검증 체인(test/ktlint/build) 모두 성공.

5차 구현 (미체크 항목 마감, 2026-03-26)

  • 무엇을:
    • 계획 문서의 미체크 항목 5개를 전수 점검하고, 구현/검증/문서화를 완료했다.
    • 4-3 쿼리 레이어 검증 항목은 explore 병렬 감사 결과와 직접 검색 결과를 근거로 완료 처리했다.
    • 통합 테스트 항목은 MemberContentPreferenceIntegrationTest를 추가해 아래 시나리오를 실제 영속성 연동으로 검증했다.
      • 직접 설정(updatePreference) 저장 후 즉시 조회 반영
      • authVerify 연동 메서드(markAdultVisibleAfterAuthVerify) 저장 반영
      • legacy 호출 경로(resolveForQuery)의 row 생성 + 즉시 반영
      • 헤더 누락 시 KR fallback 및 KR+미인증 기본값 유지
      • KR+인증 회원의 요청값 반영 및 isAdult 계산
      • 강제 국가 매핑 ID(2, 16) 우선 적용
    • 기존 회원 백필 전략/단계적 배포 항목은 현재 구현 상태(런타임 row 보정 + 단계별 배포 절차 문서화) 기준으로 완료 처리했다.
  • 왜:
    • 체크리스트 미완 상태를 해소하지 않으면 정책 전환 완료 기준이 불명확해지고, 운영 시 회귀 검증 근거가 약해지기 때문이다.
    • 특히 통합 시나리오 부재는 “저장 후 즉시 반영” 보장을 약화시키므로 실제 repository 연동 테스트가 필요했다.
  • 어떻게:
    • 명령:
      • grep(pattern="^- \[ \]", include="20260325_콘텐츠조회설정서버저장전환.md")
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
      • ./gradlew test && ./gradlew ktlintCheck && ./gradlew build
    • 결과:
      • 신규 통합 테스트 통과.
      • 전체 검증 체인(test/ktlint/build) 모두 성공.
      • Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

5차 후속 보완 (Oracle 리뷰 반영, 2026-03-26)

  • 무엇을:
    • AudioContentService#getDetail의 성인 상세 직접 진입 차단 로직에 대한 회귀 테스트를 AudioContentServiceTest에 추가했다.
    • 비성인 정책(isAdultContentVisible=false)에서 성인 콘텐츠 조회 시 common.error.adult_verification_required 예외를 검증했다.
  • 왜:
    • 최종 리뷰에서 기능은 구현되어 있었지만 전용 테스트 증빙이 부족해, 정책 우회 회귀를 방지하기 위한 테스트 고정을 추가할 필요가 있었다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"
      • ./gradlew test && ./gradlew ktlintCheck && ./gradlew build
    • 결과:
      • 신규 회귀 테스트 포함 대상 테스트 통과.
      • 전체 검증 체인(test/ktlint/build) 모두 성공.

6차 구현 (이슈 1/2/3 안정화, 2026-03-26)

  • 무엇을:
    • 이슈 1 대응: MemberContentPreferenceService.resolveForQuery, getStoredPreferenceREQUIRES_NEW 트랜잭션으로 분리해 LiveRoomService/ExplorerServicereadOnly 조회 흐름에서도 설정 생성·갱신이 반영되도록 수정했다.
    • 이슈 2 대응: 선호 변경 경로(updatePreference, markAdultVisibleAfterAuthVerify, legacy resolveForQuery 변경 발생 시)에 getRecommendLive 캐시 무효화를 연결하고, 커밋 이후에 evict 되도록 afterCommit 동기화를 적용했다.
    • 이슈 3 대응: initializeDefaultPreference에서 member row를 PESSIMISTIC_WRITE로 잠근 뒤 재조회/생성하도록 변경해 동시 최초 요청 경쟁에서도 단일 row만 생성되도록 보강했다.
    • 테스트 보강: MemberContentPreferenceServiceTest, MemberContentPreferenceIntegrationTest에 캐시 무효화/충돌 재조회/초기 생성 반영 케이스를 추가했다.
    • 사용자 요청 반영: 별도 계획 문서를 만들지 않고 기존 문서(20260325_콘텐츠조회설정서버저장전환.md)에 구현/검증 기록을 누적했다.
  • 왜:
    • readOnly 트랜잭션 참여로 저장이 누락될 수 있는 경로를 제거하고, 선호 변경 이후 추천 캐시 stale을 즉시 해소하며, 최초 row 생성 경쟁 시 unique 충돌이 사용자 오류로 노출되는 문제를 방지하기 위해서다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
      • ./gradlew test ktlintCheck build
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest.shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall"
    • 결과:
      • 타깃 테스트(서비스/통합) 통과.
      • 전체 검증 체인(test, ktlintCheck, build) 통과.
      • 수동 QA 성격의 핵심 시나리오 3건(legacy 변경 캐시 무효화, 생성 충돌 재조회, 최초 legacy 호출 즉시 반영) 재실행 통과.

7차 버그 수정 (요청 국가 정합화 + 강제 매핑 유지, 2026-03-26)

  • 무엇을:
    • 검색 경로 불일치 보정을 위해 SearchController/SearchService를 수정해, resolveForQuery(...)에서 계산된 preference.isAdult를 검색 쿼리에 그대로 전달하도록 변경했다.
    • MemberContentPreferencePolicy의 국가 결정을 member.countryCode 의존에서 제거하고, 강제 매핑 회원 ID(KR/JP) 우선 + 그 외 CloudFront-Viewer-Country 헤더 + KR fallback 순서로 통일했다.
    • MemberContentPreferenceService.resolveCountryCode도 동일하게 강제 매핑 우선 + 접속 국가 헤더 + KR fallback으로 유지/정렬했다.
    • 사용자 지시(2번)대로 라이브 추천 캐시 키에 접속 국가를 반영하는 변경은 적용하지 않았고, 관련 시도 변경분은 모두 원복했다.
    • 회귀 고정을 위해 MemberContentPreferencePolicyTest, SearchServiceTest를 추가하고, MemberContentPreferenceServiceTest, MemberContentPreferenceIntegrationTest를 정책 기준에 맞게 보강했다.
    • 버그 수정 문서 전략은 별도 신규 문서 분리 대신, 기존 계획 문서(본 문서)에 구현/검증 기록을 누적하는 방식으로 확정했다.
  • 왜:
    • 검색 정책 계산에서 요청 국가와 멤버 저장 국가가 혼재되면 국가별 성인 노출 정책이 엇갈릴 수 있어, 정책 기준을 요청 흐름으로 일관화할 필요가 있었다.
    • 다만 운영 중인 강제 매핑 회원은 기존 정책 계약이므로 그대로 보존해야 했고, 캐시 키 국가 분리는 현재 우선순위에서 제외하라는 사용자 지시를 준수해야 했다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest"
      • ./gradlew test
      • ./gradlew ktlintCheck
      • ./gradlew build
      • 수동 QA 성격 검증: ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest.shouldPrioritizeForcedCountryMapping" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest.shouldUseProvidedIsAdultForContentSearch"
    • 결과:
      • 정책/검색/통합/캐시 관련 타깃 테스트 통과.
      • 전체 test, ktlintCheck, build 통과.
      • 수동 QA 시나리오(강제 매핑 우선, 검색 isAdult 전달 고정) 통과.
      • Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

정정 (2026-03-26, 7차 중간 수정)

  • 무엇을:
    • ktlintCheck 1회 실패(테스트 파일 들여쓰기) 후 즉시 수정하고 재검증 결과를 반영한다.
  • 왜:
    • 7차 구현 중 테스트 파일 패치 과정에서 들여쓰기 불일치가 발생했기 때문이다.
  • 어떻게:
    • 실패 명령: ./gradlew ktlintCheck (MemberContentPreferenceServiceTest.kt 들여쓰기 오류)
    • 조치: 해당 파일 들여쓰기 정정
    • 재실행: ./gradlew ktlintCheck 성공

8차 리팩터링 (강제 매핑 국가 결정 로직 단일화, 2026-03-26)

  • 무엇을:
    • MemberContentPreferenceService.resolveCountryCode(...)MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)에 중복되어 있던 강제 매핑 국가 결정 로직을 공통 함수로 통합했다.
    • 신규 파일 MemberContentPreferenceCountryResolver.kt를 추가하고, 두 경로가 동일한 resolveCountryCodeWithForcedMapping(...)를 사용하도록 변경했다.
  • 왜:
    • 동일 정책 로직이 두 파일에 복제되어 있으면 한쪽만 수정될 때 운영 정책 불일치가 발생할 수 있어, 단일 소스로 유지보수 리스크를 줄이기 위해서다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
      • ./gradlew ktlintCheck
      • ./gradlew test
      • ./gradlew build
    • 결과:
      • 정책 관련 타깃 테스트 통과.
      • ktlintCheck, test, build 통과.
      • 참고: 병렬 실행 중 1회 테스트 리포트 파일 쓰기 충돌이 있었고(:test), 이후 ./gradlew test 단독 재실행으로 정상 통과를 확인했다.

9차 정리 (MemberService 미사용 주입 제거, 2026-03-27)

  • 무엇을:
    • MemberService 생성자에서 실제로 사용되지 않던 authRepository: AuthRepository 주입을 제거했다.
    • 관련 import(kr.co.vividnext.sodalive.member.auth.AuthRepository)를 함께 제거했다.
    • 생성자 시그니처 변경에 맞춰 테스트 수동 생성부(MemberServiceContentPreferenceTest, MemberServiceCacheEvictionTest)의 인자 목록을 정렬했다.
  • 왜:
    • 미사용 주입을 유지하면 클래스 결합도와 유지보수 비용이 불필요하게 증가하고, 생성자 계약이 실제 책임보다 과도하게 커지기 때문이다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"
      • ./gradlew ktlintCheck
      • ./gradlew test
      • ./gradlew build
    • 결과:
      • MemberService 관련 타깃 테스트 통과.
      • ktlintCheck, test, build 전체 통과.

10차 작업 계획 (communityPostLike 호출부 정합화, 2026-03-27)

  • CreatorCommunityService.communityPostLike 호출부를 전수 탐색한다.
  • 누락된 호출부에 isAdult 인자를 전달하도록 수정한다.
  • 관련 테스트 및 전체 검증(ktlintCheck, test, build)을 수행한다.

10차 정합화 (communityPostLike 호출부 인자 반영, 2026-03-27)

  • 무엇을:
    • CreatorCommunityService.communityPostLike(request, member, isAdult) 호출부를 전수 확인해 누락 지점을 정리했다.
    • 운영 코드(CreatorCommunityController)는 이미 isAdult 전달이 되어 있어 유지했다.
    • 테스트 코드(CreatorCommunityServiceTest)의 구 시그니처 호출을 신 시그니처로 수정하고, 테스트 설명/목 객체를 현재 구조에 맞게 정리했다.
  • 왜:
    • 서비스 시그니처 변경 이후 호출부가 일부 구 버전 형태를 유지하면 컴파일 실패 또는 정책 불일치가 발생할 수 있기 때문이다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityControllerTest"
      • ./gradlew ktlintCheck
      • ./gradlew test
      • ./gradlew build
    • 결과:
      • CreatorCommunity 타깃 테스트 통과.
      • ktlintCheck, test, build 전체 통과.

코드리뷰 결과 (문서 목적 적합성/잠재 버그/일반 리뷰, 2026-03-27)

  • 무엇을:

    • 문서 요구사항(서버 저장값 전환, 국가 정책, legacy 호환 저장, 가입 선저장, /member/info 확장, authVerify 연동, 직접 설정 API)의 구현 여부를 코드 기준으로 대조 점검했다.
    • git diff --cached, git diff 기준 변경 파일 전체를 검토하고, 변경된 핵심 경로(MemberContentPreferenceService, MemberController, MemberService, AuthController, Home/Live/Explorer/LiveRoom/Search, 채팅/커뮤니티/태그 경로)를 우선 리뷰했다.
    • 실제 회귀 검증(test, ktlintCheck, build)을 다시 실행해 문서화했다.
  • 왜:

    • 체크리스트의 완료 표시([x])와 실제 구현 상태의 불일치, 그리고 변경분 내 정책 회귀 가능성을 배포 전에 제거하기 위해서다.
  • 어떻게:

    • 명령:
      • git status --short
      • git diff --cached --name-only
      • git diff --name-only
      • ./gradlew test && ./gradlew ktlintCheck && ./gradlew build
    • 결과:
      • 문서 핵심 목적 항목은 코드상 대부분 구현되어 있으며, API/서비스/테스트 경로가 문서 체크리스트와 전반적으로 일치함을 확인했다.
      • 회귀 검증(test, ktlintCheck, build)은 모두 성공했다.
  • 잠재 버그 1 (중요도: 중)

    • 위치:
      • src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt
      • @Cacheable(key = "'getRecommendLive:' + (#member?.id ?: 'guest')")
    • 시나리오:
      • 동일 회원이 캐시 TTL(3시간) 내에 국가(CloudFront-Viewer-Country)가 달라진 요청을 보낼 때, 국가별 정책으로 계산되는 isAdult 결과가 달라도 캐시 키가 동일해 이전 국가 결과를 재사용할 수 있다.
      • 예: US 요청에서 성인 추천이 캐시된 뒤 KR 요청에서도 동일 캐시를 반환.
    • 영향:
      • 국가별 성인 노출 정책 정합성이 깨질 수 있음(특히 요청 국가가 자주 바뀌는 환경/네트워크).
    • 제안:
      • 캐시 키에 정책 결정값(예: countryCode 또는 최종 isAdult)을 포함하거나,
      • 선호/국가 관련 변경 시 국가 차원을 포함한 캐시 무효화 전략을 추가.
  • 잠재 버그 2 (중요도: 중)

    • 위치:
      • src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt
      • initializeDefaultPreference(...)의 조회 순서(findByMemberIdfindByIdForUpdatefindByMemberId)
    • 시나리오:
      • MySQL 기본 격리수준(REPEATABLE READ)에서 동일 회원에 대한 최초 동시 요청이 들어오면, 첫 비잠금 조회 스냅샷이 유지되어 잠금 이후 재조회에서도 신규 row를 보지 못하고 중복 insert를 시도할 여지가 있다.
    • 영향:
      • 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(member_id)로 간헐적 실패 가능.
    • 제안:
      • 잠금 획득을 선행한 뒤 선호 row를 조회하도록 순서를 변경하거나,
      • 선호 row 조회 자체를 FOR UPDATE로 수행하거나,
      • unique 충돌 예외를 잡아 재조회 후 반환하는 idempotent fallback을 추가.
  • 일반 코드리뷰 코멘트

    • 정책/저장 로직을 MemberContentPreferenceService로 집중시킨 방향은 유지보수 관점에서 일관성이 좋다.
    • 다만 정책 계산이 "요청 국가"에 의존하는 경로는 캐시 키·무효화 정책과 항상 같이 검토되어야 하며, 해당 항목은 운영 이슈 재발 방지를 위해 테스트(국가 전환 + 캐시 적중)까지 고정하는 것을 권장한다.

코드리뷰 재검증 보강 (2026-03-27)

  • 무엇을:

    • 앞서 기록한 잠재 버그 2건을 실제 구현 파일 기준으로 재검토하고, 재현 전제와 우선순위를 보강했다.
  • 왜:

    • 현재 브랜치에 추가 수정(리팩터링/테스트 보강)이 포함되어 있어, 기존 리뷰 결론의 유효성을 재확인할 필요가 있었기 때문이다.
  • 어떻게:

    • 확인 파일:
      • src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt
      • src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt
      • src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt
      • src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt
    • 결과:
      • 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
        • 근거: LiveRecommendService.getRecommendLive의 캐시 키가 memberId만 사용('getRecommendLive:' + memberId)하고, 조회 결과는 getStoredPreference(member).isAdult(요청 국가 영향)로 달라질 수 있다.
        • 전제: 동일 회원의 요청 국가가 TTL(3시간) 내 변경되는 환경.
      • 잠재 버그 2(초기 생성 경쟁 시 중복 insert 위험)도 여전히 유효하다.
        • 근거: initializeDefaultPreferencefindByMemberId(비잠금 조회) 이후 findByIdForUpdate(member)를 잡고, 다시 findByMemberId(비잠금 조회)를 수행한다. MySQL REPEATABLE READ에서는 최초 스냅샷 영향으로 잠금 이후 재조회가 최신 row를 못 보고 중복 insert를 시도할 수 있다.
        • 전제: 동일 회원 최초 접근이 동시 다발적으로 발생하는 경쟁 구간.
  • 우선순위 제안:

    • P1: 잠재 버그 2 완화(간헐적 DB unique 충돌/500 위험) — 사용자 오류로 직접 노출될 수 있어 우선 대응 권장.
    • P2: 잠재 버그 1 보강(국가 전환 환경에서 정책 불일치 가능) — 운영 트래픽 특성(국가 전환 빈도)에 따라 단계 적용.

11차 작업 계획 (코드리뷰 잠재 버그 2건 보강, 2026-03-27)

  • 추천 라이브 캐시 키를 memberId + isAdult 기준으로 분리하고 무효화 키와 테스트를 동기화한다.
  • 선호 초기 row 생성 경로를 잠금 재조회 + unique 충돌 재조회 방식으로 보강한다.
  • 관련 타깃 테스트 및 전체 검증(ktlintCheck, test, build)을 수행한다.

11차 보강 구현 (잠재 버그 1/2 대응, 2026-03-27)

  • 무엇을:
    • 잠재 버그 1 대응:
      • LiveRecommendService의 추천 조회 캐싱을 별도 빈 LiveRecommendCacheService로 분리하고, 캐시 키를 getRecommendLive:{memberId}:{isAdult} 형식으로 변경했다.
      • 선호/차단 기반 무효화 경로(MemberContentPreferenceService, MemberService)를 :false, :true 키 양쪽 삭제로 확장했고, 롤링 배포 중 잔존 캐시 정리를 위해 기존 getRecommendLive:{memberId} 키 삭제도 함께 유지했다.
      • 관련 테스트(MemberContentPreferenceServiceTest, MemberServiceCacheEvictionTest)를 신규 키 형식 기준으로 갱신했다.
    • 잠재 버그 2 대응:
      • MemberContentPreferenceRepositoryfindByMemberIdForUpdate를 추가해 잠금 재조회 경로를 명시했다.
      • MemberContentPreferenceService.initializeDefaultPreferencefindByMemberId -> member lock -> findByMemberIdForUpdate -> saveAndFlush로 보강하고, unique 충돌(DataIntegrityViolationException) 발생 시 재조회 후 반환하도록 fallback을 추가했다.
      • 경쟁 시나리오 회귀용 테스트(shouldReturnStoredRowWhenDuplicateInsertOccurs)를 추가했다.
  • 왜:
    • 동일 회원의 요청 정책 결과(isAdult)가 달라질 수 있는데 캐시 키가 memberId만 사용하면 stale 응답이 재사용될 수 있고, REPEATABLE READ 환경에서 최초 동시 생성 경쟁 시 unique 충돌이 간헐적으로 사용자 오류로 노출될 수 있기 때문이다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
      • ./gradlew ktlintCheck test build
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest.shouldEvictRecommendLiveCacheForRequesterAndTargetOnBlock" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest.shouldDelegateToRepositoryWithAdultFlagWhenMemberIsAuthenticated"
    • 결과:
      • 타깃 테스트 통과.
      • 전체 검증(ktlintCheck, test, build) 통과.
      • 핵심 수동 QA 성격 시나리오(중복 insert fallback, 차단 시 양쪽 캐시 무효화, 성인 플래그 전달 조회) 통과.
      • Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.

정정 (2026-03-27, 11차 중간 수정)

  • 무엇을:
    • 11차 1차 테스트에서 MemberContentPreferenceServiceTest 검증문이 save를 확인하고 있어 실패한 항목을 saveAndFlush 검증으로 정정했다.
  • 왜:
    • 동시성 보강 과정에서 서비스 저장 호출이 save에서 saveAndFlush로 변경되었기 때문이다.
  • 어떻게:
    • 실패 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
    • 조치:
      • MemberContentPreferenceServiceTest.shouldCreateDefaultPreferenceWhenRowIsMissing 검증 대상을 saveAndFlush로 교체
    • 재실행:
      • 동일 타깃 테스트 명령 재실행 통과

12차 잠재 버그 재점검 (보강 후 재검토, 2026-03-27)

  • 무엇을:
    • 11차 보강 코드 재검토 중 initializeDefaultPreference의 unique 충돌 fallback 재조회가 비잠금 조회(findByMemberId)로 남아 있던 지점을 추가 보강했다.
    • fallback 재조회를 findByMemberIdForUpdate로 변경해, REPEATABLE READ 스냅샷 영향으로 row를 못 보는 가능성을 낮췄다.
    • 회귀 테스트(MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs)의 목 시퀀스를 변경된 fallback 호출 순서에 맞게 업데이트했다.
  • 왜:
    • 충돌 예외 이후 같은 트랜잭션에서 비잠금 재조회를 수행하면 스냅샷 일관성 때문에 최신 row를 못 보고 예외 재전파로 끝날 수 있어, 충돌 복구 경로의 신뢰성을 높일 필요가 있었기 때문이다.
  • 어떻게:
    • 명령:
      • ./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
      • ./gradlew ktlintCheck test build
    • 결과:
      • preference 서비스/통합 타깃 테스트 통과.
      • 전체 검증(ktlintCheck, test, build) 통과.
      • Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.