Files
sodalive-ios/docs/20260630_메인_홈_팔로잉_탭/prd.md

30 KiB

PRD: 메인 홈 팔로잉 탭

1. Overview

메인 홈 화면의 팔로잉 탭에서 신규 API GET /api/v2/home/following 응답을 사용해 팔로잉 크리에이터, On Air 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 표시한다.

기존 MainHomeView는 홈 상단 공통 shell과 추천/랭킹/팔로잉 탭 전환만 담당한다. 팔로잉 탭의 API 호출, 상태 관리, 화면 조립은 SodaLive/Sources/V2/Main/Home/Following/** 하위에서 처리한다.

Figma 전체 화면 기준 섹션 순서는 아래를 따른다.

  1. 팔로잉 크리에이터
  2. On Air
  3. 최근 대화
  4. 이달의 스케줄
  5. 최근 소식

2. Problem

  • 현재 MainHomeFollowingView는 placeholder 상태라 홈의 팔로잉 탭에서 실제 팔로잉 기반 데이터를 제공하지 못한다.
  • 팔로잉 탭은 여러 도메인 데이터를 한 endpoint에서 받지만, 각 섹션의 UI와 진입 액션이 다르므로 화면 파일 하나에 직접 구현하면 유지보수와 검증이 어려워진다.
  • 최근 소식은 FollowingNewsType별로 서로 다른 카드 형태가 필요하다.
  • Figma 화면에는 고정 숫자 width/height가 포함되어 있지만, 앱 구현에서는 화면 width와 콘텐츠에 대응해야 하므로 필요한 경우를 제외하고 고정 크기를 피해야 한다.
  • 로그인하지 않은 사용자는 팔로잉 기반 데이터를 볼 수 없을 수 있으므로 isLoginRequired 상태를 명확히 처리해야 한다.

3. Goals

  • 팔로잉 탭 진입 시 GET /api/v2/home/following을 호출하고 응답 데이터로 화면을 구성한다.
  • 팔로잉 탭 전용 API, Repository, ViewModel, Response model을 SodaLive/Sources/V2/Main/Home/Following/** 아래에 둔다.
  • 기존 V2 공용 컴포넌트와 추천/랭킹 탭 패턴을 우선 재사용한다.
  • 홈 상단 팔로잉 탭 터치 시 로그인 guard를 먼저 적용해 로그인된 사용자만 탭으로 이동한다.
  • isLoginRequired == true이면 팔로잉 기반 콘텐츠 대신 로그인 필요 상태를 표시하고, 사용자가 로그인 화면으로 이동할 수 있어야 한다.
  • 팔로잉 크리에이터 섹션의 가로 목록 마지막 item에는 항상 전체 버튼을 추가한다.
  • 최근 소식은 FollowingNewsType별로 다른 View를 표시한다.
  • 각 섹션은 이후 plan-task.md 작성 시 Figma Design 기준 섹션당 1개의 Phase로 나눌 수 있도록 요구사항을 분리한다.
  • 뷰 전체는 반드시 필요한 경우를 제외하고 고정 숫자 width/height를 갖지 않는다.

4. Non-Goals

  • 추천 탭, 랭킹 탭, 하단 메인 탭 구조를 변경하지 않는다.
  • 기존 구 홈 화면의 API나 UI를 변경하지 않는다.
  • 이번 API 스펙에 없는 페이지네이션, 무한 스크롤, 섹션별 정렬/필터 기능을 추가하지 않는다.
  • Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
  • 서버 응답에 없는 팔로잉 상태 변경, 채팅방 생성, 스케줄 예약/취소 기능을 추가하지 않는다.
  • 기존 AudioContentCard처럼 고정 width 중심으로 설계된 컴포넌트를 요구사항과 맞지 않게 억지 재사용하지 않는다.

5. Target Users

  • 팔로우한 크리에이터의 라이브, 스케줄, 소식을 홈에서 빠르게 확인하려는 사용자
  • 팔로우한 크리에이터의 최근 대화방으로 빠르게 복귀하려는 사용자
  • 팔로우한 크리에이터의 랭킹, 커뮤니티, 오디오, 화보 소식을 한 화면에서 탐색하려는 사용자

6. User Stories

  • 사용자는 홈 팔로잉 탭에서 내가 팔로우한 크리에이터 목록을 보고 싶다.
  • 사용자는 팔로잉 크리에이터 목록 끝의 전체 버튼으로 전체 팔로잉 목록을 보고 싶다.
  • 사용자는 팔로우한 크리에이터가 라이브 중이면 On Air 섹션에서 바로 진입하고 싶다.
  • 사용자는 최근 대화 섹션에서 최근 채팅방을 확인하고 이어서 대화하고 싶다.
  • 사용자는 이달의 스케줄에서 예정된 라이브/콘텐츠와 On Air 여부를 확인하고 싶다.
  • 사용자는 최근 소식에서 랭킹, 커뮤니티, 오디오 콘텐츠, 화보 콘텐츠 소식을 타입에 맞는 UI로 보고 싶다.
  • 로그인하지 않은 사용자는 팔로잉 탭에서 로그인 필요 상태를 이해하고 로그인 화면으로 이동하고 싶다.

7. Core Requirements

7.1 팔로잉 홈 데이터 조회

API

  • Method: GET
  • Path: /api/v2/home/following
  • 인증: Authorization: Bearer {accessToken} 사용. endpoint 자체는 optional 인증을 허용하지만, 클라이언트는 팔로잉 탭 터치 시 로그인 guard를 먼저 적용해 비로그인 상태에서는 API를 호출하지 않고 로그인 화면으로 이동한다.
  • 응답 래퍼: 기존 관례대로 ApiResponse<HomeFollowingTabResponse> 디코딩을 우선한다.

Response

data class HomeFollowingTabResponse(
    val isLoginRequired: Boolean,
    val followingCreators: List<FollowingCreatorResponse>,
    val onAirLives: List<FollowingLiveResponse>,
    val recentChats: List<ChatRoomListItemResponse>,
    val monthlySchedules: List<FollowingScheduleResponse>,
    val recentNews: List<FollowingNewsResponse>
)

data class FollowingCreatorResponse(
    val creatorId: Long,
    val creatorNickname: String,
    val creatorProfileImageUrl: String
)

data class FollowingLiveResponse(
    val liveId: Long,
    val creatorProfileImageUrl: String,
    val creatorNickname: String,
    val title: String,
    val startedAtUtc: String
)

data class ChatRoomListItemResponse(
    val roomId: Long,
    val chatType: String,
    val targetName: String,
    val targetImageUrl: String,
    val lastMessage: String,
    val lastMessageAt: String
)

data class FollowingScheduleResponse(
    val scheduleId: String,
    val creatorId: Long,
    val creatorProfileImageUrl: String,
    val creatorNickname: String,
    val title: String,
    val type: CreatorActivityType,
    val targetId: Long,
    val scheduledAtUtc: String,
    val isOnAir: Boolean
)

enum class CreatorActivityType(val code: String) {
    LIVE("LIVE"),
    AUDIO("AUDIO"),
    COMMUNITY("COMMUNITY"),
    LIVE_REPLAY("LIVE_REPLAY")
}

data class FollowingNewsResponse(
    val newsId: String,
    val type: FollowingNewsType,
    val visibleFromAtUtc: String,
    val creatorRanking: FollowingCreatorRankingNewsResponse?,
    val audioContent: FollowingContentNewsResponse?,
    val photoContent: FollowingContentNewsResponse?,
    val contentRanking: FollowingContentRankingNewsResponse?,
    val communityPost: FollowingCommunityPostNewsResponse?
)

data class FollowingCreatorRankingNewsResponse(
    val rank: Int,
    val creatorId: Long,
    val nickname: String,
    val profileImageUrl: String
)

data class FollowingContentNewsResponse(
    val contentId: Long,
    val contentImageUrl: String?,
    val title: String,
    val creatorProfileImageUrl: String,
    val creatorNickname: String
)

data class FollowingContentRankingNewsResponse(
    val rank: Int,
    val contentId: Long,
    val contentImageUrl: String?,
    val title: String
)

data class FollowingCommunityPostNewsResponse(
    val postId: Long,
    val creatorProfileImage: String,
    val creatorNickname: String,
    val imageUrl: String?,
    val content: String,
    val createdAt: String,
    val likeCount: Int,
    val commentCount: Int
)

enum class FollowingNewsType {
    CREATOR_RANKING,
    CONTENT_RANKING,
    COMMUNITY_POST,
    AUDIO_CONTENT,
    PHOTO_CONTENT
}

Swift 모델 기준

struct HomeFollowingTabResponse: Decodable {
    let isLoginRequired: Bool
    let followingCreators: [FollowingCreatorResponse]
    let onAirLives: [FollowingLiveResponse]
    let recentChats: [ChatRoomListItemResponse]
    let monthlySchedules: [FollowingScheduleResponse]
    let recentNews: [FollowingNewsResponse]
}

struct FollowingCreatorResponse: Decodable, Identifiable {
    let creatorId: Int
    let creatorNickname: String
    let creatorProfileImageUrl: String

    var id: Int { creatorId }
}

struct FollowingLiveResponse: Decodable, Identifiable {
    let liveId: Int
    let creatorProfileImageUrl: String
    let creatorNickname: String
    let title: String
    let startedAtUtc: String

    var id: Int { liveId }
}

struct ChatRoomListItemResponse: Decodable, Identifiable {
    let roomId: Int
    let chatType: String
    let targetName: String
    let targetImageUrl: String
    let lastMessage: String
    let lastMessageAt: String

    var id: Int { roomId }
}

struct FollowingScheduleResponse: Decodable, Identifiable {
    let scheduleId: String
    let creatorId: Int
    let creatorProfileImageUrl: String
    let creatorNickname: String
    let title: String
    let type: CreatorActivityType
    let targetId: Int
    let scheduledAtUtc: String
    let isOnAir: Bool

    var id: String { scheduleId }
}

enum CreatorActivityType: Decodable, Hashable {
    case live
    case audio
    case community
    case liveReplay
    case unknown(String)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let code = try container.decode(String.self)

        switch code {
        case "LIVE":
            self = .live
        case "AUDIO":
            self = .audio
        case "COMMUNITY":
            self = .community
        case "LIVE_REPLAY":
            self = .liveReplay
        default:
            self = .unknown(code)
        }
    }
}

struct FollowingNewsResponse: Decodable, Identifiable {
    let newsId: String
    let type: FollowingNewsType
    let visibleFromAtUtc: String
    let creatorRanking: FollowingCreatorRankingNewsResponse?
    let audioContent: FollowingContentNewsResponse?
    let photoContent: FollowingContentNewsResponse?
    let contentRanking: FollowingContentRankingNewsResponse?
    let communityPost: FollowingCommunityPostNewsResponse?

    var id: String { newsId }
}

struct FollowingCreatorRankingNewsResponse: Decodable {
    let rank: Int
    let creatorId: Int
    let nickname: String
    let profileImageUrl: String
}

struct FollowingContentNewsResponse: Decodable {
    let contentId: Int
    let contentImageUrl: String?
    let title: String
    let creatorProfileImageUrl: String
    let creatorNickname: String
}

struct FollowingContentRankingNewsResponse: Decodable {
    let rank: Int
    let contentId: Int
    let contentImageUrl: String?
    let title: String
}

struct FollowingCommunityPostNewsResponse: Decodable {
    let postId: Int
    let creatorProfileImage: String
    let creatorNickname: String
    let imageUrl: String?
    let content: String
    let createdAt: String
    let likeCount: Int
    let commentCount: Int
}

enum FollowingNewsType: Decodable, Hashable {
    case creatorRanking
    case contentRanking
    case communityPost
    case audioContent
    case photoContent
    case unknown(String)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let code = try container.decode(String.self)

        switch code {
        case "CREATOR_RANKING":
            self = .creatorRanking
        case "CONTENT_RANKING":
            self = .contentRanking
        case "COMMUNITY_POST":
            self = .communityPost
        case "AUDIO_CONTENT":
            self = .audioContent
        case "PHOTO_CONTENT":
            self = .photoContent
        default:
            self = .unknown(code)
        }
    }
}

Long 값은 현재 V2 홈 모델 관례에 맞춰 Swift Int로 선언한다. 운영 데이터에서 이미지 URL이 빈 문자열로 내려올 수 있으므로 이미지 로딩 실패 시 기존 기본 배경/placeholder 처리 패턴을 따른다.

7.2 로그인 필요 상태

  • 홈 상단 팔로잉 탭을 터치할 때 로그인 guard를 먼저 확인한다.
  • 로그인되어 있으면 팔로잉 탭으로 전환한다.
  • 로그인되어 있지 않으면 팔로잉 탭으로 전환하지 않고 기존 AppState.shared.setAppStep(step: .login) 흐름으로 로그인 화면에 이동한다.
  • API 응답의 isLoginRequired == true이면 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션을 표시하지 않는다.
  • isLoginRequired == true는 토큰 만료, 서버 판단 등 탭 진입 전 guard를 통과한 뒤에도 로그인 필요 상태가 내려오는 방어 케이스로 취급한다.
  • 로그인 필요 상태에는 로그인 안내 문구와 로그인 진입 액션을 제공한다.
  • 로그인 버튼 탭 시 기존 AppState.shared.setAppStep(step: .login) 흐름을 사용한다.
  • isLoginRequired == false이면 섹션별 배열이 비어 있는 경우 해당 섹션만 숨긴다.
  • API 실패 시에는 기존 추천/랭킹 탭과 유사하게 사용자에게 과도한 오류 UI를 추가하지 않고 안내 상태를 표시한다.
  • 전체 empty state 문구는 팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.로 표시한다.
  • API 실패 문구는 팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.로 표시한다.

7.3 팔로잉 크리에이터 섹션

Figma 참조:

  • 전체 화면: node-id=24-5682

요구사항:

  • followingCreators를 가로 스크롤 목록으로 표시한다.
  • 각 item은 프로필 이미지와 크리에이터 닉네임을 표시한다.
  • 크리에이터 item 탭 시 creatorId로 기존 크리에이터 상세 화면에 진입한다.
  • 목록 마지막 item에는 항상 전체 버튼을 추가한다.
  • 전체 버튼은 서버 데이터가 비어 있어도 표시한다.
  • 전체 버튼의 width는 버튼이 차지하는 콘텐츠 크기 기준으로 잡고, 고정 화면 width를 강제하지 않는다.
  • 전체 버튼 height는 크리에이터 item 높이와 동일하게 표시한다.
  • 전체 버튼은 paddingHorizontal = 16을 적용한다.
  • 전체 버튼 text color는 Soda/400이며, 코드에서는 Color.soda400을 사용한다.
  • 전체 버튼 탭 시 기존에 만들어진 전체 팔로잉 목록 화면으로 이동한다.

7.4 On Air 섹션

요구사항:

  • onAirLives가 비어 있으면 섹션을 숨긴다.
  • 섹션 타이틀은 Figma와 동일하게 On Air를 사용한다.
  • 각 item은 크리에이터 프로필 이미지, 닉네임, 라이브 제목, 시작 후 경과 시간을 표시한다.
  • startedAtUtc는 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 경과 시간으로 표시한다.
  • item 탭 시 liveId로 기존 라이브 입장 흐름을 사용한다.
  • 라이브 입장 전 로그인 guard, 라이브룸 외부 이동 확인, 결제/비밀번호 dialog 등은 기존 MainViewhandleRecommendationLiveTapLiveViewModel.enterLiveRoom(roomId:) 패턴을 재사용한다.
  • 카드 루트는 부모 가로 스크롤/컨테이너에 맞춰 크기를 잡고, 필요한 최소 높이 외에는 고정 width/height를 피한다.

7.5 최근 대화 섹션

요구사항:

  • recentChats가 비어 있으면 섹션을 숨긴다.
  • 섹션 타이틀은 최근 대화를 사용한다.
  • ChatRoomListItemResponse.roomId를 item id와 채팅방 진입 id로 사용한다.
  • chatType == "DM"이면 Direct 태그를 표시하고, item 탭 시 UserCreatorChatRoomView(roomId:) 진입 흐름을 사용한다.
  • targetName은 채팅방 제목으로 표시한다.
  • targetImageUrl은 채팅방 이미지로 표시한다.
  • lastMessage는 최근 메시지 preview로 표시한다.
  • lastMessageAt은 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 시간 label로 표시한다.
  • Direct 태그가 없는 item 탭은 roomId로 기존 채팅방 진입 흐름을 사용한다.
  • 채팅방 진입에 로그인 guard가 필요하면 기존 메인/채팅 탭의 guard 패턴을 재사용한다.

7.6 이달의 스케줄 섹션

요구사항:

  • monthlySchedules가 비어 있으면 섹션을 숨긴다.
  • 섹션 타이틀은 이달의 스케줄을 사용한다.
  • 각 item은 날짜 영역, 크리에이터 프로필/닉네임, 제목, 활동 타입 tag, 시간 또는 On Air 상태를 표시한다.
  • scheduledAtUtc는 UTC 기준 문자열로 받아 디바이스 timezone 기준 월/일/요일/시간으로 표시한다.
  • 오늘 날짜인 item은 Figma처럼 날짜 영역에 오늘을 표시한다.
  • isOnAir == true이면 우측 상태를 On Air로 표시하고 Color.soda400을 사용한다.
  • isOnAir == true인 item은 좌측 accent line 또는 동등한 강조 표현을 Figma 기준으로 표시한다.
  • typeCreatorActivityType으로 디코딩한다.
  • CreatorActivityType 서버 값은 LIVE, AUDIO, COMMUNITY, LIVE_REPLAY 네 가지를 지원한다.
  • 기존 RecommendedActivityType과 값 범위가 같으므로 중복을 줄일 수 있으면 공용 enum으로 정리하되, 변경 범위가 커지면 팔로잉 전용 enum으로 둔다.
  • item 탭 시 typetargetId에 맞춰 라이브, 오디오 콘텐츠, 커뮤니티 등 기존 상세 진입 흐름을 사용한다.

7.7 최근 소식 섹션

요구사항:

  • recentNews가 비어 있으면 섹션을 숨긴다.
  • 섹션 타이틀은 최근 소식을 사용한다.
  • 모든 최근 소식 item의 표시 시간은 visibleFromAtUtc를 기준으로 한다.
  • 알 수 없는 FollowingNewsType은 앱 crash 없이 item을 숨기거나 기본 텍스트형 카드로 표시한다. 기본 정책은 숨김으로 둔다.

타입별 UI:

  • CREATOR_RANKING, CONTENT_RANKING
    • Figma node-id=24-5717 기준 랭킹 소식 카드로 표시한다.
    • CREATOR_RANKINGcreatorRanking payload를 사용한다.
    • CONTENT_RANKINGcontentRanking payload를 사용한다.
    • rank 값을 본문에서 N위로 표시하고 Color.soda400로 강조한다.
    • 타입과 대응되는 payload가 nil이면 해당 item은 숨긴다.
    • CREATOR_RANKING 탭 시 creatorRanking.creatorId로 크리에이터 상세로 이동한다.
    • CONTENT_RANKING 탭 시 contentRanking.contentId로 콘텐츠 상세로 이동한다.
  • COMMUNITY_POST
    • 기존 CommunityPostCard 또는 기존 커뮤니티 Feed View를 재사용한다.
    • 반응 정보는 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
    • communityPost payload를 사용한다.
    • postId, creatorProfileImage, creatorNickname, imageUrl, content, createdAt, likeCount, commentCount를 표시한다.
    • 기존 CommunityPostCard를 재사용할 경우 audioUrl = nil, price = 0, existOrdered = true로 매핑해 표시 전용으로 사용한다.
    • 타입과 대응되는 payload가 nil이면 해당 item은 숨긴다.
    • 현재 코드에는 postId 단독으로 진입 가능한 AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:) 라우트가 있으므로 communityPost.postId 기준으로 커뮤니티 게시글 상세 화면에 이동한다.
    • 잘못된 creator 화면이나 수정 화면으로 이동하지 않는다.
  • AUDIO_CONTENT
    • Figma node-id=1229-27212 기준 오디오 콘텐츠 소식 카드로 표시한다.
    • audioContent payload를 사용한다.
    • 썸네일은 정사각형 비율을 기본으로 하고, contentImageUrl이 없으면 기존 콘텐츠 placeholder를 사용한다.
    • creatorProfileImageUrl, creatorNickname, title을 표시한다.
    • tag는 오디오를 표시한다.
    • 타입과 대응되는 payload가 nil이면 해당 item은 숨긴다.
    • item 탭 시 audioContent.contentId로 콘텐츠 상세 화면에 이동한다.
  • PHOTO_CONTENT
    • Figma node-id=1229-27213 기준 화보 콘텐츠 소식 카드로 표시한다.
    • photoContent payload를 사용한다.
    • 썸네일은 세로형 비율을 기본으로 하고, contentImageUrl이 없으면 기존 콘텐츠 placeholder를 사용한다.
    • creatorProfileImageUrl, creatorNickname, title을 표시한다.
    • tag는 화보를 표시한다.
    • 타입과 대응되는 payload가 nil이면 해당 item은 숨긴다.
    • item 탭 시 photoContent.contentId로 콘텐츠 상세 화면에 이동한다.

7.8 상세 진입 guard

  • 팔로잉 탭 선택 자체에 로그인 guard를 적용한다.
  • 팔로잉 API 호출과 화면 노출은 탭 선택 guard를 통과한 뒤 서버의 isLoginRequired 응답도 함께 따른다.
  • 각 item 탭으로 상세 화면에 진입할 때는 기존 MainViewperformRecommendationDetailAction 계열 로그인 guard를 재사용하거나 팔로잉용 callback을 같은 수준에서 추가한다.
  • 토큰이 비어 있으면 AppState.shared.setAppStep(step: .login)으로 이동한다.
  • 라이브 진입은 기존 LiveViewModel.enterLiveRoom(roomId:) 흐름을 재사용한다.
  • 콘텐츠, 크리에이터, 커뮤니티, 채팅 이동은 기존 AppStep 라우팅을 재사용한다.
  • COMMUNITY_POSTAppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)를 사용해 postId 단독으로 커뮤니티 게시글 상세 화면에 진입한다.
  • 서버 응답에 없는 성인/민감 콘텐츠 여부를 클라이언트에서 임의 추정하지 않는다.

8. UX / UI Expectations

8.1 포함하는 UI

  • 홈 공통 title bar
  • 홈 상단 추천/랭킹/팔로잉 Text tab bar
  • 선택된 팔로잉 탭 콘텐츠
  • 팔로잉 크리에이터 가로 목록과 마지막 전체 버튼
  • On Air 가로 라이브 목록
  • 최근 대화 목록
  • 이달의 스케줄 목록
  • 최근 소식 타입별 카드 목록
  • 하단 메인 tab bar는 기존 MainView 구조에서 유지

8.2 시각 규칙

  • 선택된 홈 상단 탭 팔로잉은 흰색 bold text로 표시한다.
  • 비선택 탭 추천, 랭킹은 gray text로 표시한다.
  • 전체 화면 배경은 기존 V2 홈과 동일하게 검정 계열을 유지한다.
  • 섹션 타이틀은 기존 SectionTitle 재사용을 우선한다.
  • 카드 배경은 Figma 기준 Gray/900 계열이며, 코드에서는 Color.gray900을 사용한다.
  • 강조 텍스트와 On Air, 전체 버튼 텍스트는 Color.soda400을 사용한다.
  • nickname, title, body가 길면 영역을 넘지 않도록 한 줄 또는 카드별 제한 줄수에서 말줄임 처리한다.
  • 루트 container와 섹션 container에는 불필요한 고정 숫자 frame(width:), frame(height:)를 사용하지 않는다.
  • 고정 크기가 필요한 경우는 프로필 이미지, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소로 제한한다.

8.3 Empty 처리

  • isLoginRequired == true이면 로그인 필요 상태를 표시한다.
  • isLoginRequired == false이고 개별 섹션 배열이 비어 있으면 해당 섹션은 숨긴다.
  • 모든 섹션 배열이 비어 있으면 팔로잉 탭 전체 empty state를 표시한다.
  • 전체 empty state 문구는 팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.로 표시한다.
  • API 실패 문구는 팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.로 표시한다.

9. Reusable Component Candidates

사전 확인한 V2 재사용 후보:

  • SodaLive/Sources/V2/Component/SectionTitle.swift
    • 섹션 타이틀 재사용 후보.
  • SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift
    • COMMUNITY_POST 표시 재사용 후보. 단, 현재 card 입력값이 팔로잉 최근 소식 응답보다 많으므로 표시 전용 adapter 또는 variant 필요 여부를 plan-task에서 확정한다.
  • SodaLive/Sources/V2/Component/AudioContentCard.swift
    • 오디오 콘텐츠 카드 후보. 단, 현재 AudioContentCardSize.width가 고정값을 반환하므로 이번 요구사항의 “불필요한 고정 width/height 금지”와 충돌할 수 있다. 최근 소식의 행형 오디오 카드는 별도 컴포넌트가 더 적합할 수 있다.
  • SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift
    • V2 홈 API TargetType 구성과 optional token header 패턴 참조.
  • SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift
    • ApiResponse<T> 디코딩, @StateObject, 최초 로딩 패턴 참조.
  • SodaLive/Sources/V2/Main/Home/Ranking/**
    • 탭 전용 API/Repository/ViewModel/Components 분리 패턴 참조.
  • SodaLive/Sources/Chat/Talk/TalkRoom.swift
    • 최근 대화 진입 흐름과 기존 채팅방 화면 패턴 확인 후보.

10. Technical Constraints

  • 신규 팔로잉 탭 관련 파일은 SodaLive/Sources/V2/Main/Home/Following/** 아래에 둔다.
  • 여러 페이지에서 재사용 가능한 신규 공용 컴포넌트는 SodaLive/Sources/V2/Component/** 아래에 형태별 폴더로 둔다.
  • 팔로잉 탭 내부에서만 사용하는 컴포넌트는 SodaLive/Sources/V2/Main/Home/Following/Components/** 아래에 둔다.
  • MainHomeView에는 API 호출, response 변환, 팔로잉 세부 UI를 넣지 않는다.
  • 기존 MainHomeRecommendationView, MainHomeRankingView 동작을 변경하지 않는다.
  • Pods/**, generated/**, build/**는 수정하지 않는다.
  • 외부 라이브러리를 추가하지 않는다.
  • Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
  • 다국어가 필요한 신규 문구는 I18n에 추가한다.
  • 날짜/시간은 기존 DateParser 또는 프로젝트의 날짜 유틸을 우선 사용한다.
  • API 인증 헤더는 기존 V2 홈 API 패턴을 따르되, 토큰이 비어 있을 수 있음을 고려한다.

11. Plan-task Phase 기준

이 PRD를 바탕으로 plan-task.md를 생성할 때는 Figma Design의 각 섹션을 1개의 Phase로 분리한다.

권장 Phase:

  1. Phase 1: 현재 구조 확인과 팔로잉 탭 기반 계층 준비
  2. Phase 2: API, Repository, Response model, ViewModel
  3. Phase 3: 팔로잉 크리에이터 섹션과 전체 버튼
  4. Phase 4: On Air 섹션
  5. Phase 5: 최근 대화 섹션
  6. Phase 6: 이달의 스케줄 섹션
  7. Phase 7: 최근 소식 랭킹 카드
  8. Phase 8: 최근 소식 커뮤니티 카드
  9. Phase 9: 최근 소식 오디오/화보 콘텐츠 카드
  10. Phase 10: 로그인/empty/error 상태와 상세 진입 callback 연결
  11. Phase 11: 프로젝트 등록, 빌드, UI 검증

각 phase는 대상 파일 경로와 검증 기준을 포함한다. 구현 중 phase 완료 시 plan-task.md 체크박스를 즉시 갱신한다.

12. Success Criteria

  • 팔로잉 탭 진입 시 GET /api/v2/home/following 호출이 발생한다.
  • 비로그인 상태에서 홈 상단 팔로잉 탭을 터치하면 팔로잉 탭으로 전환하지 않고 로그인 화면으로 이동한다.
  • 로그인 상태에서 홈 상단 팔로잉 탭을 터치하면 팔로잉 탭으로 전환한다.
  • 응답이 ApiResponse<HomeFollowingTabResponse>로 디코딩된다.
  • isLoginRequired == true이면 로그인 필요 상태가 표시되고 로그인 화면으로 이동할 수 있다.
  • isLoginRequired == false이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다.
  • 비어 있는 섹션은 표시되지 않는다.
  • 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다.
  • 팔로잉 크리에이터 가로 목록 마지막에는 항상 전체 버튼이 표시된다.
  • 전체 버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16, Color.soda400 텍스트를 사용한다.
  • 최근 소식의 CREATOR_RANKINGcreatorRanking payload로 랭킹 소식 카드가 표시된다.
  • 최근 소식의 CONTENT_RANKINGcontentRanking payload로 랭킹 소식 카드가 표시된다.
  • 최근 소식의 COMMUNITY_POSTcommunityPost payload로 기존 커뮤니티 Feed View 또는 표시 전용 variant가 표시된다.
  • COMMUNITY_POSTcommunityPost.postId로 커뮤니티 게시글 상세 화면에 이동한다.
  • 최근 소식의 AUDIO_CONTENTaudioContent payload로 Figma 오디오 콘텐츠 카드 형태가 표시된다.
  • 최근 소식의 PHOTO_CONTENTphotoContent payload로 Figma 화보 콘텐츠 카드 형태가 표시된다.
  • 최근 소식 시간 표시는 visibleFromAtUtc 기준으로 표시된다.
  • item 탭 시 타입별 기존 상세 진입 흐름을 사용한다.
  • 루트 화면과 섹션 container에는 불필요한 고정 숫자 width/height가 없다.
  • MainHomeView는 탭 shell 역할만 유지하고 팔로잉 탭 세부 구현은 Following 하위로 분리된다.

13. Open Questions

해당 없음.

14. Verification Notes

  • docs/agent-guides/documentation-policy.md를 확인해 신규 PRD 경로와 필수 섹션 기준을 검증했다.
  • docs/prd/sample-prd.md, docs/20260602_메인_홈_추천_UI_API_연동/prd.md, docs/20260630_메인_홈_랭킹_탭/prd.md를 확인해 저장소의 PRD 작성 스타일을 맞췄다.
  • SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift가 현재 placeholder임을 확인했다.
  • SodaLive/Sources/V2/Main/Home/MainHomeView.swift가 팔로잉 탭에 MainHomeFollowingView()를 조합하고 있음을 확인했다.
  • SodaLive/Sources/V2/Main/Home/Recommendation/**SodaLive/Sources/V2/Main/Home/Ranking/**의 탭 전용 API/Repository/ViewModel/Components 분리 패턴을 확인했다.
  • Figma node-id=24-5682의 design context와 screenshot을 확인해 팔로잉 탭의 섹션 순서와 최근 소식 타입별 카드 형태를 검증했다.
  • Figma node-id=24-5717, 1229-27212, 1229-27213이 각각 랭킹 소식, 오디오 콘텐츠 소식, 화보 콘텐츠 소식 카드에 해당함을 확인했다.
  • SodaLive/Sources/V2/Component/SectionTitle.swift, SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift, SodaLive/Sources/V2/Component/AudioContentCard.swift를 확인해 재사용 후보와 제약을 PRD에 반영했다.
  • SodaLive/Sources/UI/Theme/Color.swift에서 Color.soda400#00BDF7임을 확인했다.
  • 사용자 답변을 반영해 ChatRoomListItemResponse, CreatorActivityType, nested 최근 소식 response 구조, visibleFromAtUtc 시간 표시 기준, 기존 전체 팔로잉 목록 화면 재사용, 팔로잉 탭 선택 시 로그인 guard 정책을 확정 요구사항으로 이동했다.
  • empty state 문구를 팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요., API 실패 문구를 팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.로 확정했다.
  • 2026-07-12: 현재 코드의 AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:), CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:), /api/v2/creator-channels/community-posts/{postId} 흐름을 확인해 COMMUNITY_POST tap을 postId 단독 상세 진입으로 연결하도록 요구사항을 갱신했다.