Skip to content
29 changes: 29 additions & 0 deletions src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@
import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest;
import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse;
import gg.agit.konect.domain.chat.dto.ChatRoomResponse;
import gg.agit.konect.domain.chat.dto.ChatSearchResponse;
import gg.agit.konect.domain.chat.enums.ChatInviteSortBy;
import gg.agit.konect.global.auth.annotation.UserId;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Tag(name = "(Normal) Chat: 채팅", description = "채팅 API")
@RequestMapping("/chats")
public interface ChatApi {

int MAX_SEARCH_LIMIT = 100;

@Operation(summary = "채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """
## 설명
- 특정 유저와의 1:1 채팅방을 생성하거나 기존 채팅방을 반환합니다.
Expand Down Expand Up @@ -79,6 +84,30 @@ ResponseEntity<ChatRoomsSummaryResponse> getChatRooms(
@UserId Integer userId
);

@Operation(summary = "채팅방 이름과 메시지 내용으로 채팅방을 검색한다.", description = """
## 설명
- 현재 사용자가 접근 가능한 채팅방만 검색합니다.
- 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다.

## 로직
- 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다.
- 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다.
- 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다.
- page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다.
- limit는 최대 100까지 허용됩니다.
""")
@GetMapping("/rooms/search")
ResponseEntity<ChatSearchResponse> searchChats(
@NotBlank(message = "검색어는 필수입니다.")
@RequestParam(name = "keyword") String keyword,
@Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.")
@RequestParam(name = "page", defaultValue = "1") Integer page,
@Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.")
@Max(value = MAX_SEARCH_LIMIT, message = "페이지 당 항목 수는 100 이하여야 합니다.")
@RequestParam(name = "limit", defaultValue = "20") Integer limit,
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

[LEVEL: high] limit 상한이 없어 과도한 검색 요청을 허용합니다.
문제: GET /chats/rooms/search?limit=100000처럼 큰 값을 전달해도 현재 시그니처에서 제한되지 않습니다.
영향: 이 API는 방 이름 검색과 메시지 검색을 함께 수행하므로 대용량 페이지 요청이 DB/애플리케이션 부하를 급격히 높여 운영 장애로 이어질 수 있습니다.
제안: limit에 서버 공통 최대값(예: 100)을 강제하고, 초과 값은 400으로 거절하도록 계약과 구현을 같이 보강해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java` around
lines 96 - 97, Add a server-enforced upper bound for the pagination parameter to
prevent excessive load: define a constant (e.g., MAX_LIMIT = 100) in ChatApi and
annotate the existing limit parameter with `@Max`(MAX_LIMIT) in addition to the
existing `@Min`, and inside the controller handler (the method in class ChatApi
that accepts the Integer limit parameter for the rooms/messages search) validate
at runtime that limit <= MAX_LIMIT and, if not, respond with HTTP 400 (e.g.,
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "limit must be <= 100")).
Ensure the API contract/commentation is updated to state the new maximum and
keep defaultValue "20" unchanged.

@UserId Integer userId
);

@Operation(summary = "새 채팅방에 초대할 수 있는 사용자 목록을 조회한다.", description = """
## 설명
- 현재 사용자가 속해 있는 채팅방들의 멤버를 기반으로 초대 가능 사용자 목록을 조회합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest;
import gg.agit.konect.domain.chat.dto.ChatRoomResponse;
import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse;
import gg.agit.konect.domain.chat.dto.ChatSearchResponse;
import gg.agit.konect.domain.chat.enums.ChatInviteSortBy;
import gg.agit.konect.domain.chat.service.ChatService;
import gg.agit.konect.global.auth.annotation.UserId;
Expand Down Expand Up @@ -56,6 +57,17 @@ public ResponseEntity<ChatRoomsSummaryResponse> getChatRooms(
return ResponseEntity.ok(response);
}

@Override
public ResponseEntity<ChatSearchResponse> searchChats(
@RequestParam(name = "keyword") String keyword,
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "limit", defaultValue = "20") Integer limit,
@UserId Integer userId
) {
ChatSearchResponse response = chatService.searchChats(userId, keyword, page, limit);
return ResponseEntity.ok(response);
}

@Override
public ResponseEntity<ChatInvitableUsersResponse> getInvitableUsers(
@RequestParam(name = "query", required = false) String query,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package gg.agit.konect.domain.chat.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.time.LocalDateTime;

import com.fasterxml.jackson.annotation.JsonFormat;

import gg.agit.konect.domain.chat.enums.ChatType;
import gg.agit.konect.domain.chat.model.ChatMessage;
import io.swagger.v3.oas.annotations.media.Schema;

public record ChatMessageMatchResult(
@Schema(description = "채팅방 ID", example = "1", requiredMode = REQUIRED)
Integer roomId,

@Schema(description = "채팅 타입", example = "DIRECT", requiredMode = REQUIRED)
ChatType chatType,

@Schema(description = "채팅방 이름", example = "개발팀", requiredMode = REQUIRED)
String roomName,

@Schema(description = "채팅방 이미지 URL", example = "https://example.com/image.png", requiredMode = NOT_REQUIRED)
String roomImageUrl,

@Schema(description = "검색에 매칭된 메시지 내용", example = "안녕하세요", requiredMode = REQUIRED)
String matchedMessage,

@Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED)
@JsonFormat(pattern = "yyyy.MM.dd HH:mm")
LocalDateTime matchedMessageSentAt
) {

public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) {
return new ChatMessageMatchResult(
room.roomId(),
room.chatType(),
room.roomName(),
room.roomImageUrl(),
message.getContent(),
message.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package gg.agit.konect.domain.chat.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.util.List;

import org.springframework.data.domain.Page;

import io.swagger.v3.oas.annotations.media.Schema;

public record ChatMessageMatchesResponse(
@Schema(description = "조건에 해당하는 메시지 매칭 총 개수", example = "10", requiredMode = REQUIRED)
Long totalCount,

@Schema(description = "현재 페이지에서 조회된 메시지 매칭 개수", example = "5", requiredMode = REQUIRED)
Integer currentCount,

@Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED)
Integer totalPage,

@Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED)
Integer currentPage,

@Schema(description = "메시지 내용으로 매칭된 채팅방 목록", requiredMode = REQUIRED)
List<ChatMessageMatchResult> messages
) {

public static ChatMessageMatchesResponse from(Page<ChatMessageMatchResult> page) {
return new ChatMessageMatchesResponse(
page.getTotalElements(),
page.getNumberOfElements(),
page.getTotalPages(),
page.getNumber() + 1,
page.getContent()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package gg.agit.konect.domain.chat.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.util.List;

import org.springframework.data.domain.Page;

import io.swagger.v3.oas.annotations.media.Schema;

public record ChatRoomMatchesResponse(
@Schema(description = "조건에 해당하는 채팅방 총 개수", example = "10", requiredMode = REQUIRED)
Long totalCount,

@Schema(description = "현재 페이지에서 조회된 채팅방 개수", example = "5", requiredMode = REQUIRED)
Integer currentCount,

@Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED)
Integer totalPage,

@Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED)
Integer currentPage,

@Schema(description = "채팅방 이름으로 매칭된 채팅방 목록", requiredMode = REQUIRED)
List<ChatRoomSummaryResponse> rooms
) {

public static ChatRoomMatchesResponse from(Page<ChatRoomSummaryResponse> page) {
return new ChatRoomMatchesResponse(
page.getTotalElements(),
page.getNumberOfElements(),
page.getTotalPages(),
page.getNumber() + 1,
page.getContent()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package gg.agit.konect.domain.chat.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;

public record ChatSearchResponse(
@Schema(description = "채팅방 이름으로 매칭된 검색 결과", requiredMode = REQUIRED)
ChatRoomMatchesResponse roomMatches,

@Schema(description = "메시지 내용으로 매칭된 검색 결과", requiredMode = REQUIRED)
ChatMessageMatchesResponse messageMatches
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,31 @@ SELECT MAX(m2.id)
""")
List<ChatMessage> findLatestMessagesByRoomIds(@Param("roomIds") List<Integer> roomIds);

@Query(
value = """
SELECT cm
FROM ChatMessage cm
JOIN FETCH cm.chatRoom cr
WHERE cr.id IN :roomIds
AND LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0
AND cm.id = (
SELECT MAX(innerCm.id)
FROM ChatMessage innerCm
WHERE innerCm.chatRoom.id = cr.id
AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0
)
ORDER BY cm.createdAt DESC, cm.id DESC
"""
)
List<ChatMessage> searchLatestMatchingMessagesByChatRoomIds(
@Param("roomIds") List<Integer> roomIds,
@Param("keyword") String keyword
);
Comment on lines +122 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 메시지 테이블에 content 검색을 위한 인덱스가 있는지 확인
rg -n "chat_message" --type sql -A 5 -B 2
fd -e sql | xargs grep -l "chat_message" | head -5 | xargs cat 2>/dev/null | head -100

Repository: BCSDLab/KONECT_BACK_END

Length of output: 17391


🏁 Script executed:

fd -e sql src/main/resources/db/migration | sort -V | tail -10

Repository: BCSDLab/KONECT_BACK_END

Length of output: 496


🏁 Script executed:

fd . src/main/resources/db/migration | sort -V | tail -15

Repository: BCSDLab/KONECT_BACK_END

Length of output: 1178


🏁 Script executed:

cat src/main/resources/db/migration/V62__add_performance_indexes.sql

Repository: BCSDLab/KONECT_BACK_END

Length of output: 442


🏁 Script executed:

cd src/main/resources/db/migration && cat V1__init.sql | grep -A 15 "CREATE TABLE IF NOT EXISTS chat_message"

Repository: BCSDLab/KONECT_BACK_END

Length of output: 936


🏁 Script executed:

rg "searchLatestMatchingMessagesByChatRoomIds" -A 30 src/main/java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 4739


🏁 Script executed:

cat src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java | head -150 | tail -50

Repository: BCSDLab/KONECT_BACK_END

Length of output: 1644


LIKE 검색 성능 최적화 검토 권고

메시지 콘텐츠의 전문 검색이 필요한 경우, 현재 LIKE '%keyword%' 패턴 대신 MySQL FULLTEXT 인덱스 도입을 검토하세요. 대규모 메시지 데이터셋에서 키워드 검색 응답 속도를 개선할 수 있습니다.

[LEVEL: low]

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java`
around lines 105 - 131, The JPQL query in
searchLatestMatchingMessagesByChatRoomIds uses LOWER(cm.content) LIKE
LOWER(CONCAT('%', :keyword, '%')) which will be slow on large message tables;
replace this pattern with a full-text search strategy: add a FULLTEXT index on
ChatMessage.content at the DB level and change the repository query to use a
native query (or repository method) that leverages MATCH(cm.content)
AGAINST(:keyword IN BOOLEAN MODE) (or equivalent for your MySQL version),
updating ChatMessage repository method searchLatestMatchingMessagesByChatRoomIds
to a native query and adjusting the countQuery accordingly so the DB uses the
fulltext index instead of leading wildcards.


@Query("""
SELECT COUNT(m)
FROM ChatMessage m
WHERE m.chatRoom.id = :chatRoomId
""")
long countByChatRoomId(@Param("chatRoomId") Integer chatRoomId);
}
Loading
Loading