Repository navigation
refactor: DB Repository 계층 쿼리 성능 개선 (쿼리 구조 개선 + 인덱스 추가) #846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
f270189
8785457
8cdcc41
8799fd6
8dbc113
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| import com.example.solidconnection.application.domain.ApplicationChoice; | ||
| import com.example.solidconnection.siteuser.domain.Role; | ||
| import com.example.solidconnection.siteuser.domain.SiteUser; | ||
| import com.example.solidconnection.siteuser.domain.UserBanDuration; | ||
| import com.example.solidconnection.siteuser.domain.UserStatus; | ||
| import com.querydsl.core.Tuple; | ||
| import com.querydsl.core.types.ConstructorExpression; | ||
|
|
@@ -154,35 +155,19 @@ private JPAQuery<Long> createUserCountQuery(UserSearchCondition condition) { | |
| ); | ||
| } | ||
|
|
||
| // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): | ||
| // 기존에는 siteUser 각 row마다 report 테이블 전체를 훑는 상관 서브쿼리(MAX(report.id) WHERE reported_id=...)를 | ||
| // leftJoin으로 실행해서, 페이지당 20건이라도 report(대량 테이블)를 20번 반복 스캔했다. | ||
| // -> siteUser를 먼저 페이징해서 "이 페이지에 필요한 20개 id"를 확정한 뒤, | ||
| // report/userBan은 그 id 목록(IN절)에 대해서만 한 번씩 배치 조회하도록 분리했다. | ||
| // (MentorBatchQueryRepository 등 기존 코드베이스의 배치조회 패턴과 동일) | ||
| @Override | ||
| public Page<RestrictedUserSearchResponse> searchRestrictedUsers( | ||
| RestrictedUserSearchCondition condition, | ||
| Pageable pageable | ||
| ) { | ||
| List<RestrictedUserSearchResponse> content = queryFactory | ||
| .select(RESTRICTED_USER_SEARCH_RESPONSE_PROJECTION) | ||
| .from(siteUser) | ||
|
|
||
| // 최신 신고 내역 조회 | ||
| .leftJoin(report).on( | ||
| report.reportedId.eq(siteUser.id) | ||
| .and( | ||
| report.id.eq( | ||
| JPAExpressions | ||
| .select(report.id.max()) | ||
| .from(report) | ||
| .where(report.reportedId.eq(siteUser.id)) | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| // 최신 차단 내역 조회 | ||
| .leftJoin(userBan).on( | ||
| userBan.bannedUserId.eq(siteUser.id) | ||
| .and(userBan.isExpired.eq(false)) | ||
| .and(userBan.expiredAt.after(ZonedDateTime.now(UTC))) | ||
| ) | ||
|
|
||
| List<SiteUser> siteUsers = queryFactory | ||
| .selectFrom(siteUser) | ||
| .where( | ||
| roleEq(condition.role()), | ||
| isRestrictedUser(), | ||
|
|
@@ -194,11 +179,81 @@ public Page<RestrictedUserSearchResponse> searchRestrictedUsers( | |
| .limit(pageable.getPageSize()) | ||
| .fetch(); | ||
|
|
||
| List<Long> siteUserIds = siteUsers.stream().map(SiteUser::getId).toList(); | ||
|
|
||
| Map<Long, ReportedInfoResponse> latestReportedInfoBySiteUserId = findLatestReportedInfoBySiteUserIds(siteUserIds); | ||
| Map<Long, UserBanDuration> activeBanDurationBySiteUserId = findActiveBanDurationBySiteUserIds(siteUserIds); | ||
|
|
||
| List<RestrictedUserSearchResponse> content = siteUsers.stream() | ||
| .map(su -> new RestrictedUserSearchResponse( | ||
| su.getId(), | ||
| su.getNickname(), | ||
| su.getRole(), | ||
| su.getUserStatus(), | ||
| latestReportedInfoBySiteUserId.get(su.getId()), | ||
| new BannedInfoResponse( | ||
| su.getUserStatus() == UserStatus.BANNED, | ||
| activeBanDurationBySiteUserId.get(su.getId()) | ||
| ) | ||
| )) | ||
| .toList(); | ||
|
|
||
| Long totalCount = createRestrictedUserCountQuery(condition).fetchOne(); | ||
|
|
||
| return new PageImpl<>(content, pageable, totalCount != null ? totalCount : 0L); | ||
| } | ||
|
|
||
| private Map<Long, ReportedInfoResponse> findLatestReportedInfoBySiteUserIds(List<Long> siteUserIds) { | ||
| if (siteUserIds.isEmpty()) { | ||
| return Map.of(); | ||
| } | ||
| return queryFactory | ||
| .select(report.reportedId, REPORTED_INFO_RESPONSE_PROJECTION) | ||
| .from(report) | ||
| .where( | ||
| report.reportedId.in(siteUserIds), | ||
| report.id.in( | ||
| JPAExpressions | ||
| .select(report.id.max()) | ||
| .from(report) | ||
| .where(report.reportedId.in(siteUserIds)) | ||
| .groupBy(report.reportedId) | ||
| ) | ||
| ) | ||
| .fetch() | ||
| .stream() | ||
| .collect(Collectors.toMap( | ||
| tuple -> tuple.get(report.reportedId), | ||
| tuple -> tuple.get(REPORTED_INFO_RESPONSE_PROJECTION) | ||
| )); | ||
| } | ||
|
|
||
| private Map<Long, UserBanDuration> findActiveBanDurationBySiteUserIds(List<Long> siteUserIds) { | ||
| if (siteUserIds.isEmpty()) { | ||
| return Map.of(); | ||
| } | ||
| // PR 리뷰 반영(2026-09-21): 동시 요청으로 같은 유저에게 활성 차단이 2건 이상 생길 수 있어(user_ban에 | ||
| // 유저당 활성 차단 1건 제약이 없고, validateNotAlreadyBanned도 check-then-act라 race가 가능) | ||
| // 단순 toMap은 중복 키에서 IllegalStateException을 던진다. expiredAt 내림차순으로 정렬해 | ||
| // 가장 나중에 만료되는 차단을 남기는 merge function을 추가했다. | ||
| return queryFactory | ||
| .select(userBan.bannedUserId, userBan.duration) | ||
| .from(userBan) | ||
| .where( | ||
| userBan.bannedUserId.in(siteUserIds), | ||
| userBan.isExpired.eq(false), | ||
| userBan.expiredAt.after(ZonedDateTime.now(UTC)) | ||
| ) | ||
| .orderBy(userBan.expiredAt.desc()) | ||
| .fetch() | ||
| .stream() | ||
| .collect(Collectors.toMap( | ||
| tuple -> tuple.get(userBan.bannedUserId), | ||
| tuple -> tuple.get(userBan.duration), | ||
| (first, duplicate) -> first | ||
| )); | ||
|
Comment on lines
+247
to
+251
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If two concurrent ban requests target the same user, both can pass Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 의견 감사합니다~ 동시 요청으로 같은 유저에게 활성 차단이 2건 이상 생길 수 있는 케이스를 실제로 재현해서 확인했고,
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
|
|
||
| private JPAQuery<Long> createRestrictedUserCountQuery(RestrictedUserSearchCondition condition) { | ||
| return queryFactory | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| ALTER TABLE post ADD INDEX idx_post_board_code_category_created_at (board_code, category, created_at); | ||
| ALTER TABLE post ADD INDEX idx_post_board_code_created_at (board_code, created_at); | ||
| ALTER TABLE post_image ADD INDEX idx_post_image_post_id (post_id); | ||
| ALTER TABLE post_like ADD INDEX idx_post_like_post_id (post_id); | ||
|
|
||
| ALTER TABLE chat_message ADD INDEX idx_chat_message_room_created_at (chat_room_id, created_at); | ||
|
|
||
| ALTER TABLE gpa_score ADD INDEX idx_gpa_score_verify_status_created_at (verify_status, created_at); | ||
| ALTER TABLE language_test_score ADD INDEX idx_language_test_score_verify_status_created_at (verify_status, created_at); | ||
|
|
||
| ALTER TABLE site_user ADD INDEX idx_site_user_status_created_at (user_status, created_at); | ||
| ALTER TABLE report ADD INDEX idx_report_reported_id (reported_id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user is banned directly without any report history—a supported flow exercised by
AdminUserBanServiceTest—this map lookup returnsnull, soreportedInfoResponsenow serializes asnull. The previous left-join constructor projection still created aReportedInfoResponsewhose three fields were null, so clients expecting that nested object can break despite this refactor intending to preserve the response shape; construct the empty report DTO on a miss or otherwise preserve the prior contract.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
의견 감사합니다~ 말씀하신 근거(신고 없이 바로 차단되는 플로우)는 확인해보니 실제로는 banUser가 신고 이력이 없으면 예외를 던져서 그 경로로는 재현되지 않았습니다. 다만 신고자 계정이 나중에 탈퇴 처리되며 report가 정리되는 경로로는 동일한 상태(차단된 유저인데 report row가 0건)가 재현 가능했고, 리팩터링 전에는 QueryDSL 생성자 프로젝션 특성상 필드는 null이어도 객체 자체는 non-null이었던 것도 확인했습니다. 현재 프론트에서 이 응답을 소비하는 곳이 아직 없어 계약을 바꿔도 위험은 없지만, 형제 필드 bannedInfoResponse와의 일관성을 위해 map miss 시 빈 ReportedInfoResponse를 반환하는 방식으로 반영했습니다. (8799fd6)