From f270189c552ef4b8077d66bcb875f77a51661138 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Mon, 21 Sep 2026 19:53:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=EC=BF=BC=EB=A6=AC=20=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=20=EC=A1=B0=EC=82=AC=20=EA=B8=B0=EB=B0=98=20Repositor?= =?UTF-8?q?y=20=EA=B3=84=EC=B8=B5=20=EC=BF=BC=EB=A6=AC=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 EXPLAIN ANALYZE 검증 결과를 바탕으로 5개 비효율 쿼리를 개선한다. - PostRepository/PostQueryService: category 필터를 애플리케이션 레이어에서 SQL WHERE로 이동 - ChatMessageRepository/ChatMessage: findByRoomIdWithPaging의 LEFT JOIN FETCH chatAttachments + Pageable 조합 때문에 Hibernate가 SQL LIMIT을 무시하고 방 전체 메시지를 로드하던 문제 수정(fetch join 제거 + @BatchSize) - SiteUserFilterRepositoryImpl: searchRestrictedUsers를 상관 서브쿼리에서 배치조회 3단계로 재작성 - UnivApplyInfoRepository/UnivApplyInfoFilterRepositoryImpl/UnivApplyInfo: languageRequirements fetch join 제거 + @BatchSize (검색 쿼리의 중복 반환 정합성 버그도 함께 해결) - V60 마이그레이션: post/post_image/post_like/chat_message/gpa_score/ language_test_score/site_user/report에 인덱스 8종 추가 관련 이슈: #845 Co-Authored-By: Claude Sonnet 5 --- .../chat/domain/ChatMessage.java | 2 + .../repository/ChatMessageRepository.java | 8 +- .../post/repository/PostRepository.java | 26 +++++ .../post/service/PostQueryService.java | 16 +-- .../custom/SiteUserFilterRepositoryImpl.java | 97 ++++++++++++++----- .../university/domain/UnivApplyInfo.java | 2 + .../repository/UnivApplyInfoRepository.java | 8 +- .../UnivApplyInfoFilterRepositoryImpl.java | 8 +- ...0__add_query_plan_optimization_indexes.sql | 11 +++ 9 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index 1a4fdeff2..a95c26f38 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -15,6 +15,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.Where; @@ -25,6 +26,7 @@ public class ChatMessage extends BaseEntity { @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 100) private final List chatAttachments = new ArrayList<>(); @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index 4111e6741..00a23477c 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -12,9 +12,15 @@ public interface ChatMessageRepository extends JpaRepository { + // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): + // LEFT JOIN FETCH로 chatAttachments(1:N)를 같이 가져오면서 Pageable을 쓰면, Hibernate가 + // "firstResult/maxResults specified with collection fetch; applying in memory" 규칙 때문에 + // SQL에 LIMIT/OFFSET을 넣지 못하고 해당 방의 메시지를 전부 로드한 뒤 Java에서 페이징한다. + // 즉 지금까지 이 메서드는 무한스크롤 페이지 하나를 요청할 때마다 방의 전체 메시지를 로드하고 있었다. + // chatAttachments는 필터/정렬에 쓰이지 않으므로 fetch join을 제거해서 LIMIT이 SQL에 그대로 전달되게 하고, + // ChatMessage.chatAttachments에 @BatchSize를 추가해 나중에 지연 로딩될 때 배치 조회되게 위임했다. @Query(""" SELECT cm FROM ChatMessage cm - LEFT JOIN FETCH cm.chatAttachments WHERE cm.chatRoom.id = :roomId ORDER BY cm.createdAt DESC """) diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index b7a5bfe7a..e56697b72 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -4,6 +4,7 @@ import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.EntityGraph; @@ -26,6 +27,31 @@ AND p.siteUserId NOT IN ( """) List findByBoardCodeExcludingBlockedUsersOrderByCreatedAtDesc(@Param("boardCode") String boardCode, @Param("siteUserId") Long siteUserId); + // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): + // 기존에는 board 전체 게시글을 위 메서드들로 가져온 뒤 PostQueryService에서 + // Java 스트림으로 category를 필터링했다(전체를 로드하고 대부분은 버림). + // category 조건을 SQL WHERE로 내려서 DB가 필요한 행만 반환하게 했다. + // category가 PostCategory.전체 이면 필터링 없이 전체를 반환(기존 동작과 동일). + @Query(""" + SELECT p FROM Post p + WHERE p.boardCode = :boardCode + AND (:category = com.example.solidconnection.community.post.domain.PostCategory.전체 OR p.category = :category) + ORDER BY p.createdAt DESC + """) + List findByBoardCodeAndCategoryOrderByCreatedAtDesc(@Param("boardCode") String boardCode, @Param("category") PostCategory category); + + @Query(""" + SELECT p FROM Post p + WHERE p.boardCode = :boardCode + AND (:category = com.example.solidconnection.community.post.domain.PostCategory.전체 OR p.category = :category) + AND p.siteUserId NOT IN ( + SELECT ub.blockedId FROM UserBlock ub WHERE ub.blockerId = :siteUserId + ) + ORDER BY p.createdAt DESC + """) + List findByBoardCodeAndCategoryExcludingBlockedUsersOrderByCreatedAtDesc( + @Param("boardCode") String boardCode, @Param("category") PostCategory category, @Param("siteUserId") Long siteUserId); + @EntityGraph(attributePaths = {"postImageList"}) Optional findPostById(Long id); diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java index a67156e4d..84927c74e 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -25,7 +25,6 @@ import com.example.solidconnection.siteuser.repository.UserBlockRepository; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,11 +50,11 @@ public List findPostsByCodeAndPostCategoryOrderByCreatedAtDesc List postList; if (siteUserId != null) { - postList = postRepository.findByBoardCodeExcludingBlockedUsersOrderByCreatedAtDesc(boardCode, siteUserId); + postList = postRepository.findByBoardCodeAndCategoryExcludingBlockedUsersOrderByCreatedAtDesc(boardCode, postCategory, siteUserId); } else { - postList = postRepository.findByBoardCodeOrderByCreatedAtDesc(boardCode); + postList = postRepository.findByBoardCodeAndCategoryOrderByCreatedAtDesc(boardCode, postCategory); } - return PostListResponse.from(getPostListByPostCategory(postList, postCategory)); + return PostListResponse.from(postList); } @Transactional(readOnly = true) @@ -108,15 +107,6 @@ private PostCategory validatePostCategory(String category) { return PostCategory.valueOf(category); } - private List getPostListByPostCategory(List postList, PostCategory postCategory) { - if (postCategory.equals(PostCategory.전체)) { - return postList; - } - return postList.stream() - .filter(post -> post.getCategory().equals(postCategory)) - .collect(Collectors.toList()); - } - private void validatedIsBlockedByMe(Post post, SiteUser siteUser) { if (userBlockRepository.existsByBlockerIdAndBlockedId(siteUser.getId(), post.getSiteUserId())) { throw new CustomException(ACCESS_DENIED); diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index c1132d755..7e95dd90f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -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 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 searchRestrictedUsers( RestrictedUserSearchCondition condition, Pageable pageable ) { - List 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 siteUsers = queryFactory + .selectFrom(siteUser) .where( roleEq(condition.role()), isRestrictedUser(), @@ -194,11 +179,75 @@ public Page searchRestrictedUsers( .limit(pageable.getPageSize()) .fetch(); + List siteUserIds = siteUsers.stream().map(SiteUser::getId).toList(); + + Map latestReportedInfoBySiteUserId = findLatestReportedInfoBySiteUserIds(siteUserIds); + Map activeBanDurationBySiteUserId = findActiveBanDurationBySiteUserIds(siteUserIds); + + List 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 findLatestReportedInfoBySiteUserIds(List 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 findActiveBanDurationBySiteUserIds(List siteUserIds) { + if (siteUserIds.isEmpty()) { + return Map.of(); + } + return queryFactory + .select(userBan.bannedUserId, userBan.duration) + .from(userBan) + .where( + userBan.bannedUserId.in(siteUserIds), + userBan.isExpired.eq(false), + userBan.expiredAt.after(ZonedDateTime.now(UTC)) + ) + .fetch() + .stream() + .collect(Collectors.toMap( + tuple -> tuple.get(userBan.bannedUserId), + tuple -> tuple.get(userBan.duration) + )); + } + private JPAQuery createRestrictedUserCountQuery(RestrictedUserSearchCondition condition) { return queryFactory diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java index 6ffad53be..b0b102127 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java @@ -22,6 +22,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -75,6 +76,7 @@ public class UnivApplyInfo extends BaseEntity { private Map extraInfo; @OneToMany(mappedBy = "univApplyInfo", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 100) private Set languageRequirements = new HashSet<>(); @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java index a796fed28..e8ed312a7 100644 --- a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java @@ -15,10 +15,14 @@ @Repository public interface UnivApplyInfoRepository extends JpaRepository, UnivApplyInfoFilterRepository { + // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): + // languageRequirements(1:N) fetch join 때문에 uia 1건당 여러 행으로 fan-out되어 + // SELECT DISTINCT + 임시테이블(dedup)이 매번 발생했다. languageRequirements는 필터링에 + // 쓰이지 않으므로 join을 제거하고(따라서 DISTINCT도 불필요해짐), UnivApplyInfo.languageRequirements의 + // @BatchSize로 필요할 때 배치 조회되게 위임했다. @Query(""" - SELECT DISTINCT uai + SELECT uai FROM UnivApplyInfo uai - LEFT JOIN FETCH uai.languageRequirements lr LEFT JOIN FETCH uai.homeUniversity hu JOIN FETCH uai.university u LEFT JOIN FETCH u.country c diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java index 332abf58b..c28b076c9 100644 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java @@ -89,12 +89,17 @@ private BooleanExpression termIdEq(QUnivApplyInfo univApplyInfo, Long givenTermI return univApplyInfo.termId.eq(givenTermId); } + // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): + // languageRequirements(1:N)를 fetchJoin으로 같이 가져오면 uia 1건당 최소 2행으로 fan-out되는데, + // 이 메서드는 .distinct()도 없어서 결과 List에 같은 uia가 중복 원소로 들어가는 + // 정합성 문제까지 있었다(EXPLAIN 상으로도 uia 6,019건인데 조인 결과가 12,875행으로 늘어나는 것으로 확인). + // languageRequirements는 필터/정렬에 쓰이지 않으므로 메인 쿼리에서 fetchJoin을 제거하고, + // UnivApplyInfo.languageRequirements에 @BatchSize를 추가해 필요한 시점에 IN절 배치 쿼리로 지연 로딩되게 했다. @Override public List findAllByText(String text, Long termId, Long homeUniversityId) { QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo; QHostUniversity university = QHostUniversity.hostUniversity; QHomeUniversity homeUniversity = QHomeUniversity.homeUniversity; - QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; QCountry country = QCountry.country; QRegion region = QRegion.region; @@ -103,7 +108,6 @@ public List findAllByText(String text, Long termId, Long homeUniv .join(university.country, country).fetchJoin() .join(region).on(country.regionCode.eq(region.code)) .leftJoin(univApplyInfo.homeUniversity, homeUniversity).fetchJoin() - .leftJoin(univApplyInfo.languageRequirements, languageRequirement).fetchJoin() .where( termIdEq(univApplyInfo, termId), homeUniversityIdEq(homeUniversity, homeUniversityId) diff --git a/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql b/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql new file mode 100644 index 000000000..6bbf8ed5d --- /dev/null +++ b/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql @@ -0,0 +1,11 @@ +ALTER TABLE post ADD INDEX idx_post_board_code_category_created_at (board_code, category, 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); From 8785457bb82e1be2ecd4041a873eadb3daba96c7 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Mon, 21 Sep 2026 21:10:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20=ED=99=9C=EC=84=B1=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EC=A0=84=EC=B2=B4=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SiteUserFilterRepositoryImpl.findActiveBanDurationBySiteUserIds: 동시 요청으로 같은 유저에게 활성 차단이 2건 이상 생겨도 Collectors.toMap이 IllegalStateException을 던지지 않도록 expiredAt 내림차순 정렬 + merge function 추가(가장 늦게 만료되는 차단을 남김). user_ban 중복 데이터를 만들어 실제로 재현/검증함. - V60 마이그레이션: post에 (board_code, created_at) 인덱스 추가. 기존 (board_code, category, created_at) 인덱스는 category='전체'(기본값, 필터 없음) 경로에서 정렬을 못 커버해 개선 전과 동일하게 느렸음(36.9ms) — 새 인덱스로 12~13ms대로 개선. 기존 카테고리별 조회는 회귀 없음(EXPLAIN으로 재검증). Co-Authored-By: Claude Sonnet 5 --- .../repository/custom/SiteUserFilterRepositoryImpl.java | 8 +++++++- .../V60__add_query_plan_optimization_indexes.sql | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index 7e95dd90f..4105ceb3f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -232,6 +232,10 @@ private Map findActiveBanDurationBySiteUserIds(List 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) @@ -240,11 +244,13 @@ private Map findActiveBanDurationBySiteUserIds(List 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) + tuple -> tuple.get(userBan.duration), + (first, duplicate) -> first )); } diff --git a/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql b/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql index 6bbf8ed5d..49c008a70 100644 --- a/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql +++ b/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql @@ -1,4 +1,5 @@ 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); From 8cdcc4184c3b772e423153ad91053efdbdca483b Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Mon, 21 Sep 2026 21:20:46 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EC=A1=B0=EC=82=AC=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EB=82=A8=EA=B8=B4=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업 과정을 기록한 일지성 주석("1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용)" 등)을 제거하고, 코드만으로는 알기 어려운 이유(Hibernate collection fetch join + Pageable 제약, 중복 활성 차단 row로 인한 toMap 충돌 등)만 간결하게 남겼다. Co-Authored-By: Claude Sonnet 5 --- .../chat/repository/ChatMessageRepository.java | 10 +++------- .../community/post/repository/PostRepository.java | 5 ----- .../custom/SiteUserFilterRepositoryImpl.java | 15 +++++---------- .../repository/UnivApplyInfoRepository.java | 8 +++----- .../custom/UnivApplyInfoFilterRepositoryImpl.java | 9 +++------ 5 files changed, 14 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index 00a23477c..8efc31d3a 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -12,13 +12,9 @@ public interface ChatMessageRepository extends JpaRepository { - // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): - // LEFT JOIN FETCH로 chatAttachments(1:N)를 같이 가져오면서 Pageable을 쓰면, Hibernate가 - // "firstResult/maxResults specified with collection fetch; applying in memory" 규칙 때문에 - // SQL에 LIMIT/OFFSET을 넣지 못하고 해당 방의 메시지를 전부 로드한 뒤 Java에서 페이징한다. - // 즉 지금까지 이 메서드는 무한스크롤 페이지 하나를 요청할 때마다 방의 전체 메시지를 로드하고 있었다. - // chatAttachments는 필터/정렬에 쓰이지 않으므로 fetch join을 제거해서 LIMIT이 SQL에 그대로 전달되게 하고, - // ChatMessage.chatAttachments에 @BatchSize를 추가해 나중에 지연 로딩될 때 배치 조회되게 위임했다. + // 컬렉션(chatAttachments)을 fetch join하면서 Pageable을 쓰면 Hibernate가 SQL LIMIT을 적용하지 + // 못하고 전체를 로드한 뒤 메모리에서 페이징한다. 필요 시 ChatMessage.chatAttachments의 @BatchSize로 + // 지연 로딩되게 위임하고 여기서는 fetch join을 쓰지 않는다. @Query(""" SELECT cm FROM ChatMessage cm WHERE cm.chatRoom.id = :roomId diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index e56697b72..d404bc635 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -27,11 +27,6 @@ AND p.siteUserId NOT IN ( """) List findByBoardCodeExcludingBlockedUsersOrderByCreatedAtDesc(@Param("boardCode") String boardCode, @Param("siteUserId") Long siteUserId); - // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): - // 기존에는 board 전체 게시글을 위 메서드들로 가져온 뒤 PostQueryService에서 - // Java 스트림으로 category를 필터링했다(전체를 로드하고 대부분은 버림). - // category 조건을 SQL WHERE로 내려서 DB가 필요한 행만 반환하게 했다. - // category가 PostCategory.전체 이면 필터링 없이 전체를 반환(기존 동작과 동일). @Query(""" SELECT p FROM Post p WHERE p.boardCode = :boardCode diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index 4105ceb3f..ac0cc8033 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -155,12 +155,8 @@ private JPAQuery 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 등 기존 코드베이스의 배치조회 패턴과 동일) + // siteUser를 먼저 페이징해 이 페이지에 필요한 id 목록을 확정한 뒤, report/userBan은 그 id 목록(IN절)에 + // 대해서만 한 번씩 배치 조회한다. row마다 상관 서브쿼리로 대량 테이블을 반복 스캔하는 것을 피하기 위함이다. @Override public Page searchRestrictedUsers( RestrictedUserSearchCondition condition, @@ -232,10 +228,9 @@ private Map findActiveBanDurationBySiteUserIds(List if (siteUserIds.isEmpty()) { return Map.of(); } - // PR 리뷰 반영(2026-09-21): 동시 요청으로 같은 유저에게 활성 차단이 2건 이상 생길 수 있어(user_ban에 - // 유저당 활성 차단 1건 제약이 없고, validateNotAlreadyBanned도 check-then-act라 race가 가능) - // 단순 toMap은 중복 키에서 IllegalStateException을 던진다. expiredAt 내림차순으로 정렬해 - // 가장 나중에 만료되는 차단을 남기는 merge function을 추가했다. + // user_ban에 유저당 활성 차단 1건 제약이 없어 동시 요청 등으로 활성 차단이 2건 이상 존재할 수 있다. + // 단순 toMap은 중복 키에서 IllegalStateException을 던지므로, expiredAt 내림차순으로 정렬해 + // 가장 나중에 만료되는 차단을 남기는 merge function을 사용한다. return queryFactory .select(userBan.bannedUserId, userBan.duration) .from(userBan) diff --git a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java index e8ed312a7..aa53b8d89 100644 --- a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java @@ -15,11 +15,9 @@ @Repository public interface UnivApplyInfoRepository extends JpaRepository, UnivApplyInfoFilterRepository { - // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): - // languageRequirements(1:N) fetch join 때문에 uia 1건당 여러 행으로 fan-out되어 - // SELECT DISTINCT + 임시테이블(dedup)이 매번 발생했다. languageRequirements는 필터링에 - // 쓰이지 않으므로 join을 제거하고(따라서 DISTINCT도 불필요해짐), UnivApplyInfo.languageRequirements의 - // @BatchSize로 필요할 때 배치 조회되게 위임했다. + // languageRequirements(1:N)는 필터링에 쓰이지 않아 fetch join하지 않는다(fan-out으로 인한 + // 불필요한 임시테이블 생성을 피하기 위함). 필요 시 UnivApplyInfo.languageRequirements의 + // @BatchSize로 지연 로딩된다. @Query(""" SELECT uai FROM UnivApplyInfo uai diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java index c28b076c9..fe3ee840f 100644 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java @@ -89,12 +89,9 @@ private BooleanExpression termIdEq(QUnivApplyInfo univApplyInfo, Long givenTermI return univApplyInfo.termId.eq(givenTermId); } - // 1차 쿼리 개선(2026-09-21, 미커밋 로컬 검증용): - // languageRequirements(1:N)를 fetchJoin으로 같이 가져오면 uia 1건당 최소 2행으로 fan-out되는데, - // 이 메서드는 .distinct()도 없어서 결과 List에 같은 uia가 중복 원소로 들어가는 - // 정합성 문제까지 있었다(EXPLAIN 상으로도 uia 6,019건인데 조인 결과가 12,875행으로 늘어나는 것으로 확인). - // languageRequirements는 필터/정렬에 쓰이지 않으므로 메인 쿼리에서 fetchJoin을 제거하고, - // UnivApplyInfo.languageRequirements에 @BatchSize를 추가해 필요한 시점에 IN절 배치 쿼리로 지연 로딩되게 했다. + // languageRequirements(1:N)는 필터/정렬에 쓰이지 않으므로 fetchJoin하지 않는다. 여기서 fetchJoin하면 + // uia 1건당 fan-out되고 이 메서드에는 .distinct()도 없어 결과에 같은 uia가 중복으로 들어간다. + // 필요 시 UnivApplyInfo.languageRequirements의 @BatchSize로 지연 로딩된다. @Override public List findAllByText(String text, Long termId, Long homeUniversityId) { QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo; From 8799fd6235a7a6857d3d067990f63eba71185c59 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Wed, 23 Sep 2026 15:11:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=8B=A0=EA=B3=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=97=86=EB=8A=94=20=EC=9C=A0=EC=A0=80=EC=9D=98=20?= =?UTF-8?q?reportedInfoResponse=EA=B0=80=20null=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 조회로 바꾸면서 Map에 값이 없으면 raw null을 반환하게 됐는데, 리팩터링 전에는 leftJoin 기반 QueryDSL 생성자 프로젝션이라 필드는 null이어도 객체 자체는 항상 non-null이었다. 형제 필드 bannedInfoResponse(래퍼는 항상 존재, 내부 값만 null)와도 일관되도록 map miss 시 빈 ReportedInfoResponse를 반환하게 했다. Co-Authored-By: Claude Sonnet 5 --- .../repository/custom/SiteUserFilterRepositoryImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index ac0cc8033..c90b2cdb9 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -74,6 +74,8 @@ public class SiteUserFilterRepositoryImpl implements SiteUserFilterRepository { report.reportType ); + private static final ReportedInfoResponse EMPTY_REPORTED_INFO_RESPONSE = new ReportedInfoResponse(null, null, null); + private static final ConstructorExpression BANNED_INFO_RESPONSE_PROJECTION = Projections.constructor( BannedInfoResponse.class, @@ -186,7 +188,7 @@ public Page searchRestrictedUsers( su.getNickname(), su.getRole(), su.getUserStatus(), - latestReportedInfoBySiteUserId.get(su.getId()), + latestReportedInfoBySiteUserId.getOrDefault(su.getId(), EMPTY_REPORTED_INFO_RESPONSE), new BannedInfoResponse( su.getUserStatus() == UserStatus.BANNED, activeBanDurationBySiteUserId.get(su.getId()) From 8dbc113bdfcf6b4b010ab00c2961a8813e7c1465 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Wed, 23 Sep 2026 15:18:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EA=B0=9C=EC=9D=B8=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=A7=80=EC=8B=9C=20=EB=AC=B8=EC=84=9C(AGENTS.loca?= =?UTF-8?q?l.md)=EB=A5=BC=20gitignore=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인프라 레포와 동일하게, 원격에 올라가면 안 되는 로컬 전용 작업 지시 문서를 gitignore 처리한다. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ad1756662..537674dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ application-prod.yml ### docker volumes ### mysql_data_local redis_data_local + +### 개인 작업 지시 문서 ### +AGENTS.local.md