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 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..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,9 +12,11 @@ public interface ChatMessageRepository extends JpaRepository { + // 컬렉션(chatAttachments)을 fetch join하면서 Pageable을 쓰면 Hibernate가 SQL LIMIT을 적용하지 + // 못하고 전체를 로드한 뒤 메모리에서 페이징한다. 필요 시 ChatMessage.chatAttachments의 @BatchSize로 + // 지연 로딩되게 위임하고 여기서는 fetch join을 쓰지 않는다. @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..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 @@ -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,26 @@ AND p.siteUserId NOT IN ( """) List findByBoardCodeExcludingBlockedUsersOrderByCreatedAtDesc(@Param("boardCode") String boardCode, @Param("siteUserId") Long siteUserId); + @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..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 @@ -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; @@ -73,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, @@ -154,35 +157,15 @@ private JPAQuery createUserCountQuery(UserSearchCondition condition) { ); } + // siteUser를 먼저 페이징해 이 페이지에 필요한 id 목록을 확정한 뒤, report/userBan은 그 id 목록(IN절)에 + // 대해서만 한 번씩 배치 조회한다. row마다 상관 서브쿼리로 대량 테이블을 반복 스캔하는 것을 피하기 위함이다. @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 +177,80 @@ 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.getOrDefault(su.getId(), EMPTY_REPORTED_INFO_RESPONSE), + 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(); + } + // user_ban에 유저당 활성 차단 1건 제약이 없어 동시 요청 등으로 활성 차단이 2건 이상 존재할 수 있다. + // 단순 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 + )); + } + 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..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,10 +15,12 @@ @Repository public interface UnivApplyInfoRepository extends JpaRepository, UnivApplyInfoFilterRepository { + // languageRequirements(1:N)는 필터링에 쓰이지 않아 fetch join하지 않는다(fan-out으로 인한 + // 불필요한 임시테이블 생성을 피하기 위함). 필요 시 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..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,14 @@ private BooleanExpression termIdEq(QUnivApplyInfo univApplyInfo, Long givenTermI return univApplyInfo.termId.eq(givenTermId); } + // 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; QHostUniversity university = QHostUniversity.hostUniversity; QHomeUniversity homeUniversity = QHomeUniversity.homeUniversity; - QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; QCountry country = QCountry.country; QRegion region = QRegion.region; @@ -103,7 +105,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..49c008a70 --- /dev/null +++ b/src/main/resources/db/migration/V60__add_query_plan_optimization_indexes.sql @@ -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);