diff --git a/docs/ai/spec/feed-recommendation.md b/docs/ai/spec/feed-recommendation.md index 2bf5f0e1..0f871c6b 100644 --- a/docs/ai/spec/feed-recommendation.md +++ b/docs/ai/spec/feed-recommendation.md @@ -69,6 +69,8 @@ Profile 구성: 본인 Collection과 이미 팔로우 관계가 아닌 탈퇴 User의 Collection은 후보에서 제외합니다. +> **[BD-51 상충]** 위 문장과 반대로, 실제로는 **이미 팔로우 관계인** 회원의 Collection이 탐색 탭 후보에서 제외됩니다. 팔로우 채널은 더 이상 후보에 합류하지 않고, 최신·무작위 채널도 WHERE 절에서 팔로우한 회원의 Collection을 직접 걸러냅니다. 근거와 감수한 것은 [BD-51](../../backend/decisions/BD-51-explore-excludes-followed-members.md)에 있습니다. + ### 3.3 Collection 특징 조회 후보 id 목록으로 각 Collection의 특징을 **일괄** 조회합니다. Collection별 반복 조회를 하지 않습니다. diff --git a/docs/ai/spec/feed-scoring.md b/docs/ai/spec/feed-scoring.md index 53b00c05..d70987f8 100644 --- a/docs/ai/spec/feed-scoring.md +++ b/docs/ai/spec/feed-scoring.md @@ -23,6 +23,8 @@ | 팔로우 | 이미 관심을 표현한 Shelf | 80 | | 탐색용 무작위 | 필터 버블 이탈 | 20 | +> **[BD-51 상충]** 백엔드는 탐색 탭(`GET /v1/feed/collections`)에서 팔로우 채널을 더 이상 후보에 합류시키지 않고, 최신·무작위 채널에서도 이미 팔로우한 회원의 Collection을 WHERE 절로 직접 제외합니다(제품 요구사항: 탐색은 신규 발견 목적이라 이미 관계를 맺은 회원의 책은 노출하지 않는다). 이 절이 말하는 "팔로우 = 80 배분·최우선 채널" 설계와 정면으로 상충합니다. 코드 쪽 결정과 감수한 것은 [BD-51](../../backend/decisions/BD-51-explore-excludes-followed-members.md)에 있습니다. 이 명세는 AI 파트 소유라 백엔드가 임의로 고치지 않았습니다 — 갱신 여부는 AI 파트가 판단합니다. + 최신 발행 채널의 배분을 60에서 100으로 올린 것은 [P42](../proposals/P42-feed-mvp-without-place-metadata.md)에서 Place region 항을 제거한 데 따른 조치입니다. 제거 이전에는 region이 Keyword와 달리 AI 완료 여부와 무관하게 계산돼, AI가 미완료인 Collection도 점수 항 하나를 확보했습니다. 그 경로가 사라진 만큼 최신 채널을 늘려 후보 진입 기회를 넓혔습니다. 다만 이것은 **부분적인 보상입니다.** 채널 배분은 후보 풀 진입만 결정하고 점수에는 관여하지 않습니다. `keywordAffinity = 0`인 Collection은 후보에 들어온 뒤에도 `recency`(w=0.125) 하나로 경쟁하므로 점수 열세가 그대로 남습니다. 남는 한계는 P42의 "감수하는 것"에 적어둡니다. @@ -94,6 +96,8 @@ score(c) = w_follow * followSignal(c) 이분값입니다. 팔로우 횟수나 기간으로 세분하지 않습니다. +> **[BD-51 상충]** 탐색 탭이 팔로우한 회원의 Collection을 후보 자체에서 제외하도록 바뀌어([BD-51](../../backend/decisions/BD-51-explore-excludes-followed-members.md)), 어떤 후보도 팔로우 채널에서 나올 수 없습니다. 그 결과 `followSignal`은 실질적으로 항상 0이고, `w_follow`(3.5)도 항상 곱해질 값이 없습니다. `w_follow`가 "가장 큰 가중치"라는 아래 3.5의 근거와 상충합니다. + ### 3.2 keywordAffinity — weighted Jaccard ```text diff --git a/docs/backend/decisions/BD-51-explore-excludes-followed-members.md b/docs/backend/decisions/BD-51-explore-excludes-followed-members.md new file mode 100644 index 00000000..686eec9c --- /dev/null +++ b/docs/backend/decisions/BD-51-explore-excludes-followed-members.md @@ -0,0 +1,48 @@ +# BD-51. 탐색 탭은 이미 팔로우한 회원의 Collection을 제외한다 + +- **상태**: Accepted +- **날짜**: 2026-08-07 +- **관련**: AI 파트 소유 명세 [feed-scoring](../../ai/spec/feed-scoring.md) 2.1·2.3·3.1, + [feed-recommendation](../../ai/spec/feed-recommendation.md) 3.2 — 이 결정이 상충 사실을 마킹한 자리 +- **번호**: [worklog README](../worklog/README.md)·[BD-45](BD-45-worklog-per-entry-files.md)의 규칙대로 `dev` 머지 순서가 번호를 확정한다. 이 문서는 git 저장소가 아직 초기화되지 않은 로컬 작업 중 작성됐으므로, 머지 시점에 51이 이미 다른 결정에 쓰였다면 [BD-50](BD-50-datasource-redis-config-follows-infra-env-vars.md)의 선례대로 번호만 옮기고 내용은 그대로 둔다. + +## 맥락 + +사용자 요청: 탐색(`GET /v1/feed/collections`) 탭에서 **이미 팔로우 중인 회원의 Collection이 뜨지 않게** 해 달라. 신규 발견이 탐색 탭의 목적이므로, 이미 관계를 맺은 사람의 책은 팔로잉 화면 쪽에서 보면 되고 탐색에서는 새 얼굴을 보고 싶다는 취지다. + +코드를 확인하니 이것은 버그가 아니라 **AI 파트 소유 명세가 정반대로 설계해 둔 핵심 로직**이었다. + +- `feed-scoring.md` 2.1의 후보 채널 표는 "팔로우"를 "이미 관심을 표현한 Shelf"로 적어 최신 발행 다음으로 큰 배분(80)을 준다. +- `feed-scoring.md` 3.1·3.5는 `followSignal`(팔로우 채널 출처 여부)을 점수 공식의 첫 항으로 두고, `w_follow = 0.500`으로 **세 가중치 중 가장 크게** 잡는다. 근거는 "팔로우가 사용자가 명시적으로 표현한 유일한 관심 신호이기 때문 — 추론값보다 명시값을 우선한다." +- `FeedProperties.java`의 클래스 Javadoc은 "값의 정본은 명세이고 여기서 임의로 바꾸지 않는다 — 어긋나 보이면 AI 파트에 올린다"고 명시한다. + +즉 지금 코드(`FeedCandidateRepository.findFollowed`, `FeedScorer`의 `followSignal`)는 이 명세를 정확히 구현한 것이다. 사용자가 원하는 동작은 이 설계 의도를 **정면으로 뒤집는다.** + +`back/CLAUDE.md` 규칙 9는 "코드와 문서가 충돌하면 스스로 풀지 말고 정본 쪽에 마킹만 남기라"고 한다. 이 사안은 코드-문서 불일치가 아니라 **제품 요구사항이 기존 명세의 설계를 뒤집는 경우**이지만, 같은 원칙을 적용해 AI 파트 명세는 고치지 않고 상충 사실만 마킹했다({@code feed-scoring.md} 2.1·3.1, {@code feed-recommendation.md} 3.2의 `[BD-51 상충]` 표시). 명세의 값 자체(`w_follow`, `follow-limit` 등)도 지우지 않았다 — AI 파트 소유이며, 되돌릴 때 근거가 남아 있어야 한다. + +## 선택지 + +| 안 | 장점 | 단점 | +|---|---|---| +| (a) 아무것도 바꾸지 않고 사용자에게 "명세와 상충한다"만 알린다 | 명세와의 정합이 깨지지 않는다 | 사용자가 명시적으로 원하는 동작을 구현하지 않는 것이 된다 | +| (b) `findFollowed` 채널·`FeedScorer`의 `followSignal`·`FeedProperties`의 `wFollow`/`followLimit`을 코드에서 완전히 지운다 | 죽은 코드가 남지 않는다 | AI 파트 소유 값·구조를 백엔드가 임의로 삭제하는 것이라 명세 재정비 없이는 되돌리기 어렵고, `FeedCandidateChannelTests`의 C6·C8(팔로우 채널 자체의 동작 검증)도 함께 지워야 해 팔로우 채널 인프라 자체의 회귀 방지가 없어진다 | +| **(c) 탐색 파이프라인(`FeedService.collectCandidates`)이 `findFollowed` 채널 호출을 멈추고, 남은 두 채널(`RECENT_SQL`·`SAMPLE_*_SQL`)의 WHERE 절에 `NOT EXISTS(core.follow ...)`로 팔로우한 회원을 직접 제외한다. `FOLLOWED_SQL`·`findFollowed`·`wFollow`·`followLimit`은 남긴다** | 명세가 소유한 값·SQL·테스트(C6·C8, `FeedChannelPlanTests`)를 건드리지 않아 되돌리기 쉽다. `followSignal`이 항상 0이 되어 사실상 무력화되면서도 삭제로 인한 컴파일 연쇄가 없다 | `wFollow`·`followLimit`이 당분간 아무 데서도 읽히지 않는 설정값으로 남는다 — 죽은 설정처럼 보일 수 있다 | + +## 결정 + +**(c)를 골랐다. 능동적 선택이다.** + +1. **명세 값은 AI 파트 소유이므로 백엔드가 구조까지 지우지 않는다.** `FeedProperties` Javadoc이 이미 "어긋나 보이면 AI 파트에 올린다"고 명시하는데, (b)처럼 레코드 필드·SQL·전용 테스트를 지우면 그 경로 자체가 없어져 AI 파트와 재조율할 근거가 사라진다. +2. **행동은 확실히 바뀌어야 한다.** 단순히 `wFollow`를 설정에서 0으로 낮추는 것만으로는 부족하다 — 그러면 팔로우한 회원의 Collection이 여전히 최신/무작위 채널을 통해 후보에 들어와 노출될 수 있다(팔로우 여부와 무관하게 최신순·무작위 채널은 원래 모든 공개 Collection을 대상으로 하므로). 그래서 남은 두 채널의 WHERE 절에 **명시적 제외 조건**을 추가했다 — 이 저장소의 다른 노출 조건(활성·발행·소유자 미탈퇴 등)과 같은 자리, 같은 방식이다(feed-recommendation 3.3 규약). +3. **팔로우 채널 인프라 자체는 살아 있는 게 맞다.** `FOLLOWED_SQL`은 `core.follow`를 기점으로 좁히는 올바른 쿼리 계획(`ix_collection_member`)이 이미 검증돼 있고(`FeedChannelPlanTests`), 이 자산을 지울 이유가 없다 — 지금은 탐색 파이프라인이 부르지 않을 뿐이다. + +## 결과 + +- **이 결정으로 감수하는 것** + - `FeedProperties.Candidate.followLimit()`과 `FeedProperties.Scoring.wFollow()`/`ColdStart.wFollow()`가 당분간 어떤 프로덕션 코드에서도 읽히지 않는다. `application.yml`의 `follow-limit`·`w-follow` 값도 마찬가지다. 죽은 설정처럼 보이지만, 명세 재조율 시 되살릴 자리를 남겨 두기 위한 의도적 선택이다. + - `FeedCandidateRepository.findFollowed`/`FOLLOWED_SQL`도 프로덕션 호출자가 없다 — 오직 `FeedCandidateChannelTests`(C6·C8)와 `FeedChannelPlanTests`만 직접 부른다. 이 테스트들은 "팔로우 채널 쿼리 자체는 여전히 올바르다"만 검증하고, "탐색 응답에 반영되는지"는 더 이상 보장하지 않는다. + - 탐색 탭에서 팔로우한 회원의 Collection은 어떤 경로로도 다시 나타나지 않는다. 팔로우한 사람의 새 책을 보려면 별도 화면(팔로잉 Shelf 등)이 필요하며, 이 저장소 범위에는 없다. + - Cold Start 가중치 표(`w_follow = 0.500`)도 명세상 그대로지만 실질적으로 무의미해졌다 — Cold Start 사용자도 팔로우한 회원의 Collection을 후보로 받지 못하므로 `followSignal`이 항상 0이다. +- **재검토 트리거** + - AI 파트가 `feed-scoring.md`·`feed-recommendation.md`를 이 결정에 맞춰 갱신하면(또는 반대로 원래 설계를 재확인하면), 그 갱신에 맞춰 `wFollow`·`followLimit`을 코드에서 실제로 정리(삭제 또는 별도 "팔로잉" 채널로 전환)한다. + - 팔로우한 회원의 새 책을 보여줄 별도 화면(팔로잉 피드)이 생기면, `findFollowed`/`FOLLOWED_SQL`을 그 화면의 데이터 소스로 재사용할 수 있다 — 지금 지우지 않은 이유이기도 하다. diff --git a/docs/backend/worklog/2026-08-07-explore-excludes-followed-members.md b/docs/backend/worklog/2026-08-07-explore-excludes-followed-members.md new file mode 100644 index 00000000..b034bde9 --- /dev/null +++ b/docs/backend/worklog/2026-08-07-explore-excludes-followed-members.md @@ -0,0 +1,14 @@ +# 탐색 탭에서 이미 팔로우한 회원의 Collection을 제외했다 + +- **날짜**: 2026-08-07 +- **관련**: [BD-51](../decisions/BD-51-explore-excludes-followed-members.md) · AI 파트 명세 [feed-scoring](../../ai/spec/feed-scoring.md) 2.1·3.1 · [feed-recommendation](../../ai/spec/feed-recommendation.md) 3.2 + +사용자 요청으로 탐색(`GET /v1/feed/collections`) 탭에서 이미 팔로우 중인 회원의 책이 뜨지 않도록 했다. 조사해 보니 현재 코드는 버그가 아니라 AI 파트 소유 명세(`feed-scoring.md`)가 "팔로우 = 사용자가 명시적으로 표현한 유일한 관심 신호"라는 근거로 가장 큰 가중치(`w_follow = 0.500`)를 주고 우선 노출하도록 설계한 그대로였다. 요청한 동작은 이 설계를 정면으로 뒤집는다. + +`CLAUDE.md` 9번 규칙(코드-문서 충돌은 정본을 임의로 고치지 않고 마킹만 남긴다)의 정신을 따라, AI 파트 소유 값(`FeedProperties`의 `wFollow`·`followLimit`, `FeedCandidateRepository`의 `FOLLOWED_SQL`·`findFollowed`)은 코드에서 지우지 않고 명세 쪽에 `[BD-51 상충]` 마킹만 남겼다. 대신 `FeedService.collectCandidates`가 `findFollowed` 채널 호출을 멈추고, 남은 최신·무작위 채널(`RECENT_SQL`·`SAMPLE_FROM_PIVOT_SQL`·`SAMPLE_WRAPPED_SQL`)의 WHERE 절에 `core.follow` 기준 `NOT EXISTS`를 추가해 팔로우한 회원의 Collection을 직접 제외했다. 그 결과 `followSignal`은 어떤 후보도 팔로우 채널에서 나올 수 없어 항상 0이 된다 — 점수 공식 자체는 건드리지 않고 입력을 막는 방식이라, 명세를 되돌릴 때도 코드를 되돌리기 쉽다. 실패 테스트를 먼저 추가했다(`FeedCandidateChannelTests.exploreChannelsExcludeFollowedMembersCollections`, C10) — RED에서 최신 채널만 새기는 것을 확인하고 SQL을 고쳐 GREEN으로 만들었다. `FeedChannelPlanTests`로 `NOT EXISTS` 추가 후에도 `RECENT_SQL`이 `ix_collection_feed`의 정렬 순서(Presorted Key)를 그대로 쓰는지 확인했다 — Postgres가 Nested Loop Anti Join으로 처리해 순서를 잃지 않았다. + +과정에서 두 가지 무관한 선결함을 발견해 함께 고쳤다. (1) `FeedProfileServiceTests`가 존재하지 않는 `PostgresContainerSupport`를 참조해 전체 테스트 컴파일이 깨져 있었다 — `IntegrationContainerSupport`로 교체했다. (2) 같은 파일의 `insertMember`/`insertFollow`/`insertRecordWithContext`가 `deleted ? Instant.now() : null`을 그대로 JDBC 파라미터로 넘겨 `deleted=true` 분기에서 `BadSqlGrammarException`("Can't infer the SQL type … java.time.Instant")이 났다 — `java.sql.Timestamp.from(...)`으로 바꿨다. 둘 다 이번 컴파일이 처음으로 이 파일을 통과시키면서 드러난, 전부터 있던 문제다. + +부수적으로 `FeedKeywordDisplayOrderTests`가 "방금 만든 Collection을 팔로우해 점수를 강제로 올려 페이지 밖으로 밀리지 않게 하는" 픽스처 트릭을 쓰고 있었는데, 팔로우가 이제 오히려 제외 조건이라 이 트릭이 역효과를 냈다(8개 테스트가 RED로 변함). 팔로우 대신 커서를 끝까지 순회해 대상 Collection을 찾는 방식(`findAcrossPages`)으로 바꿨다 — 이 테스트들은 순위가 아니라 선택된 Keyword의 정렬만 검증하므로 몇 페이지째에 있는지는 상관없다. + +`./gradlew clean check --no-daemon`으로 전체 검증했다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/feed/repository/FeedCandidateRepository.java b/src/main/java/com/pinlog/pinlogback/domain/feed/repository/FeedCandidateRepository.java index 26d6e203..0dd817a8 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/feed/repository/FeedCandidateRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/feed/repository/FeedCandidateRepository.java @@ -24,6 +24,15 @@ * 한 단계만 빠져도 삭제되었거나 비공개인 데이터가 타인에게 노출된다. 이 조건을 자바 코드가 아니라 * WHERE 절에 두는 것이 규약이다(feed-recommendation 3.3). * + *

탐색 채널({@link #RECENT_SQL}·{@link #SAMPLE_FROM_PIVOT_SQL}·{@link #SAMPLE_WRAPPED_SQL})은 + * 팔로우한 회원의 Collection도 추가로 제외한다. 이 저장소가 소비하는 AI 파트 소유 명세 + * ({@code docs/ai/spec/feed-scoring.md} 2.1, {@code followSignal})는 반대로 팔로우를 최고 가중치 + * 신호로 다뤄 우선 노출한다 — 탐색 탭에서는 신규 발견을 위해 그 신호를 뒤집기로 한 백엔드 제품 + * 결정이다({@code docs/backend/decisions/BD-51-explore-excludes-followed-members.md}). 그 명세 + * 쪽에도 상충 사실을 마킹해 두었다. {@link #FOLLOWED_SQL}·{@link #findFollowed}는 삭제하지 않았다 + * — SQL 자체는 여전히 유효하고 별도로 테스트되는 채널이며, {@code FeedService}가 더 이상 이 + * 메서드를 호출하지 않을 뿐이다. + * *

최신성 기준 시각은 {@code published_at}을 그대로 쓴다. 발행된 행은 그 값을 반드시 가지므로 * ({@code is_published = true} 필터 + V5 {@code ck_collection_published_at} CHECK, BD-33) * {@code COALESCE}로 감쌀 대상이 없다. 감싸면 정렬키가 표현식이 되어 {@code ix_collection_feed}의 @@ -54,6 +63,12 @@ public class FeedCandidateRepository { AND c.record_count > 0 AND c.member_id <> :me AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM core.follow f + WHERE f.follower_member_id = :me + AND f.followee_member_id = c.member_id + AND f.deleted_at IS NULL + ) ORDER BY c.published_at DESC, c.id DESC LIMIT :limit """; @@ -97,6 +112,12 @@ public class FeedCandidateRepository { AND c.record_count > 0 AND c.member_id <> :me AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM core.follow f + WHERE f.follower_member_id = :me + AND f.followee_member_id = c.member_id + AND f.deleted_at IS NULL + ) ORDER BY c.id LIMIT :limit """; @@ -112,6 +133,12 @@ public class FeedCandidateRepository { AND c.record_count > 0 AND c.member_id <> :me AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM core.follow f + WHERE f.follower_member_id = :me + AND f.followee_member_id = c.member_id + AND f.deleted_at IS NULL + ) ORDER BY c.id LIMIT :limit """; diff --git a/src/main/java/com/pinlog/pinlogback/domain/feed/service/FeedService.java b/src/main/java/com/pinlog/pinlogback/domain/feed/service/FeedService.java index 8b23d0aa..e4c7a35b 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/feed/service/FeedService.java +++ b/src/main/java/com/pinlog/pinlogback/domain/feed/service/FeedService.java @@ -172,17 +172,25 @@ private FeedProfile loadProfile(long memberId) { } /** - * 세 채널의 합집합(feed-scoring 2.3). 중복은 페널티가 아니라 신호이므로 제거하되 출처는 - * 보존한다 — 팔로우 채널 출처 여부가 점수 공식의 {@code followSignal}이 된다. + * 두 채널의 합집합(feed-scoring 2.3 변경, BD-51). 중복은 페널티가 아니라 신호이므로 + * 제거하되 출처는 보존한다. * - *

삽입 순서가 곧 잘라내기 우선순위다(팔로우 → 최신 → 무작위). 현재 배분에서는 합이 - * {@code pool-size}와 같아 잘라내기가 발동하지 않지만, 배분을 올렸을 때의 방어선으로 남긴다. + *

팔로우 채널({@code findFollowed})은 더 이상 합류하지 않는다. 탐색 탭은 신규 발견이 + * 목적이므로 이미 팔로우한 회원의 Collection은 노출하지 않기로 했다(BD-51) — AI 파트 소유 + * 명세(feed-scoring 2.1)가 원래 의도한 "팔로우 = 최고 가중치 신호"와 반대 방향이며, 그 명세 + * 쪽에 상충 사실을 마킹해 두었다. 남은 두 채널(최신·무작위)의 WHERE 절이 팔로우한 회원의 + * Collection을 직접 제외한다({@code FeedCandidateRepository} 참고). 그 결과 어떤 후보도 + * {@code fromFollow=true}가 될 수 없으므로 {@link FeedScorer}의 {@code followSignal}은 항상 + * 0이 된다 — {@code wFollow} 가중치와 {@code findFollowed}/{@code FOLLOWED_SQL} 자체는 AI + * 파트 소유 값이라 여기서 지우지 않았다. + * + *

삽입 순서가 곧 잘라내기 우선순위다(최신 → 무작위). 현재 배분에서는 합이 + * {@code pool-size}보다 작아 잘라내기가 발동하지 않지만, 배분을 올렸을 때의 방어선으로 남긴다. */ private List collectCandidates(long memberId, long seed) { FeedProperties.Candidate config = properties.candidate(); Map merged = new LinkedHashMap<>(); List.of( - candidateRepository.findFollowed(memberId, config.followLimit()), candidateRepository.findRecent(memberId, config.recentLimit()), candidateRepository.findRandomSample(memberId, config.randomLimit(), seed)) .forEach(channel -> channel.forEach(candidate -> diff --git a/src/test/java/com/pinlog/pinlogback/domain/feed/FeedCandidateChannelTests.java b/src/test/java/com/pinlog/pinlogback/domain/feed/FeedCandidateChannelTests.java index 85226c41..b7f8a27e 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/feed/FeedCandidateChannelTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/feed/FeedCandidateChannelTests.java @@ -126,6 +126,30 @@ void memberWithoutFollowsGetsEmptyFollowChannel() throws Exception { assertThat(candidateRepository.findRecent(viewer, GENEROUS_LIMIT)).isNotEmpty(); } + /** + * C10 — 탐색(최신·무작위) 채널은 팔로우한 사람의 Collection을 제외한다. + * + *

탐색 탭은 신규 발견이 목적이므로, 이미 팔로우해 관계를 맺은 사람의 Collection은 이 두 + * 채널로 새어 들어오면 안 된다. 팔로우하지 않은 타인의 Collection은 그대로 보여야 하므로 + * 함께 확인한다. + */ + @Test + void exploreChannelsExcludeFollowedMembersCollections() throws Exception { + long viewer = newMemberId(); + long followedOwner = newMemberId(); + long strangerOwner = newMemberId(); + long followedCollection = publishedCollection(followedOwner, uniqueSeed("explore-followed")); + long strangerCollection = publishedCollection(strangerOwner, uniqueSeed("explore-stranger")); + follow(viewer, followedCollection); + + List randomIds = candidateRepository.findRandomSample(viewer, GENEROUS_LIMIT, 555_555L).stream() + .map(FeedCandidate::collectionId) + .toList(); + + assertThat(recentIds(viewer)).doesNotContain(followedCollection).contains(strangerCollection); + assertThat(randomIds).doesNotContain(followedCollection).contains(strangerCollection); + } + /** C9 — 요청한 수보다 Collection이 적어도 있는 만큼 돌려주고 예외를 내지 않는다. */ @Test void fewerCollectionsThanRequestedIsNotAnError() throws Exception { diff --git a/src/test/java/com/pinlog/pinlogback/domain/feed/FeedKeywordDisplayOrderTests.java b/src/test/java/com/pinlog/pinlogback/domain/feed/FeedKeywordDisplayOrderTests.java index 00ec22d6..e145bd91 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/feed/FeedKeywordDisplayOrderTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/feed/FeedKeywordDisplayOrderTests.java @@ -2,14 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.HashSet; import java.util.List; -import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import com.pinlog.pinlogback.global.response.CursorPage; + import tools.jackson.databind.JsonNode; /** @@ -35,9 +35,6 @@ @AutoConfigureMockMvc class FeedKeywordDisplayOrderTests extends FeedFixtures { - /** 같은 테스트가 두 번 조회할 때 중복 Follow(409)를 내지 않기 위한 기록. */ - private final Set following = new HashSet<>(); - /** * KW1·KW3 — 축이 넷이고 빈도가 전부 같을 때. 상위 3칸이 서로 다른 축으로 채워지고 같은 축의 * 두 번째(2·4번째로 넣은 것)는 자리를 얻지 못한다. 상한이 3이라 네 번째 축도 밀린다. @@ -213,18 +210,35 @@ private int preset(String displayName, String category) { } /** - * 후보 풀에 확실히 들어가도록 팔로우한 뒤 조회한다. 공유 컨테이너라 다른 테스트가 만든 - * Collection이 계속 쌓이는데, 팔로우 채널은 후보 병합에서 가장 앞이고 {@code followSignal}이 - * 1.0이라 점수에서도 앞선다 — 내 Collection이 페이지 밖으로 밀려 테스트가 산발적으로 깨지는 - * 것을 막는다. + * 후보 풀에서 만든 Collection을 페이지를 끝까지 넘겨 찾는다. + * + *

예전에는 팔로우로 강제 상위 노출을 만들었다. 탐색 탭이 팔로우한 사람의 Collection을 + * 제외하도록 바뀌면서(BD-51) 그 트릭은 역효과가 됐다 — 지금 팔로우하면 오히려 후보에서 + * 빠진다. 이 테스트는 순위·팔로우 여부가 아니라 선택된 Keyword의 정렬만 보므로, 몇 번째 + * 페이지에 있는지는 중요하지 않다. 공유 컨테이너라 다른 테스트가 계속 Collection을 쌓지만, + * 방금 만든 것은 최신 채널의 {@code recent-limit}(100) 안에는 반드시 들어오므로 페이지를 + * 끝까지 넘기면 찾는다. */ private List keywordsFor(long viewer, long owner, long collectionId) throws Exception { - if (!following.contains(collectionId)) { - follow(viewer, collectionId); - following.add(collectionId); - } - JsonNode item = itemOf(parse(feedPayload(viewer)), collectionId); + JsonNode item = findAcrossPages(viewer, collectionId); assertThat(item).as("만든 Collection이 후보에 들어오지 않았다. owner=%d", owner).isNotNull(); return keywordsOf(item); } + + private JsonNode findAcrossPages(long viewer, long collectionId) throws Exception { + String cursor = null; + for (int page = 0; page < 10; page++) { + String query = "?size=" + CursorPage.MAX_SIZE + (cursor == null ? "" : "&cursor=" + cursor); + JsonNode response = feed(viewer, query); + JsonNode item = itemOf(response, collectionId); + if (item != null) { + return item; + } + if (!response.at("/data/hasNext").asBoolean()) { + return null; + } + cursor = response.at("/data/nextCursor").asString(); + } + return null; + } }