Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ application-prod.yml
### docker volumes ###
mysql_data_local
redis_data_local

### 개인 작업 지시 문서 ###
AGENTS.local.md
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,11 @@

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

// 컬렉션(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
""")
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,26 @@ AND p.siteUserId NOT IN (
""")
List<Post> 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<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 @@ -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<BannedInfoResponse> BANNED_INFO_RESPONSE_PROJECTION =
Projections.constructor(
BannedInfoResponse.class,
Expand Down Expand Up @@ -154,35 +157,15 @@ private JPAQuery<Long> createUserCountQuery(UserSearchCondition condition) {
);
}

// siteUser를 먼저 페이징해 이 페이지에 필요한 id 목록을 확정한 뒤, report/userBan은 그 id 목록(IN절)에
// 대해서만 한 번씩 배치 조회한다. row마다 상관 서브쿼리로 대량 테이블을 반복 스캔하는 것을 피하기 위함이다.
@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 +177,80 @@ 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.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<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();
}
// 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
));
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,12 @@
@Repository
public interface UnivApplyInfoRepository extends JpaRepository<UnivApplyInfo, Long>, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 +105,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