docs(live): 현재 진행 중인 라이브 조회 API 계획을 추가한다

This commit is contained in:
2026-06-26 22:48:10 +09:00
parent f2be184fc9
commit 8ae48d7e67
2 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
# 현재 진행 중인 라이브 조회 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/home/on-air-lives`로 현재 진행 중인 라이브를 20개씩 페이징 조회한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.live` 조립 계층에 둔다. 도메인 조회는 기존 `kr.co.vividnext.sodalive.v2.recommendation``HomeRecommendationQueryService``HomeRecommendationQueryPort.findLiveRecommendations(...)`를 확장 재사용한다. 기존 추천 탭 공개 응답 DTO는 변경하지 않고, 신규 endpoint에서만 `title`, `price`, `beginDateTimeUtc`를 포함한 응답 DTO로 조립한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 확정 사항
- API endpoint: `GET /api/v2/home/on-air-lives`
- 인증 정책: 인증 회원만 조회 가능
- 비회원/anonymous 요청: 기존 인증 필요 API와 동일하게 인증 오류 반환
- 응답 wrapper: `ApiResponse.ok(...)`
- query parameter: `page`만 받음, 기본값 `0`
- page size: 항상 20개 고정, 클라이언트가 `size`를 지정하지 않음
- page 응답: `items`, `page`, `size`, `hasNext`
- `hasNext` 판정: 내부에서 `PAGE_SIZE + 1`개 조회 후 응답에는 최대 20개만 노출
- 현재 진행 중인 라이브 조건: `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''`
- 정렬: `live_room.begin_date_time desc`, `live_room.id desc`
- 방송자 조건: `member.is_active = true`
- 차단 정책: 요청 회원과 크리에이터의 양방향 활성 차단 관계 제외
- 성인 라이브 정책: `MemberContentPreferenceService.canViewAdultContent(member)` 결과 반영
- 시작 시간 응답: `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 응답
- 프로필 이미지: 기존 홈 추천 패턴과 동일하게 CDN URL 변환, 없으면 기본 프로필 이미지 URL
- 기존 공개 API 스키마 유지:
- `GET /api/v2/home/recommendations`
- `GET /api/v2/home/recommendations/lives`
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
### 기존 도메인 조회 계층 확장
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
### 기존 설정 수정
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
### 문서
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
---
## 2. Response data class 초안
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`에 아래 DTO를 추가한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.home.live.dto
data class HomeOnAirLivePageResponse(
val items: List<HomeOnAirLiveResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class HomeOnAirLiveResponse(
val roomId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val beginDateTimeUtc: String
)
```
`src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`의 기존 record는 아래처럼 확장한다.
```kotlin
package kr.co.vividnext.sodalive.v2.recommendation.port.out
import java.time.LocalDateTime
data class HomeLiveRecommendationRecord(
val liveRoomId: Long,
val creatorNickname: String,
val creatorProfileImage: String?,
val title: String,
val price: Int,
val beginDateTime: LocalDateTime
)
```
기존 `HomeRecommendationFacade.toItem()``title`, `price`를 무시하고 기존 `HomeLiveItem` 필드만 매핑해 기존 API 응답 스키마를 유지한다.
---
### Phase 1: 도메인 조회 record 확장
- [ ] **Task 1.1: 라이브 추천 record에 title/price/beginDateTime 포함**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `DefaultHomeRecommendationQueryRepositoryTest``shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery` 테스트를 추가한다. fixture는 `LiveRoom(title = "paid live", price = 30, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), channelName = "channel")`를 저장하고, `findLiveRecommendations(offset = 0, limit = 1, memberId = viewer.id, includeAdultLives = true)` 결과의 `title == "paid live"`, `price == 30`, `beginDateTime == LocalDateTime.of(2026, 6, 26, 12, 30)`을 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `HomeLiveRecommendationRecord``title`, `price`, `beginDateTime`을 추가하고, QueryDSL projection에 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime`을 추가한다.
- REFACTOR: 기존 `HomeRecommendationFacade.toItem()`과 기존 테스트 컴파일 오류를 수정하되 `HomeLiveItem` 공개 필드는 추가하지 않는다.
- 기대 결과: repository 테스트가 PASS이고 기존 추천 탭 응답 DTO에는 `title`, `price`, `beginDateTimeUtc`가 추가되지 않는다.
- [ ] **Task 1.2: 기존 라이브 조회 조건 회귀 테스트 보강**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: 기존 `shouldFindPagedLiveRecommendationsWithAdultFilter` 테스트를 확장하거나 별도 `shouldApplyOnAirLiveVisibilityPolicy` 테스트를 추가한다. 활성 방송자/비활성 방송자, `channelName = null`, 빈 `channelName`, `isActive = false`, 성인 라이브, 양방향 차단 라이브를 fixture로 만들고 조건에 맞는 라이브만 최신순으로 반환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 기존 조회 조건이 부족하면 `member.isActive.isTrue`, `liveRoom.channelName.isNotNull`, `liveRoom.channelName.isNotEmpty`, `includeAdultLiveCondition(...)`, `notBlockedCreatorCondition(...)`을 보강한다.
- REFACTOR: 중복 조건은 기존 private condition 함수로 유지하고 신규 abstraction은 추가하지 않는다.
- 기대 결과: 진행 중 라이브 조회 정책이 PRD의 노출 조건과 일치한다.
- [ ] **Task 1.3: HomeRecommendationQueryService 위임 계약 유지**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- RED: `shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag` 테스트를 추가한다. mock `HomeRecommendationQueryPort``HomeLiveRecommendationRecord(liveRoomId = 1L, creatorNickname = "creator", creatorProfileImage = "profile.png", title = "live", price = 10, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30))`을 반환하도록 하고, service가 `offset`, `limit`, `memberId`, `includeAdultLives`를 그대로 port에 전달하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 컴파일 오류가 있으면 record 생성부와 import를 갱신한다. service 메서드 시그니처는 기존 `findLiveRecommendations(offset, limit, memberId, includeAdultLives)`를 유지한다.
- REFACTOR: service에는 신규 API 전용 page 조립 로직을 넣지 않는다.
- 기대 결과: 도메인 조회 계층은 API DTO에 의존하지 않고 기존 port record만 반환한다.
### Phase 2: 신규 API 조립 계층
- [ ] **Task 2.1: 신규 응답 DTO와 직렬화 테스트 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
- RED: `shouldSerializeOnAirLivePageResponse` 테스트를 작성한다. `HomeOnAirLivePageResponse(items = listOf(HomeOnAirLiveResponse(...)), page = 0, size = 20, hasNext = true)`를 Jackson으로 직렬화하고 `items[0].roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page`, `size`, `hasNext` 필드가 존재하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- GREEN: PRD의 Response data class와 동일한 DTO를 추가한다.
- REFACTOR: DTO에는 도메인 조회나 CDN 변환 로직을 넣지 않는다.
- 기대 결과: 공개 응답 필드명이 PRD와 일치한다.
- [ ] **Task 2.2: HomeOnAirLiveFacade 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
- RED: `shouldReturnFixedSizePageAndHasNext` 테스트를 작성한다. mock `HomeRecommendationQueryService`가 21개 record를 반환하게 하고, facade가 `page = 0`, `size = 20`, `hasNext = true`, `items.size = 20`을 반환하는지 검증한다. `offset = 0`, `limit = 21`, `memberId = member.id`, `includeAdultLives = true` 호출도 검증한다.
- RED: `shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank` 테스트를 작성한다. `creatorProfileImage = null`인 record가 `https://cdn.test/profile/default-profile.png`로 매핑되는지 검증한다.
- RED: `shouldMapBeginDateTimeToUtcIsoString` 테스트를 작성한다. record의 `beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)`가 응답 `beginDateTimeUtc = "2026-06-26T12:30:00Z"`로 변환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- GREEN: `HomeOnAirLiveFacade``@Component`로 추가한다. 생성자에는 `HomeRecommendationQueryService`, `MemberContentPreferenceService`, `@Value("\${cloud.aws.cloud-front.host}") cloudFrontHost`를 주입한다.
- GREEN: `getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse`를 구현하고, 내부 상수는 `PAGE_SIZE = 20`, `MAX_PAGE = 10_000`으로 둔다.
- GREEN: `page.coerceIn(0, MAX_PAGE)`로 page를 보정하고, `offset = normalizedPage * PAGE_SIZE`, `limit = PAGE_SIZE + 1`로 조회한다.
- REFACTOR: CDN URL 변환은 기존 홈 추천의 `profileImageUrl(cloudFrontHost, path)` 의미와 동일하게 유지한다. 시작 시간 UTC 문자열 변환은 기존 `toUtcIso` 의미와 동일하게 유지한다. 해당 helper들이 package-private이라 재사용이 어렵다면 facade 내부 private 함수로 최소 복제한다.
- 기대 결과: facade가 page 조립, 성인 노출 플래그 계산, DTO 매핑만 담당한다.
- [ ] **Task 2.3: HomeOnAirLiveController 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- RED: `shouldRejectAnonymousRequest` 테스트를 작성한다. `GET /api/v2/home/on-air-lives`를 인증 없이 호출하면 401 Unauthorized가 반환되는지 검증한다.
- RED: `shouldPassAuthenticatedMemberAndPageToFacade` 테스트를 작성한다. `with(user(MemberAdapter(member)))``GET /api/v2/home/on-air-lives?page=2`를 호출하고 facade가 member와 page 2를 받으며 `$.data.size == 20` 응답을 반환하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- GREEN: `@RestController`, `@RequestMapping("/api/v2/home/on-air-lives")` controller를 추가한다. `@GetMapping` 메서드는 `@RequestParam(defaultValue = "0") page: Int``@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 받는다.
- GREEN: `member ?: throw SodaException(messageKey = "common.error.bad_credentials")`로 인증 회원을 요구하고, `ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(member, page))`를 반환한다.
- REFACTOR: controller에는 조회 조건/응답 매핑 로직을 넣지 않는다.
- 기대 결과: 신규 endpoint는 인증 회원만 접근 가능하고 기존 `ApiResponse.ok(...)` wrapper를 따른다.
### Phase 3: 보안 설정과 회귀 검증
- [ ] **Task 3.1: SecurityConfig에 인증 필요 endpoint 등록**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
- RED: `HomeOnAirLiveControllerTest.shouldRejectAnonymousRequest``SecurityConfig` 적용 상태에서 401을 기대하도록 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- GREEN: `SecurityConfig``GET /api/v2/home/on-air-lives``authenticated()` 경로로 추가한다. `permitAll`에는 추가하지 않는다.
- REFACTOR: 기존 `/api/v2/home/recommendations` permitAll과 `/api/v2/home/recommendations/**` authenticated 정책을 변경하지 않는다.
- 기대 결과: 현재 진행 중인 라이브 신규 API는 인증 필수이고, 기존 추천 탭 통합 조회와 전체보기 API의 기존 보안 정책은 변경되지 않는다.
- [ ] **Task 3.2: 기존 추천 탭 응답 스키마 회귀 테스트**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- RED: `shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc` 테스트를 추가한다. `HomeLiveItem(roomId = 1L, creatorNickname = "creator", creatorProfileImage = "https://cdn.test/profile.png")`를 직렬화하고 `title`, `price`, `beginDateTimeUtc` 필드가 없음을 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: `HomeRecommendationFacade`의 기존 `HomeLiveRecommendationRecord.toItem()` 매핑은 `roomId`, `creatorNickname`, `creatorProfileImage`만 사용하도록 유지한다.
- REFACTOR: 신규 API DTO와 기존 추천 탭 DTO import가 섞이지 않도록 패키지를 명확히 유지한다.
- 기대 결과: 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마는 변경되지 않는다.
- [ ] **Task 3.3: End-to-end 조회 검증**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: `shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc` 통합 테스트를 작성한다. 인증 회원, 활성 크리에이터, 진행 중 라이브 2개를 저장하고 `GET /api/v2/home/on-air-lives?page=0` 호출 결과에서 최신순, `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page = 0`, `size = 20`, `hasNext = false`를 검증한다.
- RED: `shouldExcludeAdultLiveWhenViewerCannotViewAdultContent` 통합 테스트를 작성한다. 성인 콘텐츠 노출 불가 회원 기준으로 성인 라이브가 제외되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
- GREEN: controller, facade, query repository 연결을 보강해 통합 테스트를 통과시킨다.
- REFACTOR: 테스트 fixture helper는 해당 테스트 클래스 내부 private 함수로 두고, 공용 테스트 유틸은 만들지 않는다.
- 기대 결과: 실제 Spring MVC, Security, JPA/QueryDSL 경로로 신규 API 요구사항이 검증된다.
### Phase 4: 최종 검증과 문서 기록
- [ ] **Task 4.1: 단일/회귀 테스트 실행 및 기록**
- Files:
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
- RED: 신규/수정 테스트가 모두 구현된 상태에서 아래 명령을 실행한다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 실패 확인: 실패가 있으면 해당 task로 돌아가 원인을 수정한다.
- GREEN: 신규 API 관련 단일 테스트가 모두 PASS인지 확인한다.
- REFACTOR: `./gradlew ktlintCheck`를 실행해 포맷 위반을 확인한다.
- 회귀 확인: `./gradlew test`를 실행해 전체 테스트 회귀를 확인한다.
- 기대 결과: 단일 테스트, ktlint, 전체 테스트 결과를 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 4.2: 문서 동기화 확인**
- Files:
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
- RED: 구현 중 endpoint, response field, 인증 정책, page size가 바뀌었는지 확인한다.
- 실패 확인: PRD와 구현이 다르면 구현 전에 PRD와 plan-task를 먼저 갱신한다.
- GREEN: 변경 사항이 없으면 문서 경로와 검증 결과만 유지한다.
- REFACTOR: `./gradlew tasks --all`을 실행해 문서 유지보수 규칙의 명령 유효성을 확인한다.
- 기대 결과: PRD와 plan-task가 같은 endpoint, response data class, 인증 정책, 페이징 정책을 설명한다.
---
## 4. 실행 명령
- 컨트롤러 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
- facade 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
- DTO 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
- repository 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- query service 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 신규 API E2E 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
- 포맷 검증: `./gradlew ktlintCheck`
- 전체 회귀 테스트: `./gradlew test`
- Gradle 명령 유효성 확인: `./gradlew tasks --all`
---
## 5. 검증 기록
- 문서 작성 시점에는 구현을 진행하지 않았으므로 테스트 실행 기록은 없다.
- 2026-06-26 문서 작성 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-26 `beginDateTimeUtc` 응답 필드 문서 보강 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,186 @@
# PRD: 현재 진행 중인 라이브 조회 API
## 1. Overview
메인 홈에서 현재 진행 중인 라이브 목록을 20개씩 페이징 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 메인 홈 추천 탭 통합 API는 상단에 현재 진행 중인 라이브를 일부 내려주지만, 별도 목록 조회에 필요한 응답 필드가 부족하다.
- 기존 `GET /api/v2/home/recommendations/lives``roomId`, `creatorNickname`, `creatorProfileImage`만 내려주며 이번 요구사항의 `title`, `price`, `beginDateTimeUtc`를 포함하지 않는다.
- 기존 공개 API 스키마를 변경하면 클라이언트 회귀 영향이 생길 수 있으므로, 신규 API 계약을 별도로 명시해야 한다.
- 기존 v2 홈 추천/팔로잉 탭에는 현재 진행 중인 라이브 조회 조건과 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재활용해야 한다.
---
## 3. Goals
- 현재 진행 중인 라이브 목록 조회 API를 `kr.co.vividnext.sodalive.v2` 하위에 제공한다.
- 한 page당 20개씩 조회한다.
- 응답 item에는 `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`를 포함한다.
- 기존 패턴과 동일하게 클라이언트 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
- 기존 메인 홈 추천 탭의 라이브 조회 조건을 최대한 재사용한다.
- 인증 회원만 조회할 수 있게 하고, 회원별 차단/성인 콘텐츠 노출 조건을 반영한다.
- 기존 공개 API 응답 스키마는 변경하지 않는다.
---
## 4. Non-Goals
- 기존 `GET /api/v2/home/recommendations` 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마를 변경하지 않는다.
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
- 라이브 추천 산식, 스냅샷, 랭킹, 배너 정책은 변경하지 않는다.
- 앱 표시용 가격 단위, 다국어 문구, 날짜 포맷은 서버에서 처리하지 않는다.
- 20개 외 page size를 클라이언트가 지정하는 기능은 이번 범위에 포함하지 않는다.
---
## 5. Target Users
- 회원: 메인 홈에서 현재 진행 중인 라이브 목록을 더 탐색하는 사용자
- 앱 클라이언트: 현재 라이브 목록 화면 또는 추천 탭의 추가 로딩 화면을 구성하는 클라이언트
---
## 6. User Stories
- 사용자는 메인 홈에서 현재 진행 중인 라이브를 20개씩 추가로 보고 싶다.
- 사용자는 라이브 제목과 가격을 목록에서 바로 확인하고 싶다.
- 앱 클라이언트는 다음 page 존재 여부를 응답에서 확인해 무한 스크롤 또는 더보기 UI를 구성하고 싶다.
- 앱 클라이언트는 기존 추천 탭 상단 라이브와 동일한 노출 정책으로 별도 목록을 조회하고 싶다.
---
## 7. Core Features
### Feature A. 현재 진행 중인 라이브 목록 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/home/on-air-lives`로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- `page` query parameter를 받는다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `size` query parameter는 받지 않고, page size는 항상 20으로 고정한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단한다.
- 응답 목록에는 최대 20개만 내려준다.
- 인증 회원만 조회할 수 있다.
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
- `member == null`이면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
- 방송자는 `member.is_active = true`인 대상만 노출한다.
- 정렬은 기존 홈 추천 라이브와 동일하게 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
- 양방향 차단 관계가 있는 크리에이터의 라이브는 제외한다.
- 성인 라이브 노출 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 따른다.
- 프로필 이미지는 기존 홈 추천/팔로잉 탭과 동일하게 CDN URL로 변환하고, 값이 없으면 기본 프로필 이미지 URL을 내려준다.
#### Edge Cases
- 조회 결과가 없으면 `items = emptyList()`, `hasNext = false`를 내려준다.
- 비회원이 조회하면 목록을 내려주지 않고 인증 오류를 반환한다.
- `page`가 0보다 작으면 기존 홈 추천 컨트롤러의 `normalizePage` 패턴과 동일하게 0으로 보정한다.
- 매우 큰 `page` 값은 기존 홈 추천 컨트롤러의 `MAX_PAGE = 10_000` 패턴과 동일하게 상한 보정한다.
- 20개보다 적게 조회되면 가능한 개수만 내려주고 성공 처리한다.
- 라이브 제목이 빈 문자열이면 별도 fallback을 만들지 않고 저장된 `LiveRoom.title` 값을 그대로 내려준다.
- 라이브 가격은 `LiveRoom.price` 값을 그대로 내려준다.
- 라이브 시작 시간은 `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 내려준다.
### Feature B. 계층 분리와 재사용 정책
#### Requirements
- 클라이언트 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.live` 하위에 둔다.
- API 조립 계층 후보 파일은 다음과 같다.
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
- 도메인 조회 계층은 기존 `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService``HomeRecommendationQueryPort.findLiveRecommendations(...)` 확장 재사용을 기본안으로 한다.
- 기존 `HomeLiveRecommendationRecord``title`, `price`, `beginDateTime`을 추가해 신규 API DTO로 조립할 수 있게 한다.
- 기존 `HomeLiveItem`은 기존 필드만 매핑해 기존 추천 탭 공개 응답 스키마를 유지한다.
- 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`의 조회 조건과 정렬을 유지하되 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime` select를 추가한다.
- API 조립 계층은 도메인 조회 결과를 공개 응답 DTO로 변환하고, CDN URL 변환/기본 프로필 이미지 정책은 기존 홈 추천 패턴을 따른다.
#### Edge Cases
- `HomeRecommendationQueryService` 확장으로 추천 도메인 결합이 과도하다고 판단되면 구현 계획 단계에서 `kr.co.vividnext.sodalive.v2.home.live` 하위 전용 query service/port/repository를 만들 수 있다. 이 경우에도 기존 조회 조건, 정렬, 테스트 케이스는 동일하게 유지한다.
- 기존 record 확장 시 생성자 projection 순서와 모든 매핑 호출부를 함께 수정해야 한다.
### Feature C. Response 스키마
#### Requirements
- 응답 최상위 DTO 이름은 `HomeOnAirLivePageResponse`를 기본안으로 한다.
- 응답 item DTO 이름은 `HomeOnAirLiveResponse`를 기본안으로 한다.
- 응답 item은 다음 값을 포함한다.
- `roomId`: 라이브 방 id
- `creatorNickname`: 방송자 닉네임
- `creatorProfileImage`: 방송자 프로필 이미지 CDN URL
- `title`: 라이브 제목
- `price`: 라이브 입장 가격
- `beginDateTimeUtc`: 라이브 시작 시간 UTC ISO 문자열
- page metadata는 기존 `HomeRecommendationPageResponse`와 동일한 의미로 `page`, `size`, `hasNext`를 포함한다.
- `size`는 항상 `20`으로 내려준다.
#### Edge Cases
- `creatorProfileImage` 원본 값이 없으면 기본 프로필 이미지 CDN URL을 내려준다.
- `price`가 무료이면 `0`을 내려준다.
- `beginDateTimeUtc``LiveRoom.beginDateTime`을 UTC ISO 문자열로 변환한 값으로 내려준다.
---
## 8. API Endpoint
```http
GET /api/v2/home/on-air-lives?page=0
Authorization: Bearer {accessToken}
```
- `page`: 선택값, 기본값 `0`, 0부터 시작하는 page index
- `size`: 받지 않음, 서버에서 20으로 고정
- `SecurityConfig``GET /api/v2/home/on-air-lives` authenticated 설정을 추가한다.
- 회원 token이 없거나 anonymous이면 기존 인증 필요 API와 동일하게 인증 오류를 반환한다.
- 인증 회원 기준으로 차단/성인 콘텐츠 노출 조건을 반영한다.
---
## 9. Response Data Class
```kotlin
data class HomeOnAirLivePageResponse(
val items: List<HomeOnAirLiveResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class HomeOnAirLiveResponse(
val roomId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val beginDateTimeUtc: String
)
```
---
## 10. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 신규 코드는 기존 v2 패키지 구조와 네이밍을 따른다.
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.*` 하위에 두고, 재사용 가능한 조회 책임은 API 패키지 밖 도메인 조회 계층에 둔다.
- 기존 `ApiResponse.ok(...)` 응답 wrapper를 사용한다.
- QueryDSL 기반 조회 패턴을 유지한다.
- 공개 API 스키마 변경은 신규 endpoint에만 한정한다.
- 구현 계획 단계에서는 TDD 기준으로 controller/facade/query repository 테스트를 작성한 뒤 최소 구현한다.
---
## 11. Reuse Candidates
- `HomeRecommendationController`: page 정규화, `ApiResponse.ok(...)`, `requireMember(...)` 인증 필수 패턴 참고
- `HomeRecommendationFacade`: `size + 1` 조회 후 `hasNext`를 판단하는 page 응답 조립 패턴 참고
- `HomeRecommendationPageResponse`: page metadata 의미 참고
- `HomeRecommendationQueryService.findLiveRecommendations(...)`: 현재 진행 중인 라이브 도메인 조회 진입점으로 확장 재사용
- `HomeRecommendationQueryPort.HomeLiveRecommendationRecord`: `title`, `price`, `beginDateTime`을 추가해 신규 API 응답 조립에 재사용
- `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`: 진행 중 라이브 조건, 정렬, 차단 필터, 성인 라이브 필터 재사용
- `HomeFollowingLive`/`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`: `title` 포함 라이브 응답 모델링과 CDN URL 변환 패턴 참고
- `LiveRoom`: `title`, `price`, `beginDateTime`, `channelName`, `isAdult`, `isActive` 필드 사용
- `MemberContentPreferenceService.canViewAdultContent(member)`: 성인 라이브 노출 가능 여부 판단
---
## 12. Open Questions
- 없음. 현재 PRD는 인증 회원만 조회 가능, page size 20 고정, 기존 추천 라이브 조건 재사용을 기본 가정으로 작성한다.