feat(S15P11A705-120): Feed 추천 MVP — 후보 3채널·결정적 점수·opaque cursor - #77
Merged
Merged
Conversation
minyongP
marked this pull request as ready for review
July 29, 2026 03:14
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
force-pushed
the
feat/S15P11A705-120-feed-recommendation-mvp
branch
from
July 29, 2026 03:28
d0d73ce to
fcfd8a8
Compare
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>
3 tasks
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
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 (필수)
S15P11A705-120관련 GitHub Issue (선택)
변경 사항
신규 —
domain/feedcontroller/FeedControllerGET /v1/feed/collections·POST /v1/feed/events. context-path(/api/core)는 매핑에 반복하지 않습니다service/FeedService@Transactional을 걸지 않습니다 — 전 과정이 읽기이고, IMPRESSION 기록 실패가 응답을 되돌리면 안 되기 때문입니다service/FeedScorerservice/FeedRankerpage-size블록 단위로 적용합니다service/FeedSessionrequestId+ offset. 공통Cursor(Base64(정렬키,id))를 재사용합니다service/FeedPropertiespinlog.feed.*정책값. 소비자와 같은 패키지에 둡니다(JwtProperties선례)service/{FeedCandidate,ScoredCandidate,FeedProfile}repository/FeedCandidateRepositoryrepository/FeedKeywordRepositoryrepository/FeedEventRepositorycore.feed_eventbatch INSERT와 패널티 집계repository/FeedCollectionCardentity/FeedEventTypeevent컬럼의 도메인 값. 테이블은 AI 소유라@Entity를 두지 않습니다dto/*기존 파일
global/common/InputLimits—FEED_EVENTS_MAX = 100추가.RECORD_IDS_MAX·CursorPage.MAX_SIZE와 같은 값입니다(S15P11A705-117의 "서버 방어 상한의 답은 하나").application.yml—pinlog.feed.*블록. 각 값의 근거를 주석으로 달았습니다.docs/backend/{decisions,implements,WORKLOG}— BD-34·BI-20과 로그 1줄.테스트 / 검증
./gradlew clean check --no-daemon— 292개 통과, 실패 0 (d0d73ce)pgvector/pgvector:0.8.5-pg16)docs#21)이 정본이고 그것을 구현했습니다. back 쪽 기록은 BI-20커버리지: 라인 96.6% · 브랜치 82.2% (게이트 BUNDLE 80%).
RED → GREEN
명세를 테스트로 먼저 옮기고 구현했습니다. 실행 중 실제로 잡힌 것 두 건을 남깁니다.
FeedEventApiTests2건이Status expected:<201> but was:<400>으로 실패 — Fixture의kakaoPlaceId가VARCHAR(50)을 넘었습니다. 상한을 넘기는 접두사를 쓴 테스트만 실패해 원인이 좁혀졌습니다--tests "…domain.feed.*"전체 통과checkstyleMain38건 위반 — SQL 텍스트 블록의 이어지는 줄을 공백으로 들여썼습니다(레포 규약은 탭)명세 대응표
FeedScorerTests— weighted Jaccard 4종, 크기 편향 없음, followSignal 이분값, 지수 감쇠, cap, 정규화 범위, 가중치 설정 의존FeedRankerTests— 소유자 상한 2, 상한 완화, 탐색 4건이 하위 절반, 탐색 0건 백필, Cold Start 6건FeedCandidateChannelTests— 본인·삭제·비공개·탈퇴·빈 Collection 제외, 팔로우 출처 보존, 재검증 순서 보존FeedKeywordVisibilityTests—PUBLIC만 타인에게,PRIVATE_ONLY는 본인 Profile에만,BLOCKED·비활성 Preset 전면 제외FeedApiTests— 기본 20, 상한 보정, 0 이하 보정, 커서 왕복 무중복,requestId별도 필드, 소유자 식별자 부재,keywords: [], 위조 커서 400FeedEventApiTests+FeedServiceTests배경
명세가 페이지네이션에 Redis Session Cache(
feed:session:{requestId})를 두는데, 같은 명세가 "Redis 장애 시 정상 응답"과 "Session 만료 시 첫 페이지부터"를 함께 요구합니다. 즉 Cache가 있어도 없을 때 도는 경로를 반드시 따로 만들어야 합니다. 그 경로 하나만 두고 Cache는 나중에 앞에 얹기로 했습니다 — 정렬을 만드는 코드가 둘이 되면 페이지 경계에서만 재현되는 버그가 생깁니다.지켜야 할 것은 계약(페이지 간 중복·누락 없음 — K10·Q4)이지 기법이 아니고,
requestIdseed 고정 + 결정적 정렬이 같은 성질을 만듭니다. 대안과 감수하는 점은 BD-34에 있습니다.리뷰 포인트
BD-34의 트레이드오프. 후보 풀을 얼리지 않으므로 요청 사이에 새 Collection이 발행되면 다음 페이지에서 한 항목이 밀려 중복 노출될 수 있습니다. 반대로 Session Cache는 그걸 막는 대신 삭제·비공개 전환이 stale해집니다(그쪽은 최종 재검증이 막습니다). 어느 쪽도 완전하지 않아 "얼리지 않는 대신 최신 상태를 본다"를 골랐습니다 — 이 선택이 맞는지 봐 주세요.
다양성 조정을 블록 단위로 둔 것. "한 응답 내 동일 소유자 최대 2건"·"20개 중 탐색 4건"은 한 응답에 대한 규칙이라, 전체 정렬 결과를
diversity.page-size(20) 블록으로 끊고 블록마다 적용합니다. 요청size가 20이 아니면 한 응답에 블록이 여러 개 걸치거나 잘립니다. 명세가 슬롯 수를page-size비례로 정의하므로 설정값 쪽을 기준으로 삼았는데, 요청size기준이 나은지 판단이 필요합니다.FeedCandidateChannelTests가 API가 아니라 리포지토리를 직접 부릅니다. 채널 쿼리의 WHERE 절이 검증 대상인데 API까지 거치면 다양성 조정·페이지 크기에 가려 무엇 때문에 빠졌는지가 흐려집니다. 계층 규칙(controller→service→repository)의 예외로 볼 여지가 있어 남깁니다.후보 0건만 대역(Mockito)으로 검증했습니다. 컨테이너를 공유하고 테스트 간 DB를 비우지 않아, 다른 테스트가 만든 Collection이 항상 후보에 들어옵니다. 같은 이유로 통합 단언은 "응답 전체"가 아니라 내가 만든 id로 걸러낸 부분에만 겁니다. DB를 비우는 쪽이 나을지 봐 주세요(truncate는 다른 클래스의 FK까지 건드립니다).
문서 번호를 BD-34·BI-20으로 잡았습니다.
decisions/README의 규칙은 "dev머지 기준 다음 값"(→ BD-33·BI-19)이지만, 열린 PR back#75가 그 번호를 파일명으로 쓰고 있어 병합 시 충돌합니다. 충돌을 피하는 쪽을 골랐고, back#75가 먼저 머지되면 그대로 맞고, 취소되면 번호가 하나 비게 됩니다.미결 / 후속
이 PR의 필수 범위(후보·점수·API·커서·테스트)는 완결이고, 아래는 남긴 것입니다. BI-20에 표로 정리했습니다.
try/catch입니다. 실패해도 응답이 정상이라 계약은 지키지만 명세가 말하는 별도 스레드는 아닙니다.GET /feed/collections/{id}/shelf— 티켓 범위 밖(백엔드 합의).🤖 Generated with Claude Code