59 KiB
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)를 생성한다. - 국가 판별 우선순위:
- 회원 ID 강제 매핑 우선 적용
member.id in [16, 17]→countryCode = "KR"member.id in [2, 29721, 32050, 40850]→countryCode = "JP"
- 그 외 회원은
CloudFront-Viewer-Country기반으로 결정 - 헤더 누락/오작동 시
countryCode = "KR"fallback 적용
- 회원 ID 강제 매핑 우선 적용
- 한국(
countryCode == "KR") 정책:- 저장 시:
member.auth != null일 때만 전달값 반영 - 조회 시:
isAdult = isAdultContentVisible && (member.auth != null)로 계산하고,contentType필터를 함께 적용
- 저장 시:
- 해외(한국 외) 정책:
- 저장 시: 전달받은 값 그대로 저장
- 조회 시:
isAdult = isAdultContentVisible로 계산하고,contentType필터를 함께 적용
AuthController.authVerify본인인증 성공 시isAdultContentVisible = true로 즉시 저장한다.- 주의: 조회 판단은 서버 저장값 기준으로 수행하며, 구버전 호환 구간에서는 기존 파라미터 수신 후 저장값을 갱신해 동일 정책을 적용한다.
- 기존 회원(설정 row 미존재)은 호환 대상 API 호출 시 저장 조건에 따라 row를 생성/갱신하고, 생성 즉시 저장값 기준 조회를 적용한다.
/member/info응답에 아래 필드를 추가한다.countryCodeisAdultContentVisiblecontentType
네이밍 정책 결정 (이번 작업에서 확정)
- 외부 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 = falsecontentType: ContentType = ContentType.ALLadultContentVisibilityChangedAt: 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 = falsecontentType = ContentType.ALLchangedAt초기값 = 생성 시각
3) 기존 isAdultContentVisible 파라미터 수신 API 전체 호환 저장
- 호환 대상 API(4-1, 4-2 목록)에서 기존 파라미터 수신 후 저장 처리
- 대표 진입점 구현/검증
src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.ktsrc/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.ktsrc/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.ktsrc/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) → 접속 국가 헤더 →
KRfallback 순서를 따른다. isAdultContentVisible/contentType변경 시changedAt갱신 규칙(동일값 재저장 미갱신)을 동일 적용한다.
- 회원 설정 row가 없으면 기본값(
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갱신, 동일값이면 미갱신
- 설정 row 미존재 시 기본 row 생성 후
- 실패/차단 시나리오
isBlockAuth(...)로 차단되어 예외가 발생한 경우 저장하지 않는다.- 본인인증 실패 예외 흐름에서는 저장하지 않는다.
4) 콘텐츠/라이브/채팅 조회 경로를 저장값 기반으로 전환
4-1. 홈/라이브 진입점
/api/home계열HomeController.kt,HomeService.kt
/api/liveLiveApiController.kt,LiveApiService.kt- 연계 추천 경로:
LiveRecommendService.kt,LiveRecommendRepository.kt
/live/roomLiveRoomController.kt,LiveRoomService.kt
4-2. 파라미터 수신 컨트롤러 전수 목록(저장값 기반 전환 대상)
- 참고:
/api/home,/api/live,/live/room은 4-1에서 별도 관리하며, 아래는 그 외 컨트롤러 전수 목록 isAdultContentVisible+contentType를 둘 다 받는 컨트롤러AudioContentController.ktAudioContentMainController.ktAudioContentCurationController.ktAudioContentThemeController.ktSearchController.ktContentSeriesController.ktSeriesMainController.ktAudioContentMainTabHomeController.ktAudioContentMainTabContentController.ktAudioContentMainTabFreeController.ktAudioContentMainTabAsmrController.ktAudioContentMainTabAlarmController.ktAudioContentMainTabLiveReplayController.ktAudioContentMainTabSeriesController.ktisAdultContentVisible만 받는 컨트롤러(동일 저장값 정책 연계 필요)ExplorerController.kt(/explorer/profile/{id})LiveRoomController.kt(/live/room)
- 컨트롤러 레벨에서
member.auth != null && (isAdultContentVisible ?: true)를 직접 계산하는 구간도 함께 전환AudioContentController.kt,AudioContentMainController.kt,AudioContentThemeController.ktSeriesMainController.kt,AudioContentMainTabContentController.kt,AudioContentMainTabFreeController.ktAudioContentMainTabHomeController.kt,AudioContentMainTabAsmrController.kt,AudioContentMainTabSeriesController.kt,AudioContentMainTabLiveReplayController.kt
4-3. 서비스/쿼리 계층 (실제 필터 적용)
member.auth != null && isAdultContentVisible계산식을 사용하는 서비스 전수 수정HomeService.kt,LiveApiService.kt,LiveRoomService.kt,LiveRecommendService.ktAudioContentService.kt,AudioContentMainService.ktAudioContentMainTabHomeService.kt,AudioContentMainTabContentService.kt,AudioContentMainTabFreeService.ktAudioContentMainTabAsmrService.kt,AudioContentMainTabAlarmService.kt,AudioContentMainTabLiveReplayService.kt,AudioContentMainTabSeriesService.ktAudioContentCurationService.kt,AudioContentThemeService.ktContentSeriesService.kt,SearchService.kt,ExplorerService.kt
AudioContentRepository.kt및 아래 쿼리 레이어의contentType/성인 필터 검증RankingRepository.ktSearchRepository.ktContentSeriesRepository.ktContentSeriesContentRepository.ktAudioContentThemeQueryRepository.ktAudioContentCurationQueryRepository.ktAudioContentMainTabRepository.ktRecommendSeriesRepository.ktContentMainTabTagCurationRepository.ktRecommendChannelQueryRepository.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.ktCharacterCommentController.kt
5) /member/info 응답 확장
- DTO 확장
src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt- 추가:
countryCode,isAdultContentVisible,contentType
- 서비스 확장
MemberService.getMemberInfo(...)에서 저장값 조회 후 응답 주입countryCode는member.countryCode가 아닌 요청 시점 국가 결정값으로 반환- 국가 결정 우선순위:
member.id강제 매핑 (KR: 16, 17 /JP: 2, 29721, 32050, 40850)CountryContext.countryCode(CloudFront-Viewer-Country)- 헤더 누락/오작동 시
KR
- 인프라 전제: CloudFront에서
CloudFront-Viewer-Country헤더를 오리진으로 전달하도록 설정되어 있어야 한다. - 캐시 주의: 국가별 응답이 달라지는 구간은 캐시 키에 국가 헤더를 포함하는지 함께 점검한다.
6) 기본값 true → false 전환
- 기존
?: true기본값 사용 지점 제거 또는 서버 저장값 fallback으로 대체- 전수 대상(18개):
HomeController.kt,LiveApiController.kt,LiveRoomController.kt,ExplorerController.ktAudioContentController.kt,AudioContentMainController.kt,AudioContentCurationController.kt,AudioContentThemeController.ktSearchController.kt,ContentSeriesController.kt,SeriesMainController.ktAudioContentMainTabHomeController.kt,AudioContentMainTabContentController.kt,AudioContentMainTabFreeController.ktAudioContentMainTabAsmrController.kt,AudioContentMainTabAlarmController.kt,AudioContentMainTabLiveReplayController.kt,AudioContentMainTabSeriesController.kt
- 전수 대상(18개):
- fallback 규칙 표준화:
- 저장값 존재 시 저장값 사용
- 저장값 미존재 시 신규 기본값(
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 호환 저장 정책을 따름)
- 처리 순서:
- 회원 설정 테이블에 해당 member row 존재 여부 확인
- row가 없으면 기본값(
isAdultContentVisible=false,contentType=ALL)으로 생성 member.auth != null이면 요청으로 받은 값으로 갱신member.auth == null이면 기본값을 그대로 유지(요청값으로 갱신하지 않음)
- 필요 시 배치/스크립트 실행
- 기본값:
- 단계적 배포
- 저장 모델 배포 + 백필
- 직접 설정 API 배포 +
authVerify성공 연동 배포 - 호환 파라미터 수신 저장 전환(기존
isAdultContentVisible파라미터 수신 API 전체) - 조회 경로 저장값 전환 +
/member/info확장 배포 - 호환 파라미터 종료 조건 문서화(구버전 비율/공지/제거 시점)
1차 배포 구현 우선순위 (실행 순서 재정렬)
- 0단계: 정책 고정
- 국가 판별 우선순위 확정:
member.id강제 매핑(KR: 16,17 / JP: 2,29721,32050,40850) → 접속 국가 헤더 →KRfallback - 기존 회원 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 강제 매핑 우선 적용 후 접속 국가/KRfallback 적용
- 응답 필드
- 9단계: 기본값 true → false 전수 치환
- 컨트롤러 18개
isAdultContentVisible ?: true제거 - 저장값 우선 + 미존재 시
false/ALL보정 저장으로 표준화
- 컨트롤러 18개
- 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은 헤더와 무관하게KRmember.id=2,29721,32050,40850은 헤더와 무관하게JP- 그 외 회원은
CloudFront-Viewer-Country사용, 누락 시KRfallback
- 한국/해외 저장 정책 분기 테스트
- 한국 +
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)으로 반환되는지 확인- 콘텐츠/라이브/채팅 캐릭터 조회 결과 정책 반영 확인
- 직접 설정 API(
- 회귀 검증 명령
./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) + 헤더 +KRfallback 규칙을 서비스 단일 경로로 구현했다.- 회원가입/소셜가입(
signUpV2,signUp,findOrRegister4종) 직후 기본값(false,ALL) 선저장을 연동했다. PATCH /member/content-preference를 추가하고, 요청값(둘 중 하나 이상) 갱신 및 최신 설정 응답을 구현했다.AuthController.authVerify성공 직후isAdultContentVisible=true저장 연동을 추가했다.- 핵심 트래픽 진입점(
/api/home,/api/live,/live/room,/explorer/profile/{id})을 저장값 기반으로 전환하고,/member/info에countryCode,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차 배포 우선순위와 테스트 계획을 재정렬했다.
- 회원 ID 강제 국가 매핑 정책(KR: 16,17 / JP: 2,29721,32050,40850)과
- 왜:
- 현재 코드는 조회 파라미터 기반(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저장 항목을 구현 범위/우선순위/테스트에 추가했다.
- 직접 설정 API 부재(
- 명령:
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,CharacterCommentController의member.auth강제 분기를MemberContentPreferenceService.getStoredPreference(member).isAdult기반 정책 가드로 전환했다.
- 4-2 전수 대상 컨트롤러(
- 왜:
- legacy 파라미터 기본값(
true) 의존을 제거해 국가/인증 정책이 컨트롤러별로 분산되는 문제를 없애고, 저장값 기준 단일 정책으로 수렴하기 위해서다. - 채팅 캐릭터 연관 경로까지 동일 정책을 적용해 도메인별 예외 분기를 줄이고 운영 일관성을 확보하기 위해서다.
- legacy 파라미터 기본값(
- 어떻게:
- 명령:
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-2 체크리스트 전 항목, 4-4 체크리스트 전 항목, 1차 배포 우선순위 6/7/9단계를 완료 상태(
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를 추가/확장했다.
- 사용자 요청에 따라 정책 분기 의도를 설명하는 주석을 변경 코드의 핵심 분기 지점에 보강했다.
- 4-3 잔여 항목 중 성인 제어의
- 왜:
- 동일 기능 내에서
member.auth직접 분기와 저장 선호 분기가 혼재하면 국가/인증 정책 일관성이 깨질 수 있어, 조회/필터 기준을 저장 선호 정책으로 단일화할 필요가 있었다. - 누락된 성인 필터는 비성인 조회에서 의도치 않은 노출을 만들 수 있어 쿼리 레이어 보완이 필요했다.
- 동일 기능 내에서
- 어떻게:
- 명령:
./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest"./gradlew test./gradlew ktlintCheck./gradlew build
- 결과:
- 초기
test에서MemberServiceContentPreferenceTest2건 실패를 확인했고, 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 생성 + 즉시 반영 - 헤더 누락 시
KRfallback 및 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,getStoredPreference를REQUIRES_NEW트랜잭션으로 분리해LiveRoomService/ExplorerService의readOnly조회 흐름에서도 설정 생성·갱신이 반영되도록 수정했다. - 이슈 2 대응: 선호 변경 경로(
updatePreference,markAdultVisibleAfterAuthVerify, legacyresolveForQuery변경 발생 시)에getRecommendLive캐시 무효화를 연결하고, 커밋 이후에 evict 되도록afterCommit동기화를 적용했다. - 이슈 3 대응:
initializeDefaultPreference에서memberrow를PESSIMISTIC_WRITE로 잠근 뒤 재조회/생성하도록 변경해 동시 최초 요청 경쟁에서도 단일 row만 생성되도록 보강했다. - 테스트 보강:
MemberContentPreferenceServiceTest,MemberContentPreferenceIntegrationTest에 캐시 무효화/충돌 재조회/초기 생성 반영 케이스를 추가했다. - 사용자 요청 반영: 별도 계획 문서를 만들지 않고 기존 문서(
20260325_콘텐츠조회설정서버저장전환.md)에 구현/검증 기록을 누적했다.
- 이슈 1 대응:
- 왜:
- 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헤더 +KRfallback 순서로 통일했다.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차 중간 수정)
- 무엇을:
ktlintCheck1회 실패(테스트 파일 들여쓰기) 후 즉시 수정하고 재검증 결과를 반영한다.
- 왜:
- 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)을 다시 실행해 문서화했다.
- 문서 요구사항(서버 저장값 전환, 국가 정책, legacy 호환 저장, 가입 선저장,
-
왜:
- 체크리스트의 완료 표시(
[x])와 실제 구현 상태의 불일치, 그리고 변경분 내 정책 회귀 가능성을 배포 전에 제거하기 위해서다.
- 체크리스트의 완료 표시(
-
어떻게:
- 명령:
git status --shortgit diff --cached --name-onlygit 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 요청에서도 동일 캐시를 반환.
- 동일 회원이 캐시 TTL(3시간) 내에 국가(
- 영향:
- 국가별 성인 노출 정책 정합성이 깨질 수 있음(특히 요청 국가가 자주 바뀌는 환경/네트워크).
- 제안:
- 캐시 키에 정책 결정값(예:
countryCode또는 최종isAdult)을 포함하거나, - 선호/국가 관련 변경 시 국가 차원을 포함한 캐시 무효화 전략을 추가.
- 캐시 키에 정책 결정값(예:
- 위치:
-
잠재 버그 2 (중요도: 중)
- 위치:
src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.ktinitializeDefaultPreference(...)의 조회 순서(findByMemberId→findByIdForUpdate→findByMemberId)
- 시나리오:
- MySQL 기본 격리수준(REPEATABLE READ)에서 동일 회원에 대한 최초 동시 요청이 들어오면, 첫 비잠금 조회 스냅샷이 유지되어 잠금 이후 재조회에서도 신규 row를 보지 못하고 중복 insert를 시도할 여지가 있다.
- 영향:
- 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(
member_id)로 간헐적 실패 가능.
- 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(
- 제안:
- 잠금 획득을 선행한 뒤 선호 row를 조회하도록 순서를 변경하거나,
- 선호 row 조회 자체를
FOR UPDATE로 수행하거나, - unique 충돌 예외를 잡아 재조회 후 반환하는 idempotent fallback을 추가.
- 위치:
-
일반 코드리뷰 코멘트
- 정책/저장 로직을
MemberContentPreferenceService로 집중시킨 방향은 유지보수 관점에서 일관성이 좋다. - 다만 정책 계산이 "요청 국가"에 의존하는 경로는 캐시 키·무효화 정책과 항상 같이 검토되어야 하며, 해당 항목은 운영 이슈 재발 방지를 위해 테스트(국가 전환 + 캐시 적중)까지 고정하는 것을 권장한다.
- 정책/저장 로직을
코드리뷰 재검증 보강 (2026-03-27)
-
무엇을:
- 앞서 기록한 잠재 버그 2건을 실제 구현 파일 기준으로 재검토하고, 재현 전제와 우선순위를 보강했다.
-
왜:
- 현재 브랜치에 추가 수정(리팩터링/테스트 보강)이 포함되어 있어, 기존 리뷰 결론의 유효성을 재확인할 필요가 있었기 때문이다.
-
어떻게:
- 확인 파일:
src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.ktsrc/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.ktsrc/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.ktsrc/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt
- 결과:
- 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
- 근거:
LiveRecommendService.getRecommendLive의 캐시 키가memberId만 사용('getRecommendLive:' + memberId)하고, 조회 결과는getStoredPreference(member).isAdult(요청 국가 영향)로 달라질 수 있다. - 전제: 동일 회원의 요청 국가가 TTL(3시간) 내 변경되는 환경.
- 근거:
- 잠재 버그 2(초기 생성 경쟁 시 중복 insert 위험)도 여전히 유효하다.
- 근거:
initializeDefaultPreference가findByMemberId(비잠금 조회) 이후findByIdForUpdate(member)를 잡고, 다시findByMemberId(비잠금 조회)를 수행한다. MySQL REPEATABLE READ에서는 최초 스냅샷 영향으로 잠금 이후 재조회가 최신 row를 못 보고 중복 insert를 시도할 수 있다. - 전제: 동일 회원 최초 접근이 동시 다발적으로 발생하는 경쟁 구간.
- 근거:
- 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
- 확인 파일:
-
우선순위 제안:
- 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 대응:
MemberContentPreferenceRepository에findByMemberIdForUpdate를 추가해 잠금 재조회 경로를 명시했다.MemberContentPreferenceService.initializeDefaultPreference를findByMemberId -> member lock -> findByMemberIdForUpdate -> saveAndFlush로 보강하고, unique 충돌(DataIntegrityViolationException) 발생 시 재조회 후 반환하도록 fallback을 추가했다.- 경쟁 시나리오 회귀용 테스트(
shouldReturnStoredRowWhenDuplicateInsertOccurs)를 추가했다.
- 잠재 버그 1 대응:
- 왜:
- 동일 회원의 요청 정책 결과(
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검증으로 정정했다.
- 11차 1차 테스트에서
- 왜:
- 동시성 보강 과정에서 서비스 저장 호출이
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 호출 순서에 맞게 업데이트했다.
- 11차 보강 코드 재검토 중
- 왜:
- 충돌 예외 이후 같은 트랜잭션에서 비잠금 재조회를 수행하면 스냅샷 일관성 때문에 최신 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 검증으로 대체했다.
- 명령: