Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,6 +26,7 @@
public class ChatMessage extends BaseEntity {

@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 100)
private final List<ChatAttachment> chatAttachments = new ArrayList<>();
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

// 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
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,31 @@ AND p.siteUserId NOT IN (
""")
List<Post> 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<Post> 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<Post> findByBoardCodeAndCategoryExcludingBlockedUsersOrderByCreatedAtDesc(
@Param("boardCode") String boardCode, @Param("category") PostCategory category, @Param("siteUserId") Long siteUserId);

@EntityGraph(attributePaths = {"postImageList"})
Optional<Post> findPostById(Long id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,11 +50,11 @@ public List<PostListResponse> findPostsByCodeAndPostCategoryOrderByCreatedAtDesc

List<Post> 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)
Expand Down Expand Up @@ -108,15 +107,6 @@ private PostCategory validatePostCategory(String category) {
return PostCategory.valueOf(category);
}

private List<Post> getPostListByPostCategory(List<Post> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the empty report object for ban-only users

When a user is banned directly without any report history—a supported flow exercised by AdminUserBanServiceTest—this map lookup returns null, so reportedInfoResponse now serializes as null. The previous left-join constructor projection still created a ReportedInfoResponse whose 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의견 감사합니다~ 말씀하신 근거(신고 없이 바로 차단되는 플로우)는 확인해보니 실제로는 banUser가 신고 이력이 없으면 예외를 던져서 그 경로로는 재현되지 않았습니다. 다만 신고자 계정이 나중에 탈퇴 처리되며 report가 정리되는 경로로는 동일한 상태(차단된 유저인데 report row가 0건)가 재현 가능했고, 리팩터링 전에는 QueryDSL 생성자 프로젝션 특성상 필드는 null이어도 객체 자체는 non-null이었던 것도 확인했습니다. 현재 프론트에서 이 응답을 소비하는 곳이 아직 없어 계약을 바꿔도 위험은 없지만, 형제 필드 bannedInfoResponse와의 일관성을 위해 map miss 시 빈 ReportedInfoResponse를 반환하는 방식으로 반영했습니다. (8799fd6)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle duplicate active bans when building the map

If two concurrent ban requests target the same user, both can pass AdminUserBanService.validateNotAlreadyBanned before either transaction inserts, because user_ban has no uniqueness constraint or locking for active bans. This query then returns both active rows, and Collectors.toMap throws IllegalStateException for the duplicate user ID, causing the entire restricted-user search request to fail. Select a deterministic active ban or provide a merge function while the underlying uniqueness invariant is enforced.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의견 감사합니다~ 동시 요청으로 같은 유저에게 활성 차단이 2건 이상 생길 수 있는 케이스를 실제로 재현해서 확인했고, expiredAt 내림차순 정렬 + Collectors.toMap merge function(가장 늦게 만료되는 차단을 유지) 방식으로 반영했습니다. (8785457)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
}


private JPAQuery<Long> createRestrictedUserCountQuery(RestrictedUserSearchCondition condition) {
return queryFactory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -75,6 +76,7 @@ public class UnivApplyInfo extends BaseEntity {
private Map<String, String> extraInfo;

@OneToMany(mappedBy = "univApplyInfo", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 100)
private Set<LanguageRequirement> languageRequirements = new HashSet<>();

@ManyToOne(fetch = FetchType.LAZY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
@Repository
public interface UnivApplyInfoRepository extends JpaRepository<UnivApplyInfo, Long>, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UnivApplyInfo>에 같은 uia가 중복 원소로 들어가는
// 정합성 문제까지 있었다(EXPLAIN 상으로도 uia 6,019건인데 조인 결과가 12,875행으로 늘어나는 것으로 확인).
// languageRequirements는 필터/정렬에 쓰이지 않으므로 메인 쿼리에서 fetchJoin을 제거하고,
// UnivApplyInfo.languageRequirements에 @BatchSize를 추가해 필요한 시점에 IN절 배치 쿼리로 지연 로딩되게 했다.
@Override
public List<UnivApplyInfo> 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;

Expand All @@ -103,7 +108,6 @@ public List<UnivApplyInfo> 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)
Expand Down
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);
Loading