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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.solidconnection.chat.dto;

import com.example.solidconnection.chat.domain.ChatMessage;
import com.example.solidconnection.siteuser.domain.SiteUser;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public record ChatRoomData(
Map<Long, ChatMessage> latestMessages,
Map<Long, Long> unreadCounts,
Map<Long, SiteUser> partnerUsers
) {

public static ChatRoomData from(List<ChatMessage> latestMessages,
List<UnreadCountDto> unreadCounts,
List<SiteUser> partnerUsers) {
return new ChatRoomData(
latestMessages.stream().collect(Collectors.toMap(msg -> msg.getChatRoom().getId(), msg -> msg)),
unreadCounts.stream().collect(Collectors.toMap(UnreadCountDto::chatRoomId, UnreadCountDto::count)),
partnerUsers.stream().collect(Collectors.toMap(SiteUser::getId, user -> user))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.solidconnection.chat.dto;

public record UnreadCountDto(
long chatRoomId,
long count
) {

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatMessage;
import java.util.Optional;
import com.example.solidconnection.chat.dto.UnreadCountDto;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -18,5 +19,33 @@ public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long>
""")
Slice<ChatMessage> findByRoomIdWithPaging(@Param("roomId") long roomId, Pageable pageable);

Optional<ChatMessage> findFirstByChatRoomIdOrderByCreatedAtDesc(long chatRoomId);
@Query("""
SELECT cm FROM ChatMessage cm
WHERE cm.id IN (
SELECT MAX(cm2.id)
FROM ChatMessage cm2
WHERE cm2.chatRoom.id IN :chatRoomIds
GROUP BY cm2.chatRoom.id
)
""")
List<ChatMessage> findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List<Long> chatRoomIds);
Comment on lines +22 to +31
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

최신 메시지 선정 기준을 id → createdAt로 통일해 의미적 정합성 보장.

    1. 의미 불일치. 현재 쿼리는 MAX(id)로 "최신"을 결정하지만, ChatRoomRepository는 createdAt 기준으로 정렬합니다. 두 기준이 불일치하면 복원력 없는 결과가 됩니다(시퀀스 점프·데이터 마이그레이션·수동 삽입 시).
    1. 정확성. "최신"의 정의가 도메인적으로 생성시각(createdAt)이라면 쿼리도 동일 기준을 써야 합니다.
    1. 동률 처리. 동일 createdAt이 드물지만 가능하므로, 동률 시 더 큰 id를 고르는 보조 기준을 서비스/조합 단계에서 처리하는 것이 안전합니다(아래 ChatRoomData 개선안에 병행 제안).
    1. 빈 목록. IN (:chatRoomIds)로 빈 리스트가 들어오면 프로바이더에 따라 예외가 날 수 있어 호출부에서 빈 리스트 가드가 필요합니다.

ChatMessage의 @GeneratedValue 전략과 createdAt 세터·감사 필드 전략을 확인해 일관된 시간 정렬이 가능한지 점검해 주세요.

아래와 같이 쿼리를 createdAt 기준으로 바꾸는 것을 권장합니다.

-    @Query("""
-           SELECT cm FROM ChatMessage cm
-           WHERE cm.id IN (
-               SELECT MAX(cm2.id)
-               FROM ChatMessage cm2
-               WHERE cm2.chatRoom.id IN :chatRoomIds
-               GROUP BY cm2.chatRoom.id
-           )
-           """)
+    @Query("""
+           SELECT cm FROM ChatMessage cm
+           WHERE cm.chatRoom.id IN :chatRoomIds
+           AND cm.createdAt = (
+               SELECT MAX(cm2.createdAt)
+               FROM ChatMessage cm2
+               WHERE cm2.chatRoom = cm.chatRoom
+           )
+           """)
     List<ChatMessage> findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List<Long> chatRoomIds);

🏁 Script executed:

#!/bin/bash
# ChatMessage ID/시간 필드 정의 확인
rg -n -C3 'class\s+ChatMessage\b'
rg -n -C2 '@GeneratedValue|createdAt' --type=java

Length of output: 37996


ChatMessageRepository의 최신 메시지 선별 기준을 createdAt으로 통일하기

  1. 쿼리 기준 통일

    • ChatRoomRepository는 메시지 최신 순 선별에 createdAt을 사용합니다.
    • 반면 ChatMessageRepository는 MAX(id)로 최신 메시지를 선택하고 있어 두 결과가 불일치할 수 있습니다.
    • 아래 코드처럼 쿼리를 createdAt 기준으로 수정해주세요.
    -@Query("""
    -       SELECT cm FROM ChatMessage cm
    -       WHERE cm.id IN (
    -           SELECT MAX(cm2.id)
    -           FROM ChatMessage cm2
    -           WHERE cm2.chatRoom.id IN :chatRoomIds
    -           GROUP BY cm2.chatRoom.id
    -       )
    -       """)
    +@Query("""
    +       SELECT cm FROM ChatMessage cm
    +       WHERE cm.chatRoom.id IN :chatRoomIds
    +       AND cm.createdAt = (
    +           SELECT MAX(cm2.createdAt)
    +           FROM ChatMessage cm2
    +           WHERE cm2.chatRoom = cm.chatRoom
    +       )
    +       """)
    List<ChatMessage> findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List<Long> chatRoomIds);
  2. createdAt 필드 설정 확인

    • ChatMessage는 BaseEntity를 상속받아 @PrePersist에서 createdAt이 ZonedDateTime.now(UTC).truncatedTo(MICROS)로 자동 설정됩니다.
    • 따라서 createdAt을 비교 기준으로 사용하는 것이 도메인적으로도 일관되고 안전합니다.
  3. 빈 리스트 처리

    • IN절에 빈 컬렉션(chatRoomIds)이 들어갈 경우 일부 JPA 구현체에서 예외가 발생할 수 있습니다.
    • 호출부에서 빈 리스트인 경우 바로 빈 결과를 반환하거나, 레포지토리 호출 전 guard 로직을 추가해주세요.
  4. 동률 처리

    • 동일한 createdAt을 가지는 메시지가 극히 드물지만 가능하므로, 동률 시 더 큰 id를 선택하는 보조 로직을 서비스 레이어에서 추가 적용을 권장합니다.
  • 대상 파일: src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java (22–31번 라인)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Query("""
SELECT cm FROM ChatMessage cm
WHERE cm.id IN (
SELECT MAX(cm2.id)
FROM ChatMessage cm2
WHERE cm2.chatRoom.id IN :chatRoomIds
GROUP BY cm2.chatRoom.id
)
""")
List<ChatMessage> findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List<Long> chatRoomIds);
@Query("""
SELECT cm FROM ChatMessage cm
WHERE cm.chatRoom.id IN :chatRoomIds
AND cm.createdAt = (
SELECT MAX(cm2.createdAt)
FROM ChatMessage cm2
WHERE cm2.chatRoom = cm.chatRoom
)
""")
List<ChatMessage> findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List<Long> chatRoomIds);
🤖 Prompt for AI Agents
In
src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java
around lines 22-31, the repository currently selects latest messages by MAX(id)
which conflicts with ChatRoomRepository that uses createdAt; change the JPQL to
select messages whose createdAt equals the MAX(cm2.createdAt) grouped by
cm2.chatRoom.id (i.e. use MAX(cm2.createdAt) in the subquery and compare
cm.createdAt), keep the method signature and @Param unchanged, and ensure
callers guard against an empty chatRoomIds (return empty list before calling
repository) to avoid IN () issues; optionally handle tie-breakers (same
createdAt) in the service layer by selecting the message with the larger id when
createdAt values are equal.


@Query("""
SELECT new com.example.solidconnection.chat.dto.UnreadCountDto(
cm.chatRoom.id,
COUNT(cm)
)
FROM ChatMessage cm
LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id
AND crs.chatParticipantId = (
SELECT cp.id FROM ChatParticipant cp
WHERE cp.chatRoom.id = cm.chatRoom.id
AND cp.siteUserId = :userId
)
WHERE cm.chatRoom.id IN :chatRoomIds
AND cm.senderId != :userId
AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt)
GROUP BY cm.chatRoom.id
""")
List<UnreadCountDto> countUnreadMessagesBatch(@Param("chatRoomIds") List<Long> chatRoomIds, @Param("userId") long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,21 @@
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {

@Query("""
SELECT cr FROM ChatRoom cr
JOIN cr.chatParticipants cp
WHERE cp.siteUserId = :userId AND cr.isGroup = false
SELECT DISTINCT cr FROM ChatRoom cr
JOIN FETCH cr.chatParticipants
WHERE cr.id IN (
SELECT DISTINCT cp2.chatRoom.id
FROM ChatParticipant cp2
WHERE cp2.siteUserId = :userId
)
AND cr.isGroup = false
ORDER BY (
SELECT MAX(cm.createdAt)
FROM ChatMessage cm
WHERE cm.chatRoom = cr
) DESC NULLS LAST
""")
List<ChatRoom> findOneOnOneChatRoomsByUserId(@Param("userId") long userId);

@Query("""
SELECT COUNT(cm) FROM ChatMessage cm
LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id
AND crs.chatParticipantId = (
SELECT cp.id FROM ChatParticipant cp
WHERE cp.chatRoom.id = :chatRoomId
AND cp.siteUserId = :userId
)
WHERE cm.chatRoom.id = :chatRoomId
AND cm.senderId != :userId
AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt)
""")
long countUnreadMessages(@Param("chatRoomId") long chatRoomId, @Param("userId") long userId);
List<ChatRoom> findOneOnOneChatRoomsByUserIdWithParticipants(@Param("userId") long userId);

boolean existsByMentoringId(long mentoringId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
import com.example.solidconnection.chat.repository.ChatRoomRepository;
import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.chat.dto.ChatRoomData;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
Expand Down Expand Up @@ -60,28 +60,50 @@ public ChatService(ChatRoomRepository chatRoomRepository,

@Transactional(readOnly = true)
public ChatRoomListResponse getChatRooms(long siteUserId) {
// todo : n + 1 문제 해결 필요!
List<ChatRoom> chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserId(siteUserId);
List<ChatRoomResponse> chatRoomInfos = chatRooms.stream()
.map(chatRoom -> toChatRoomResponse(chatRoom, siteUserId))
List<ChatRoom> chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserIdWithParticipants(siteUserId);

if (chatRooms.isEmpty()) {
return ChatRoomListResponse.of(Collections.emptyList());
}

ChatRoomData chatRoomData = getChatRoomData(chatRooms, siteUserId);

List<ChatRoomResponse> responses = chatRooms.stream()
.map(chatRoom -> createChatRoomResponse(chatRoom, siteUserId, chatRoomData))
.toList();
return ChatRoomListResponse.of(chatRoomInfos);

return ChatRoomListResponse.of(responses);
}

private ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, long siteUserId) {
Optional<ChatMessage> latestMessage = chatMessageRepository.findFirstByChatRoomIdOrderByCreatedAtDesc(chatRoom.getId());
String lastChatMessage = latestMessage.map(ChatMessage::getContent).orElse("");
ZonedDateTime lastReceivedTime = latestMessage.map(ChatMessage::getCreatedAt).orElse(null);
private ChatRoomData getChatRoomData(List<ChatRoom> chatRooms, long siteUserId) {
List<Long> chatRoomIds = chatRooms.stream().map(ChatRoom::getId).toList();
List<Long> partnerUserIds = chatRooms.stream()
.map(chatRoom -> findPartner(chatRoom, siteUserId).getSiteUserId())
.toList();

ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId);
return ChatRoomData.from(
chatMessageRepository.findLatestMessagesByChatRoomIds(chatRoomIds),
chatMessageRepository.countUnreadMessagesBatch(chatRoomIds, siteUserId),
siteUserRepository.findAllByIdIn(partnerUserIds)
);
}

SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId())
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
ChatParticipantResponse partner = ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl());
private ChatRoomResponse createChatRoomResponse(ChatRoom chatRoom, long siteUserId, ChatRoomData chatRoomData) {
ChatMessage latestMessage = chatRoomData.latestMessages().get(chatRoom.getId());
ChatParticipant partner = findPartner(chatRoom, siteUserId);
SiteUser partnerUser = chatRoomData.partnerUsers().get(partner.getSiteUserId());

long unReadCount = chatRoomRepository.countUnreadMessages(chatRoom.getId(), siteUserId);
if (partnerUser == null) {
throw new CustomException(USER_NOT_FOUND);
}

return ChatRoomResponse.of(chatRoom.getId(), lastChatMessage, lastReceivedTime, partner, unReadCount);
return ChatRoomResponse.of(
chatRoom.getId(),
latestMessage != null ? latestMessage.getContent() : "",
latestMessage != null ? latestMessage.getCreatedAt() : null,
ChatParticipantResponse.of(partnerUser.getId(), partnerUser.getNickname(), partnerUser.getProfileImageUrl()),
chatRoomData.unreadCounts().getOrDefault(chatRoom.getId(), 0L)
);
}

private ChatParticipant findPartner(ChatRoom chatRoom, long siteUserId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ public interface SiteUserRepository extends JpaRepository<SiteUser, Long> {

@Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate")
List<SiteUser> findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate);

List<SiteUser> findAllByIdIn(List<Long> ids);
}
Loading