Skip to content
Open
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
53 changes: 53 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 성능 개선

## 🚀 개선 내용

- 개선 대상:
- 변경 내용:

## 🔍 문제 및 원인

- 확인된 문제:
- 원인:

## 📊 개선 결과

| 지표 | 개선 전 | 개선 후 |
| ---------- | ------: | ------: |
| TPS / RPS | | |
| Avg | | |
| P95 | | |
| P99 | | |
| Error Rate | | |

## ✅ 추가 확인

- 추가 테스트 또는 확인이 필요한 부분:

---

# 테스트

## 🧪 테스트 내용

- 테스트 대상:
- 테스트 목적:

## ⚙️ 테스트 조건

- VU:
- 테스트 시간:
- 요청 수 / 요청 빈도:

## 📊 테스트 결과

- TPS / RPS:
- Avg:
- P95:
- P99:
- Error Rate:

## 🔍 확인된 문제

- 특이사항 또는 확인된 병목:
- 추가 확인이 필요한 부분:
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules/
**/node_modules/

**/.next/

# 환경 변수
.env
.env.*
!.env.example

# Java / Maven build
**/target/
Binary file added apps/backend/.mvn/wrapper/maven-wrapper.jar
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import com.ktb.chatapp.model.Message;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
Expand All @@ -19,6 +22,15 @@ public interface MessageRepository extends MongoRepository<Message, String> {
@Query(value = "{ 'room': ?0, 'timestamp': { $gte: ?1 } }", count = true)
long countRecentMessagesByRoomId(String roomId, LocalDateTime since);

@Aggregation(pipeline = {
"{ '$match': { 'room': { '$in': ?0 }, 'timestamp': { '$gte': ?1 } } }",
"{ '$group': { '_id': '$room', 'count': { '$sum': 1 } } }",
"{ '$project': { '_id': 0, 'roomKey': '$_id', 'count': 1 } }"
})
List<RecentMessageCount> countRecentMessagesByRoomIds(
Set<String> roomIds,
LocalDateTime since);

/**
* fileId로 메시지 조회 (파일 권한 검증용)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.ktb.chatapp.repository;

public record RecentMessageCount(String roomKey, long count) {
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.ktb.chatapp.service;

import com.ktb.chatapp.repository.MessageRepository;
import com.ktb.chatapp.repository.RecentMessageCount;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -21,4 +25,16 @@ public int countRecentMessages(String roomId) {
LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW);
return (int) messageRepository.countRecentMessagesByRoomId(roomId, since);
}

public Map<String, Integer> countRecentMessagesByRoomIds(Set<String> roomIds) {
if (roomIds.isEmpty()) {
return Map.of();
}

LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW);
return messageRepository.countRecentMessagesByRoomIds(roomIds, since).stream()
.collect(Collectors.toMap(
RecentMessageCount::roomKey,
count -> Math.toIntExact(count.count())));
}
}
90 changes: 74 additions & 16 deletions apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -35,27 +37,39 @@ public RoomsResponse getAllRooms(String name) {

try {
// 전체 방을 조회해 최신순으로 정렬한다
List<RoomResponse> roomResponses = roomRepository.findAll().stream()
.map(room -> mapToRoomResponse(room, name))
List<Room> rooms = roomRepository.findAll();
if (rooms.isEmpty()) {
return successfulRoomsResponse(List.of());
}

Set<String> userIds = new HashSet<>();
Set<String> roomIds = new HashSet<>();
for (Room room : rooms) {
if (room.getId() != null) {
roomIds.add(room.getId());
}
if (room.getCreator() != null) {
userIds.add(room.getCreator());
}
if (room.getParticipantIds() != null) {
userIds.addAll(room.getParticipantIds());
}
}

Map<String, User> usersById = new HashMap<>();
userRepository.findAllById(userIds)
.forEach(user -> usersById.put(user.getId(), user));
Map<String, Integer> recentMessageCounts =
recentMessageCounter.countRecentMessagesByRoomIds(roomIds);

List<RoomResponse> roomResponses = rooms.stream()
.map(room -> mapToRoomResponse(room, name, usersById, recentMessageCounts))
.sorted(Comparator.comparing(
RoomResponse::getCreatedAtDateTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());

PageMetadata metadata = PageMetadata.builder()
.total(roomResponses.size())
.page(0)
.pageSize(roomResponses.size())
.totalPages(1)
.hasMore(false)
.currentCount(roomResponses.size())
.build();

return RoomsResponse.builder()
.success(true)
.data(roomResponses)
.metadata(metadata)
.build();
return successfulRoomsResponse(roomResponses);

} catch (Exception e) {
log.error("방 목록 조회 에러", e);
Expand Down Expand Up @@ -194,6 +208,33 @@ private RoomResponse mapToRoomResponse(Room room, String name) {

int recentMessageCount = recentMessageCounter.countRecentMessages(room.getId());

return buildRoomResponse(room, name, creator, participants, recentMessageCount);
}

private RoomResponse mapToRoomResponse(
Room room,
String name,
Map<String, User> usersById,
Map<String, Integer> recentMessageCounts) {
User creator = usersById.get(room.getCreator());
List<User> participants = room.getParticipantIds() == null
? List.of()
: room.getParticipantIds().stream()
.map(usersById::get)
.filter(java.util.Objects::nonNull)
.toList();
int recentMessageCount = recentMessageCounts.getOrDefault(room.getId(), 0);

return buildRoomResponse(room, name, creator, participants, recentMessageCount);
}

private RoomResponse buildRoomResponse(
Room room,
String name,
User creator,
List<User> participants,
int recentMessageCount) {

return RoomResponse.builder()
.id(room.getId())
.name(room.getName() != null ? room.getName() : "제목 없음")
Expand All @@ -216,4 +257,21 @@ private RoomResponse mapToRoomResponse(Room room, String name) {
.recentMessageCount(recentMessageCount)
.build();
}

private RoomsResponse successfulRoomsResponse(List<RoomResponse> roomResponses) {
PageMetadata metadata = PageMetadata.builder()
.total(roomResponses.size())
.page(0)
.pageSize(roomResponses.size())
.totalPages(1)
.hasMore(false)
.currentCount(roomResponses.size())
.build();

return RoomsResponse.builder()
.success(true)
.data(roomResponses)
.metadata(metadata)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.ktb.chatapp.repository;

import static org.assertj.core.api.Assertions.assertThat;

import com.ktb.chatapp.config.MongoTestContainer;
import com.ktb.chatapp.config.RedisTestContainer;
import com.ktb.chatapp.model.Message;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest
@Import({MongoTestContainer.class, RedisTestContainer.class})
@TestPropertySource(properties = {
"spring.data.mongodb.auto-index-creation=true",
"socketio.enabled=false"
})
@DisplayName("MessageRepository 통합 테스트")
class MessageRepositoryIntegrationTest {

@Autowired private MessageRepository messageRepository;

@AfterEach
void tearDown() {
messageRepository.deleteAll();
}

@Test
@DisplayName("여러 채팅방의 최근 메시지 수를 한 번의 집계로 반환한다")
void countRecentMessagesByRoomIds_GroupsRecentMessagesByRequestedRoom() {
LocalDateTime now = LocalDateTime.now();
saveMessage("room-1", now.minusMinutes(10));
saveMessage("room-1", now.minusMinutes(20));
saveMessage("room-1", now.minusMinutes(40));
saveMessage("room-2", now.minusMinutes(5));
saveMessage("room-3", now.minusMinutes(5));

List<RecentMessageCount> result =
messageRepository.countRecentMessagesByRoomIds(
Set.of("room-1", "room-2"),
now.minusMinutes(30));

assertThat(result)
.extracting(RecentMessageCount::roomKey, RecentMessageCount::count)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple("room-1", 2L),
org.assertj.core.groups.Tuple.tuple("room-2", 1L));
}

private void saveMessage(String roomId, LocalDateTime timestamp) {
Message saved = messageRepository.save(Message.builder()
.roomId(roomId)
.content("메시지")
.build());
saved.setTimestamp(timestamp);
messageRepository.save(saved);
}
}
Loading