Skip to content

Feat/#108 외부 부스 랭킹에서 제거#109

Merged
wlgns12370 merged 4 commits intomainfrom
feat/#108
Mar 15, 2026
Merged

Feat/#108 외부 부스 랭킹에서 제거#109
wlgns12370 merged 4 commits intomainfrom
feat/#108

Conversation

@wlgns12370
Copy link
Copy Markdown
Contributor

@wlgns12370 wlgns12370 commented Mar 15, 2026

✨ 구현한 기능

  • 외부 부스 랭킹에서 제거

🎸 기타

  • Map 컬렉션 일급컬렉션으로 래핑

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • 특정 부스 유형에 대해 좋아요 등록이 차단됨 (사용자에게 알림/오류 제공)
  • 개선 사항

    • 부스 랭킹 집계 및 조회 로직 개선으로 랭킹 정확도 향상
    • 초기 랭킹 설정 시 특정 부스 유형을 자동 제외
    • 검색 결과의 대표 이미지 및 다음 페이지 커서 처리 개선
  • 버그 픽스

    • 관련 오류 처리 및 안정성 강화

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

부스 좋아요 흐름에 부스 존재·구분 검증을 추가해 EXTERNAL_SUPPORT 부스에 대한 좋아요 등록을 차단하고, Redis 기반 랭킹 조회를 BoothRanking으로 캡슐화하며 워밍업에서 해당 부스를 제외하도록 쿼리를 변경했습니다.

Changes

Cohort / File(s) Summary
좋아요 서비스 및 에러 코드
src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java, src/main/java/kr/co/knuserver/global/exception/BusinessErrorCode.java
BoothRepository로 부스 존재 검사 추가; EXTERNAL_SUPPORT 부스에 대해 좋아요 등록 차단 로직 추가; 새로운 LIKE_NOT_ALLOWED 에러 코드 추가.
랭킹 캡슐화 및 조회 변경
src/main/java/kr/co/knuserver/application/booth/BoothRanking.java, src/main/java/kr/co/knuserver/application/booth/BoothQueryService.java
Redis ZSet 결과를 파싱해 부스 ID→좋아요 수 매핑을 제공하는 BoothRanking 클래스 추가; getBoothRanking에서 해당 래퍼 사용 및 리스트 접근 방식 일부 수정.
레포지토리 쿼리 및 워밍업 초기화
src/main/java/kr/co/knuserver/domain/booth/repository/BoothRepository.java, src/main/java/kr/co/knuserver/application/booth/BoothLikeWarmupRunner.java
활성 부스 조회에서 EXTERNAL_SUPPORT 분류를 제외하는 쿼리 메서드 추가 및 워밍업 로직에서 해당 분류를 필터링하도록 변경.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested labels

first

Poem

🐰 부스 앞에 서서 껑충 뛰며,
EXTERNAL은 건너뛰고 좋아요는 멈췄네.
랭킹은 새 보따리로 정리했지요,
워밍업도 깔끔히 필터링,
토끼가 박수로 축하해요! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경사항을 명확하게 설명하고 있습니다. '외부 부스 랭킹에서 제거'는 변경사항의 핵심을 정확하게 반영합니다.
Description check ✅ Passed PR 설명이 필수 템플릿 섹션을 충분히 포함하고 있으며, 구현한 기능과 기타사항이 명확하게 작성되어 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#108
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/kr/co/knuserver/application/booth/BoothQueryService.java`:
- Line 86: The ranking lookup uses boothRepository.findAllById which can return
EXTERNAL_SUPPORT or inactive booths stored in Redis; replace that call in
BoothQueryService (where booths are loaded for BoothRanking) with a filtered
repository method such as
boothRepository.findByIsActiveTrueAndDivisionNotAndIdIn(BoothDivision.EXTERNAL_SUPPORT,
boothRanking.boothIds()) and add that method to BoothRepository (List<Booth>
findByIsActiveTrueAndDivisionNotAndIdIn(BoothDivision division, Collection<Long>
ids)) so only active, non-EXTERNAL_SUPPORT booths are returned; update any
variable names as needed to use the filtered result in subsequent logic.

In `@src/main/java/kr/co/knuserver/application/booth/BoothRanking.java`:
- Around line 13-18: BoothRanking currently builds likeCountMap by parsing Redis
member strings with Long.parseLong which can throw and break the entire ranking
response; update the mapping logic in the constructor/method that initializes
likeCountMap (the stream over tuples) to defensively skip malformed
members—e.g., filter out or catch NumberFormatException for t.getValue(), only
include entries where parsing to long succeeds (or validate with a numeric
check) and log or ignore the bad member so a single bad Redis member cannot
cause the whole ranking to fail.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: de65e4d0-e573-405f-a247-92c01e8b82b7

📥 Commits

Reviewing files that changed from the base of the PR and between c17b9ba and 2cd8ce9.

📒 Files selected for processing (6)
  • src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java
  • src/main/java/kr/co/knuserver/application/booth/BoothLikeWarmupRunner.java
  • src/main/java/kr/co/knuserver/application/booth/BoothQueryService.java
  • src/main/java/kr/co/knuserver/application/booth/BoothRanking.java
  • src/main/java/kr/co/knuserver/domain/booth/repository/BoothRepository.java
  • src/main/java/kr/co/knuserver/global/exception/BusinessErrorCode.java

}

return allBooths.stream()
List<Booth> booths = boothRepository.findAllById(boothRanking.boothIds());
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 15, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

랭킹 조회에서 외부/비활성 부스가 다시 노출될 수 있습니다.

Line 86의 findAllById는 필터가 없어 Redis에 남아 있는 EXTERNAL_SUPPORT/비활성 부스를 그대로 응답에 포함시킬 수 있습니다. PR 목표와 충돌합니다.

🔧 제안 수정안
-        List<Booth> booths = boothRepository.findAllById(boothRanking.boothIds());
+        List<Booth> booths = boothRepository.findByIsActiveTrueAndDivisionNotAndIdIn(
+            BoothDivision.EXTERNAL_SUPPORT,
+            boothRanking.boothIds()
+        );
// BoothRepository에 추가
List<Booth> findByIsActiveTrueAndDivisionNotAndIdIn(BoothDivision division, Collection<Long> ids);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/kr/co/knuserver/application/booth/BoothQueryService.java` at
line 86, The ranking lookup uses boothRepository.findAllById which can return
EXTERNAL_SUPPORT or inactive booths stored in Redis; replace that call in
BoothQueryService (where booths are loaded for BoothRanking) with a filtered
repository method such as
boothRepository.findByIsActiveTrueAndDivisionNotAndIdIn(BoothDivision.EXTERNAL_SUPPORT,
boothRanking.boothIds()) and add that method to BoothRepository (List<Booth>
findByIsActiveTrueAndDivisionNotAndIdIn(BoothDivision division, Collection<Long>
ids)) so only active, non-EXTERNAL_SUPPORT booths are returned; update any
variable names as needed to use the filtered result in subsequent logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

76번줄 boothLikeService.getRanking(); 에서 이미 필터링 되는데 왜 포함되나요?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check getRanking() implementation in BoothLikeService
rg -n "getRanking" --type java -C 5

# Also check the full BoothLikeService for context
fd "BoothLikeService.java" --exec cat {}

Length of output: 9879


@wlgns12370, 좋은 지적입니다! 다시 확인해 보겠습니다.

getRanking() 구현을 보면 실제로는 필터링이 없습니다:

public Set<ZSetOperations.TypedTuple<String>> getRanking() {
    return redisTemplate.opsForZSet().reverseRangeWithScores(RANKING_KEY, 0, -1); // 필터 없음, 원시 Redis ZSet 그대로 반환
}

워밍업과 like() 차단으로 EXTERNAL_SUPPORT 부스가 Redis에 들어가지 않는다는 점은 맞습니다. 그런데 다른 진입 경로가 있습니다. BoothLikeService.getLikeCount()를 확인해 보세요:

public long getLikeCount(Long boothId) {
    Double score = redisTemplate.opsForZSet().score(RANKING_KEY, String.valueOf(boothId));
    if (score == null) {
        redisTemplate.opsForZSet().addIfAbsent(RANKING_KEY, String.valueOf(boothId), 0); // ⚠️ ZSet에 부스 삽입!
        return 0L;
    }
    ...
}

그리고 BoothQueryService.getBooth()는 모든 부스에 대해 이를 호출합니다:

public BoothInfoResponseDto getBooth(Long boothId) {
    ...
    long likeCount = boothLikeService.getLikeCount(boothId); // EXTERNAL_SUPPORT 부스도 포함
    ...
}

결과적으로 다음 경로가 만들어집니다:

getBooth(externalBoothId)
  → getLikeCount(externalBoothId)
    → addIfAbsent(RANKING_KEY, externalBoothId, 0)  ← Redis ZSet에 삽입됨!
      → 이후 getBoothRanking() 시 랭킹에 노출

따라서 원래 제안한 수정이 여전히 유효합니다. 수정 방법은 두 가지입니다:

방법 1: getLikeCount()에서 EXTERNAL_SUPPORT 부스가 ZSet에 등록되지 않도록 방어

// BoothLikeService.getLikeCount()
if (score == null) {
    Booth booth = boothRepository.findById(boothId).orElse(null);
    if (booth == null || booth.getDivision() == BoothDivision.EXTERNAL_SUPPORT) {
        return 0L; // ZSet에 추가하지 않음
    }
    redisTemplate.opsForZSet().addIfAbsent(RANKING_KEY, String.valueOf(boothId), 0);
    return 0L;
}

방법 2 (더 단순): getBoothRanking()에서 필터링된 쿼리 사용 (기존 제안)

List<Booth> booths = boothRepository.findByIsActiveTrueAndDivisionNotAndIdIn(
    BoothDivision.EXTERNAL_SUPPORT,
    boothRanking.boothIds()
);

방법 1은 근본 원인을 해결하고, 방법 2는 랭킹 응답에서 방어적으로 필터링합니다. 두 방법을 함께 적용하는 것이 가장 안전합니다.


🧠 Learnings used
Learnt from: milk-stone
Repo: knu-80/knu-server PR: 72
File: src/main/java/kr/co/knuserver/application/notice/NoticeCommandService.java:0-0
Timestamp: 2026-03-05T13:49:47.963Z
Learning: In Spring Java projects, when using TransactionalEventListener with phase = TransactionPhase.AFTER_COMMIT, the listener executes only after a successful commit. Actions such as deleting resources (e.g., S3) should not be assumed to run in a specific order relative to DB operations within the same transaction. Do not rely on the publishEvent order inside a transaction to guarantee data consistency; instead, design with the understanding that handlers run post-commit and may observe only committed state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

아니 애초에 warm up에서 넣어가는거 아니야?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

좋은 질문입니다. 코드를 다시 확인해보겠습니다.


🏁 Script executed:

#!/bin/bash
# Check getLikeCount() implementation
fd "BoothLikeService.java" --exec cat {}

# Check who calls getLikeCount
rg -n "getLikeCount" --type java -C 3

Length of output: 9273


@wlgns12370, 맞습니다 — 워밍업이 주된 삽입 경로이고, 이번 PR에서 EXTERNAL_SUPPORT를 올바르게 제외했습니다. 그런데 두 번째 삽입 경로getLikeCount()에 존재합니다:

// BoothLikeService.java
public long getLikeCount(Long boothId) {
    Double score = redisTemplate.opsForZSet().score(RANKING_KEY, String.valueOf(boothId));
    if (score == null) {
        redisTemplate.opsForZSet().addIfAbsent(RANKING_KEY, String.valueOf(boothId), 0); // ⚠️ division 체크 없음
        return 0L;
    }
    ...
}

그리고 이 메서드는 getBooth()에서 모든 부스 ID에 대해 호출됩니다:

// BoothQueryService.java line 48
public BoothInfoResponseDto getBooth(Long boothId) {
    ...
    long likeCount = boothLikeService.getLikeCount(boothId); // EXTERNAL_SUPPORT 부스도 포함
    ...
}

따라서 실제 흐름은 다음과 같습니다:

1. 워밍업 → EXTERNAL_SUPPORT 제외 ✅
2. getBooth(외부부스_ID) 호출
   → getLikeCount(외부부스_ID)
   → Redis에 score 없음 (워밍업에서 제외했으므로)
   → addIfAbsent 로 Redis ZSet에 삽입 ⚠️
3. getBoothRanking() → 외부 부스 노출 ❌

getLikeCount()에 division 체크를 추가하는 것이 가장 근본적인 수정입니다:

public long getLikeCount(Long boothId) {
    try {
        Double score = redisTemplate.opsForZSet().score(RANKING_KEY, String.valueOf(boothId));
        if (score == null) {
            Booth booth = boothRepository.findById(boothId).orElse(null);
            if (booth == null || booth.getDivision() == BoothDivision.EXTERNAL_SUPPORT) {
                return 0L; // ZSet에 추가하지 않음
            }
            redisTemplate.opsForZSet().addIfAbsent(RANKING_KEY, String.valueOf(boothId), 0);
            return 0L;
        }
        return score.longValue();
    } catch (DataAccessException e) {
        log.warn("[LikeCount] Redis 조회 실패 boothId={}, 0 반환", boothId, e);
        return 0L;
    }
}

🧠 Learnings used
Learnt from: milk-stone
Repo: knu-80/knu-server PR: 72
File: src/main/java/kr/co/knuserver/application/notice/NoticeCommandService.java:0-0
Timestamp: 2026-03-05T13:49:47.963Z
Learning: In Spring Java projects, when using TransactionalEventListener with phase = TransactionPhase.AFTER_COMMIT, the listener executes only after a successful commit. Actions such as deleting resources (e.g., S3) should not be assumed to run in a specific order relative to DB operations within the same transaction. Do not rely on the publishEvent order inside a transaction to guarantee data consistency; instead, design with the understanding that handlers run post-commit and may observe only committed state.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/main/java/kr/co/knuserver/application/booth/BoothRanking.java (1)

37-38: 내부 상태 노출을 막도록 boothIds() 반환을 불변화하는 것을 권장합니다.

Line 38의 keySet() 뷰는 호출자가 수정 시 likeCountMap까지 변경됩니다. 일급 컬렉션 의도에 맞게 불변 반환이 안전합니다.

리팩터 제안
+import java.util.Collections;
...
     public Set<Long> boothIds() {
-        return likeCountMap.keySet();
+        return Collections.unmodifiableSet(likeCountMap.keySet());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/kr/co/knuserver/application/booth/BoothRanking.java` around
lines 37 - 38, The boothIds() method currently returns likeCountMap.keySet()
which exposes the internal map view to callers; change boothIds() to return an
immutable snapshot instead (e.g., using Set.copyOf(...) or
Collections.unmodifiableSet(new HashSet<>(likeCountMap.keySet()))) so callers
cannot mutate likeCountMap; update the method body in BoothRanking.boothIds() to
produce and return that immutable set.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/kr/co/knuserver/application/booth/BoothRanking.java`:
- Around line 16-21: The current collection into likeCountMap via
tuples.stream(...).collect(Collectors.toMap(Map.Entry::getKey,
Map.Entry::getValue)) will throw on duplicate keys when Redis members like "1"
and "01" both parse to the same Long; change the collector to resolve collisions
(e.g., Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Long::sum)) so
duplicates are merged (sum the scores) and ensure this change is applied where
likeCountMap is built from parseBoothId and tuples.

---

Nitpick comments:
In `@src/main/java/kr/co/knuserver/application/booth/BoothRanking.java`:
- Around line 37-38: The boothIds() method currently returns
likeCountMap.keySet() which exposes the internal map view to callers; change
boothIds() to return an immutable snapshot instead (e.g., using Set.copyOf(...)
or Collections.unmodifiableSet(new HashSet<>(likeCountMap.keySet()))) so callers
cannot mutate likeCountMap; update the method body in BoothRanking.boothIds() to
produce and return that immutable set.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c3ba3866-29f2-4630-b74f-285eb307d916

📥 Commits

Reviewing files that changed from the base of the PR and between 2cd8ce9 and 7b7a474.

📒 Files selected for processing (1)
  • src/main/java/kr/co/knuserver/application/booth/BoothRanking.java

Comment on lines +16 to +21
this.likeCountMap = tuples.stream()
.filter(t -> t.getValue() != null)
.flatMap(t -> parseBoothId(t.getValue())
.map(id -> Map.entry(id, t.getScore() == null ? 0L : t.getScore().longValue()))
.stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/main/java/kr/co/knuserver/application/booth/BoothRanking.java

Repository: knu-80/knu-server

Length of output: 1691


🏁 Script executed:

rg "parseBoothId" -A 10 -B 2 src/main/java/kr/co/knuserver/application/booth/

Repository: knu-80/knu-server

Length of output: 2040


🏁 Script executed:

# parseBoothId 메서드의 파싱 로직 정확성을 재확인
# "1"과 "01" 모두 같은 Long 값으로 파싱되는지 검증
python3 << 'EOF'
# Java Long.parseLong("1") vs Long.parseLong("01")과 동일한 동작
result1 = int("1")
result2 = int("01")
print(f"int('1') = {result1}")
print(f"int('01') = {result2}")
print(f"Are they equal? {result1 == result2}")
EOF

Repository: knu-80/knu-server

Length of output: 108


Line 21의 Collectors.toMap이 중복 키 충돌 시 예외를 발생시킵니다.

Redis member가 "1"과 "01"처럼 파싱 후 동일한 Long 값(1L)이 되는 경우, Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)는 중복 키 발견 시 IllegalStateException을 던져 랭킹 조회가 실패합니다.

수정 제안
-            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+            .collect(Collectors.toMap(
+                Map.Entry::getKey,
+                Map.Entry::getValue,
+                Long::max
+            ));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.likeCountMap = tuples.stream()
.filter(t -> t.getValue() != null)
.flatMap(t -> parseBoothId(t.getValue())
.map(id -> Map.entry(id, t.getScore() == null ? 0L : t.getScore().longValue()))
.stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
this.likeCountMap = tuples.stream()
.filter(t -> t.getValue() != null)
.flatMap(t -> parseBoothId(t.getValue())
.map(id -> Map.entry(id, t.getScore() == null ? 0L : t.getScore().longValue()))
.stream())
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
Long::max
));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/kr/co/knuserver/application/booth/BoothRanking.java` around
lines 16 - 21, The current collection into likeCountMap via
tuples.stream(...).collect(Collectors.toMap(Map.Entry::getKey,
Map.Entry::getValue)) will throw on duplicate keys when Redis members like "1"
and "01" both parse to the same Long; change the collector to resolve collisions
(e.g., Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Long::sum)) so
duplicates are merged (sum the scores) and ensure this change is applied where
likeCountMap is built from parseBoothId and tuples.

@wlgns12370 wlgns12370 merged commit a9e7382 into main Mar 15, 2026
2 checks passed
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