Skip to content

feat(S15P11A705-120): Feed 추천 MVP — 후보 3채널·결정적 점수·opaque cursor - #77

Merged
colosair merged 1 commit into
devfrom
feat/S15P11A705-120-feed-recommendation-mvp
Jul 29, 2026
Merged

feat(S15P11A705-120): Feed 추천 MVP — 후보 3채널·결정적 점수·opaque cursor#77
colosair merged 1 commit into
devfrom
feat/S15P11A705-120-feed-recommendation-mvp

Conversation

@colosair

Copy link
Copy Markdown
Member

요약

AI 파트가 소유한 Feed 명세(docs/ai/spec/feed-*.md, dev에 병합됨)를 Spring으로 구현합니다. 후보 3채널·결정적 점수 계산·GET /api/core/v1/feed/collections·opaque cursor가 필수 범위이고, 이벤트 수집(POST /feed/events)과 IMPRESSION 기록까지 함께 넣었습니다. 정책 수치는 하나도 바꾸지 않았습니다 — 가중치·채널 배분·탐색 슬롯을 전부 @ConfigurationProperties로 외부화했고 값은 명세의 초기값 그대로입니다.

DDL을 추가하지 않습니다. core.feed_event(V102·AI 구간)와 ix_collection_feed(V3)가 이미 있어 조회·삽입만 합니다. Collection 엔티티도 건드리지 않았습니다(back#75 리뷰 중).

Jira (필수)

관련 GitHub Issue (선택)

변경 사항

신규 — domain/feed

파일 무엇을·왜
controller/FeedController GET /v1/feed/collections · POST /v1/feed/events. context-path(/api/core)는 매핑에 반복하지 않습니다
service/FeedService 파이프라인 조립. @Transactional을 걸지 않습니다 — 전 과정이 읽기이고, IMPRESSION 기록 실패가 응답을 되돌리면 안 되기 때문입니다
service/FeedScorer 점수 공식. weighted Jaccard·지수 감쇠·노출 패널티. 시각을 인자로 받아 순수 함수로 뒀습니다
service/FeedRanker 다양성 조정. 소유자 상한과 탐색 슬롯을 page-size 블록 단위로 적용합니다
service/FeedSession requestId + offset. 공통 Cursor(Base64(정렬키,id))를 재사용합니다
service/FeedProperties pinlog.feed.* 정책값. 소비자와 같은 패키지에 둡니다(JwtProperties 선례)
service/{FeedCandidate,ScoredCandidate,FeedProfile} 채널 출처·소유자·점수를 나르는 내부 타입. 응답 DTO로 변환되지 않습니다
repository/FeedCandidateRepository 3채널 쿼리 + 최종 재검증. 노출 조건을 네 쿼리가 똑같이 겁니다
repository/FeedKeywordRepository Keyword 분포 집계. 가시성 필터가 WHERE 절에 있습니다
repository/FeedEventRepository core.feed_event batch INSERT와 패널티 집계
repository/FeedCollectionCard 재검증을 통과한 표시용 데이터. 소유자 식별자를 담는 자리가 없습니다
entity/FeedEventType event 컬럼의 도메인 값. 테이블은 AI 소유라 @Entity를 두지 않습니다
dto/* 응답·요청 계약 4종

기존 파일

  • global/common/InputLimitsFEED_EVENTS_MAX = 100 추가. RECORD_IDS_MAX·CursorPage.MAX_SIZE같은 값입니다(S15P11A705-117의 "서버 방어 상한의 답은 하나").
  • application.ymlpinlog.feed.* 블록. 각 값의 근거를 주석으로 달았습니다.
  • docs/backend/{decisions,implements,WORKLOG} — BD-34·BI-20과 로그 1줄.

테스트 / 검증

  • ./gradlew clean check --no-daemon292개 통과, 실패 0 (d0d73ce)
  • DB 변경 시 PostgreSQL 통합 테스트 — DB가 필요한 테스트 전부 Testcontainers(pgvector/pgvector:0.8.5-pg16)
  • migration 변경 시 빈 DB migration 테스트 — 해당 없음(마이그레이션 파일을 추가하지 않았습니다)
  • API 계약 변경 시 관련 문서 갱신 — 공용 계약(docs#21)이 정본이고 그것을 구현했습니다. back 쪽 기록은 BI-20
  • BD 추가 — BD-34

커버리지: 라인 96.6% · 브랜치 82.2% (게이트 BUNDLE 80%).

RED → GREEN

명세를 테스트로 먼저 옮기고 구현했습니다. 실행 중 실제로 잡힌 것 두 건을 남깁니다.

단계 내용
RED FeedEventApiTests 2건이 Status expected:<201> but was:<400>으로 실패 — Fixture의 kakaoPlaceIdVARCHAR(50)을 넘었습니다. 상한을 넘기는 접두사를 쓴 테스트만 실패해 원인이 좁혀졌습니다
GREEN seed 길이를 줄여 통과. 이후 --tests "…domain.feed.*" 전체 통과
RED checkstyleMain 38건 위반 — SQL 텍스트 블록의 이어지는 줄을 공백으로 들여썼습니다(레포 규약은 탭)
GREEN 탭으로 교정 후 통과
Regression 기존 226개 + 신규 66개 = 292개 전부 통과. 기존 테스트는 한 건도 수정하지 않았습니다

명세 대응표

명세 테스트
S1~S10 (점수 공식) FeedScorerTests — weighted Jaccard 4종, 크기 편향 없음, followSignal 이분값, 지수 감쇠, cap, 정규화 범위, 가중치 설정 의존
D1~D4 (다양성·탐색) FeedRankerTests — 소유자 상한 2, 상한 완화, 탐색 4건이 하위 절반, 탐색 0건 백필, Cold Start 6건
C1~C6·C8·C9 (후보 채널) FeedCandidateChannelTests — 본인·삭제·비공개·탈퇴·빈 Collection 제외, 팔로우 출처 보존, 재검증 순서 보존
P3~P7 (공개 범위) FeedKeywordVisibilityTestsPUBLIC만 타인에게, PRIVATE_ONLY는 본인 Profile에만, BLOCKED·비활성 Preset 전면 제외
Q1~Q7 (API 계약) FeedApiTests — 기본 20, 상한 보정, 0 이하 보정, 커서 왕복 무중복, requestId 별도 필드, 소유자 식별자 부재, keywords: [], 위조 커서 400
E1·E3·E4·E5·E8·E9 (이벤트) FeedEventApiTests + FeedServiceTests

배경

명세가 페이지네이션에 Redis Session Cache(feed:session:{requestId})를 두는데, 같은 명세가 "Redis 장애 시 정상 응답"과 "Session 만료 시 첫 페이지부터"를 함께 요구합니다. 즉 Cache가 있어도 없을 때 도는 경로를 반드시 따로 만들어야 합니다. 그 경로 하나만 두고 Cache는 나중에 앞에 얹기로 했습니다 — 정렬을 만드는 코드가 둘이 되면 페이지 경계에서만 재현되는 버그가 생깁니다.

지켜야 할 것은 계약(페이지 간 중복·누락 없음 — K10·Q4)이지 기법이 아니고, requestId seed 고정 + 결정적 정렬이 같은 성질을 만듭니다. 대안과 감수하는 점은 BD-34에 있습니다.

리뷰 포인트

  1. BD-34의 트레이드오프. 후보 풀을 얼리지 않으므로 요청 사이에 새 Collection이 발행되면 다음 페이지에서 한 항목이 밀려 중복 노출될 수 있습니다. 반대로 Session Cache는 그걸 막는 대신 삭제·비공개 전환이 stale해집니다(그쪽은 최종 재검증이 막습니다). 어느 쪽도 완전하지 않아 "얼리지 않는 대신 최신 상태를 본다"를 골랐습니다 — 이 선택이 맞는지 봐 주세요.

  2. 다양성 조정을 블록 단위로 둔 것. "한 응답 내 동일 소유자 최대 2건"·"20개 중 탐색 4건"은 한 응답에 대한 규칙이라, 전체 정렬 결과를 diversity.page-size(20) 블록으로 끊고 블록마다 적용합니다. 요청 size가 20이 아니면 한 응답에 블록이 여러 개 걸치거나 잘립니다. 명세가 슬롯 수를 page-size 비례로 정의하므로 설정값 쪽을 기준으로 삼았는데, 요청 size 기준이 나은지 판단이 필요합니다.

  3. FeedCandidateChannelTests가 API가 아니라 리포지토리를 직접 부릅니다. 채널 쿼리의 WHERE 절이 검증 대상인데 API까지 거치면 다양성 조정·페이지 크기에 가려 무엇 때문에 빠졌는지가 흐려집니다. 계층 규칙(controller→service→repository)의 예외로 볼 여지가 있어 남깁니다.

  4. 후보 0건만 대역(Mockito)으로 검증했습니다. 컨테이너를 공유하고 테스트 간 DB를 비우지 않아, 다른 테스트가 만든 Collection이 항상 후보에 들어옵니다. 같은 이유로 통합 단언은 "응답 전체"가 아니라 내가 만든 id로 걸러낸 부분에만 겁니다. DB를 비우는 쪽이 나을지 봐 주세요(truncate는 다른 클래스의 FK까지 건드립니다).

  5. 문서 번호를 BD-34·BI-20으로 잡았습니다. decisions/README의 규칙은 "dev 머지 기준 다음 값"(→ BD-33·BI-19)이지만, 열린 PR back#75가 그 번호를 파일명으로 쓰고 있어 병합 시 충돌합니다. 충돌을 피하는 쪽을 골랐고, back#75가 먼저 머지되면 그대로 맞고, 취소되면 번호가 하나 비게 됩니다.

미결 / 후속

이 PR의 필수 범위(후보·점수·API·커서·테스트)는 완결이고, 아래는 남긴 것입니다. BI-20에 표로 정리했습니다.

  • Redis Cache 3종 — Profile(6h)·Collection 특징(1h)·Session(10m). 매 요청 DB 집계 중입니다. TTL·직렬화·장애 차단이 함께 들어와야 해 분리했습니다. K1~K8 테스트도 여기에 딸려 갑니다.
  • IMPRESSION 비동기 기록 — 지금은 응답 직전 동기 기록 + try/catch입니다. 실패해도 응답이 정상이라 계약은 지키지만 명세가 말하는 별도 스레드는 아닙니다.
  • N1~N3(외부 호출 0회) Mock 단언 — Feed 경로에 주입되는 외부 Client 빈이 아직 없습니다. 지금은 "호출할 대상이 코드에 없다"가 경계이고, FastAPI Client가 생기는 시점에 단언을 붙여야 합니다.
  • N4~N7 쿼리 카운터 — 일괄 조회로 구현했지만 카운터로 고정하지 않아, 리팩터가 N+1을 다시 들여와도 테스트가 잡지 못합니다.
  • GET /feed/collections/{id}/shelf — 티켓 범위 밖(백엔드 합의).
  • 빈 후보 fallback — 후보 0건이면 빈 배열로 응답합니다. 최신 채널이 이미 모든 발행분을 훑으므로 "후보 0건"은 곧 "보여줄 것이 없음"이고, 명세의 fallback이 추가로 건질 대상이 없다고 판단했습니다. 판단이 틀렸다면 알려 주세요.

🤖 Generated with Claude Code

AI 파트가 소유한 `docs/ai/spec/feed-*.md`의 정책을 Spring으로 옮깁니다. 가중치·채널
배분·탐색 슬롯 등 정책 수치는 하나도 바꾸지 않고 전부 `@ConfigurationProperties`로
외부화했습니다.

- 후보 3채널(최신 발행·팔로우·탐색용 무작위)과 합집합·중복 제거. 채널 출처는 보존해
  팔로우 여부가 점수의 followSignal이 되고, 무작위 출처가 탐색 슬롯 자격이 됩니다.
- 점수 계산은 전 과정 메모리 내 산술입니다. 요청 경로에서 FastAPI·Embedding·LLM을
  호출하지 않고 벡터 유사도를 계산하지 않습니다.
- `GET /api/core/v1/feed/collections` — 응답에 소유자 식별 정보를 담을 필드가 없고,
  Keyword 가시성 필터는 자바가 아니라 WHERE 절에 있습니다.
- opaque cursor는 공통 `Cursor`·`CursorPage` 규약을 그대로 재사용합니다. Feed 전용
  커서 형식도 전용 `size` 상한도 만들지 않았습니다.
- `POST /api/core/v1/feed/events` — CLICK·SAVE 수집. `member_id`는 인증 컨텍스트에서만
  결정되고, 배열 상한은 `InputLimits.FEED_EVENTS_MAX`(100)입니다.

DDL은 추가하지 않았습니다. `core.feed_event`(V102)와 `ix_collection_feed`(V3)가 이미
있어 조회·삽입만 합니다.

Redis Session Cache 대신 `requestId`를 무작위 채널의 seed로 삼아 페이지마다 결정적으로
재계산합니다. 근거와 감수하는 점은 BD-34에 적었습니다.

`./gradlew clean check --no-daemon` 292개 통과, 라인 96.6%·브랜치 82.2%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colosair
colosair force-pushed the feat/S15P11A705-120-feed-recommendation-mvp branch from d0d73ce to fcfd8a8 Compare July 29, 2026 03:28
@colosair
colosair merged commit cd48da1 into dev Jul 29, 2026
2 checks passed
@colosair
colosair deleted the feat/S15P11A705-120-feed-recommendation-mvp branch July 29, 2026 03:33
cherry-go-round added a commit that referenced this pull request Jul 29, 2026
재사용 감지가 401만 돌려주고 끝나 유출 시 공격자 세션이 살아남았다. 공격자가
먼저 회전하면 정상 사용자만 401로 끊기고, 공격자가 방금 받은 토큰은 최대 7일
유효하다(RFC 9700 §4.14.2). BD-32가 PR 크기 때문에 미뤄 둔 구멍이다.

RED — 한 회원의 두 세션 중 하나를 회전 후 재사용했을 때
  expected: 401 but was: 204  (다른 기기의 Refresh가 살아 있었다)

GREEN
- RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId> Set)와
  revokeAll(memberId) 추가. SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에
  권장되지 않아 BD-32가 이미 기각했다
- rotate가 재사용을 감지하면 revokeAll 후 401

구현하며 드러난 것
- 기존 회전 테스트가 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를
  단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다
- 폐기 대상에 회전으로 갓 발급된 토큰이 들어가야 한다. 유출 시나리오에서
  그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다
- SADD만 하면 Set에 TTL이 없어 영구 키가 된다. 발급마다 EXPIRE를 다시 건다

과잉 폐기를 잡는 반대 방향 회귀도 넣었다(normalRotationKeepsOtherSessionsAlive)
— 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다.

감수하는 것은 오탐이다. 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이
된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한
쪽을 골랐다. 방어선은 08 §3.3의 "재발급은 동시에 하나만"이다.

REFACTOR
- 인라인 주석을 Javadoc으로 옮겼다. 폐기 근거와 오탐 트레이드오프는 코드를 읽는
  사람만이 아니라 이 API를 쓰는 사람이 알아야 한다
- 틀린 근거를 단 주석을 지웠다 — 접두사를 나눠야 키가 겹치지 않는다고 썼지만
  auth:refresh:123과 auth:refresh:123:abc는 애초에 겹치지 않는다
- key/index → tokenKey/indexKey, valid → consumed(delete 후 변수라 "지금
  유효하다"로 읽히면 틀리다), deviceA → deviceARefresh(토큰 문자열이다)

문서: BD-35 신규(새 트레이드오프), BD-32는 Superseded by BD-35로 상태만 갱신,
BI-21 + 인덱스 + WORKLOG. 처음 BD-34/BI-20으로 썼다가 dev의 Feed 작업(#77)이
같은 번호를 먼저 확정해 재배정했다 — 번호는 dev 머지 순서로 확정된다.

회원 탈퇴(S15P11A705-65)가 같은 revokeAll을 쓴다.

Refs #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cherry-go-round added a commit that referenced this pull request Jul 29, 2026
재사용 감지가 401만 돌려주고 끝나 유출 시 공격자 세션이 살아남았다. 공격자가
먼저 회전하면 정상 사용자만 401로 끊기고, 공격자가 방금 받은 토큰은 최대 7일
유효하다(RFC 9700 §4.14.2). BD-32가 PR 크기 때문에 미뤄 둔 구멍이다.

RED — 한 회원의 두 세션 중 하나를 회전 후 재사용했을 때
  expected: 401 but was: 204  (다른 기기의 Refresh가 살아 있었다)

GREEN
- RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId> Set)와
  revokeAll(memberId) 추가. SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에
  권장되지 않아 BD-32가 이미 기각했다
- rotate가 재사용을 감지하면 revokeAll 후 401

구현하며 드러난 것
- 기존 회전 테스트가 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를
  단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다
- 폐기 대상에 회전으로 갓 발급된 토큰이 들어가야 한다. 유출 시나리오에서
  그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다
- SADD만 하면 Set에 TTL이 없어 영구 키가 된다. 발급마다 EXPIRE를 다시 건다

과잉 폐기를 잡는 반대 방향 회귀도 넣었다(normalRotationKeepsOtherSessionsAlive)
— 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다.

감수하는 것은 오탐이다. 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이
된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한
쪽을 골랐다. 방어선은 08 §3.3의 "재발급은 동시에 하나만"이다.

REFACTOR
- 인라인 주석을 Javadoc으로 옮겼다. 폐기 근거와 오탐 트레이드오프는 코드를 읽는
  사람만이 아니라 이 API를 쓰는 사람이 알아야 한다
- 틀린 근거를 단 주석을 지웠다 — 접두사를 나눠야 키가 겹치지 않는다고 썼지만
  auth:refresh:123과 auth:refresh:123:abc는 애초에 겹치지 않는다
- key/index → tokenKey/indexKey, valid → consumed(delete 후 변수라 "지금
  유효하다"로 읽히면 틀리다), deviceA → deviceARefresh(토큰 문자열이다)

문서: BD-35 신규(새 트레이드오프), BD-32는 Superseded by BD-35로 상태만 갱신,
BI-21 + 인덱스 + WORKLOG. 처음 BD-34/BI-20으로 썼다가 dev의 Feed 작업(#77)이
같은 번호를 먼저 확정해 재배정했다 — 번호는 dev 머지 순서로 확정된다.

회원 탈퇴(S15P11A705-65)가 같은 revokeAll을 쓴다.

Refs #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant