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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import java.time.Duration;
import java.util.Collections;
import java.util.Set;
import kr.co.knuserver.domain.booth.entity.Booth;
import kr.co.knuserver.domain.booth.entity.BoothDivision;
import kr.co.knuserver.domain.booth.repository.BoothRepository;
import kr.co.knuserver.global.exception.BusinessErrorCode;
import kr.co.knuserver.global.exception.BusinessException;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,8 +28,15 @@ public class BoothLikeService {
private long rateLimitTtlSeconds;

private final StringRedisTemplate redisTemplate;
private final BoothRepository boothRepository;

public long like(Long boothId, String deviceId, String clientIp) {
Booth booth = boothRepository.findById(boothId)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.BOOTH_NOT_FOUND));
if (booth.getDivision() == BoothDivision.EXTERNAL_SUPPORT) {
throw new BusinessException(BusinessErrorCode.LIKE_NOT_ALLOWED);
}

try {
checkRateLimit(clientIp, deviceId, boothId);
Double score = redisTemplate.opsForZSet().incrementScore(RANKING_KEY, String.valueOf(boothId), 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.co.knuserver.application.booth;

import jakarta.annotation.PostConstruct;
import kr.co.knuserver.domain.booth.entity.BoothDivision;
import kr.co.knuserver.domain.booth.repository.BoothRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,7 +23,7 @@ public void warmup() {
try {
log.debug("[LikeWarmup] 부스 좋아요 캐시 초기화 시작");

boothRepository.findByIsActiveTrue()
boothRepository.findByIsActiveTrueAndDivisionNot(BoothDivision.EXTERNAL_SUPPORT)
.forEach(booth -> redisTemplate.opsForZSet()
.addIfAbsent(RANKING_KEY, String.valueOf(booth.getId()), 0));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,23 @@ public List<BoothInfoResponseDto> searchBoothsByKeyword1(String keyword) {
}

public List<BoothRankingResponseDto> getBoothRanking() {
List<Booth> allBooths = boothRepository.findByIsActiveTrue();
if (allBooths.isEmpty()) {
Set<ZSetOperations.TypedTuple<String>> rawRanking = boothLikeService.getRanking();
if (rawRanking == null || rawRanking.isEmpty()) {
return List.of();
}

Set<ZSetOperations.TypedTuple<String>> ranking = boothLikeService.getRanking();
Map<Long, Long> likeCountMap = (ranking == null) ? Map.of() : ranking.stream()
.collect(Collectors.toMap(
t -> Long.parseLong(t.getValue()),
t -> t.getScore() == null ? 0L : t.getScore().longValue()
));
BoothRanking boothRanking = new BoothRanking(rawRanking);
if (boothRanking.isEmpty()) {
return List.of();
}

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.


return booths.stream()
.map(booth -> new BoothRankingResponseDto(
booth.getId(),
booth.getName(),
likeCountMap.getOrDefault(booth.getId(), 0L)
boothRanking.getLikeCount(booth.getId())
))
.sorted(Comparator.comparingLong(BoothRankingResponseDto::likeCount).reversed()
.thenComparingLong(BoothRankingResponseDto::boothId))
Expand Down Expand Up @@ -125,12 +125,12 @@ public CursorPaginationResponse<BoothListResponseDto> searchBoothsByKeyword2(Str
List<BoothListResponseDto> items = pagedBooths.stream()
.map(booth -> {
List<String> urls = imageUrlsMap.getOrDefault(booth.getId(), Collections.emptyList());
String firstImageUrl = urls.isEmpty() ? null : urls.get(0);
String firstImageUrl = urls.isEmpty() ? null : urls.getFirst();
return BoothListResponseDto.fromEntity(booth, firstImageUrl);
})
.toList();

Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).id();
Long nextCursor = items.isEmpty() ? null : items.getLast().id();

return CursorPaginationResponse.of(items, nextCursor, hasNext);
}
Expand Down
44 changes: 44 additions & 0 deletions src/main/java/kr/co/knuserver/application/booth/BoothRanking.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package kr.co.knuserver.application.booth;

import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ZSetOperations;

@Slf4j
public class BoothRanking {

private final Map<Long, Long> likeCountMap;

public BoothRanking(Set<ZSetOperations.TypedTuple<String>> tuples) {
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));
Comment on lines +16 to +21
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.

}

private Optional<Long> parseBoothId(String value) {
try {
return Optional.of(Long.parseLong(value));
} catch (NumberFormatException e) {
log.error("[BoothRanking] Redis member 파싱 실패 value={}", value, e);
return Optional.empty();
}
}

public boolean isEmpty() {
return likeCountMap.isEmpty();
}

public Set<Long> boothIds() {
return likeCountMap.keySet();
}

public long getLikeCount(Long boothId) {
return likeCountMap.getOrDefault(boothId, 0L);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.List;
import kr.co.knuserver.domain.booth.entity.Booth;
import kr.co.knuserver.domain.booth.entity.BoothDivision;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -16,15 +17,16 @@ public interface BoothRepository extends JpaRepository<Booth, Long> {

List<Booth> findByIsActiveTrue();

// 주어진 키워드가 부스명 or 부스 설명에 들어간 부스들을 조회(페이지네이션 X)
List<Booth> findByIsActiveTrueAndDivisionNot(BoothDivision division);

@Query("SELECT b FROM Booth b " +
"WHERE b.isActive = true " +
"AND (b.name LIKE CONCAT('%', :keyword, '%') " +
" OR CONCAT(',', b.keywords, ',') LIKE CONCAT('%,', :keyword, ',%')) " +
"ORDER BY " +
" CASE " +
" WHEN b.name LIKE CONCAT('%', :keyword, '%') THEN 1 " + // 1순위: 이름에 키워드가 포함 (부분 일치)
" ELSE 2 " + // 2순위: keywords에 해당 키워드가 포함 (정확히 일치)
" WHEN b.name LIKE CONCAT('%', :keyword, '%') THEN 1 " +
" ELSE 2 " +
" END ASC, " +
" b.boothNumber ASC")
List<Booth> searchByKeyword(@Param("keyword") String keyword);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum BusinessErrorCode implements ErrorCode {
*/
EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C102", "이벤트가 존재하지 않습니다."),
BOOTH_NOT_FOUND(HttpStatus.NOT_FOUND, "C101", "존재하지 않는 부스입니다."),
LIKE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "C103", "해당 부스는 좋아요를 등록할 수 없습니다."),
NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "C403", "해당 공지사항을 찾을 수 없습니다."),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "C404", "해당 사용자를 찾을 수 없습니다."),
PUB_TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "C201", "존재하지 않는 테이블입니다."),
Expand Down
Loading