Skip to content

[fix] Artillery 부하 테스트 도입 및 Redis Sorted Set 북마크 캐시 개선#351

Merged
uykm merged 27 commits intodevelopfrom
chore/#349-artillery-load-test
Mar 23, 2026
Merged

[fix] Artillery 부하 테스트 도입 및 Redis Sorted Set 북마크 캐시 개선#351
uykm merged 27 commits intodevelopfrom
chore/#349-artillery-load-test

Conversation

@uykm
Copy link
Copy Markdown
Collaborator

@uykm uykm commented Mar 22, 2026

🌳 이슈 번호

resolves #349
resolves #350


☀️ 어떻게 이슈를 해결했나요?

#349 Artillery 부하 테스트 도입

load-test/ 디렉토리에 Artillery 환경을 구성하고 북마크 API 4개에 대한 부하 테스트 시나리오를 작성했습니다.

  • 시나리오: Warm up(30s, 5 req/s) → Ramp up(60s, 10→50 req/s) → Sustained load(120s, 50 req/s)
  • 대상 API: GET /api/places?isBookmarkSearch=true, GET /api/places/bookmarks/folders/preview, GET /api/courses/bookmarks, GET /api/courses/bookmarks/folders

#350 Redis Sorted Set 북마크 캐시 개선

기존 문제
기존 Redis Set 방식은 캐시 hit 시에도 createdAt 정렬을 위해 DB에 추가 쿼리(findByUserIdAndTargetTypeAndTargetIdIn)를 매번 실행했습니다.

개선 내용
키 패턴을 bookmark:sorted-set:{userId}:{type}:{townId}(score = createdAt epoch)으로 전환해 정렬 정보를 ZSET에 내장했습니다.

항목 Before After
캐시 구조 Redis Set (bookmark:active-set) Redis Sorted Set (town-scoped)
createdAt 정렬 캐시 hit 시에도 DB 추가 쿼리 ZSET score로 해결 (DB 조회 없음)
폴더 프리뷰 전체 목록 조회 후 town 매핑 + 정렬 towns-set으로 동네 인덱스 관리, ZSET에서 town별 최신 1개 반환
캐시 TTL 1일 1시간 (장소 동네 변경 시 stale 윈도우 최소화)
Redis 장애 시 쓰기 실패 로그만 기록 / 리스트 조회 시 500 에러 ZADD/ZREM 실패 시 캐시 키 invalidate, 모든 읽기 경로 DB fallback 보장

DB 쿼리 변화

API Before After
GET /api/courses/bookmarks 2회 (createdAt + 코스 조회) 1회 (코스 조회)
GET /api/courses/bookmarks/folders 3회 (createdAt + 코스 조회 + 정렬) 1회 (town별 최신 ID → 코스 조회)
GET /api/places?isBookmarkSearch=true 2회 (createdAt + QueryDSL) 1회 (QueryDSL)
GET /api/places/bookmarks/folders/preview 3회 (createdAt + town매핑 + 장소조회) 1회 (town별 최신 ID → 장소조회)

📊 부하 테스트 결과 (50 req/s, Sustained 120s)

코스 북마크

GET /api/courses/bookmarks

지표 Before After 개선율 비고
성공률 99.5% 100% -
Median 596ms 16ms -97% createdAt DB 조회 제거 효과
p75 3,134ms 290ms -91%
p90 6,702ms 1,466ms -78%
p95 7,260ms 1,939ms -73%
p99 9,047ms 2,836ms -69% 캐시 cold start 또는 backfill 발생 요청

GET /api/courses/bookmarks/folders

지표 Before After 개선율 비고
성공률 99.5% 100% -
Median 608ms 19ms -97% towns-set + ZSET 최신 1개 반환 효과
p75 2,780ms 327ms -88%
p90 6,187ms 1,466ms -76%
p95 7,260ms 1,979ms -73%
p99 9,047ms 2,893ms -68% 캐시 cold start 또는 backfill 발생 요청

createdAt DB 조회 제거로 캐시 hit 시 Median 기준 97% 개선.

장소 북마크

GET /api/places?isBookmarkSearch=true

지표 Before After 비고
성공률 15.5% (605건) 49.2% (1,952건) 사실 100%에 근접하게 나오는게 정상
Median 3,752ms 468ms (-88%) 성공 요청 기준
p75 6,064ms 7,407ms 수치 악화처럼 보이나 모수 차이로 인한 착시
p90 7,260ms 8,868ms Before는 빠른 상위 15% 요청만 집계됨
p95 8,024ms 9,047ms After는 3배 더 많은 요청이 집계에 포함
p99 9,047ms 9,801ms QueryDSL 병목으로 고부하 구간 타임아웃 잔존

GET /api/places/bookmarks/folders/preview

지표 Before After 비고
성공률 16.0% (650건) 50.4% (2,008건) 사실 100%에 근접하게 나오는게 정상
Median 3,752ms 407ms (-89%) 성공 요청 기준
p75 5,944ms 7,260ms 수치 악화처럼 보이나 모수 차이로 인한 착시
p90 7,260ms 8,868ms Before는 빠른 상위 16% 요청만 집계됨
p95 7,709ms 9,230ms After는 3배 더 많은 요청이 집계에 포함
p99 8,520ms 9,801ms QueryDSL 병목으로 고부하 구간 타임아웃 잔존

p75 이상 수치가 악화된 것처럼 보이는 이유: Before에서 84%가 타임아웃으로 실패해 퍼센타일은 살아남은 빠른 상위 16% 요청만 대상으로 계산됨. After는 성공률이 3배 향상돼 더 많은 요청(느린 요청 포함)이 집계에 포함되어 상위 퍼센타일이 올라간 것. QueryDSL 필터링 쿼리 캐싱은 별도 이슈로 분리해 추후 개선 예정.


🗯️ PR 포인트

  • town-scoped ZSET 설계: 동네별로 ZSET을 분리해 특정 동네 조회 시 해당 키만 접근. bookmark:towns-set:{userId}:{type}으로 북마크가 있는 동네 인덱스를 별도 관리해 폴더 프리뷰 조회 최적화
  • Lazy backfill 전략: cache miss 시 DB JOIN 쿼리로 해당 town ZSET만 적재. 전체 backfill은 폴더 프리뷰 최초 조회 시에만 실행
  • 다중 동네 케이스 분리: 코스 내 장소처럼 townId가 불확정인 경우 town-scoped ZSET 대신 DB 배치 쿼리 1회로 처리
  • Redis 장애 격리: ZADD/ZREM 실패 시 캐시 키 전체 invalidate → 다음 읽기 시 DB backfill 강제 유도. zRevRangeWithScores DataAccessException catch 추가로 리스트 조회 DB fallback 보장

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 북마크가 타운 단위로 스코핑되어 각 지역별 최신 북마크 조회·정렬 및 폴더 미리보기가 개선되었습니다. 북마크 생성/삭제에 타운 정보가 포함됩니다.
  • Tests

    • 신규 로드테스트 시나리오와 실행·리포트 스크립트가 추가되어 성능 검증 및 HTML 리포트 생성을 지원합니다.
  • Chores

    • 캐시 읽기/쓰기 흐름과 Redis 관련 동작이 재설계되어 응답 일관성과 안정성이 향상되었습니다.
    • 로드테스트 관련 .gitignore 규칙이 갱신되었습니다.

uykm added 12 commits March 23, 2026 02:57
- CacheService/RedisCacheService에 Sorted Set 연산 5개 추가 (zAdd, zAddAll, zRem, zRevRangeWithScores, zScore)
- zRevRangeWithScores를 LinkedHashMap으로 반환해 score 내림차순 순서 보존
- BookmarkCacheManager를 town-scoped ZSET 방식으로 전면 교체
  - Key: bookmark:sorted-set:{userId}:{type}:{townId}
  - towns-set(bookmark:towns-set:{userId}:{type})으로 북마크가 있는 동네 ID 추적
  - 신규 북마크 추가 시 key가 이미 존재하는 경우에만 ZADD (lazy backfill 전략)
- BookmarkCreatedEvent/BookmarkDeletedEvent에 createdAt, townId 필드 추가
- BookmarkEventHandler를 town-scoped ZADD/ZREM으로 교체
- BookmarkRepository에 town-scoped backfill용 native 쿼리 4개 추가
  - findPlaceBookmarksByUserAndTown: 특정 동네 장소 북마크 (target_id, created_at)
  - findCourseBookmarksByUserAndTown: 특정 동네 코스 북마크
  - findAllPlaceBookmarksWithTownId: 전체 장소 북마크 + town_id (폴더 프리뷰 backfill)
  - findAllCourseBookmarksWithTownId: 전체 코스 북마크 + town_id
- BookmarkService.create/delete에 townId 파라미터 추가
- getBookmarkCreatedAtMap 제거, DB 배치 조회용 getBookmarkStatusMap 추가
PlaceBookmarkFacade:
- createPlaceBookmark/deletePlaceBookmark에서 Place 로딩 후 townId 추출해 BookmarkService 전달
- getBookmarkedPlaceIdsForTown: ZSET 캐시 → cache miss 시 DB JOIN backfill
- getLatestBookmarkedPlaceIdPerTown: towns-set → 동네별 ZREVRANGE 0 0 (폴더 프리뷰)
- getPlaceBookmarkStatusMap: 다중 동네 가능 시 DB 배치 조회 (코스 내 장소 등)
- 전체 backfill: findAllPlaceBookmarksWithTownId 1회 → 모든 town ZSET + towns-set 초기화

CourseBookmarkFacade:
- 동일 패턴으로 구현 (COURSE 타입)
- findBookmarkedCourseIds: 전체 courseId 조회 (코스명 중복 검사용, DB)
- getBookmarkStatusMap: 다중 동네 가능 시 DB 배치 조회 (추천 코스 등)
PlaceService:
- getPlacesByTownAndTag: N번 ZSCORE → ZREVRANGE 1회 + Set.contains() 방식으로 개선
- getBookmarkedPlacesByLatest: ZSET 최신순 정렬된 placeIds → DB 필터링 → ZSET 순서 복원
- getBookmarkedPlaceFolderPreviewList: towns-set + 동네별 최신 placeId → DB 조회로 단순화

CourseService:
- getBookmarkedCourses: town-scoped ZSET에서 courseIds 직접 조회
- getBookmarkedCourseFolderPreview: getLatestBookmarkedCourseIdPerTown 사용
- createSortedCourseInfoDtoList: Map<createdAt> 기반 정렬 → ZSET orderedIds 기반 정렬로 전환
- 사용하지 않는 findLatestBookmarkedCourseIdsByTown 등 private 메서드 제거
Redis 다운 시 sMembers가 예외를 던지는 대신 null(cache miss)을 반환해
DB fallback 경로로 자연스럽게 이어지도록 수정
- RedisCacheService.zRem: 예외 로그 후 re-throw (호출자가 실패 인지 가능하도록)
- BookmarkCacheManager.remove: ZREM 실패 시 해당 town ZSET 키 전체 삭제(invalidate)
  → 다음 읽기 시 cache miss 발생 → DB backfill로 강제 유도
  → Redis 장애 시 삭제된 북마크가 stale 캐시에 남아 잘못 반환되는 문제 방지
@uykm uykm self-assigned this Mar 22, 2026
@uykm uykm requested review from 88guri and ImHyungsuk March 22, 2026 21:09
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

북마크 캐시를 마을별 Redis ZSET으로 재구성하고 이벤트/서비스/페이즈를 town 범위로 전환했으며, Artillery 기반 load-test 시나리오·패키지와 .gitignore 규칙을 추가했습니다.

Changes

Cohort / File(s) Summary
부하 테스트 설정
/.gitignore, load-test/package.json, load-test/scenarios/bookmark-place.yml, load-test/scenarios/bookmark-course.yml
Artillery load-test 패키지 및 시나리오 추가. load-test/reports/, load-test/node_modules/ 무시 규칙 추가 및 load-test/.gitignore·load-test/scenarios/*.yml 일부 예외 처리.
캐시 인터페이스 및 구현 (ZSET 도입)
src/main/java/.../global/cache/CacheService.java, src/main/java/.../global/cache/RedisCacheService.java
CacheService에 ZSET API 선언 추가 및 RedisCacheService에 zAdd/zAddAll/zRem/zRevRangeWithScores/zScore 구현. sMembers 동작을 빈 집합→null/예외시 null 반환으로 변경.
북마크 캐시 관리자 변경
src/main/java/.../domain/bookmark/service/BookmarkCacheManager.java
단일 Set에서 마을별 ZSET(bookmark:sorted-set:{userId}:{type}:{townId}) 모델로 전환. 마을 인덱스 Set 추가, EMPTY_SENTINEL 도입, addIfPresent/addAll/remove 등 ZSET 중심 API 및 읽기/쓰기/TTL 로직 변경.
저장소 및 이벤트 페이로드
src/main/java/.../domain/bookmark/repository/BookmarkRepository.java, .../service/event/BookmarkCreatedEvent.java, .../service/event/BookmarkDeletedEvent.java, .../service/event/BookmarkEventHandler.java
Repository에 town-scoped native 쿼리(백필용 target_id/created_at[, town_id]) 추가. 이벤트 레코드에 createdAttownId 필드 추가. 이벤트 핸들러가 새 캐시 API(addIfPresent/remove) 호출하도록 변경.
북마크 서비스 변경
src/main/java/.../domain/bookmark/service/BookmarkService.java
캐시 종속 로직 제거 및 create/delete에 townId 인자 추가(이벤트에 createdAt/townId 포함). DB 기반 조회(isBookmarkedFromDb, findBookmarkedTargetIdsByTargetIds)로 상태 판정 단순화.
Place 관련 변경 (서비스·페이드)
src/main/java/.../domain/place/service/PlaceService.java, .../place/service/facade/PlaceBookmarkFacade.java
Place 쪽에서 town-scoped 캐시 사용으로 전환: bulk town-level 조회(getBookmarkedPlaceIdsForTown), 최신-per-town 추출(getLatestBookmarkedPlaceIdPerTown), ZSET 기반 정렬 복원 및 DB 백필 로직 추가. 생성/삭제는 place의 townId로 호출.
Course 관련 변경 (서비스·페이드)
src/main/java/.../domain/course/service/CourseService.java, .../course/service/facade/CourseBookmarkFacade.java
Course 쪽도 town-scoped 캐시 및 per-town 최신 조회 추가, ZSET 순서 기반 정렬 사용, DB 백필 및 캐시 적재 로직 추가. 생성/삭제는 course의 townId로 호출.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Service as PlaceService/CourseService
    participant Facade as BookmarkFacade
    participant Cache as BookmarkCacheManager
    participant Redis
    participant DB as Database

    Client->>Service: 북마크 목록/썸네일 요청(userId, townId)
    Service->>Facade: getBookmarked...IdsForTown(userId, townId)
    Facade->>Cache: getActiveOrderedIds(userId, TYPE, townId)
    Cache->>Redis: ZREVRANGE bookmark:sorted-set:{userId}:{TYPE}:{townId}
    alt 캐시 히트
        Redis-->>Cache: ordered ids
        Cache-->>Facade: ordered ids
    else 캐시 미스
        Redis-->>Cache: null
        Cache->>DB: find{Place|Course}BookmarksByUserAndTown(userId, townId)
        DB-->>Cache: [(target_id, created_at), ...]
        Cache->>Redis: ZADD (score=epoch(created_at)) + EXPIRE, towns-set 갱신
        Cache-->>Facade: ordered ids
    end
    Facade->>DB: find{Places|Courses}ByIdsFilteredTown(ordered ids)
    DB-->>Facade: entities
    Facade-->>Service: ordered entities
    Service-->>Client: DTO 리스트
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • bykbyk0401
  • leejisoo0617

"나는 작은 토끼, 캐시를 정리했네 🐰
마을마다 ZSET에 순서를 맡기고,
이벤트엔 시간과 마을을 담았지요.
Artillery가 북마크 리듬을 재고,
서버는 조용히 당근을 기다리네 🥕"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항을 명확하게 요약합니다: Artillery 부하 테스트 도입과 Redis Sorted Set 북마크 캐시 개선이라는 두 가지 핵심 내용을 간결하게 표현하고 있습니다.
Linked Issues check ✅ Passed 모든 연관 이슈의 요구사항이 충족됩니다. #349는 load-test 디렉토리, Artillery 설정, 북마크 시나리오, .gitignore 수정이 완료되었고, #350은 CacheService/RedisCacheService ZSET 연산, BookmarkCacheManager 전면 교체, BookmarkService/BookmarkCreatedEvent 업데이트, BookmarkRepository 배치 메서드 추가, 이벤트 핸들러 동기화가 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 코드 변경이 #349(Artillery 부하 테스트)와 #350(Redis Sorted Set 캐시)의 명시된 범위 내에 있습니다. 북마크 서비스 계층, 캐시 레이어, 리포지토리 메서드, 이벤트 처리 등 모두 두 이슈의 목표 달성을 위한 필수 변경입니다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/#349-artillery-load-test

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: 8

🧹 Nitpick comments (2)
load-test/package.json (1)

6-17: 초기 실행 실패 방지를 위해 reports 디렉터리 생성 단계를 추가해 주세요.

클린 환경에서 reports/가 없으면 출력 파일 생성 단계에서 실패할 수 있습니다. 스크립트 시작 전에 디렉터리 보장을 넣어두면 재현성과 사용성이 좋아집니다.

제안 diff
{
   "scripts": {
+    "prepare:reports": "mkdir -p reports",
-    "test:place": "artillery run scenarios/bookmark-place.yml --output reports/place-baseline.json",
-    "test:course": "artillery run scenarios/bookmark-course.yml --output reports/course-baseline.json",
+    "test:place": "npm run prepare:reports && artillery run scenarios/bookmark-place.yml --output reports/place-baseline.json",
+    "test:course": "npm run prepare:reports && artillery run scenarios/bookmark-course.yml --output reports/course-baseline.json",
     "test:all": "npm run test:place && npm run test:course",
-    "test:place:after": "artillery run scenarios/bookmark-place.yml --output reports/place-after.json",
-    "test:course:after": "artillery run scenarios/bookmark-course.yml --output reports/course-after.json",
+    "test:place:after": "npm run prepare:reports && artillery run scenarios/bookmark-place.yml --output reports/place-after.json",
+    "test:course:after": "npm run prepare:reports && artillery run scenarios/bookmark-course.yml --output reports/course-after.json",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@load-test/package.json` around lines 6 - 17, Ensure the reports directory
exists before running tests/reports by updating the package.json scripts that
produce outputs (e.g., "test:place", "test:course", "test:place:after",
"test:course:after", "report:place", "report:course", "report:place:after",
"report:course:after") to first create the reports folder (use a cross-platform
approach you prefer, e.g., a pre-script or a shell command like mkdir -p
reports) so each script guarantees the reports/ directory is present before
writing output.
src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java (1)

40-100: Object[] 프로젝션은 타입 계약이 너무 약합니다.

이 4개 쿼리의 결과 shape를 다른 계층에서 row[0], row[1], row[2]로 직접 언팩하고 있어서, SELECT 컬럼 순서나 타입이 바뀌어도 컴파일 타임에 잡히지 않습니다. interface projection이나 record DTO로 고정해두면 이 계약을 더 안전하게 유지할 수 있습니다.

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

In
`@src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java`
around lines 40 - 100, The repository methods use raw Object[] results
(findPlaceBookmarksByUserAndTown, findCourseBookmarksByUserAndTown,
findAllPlaceBookmarksWithTownId, findAllCourseBookmarksWithTownId) which is
fragile; create explicit projection types (either Java records or Spring Data
interface projections) matching each SELECT shape (e.g., PlaceBookmarkRow { Long
getTargetId(); LocalDateTime getCreatedAt(); }, CourseBookmarkRow,
PlaceBookmarkWithTownRow { Long getTargetId(); LocalDateTime getCreatedAt();
Long getTownId(); } or equivalent record DTOs), change each method signature to
return List<ThatProjection> instead of List<Object[]>, update the `@Query`
mappings to return the projection (no SQL change required) and then update all
callers to use the typed getters instead of row[0]/row[1]/row[2] unpacking so
the contract is enforced at compile time.
🤖 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/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java`:
- Around line 88-95: The addAll method in BookmarkCacheManager (addAll(Long
userId, BookmarkTargetType type, Long townId, Map<Long, LocalDateTime>
targetCreatedAtMap)) returns early on null/empty input so no cache key is
created for "0 bookmarks" — add logic to write a miss marker instead: when
targetCreatedAtMap is null or empty, still create the cache key (either create
an empty ZSET with a TTL or add a sentinel member like "__EMPTY__" with a
special score and TTL) rather than returning; do the same for the corresponding
empty-set variant referenced around lines 148-152 (the other addAll overload
that writes sets), and ensure any readers (zGet/zRange methods) ignore the
sentinel member when interpreting real data or treat the sentinel as an explicit
empty-state indicator.
- Around line 66-76: In remove(...) (referencing zsetKey, cacheService.zRem,
cacheService.zRevRangeWithScores, cacheService.delete, cacheService.sRem),
handle the case where zRevRangeWithScores returns null (Redis ZSET key was
already evicted when last member was removed) the same as an empty map: change
the condition to treat remaining == null || remaining.isEmpty() so you still
call cacheService.delete(key) and cacheService.sRem(townsSetKey(userId, type),
townId); this ensures the towns-set entry is removed when the ZSET is missing
and prevents stale empty-folder state that blocks addIfPresent().

In
`@src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkService.java`:
- Around line 76-84: getBookmarkStatusMap currently loads all bookmarked IDs via
bookmarkRepository.findBookmarkedTargetIds(userId, type) and then maps
targetIds, which is inefficient and fails on duplicate IDs; change the
repository call to accept the requested targetIds (so the query uses IN
(:targetIds) and returns only those matches) — update or add a method like
bookmarkRepository.findBookmarkedTargetIdsByUserAndTargets(userId, type,
targetIds) and use it here — and when building the result map from targetIds use
Collectors.toMap(id -> id, bookmarkedIds::contains, (a,b) -> a) (i.e., provide a
merge function) to safely absorb duplicate targetIds.

In
`@src/main/java/org/sopt/solply_server/domain/bookmark/service/event/BookmarkEventHandler.java`:
- Around line 17-30: The current onCreated event handler calls
BookmarkCacheManager.addIfPresent which only writes to an existing per-town
ZSET, causing a race where a new town ZSET never gets created and the global
"towns-set" is not updated; modify the onCreated flow so that when addIfPresent
detects the per-town ZSET did not exist (or when it does create a new per-town
ZSET during backfill), the code also updates or invalidates the global towns-set
so that per-town backfill and preview logic will pick up the new town;
specifically change the behavior around BookmarkEventHandler.onCreated ->
BookmarkCacheManager.addIfPresent to either (a) atomically add the townId into
the "towns-set" when creating a new per-town ZSET, or (b) invalidate the
"towns-set" cache entry so the next preview triggers a full refresh/backfill for
that town.

In
`@src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java`:
- Around line 301-315: The query in
courseRepository.findActiveFolderPreviewCourses loses the input order of
courseIds from latestCourseIdByTown so returned courses can be shuffled; update
getBookmarkedCourseFolderPreview to preserve order by either (A) converting the
returned List<Course> into a Map keyed by Course.id and then iterate
latestCourseIdByTown.entrySet() to build a List<Course> in the original order
before calling toCourseFolderDtos, or (B) modify the repository query
(findActiveFolderPreviewCourses) to include an ORDER BY that respects the input
ids order (e.g., ORDER BY CASE ... WHEN id=... THEN ... END) similar to
PlaceService.getBookmarkedPlaceFolderPreviewList, ensuring the final
CourseFolderPreviewListGetResponse.from(...) receives courses in the same
sequence as latestCourseIdByTown.

In
`@src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java`:
- Around line 45-47: deleteCourseBookmark currently calls
entityLoader.getActiveCourse(courseId) which throws NOT_FOUND for inactive
courses, preventing users from removing bookmarks on deactivated courses; change
the lookup to use a non-active-restricted course loader or derive townId from
the bookmark row before calling bookmarkService.delete. Specifically, replace
the use of getActiveCourse(...) in CourseBookmarkFacade.deleteCourseBookmark
with a call that does not enforce active state (e.g., getCourse(...) or a new
entityLoader method that returns courses regardless of active flag) or fetch the
Bookmark by (userId, BookmarkTargetType.COURSE, courseId) and extract its
townId, then pass that townId into bookmarkService.delete(userId,
BookmarkTargetType.COURSE, courseId, townId).

In
`@src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java`:
- Around line 181-184: Replace the direct IllegalArgumentException in the
toLocalDateTime method with the standard BusinessException(ErrorCode) used
across services: detect the unexpected type in toLocalDateTime(Object value) and
throw new BusinessException(ErrorCode.SOME_MAPPING_ERROR) (choose or add an
appropriate ErrorCode entry) so the error flows through GlobalExceptionHandler;
mirror the same change made in CourseBookmarkFacade to keep behavior consistent
across facades.

In `@src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java`:
- Around line 440-455: zRevRangeWithScores currently lets Redis read exceptions
bubble up (calls to stringRedisTemplate.hasKey and
opsForZSet().reverseRangeWithScores), causing a 500 instead of degrading to a
cache miss; wrap the key existence check and reverseRangeWithScores call in a
try/catch (like sMembers()/zScore() behavior) and on any Redis-related Exception
return null to signal a cache miss, preserving the existing empty-result
handling and LinkedHashMap population (keep parsing of
tuple.getValue()/getScore() unchanged).

---

Nitpick comments:
In `@load-test/package.json`:
- Around line 6-17: Ensure the reports directory exists before running
tests/reports by updating the package.json scripts that produce outputs (e.g.,
"test:place", "test:course", "test:place:after", "test:course:after",
"report:place", "report:course", "report:place:after", "report:course:after") to
first create the reports folder (use a cross-platform approach you prefer, e.g.,
a pre-script or a shell command like mkdir -p reports) so each script guarantees
the reports/ directory is present before writing output.

In
`@src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java`:
- Around line 40-100: The repository methods use raw Object[] results
(findPlaceBookmarksByUserAndTown, findCourseBookmarksByUserAndTown,
findAllPlaceBookmarksWithTownId, findAllCourseBookmarksWithTownId) which is
fragile; create explicit projection types (either Java records or Spring Data
interface projections) matching each SELECT shape (e.g., PlaceBookmarkRow { Long
getTargetId(); LocalDateTime getCreatedAt(); }, CourseBookmarkRow,
PlaceBookmarkWithTownRow { Long getTargetId(); LocalDateTime getCreatedAt();
Long getTownId(); } or equivalent record DTOs), change each method signature to
return List<ThatProjection> instead of List<Object[]>, update the `@Query`
mappings to return the projection (no SQL change required) and then update all
callers to use the typed getters instead of row[0]/row[1]/row[2] unpacking so
the contract is enforced at compile time.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 84796031-1af1-4163-8e49-58b5e5fb3259

📥 Commits

Reviewing files that changed from the base of the PR and between 8a665b5 and 1618379.

⛔ Files ignored due to path filters (1)
  • load-test/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (16)
  • .gitignore
  • load-test/package.json
  • load-test/scenarios/bookmark-course.yml
  • load-test/scenarios/bookmark-place.yml
  • src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkService.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/event/BookmarkCreatedEvent.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/event/BookmarkDeletedEvent.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/event/BookmarkEventHandler.java
  • src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java
  • src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java
  • src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/global/cache/CacheService.java
  • src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java

Comment thread src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkService.java Outdated
Comment thread src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java Outdated
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/org/sopt/solply_server/global/cache/RedisCacheService.java`:
- Around line 408-414: The zAdd and zRem public methods in RedisCacheService
currently rethrow DataAccessException directly; change their exception handling
to log the error (as already done) and then throw new
BusinessException(ErrorCode.REDIS_OPERATION_FAILED) instead of rethrowing the
raw DataAccessException so failures are normalized with the rest of the class
and handled by GlobalExceptionHandler; update both zAdd(...) and zRem(...) catch
blocks to convert the exception to
BusinessException(ErrorCode.REDIS_OPERATION_FAILED) while preserving the logged
details.
- Around line 367-375: In RedisCacheService, remove the pre-check using
stringRedisTemplate.hasKey(key) in the set/zset read paths (the block around
opsForSet().members(key) and the similar logic at the other occurrence) and
instead call stringRedisTemplate.opsForSet().members(key) directly; treat a null
return from members() as a cache miss (return null) and treat an empty Set as a
cached empty value (return Set.of()), thereby avoiding a race where hasKey()
becomes stale and reducing extra RTT; if you need to explicitly cache empty
results, use a sentinel key or separate marker rather than relying on hasKey().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 1cb21a66-71ff-4782-991f-0b400cf33ef5

📥 Commits

Reviewing files that changed from the base of the PR and between 1618379 and 92a290b.

📒 Files selected for processing (1)
  • src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java

@uykm uykm changed the title [chore] Artillery 부하 테스트 도입 및 Redis Sorted Set 북마크 캐시 개선 [fix] Artillery 부하 테스트 도입 및 Redis Sorted Set 북마크 캐시 개선 Mar 22, 2026
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: 4

♻️ Duplicate comments (1)
src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java (1)

158-160: ⚠️ Potential issue | 🟠 Major

북마크가 0건인 사용자에게는 towns-set miss marker가 남지 않습니다.

여기서 바로 반환하면 getLatestBookmarkedPlaceIdPerTown()가 매 요청마다 findAllPlaceBookmarksWithTownId()를 다시 타게 됩니다. 빈 결과일 때도 setTownIds(..., emptySet())를 호출해서 sentinel을 남겨야 반복 backfill을 막을 수 있습니다.

🛠️ 예시 수정
     private Set<Long> fullBackfillPlaces(Long userId) {
         List<Object[]> rows = bookmarkRepository.findAllPlaceBookmarksWithTownId(userId);
-        if (rows.isEmpty()) return Collections.emptySet();
+        if (rows.isEmpty()) {
+            bookmarkCacheManager.setTownIds(userId, BookmarkTargetType.PLACE, Collections.emptySet());
+            return Collections.emptySet();
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java`
around lines 158 - 160, fullBackfillPlaces currently returns immediately when
bookmarkRepository.findAllPlaceBookmarksWithTownId(userId) is empty, leaving no
sentinel and causing getLatestBookmarkedPlaceIdPerTown() to re-run backfill
repeatedly; fix by calling the facade method that writes the towns-set sentinel
(invoke setTownIds(userId, Collections.emptySet()) or the equivalent setter used
elsewhere) before returning the empty set so a miss marker is stored, then
return Collections.emptySet(); keep using fullBackfillPlaces,
bookmarkRepository.findAllPlaceBookmarksWithTownId and setTownIds names to
locate the change.
🤖 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/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java`:
- Around line 200-201: The current toScore(LocalDateTime dt) in
BookmarkCacheManager uses seconds (dt.toEpochSecond), causing same-second ties;
change it to include sub-second precision (preferably microseconds or at least
milliseconds) so ZSET ordering remains stable—for example compute an integral
timestamp combining epoch seconds and micro/millis (use
dt.toInstant(ZoneOffset.UTC).toEpochMilli() for millis or
dt.toEpochSecond(ZoneOffset.UTC) * 1_000_000 +
dt.get(ChronoField.MICRO_OF_SECOND) for microseconds) and return that value as
the score (cast/convert to double if needed).
- Around line 115-119: The sentinel-write path that caches an empty result (when
targetCreatedAtMap is null or empty) must not propagate cache write failures;
wrap the calls to cacheService.zAdd(key, EMPTY_SENTINEL, -1.0) and
cacheService.expire(key, ZSET_TTL_HOURS, TimeUnit.HOURS) in a try-catch that
catches the cache-related exception (e.g., BusinessException or RuntimeException
thrown by zAdd) and swallows/logs it, then still return normally so the call
remains a cache miss; keep the surrounding condition using targetCreatedAtMap
and ensure the method returns after the try-catch.

In
`@src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java`:
- Around line 92-98: The fallback currently returns new
ArrayList<>(entries.keySet()) which is unordered because entries is a HashMap;
modify the fallback in PlaceBookmarkFacade so that when
bookmarkCacheManager.getActiveOrderedIds(...) returns null you sort the entries
Map<Long, LocalDateTime> by the LocalDateTime value descending (newest first)
and return a List<Long> of keys in that order (i.e., produce a list of entry
keys sorted by createdAt desc from the result of loadPlaceBookmarkEntries),
ensuring the method preserves the "latest-first" contract even on cache
fallback.
- Around line 68-75: The current isBookmarked method treats any cache-path
failure as false; change it so Redis read errors don't get collapsed to "not
bookmarked" but instead trigger a DB fallback (i.e., return true/false only when
cache read succeeds, otherwise treat as "unknown" and call
bookmarkService.isBookmarkedFromDb). Concretely, inside isBookmarked, keep the
hasKey check using bookmarkCacheManager.hasKey, then call
bookmarkCacheManager.isActive(userId, BookmarkTargetType.PLACE, placeId, townId)
inside a try/catch (or adapt isActive to return Optional<Boolean>/tri-state) and
if the cache read throws a Redis/IO exception or returns empty/unknown, call and
return bookmarkService.isBookmarkedFromDb(userId, BookmarkTargetType.PLACE,
placeId); only return the boolean from isActive when it successfully provides a
definite value.

---

Duplicate comments:
In
`@src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java`:
- Around line 158-160: fullBackfillPlaces currently returns immediately when
bookmarkRepository.findAllPlaceBookmarksWithTownId(userId) is empty, leaving no
sentinel and causing getLatestBookmarkedPlaceIdPerTown() to re-run backfill
repeatedly; fix by calling the facade method that writes the towns-set sentinel
(invoke setTownIds(userId, Collections.emptySet()) or the equivalent setter used
elsewhere) before returning the empty set so a miss marker is stored, then
return Collections.emptySet(); keep using fullBackfillPlaces,
bookmarkRepository.findAllPlaceBookmarksWithTownId and setTownIds names to
locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 055194a9-5d62-4cb0-9710-f4464bffe52d

📥 Commits

Reviewing files that changed from the base of the PR and between 92a290b and 9c34b9d.

📒 Files selected for processing (7)
  • src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkService.java
  • src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java
  • src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java
✅ Files skipped from review due to trivial changes (1)
  • src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java

uykm added 4 commits March 23, 2026 10:33
addAll/setTownIds의 빈 결과 sentinel 경로에서 zAdd/sAdd가
BusinessException을 던질 경우 요청 전체가 실패하는 문제 수정.
DB fallback이 이미 완료된 경로이므로 sentinel 저장 실패는 miss
상태로 두고 정상 return한다.
zScore()를 Redis 오류 시 null 반환 → BusinessException throw로 변경해
"멤버 없음(null)"과 "오류"를 구분 가능하게 함.
isActive()를 Boolean(nullable)으로 변경: true=있음, false=없음, null=오류.
양쪽 Facade에서 null일 때만 DB fallback을 타도록 수정.
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

🧹 Nitpick comments (2)
src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java (1)

415-426: zAddAll의 silent failure가 의도된 것인지 확인 필요

zAddAll()DataAccessException 발생 시 로그만 남기고 예외를 던지지 않습니다. zAdd()zRem()BusinessException을 던지는 것과 일관성이 없어 보입니다.

backfill 경로에서 사용되어 실패해도 다음 요청에서 재시도할 수 있으므로 의도된 설계일 수 있지만, 호출자가 실패 여부를 알 수 없습니다. 만약 의도된 것이라면 Javadoc에 이 동작을 명시하는 것이 좋겠습니다.

📝 Javadoc 추가 제안
 `@Override`
+/**
+ * Batch ZSET add. Silently logs and ignores DataAccessException
+ * to allow caller to proceed; cache will be repopulated on next miss.
+ */
 public void zAddAll(String key, Map<Long, Double> memberScores) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java`
around lines 415 - 426, zAddAll currently swallows DataAccessException which is
inconsistent with zAdd and zRem that throw BusinessException; update
RedisCacheService.zAddAll to log the error and rethrow a BusinessException
(e.g., new BusinessException(ErrorCode.REDIS_OPERATION_FAILED, e) or the
project’s Redis-related error code) so callers can detect failure, and add a
short Javadoc on zAddAll stating it may throw BusinessException on Redis
failures; reference zAddAll, zAdd, zRem, BusinessException, and ErrorCode in the
change.
src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java (1)

127-131: non-empty backfill 시 zAddAll 실패가 조용히 무시됩니다.

zAddAll()은 내부에서 예외를 삼키므로, 이 코드 경로에서 Redis 쓰기가 실패해도 호출자는 성공으로 인식합니다. 하지만 expire() 호출이 이어지므로, zAddAll()이 실패하고 expire()만 성공하면 빈 키에 TTL만 설정될 수 있습니다.

실제로 zAddAll()이 실패하면 키가 존재하지 않아 expire()도 아무 효과가 없겠지만, 명시적으로 zAddAll() 성공 여부를 확인하거나, 둘을 함께 try-catch로 감싸는 것이 더 명확할 수 있습니다.

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

In
`@src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java`
around lines 127 - 131, The zAddAll call on cacheService can swallow exceptions
so failures are silently ignored which can leave only an expire() call or no
data; update the non-empty backfill path that builds scores from
targetCreatedAtMap (using toScore) to explicitly check zAddAll success or wrap
both cacheService.zAddAll(key, scores) and cacheService.expire(key,
ZSET_TTL_HOURS, TimeUnit.HOURS) in a single try-catch, log or rethrow the caught
exception (including key and scores info) when zAddAll fails, and only call
expire() if zAddAll succeeded; reference the cacheService.zAddAll,
cacheService.expire, targetCreatedAtMap, toScore and ZSET_TTL_HOURS symbols when
making the change.
🤖 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/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java`:
- Around line 163-183: fullBackfillCourses currently returns immediately when
rows.isEmpty(), skipping the cache sentinel write; change it so that when
bookmarkRepository.findAllCourseBookmarksWithTownId(userId) returns empty you
still call bookmarkCacheManager.setTownIds(userId, BookmarkTargetType.COURSE,
Collections.emptySet()) before returning Collections.emptySet(), ensuring the
empty town-set is cached as a sentinel; keep all existing behavior for the
non-empty path (building byTown, calling bookmarkCacheManager.addAll and
setTownIds).

In
`@src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java`:
- Around line 158-181: fullBackfillPlaces currently returns early when
rows.isEmpty(), but it must also persist the empty towns-set sentinel so
backfill isn't retried; update PlaceBookmarkFacade.fullBackfillPlaces (which
calls bookmarkRepository.findAllPlaceBookmarksWithTownId) to invoke
bookmarkCacheManager.setTownIds(userId, BookmarkTargetType.PLACE,
Collections.emptySet()) before returning Collections.emptySet(), mirroring the
behavior in CourseBookmarkFacade.fullBackfillCourses so users with no bookmarks
get a cached empty sentinel.

---

Nitpick comments:
In
`@src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java`:
- Around line 127-131: The zAddAll call on cacheService can swallow exceptions
so failures are silently ignored which can leave only an expire() call or no
data; update the non-empty backfill path that builds scores from
targetCreatedAtMap (using toScore) to explicitly check zAddAll success or wrap
both cacheService.zAddAll(key, scores) and cacheService.expire(key,
ZSET_TTL_HOURS, TimeUnit.HOURS) in a single try-catch, log or rethrow the caught
exception (including key and scores info) when zAddAll fails, and only call
expire() if zAddAll succeeded; reference the cacheService.zAddAll,
cacheService.expire, targetCreatedAtMap, toScore and ZSET_TTL_HOURS symbols when
making the change.

In `@src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java`:
- Around line 415-426: zAddAll currently swallows DataAccessException which is
inconsistent with zAdd and zRem that throw BusinessException; update
RedisCacheService.zAddAll to log the error and rethrow a BusinessException
(e.g., new BusinessException(ErrorCode.REDIS_OPERATION_FAILED, e) or the
project’s Redis-related error code) so callers can detect failure, and add a
short Javadoc on zAddAll stating it may throw BusinessException on Redis
failures; reference zAddAll, zAdd, zRem, BusinessException, and ErrorCode in the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 75100a9e-ea3c-4ce0-9eee-d0694bbdaf5b

📥 Commits

Reviewing files that changed from the base of the PR and between 9c34b9d and c5c1fca.

📒 Files selected for processing (4)
  • src/main/java/org/sopt/solply_server/domain/bookmark/service/BookmarkCacheManager.java
  • src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/global/cache/RedisCacheService.java

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

🤖 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/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java`:
- Around line 115-120: Update the JavaDoc for findBookmarkedCourseIds to clearly
state it returns all bookmarked course IDs without filtering out soft-deleted
courses: mention that findBookmarkedCourseIds(Long userId) delegates to
bookmarkRepository.findBookmarkedTargetIds(userId, BookmarkTargetType.COURSE)
and therefore may include inactive/soft-deleted course IDs and callers must
apply Course.active filtering where needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 2525f36b-ac38-4035-8346-5bb2cd3408d6

📥 Commits

Reviewing files that changed from the base of the PR and between c5c1fca and 9496508.

📒 Files selected for processing (2)
  • src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java
  • src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/org/sopt/solply_server/domain/place/service/facade/PlaceBookmarkFacade.java

Comment on lines +115 to +120
/**
* 사용자의 전체 북마크 courseId 목록 반환 (코스명 중복 검사 등 전체 조회 필요 시).
*/
public Set<Long> findBookmarkedCourseIds(final Long userId) {
return bookmarkRepository.findBookmarkedTargetIds(userId, BookmarkTargetType.COURSE);
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# findBookmarkedCourseIds 호출처 확인
rg -n "findBookmarkedCourseIds" --type java

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 406


🏁 Script executed:

# Check usage context in CourseNameGenerator
sed -n '20,40p' src/main/java/org/sopt/solply_server/domain/course/service/CourseNameGenerator.java

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 860


🏁 Script executed:

# Check the findBookmarkedTargetIds method implementation
rg -n "findBookmarkedTargetIds" --type java -A 5

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 3100


🏁 Script executed:

# Check the CourseBookmarkFacade class to see other filtering patterns
sed -n '150,175p' src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 1291


🏁 Script executed:

# Get the full BookmarkRepository to see the SQL query for findBookmarkedTargetIds
cat src/main/java/org/sopt/solply_server/domain/bookmark/repository/BookmarkRepository.java

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 3909


🏁 Script executed:

# Find the CourseRepository and check findCourseNamesByBookmarkedCourses method
fd -t f "CourseRepository.java"

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 242


🏁 Script executed:

# Get the CourseRepository to see findCourseNamesByBookmarkedCourses implementation
cat src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java

Repository: SOLPLY/SOLPLY-SERVER

Length of output: 2875


메서드의 의도를 주석으로 명확히 문서화하기

이 메서드가 active 필터링 없이 모든 북마크된 코스 ID를 반환하는 것은 의도된 설계입니다. findCourseNamesByBookmarkedCourses 등 하위 쿼리에서 c.active = true로 필터링되므로 soft-deleted 코스로 인한 데이터 노출은 없습니다.

다만 findAllCourseBookmarksWithTownId 등 다른 backfill 메서드들은 쿼리 단계에서 필터링하는 반면, 이 메서드는 필터링 없이 ID만 반환하는 패턴의 차이가 있습니다. 이를 개발자가 쉽게 이해하도록 주석을 개선해주세요:

/**
 * 사용자의 전체 북마크 courseId 목록 반환 (코스명 중복 검사 등 전체 조회 필요 시).
 * 
 * 주의: 이 메서드는 soft-deleted 코스 ID도 포함하여 반환합니다.
 * 호출자는 필요에 따라 Course의 active 상태를 별도로 필터링해야 합니다.
 */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseBookmarkFacade.java`
around lines 115 - 120, Update the JavaDoc for findBookmarkedCourseIds to
clearly state it returns all bookmarked course IDs without filtering out
soft-deleted courses: mention that findBookmarkedCourseIds(Long userId)
delegates to bookmarkRepository.findBookmarkedTargetIds(userId,
BookmarkTargetType.COURSE) and therefore may include inactive/soft-deleted
course IDs and callers must apply Course.active filtering where needed.

- findBookmarkedActiveCourseIds로 rename 및 Course INNER JOIN + active = true 조건 추가
- 불필요한 targetType 파라미터 제거로 쿼리 단순화
@uykm uykm merged commit c8756b0 into develop Mar 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant