feat(S15P11A705-200): 마이페이지 요약 조회 구현 - #128
Conversation
필수 변경 1건
|
#128 리뷰 반영(@minyongP). 회차마다 갈리는 flaky 테스트였다. doesNotExposeMemberId가 본문 전문에서 id 문자열을 찾는데, 픽스처 이메일이 summary6@kakao.com이라 본문에 6이 들어 있었다. 성공 envelope는 success/data뿐이고 나머지 수는 카운트 넷의 0이므로, 시퀀스가 이 테스트에 memberId=6을 주는 회차에서는 실증하려는 것과 무관하게 실패한다. 공유 컨테이너라 앞선 클래스가 시퀀스를 얼마나 밀었는지에 달려 회차마다 갈린다. 기전을 재현해 확인했다. 이메일을 그대로 두고 단언을 doesNotContain("6")으로 바꾸면 그 1건이 실패한다. 이메일에서 숫자를 뺐다(summary-six@kakao.com). 남는 수는 카운트 넷의 0뿐이고 id는 1부터라 우연 일치가 성립하지 않는다. 단언도 강화했다. /data의 필드 집합을 그대로 고정하므로 memberId가 다른 이름으로 새는 경우까지 걸린다 — 원래 단언은 그 이름만 봤다. assertThat(...at("/data").propertyNames()) .containsExactlyInAnyOrder("provider", "email", "recordCount", "collectionCount", "followerCount", "followingCount"); 이유를 테스트 javadoc에 남겼다. 다음에 픽스처 이메일에 숫자를 넣으면 같은 함정이 돌아온다. clean check 417개 통과, 실패 0 Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
리뷰 감사합니다. 맞는 지적이고 실제 flaky였습니다. 기전을 재현해 확인했습니다이메일은 그대로 두고 단언만 말씀하신 그대로입니다 — 성공 envelope는 고친 방식 — 제안하신 둘을 함께 했습니다① 이메일에서 숫자를 뺐습니다( ② 단언을 필드 집합으로 바꿨습니다. assertThat(jsonMapper.readTree(body).at("/data").propertyNames())
.containsExactlyInAnyOrder("provider", "email",
"recordCount", "collectionCount", "followerCount", "followingCount");원래 단언( 본문 전문 검사는 남겼습니다. 숫자를 뺀 뒤에는 안전하고, 그것이 잡는 것( 함정이 돌아오지 않게 이유를 테스트 javadoc에 적었습니다 — 다음에 누가 픽스처 이메일에 숫자를 넣으면 같은 문제가 재발하고, 그때 이 주석이 원인을 가리킵니다. 검증
|
재리뷰입니다 — 4b2dd4e..b48fd96 구간의 변경을 봤습니다. 필수 변경은 없습니다. 이전 지적( |
b48fd96 to
7ae57f5
Compare
#128 리뷰 반영(@minyongP). 회차마다 갈리는 flaky 테스트였다. doesNotExposeMemberId가 본문 전문에서 id 문자열을 찾는데, 픽스처 이메일이 summary6@kakao.com이라 본문에 6이 들어 있었다. 성공 envelope는 success/data뿐이고 나머지 수는 카운트 넷의 0이므로, 시퀀스가 이 테스트에 memberId=6을 주는 회차에서는 실증하려는 것과 무관하게 실패한다. 공유 컨테이너라 앞선 클래스가 시퀀스를 얼마나 밀었는지에 달려 회차마다 갈린다. 기전을 재현해 확인했다. 이메일을 그대로 두고 단언을 doesNotContain("6")으로 바꾸면 그 1건이 실패한다. 이메일에서 숫자를 뺐다(summary-six@kakao.com). 남는 수는 카운트 넷의 0뿐이고 id는 1부터라 우연 일치가 성립하지 않는다. 단언도 강화했다. /data의 필드 집합을 그대로 고정하므로 memberId가 다른 이름으로 새는 경우까지 걸린다 — 원래 단언은 그 이름만 봤다. assertThat(...at("/data").propertyNames()) .containsExactlyInAnyOrder("provider", "email", "recordCount", "collectionCount", "followerCount", "followingCount"); 이유를 테스트 javadoc에 남겼다. 다음에 픽스처 이메일에 숫자를 넣으면 같은 함정이 돌아온다. clean check 417개 통과, 실패 0 Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /v1/me/summary. 계정 정보(provider·email)와 활성 기준 카운트 넷을 한 응답에 담는다
(08 §3.5).
명세를 구현과 대조해 보니 미구현이 둘뿐이었다 — 이것과 /feed/collections/{id}/shelf(#85).
프론트가 요구한 두 기능(내 프로필, 팔로잉·팔로워 수)이 이 엔드포인트 하나로 해결된다.
함께 요청된 "내 기록 조회"는 계약에 없어 범위에서 뺐다. 기록 장(§5)의 조회는 지도 마커·
상세·장소별 셋뿐이고 목록 엔드포인트가 없다 — 필요해지면 docs 개정이 선행이다.
RED 7건(미인증 401만 이미 통과) → GREEN 8건
- MeSummaryResponse, MemberSummaryService
- 집계 메서드 4개: record·collection 각 countByMemberId, follow 양방향 count
- MeController에 @GetMapping("/summary")
열려 있던 질문에 답이 나왔다 — @SQLRestriction은 count 쿼리에도 적용된다
MemberRepository.isActive의 javadoc이 "바꾸면 @SQLRestriction이 count 쿼리에도 적용되는지가
별개의 질문이 되고, 그것은 이 변경의 범위가 아니다(S15P11A705-147)"로 남겨 뒀던 것이다.
활성 기준 집계가 넷 필요해지며 처음 답이 필요해졌고, 파생 countBy...만으로 성립한다.
뮤테이션으로 확인했다. countByMemberId를 조건 없는 native 쿼리로 바꾸면 소프트 삭제 제외
테스트 1건만 실패한다. 조건을 명시하려다 빠뜨리는 쪽이 오히려 위험하다는 것도 같은 실험이
보여 준다. 결론을 RecordRepository와 MemberRepository 양쪽 javadoc에 적었다.
설계 판단 셋
- 카운트를 넷으로 나눴다. 조인으로 묶으면 카운트가 곱해진다(Record 3 × Collection 2 = 6).
count(DISTINCT ...)로 피할 수 있으나 화면 진입당 1회 호출이라 그 복잡도를 살 이유가 없다
- 팔로워 수에 DISTINCT가 불필요하다. 유니크가 (followee, follower) 활성 기준이라 한 사람이
여러 Collection을 팔로우해도 행이 하나이고 두 번째 시도는 409다. 팔로우 단위가 Collection이
아니라 작성자라는 뜻이고, 계약이 "팔로워 수"라고만 적어 단위가 드러나지 않으므로 테스트로
고정했다
- 소셜 계정이 없으면 IllegalStateException. 조용히 빈 값을 내보내면 "email은 항상 있다"는
계약이 깨진 채 프론트로 나간다
드러난 것
- 픽스처 계산을 틀렸고 테스트가 잡았다. collectionCount를 3으로 기대했는데 2였다 — 세 번째는
남의 소유다. 구현이 아니라 기대값이 틀린 경우였고 픽스처에 소유자를 주석으로 명시했다
- 소프트 삭제를 삭제 API가 아니라 JdbcTemplate으로 만들었다. 삭제 API는 연쇄 삭제까지 해서
세려는 대상이 함께 사라지고, 그러면 검증 대상이 "카운트가 활성만 세는가"에서 "연쇄 삭제가
무엇을 지웠는가"로 옮겨간다
clean check 417개 통과, 실패 0
Refs #125
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createRecord·createCollection·follow 같은 픽스처가 클래스마다 복사돼 있었다. support/CoreApiFixtures로 올리고 member 도메인 두 테스트가 상속하게 했다 — 두 파일에서 177줄이 사라졌다. support에 둔 이유는 도메인을 가로지르기 때문이다. Record·Collection·Follow를 함께 쓰는 픽스처라 어느 한 도메인 패키지에 두면 그 도메인이 아닌 테스트가 남의 패키지를 참조하게 된다. 한 도메인만 쓰는 픽스처는 계속 그 도메인 안에 둔다 — FeedFixtures가 그 경우이고, 이 클래스는 그 구조(추상 클래스 + IntegrationContainerSupport 상속 + protected 필드)를 그대로 따랐다. AiDerivedDataInvalidationTests는 옮기지 않았다. 같은 픽스처가 그쪽에도 있지만 AI 파트 소유 파일(S15P11A705-124)이라 이 PR에서 건드리지 않는다. 필요할 때 그쪽이 상속하면 된다. MemberWithdrawalApiTests에는 givenSocialAccount 3인자 오버로드를 남겨 공용의 4인자 버전에 위임한다. 그 클래스는 provider를 가리지 않아 Google로 고정되어 있고, 호출부 11곳을 건드리지 않으려는 선택이다. 옮기다 두 번 과하게 지웠다 — 남은 테스트가 쓰는 Member와 Collectors import를 함께 지워 컴파일이 깨졌고 되돌렸다. 헬퍼를 지울 때 그 파일의 다른 테스트가 같은 타입을 쓰는지 함께 봐야 한다. clean check 417개 통과, 실패 0 — 리팩터 전후 같은 수치다 Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#128 리뷰 반영(@minyongP). 회차마다 갈리는 flaky 테스트였다. doesNotExposeMemberId가 본문 전문에서 id 문자열을 찾는데, 픽스처 이메일이 summary6@kakao.com이라 본문에 6이 들어 있었다. 성공 envelope는 success/data뿐이고 나머지 수는 카운트 넷의 0이므로, 시퀀스가 이 테스트에 memberId=6을 주는 회차에서는 실증하려는 것과 무관하게 실패한다. 공유 컨테이너라 앞선 클래스가 시퀀스를 얼마나 밀었는지에 달려 회차마다 갈린다. 기전을 재현해 확인했다. 이메일을 그대로 두고 단언을 doesNotContain("6")으로 바꾸면 그 1건이 실패한다. 이메일에서 숫자를 뺐다(summary-six@kakao.com). 남는 수는 카운트 넷의 0뿐이고 id는 1부터라 우연 일치가 성립하지 않는다. 단언도 강화했다. /data의 필드 집합을 그대로 고정하므로 memberId가 다른 이름으로 새는 경우까지 걸린다 — 원래 단언은 그 이름만 봤다. assertThat(...at("/data").propertyNames()) .containsExactlyInAnyOrder("provider", "email", "recordCount", "collectionCount", "followerCount", "followingCount"); 이유를 테스트 javadoc에 남겼다. 다음에 픽스처 이메일에 숫자를 넣으면 같은 함정이 돌아온다. clean check 417개 통과, 실패 0 Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7ae57f5 to
f706029
Compare
리베이스로 들어온 GET /v1/me/summary(#128)를 시나리오에 더하고, 그 검사가 드러낸 하네스 한계를 고쳤다 — SQL로 회원 행만 만들면 '소셜 계정 없는 활성 회원'이라는 API로 도달 불가능한 상태가 되고 summarize가 계약대로 500을 던진다. setup이 소셜 계정까지 만들어 실제 가입 결과와 같은 모양을 갖춘다. 전체 파이프라인 138/138, exit 0.
* test(S15P11A705-192): 검증용 RS256 토큰 발급기를 추가한다 * fix(S15P11A705-192): member_id 검증을 추가해 JSON 깨짐을 방지한다 * fix(S15P11A705-192): 출력 블록 전에 member_id를 검증해 JSON 손상을 방지한다 * test(S15P11A705-192): 전용 테스트 회원 생성·정리 SQL을 추가한다 * fix(S15P11A705-192): setup 출력을 한 줄로 고정하고 social_account 정리를 더한다 * test(S15P11A705-192): k6 공용 라이브러리(설정·HTTP·기록기)를 추가한다 * fix(S15P11A705-192): CSRF 토큰을 캐시하지 않고 항아리에서 매번 읽는다 * test(S15P11A705-192): 읽기 9개와 인증·소유권 부정 계약 시나리오를 추가한다 * fix(S15P11A705-192): 골든 장소 id를 실제 시드 값으로 맞춘다 * fix(S15P11A705-192): size 상한 검사가 깨진 엔벨로프에서도 소리 내게 한다 * test(S15P11A705-192): Record·Context 쓰기 시나리오와 BD-07·BD-12 계약 검사를 추가한다 * test(S15P11A705-192): Collection 쓰기와 BD-11 삭제 확인 흐름 검사를 추가한다 * test(S15P11A705-192): Follow·Feed·Search·Auth 시나리오로 27개 전수를 채운다 * test(S15P11A705-192): k6가 만진 id를 지목 검증하는 SQL을 추가한다 * test(S15P11A705-192): DB 전역 불변식 검증 SQL을 추가한다 * test(S15P11A705-192): 전 과정 오케스트레이터와 하네스 문서를 추가한다 * fix(S15P11A705-192): 삭제된 Collection을 BD-20 지목 검증 대상에서 뺀다 run.sh로 처음 전 과정을 실제로 돌려보니 k6 시나리오가 force 연쇄로 Collection을 지운 뒤 그 record_count를 지목 검증하면서 매번 거짓 위반이 났다 — 삭제된 행은 record_count가 더 갱신되지 않으므로 활성 연결 수(0)와 항상 어긋난다. * fix(S15P11A705-192): 정리 트랩을 앞당기고 표식 추출을 JSON 디코더로 바꾼다 * test(S15P11A705-192): 회원 탈퇴 엔드포인트를 더해 28개 전수를 맞춘다 * docs(S15P11A705-192): 종단 검증 하네스 구현 보고서와 시드 원인 삼분류를 남긴다 * fix(S15P11A705-192): FAIL 단언·미호출·스윕 오류·골든 훼손이 종료 코드에 반영되게 한다 * test(S15P11A705-192): dev 전진분 me/summary를 더해 29개 전수를 맞춘다 리베이스로 들어온 GET /v1/me/summary(#128)를 시나리오에 더하고, 그 검사가 드러낸 하네스 한계를 고쳤다 — SQL로 회원 행만 만들면 '소셜 계정 없는 활성 회원'이라는 API로 도달 불가능한 상태가 되고 summarize가 계약대로 500을 던진다. setup이 소셜 계정까지 만들어 실제 가입 결과와 같은 모양을 갖춘다. 전체 파이프라인 138/138, exit 0. * docs(S15P11A705-192): dev가 선점한 BI-32를 피해 BI-33으로 재번호한다
요약
GET /api/core/v1/me/summary를 구현한다. 프론트의 프로필 화면이 요구한 두 기능(내 프로필, 팔로잉·팔로워 수)이 이 엔드포인트 하나로 해결된다 — 명세 §3.5가 계정 정보와 카운트 넷을 한 응답에 담아 뒀다.명세를 구현과 대조해 보니 미구현이 둘뿐이었다. 이것과
GET /feed/collections/{id}/shelf(#85)이고 나머지는 전부 있었다.Jira (필수)
관련 GitHub Issue (선택)
변경 사항
MeSummaryResponse·MemberSummaryService·MeController.summary()RecordRepository.countByMemberId,CollectionRepository.countByMemberId,FollowRepository.countByFolloweeMemberId·countByFollowerMemberIdBI-30, WORKLOG 1줄support/CoreApiFixtures로 올림(별도 커밋)테스트 / 검증
./gradlew clean check --no-daemon— 417개 통과, 실패 0, 오류 0RED — 7건 실패. 미인증 401만 이미 통과했다(
anyRequest().authenticated()가 담당).GREEN — 8건. 완료 조건 대응은 이렇다.
returnsAccountInfoAndFourCountssoftDeletedRecordsAndCollectionsAreExcludedsoftDeletedFollowsAreExcludedfollowerCountCountsTheFollowerOncePerOwnermemberId미포함doesNotExposeMemberIdunauthenticatedRequestIs401추가로 데이터 없는 회원의 0 집계, 타인 데이터 미포함을 더 넣었다.
열려 있던 질문에 답이 나왔다 —
@SQLRestriction은 count 쿼리에도 적용된다MemberRepository.isActive의 javadoc이 S15P11A705-147부터 이 질문을 미결로 남겨 뒀다.활성 기준 집계가 넷 필요해지며 처음 답이 필요해졌고, 파생
countBy...만으로 성립한다.뮤테이션으로 확인했다.
countByMemberId를 조건 없는 native 쿼리로 바꿨을 때:그 1건만 실패한다. 조건을 명시하려다 빠뜨리는 쪽이 오히려 위험하다는 것도 같은 실험이 보여 준다. 결론을
RecordRepository와 원래 질문을 남긴MemberRepository양쪽 javadoc에 적었다.리뷰 포인트
① 카운트 쿼리를 넷으로 나눴다. 한 번의 조인으로 묶으면 카운트가 곱해진다 — Record 3건과 Collection 2건을 조인하면 6이다.
count(DISTINCT ...)로 피할 수 있으나, 화면 진입당 1회 호출되는 경로에서 PK·FK 인덱스를 타는 count 넷이 그 복잡도를 살 이유가 되지 않는다고 봤다.② 팔로워 수에
DISTINCT를 쓰지 않았다. 유니크가(followee_member_id, follower_member_id)활성 기준이라(V3__core_domain.sql:125) 한 사람이 그 작성자의 Collection을 여러 개 팔로우해도 행이 하나이고, 두 번째 시도는409 DUPLICATE_FOLLOW로 거절된다. 즉 팔로우 단위가 Collection이 아니라 작성자다. 계약이 "팔로워 수"라고만 적어 단위가 드러나지 않으므로 그 성질을 테스트로 고정했다.③ 소셜 계정이 없으면
IllegalStateException이다. 가입이 회원과 소셜 계정을 한 트랜잭션에서 만들어(SocialLoginService.signUp) 정상 상태에서는 없는 경우다. 조용히 빈 값을 내보내면 *"email은 항상 있다"*는 계약(§3.5)이 깨진 채 프론트로 나간다 — 프론트가 값 없음 대비를 하지 않기로 한 근거가 그 문장이다.④ 리팩터를 같은 PR에 넣었다. 픽스처가 클래스마다 복사돼 있어
support/CoreApiFixtures로 올렸다(별도 커밋, 두 파일에서 −177줄).AiDerivedDataInvalidationTests는 같은 픽스처를 갖지만 AI 파트 소유 파일(S15P11A705-124)이라 건드리지 않았다 — 필요할 때 그쪽이 상속하면 된다.미결 / 후속
docs개정이 선행이다(CLAUDE.md9번).provider가 하나뿐이다. 계정당 소셜 계정이 하나인 것은 계정 연결 흐름이 없기 때문이고 그 전제가 타입에는 없다.findByMemberId가 목록을 반환하므로 첫 행을 쓴다 — 연결 기능이 생기면 여기가 먼저 깨진다.