Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .claude/skills/review-fix-plan/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
name: review-fix-plan
description: |
코드 리뷰 후 수정 계획 문서를 생성하는 스킬.
코드 리뷰 결과를 구조화된 마크다운 문서로 docs/local/ 하위에 저장한다.
"리뷰 문서 만들어줘", "코드리뷰 정리해줘", "/review-fix-plan" 요청 시 사용.
---

# Review Fix Plan

코드 리뷰 결과를 **문제상황 → 문제원인 → 해결 방법 → 결과** 4단계 구조로 정리하여
`docs/local/` 하위에 마크다운 문서로 저장한다.

## When to Use

- 코드 리뷰 수행 후 이슈를 문서로 정리하고 싶을 때
- "리뷰 문서 만들어줘", "코드리뷰 정리해줘" 요청 시
- "/review-fix-plan" 슬래시 커맨드 입력 시
- PR 리뷰 후 수정 사항을 추적하고 싶을 때

## Process

1. **리뷰 결과 수집**: 현재 대화에서 발견된 코드 리뷰 이슈 파악
- 각 이슈의 심각도 판단 (CRITICAL / WARNING / LOW)
- 관련 파일과 코드 위치 식별

2. **문서 작성**: 아래 템플릿에 맞춰 각 이슈를 정리

3. **파일 저장**: `docs/local/{도메인}-review-fixes.md` 경로에 저장
- 기존 파일이 있으면 덮어쓰기 (같은 PR/작업 단위)

4. **완료 보고**: 저장된 파일 경로를 사용자에게 알림

## Template

```markdown
# {작업명} 코드 리뷰 이슈 분석 ({날짜})

## 수정 파일

- `{파일 경로 1}`
- `{파일 경로 2}`

---

## [{심각도 코드}] {위치} — {이슈 한줄 요약}

**심각도**: {이모지} {CRITICAL | WARNING | LOW}

**문제 상황**: {어떤 동작이 문제인지 사용자 관점에서 서술}

**문제 원인**: {왜 이 문제가 발생했는지 코드 레벨에서 분석}

```java
// AS-IS — 문제 코드
{기존 코드}
```

**해결 방법**: {구체적인 수정 방안 서술}

```java
// TO-BE — 개선 코드
{수정 코드}
```

**결과**: {수정 완료 여부, 수정 후 기대 효과}

---

## 패턴 정리

| 상황 | 패턴 |
|------|------|
| {이번 리뷰에서 얻은 교훈} | {재발 방지를 위한 규칙} |
```
Comment on lines +74 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

마크다운 린팅 경고 2건

정적 분석 도구가 두 가지 경고를 감지했습니다:

  • Line 74: 테이블 앞뒤에 빈 줄 추가 필요 (MD058)
  • Line 75: 코드 블록에 언어 지정 필요 (MD040)
🔧 수정 제안
 **해결 방법**: {구체적인 수정 방안 서술}
 
+
 | 상황 | 패턴 |
 |------|------|
 | {이번 리뷰에서 얻은 교훈} | {재발 방지를 위한 규칙} |
+
-```
+```text
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 74-74: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 75-75: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/skills/review-fix-plan/SKILL.md around lines 74 - 75, Add a blank
line before and after the table row "| {이번 리뷰에서 얻은 교훈} | {재발 방지를 위한 규칙} |" to
satisfy MD058, and change the fenced code block from a language-less "```" to
include a language tag (e.g., "```text" or "```md") to satisfy MD040; locate the
table line and the following code fence in SKILL.md and update the surrounding
blank lines and the opening triple-backtick to include an appropriate language.


## Severity Guide

| 코드 | 이모지 | 기준 |
|------|--------|------|
| C-N | 🔴 CRITICAL | 잘못된 데이터 반환, 런타임 에러, 보안 취약점 |
| W-N | 🟡 WARNING | 성능 저하, 중복 코드, 유지보수 위험 |
| L-N | 🟢 LOW | dead code, 네이밍, 스타일 |

## Naming Convention

- 파일명: `{도메인}-review-fixes.md` (예: `feed-modify-existing-review-fixes.md`)
- 이슈 ID: `[{심각도 첫글자}-{번호}]` (예: `[C-1]`, `[W-2]`, `[L-1]`)
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- 완전히 실행 가능한 코드만 제공한다 (의사코드 금지)
- `@Valid`, `@NotNull` 등 DTO 검증 어노테이션 적용
- secrets/환경변수 하드코딩 절대 금지
- **변수명은 의미를 알 수 있도록 작성한다** — `dto`, `r`, `p` 같은 축약 금지, 역할이 드러나는 이름 사용 (예: `dto` → `rawRanking`, `r` → `ranking`)

### 새 기능 추가 시 필수 순서
1. (DB 변경 시) `resources/db/migration/`에 Flyway 마이그레이션 파일 추가
Expand All @@ -31,6 +32,9 @@
- 버그 수정/기능 추가 시 반드시 테스트 추가
- TestContainers 사용 — Docker 실행 상태 필요
- **FixtureMonkey 사용 금지** — 테스트 데이터는 `common/fixture/` 의 static 메서드로만 생성한다
- `@DisplayName`에 메서드명을 넣지 않고, **테스트만 보고 요구사항을 파악할 수 있는 문장**으로 작성한다
- 좋은 예: `"피드가 없으면 모든 집계가 0이다"`, `"삭제된 피드는 조회되지 않는다"`
- 나쁜 예: `"findMyFeedStat - 피드가 없으면 0"`, `"getAllFeeds는 삭제된 피드 제외"`

---

Expand All @@ -39,6 +43,7 @@
- 브랜치명: `{type}/{DDING-이슈번호}-{설명}` (예: `feat/DDING-123-club-search`)
- 커밋 메시지: 한국어, `[DDING-000] 작업 내용` 형식
- PR 템플릿: 🚀 작업 내용 / 🤔 고민했던 내용 / 💬 리뷰 중점사항
- PR 본문 작성 시 특정 클래스명, 메서드명을 나열하지 않고 **작업 내용 중심의 자연스러운 글**로 작성한다 (가독성 우선)

### API 단위 브랜치 전략 (PR 크기 관리)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,24 @@
@Builder
public record AdminClubFeedRankingResponse(
int rank,
Long clubId,
String clubName,
long feedCount,
long viewCount,
long likeCount,
long commentCount,
long score
long feedScore,
long viewScore,
long likeScore,
long commentScore,
long totalScore
) {

public static List<AdminClubFeedRankingResponse> from(List<ClubFeedRankingQuery> queries) {
return queries.stream()
.map(query -> AdminClubFeedRankingResponse.builder()
.rank(query.rank())
.clubId(query.clubId())
.clubName(query.clubName())
.feedCount(query.feedCount())
.viewCount(query.viewCount())
.likeCount(query.likeCount())
.commentCount(query.commentCount())
.score(query.score())
.feedScore(query.feedScore())
.viewScore(query.viewScore())
.likeScore(query.likeScore())
.commentScore(query.commentScore())
.totalScore(query.totalScore())
.build())
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,25 @@ public record ClubMonthlyStatusResponse(
int year,
int month,
int rank,
long feedCount,
long viewCount,
long likeCount,
long commentCount,
long score
int lastMonthRank,
long feedScore,
long viewScore,
long likeScore,
long commentScore,
long totalScore
) {

public static ClubMonthlyStatusResponse from(ClubMonthlyStatusQuery query) {
return ClubMonthlyStatusResponse.builder()
.year(query.year())
.month(query.month())
.rank(query.rank())
.feedCount(query.feedCount())
.viewCount(query.viewCount())
.likeCount(query.likeCount())
.commentCount(query.commentCount())
.score(query.score())
.lastMonthRank(query.lastMonthRank())
.feedScore(query.feedScore())
.viewScore(query.viewScore())
.likeScore(query.likeScore())
.commentScore(query.commentScore())
.totalScore(query.totalScore())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ public List<ClubFeedRankingQuery> getClubFeedRanking(int year, int month) {
long previousScore = Long.MAX_VALUE;
int rank = 1;
for (int i = 0; i < sorted.size(); i++) {
long score = calculateScore(sorted.get(i));
if (i > 0 && score < previousScore) {
long totalScore = calculateScore(sorted.get(i));
if (i > 0 && totalScore < previousScore) {
rank = i + 1;
}
result.add(ClubFeedRankingQuery.of(rank, sorted.get(i), score));
previousScore = score;
result.add(toClubFeedRankingQuery(rank, sorted.get(i)));
previousScore = totalScore;
}

return result;
Expand All @@ -53,19 +53,49 @@ public List<ClubFeedRankingQuery> getClubFeedRanking(int year, int month) {
public ClubMonthlyStatusQuery getClubMonthlyStatus(Long userId, int year, int month) {
Club club = clubService.getByUserId(userId);
List<ClubFeedRankingQuery> rankings = getClubFeedRanking(year, month);
int lastMonthRank = getLastMonthRank(club.getId(), year, month);

return rankings.stream()
.filter(rankingQuery -> rankingQuery.clubId().equals(club.getId()))
.findFirst()
.filter(rankingQuery -> rankingQuery.score() > 0)
.map(rankingQuery -> ClubMonthlyStatusQuery.from(year, month, rankingQuery))
.orElse(ClubMonthlyStatusQuery.createEmpty(year, month));
.filter(rankingQuery -> rankingQuery.totalScore() > 0)
.map(rankingQuery -> toMonthlyStatus(year, month, rankingQuery, lastMonthRank))
.orElse(ClubMonthlyStatusQuery.createEmpty(year, month, lastMonthRank));
}

private long calculateScore(MonthlyFeedRankingDto dto) {
return dto.getFeedCount() * FEED_WEIGHT
+ dto.getViewCount() * VIEW_WEIGHT
+ dto.getLikeCount() * LIKE_WEIGHT
+ dto.getCommentCount() * COMMENT_WEIGHT;
private ClubMonthlyStatusQuery toMonthlyStatus(int year, int month,
ClubFeedRankingQuery ranking, int lastMonthRank) {
return ClubMonthlyStatusQuery.of(year, month, ranking.rank(), lastMonthRank,
ranking.feedScore(), ranking.viewScore(), ranking.likeScore(), ranking.commentScore());
}

private int getLastMonthRank(Long clubId, int year, int month) {
int lastYear = month == 1 ? year - 1 : year;
int lastMonth = month == 1 ? 12 : month - 1;

List<ClubFeedRankingQuery> lastMonthRankings = getClubFeedRanking(lastYear, lastMonth);
return lastMonthRankings.stream()
.filter(ranking -> ranking.clubId().equals(clubId))
.filter(ranking -> ranking.totalScore() > 0)
.findFirst()
.map(ClubFeedRankingQuery::rank)
.orElse(0);
}

private ClubFeedRankingQuery toClubFeedRankingQuery(int rank, MonthlyFeedRankingDto rawRanking) {
long feedScore = rawRanking.getFeedCount() * FEED_WEIGHT;
long viewScore = rawRanking.getViewCount() * VIEW_WEIGHT;
long likeScore = rawRanking.getLikeCount() * LIKE_WEIGHT;
long commentScore = rawRanking.getCommentCount() * COMMENT_WEIGHT;
long totalScore = feedScore + viewScore + likeScore + commentScore;
return ClubFeedRankingQuery.of(rank, rawRanking.getClubId(), rawRanking.getClubName(),
feedScore, viewScore, likeScore, commentScore, totalScore);
}

private long calculateScore(MonthlyFeedRankingDto rawRanking) {
return rawRanking.getFeedCount() * FEED_WEIGHT
+ rawRanking.getViewCount() * VIEW_WEIGHT
+ rawRanking.getLikeCount() * LIKE_WEIGHT
+ rawRanking.getCommentCount() * COMMENT_WEIGHT;
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
package ddingdong.ddingdongBE.domain.feed.service.dto.query;

import ddingdong.ddingdongBE.domain.feed.repository.dto.MonthlyFeedRankingDto;
import lombok.Builder;

@Builder
public record ClubFeedRankingQuery(
int rank,
Long clubId,
String clubName,
long feedCount,
long viewCount,
long likeCount,
long commentCount,
long score
long feedScore,
long viewScore,
long likeScore,
long commentScore,
long totalScore
) {

public static ClubFeedRankingQuery of(int rank, MonthlyFeedRankingDto dto, long score) {
public static ClubFeedRankingQuery of(int rank, Long clubId, String clubName,
long feedScore, long viewScore, long likeScore, long commentScore, long totalScore) {
return ClubFeedRankingQuery.builder()
.rank(rank)
.clubId(dto.getClubId())
.clubName(dto.getClubName())
.feedCount(dto.getFeedCount())
.viewCount(dto.getViewCount())
.likeCount(dto.getLikeCount())
.commentCount(dto.getCommentCount())
.score(score)
.clubId(clubId)
.clubName(clubName)
.feedScore(feedScore)
.viewScore(viewScore)
.likeScore(likeScore)
.commentScore(commentScore)
.totalScore(totalScore)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,40 @@ public record ClubMonthlyStatusQuery(
int year,
int month,
int rank,
long feedCount,
long viewCount,
long likeCount,
long commentCount,
long score
int lastMonthRank,
long feedScore,
long viewScore,
long likeScore,
long commentScore,
long totalScore
) {

public static ClubMonthlyStatusQuery from(int year, int month, ClubFeedRankingQuery ranking) {
public static ClubMonthlyStatusQuery of(int year, int month, int rank, int lastMonthRank,
long feedScore, long viewScore, long likeScore, long commentScore) {
return ClubMonthlyStatusQuery.builder()
.year(year)
.month(month)
.rank(ranking.rank())
.feedCount(ranking.feedCount())
.viewCount(ranking.viewCount())
.likeCount(ranking.likeCount())
.commentCount(ranking.commentCount())
.score(ranking.score())
.rank(rank)
.lastMonthRank(lastMonthRank)
.feedScore(feedScore)
.viewScore(viewScore)
.likeScore(likeScore)
.commentScore(commentScore)
.totalScore(feedScore + viewScore + likeScore + commentScore)
.build();
}

public static ClubMonthlyStatusQuery createEmpty(int year, int month) {
public static ClubMonthlyStatusQuery createEmpty(int year, int month, int lastMonthRank) {
return ClubMonthlyStatusQuery.builder()
.year(year)
.month(month)
.rank(0)
.feedCount(0)
.viewCount(0)
.likeCount(0)
.commentCount(0)
.score(0)
.lastMonthRank(lastMonthRank)
.feedScore(0)
.viewScore(0)
.likeScore(0)
.commentScore(0)
.totalScore(0)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,21 @@ void getClubFeedRanking_success() {
.getList(".", AdminClubFeedRankingResponse.class);

assertThat(response).hasSize(2);
assertThat(response.get(0).clubName()).isEqualTo("동아리B");
assertThat(response.get(0).rank()).isEqualTo(1);
assertThat(response.get(1).clubName()).isEqualTo("동아리A");
assertThat(response.get(1).rank()).isEqualTo(2);

AdminClubFeedRankingResponse first = response.get(0);
assertThat(first.clubName()).isEqualTo("동아리B");
assertThat(first.rank()).isEqualTo(1);
assertThat(first.feedScore()).isEqualTo(2 * 10);
assertThat(first.viewScore()).isEqualTo(0);
assertThat(first.likeScore()).isEqualTo(1 * 3);
assertThat(first.commentScore()).isEqualTo(0);
assertThat(first.totalScore()).isEqualTo(23);

AdminClubFeedRankingResponse second = response.get(1);
assertThat(second.clubName()).isEqualTo("동아리A");
assertThat(second.rank()).isEqualTo(2);
assertThat(second.feedScore()).isEqualTo(1 * 10);
assertThat(second.totalScore()).isEqualTo(10);
}

@DisplayName("총동연 피드 랭킹 조회 API - 성공: 데이터 없으면 빈 리스트 반환")
Expand Down
Loading