From af3cc22d35540294fbfda4d188491cec84ece470 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Mon, 10 Aug 2026 13:41:23 +0900 Subject: [PATCH 01/56] =?UTF-8?q?chore:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/performance.md | 23 +++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE/test.md | 23 +++++++++++++++++++ .gitignore | 12 ++++++++++ apps/backend/.mvn/wrapper/maven-wrapper.jar | Bin 0 -> 59925 bytes 4 files changed, 58 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE/performance.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/test.md create mode 100644 .gitignore create mode 100644 apps/backend/.mvn/wrapper/maven-wrapper.jar diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/PULL_REQUEST_TEMPLATE/performance.md new file mode 100644 index 00000000..836381a1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/performance.md @@ -0,0 +1,23 @@ +## ๐Ÿš€ ๊ฐœ์„  ๋‚ด์šฉ + +- ๊ฐœ์„  ๋Œ€์ƒ: +- ๋ณ€๊ฒฝ ๋‚ด์šฉ: + +## ๐Ÿ” ๋ฌธ์ œ ๋ฐ ์›์ธ + +- ํ™•์ธ๋œ ๋ฌธ์ œ: +- ์›์ธ: + +## ๐Ÿ“Š ๊ฐœ์„  ๊ฒฐ๊ณผ + +| ์ง€ํ‘œ | ๊ฐœ์„  ์ „ | ๊ฐœ์„  ํ›„ | +| ---------- | ------: | ------: | +| TPS / RPS | | | +| Avg | | | +| P95 | | | +| P99 | | | +| Error Rate | | | + +## โœ… ์ถ”๊ฐ€ ํ™•์ธ + +- ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ ๋˜๋Š” ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: diff --git a/.github/PULL_REQUEST_TEMPLATE/test.md b/.github/PULL_REQUEST_TEMPLATE/test.md new file mode 100644 index 00000000..efbc2be5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/test.md @@ -0,0 +1,23 @@ +## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋‚ด์šฉ + +- ํ…Œ์ŠคํŠธ ๋Œ€์ƒ: +- ํ…Œ์ŠคํŠธ ๋ชฉ์ : + +## โš™๏ธ ํ…Œ์ŠคํŠธ ์กฐ๊ฑด + +- VU: +- ํ…Œ์ŠคํŠธ ์‹œ๊ฐ„: +- ์š”์ฒญ ์ˆ˜ / ์š”์ฒญ ๋นˆ๋„: + +## ๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ + +- TPS / RPS: +- Avg: +- P95: +- P99: +- Error Rate: + +## ๐Ÿ” ํ™•์ธ๋œ ๋ฌธ์ œ + +- ํŠน์ด์‚ฌํ•ญ ๋˜๋Š” ํ™•์ธ๋œ ๋ณ‘๋ชฉ: +- ์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a4570e15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +**/node_modules/ + +**/.next/ + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +.env +.env.* +!.env.example + +# Java / Maven build +**/target/ \ No newline at end of file diff --git a/apps/backend/.mvn/wrapper/maven-wrapper.jar b/apps/backend/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..bf82ff01c6cdae4a1bb754a6e062954d77ac5c11 GIT binary patch literal 59925 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

dy!05UMhSKsK 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 VCNKd3JGtOvEm(5t0lFyE{{i--k}m)N literal 0 HcmV?d00001 From 3e6d44f17fd477e50aba62f77420addd79f0ad2a Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Mon, 10 Aug 2026 13:44:29 +0900 Subject: [PATCH 02/56] =?UTF-8?q?chore:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=202=EA=B0=9C(test,=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0)?= =?UTF-8?q?=20=ED=95=A9=EC=B9=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/test.md | 23 ---------------- ...erformance.md => pull_request_template.md} | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE/test.md rename .github/{PULL_REQUEST_TEMPLATE/performance.md => pull_request_template.md} (58%) diff --git a/.github/PULL_REQUEST_TEMPLATE/test.md b/.github/PULL_REQUEST_TEMPLATE/test.md deleted file mode 100644 index efbc2be5..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/test.md +++ /dev/null @@ -1,23 +0,0 @@ -## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋‚ด์šฉ - -- ํ…Œ์ŠคํŠธ ๋Œ€์ƒ: -- ํ…Œ์ŠคํŠธ ๋ชฉ์ : - -## โš™๏ธ ํ…Œ์ŠคํŠธ ์กฐ๊ฑด - -- VU: -- ํ…Œ์ŠคํŠธ ์‹œ๊ฐ„: -- ์š”์ฒญ ์ˆ˜ / ์š”์ฒญ ๋นˆ๋„: - -## ๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ - -- TPS / RPS: -- Avg: -- P95: -- P99: -- Error Rate: - -## ๐Ÿ” ํ™•์ธ๋œ ๋ฌธ์ œ - -- ํŠน์ด์‚ฌํ•ญ ๋˜๋Š” ํ™•์ธ๋œ ๋ณ‘๋ชฉ: -- ์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/pull_request_template.md similarity index 58% rename from .github/PULL_REQUEST_TEMPLATE/performance.md rename to .github/pull_request_template.md index 836381a1..842ce73d 100644 --- a/.github/PULL_REQUEST_TEMPLATE/performance.md +++ b/.github/pull_request_template.md @@ -21,3 +21,29 @@ ## โœ… ์ถ”๊ฐ€ ํ™•์ธ - ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ ๋˜๋Š” ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: + +--- + +## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋‚ด์šฉ + +- ํ…Œ์ŠคํŠธ ๋Œ€์ƒ: +- ํ…Œ์ŠคํŠธ ๋ชฉ์ : + +## โš™๏ธ ํ…Œ์ŠคํŠธ ์กฐ๊ฑด + +- VU: +- ํ…Œ์ŠคํŠธ ์‹œ๊ฐ„: +- ์š”์ฒญ ์ˆ˜ / ์š”์ฒญ ๋นˆ๋„: + +## ๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ + +- TPS / RPS: +- Avg: +- P95: +- P99: +- Error Rate: + +## ๐Ÿ” ํ™•์ธ๋œ ๋ฌธ์ œ + +- ํŠน์ด์‚ฌํ•ญ ๋˜๋Š” ํ™•์ธ๋œ ๋ณ‘๋ชฉ: +- ์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„: From f1fea5c5290894a1cd58cdf2e10c5fd7a0668b94 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Mon, 10 Aug 2026 13:46:13 +0900 Subject: [PATCH 03/56] =?UTF-8?q?chore:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 842ce73d..03640e68 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ +# ์„ฑ๋Šฅ ๊ฐœ์„  + ## ๐Ÿš€ ๊ฐœ์„  ๋‚ด์šฉ - ๊ฐœ์„  ๋Œ€์ƒ: @@ -24,6 +26,8 @@ --- +# ํ…Œ์ŠคํŠธ + ## ๐Ÿงช ํ…Œ์ŠคํŠธ ๋‚ด์šฉ - ํ…Œ์ŠคํŠธ ๋Œ€์ƒ: From 5c2227217eb4e64a0ac49e4048d372b22ac9e586 Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Mon, 10 Aug 2026 20:04:45 +0900 Subject: [PATCH 04/56] =?UTF-8?q?perf:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20N+1=20=EC=A1=B0=ED=9A=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatapp/repository/MessageRepository.java | 12 ++ .../repository/RecentMessageCount.java | 4 + .../chatapp/service/RecentMessageCounter.java | 16 ++ .../com/ktb/chatapp/service/RoomService.java | 90 ++++++++-- .../MessageRepositoryIntegrationTest.java | 65 ++++++++ .../service/RecentMessageCounterTest.java | 78 +++++++++ .../ktb/chatapp/service/RoomServiceTest.java | 154 ++++++++++++++++++ 7 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java index 932ce8e8..0080a53c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/MessageRepository.java @@ -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; @@ -19,6 +22,15 @@ public interface MessageRepository extends MongoRepository { @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 countRecentMessagesByRoomIds( + Set roomIds, + LocalDateTime since); + /** * fileId๋กœ ๋ฉ”์‹œ์ง€ ์กฐํšŒ (ํŒŒ์ผ ๊ถŒํ•œ ๊ฒ€์ฆ์šฉ) */ diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java new file mode 100644 index 00000000..5b60eaf4 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RecentMessageCount.java @@ -0,0 +1,4 @@ +package com.ktb.chatapp.repository; + +public record RecentMessageCount(String roomKey, long count) { +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java index 39a1fd00..e54daf56 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RecentMessageCounter.java @@ -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; @@ -21,4 +25,16 @@ public int countRecentMessages(String roomId) { LocalDateTime since = LocalDateTime.now().minus(RECENT_WINDOW); return (int) messageRepository.countRecentMessagesByRoomId(roomId, since); } + + public Map countRecentMessagesByRoomIds(Set 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()))); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java index 5f5ecf40..9323ab2a 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java @@ -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; @@ -35,27 +37,39 @@ public RoomsResponse getAllRooms(String name) { try { // ์ „์ฒด ๋ฐฉ์„ ์กฐํšŒํ•ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค - List roomResponses = roomRepository.findAll().stream() - .map(room -> mapToRoomResponse(room, name)) + List rooms = roomRepository.findAll(); + if (rooms.isEmpty()) { + return successfulRoomsResponse(List.of()); + } + + Set userIds = new HashSet<>(); + Set 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 usersById = new HashMap<>(); + userRepository.findAllById(userIds) + .forEach(user -> usersById.put(user.getId(), user)); + Map recentMessageCounts = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + List 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); @@ -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 usersById, + Map recentMessageCounts) { + User creator = usersById.get(room.getCreator()); + List 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 participants, + int recentMessageCount) { + return RoomResponse.builder() .id(room.getId()) .name(room.getName() != null ? room.getName() : "์ œ๋ชฉ ์—†์Œ") @@ -216,4 +257,21 @@ private RoomResponse mapToRoomResponse(Room room, String name) { .recentMessageCount(recentMessageCount) .build(); } + + private RoomsResponse successfulRoomsResponse(List 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(); + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java new file mode 100644 index 00000000..1f645fbb --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java @@ -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 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); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java new file mode 100644 index 00000000..2968d334 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java @@ -0,0 +1,78 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.repository.RecentMessageCount; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecentMessageCounter ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class RecentMessageCounterTest { + + @Mock private MessageRepository messageRepository; + + private RecentMessageCounter recentMessageCounter; + + @BeforeEach + void setUp() { + recentMessageCounter = new RecentMessageCounter(messageRepository); + } + + @Test + @DisplayName("์—ฌ๋Ÿฌ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒํ•ด Map์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค") + void countRecentMessagesByRoomIds_LoadsCountsOnceAndMapsByRoomId() { + Set roomIds = Set.of("room-1", "room-2"); + when(messageRepository.countRecentMessagesByRoomIds( + eq(roomIds), any(LocalDateTime.class))) + .thenReturn(List.of( + new RecentMessageCount("room-1", 3), + new RecentMessageCount("room-2", 5))); + + Map result = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + assertThat(result).containsExactlyInAnyOrderEntriesOf( + Map.of("room-1", 3, "room-2", 5)); + verify(messageRepository).countRecentMessagesByRoomIds( + eq(roomIds), any(LocalDateTime.class)); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ ID๊ฐ€ ์—†์œผ๋ฉด ๋ฉ”์‹œ์ง€๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void countRecentMessagesByRoomIds_EmptyRoomIdsSkipsRepository() { + Map result = + recentMessageCounter.countRecentMessagesByRoomIds(Set.of()); + + assertThat(result).isEmpty(); + verifyNoInteractions(messageRepository); + } + + @Test + @DisplayName("๋‹จ๊ฑด ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์กฐํšŒ ๋™์ž‘์„ ์œ ์ง€ํ•œ๋‹ค") + void countRecentMessages_KeepsSingleRoomQuery() { + when(messageRepository.countRecentMessagesByRoomId( + eq("room-1"), any(LocalDateTime.class))) + .thenReturn(7L); + + int result = recentMessageCounter.countRecentMessages("room-1"); + + assertThat(result).isEqualTo(7); + verify(messageRepository).countRecentMessagesByRoomId( + eq("room-1"), any(LocalDateTime.class)); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java new file mode 100644 index 00000000..fce0c1bc --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -0,0 +1,154 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomsResponse; +import com.ktb.chatapp.model.Room; +import com.ktb.chatapp.model.User; +import com.ktb.chatapp.repository.RoomRepository; +import com.ktb.chatapp.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class RoomServiceTest { + + @Mock private RoomRepository roomRepository; + @Mock private UserRepository userRepository; + @Mock private RecentMessageCounter recentMessageCounter; + @Mock private PasswordEncoder passwordEncoder; + @Mock private ApplicationEventPublisher eventPublisher; + + private RoomService roomService; + + @BeforeEach + void setUp() { + roomService = new RoomService( + roomRepository, + userRepository, + recentMessageCounter, + passwordEncoder, + eventPublisher); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์˜ ์ƒ์„ฑ์ž์™€ ์ฐธ์—ฌ์ž๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒํ•ด ์‘๋‹ต์„ ์กฐ๋ฆฝํ•œ๋‹ค") + void getAllRooms_LoadsDistinctUsersOnceAndMapsResponses() { + LocalDateTime older = LocalDateTime.of(2026, 8, 9, 10, 0); + LocalDateTime newer = older.plusHours(1); + Room olderRoom = room("room-1", "user-1", Set.of("user-1", "user-2"), older); + Room newerRoom = room("room-2", "user-2", Set.of("user-2", "user-3"), newer); + User user1 = user("user-1", "์‚ฌ์šฉ์ž 1"); + User user2 = user("user-2", "์‚ฌ์šฉ์ž 2"); + User user3 = user("user-3", "์‚ฌ์šฉ์ž 3"); + + when(roomRepository.findAll()).thenReturn(List.of(olderRoom, newerRoom)); + when(userRepository.findAllById(anySet())).thenReturn(List.of(user1, user2, user3)); + when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())) + .thenReturn(Map.of("room-1", 3, "room-2", 5)); + + RoomsResponse response = roomService.getAllRooms("user@example.com"); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).extracting(RoomResponse::getId) + .containsExactly("room-2", "room-1"); + assertThat(response.getData().get(0).getCreator().getId()).isEqualTo("user-2"); + assertThat(response.getData().get(0).getParticipants()) + .extracting(participant -> participant.getId()) + .containsExactlyInAnyOrder("user-2", "user-3"); + assertThat(response.getData().get(0).getRecentMessageCount()).isEqualTo(5); + assertThat(response.getMetadata().getTotal()).isEqualTo(2); + assertThat(response.getMetadata().getCurrentCount()).isEqualTo(2); + + @SuppressWarnings("unchecked") + ArgumentCaptor> userIdsCaptor = ArgumentCaptor.forClass(Iterable.class); + verify(userRepository).findAllById(userIdsCaptor.capture()); + assertThat(StreamSupport.stream(userIdsCaptor.getValue().spliterator(), false)) + .containsExactlyInAnyOrder("user-1", "user-2", "user-3"); + verify(userRepository, never()).findById(anyString()); + verify(recentMessageCounter, times(1)) + .countRecentMessagesByRoomIds(Set.of("room-1", "room-2")); + verify(recentMessageCounter, never()).countRecentMessages(anyString()); + } + + @Test + @DisplayName("์กฐํšŒ๋˜์ง€ ์•Š์€ ์ƒ์„ฑ์ž๋Š” null๋กœ ๋‘๊ณ  ์กฐํšŒ๋˜์ง€ ์•Š์€ ์ฐธ์—ฌ์ž๋Š” ์ œ์™ธํ•œ๋‹ค") + void getAllRooms_OmitsMissingParticipantsAndKeepsMissingCreatorNull() { + Room room = room("room-1", "missing-creator", Set.of("user-1", "missing-user"), + LocalDateTime.of(2026, 8, 10, 10, 0)); + when(roomRepository.findAll()).thenReturn(List.of(room)); + when(userRepository.findAllById(anySet())).thenReturn(List.of(user("user-1", "์‚ฌ์šฉ์ž 1"))); + when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())).thenReturn(Map.of()); + + RoomsResponse response = roomService.getAllRooms("user@example.com"); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).singleElement().satisfies(roomResponse -> { + assertThat(roomResponse.getCreator()).isNull(); + assertThat(roomResponse.getParticipants()) + .extracting(participant -> participant.getId()) + .containsExactly("user-1"); + assertThat(roomResponse.getRecentMessageCount()).isZero(); + }); + verify(userRepository).findAllById(anySet()); + verify(userRepository, never()).findById(anyString()); + verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1")); + verify(recentMessageCounter, never()).countRecentMessages(anyString()); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ์ด ์—†์œผ๋ฉด ์‚ฌ์šฉ์ž์™€ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void getAllRooms_EmptyRoomsSkipsUserAndMessageQueries() { + when(roomRepository.findAll()).thenReturn(List.of()); + + RoomsResponse response = roomService.getAllRooms("user@example.com"); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + assertThat(response.getMetadata().getTotal()).isZero(); + verifyNoInteractions(userRepository, recentMessageCounter); + } + + private Room room( + String id, + String creatorId, + Set participantIds, + LocalDateTime createdAt) { + return Room.builder() + .id(id) + .name("์ฑ„ํŒ…๋ฐฉ " + id) + .creator(creatorId) + .participantIds(participantIds) + .createdAt(createdAt) + .build(); + } + + private User user(String id, String name) { + return User.builder() + .id(id) + .name(name) + .email(id + "@example.com") + .build(); + } +} From a474eda4cd00bc9f7fe617af27c43eaf53ddab26 Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Mon, 10 Aug 2026 22:22:18 +0900 Subject: [PATCH 05/56] =?UTF-8?q?perf:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=EA=B2=BD=EB=A1=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatapp/controller/AuthController.java | 22 +++----- .../java/com/ktb/chatapp/model/Session.java | 6 +-- .../ktb/chatapp/service/SessionService.java | 5 +- .../service/session/SessionMongoStore.java | 25 +++++++++ .../chatapp/service/session/SessionStore.java | 8 +++ .../controller/AuthControllerTest.java | 13 ++++- .../chatapp/service/SessionServiceTest.java | 52 ++++++++++++++++++- .../service/SessionServiceUnitTest.java | 13 ++--- 8 files changed, 110 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java index e95a11ed..c7e045e7 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/AuthController.java @@ -20,6 +20,7 @@ import jakarta.validation.Valid; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -27,9 +28,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -44,7 +43,6 @@ @RequestMapping("/api/auth") public class AuthController { - private final AuthenticationManager authenticationManager; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; @@ -163,20 +161,12 @@ public ResponseEntity login( if (errors != null) return errors; try { - // Authenticate user - User user = userRepository.findByEmail(loginRequest.getEmail().toLowerCase()) + String normalizedEmail = loginRequest.getEmail().toLowerCase(Locale.ROOT); + User user = userRepository.findByEmail(normalizedEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - user.getEmail(), - loginRequest.getPassword() - ) - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - // ๋‹จ์ผ ์„ธ์…˜ ์ •์ฑ…์„ ์œ„ํ•ด ๊ธฐ์กด ์„ธ์…˜ ์ œ๊ฑฐ - sessionService.removeAllUserSessions(user.getId()); + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } // Create new session SessionMetadata metadata = new SessionMetadata( diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java index 47071863..eb6ceff5 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java @@ -7,8 +7,6 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.CompoundIndex; -import org.springframework.data.mongodb.core.index.CompoundIndexes; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -17,15 +15,13 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "sessions") -@CompoundIndexes({ - @CompoundIndex(name = "userId_sessionId_idx", def = "{'userId': 1, 'sessionId': 1}", unique = true) -}) public class Session { public static final String SESSION_TTL = "30m"; @Id private String id; + @Indexed(unique = true) private String userId; private String sessionId; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java index 4618267b..72e4fcc8 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java @@ -36,9 +36,6 @@ private SessionData toSessionData(Session session) { public SessionCreationResult createSession(String userId, SessionMetadata metadata) { try { - // Remove all existing user sessions - removeAllUserSessions(userId); - String sessionId = generateSessionId(); long now = Instant.now().toEpochMilli(); @@ -51,7 +48,7 @@ public SessionCreationResult createSession(String userId, SessionMetadata metada .expiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)) .build(); - session = sessionStore.save(session); + session = sessionStore.replaceByUserId(session); SessionData sessionData = toSessionData(session); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java index e2725daa..d5d0d479 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java @@ -4,6 +4,11 @@ import com.ktb.chatapp.repository.SessionRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Component; /** @@ -15,6 +20,7 @@ public class SessionMongoStore implements SessionStore { private final SessionRepository sessionRepository; + private final MongoTemplate mongoTemplate; @Override public Optional findByUserId(String userId) { @@ -25,6 +31,25 @@ public Optional findByUserId(String userId) { public Session save(Session session) { return sessionRepository.save(session); } + + @Override + public Session replaceByUserId(Session session) { + Query query = Query.query(Criteria.where("userId").is(session.getUserId())); + Update update = new Update() + .set("userId", session.getUserId()) + .set("sessionId", session.getSessionId()) + .set("createdAt", session.getCreatedAt()) + .set("lastActivity", session.getLastActivity()) + .set("metadata", session.getMetadata()) + .set("expiresAt", session.getExpiresAt()); + + return mongoTemplate.findAndModify( + query, + update, + FindAndModifyOptions.options().upsert(true).returnNew(true), + Session.class + ); + } @Override public void delete(String userId, String sessionId) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java index 49870236..b3e3d666 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java @@ -24,6 +24,14 @@ public interface SessionStore { * @return the saved session */ Session save(Session session); + + /** + * Atomically replace or create the session identified by user ID. + * + * @param session the new active session + * @return the stored session + */ + Session replaceByUserId(Session session); /** * Delete all sessions for a user diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java index 49d4e4a3..633cadce 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java @@ -4,6 +4,7 @@ import com.ktb.chatapp.config.MongoTestContainer; import com.ktb.chatapp.dto.LoginRequest; import com.ktb.chatapp.dto.RegisterRequest; +import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.SessionCreationResult; import com.ktb.chatapp.service.SessionMetadata; import com.ktb.chatapp.service.SessionService; @@ -16,9 +17,13 @@ import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -40,6 +45,9 @@ public class AuthControllerTest { @MockitoBean private SessionService sessionService; + @MockitoSpyBean + private UserRepository userRepository; + @Test @WithAnonymousUser public void testRegisterUser() throws Exception { @@ -106,7 +114,8 @@ public void testAuthenticateUser() throws Exception { .content(objectMapper.writeValueAsString(registerRequest))) .andExpect(status().isCreated()); - LoginRequest loginRequest = new LoginRequest(email, "password"); + clearInvocations(userRepository); + LoginRequest loginRequest = new LoginRequest(email.toUpperCase(), "password"); mockMvc.perform(post("/api/auth/login") .with(csrf()) @@ -114,5 +123,7 @@ public void testAuthenticateUser() throws Exception { .content(objectMapper.writeValueAsString(loginRequest))) .andExpect(status().isOk()) .andExpect(jsonPath("$.token").exists()); + + verify(userRepository, times(1)).findByEmail(email); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java index 1dea3427..41758ec7 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java @@ -1,12 +1,21 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.model.Session; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; 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.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.test.context.TestPropertySource; import static org.assertj.core.api.Assertions.assertThat; @@ -28,6 +37,9 @@ class SessionServiceTest { @Autowired private SessionService sessionService; + @Autowired + private MongoTemplate mongoTemplate; + private static final String TEST_USER_ID = "test-user-123"; private static final String TEST_USER_ID_2 = "test-user-456"; private static final String TEST_IP = "127.0.0.1"; @@ -70,8 +82,8 @@ void createSession_Success() { } @Test - @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์‹œ ๊ธฐ์กด ์„ธ์…˜ ์ œ๊ฑฐ") - void createSession_RemovesExistingSession() { + @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์‹œ ๊ธฐ์กด ์„ธ์…˜ ๊ต์ฒด") + void createSession_ReplacesExistingSession() { // Given - ์ฒซ ๋ฒˆ์งธ ์„ธ์…˜ ์ƒ์„ฑ SessionMetadata metadata = createTestMetadata(); SessionCreationResult firstSession = sessionService.createSession(TEST_USER_ID, metadata); @@ -300,6 +312,42 @@ void getActiveSession_NoSession_ReturnsNull() { // ============ ๋™์‹œ์„ฑ ๋ฐ ๋ฉ€ํ‹ฐ ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ ============ + @Test + @DisplayName("๋™์ผ ์‚ฌ์šฉ์ž์˜ ๋™์‹œ ์„ธ์…˜ ์ƒ์„ฑ์€ ํ•œ ๋ฌธ์„œ๋งŒ ์œ ์ง€ํ•œ๋‹ค") + void createSession_ConcurrentSameUser_KeepsSingleDocument() throws Exception { + int attempts = 20; + ExecutorService executor = Executors.newFixedThreadPool(attempts); + List> tasks = java.util.stream.IntStream.range(0, attempts) + .mapToObj(index -> (Callable) () -> + sessionService.createSession(TEST_USER_ID, createTestMetadata())) + .toList(); + + try { + List> futures = executor.invokeAll(tasks); + List issuedSessionIds = futures.stream() + .map(future -> { + try { + return future.get().getSessionId(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .toList(); + + long storedSessions = mongoTemplate.count( + Query.query(Criteria.where("userId").is(TEST_USER_ID)), + Session.class + ); + SessionData activeSession = sessionService.getActiveSession(TEST_USER_ID); + + assertEquals(1L, storedSessions); + assertNotNull(activeSession); + assertTrue(issuedSessionIds.contains(activeSession.getSessionId())); + } finally { + executor.shutdownNow(); + } + } + @Test @DisplayName("์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž์˜ ๋…๋ฆฝ์ ์ธ ์„ธ์…˜ ๊ด€๋ฆฌ") void multipleSessions_IndependentUsers() { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java index 55901111..20c7a03c 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java @@ -35,17 +35,17 @@ class SessionServiceUnitTest { private SessionService sessionService; @Test - @DisplayName("์„ธ์…˜ ์ƒ์„ฑ์€ ๊ธฐ์กด ์‚ฌ์šฉ์ž ์„ธ์…˜์„ ์ œ๊ฑฐํ•œ ๋’ค ์ƒˆ ์„ธ์…˜์„ ์ €์žฅํ•œ๋‹ค") - void createSession_RemovesExistingSessionsBeforeSave() { + @DisplayName("์„ธ์…˜ ์ƒ์„ฑ์€ ์‚ฌ์šฉ์ž ๊ธฐ์ค€์œผ๋กœ ์›์ž์  ๊ต์ฒดํ•œ๋‹ค") + void createSession_AtomicallyReplacesSessionByUserId() { ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(Session.class); - when(sessionStore.save(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(sessionStore.replaceByUserId(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0)); SessionCreationResult result = sessionService.createSession( USER_ID, new SessionMetadata("agent", "127.0.0.1", "device")); - verify(sessionStore).deleteAll(USER_ID); - verify(sessionStore).save(sessionCaptor.capture()); + verify(sessionStore, never()).deleteAll(USER_ID); + verify(sessionStore).replaceByUserId(sessionCaptor.capture()); Session savedSession = sessionCaptor.getValue(); assertThat(result.getSessionId()).isEqualTo(savedSession.getSessionId()); assertThat(result.getExpiresIn()).isEqualTo(SessionService.SESSION_TTL_SEC); @@ -56,7 +56,7 @@ void createSession_RemovesExistingSessionsBeforeSave() { @Test @DisplayName("์„ธ์…˜ ์ƒ์„ฑ ์ค‘ ์ €์žฅ์†Œ ์‹คํŒจ๋Š” RuntimeException์œผ๋กœ ๋ž˜ํ•‘๋œ๋‹ค") void createSession_StoreFailure_ThrowsRuntimeException() { - doThrow(new IllegalStateException("store down")).when(sessionStore).deleteAll(USER_ID); + doThrow(new IllegalStateException("store down")).when(sessionStore).replaceByUserId(any(Session.class)); RuntimeException exception = assertThrows( RuntimeException.class, @@ -65,6 +65,7 @@ void createSession_StoreFailure_ThrowsRuntimeException() { assertThat(exception).hasMessage("์„ธ์…˜ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); assertThat(exception).hasRootCauseInstanceOf(IllegalStateException.class); verify(sessionStore, never()).save(any(Session.class)); + verify(sessionStore, never()).deleteAll(USER_ID); } @Test From 3e7e70f92e04c973c0c35317f5d0e9c61ed53e5a Mon Sep 17 00:00:00 2001 From: sodam2z Date: Tue, 11 Aug 2026 08:47:33 +0900 Subject: [PATCH 06/56] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +- apps/backend/Dockerfile.runtime | 9 + apps/backend/docker-compose.o11y.yaml | 4 +- .../ktb-chat-app-dashboard.prod.json | 420 +++++++ .../ktb-datastores-dashboard.prod.json | 529 +++++++++ .../ktb-load-test-overview.prod.json | 1009 +++++++++++++++++ .../node-exporter-dashboard.prod.json | 886 +++++++++++++++ .../monitoring/prometheus/prometheus.prod.yml | 4 +- .../prometheus/targets/backend-apps.prod.yml | 5 +- .../targets/node-exporters.prod.yml | 18 +- deploy-backend-docker.sh | 91 ++ deploy-frontend.sh | 16 + 12 files changed, 2979 insertions(+), 19 deletions(-) create mode 100644 apps/backend/Dockerfile.runtime create mode 100644 apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json create mode 100644 apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json create mode 100644 apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json create mode 100644 apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json create mode 100755 deploy-backend-docker.sh create mode 100755 deploy-frontend.sh diff --git a/.gitignore b/.gitignore index a4570e15..bb32a5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,15 @@ node_modules/ **/.next/ +# IDE / OS local files +.idea/ +.vscode/ +.DS_Store + # ํ™˜๊ฒฝ ๋ณ€์ˆ˜ .env .env.* !.env.example # Java / Maven build -**/target/ \ No newline at end of file +**/target/ diff --git a/apps/backend/Dockerfile.runtime b/apps/backend/Dockerfile.runtime new file mode 100644 index 00000000..48c02994 --- /dev/null +++ b/apps/backend/Dockerfile.runtime @@ -0,0 +1,9 @@ +FROM eclipse-temurin:25-jre + +WORKDIR /app + +COPY ktb-chat-backend-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 5001 5002 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/apps/backend/docker-compose.o11y.yaml b/apps/backend/docker-compose.o11y.yaml index 15bcb4ad..aae9af7d 100644 --- a/apps/backend/docker-compose.o11y.yaml +++ b/apps/backend/docker-compose.o11y.yaml @@ -39,7 +39,7 @@ services: container_name: grafana-ktb restart: unless-stopped ports: - - "3000:3000" + - "9091:3000" environment: - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} @@ -47,7 +47,7 @@ services: # Behind the ALB Grafana must know its public name, or its redirects and # asset links point at the container. The default is the local port # mapping in this file, not the 9091 the dev compose uses. - - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3000} + - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:9091} - GF_ANALYTICS_REPORTING_ENABLED=false volumes: - grafana_data:/var/lib/grafana diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json new file mode 100644 index 00000000..117f764b --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-chat-app-dashboard.prod.json @@ -0,0 +1,420 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "legendFormat": "{{method}} {{uri}} {{status}}", + "refId": "A" + } + ], + "title": "HTTP Requests/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 8, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "legendFormat": "p95 {{uri}}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "legendFormat": "p99 {{uri}}", + "refId": "B" + } + ], + "title": "HTTP Latency p95/p99", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", status=~\"5..\"}[1m]))", + "legendFormat": "5xx/sec", + "refId": "A" + } + ], + "title": "HTTP 5xx/sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (event_type) (rate(socketio_events_total{job=\"spring-boot-app\"}[1m]))", + "legendFormat": "{{event_type}}", + "refId": "A" + } + ], + "title": "Socket.IO Events/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "100 * sum(jvm_memory_used_bytes{job=\"spring-boot-app\", area=\"heap\"}) / sum(jvm_memory_max_bytes{job=\"spring-boot-app\", area=\"heap\"})", + "legendFormat": "heap used", + "refId": "A" + } + ], + "title": "JVM Heap Used", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (area, id) (jvm_memory_used_bytes{job=\"spring-boot-app\"})", + "legendFormat": "{{area}} {{id}}", + "refId": "A" + } + ], + "title": "JVM Memory Used", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (gc, cause) (rate(jvm_gc_pause_seconds_sum{job=\"spring-boot-app\"}[5m]))", + "legendFormat": "{{gc}} {{cause}}", + "refId": "A" + } + ], + "title": "JVM GC Pause Seconds/sec", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "spring", + "load-test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timezone": "browser", + "title": "KTB Chat App Load Test", + "uid": "ktb-chat-app-load-test", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json new file mode 100644 index 00000000..dc793956 --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-datastores-dashboard.prod.json @@ -0,0 +1,529 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "DOWN" + }, + "1": { + "color": "green", + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "mongodb_up{job=\"mongodb\"}", + "legendFormat": "MongoDB", + "refId": "A" + } + ], + "title": "MongoDB Exporter", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "DOWN" + }, + "1": { + "color": "green", + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "redis_up{job=\"redis\"}", + "legendFormat": "Redis", + "refId": "A" + } + ], + "title": "Redis Exporter", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "red", + "value": 500 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(redis_commands_processed_total{job=\"redis\"}[1m]))", + "legendFormat": "commands/sec", + "refId": "A" + } + ], + "title": "Redis Commands/sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "100 * redis_memory_used_bytes{job=\"redis\"} / redis_memory_max_bytes{job=\"redis\"}", + "legendFormat": "memory", + "refId": "A" + } + ], + "title": "Redis Memory Used", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(rate(mongodb_collstats_latencyStats_reads_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "reads/sec", + "refId": "A" + }, + { + "expr": "sum(rate(mongodb_collstats_latencyStats_writes_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "writes/sec", + "refId": "B" + }, + { + "expr": "sum(rate(mongodb_collstats_latencyStats_commands_ops{job=\"mongodb\"}[1m]))", + "legendFormat": "commands/sec", + "refId": "C" + } + ], + "title": "MongoDB Collection Ops/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "redis_connected_clients{job=\"redis\"}", + "legendFormat": "connected clients", + "refId": "A" + }, + { + "expr": "sum(redis_db_keys{job=\"redis\"})", + "legendFormat": "keys", + "refId": "B" + } + ], + "title": "Redis Clients / Keys", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "100 * rate(redis_keyspace_hits_total{job=\"redis\"}[5m]) / (rate(redis_keyspace_hits_total{job=\"redis\"}[5m]) + rate(redis_keyspace_misses_total{job=\"redis\"}[5m]))", + "legendFormat": "hit ratio", + "refId": "A" + } + ], + "title": "Redis Cache Hit Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 8, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "mongodb_members_health{job=\"mongodb\"}", + "legendFormat": "member {{name}} health", + "refId": "A" + }, + { + "expr": "mongodb_members_state{job=\"mongodb\"}", + "legendFormat": "member {{name}} state", + "refId": "B" + } + ], + "title": "MongoDB Replica Member Health / State", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "mongodb", + "redis", + "load-test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timezone": "browser", + "title": "KTB Datastores", + "uid": "ktb-datastores", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json new file mode 100644 index 00000000..c16abfc3 --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/ktb-load-test-overview.prod.json @@ -0,0 +1,1009 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 1, + "type": "text", + "title": "์ด ํ™”๋ฉด์—์„œ ๋ณด๋Š” ์ˆœ์„œ", + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 4 + }, + "options": { + "mode": "markdown", + "content": "### ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ ์ค‘์—๋Š” ์œ„์—์„œ ์•„๋ž˜๋กœ ๋ด…๋‹ˆ๋‹ค\n1. **Health / 5xx / p95 / RPS**: ์„œ๋น„์Šค๊ฐ€ ์‚ด์•„ ์žˆ๊ณ , ์—๋Ÿฌ์™€ ์ง€์—ฐ์‹œ๊ฐ„์ด ํŠ€๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n2. **CPU / Memory / Root Disk**: ์–ด๋А ์„œ๋ฒ„ ์ž์›์ด ๋จผ์ € ํ•œ๊ณ„์— ๊ฐ€๊นŒ์›Œ์ง€๋Š”์ง€ ๋ด…๋‹ˆ๋‹ค.\n3. **Backend / MongoDB / Redis**: ๋ณ‘๋ชฉ์ด ์•ฑ์ธ์ง€ DB์ธ์ง€ ์บ์‹œ์ธ์ง€ ์ขํž™๋‹ˆ๋‹ค.\n\n๊ธฐ์ค€: CPU 80% ์ง€์†, Memory 85% ์ง€์†, Root Disk 80% ์ฃผ์˜/90% ์œ„ํ—˜, 5xx๊ฐ€ 0๋ณด๋‹ค ํฌ๋ฉด ์ฆ‰์‹œ ์›์ธ ํ™•์ธ." + } + }, + { + "id": 2, + "type": "stat", + "title": "Health - ์ฃฝ์€ target์ด ์žˆ๋Š”์ง€", + "description": "0์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. 1 ์ด์ƒ์ด๋ฉด Prometheus๊ฐ€ ๋ชป ๊ธ๋Š” ์„œ๋ฒ„/exporter๊ฐ€ ์žˆ๋‹ค๋Š” ๋œป์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 4, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(up == 0)", + "refId": "A", + "legendFormat": "down targets" + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "Backend 5xx/sec - ์„œ๋ฒ„ ์—๋Ÿฌ์œจ", + "description": "0์ด์–ด์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฐ’์ด ์ƒ๊ธฐ๋ฉด ๋ฐฑ์—”๋“œ ์˜ˆ์™ธ/DB ์˜ค๋ฅ˜/ํƒ€์ž„์•„์›ƒ์„ ๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 4, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.1 + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", status=~\"5..\"}[1m]))", + "refId": "A", + "legendFormat": "5xx/sec" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Backend p95 Latency - ๋А๋ ค์ง€๋Š”์ง€", + "description": "์š”์ฒญ 100๊ฐœ ์ค‘ ๋А๋ฆฐ 5๊ฐœ์˜ ๊ฒฝ๊ณ„์ž…๋‹ˆ๋‹ค. ๋ถ€ํ•˜๊ฐ€ ์˜ค๋ฅผ ๋•Œ ๊ฐ€์žฅ ๋จผ์ € ๋ด์•ผ ํ•˜๋Š” ์‘๋‹ต์‹œ๊ฐ„์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 9, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "red", + "value": 1.5 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m])))", + "refId": "A", + "legendFormat": "p95" + } + ] + }, + { + "id": 5, + "type": "stat", + "title": "Backend RPS - ์ดˆ๋‹น ์š”์ฒญ ์ˆ˜", + "description": "๋ฐฑ์—”๋“œ๊ฐ€ ์‹ค์ œ๋กœ ๋ฐ›๊ณ  ์žˆ๋Š” ์ดˆ๋‹น HTTP ์š”์ฒญ ์ˆ˜์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 14, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "refId": "A", + "legendFormat": "rps" + } + ] + }, + { + "id": 6, + "type": "stat", + "title": "Socket.IO Events/sec", + "description": "์ฑ„ํŒ… ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋Ÿ‰์ž…๋‹ˆ๋‹ค. HTTP๋Š” ๊ดœ์ฐฎ์€๋ฐ ์ฑ„ํŒ…๋งŒ ๋А๋ฆฌ๋ฉด ์ด ํŒจ๋„๊ณผ Socket.IO ์ƒ์„ธ๋ฅผ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 19, + "y": 4, + "w": 5, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rate(socketio_events_total{job=\"spring-boot-app\"}[1m]))", + "refId": "A", + "legendFormat": "events/sec" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "1. CPU Usage - ์–ด๋А ์„œ๋ฒ„ CPU๊ฐ€ ๋จผ์ € ์ฐจ๋Š”์ง€", + "description": "80% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋Š” ์„œ๋ฒ„๊ฐ€ CPU ๋ณ‘๋ชฉ ํ›„๋ณด์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "100 - (avg by (service, instance) (irate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "refId": "A", + "legendFormat": "{{service}} {{instance}}" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "2. Memory Usage - ๋ฉ”๋ชจ๋ฆฌ๊ฐ€ ๋ถ€์กฑํ•ด์ง€๋Š”์ง€", + "description": "85~90% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋ฉด OOM, GC ์ฆ๊ฐ€, ์Šค์™‘ ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 75 + }, + { + "color": "red", + "value": 90 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "((node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes) * 100", + "refId": "A", + "legendFormat": "{{service}} {{instance}}" + } + ] + }, + { + "id": 9, + "type": "gauge", + "title": "3. Root Disk Usage - / ํŒŒํ‹ฐ์…˜๋งŒ ๋ณด๊ธฐ", + "description": "/boot, /boot/efi๋Š” ์ˆจ๊ธฐ๊ณ  ์‹ค์ œ ์•ฑ/๋กœ๊ทธ/Docker/DB ๋ฐ์ดํ„ฐ๊ฐ€ ์Œ“์ด๋Š” ๋ฃจํŠธ ๋””์Šคํฌ๋งŒ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 16, + "w": 24, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 80 + }, + { + "color": "red", + "value": 90 + } + ] + } + }, + "overrides": [] + }, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "expr": "100 - ((node_filesystem_avail_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"} * 100) / node_filesystem_size_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"})", + "refId": "A", + "legendFormat": "{{service}} {{instance}} /" + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "4. Backend Latency by URI - ์–ด๋–ค API๊ฐ€ ๋А๋ฆฐ์ง€", + "description": "p95๊ฐ€ ํŠ€๋Š” URI๊ฐ€ ๋ณ‘๋ชฉ API ํ›„๋ณด์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 23, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "topk(8, histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[5m]))))", + "refId": "A", + "legendFormat": "p95 {{uri}}" + } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "5. HTTP Status Rate - 2xx/4xx/5xx ํ๋ฆ„", + "description": "5xx๋Š” ์„œ๋ฒ„ ๋ฌธ์ œ, 4xx๋Š” ์ธ์ฆ/๊ฒ€์ฆ/์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์ œ์ผ ๊ฐ€๋Šฅ์„ฑ์ด ํฝ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 23, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (status) (rate(http_server_requests_seconds_count{job=\"spring-boot-app\", uri!=\"/actuator/prometheus\"}[1m]))", + "refId": "A", + "legendFormat": "{{status}}" + } + ] + }, + { + "id": 12, + "type": "timeseries", + "title": "6. JVM Memory / GC - ๋ฐฑ์—”๋“œ ๋‚ด๋ถ€ ์••๋ฐ•", + "description": "CPU/์‘๋‹ต์‹œ๊ฐ„์ด ํŠˆ ๋•Œ heap ์‚ฌ์šฉ๋ฅ ๊ณผ GC pause๊ฐ€ ๊ฐ™์ด ์˜ค๋ฅด๋ฉด JVM ๋ฉ”๋ชจ๋ฆฌ/GC ๋ณ‘๋ชฉ์„ ์˜์‹ฌํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 31, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{job=\"spring-boot-app\", area=\"heap\"}) / sum(jvm_memory_max_bytes{job=\"spring-boot-app\", area=\"heap\"}) * 100", + "refId": "A", + "legendFormat": "heap used %" + }, + { + "expr": "sum(rate(jvm_gc_pause_seconds_sum{job=\"spring-boot-app\"}[1m]))", + "refId": "B", + "legendFormat": "gc pause sec/sec" + } + ] + }, + { + "id": 13, + "type": "timeseries", + "title": "7. Network Traffic - ์„œ๋ฒ„ ๊ฐ„ ํ†ต์‹ /์—…๋กœ๋“œ ์ฆ๊ฐ€", + "description": "ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ, DB/Redis ํ†ต์‹  ์ฆ๊ฐ€๋ฅผ ๋ณผ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 31, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (service, instance) (rate(node_network_receive_bytes_total{device!~\"lo|docker.*|br-.*|veth.*\"}[1m]))", + "refId": "A", + "legendFormat": "RX {{service}} {{instance}}" + }, + { + "expr": "sum by (service, instance) (rate(node_network_transmit_bytes_total{device!~\"lo|docker.*|br-.*|veth.*\"}[1m]))", + "refId": "B", + "legendFormat": "TX {{service}} {{instance}}" + } + ] + }, + { + "id": 14, + "type": "timeseries", + "title": "8. MongoDB Ops - DB ์ž‘์—…๋Ÿ‰", + "description": "insert/query/update/delete๊ฐ€ ๋ถ€ํ•˜์™€ ํ•จ๊ป˜ ์ฆ๊ฐ€ํ•˜๋Š”์ง€ ๋ด…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 0, + "y": 39, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (type) (rate(mongodb_op_counters_total{job=\"mongodb\"}[1m]))", + "refId": "A", + "legendFormat": "{{type}}" + } + ] + }, + { + "id": 15, + "type": "stat", + "title": "MongoDB Up", + "description": "1์ด๋ฉด exporter๊ฐ€ MongoDB์— ์ ‘์† ๊ฐ€๋Šฅ, 0์ด๋ฉด ์žฅ์• ์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 8, + "y": 39, + "w": 4, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "mongodb_up{job=\"mongodb\"}", + "refId": "A", + "legendFormat": "MongoDB" + } + ] + }, + { + "id": 16, + "type": "timeseries", + "title": "9. Redis Commands / Cache Hit - ์บ์‹œ ์ƒํƒœ", + "description": "Redis ์ฒ˜๋ฆฌ๋Ÿ‰๊ณผ hit ratio๋ฅผ ๊ฐ™์ด ๋ด…๋‹ˆ๋‹ค. hit ratio๊ฐ€ ๋‚ฎ์œผ๋ฉด ์บ์‹œ๊ฐ€ ๋œ ๋จนํžˆ๋Š” ์ƒํƒœ์ž…๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 12, + "y": 39, + "w": 8, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum(rate(redis_commands_processed_total{job=\"redis\"}[1m]))", + "refId": "A", + "legendFormat": "commands/sec" + }, + { + "expr": "(sum(rate(redis_keyspace_hits_total{job=\"redis\"}[1m])) / (sum(rate(redis_keyspace_hits_total{job=\"redis\"}[1m])) + sum(rate(redis_keyspace_misses_total{job=\"redis\"}[1m])))) * 100", + "refId": "B", + "legendFormat": "hit ratio %" + } + ] + }, + { + "id": 17, + "type": "stat", + "title": "Redis Memory Used", + "description": "Redis ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ž…๋‹ˆ๋‹ค. ๊ธ‰์ฆํ•˜๋ฉด ํ‚ค ๋ˆ„์ /๋งŒ๋ฃŒ ์ •์ฑ…์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "gridPos": { + "x": 20, + "y": 39, + "w": 4, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "redis_memory_used_bytes{job=\"redis\"}", + "refId": "A", + "legendFormat": "Redis memory" + } + ] + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "ktb", + "load-test", + "overview" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "KTB Load Test Overview - ๋จผ์ € ๋ณด๋Š” ํ™”๋ฉด", + "uid": "ktb-load-overview", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json b/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json new file mode 100644 index 00000000..f3c7071e --- /dev/null +++ b/apps/backend/monitoring/grafana/provisioning/dashboards/node-exporter-dashboard.prod.json @@ -0,0 +1,886 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "PBFA97CFB590B2093" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "100 - (avg(irate(node_cpu_seconds_total{mode=\"idle\", service=~\"$service\"}[5m])) by (instance, service) * 100)", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "CPU Usage - ์„œ๋ฒ„๊ฐ€ ๊ณ„์‚ฐ์„ ์–ผ๋งˆ๋‚˜ ๋ฐ”์˜๊ฒŒ ํ•˜๋Š”์ง€", + "type": "timeseries", + "description": "80% ์ด์ƒ์ด ์˜ค๋ž˜ ์œ ์ง€๋˜๋ฉด ํ•ด๋‹น ์„œ๋ฒ„ CPU๊ฐ€ ๋ณ‘๋ชฉ์ผ ๊ฐ€๋Šฅ์„ฑ์ด ํฝ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "((node_memory_MemTotal_bytes{service=~\"$service\"} - node_memory_MemAvailable_bytes{service=~\"$service\"}) / node_memory_MemTotal_bytes{service=~\"$service\"}) * 100", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Memory Usage - ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ ", + "type": "timeseries", + "description": "85~90% ์ด์ƒ์ด ๊ณ„์†๋˜๋ฉด OOM, GC ์ฆ๊ฐ€, ์Šค์™‘ ์œ„ํ—˜์„ ๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_disk_read_bytes_total{service=~\"$service\"}[5m])", + "refId": "A", + "legendFormat": "Read - {{service}} - {{instance}} - {{device}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_disk_written_bytes_total{service=~\"$service\"}[5m])", + "refId": "B", + "legendFormat": "Write - {{service}} - {{instance}} - {{device}}" + } + ], + "title": "Disk I/O - ๋””์Šคํฌ ์ฝ๊ธฐ/์“ฐ๊ธฐ๋Ÿ‰", + "type": "timeseries", + "description": "MongoDB, ๋กœ๊ทธ, ํŒŒ์ผ ์—…๋กœ๋“œ๊ฐ€ ๋งŽ์„ ๋•Œ ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์‘๋‹ต์‹œ๊ฐ„ ์ฆ๊ฐ€์™€ ๊ฐ™์ด ๋ณด๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_network_receive_bytes_total{service=~\"$service\"}[5m])", + "refId": "A", + "legendFormat": "Receive - {{service}} - {{instance}} - {{device}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "irate(node_network_transmit_bytes_total{service=~\"$service\"}[5m])", + "refId": "B", + "legendFormat": "Transmit - {{service}} - {{instance}} - {{device}}" + } + ], + "title": "Network Traffic - ์„œ๋ฒ„๋ณ„ ๋„คํŠธ์›Œํฌ ์†ก์ˆ˜์‹ ", + "type": "timeseries", + "description": "ํŠธ๋ž˜ํ”ฝ ๋ถ„์‚ฐ, ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ, Redis/MongoDB ํ†ต์‹ ๋Ÿ‰์„ ๋ณผ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "legend": { + "showLegend": true + } + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "100 - ((node_filesystem_avail_bytes{service=~\"$service\", mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"} * 100) / node_filesystem_size_bytes{service=~\"$service\", mountpoint=\"/\", fstype!~\"tmpfs|overlay|squashfs\"})", + "refId": "A", + "legendFormat": "{{service}} - {{instance}} - /" + } + ], + "title": "Root Disk Usage - ์„œ๋ฒ„๋ณ„ / ๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ ๋งŒ ๋ณด๊ธฐ", + "type": "gauge", + "description": "๋ถ€ํ•˜ํ…Œ์ŠคํŠธ ๋•Œ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๋””์Šคํฌ ์ง€ํ‘œ์ž…๋‹ˆ๋‹ค. /boot, /boot/efi๋Š” ์ˆจ๊ธฐ๊ณ  ์•ฑ/๋กœ๊ทธ/Docker/DB ๋ฐ์ดํ„ฐ๊ฐ€ ์Œ“์ด๋Š” ๋ฃจํŠธ(/) ํŒŒํ‹ฐ์…˜๋งŒ ๋ด…๋‹ˆ๋‹ค. 80% ์ด์ƒ ์ฃผ์˜, 90% ์ด์ƒ ์œ„ํ—˜์ž…๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load1{service=~\"$service\"}", + "refId": "A", + "legendFormat": "1m - {{service}} - {{instance}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load5{service=~\"$service\"}", + "refId": "B", + "legendFormat": "5m - {{service}} - {{instance}}" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_load15{service=~\"$service\"}", + "refId": "C", + "legendFormat": "15m - {{service}} - {{instance}}" + } + ], + "title": "System Load Average - CPU ๋Œ€๊ธฐ์—ด ์••๋ฐ•", + "type": "timeseries", + "description": "CPU ์ฝ”์–ด ์ˆ˜๋ณด๋‹ค load average๊ฐ€ ์˜ค๋ž˜ ๋†’์œผ๋ฉด ์ฒ˜๋ฆฌ ๋Œ€๊ธฐ์—ด์ด ์Œ“์ด๋Š” ์ƒํƒœ์ž…๋‹ˆ๋‹ค." + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_time_seconds{service=~\"$service\"} - node_boot_time_seconds{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "System Uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 24 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "count(node_cpu_seconds_total{mode=\"idle\", service=~\"$service\"}) by (instance, service)", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "CPU Cores", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_memory_MemTotal_bytes{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Total Memory", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "node_procs_running{service=~\"$service\"}", + "refId": "A", + "legendFormat": "{{service}} - {{instance}}" + } + ], + "title": "Running Processes", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 36, + "style": "dark", + "tags": [ + "node-exporter", + "system", + "monitoring" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "definition": "label_values(node_uname_info, service)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "service", + "options": [], + "query": { + "query": "label_values(node_uname_info, service)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Node Exporter Dashboard", + "uid": "node-exporter", + "version": 1, + "weekStart": "" +} diff --git a/apps/backend/monitoring/prometheus/prometheus.prod.yml b/apps/backend/monitoring/prometheus/prometheus.prod.yml index 43132c4f..55a61732 100644 --- a/apps/backend/monitoring/prometheus/prometheus.prod.yml +++ b/apps/backend/monitoring/prometheus/prometheus.prod.yml @@ -21,13 +21,13 @@ scrape_configs: - job_name: 'mongodb' static_configs: - - targets: ['IP:9216'] # your-mongo-server + - targets: ['10.0.101.238:9216'] # ktb-mongodb mongodb-exporter labels: environment: 'production' - job_name: 'redis' static_configs: - - targets: ['IP:9121'] # your-redis-server + - targets: ['10.0.101.175:9121'] # ktb-redis redis-exporter labels: environment: 'production' diff --git a/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml b/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml index 711ae121..e7f91167 100644 --- a/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml +++ b/apps/backend/monitoring/prometheus/targets/backend-apps.prod.yml @@ -5,10 +5,7 @@ # App Server (Spring Boot actuator) - targets: - - 'IP:5001' # your-app-server1 - - 'IP:5001' # your-app-server2 - - 'IP:5001' # your-app-server3 - - 'IP:5001' # your-app-server4 + - '10.0.101.47:5001' # ktb-backend labels: application: 'ktb-chat-backend' environment: 'production' diff --git a/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml b/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml index d6571221..2d244b90 100644 --- a/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml +++ b/apps/backend/monitoring/prometheus/targets/node-exporters.prod.yml @@ -2,21 +2,19 @@ # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์˜ Node Exporter ํƒ€๊ฒŸ ๋ชฉ๋ก # ์„œ๋ฒ„ ์ถ”๊ฐ€ ์‹œ ์ด ํŒŒ์ผ๋งŒ ์ˆ˜์ •ํ•˜๋ฉด ์ž๋™์œผ๋กœ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค (Prometheus ์žฌ์‹œ์ž‘ ๋ถˆํ•„์š”) -# App Server (backend + frontend on one host) +# App Servers - targets: - - 'IP:9100' # your-app-server1 - - 'IP:9100' # your-app-server2 - - 'IP:9100' # your-app-server3 - - 'IP:9100' # your-app-server4 + - '10.0.101.12:9100' # ktb-frontend + - '10.0.101.47:9100' # ktb-backend labels: environment: 'production' region: 'ap-northeast-2' cluster: 'app' - service: 'api+next' + service: 'frontend+backend' # Database Cluster - targets: - - 'IP:9100' # your-mongo-server + - '10.0.101.238:9100' # ktb-mongodb labels: environment: 'production' region: 'ap-northeast-2' @@ -25,7 +23,7 @@ # Redis Cluster - targets: - - 'IP:9100' # your-redis-server + - '10.0.101.175:9100' # ktb-redis labels: environment: 'production' region: 'ap-northeast-2' @@ -34,9 +32,9 @@ # Monitoring Server - targets: - - 'IP:9100' # your-o11y-server + - '10.0.101.211:9100' # ktb-monitoring labels: environment: 'production' region: 'ap-northeast-2' cluster: 'monitoring' - service: 'observability' + service: 'monitoring' diff --git a/deploy-backend-docker.sh b/deploy-backend-docker.sh new file mode 100755 index 00000000..238e34fa --- /dev/null +++ b/deploy-backend-docker.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$ROOT_DIR/apps/backend" +REMOTE_HOST="${REMOTE_HOST:-ktb-backend}" +REMOTE_APP_DIR="${REMOTE_APP_DIR:-/home/ubuntu/ktb-chat-backend}" +REMOTE_BUILD_DIR="${REMOTE_BUILD_DIR:-/home/ubuntu/ktb-chat-backend-docker/build}" +IMAGE_NAME="${IMAGE_NAME:-ktb-chat-backend}" +CONTAINER_NAME="${CONTAINER_NAME:-ktb-backend}" +IMAGE_TAG="${IMAGE_TAG:-$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || date +%Y%m%d%H%M%S)}" +JAVA_TOOL_OPTIONS_VALUE="${JAVA_TOOL_OPTIONS_VALUE:--Xms512m -Xmx1024m}" +JAR_NAME="ktb-chat-backend-0.0.1-SNAPSHOT.jar" + +echo "==> Build backend JAR locally" +(cd "$BACKEND_DIR" && make build-jar) + +echo "==> Sync runtime Docker build files to $REMOTE_HOST:$REMOTE_BUILD_DIR" +ssh "$REMOTE_HOST" "rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'" +rsync -az --delete \ + "$BACKEND_DIR/Dockerfile.runtime" \ + "$BACKEND_DIR/target/$JAR_NAME" \ + "$REMOTE_HOST:$REMOTE_BUILD_DIR/" + +echo "==> Build Docker image on $REMOTE_HOST: $IMAGE_NAME:$IMAGE_TAG" +ssh "$REMOTE_HOST" "cd '$REMOTE_BUILD_DIR' && sudo docker build -f Dockerfile.runtime -t '$IMAGE_NAME:$IMAGE_TAG' -t '$IMAGE_NAME:latest' ." + +echo "==> Replace systemd backend with Docker container" +ssh "$REMOTE_HOST" " +set -euo pipefail + +APP_DIR='$REMOTE_APP_DIR' +CONTAINER='$CONTAINER_NAME' +IMAGE='$IMAGE_NAME:$IMAGE_TAG' +JAVA_TOOL_OPTIONS_VALUE='$JAVA_TOOL_OPTIONS_VALUE' + +if [ ! -f \"\$APP_DIR/.env\" ]; then + echo \"Missing \$APP_DIR/.env\" >&2 + exit 1 +fi + +mkdir -p \"\$APP_DIR/uploads\" +PREVIOUS_SYSTEMD_STATE=\$(systemctl is-active ktb-backend || true) + +rollback() { + echo \"Docker backend failed; rolling back to systemd ktb-backend\" >&2 + sudo docker rm -f \"\$CONTAINER\" >/dev/null 2>&1 || true + if [ \"\$PREVIOUS_SYSTEMD_STATE\" = \"active\" ]; then + sudo systemctl start ktb-backend || true + fi +} + +sudo docker rm -f \"\$CONTAINER\" >/dev/null 2>&1 || true +sudo systemctl stop ktb-backend || true + +sudo docker run -d \ + --name \"\$CONTAINER\" \ + --restart unless-stopped \ + --env-file \"\$APP_DIR/.env\" \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e JAVA_TOOL_OPTIONS=\"\$JAVA_TOOL_OPTIONS_VALUE\" \ + -v \"\$APP_DIR/uploads:/app/uploads\" \ + -p 5001:5001 \ + -p 5002:5002 \ + \"\$IMAGE\" + +for attempt in \$(seq 1 40); do + if curl -fsS http://localhost:5001/api/health >/dev/null 2>&1; then + sudo systemctl disable ktb-backend >/dev/null 2>&1 || true + echo \"Docker backend is healthy\" + sudo docker ps --filter \"name=\$CONTAINER\" + exit 0 + fi + if ! sudo docker ps --filter \"name=\$CONTAINER\" --filter status=running --format '{{.Names}}' | grep -qx \"\$CONTAINER\"; then + sudo docker logs --tail 120 \"\$CONTAINER\" >&2 || true + rollback + exit 1 + fi + sleep 3 +done + +sudo docker logs --tail 120 \"\$CONTAINER\" >&2 || true +rollback +exit 1 +" + +echo "==> Verify public ALB routes" +curl -fsS https://chat.goorm-ktb-012.goorm.team/api/health +echo +curl -fsS 'https://chat.goorm-ktb-012.goorm.team/socket.io/?EIO=4&transport=polling' >/dev/null +echo "Docker backend deployment completed." diff --git a/deploy-frontend.sh b/deploy-frontend.sh new file mode 100755 index 00000000..83196972 --- /dev/null +++ b/deploy-frontend.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="$ROOT_DIR/apps/frontend" +REMOTE_HOST="${REMOTE_HOST:-ktb-frontend}" +REMOTE_DIR="${REMOTE_DIR:-/home/ubuntu/ktb-chat-frontend}" + +cd "$FRONTEND_DIR" +pnpm build + +ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" +rsync -az --delete .next/standalone/ "$REMOTE_HOST:$REMOTE_DIR/" +rsync -az --delete .next/static/ "$REMOTE_HOST:$REMOTE_DIR/apps/frontend/.next/static/" +rsync -az --delete public/ "$REMOTE_HOST:$REMOTE_DIR/apps/frontend/public/" +ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && pm2 delete ktb-chat-frontend >/dev/null 2>&1 || true && pm2 start apps/frontend/server.js --name ktb-chat-frontend && pm2 save" From 42e2fa034c0c9a03994f96b0296a570c6de11978 Mon Sep 17 00:00:00 2001 From: sodam2z Date: Tue, 11 Aug 2026 08:47:57 +0900 Subject: [PATCH 07/56] =?UTF-8?q?perf:=20MongoDB=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/main/java/com/ktb/chatapp/model/File.java | 5 ++++- .../src/main/java/com/ktb/chatapp/model/Message.java | 6 ++++++ apps/backend/src/main/java/com/ktb/chatapp/model/Room.java | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java index e3780e9d..e04fd0dc 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -25,6 +26,8 @@ public class File { @Id private String id; + // ํŒŒ์ผ ์กฐํšŒ API๋Š” ์ €์žฅ๋œ filename์œผ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์œผ๋ฏ€๋กœ ๋‹ค์šด๋กœ๋“œ/๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง€์—ฐ์„ ์ค„์ธ๋‹ค. + @Indexed(name = "filename_idx") private String filename; private String originalname; @@ -78,4 +81,4 @@ public String getFileUrl(String type) { type, URLEncoder.encode(this.filename, StandardCharsets.UTF_8)); } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java index eb283faf..8c6fa10d 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java @@ -7,6 +7,8 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -26,6 +28,8 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "messages") +// ์ด์ „ ๋ฉ”์‹œ์ง€ ์กฐํšŒ์™€ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์นด์šดํŠธ๊ฐ€ room + timestamp ์กฐ๊ฑด์„ ๊ฐ™์ด ์“ฐ๋ฏ€๋กœ ์ปฌ๋ ‰์…˜ ์Šค์บ”์„ ์ค„์ธ๋‹ค. +@CompoundIndex(name = "room_timestamp_desc_idx", def = "{'room': 1, 'timestamp': -1}") public class Message { @Id @@ -45,6 +49,8 @@ public class Message { private MessageType type; // Mongo ๋ฌธ์„œ ํ•„๋“œ๋ช… "file" ์‚ฌ์šฉ + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ๊ถŒํ•œ ๊ฒ€์ฆ์€ fileId๋กœ ๋ฉ”์‹œ์ง€๋ฅผ ์—ญ์กฐํšŒํ•˜๋ฏ€๋กœ ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋งŒ sparse index์— ์˜ฌ๋ฆฐ๋‹ค. + @Indexed(name = "file_idx", sparse = true) @Field("file") private String fileId; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java index 073cf04e..8252644e 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java @@ -10,6 +10,8 @@ import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.IndexDirection; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -32,6 +34,8 @@ public class Room { @JsonIgnore private String password; + // Health Check๊ฐ€ ์ตœ์‹  ๋ฐฉ 1๊ฐœ๋ฅผ createdAt desc๋กœ ์ฐพ์œผ๋ฏ€๋กœ ์ •๋ ฌ ๋น„์šฉ์„ ์ค„์ธ๋‹ค. + @Indexed(name = "created_at_desc_idx", direction = IndexDirection.DESCENDING) @CreatedDate private LocalDateTime createdAt; From d99c0b42c85a1748201be183abecc520c96b38fb Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 09:37:05 +0900 Subject: [PATCH 08/56] =?UTF-8?q?tomcat=20=EB=8F=99=EC=8B=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EA=B0=9C=EC=84=A0=20+=20MongoDB=20findAndModify=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20+=20S3=20=EC=A0=81=EC=9A=A9=20+=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A7=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + apps/backend/.env.template | 10 + apps/backend/pom.xml | 16 + .../ktb/chatapp/config/S3StorageConfig.java | 53 ++ .../controller/ProfileImageController.java | 24 +- .../ktb/chatapp/service/LocalFileService.java | 4 +- .../ktb/chatapp/service/SessionService.java | 12 +- .../com/ktb/chatapp/service/UserService.java | 105 ++-- .../com/ktb/chatapp/storage/S3Storage.java | 126 +++++ .../src/main/resources/application.properties | 14 +- .../config/UploadsResourceAccessTest.java | 2 + .../chatapp/service/SessionServiceTest.java | 6 +- .../service/SessionServiceUnitTest.java | 35 ++ .../ktb/chatapp/service/UserServiceTest.java | 68 ++- .../ktb/chatapp/storage/S3StorageTest.java | 72 +++ apps/frontend/.env.example | 3 +- apps/frontend/.env.production.example | 1 + .../frontend/components/ProfileImageUpload.js | 11 +- .../__tests__/optimizeProfileImage.test.js | 53 ++ .../lib/profile/optimizeProfileImage.js | 42 ++ e2e/artillery/all-scenarios.js | 89 +-- e2e/artillery/artillery-config.yaml | 2 +- e2e/profile-100vu-before-s3.log | 523 ++++++++++++++++++ 23 files changed, 1175 insertions(+), 99 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java create mode 100644 apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js create mode 100644 apps/frontend/lib/profile/optimizeProfileImage.js create mode 100644 e2e/profile-100vu-before-s3.log diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..849f79e6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} diff --git a/apps/backend/.env.template b/apps/backend/.env.template index 00296358..02074fa4 100644 --- a/apps/backend/.env.template +++ b/apps/backend/.env.template @@ -12,4 +12,14 @@ REDIS_PASSWORD=ktb PORT=5001 WS_PORT=5002 +# Storage: local (default) or s3. In production, prefer an instance IAM role +# instead of static AWS credentials. +FILE_STORAGE_TYPE=local +AWS_REGION=ap-northeast-2 +S3_BUCKET= +S3_ENDPOINT= +S3_PATH_STYLE=false +S3_KEY_PREFIX= +S3_PRESIGN_TTL=10m + OPENAI_API_KEY=sk-... diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index c52460af..befc92ae 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -16,6 +16,7 @@ 25 2.0.0 + 2.49.6 @@ -26,6 +27,13 @@ pom import + + software.amazon.awssdk + bom + ${aws.java.sdk.version} + pom + import + @@ -65,6 +73,14 @@ io.micrometer micrometer-registry-prometheus + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + url-connection-client + org.projectlombok lombok diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java new file mode 100644 index 00000000..05be7f9a --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/S3StorageConfig.java @@ -0,0 +1,53 @@ +package com.ktb.chatapp.config; + +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") +public class S3StorageConfig { + + @Bean + S3Client s3Client( + @Value("${app.s3.region}") String region, + @Value("${app.s3.endpoint:}") String endpoint, + @Value("${app.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .httpClientBuilder(UrlConnectionHttpClient.builder()) + .serviceConfiguration(software.amazon.awssdk.services.s3.S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccess) + .build()); + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } + + @Bean + S3Presigner s3Presigner( + @Value("${app.s3.region}") String region, + @Value("${app.s3.endpoint:}") String endpoint, + @Value("${app.s3.path-style-access:false}") boolean pathStyleAccess) { + var builder = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .serviceConfiguration(software.amazon.awssdk.services.s3.S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccess) + .build()); + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java index 3162f55a..0f8a56e9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/ProfileImageController.java @@ -11,6 +11,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; @@ -19,6 +22,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.concurrent.TimeUnit; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + /** * ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋งŒ์„ ์ต๋ช…์—๊ฒŒ ์„œ๋น™ํ•œ๋‹ค โ€” ์•ฑ ์ „์ฒด์—์„œ ์ธ๊ฐ€ ์—†์ด ์ฝํžˆ๋Š” ์œ ์ผํ•œ ํŒŒ์ผ ํ‘œ๋ฉด์ด๋‹ค. * @@ -29,6 +36,8 @@ @RequestMapping("/api/files/profiles") public class ProfileImageController { + private static final Duration OFFLOAD_URL_TTL = Duration.ofMinutes(10); + private final StoragePort storagePort; @Operation(summary = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์กฐํšŒ", description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") @@ -39,15 +48,28 @@ public class ProfileImageController { }) @SecurityRequirement(name = "") @GetMapping("/{filename:.+}") - public ResponseEntity getProfileImage( + public ResponseEntity getProfileImage( @Parameter(description = "์กฐํšŒํ•  ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ํŒŒ์ผ๋ช…") @PathVariable String filename) { if (FileUtil.containsPathTraversal(filename)) { return ResponseEntity.badRequest().build(); } + var offloadUrl = storagePort.offloadUrl( + StorageKey.profile(filename), + OFFLOAD_URL_TTL, + ContentDisposition.inline().filename(filename, StandardCharsets.UTF_8).build()); + if (offloadUrl.isPresent()) { + // ๋งŒ๋ฃŒ๋˜๋Š” presigned URL ์ž์ฒด๋Š” ์บ์‹œํ•˜์ง€ ์•Š๋Š”๋‹ค. ์‹ค์ œ S3 ๊ฐ์ฒด ์‘๋‹ต์—๋Š” immutable ์บ์‹œ๊ฐ€ ๋ถ™๋Š”๋‹ค. + return ResponseEntity.status(HttpStatus.FOUND) + .cacheControl(CacheControl.noStore()) + .location(offloadUrl.get()) + .build(); + } + return storagePort.open(StorageKey.profile(filename)) .map(resource -> ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()) .contentType(contentTypeOf(filename)) .body(resource)) .orElseGet(() -> ResponseEntity.notFound().build()); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java index 7c27e8a5..7c3a1098 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java @@ -42,7 +42,7 @@ public FileUploadResult uploadFile(MultipartFile file, String uploaderId) { String key = StorageKey.chat(safeFileName); storagePort.put(file.getInputStream(), key, file.getContentType(), file.getSize()); - log.info("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); + log.debug("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); // ์›๋ณธ ํŒŒ์ผ๋ช… ์ •๊ทœํ™” String normalizedOriginalname = FileUtil.normalizeOriginalFilename(originalFilename); @@ -93,7 +93,7 @@ public String storeFile(MultipartFile file, String subDirectory) { // ํŒŒ์ผ ์ €์žฅ storagePort.put(file.getInputStream(), key, file.getContentType(), file.getSize()); - log.info("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); + log.debug("ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {}", safeFileName); return key; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java index 4618267b..a4f088cf 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java @@ -19,6 +19,7 @@ public class SessionService { private final SessionStore sessionStore; public static final long SESSION_TTL_SEC = DurationStyle.detectAndParse(SESSION_TTL).getSeconds(); private static final long SESSION_TIMEOUT = SESSION_TTL_SEC * 1000; + private static final long ACTIVITY_WRITE_INTERVAL_MS = 30_000; private String generateSessionId() { return UUID.randomUUID().toString().replace("-", ""); @@ -94,10 +95,13 @@ public SessionValidationResult validateSession(String userId, String sessionId) return SessionValidationResult.invalid("SESSION_EXPIRED", "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); } - // Update last activity - session.setLastActivity(now); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - session = sessionStore.save(session); + // ์ธ์ฆ ์š”์ฒญ๋งˆ๋‹ค MongoDB write๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค. ์ตœ๊ทผ์— ๊ฐฑ์‹ ๋œ ์„ธ์…˜์€ ์ฝ๊ธฐ๋งŒ ํ•˜๊ณ  + // ์ผ์ • ์‹œ๊ฐ„์ด ์ง€๋‚œ ๊ฒฝ์šฐ์—๋งŒ lastActivity์™€ TTL์„ ์—ฐ์žฅํ•œ๋‹ค. + if (now - session.getLastActivity() >= ACTIVITY_WRITE_INTERVAL_MS) { + session.setLastActivity(now); + session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); + session = sessionStore.save(session); + } SessionData sessionData = toSessionData(session); return SessionValidationResult.valid(sessionData); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java index 9c47c24c..449d71cc 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/UserService.java @@ -10,6 +10,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -26,6 +31,7 @@ public class UserService { private final UserRepository userRepository; private final FileService fileService; private final StoragePort storagePort; + private final MongoOperations mongoOperations; @Value("${app.profile.image.max-size:5242880}") // 5MB private long maxProfileImageSize; @@ -49,15 +55,17 @@ public UserResponse getCurrentUserProfile(String email) { * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public UserResponse updateUserProfile(String email, UpdateProfileRequest request) { - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - // ํ”„๋กœํ•„ ์ •๋ณด ์—…๋ฐ์ดํŠธ - user.setName(request.getName()); - user.setUpdatedAt(LocalDateTime.now()); - - User updatedUser = userRepository.save(user); - log.info("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ - ID: {}, Name: {}", user.getId(), request.getName()); + User updatedUser = mongoOperations.findAndModify( + Query.query(Criteria.where("email").is(email.toLowerCase())), + new Update() + .set("name", request.getName()) + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(true), + User.class); + if (updatedUser == null) { + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + log.debug("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ - ID: {}, Name: {}", updatedUser.getId(), request.getName()); return UserResponse.from(updatedUser); } @@ -67,27 +75,39 @@ public UserResponse updateUserProfile(String email, UpdateProfileRequest request * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public ProfileImageResponse uploadProfileImage(String email, MultipartFile file) { - // ์‚ฌ์šฉ์ž ์กฐํšŒ - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - // ํŒŒ์ผ ์œ ํšจ์„ฑ ๊ฒ€์ฆ validateProfileImageFile(file); - // ๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ - if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); - } - - // ์ƒˆ ํŒŒ์ผ ์ €์žฅ (๋ณด์•ˆ ๊ฒ€์ฆ ํฌํ•จ) + // ์ƒˆ ํŒŒ์ผ์„ ๋จผ์ € ์ €์žฅํ•œ๋‹ค. ์ดํ›„ DB ์ €์žฅ์ด ์‹คํŒจํ•˜๋ฉด ์ƒˆ ํŒŒ์ผ์„ ์ •๋ฆฌํ•ด ๊ธฐ์กด ํ”„๋กœํ•„์„ ๋ณด์กดํ•œ๋‹ค. String profileImageKey = fileService.storeFile(file, "profiles"); - // DB์—๋Š” key๋งŒ ์ €์žฅํ•œ๋‹ค โ€” URL์€ ์‘๋‹ต ๊ฒฝ๊ณ„์—์„œ ์กฐ๋ฆฝ๋œ๋‹ค - user.setProfileImage(profileImageKey); - user.setUpdatedAt(LocalDateTime.now()); - userRepository.save(user); + User previousUser; + try { + previousUser = mongoOperations.findAndModify( + Query.query(Criteria.where("email").is(email.toLowerCase())), + new Update() + .set("profileImage", profileImageKey) + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(false), + User.class); + } catch (RuntimeException e) { + deleteProfileImageFile(profileImageKey, "DB ์ €์žฅ ์‹คํŒจ ํ›„ ์ƒˆ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw e; + } + if (previousUser == null) { + deleteProfileImageFile(profileImageKey, "์‚ฌ์šฉ์ž ์—†์Œ์œผ๋กœ ์ƒˆ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ •๋ฆฌ"); + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ์ƒˆ key๊ฐ€ DB์— ๋ฐ˜์˜๋œ ๋’ค์—๋งŒ ์ด์ „ ์‹ค๋ฌผ ํŒŒ์ผ์„ ์‚ญ์ œํ•œ๋‹ค. + String oldProfileImageKey = previousUser.getProfileImage(); + if (oldProfileImageKey != null + && !oldProfileImageKey.isEmpty() + && !oldProfileImageKey.equals(profileImageKey)) { + deleteProfileImageFile(oldProfileImageKey, "๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); + } - log.info("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์™„๋ฃŒ - User ID: {}, Key: {}", user.getId(), profileImageKey); + log.debug("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์™„๋ฃŒ - User ID: {}, Key: {}", previousUser.getId(), profileImageKey); return ProfileImageResponse.updated(profileImageKey); } @@ -138,12 +158,12 @@ private void validateProfileImageFile(MultipartFile file) { * ๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‹ค๋ฌผ ์‚ญ์ œ. ์ €์žฅ๊ฐ’์ด key์ด๋ฏ€๋กœ ์Šคํ† ๋ฆฌ์ง€์— ๊ทธ๋Œ€๋กœ ๋„˜๊ธด๋‹ค โ€” ์‚ญ์ œ ์‹คํŒจ๊ฐ€ ํ”„๋กœํ•„ ๊ฐฑ์‹  * ์ž์ฒด๋ฅผ ๋ง‰์ง€๋Š” ์•Š๋Š”๋‹ค. */ - private void deleteOldProfileImage(String profileImageKey) { + private void deleteProfileImageFile(String profileImageKey, String operation) { try { storagePort.delete(profileImageKey); - log.info("๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ: {}", profileImageKey); + log.debug("{} ์™„๋ฃŒ: {}", operation, profileImageKey); } catch (RuntimeException e) { - log.warn("๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์‹คํŒจ: {}", e.getMessage()); + log.warn("{} ์‹คํŒจ - Key: {}, Cause: {}", operation, profileImageKey, e.getMessage()); } } @@ -152,16 +172,27 @@ private void deleteOldProfileImage(String profileImageKey) { * @param email ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ */ public void deleteProfileImage(String email) { - User user = userRepository.findByEmail(email.toLowerCase()) - .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); - user.setProfileImage(""); - user.setUpdatedAt(LocalDateTime.now()); - userRepository.save(user); - log.info("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ - User ID: {}", user.getId()); + Query emailQuery = Query.query(Criteria.where("email").is(email.toLowerCase())); + Query imageQuery = Query.query(new Criteria().andOperator( + Criteria.where("email").is(email.toLowerCase()), + Criteria.where("profileImage").nin(null, ""))); + User previousUser = mongoOperations.findAndModify( + imageQuery, + new Update() + .set("profileImage", "") + .set("updatedAt", LocalDateTime.now()), + FindAndModifyOptions.options().returnNew(false), + User.class); + + if (previousUser == null) { + if (!mongoOperations.exists(emailQuery, User.class)) { + throw new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return; } + + deleteProfileImageFile(previousUser.getProfileImage(), "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); + log.debug("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์™„๋ฃŒ - User ID: {}", previousUser.getId()); } /** @@ -173,7 +204,7 @@ public void deleteUserAccount(String email) { .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { - deleteOldProfileImage(user.getProfileImage()); + deleteProfileImageFile(user.getProfileImage(), "ํšŒ์› ํƒˆํ‡ด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ"); } userRepository.delete(user); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java new file mode 100644 index 00000000..b5a11134 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java @@ -0,0 +1,126 @@ +package com.ktb.chatapp.storage; + +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ContentDisposition; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@Component +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") +public class S3Storage implements StoragePort { + + static final String PROFILE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + static final String PRIVATE_CACHE_CONTROL = "private, no-cache, no-store, must-revalidate"; + + private final S3Client s3Client; + private final S3Presigner presigner; + private final String bucket; + private final String keyPrefix; + private final Duration fallbackPresignTtl; + + public S3Storage( + S3Client s3Client, + S3Presigner presigner, + @Value("${app.s3.bucket}") String bucket, + @Value("${app.s3.key-prefix:}") String keyPrefix, + @Value("${app.s3.presign-ttl:10m}") Duration fallbackPresignTtl) { + if (!StringUtils.hasText(bucket)) { + throw new IllegalStateException("S3_BUCKET์€ file.storage.type=s3์ผ ๋•Œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.s3Client = s3Client; + this.presigner = presigner; + this.bucket = bucket; + this.keyPrefix = normalizePrefix(keyPrefix); + this.fallbackPresignTtl = fallbackPresignTtl; + } + + @Override + public StoredObject put(InputStream content, String key, String contentType, long size) { + String objectKey = objectKey(key); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .contentLength(size) + .cacheControl(StorageKey.isProfile(key) ? PROFILE_CACHE_CONTROL : PRIVATE_CACHE_CONTROL) + .build(); + s3Client.putObject(request, RequestBody.fromInputStream(content, size)); + return new StoredObject(key, size); + } + + @Override + public Optional open(String key) { + try { + s3Client.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(key)).build()); + URI location = offloadUrl(key, fallbackPresignTtl, ContentDisposition.inline().build()) + .orElseThrow(); + return Optional.of(new UrlResource(location)); + } catch (NoSuchKeyException e) { + return Optional.empty(); + } catch (MalformedURLException e) { + throw new IllegalStateException("S3 presigned URL์„ Resource๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", e); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return Optional.empty(); + } + throw e; + } + } + + @Override + public void delete(String key) { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .build()); + } + + @Override + public Optional offloadUrl(String key, Duration ttl, ContentDisposition disposition) { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .responseContentDisposition(disposition.toString()) + .build(); + return Optional.of(presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(getRequest) + .build()) + .url() + .toExternalForm()) + .map(URI::create); + } + + private String objectKey(String key) { + return keyPrefix + key; + } + + private static String normalizePrefix(String prefix) { + if (!StringUtils.hasText(prefix)) { + return ""; + } + String normalized = prefix.strip(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized.endsWith("/") ? normalized : normalized + "/"; + } +} diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index da6d0e5f..a0fffbe3 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -1,10 +1,10 @@ # Server Configuration server.port=${PORT:5001} -server.tomcat.threads.max=10 -server.tomcat.threads.min-spare=1 -server.tomcat.accept-count=10 -server.tomcat.max-connections=50 +server.tomcat.threads.max=50 +server.tomcat.threads.min-spare=5 +server.tomcat.accept-count=100 +server.tomcat.max-connections=200 # Database Configuration # Spring Boot 4: connection settings moved from spring.data.mongodb.* to spring.mongodb.* @@ -20,6 +20,12 @@ spring.data.redis.password=${REDIS_PASSWORD:} # Storage type: local or gridfs (default: local) file.storage.type=${FILE_STORAGE_TYPE:local} file.upload-dir=./uploads +app.s3.bucket=${S3_BUCKET:} +app.s3.region=${AWS_REGION:ap-northeast-2} +app.s3.endpoint=${S3_ENDPOINT:} +app.s3.path-style-access=${S3_PATH_STYLE:false} +app.s3.key-prefix=${S3_KEY_PREFIX:} +app.s3.presign-ttl=${S3_PRESIGN_TTL:10m} # Multipart upload limits spring.servlet.multipart.max-file-size=5MB diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java index 4390d557..36d29146 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java @@ -18,6 +18,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -54,6 +55,7 @@ class UploadsResourceAccessTest { void profileImageIsServedToAnonymousRequests() throws Exception { mockMvc.perform(get("/api/files/profiles/avatar.png")) .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=31536000, public, immutable")) .andExpect(content().string(org.hamcrest.Matchers.containsString("fake-png-bytes"))); } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java index 1dea3427..846f9f63 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java @@ -167,8 +167,8 @@ void validateSession_NonExistentUser_Failure() { } @Test - @DisplayName("์„ธ์…˜ ๊ฒ€์ฆ - lastActivity ์—…๋ฐ์ดํŠธ") - void validateSession_UpdatesLastActivity() throws InterruptedException { + @DisplayName("์ตœ๊ทผ ํ™œ๋™ํ•œ ์„ธ์…˜ ๊ฒ€์ฆ์€ lastActivity๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ") + void validateSession_DoesNotUpdateRecentLastActivity() throws InterruptedException { // Given SessionMetadata metadata = createTestMetadata(); SessionCreationResult created = sessionService.createSession(TEST_USER_ID, metadata); @@ -181,7 +181,7 @@ void validateSession_UpdatesLastActivity() throws InterruptedException { // Then assertTrue(result.isValid()); - assertThat(result.getSession().getLastActivity()).isGreaterThan(initialLastActivity); + assertThat(result.getSession().getLastActivity()).isEqualTo(initialLastActivity); } // ============ ์„ธ์…˜ ํ™œ๋™ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ ============ diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java index 55901111..1a48f1ff 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java @@ -123,6 +123,41 @@ void validateSession_StoreFailure_ReturnsValidationError() { assertThat(result.getError()).isEqualTo("VALIDATION_ERROR"); } + @Test + @DisplayName("์ตœ๊ทผ ํ™œ๋™ํ•œ ์œ ํšจ ์„ธ์…˜์€ ๊ฒ€์ฆ ์‹œ ๋‹ค์‹œ ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") + void validateSession_RecentlyActive_DoesNotWriteSession() { + Session session = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .lastActivity(Instant.now().toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session)); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isTrue(); + verify(sessionStore, never()).save(any(Session.class)); + } + + @Test + @DisplayName("30์ดˆ ์ด์ƒ ์ง€๋‚œ ์œ ํšจ ์„ธ์…˜์€ ๊ฒ€์ฆ ์‹œ ํ™œ๋™ ์‹œ๊ฐ„์„ ๊ฐฑ์‹ ํ•œ๋‹ค") + void validateSession_StaleActivity_UpdatesSession() { + Session session = Session.builder() + .userId(USER_ID) + .sessionId(SESSION_ID) + .lastActivity(Instant.now().minusSeconds(31).toEpochMilli()) + .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC)) + .build(); + when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session)); + when(sessionStore.save(session)).thenReturn(session); + + SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); + + assertThat(result.isValid()).isTrue(); + verify(sessionStore).save(session); + } + @Test @DisplayName("ํ™œ์„ฑ ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์ €์žฅ์†Œ ์‹คํŒจ๋Š” null๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค") void getActiveSession_StoreFailure_ReturnsNull() { diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java index 46595baa..1ef9eeea 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java @@ -8,7 +8,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,9 +17,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -37,6 +41,9 @@ class UserServiceTest { @Mock private FileService fileService; + @Mock + private MongoOperations mongoOperations; + private UserService userService; @TempDir @@ -48,7 +55,8 @@ class UserServiceTest { */ @BeforeEach void setUp() { - userService = new UserService(userRepository, fileService, new LocalStorage(uploadDir.toString())); + userService = new UserService( + userRepository, fileService, new LocalStorage(uploadDir.toString()), mongoOperations); ReflectionTestUtils.setField(userService, "maxProfileImageSize", 5242880L); } @@ -69,18 +77,45 @@ void uploadProfileImage_DeletesOldProfileImageFile() throws IOException { .email(EMAIL) .profileImage("profiles/old.jpg") .build(); - when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); when(fileService.storeFile(any(), eq("profiles"))).thenReturn("profiles/new.jpg"); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenReturn(user); MockMultipartFile file = new MockMultipartFile( "file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes()); ProfileImageResponse response = userService.uploadProfileImage(EMAIL, file); assertThat(Files.exists(oldFile)).isFalse(); - assertThat(user.getProfileImage()).isEqualTo("profiles/new.jpg"); assertThat(response.getImageUrl()).isEqualTo("/api/files/profiles/new.jpg"); } + @Test + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ DB ์ €์žฅ ์‹คํŒจ ์‹œ ๊ธฐ์กด ํŒŒ์ผ์„ ๋ณด์กดํ•˜๊ณ  ์ƒˆ ํŒŒ์ผ์„ ์ •๋ฆฌํ•œ๋‹ค") + void uploadProfileImage_WhenDatabaseSaveFails_PreservesOldFileAndDeletesNewFile() throws IOException { + Path oldFile = createOldProfileImageFile("old-save-failure.jpg"); + Path newFile = createOldProfileImageFile("new-save-failure.jpg"); + User user = User.builder() + .id("user-1") + .email(EMAIL) + .profileImage("profiles/old-save-failure.jpg") + .build(); + when(fileService.storeFile(any(), eq("profiles"))) + .thenReturn("profiles/new-save-failure.jpg"); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenThrow(new RuntimeException("mongo unavailable")); + MockMultipartFile file = new MockMultipartFile( + "file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes()); + + assertThatThrownBy(() -> userService.uploadProfileImage(EMAIL, file)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mongo unavailable"); + + assertThat(oldFile).exists(); + assertThat(newFile).doesNotExist(); + } + @Test @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์‚ญ์ œ ์‹œ ๊ธฐ์กด ์ด๋ฏธ์ง€ ์‹ค๋ฌผ ํŒŒ์ผ์„ ์‚ญ์ œํ•œ๋‹ค") void deleteProfileImage_DeletesProfileImageFile() throws IOException { @@ -90,11 +125,32 @@ void deleteProfileImage_DeletesProfileImageFile() throws IOException { .email(EMAIL) .profileImage("profiles/old2.jpg") .build(); - when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenReturn(user); userService.deleteProfileImage(EMAIL); assertThat(Files.exists(oldFile)).isFalse(); - assertThat(user.getProfileImage()).isEmpty(); + } + + @Test + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ DB ์‚ญ์ œ ๋ฐ˜์˜ ์‹คํŒจ ์‹œ ๊ธฐ์กด ํŒŒ์ผ์„ ๋ณด์กดํ•œ๋‹ค") + void deleteProfileImage_WhenDatabaseSaveFails_PreservesProfileImageFile() throws IOException { + Path oldFile = createOldProfileImageFile("delete-save-failure.jpg"); + User user = User.builder() + .id("user-1") + .email(EMAIL) + .profileImage("profiles/delete-save-failure.jpg") + .build(); + when(mongoOperations.findAndModify( + any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class))) + .thenThrow(new RuntimeException("mongo unavailable")); + + assertThatThrownBy(() -> userService.deleteProfileImage(EMAIL)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mongo unavailable"); + + assertThat(oldFile).exists(); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java new file mode 100644 index 00000000..6201df66 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java @@ -0,0 +1,72 @@ +package com.ktb.chatapp.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.ContentDisposition; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@DisplayName("S3Storage ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class S3StorageTest { + + private final S3Client s3Client = mock(S3Client.class); + private final S3Presigner presigner = mock(S3Presigner.class); + private final S3Storage storage = new S3Storage( + s3Client, presigner, "profile-bucket", "loadtest/run-1", Duration.ofMinutes(10)); + + @Test + void profileUploadUsesPrefixContentTypeAndImmutableCache() { + byte[] content = "image".getBytes(); + + StoredObject result = storage.put( + new ByteArrayInputStream(content), "profiles/avatar.jpg", "image/jpeg", content.length); + + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(request.capture(), any(RequestBody.class)); + assertThat(request.getValue().bucket()).isEqualTo("profile-bucket"); + assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg"); + assertThat(request.getValue().contentType()).isEqualTo("image/jpeg"); + assertThat(request.getValue().cacheControl()).isEqualTo(S3Storage.PROFILE_CACHE_CONTROL); + assertThat(result.key()).isEqualTo("profiles/avatar.jpg"); + } + + @Test + void deleteUsesSamePhysicalPrefix() { + storage.delete("profiles/avatar.jpg"); + + ArgumentCaptor request = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(request.capture()); + assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg"); + } + + @Test + void offloadUrlReturnsPresignedGetUrl() throws Exception { + PresignedGetObjectRequest signed = mock(PresignedGetObjectRequest.class); + when(signed.url()).thenReturn(new URL("https://example.test/signed-profile")); + when(presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(signed); + + URI result = storage.offloadUrl( + "profiles/avatar.jpg", + Duration.ofMinutes(10), + ContentDisposition.inline().filename("avatar.jpg").build()) + .orElseThrow(); + + assertThat(result).isEqualTo(URI.create("https://example.test/signed-profile")); + } +} diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index 35b55696..f927a7c2 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -1,3 +1,4 @@ PORT=3000 NEXT_PUBLIC_API_URL=http://localhost:5001 -NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 \ No newline at end of file +NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 +NEXT_PUBLIC_PROFILE_IMAGE_RESIZE_ENABLED=false diff --git a/apps/frontend/.env.production.example b/apps/frontend/.env.production.example index fd5f33bb..a0d2d76c 100644 --- a/apps/frontend/.env.production.example +++ b/apps/frontend/.env.production.example @@ -3,3 +3,4 @@ # ๋ฐฐํฌ ๋Œ€์ƒ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ๊ณ ์น˜์„ธ์š”. (.env.production ์€ ์ปค๋ฐ‹ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค) NEXT_PUBLIC_API_URL=http://localhost:5001 NEXT_PUBLIC_SOCKET_URL=http://localhost:5002 +NEXT_PUBLIC_PROFILE_IMAGE_RESIZE_ENABLED=false diff --git a/apps/frontend/components/ProfileImageUpload.js b/apps/frontend/components/ProfileImageUpload.js index 5a8e8dfc..3fee04b4 100644 --- a/apps/frontend/components/ProfileImageUpload.js +++ b/apps/frontend/components/ProfileImageUpload.js @@ -6,6 +6,10 @@ import CustomAvatar from '@/components/CustomAvatar'; import { Toast } from '@/components/Toast'; import api from '@/lib/api/client'; import { saveStoredUser } from '@/lib/auth/authStorage'; +import { optimizeProfileImage } from '@/lib/profile/optimizeProfileImage'; + +const PROFILE_IMAGE_RESIZE_ENABLED = + process.env.NEXT_PUBLIC_PROFILE_IMAGE_RESIZE_ENABLED === 'true'; const ProfileImageUpload = ({ currentImage, onImageChange }) => { const { user } = useAuth(); @@ -55,9 +59,14 @@ const ProfileImageUpload = ({ currentImage, onImageChange }) => { throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); } + // S3 ์ „ํ›„ ์„ฑ๋Šฅ์„ ๋ถ„๋ฆฌ ์ธก์ •ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋นŒ๋“œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๋ฆฌ์‚ฌ์ด์ง•์„ ์ผ ๋‹ค. + const uploadFile = PROFILE_IMAGE_RESIZE_ENABLED + ? await optimizeProfileImage(file) + : file; + // FormData ์ƒ์„ฑ const formData = new FormData(); - formData.append('profileImage', file); + formData.append('profileImage', uploadFile); // ํŒŒ์ผ ์—…๋กœ๋“œ ์š”์ฒญ const response = await api.post( diff --git a/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js b/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js new file mode 100644 index 00000000..608aa22a --- /dev/null +++ b/apps/frontend/lib/profile/__tests__/optimizeProfileImage.test.js @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { optimizeProfileImage } from '../optimizeProfileImage'; + +describe('optimizeProfileImage', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ์›๋ณธ์„ ์œ ์ง€ํ•œ๋‹ค', async () => { + vi.stubGlobal('createImageBitmap', undefined); + const file = new File(['original'], 'profile.jpg', { type: 'image/jpeg' }); + + await expect(optimizeProfileImage(file)).resolves.toBe(file); + }); + + it('512px ์ดํ•˜ WebP๋กœ ์ถ•์†Œํ•˜๊ณ  ๋” ์ž‘์€ ํŒŒ์ผ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', async () => { + const bitmap = { width: 1024, height: 512, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const context = { drawImage: vi.fn() }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + toBlob: vi.fn((callback) => callback(new Blob(['small'], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File([new Uint8Array(1024)], 'profile.jpg', { type: 'image/jpeg' }); + + const optimized = await optimizeProfileImage(file); + + expect(canvas.width).toBe(512); + expect(canvas.height).toBe(256); + expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 512, 256); + expect(optimized.name).toBe('profile.webp'); + expect(optimized.type).toBe('image/webp'); + expect(optimized.size).toBeLessThan(file.size); + expect(bitmap.close).toHaveBeenCalled(); + }); + + it('๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ์›๋ณธ๋ณด๋‹ค ํฌ๋ฉด ์›๋ณธ์„ ์œ ์ง€ํ•œ๋‹ค', async () => { + const bitmap = { width: 256, height: 256, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)); + const canvas = { + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toBlob: vi.fn((callback) => callback(new Blob([new Uint8Array(20)], { type: 'image/webp' }))), + }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const file = new File(['tiny'], 'profile.png', { type: 'image/png' }); + + await expect(optimizeProfileImage(file)).resolves.toBe(file); + }); +}); diff --git a/apps/frontend/lib/profile/optimizeProfileImage.js b/apps/frontend/lib/profile/optimizeProfileImage.js new file mode 100644 index 00000000..b3934eac --- /dev/null +++ b/apps/frontend/lib/profile/optimizeProfileImage.js @@ -0,0 +1,42 @@ +const DEFAULT_MAX_DIMENSION = 512; +const DEFAULT_QUALITY = 0.82; + +const canvasToBlob = (canvas, type, quality) => + new Promise((resolve) => canvas.toBlob(resolve, type, quality)); + +/** + * ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ถ•์†Œํ•œ๋‹ค. ์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋ผ์šฐ์ €๋‚˜ ๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ๋” ํฐ ๊ฒฝ์šฐ ์›๋ณธ์„ ์œ ์ง€ํ•œ๋‹ค. + * ์„œ๋ฒ„์˜ 5MB ๊ฒ€์ฆ์€ ๋ณ„๋„๋กœ ์œ ์ง€ํ•˜๋ฏ€๋กœ ์ด ํ•จ์ˆ˜๋Š” ๋ณด์•ˆ ๊ฒฝ๊ณ„๊ฐ€ ์•„๋‹ˆ๋ผ ์ „์†ก๋Ÿ‰ ์ตœ์ ํ™”๋‹ค. + */ +export async function optimizeProfileImage( + file, + { maxDimension = DEFAULT_MAX_DIMENSION, quality = DEFAULT_QUALITY } = {} +) { + if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') { + return file; + } + + const bitmap = await createImageBitmap(file); + try { + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) return file; + + context.drawImage(bitmap, 0, 0, width, height); + const blob = await canvasToBlob(canvas, 'image/webp', quality); + if (!blob || blob.size >= file.size) return file; + + const baseName = file.name.replace(/\.[^.]+$/, '') || 'profile'; + return new File([blob], `${baseName}.webp`, { + type: 'image/webp', + lastModified: Date.now(), + }); + } finally { + bitmap.close?.(); + } +} diff --git a/e2e/artillery/all-scenarios.js b/e2e/artillery/all-scenarios.js index c0c77e26..20fd4f11 100644 --- a/e2e/artillery/all-scenarios.js +++ b/e2e/artillery/all-scenarios.js @@ -1,44 +1,46 @@ -const { loginScenario, failedLoginScenario } = require('./scenarios/auth.scenario.js'); const { - chatRoomCreationScenario, - massMessageScenario, - fileUploadScenario, - forbiddenWordScenario, -} = require('./scenarios/chat.scenario.js'); + loginScenario, + failedLoginScenario, +} = require("./scenarios/auth.scenario.js"); const { - fullProfileUpdateScenario, -} = require('./scenarios/profile.scenario.js'); + chatRoomCreationScenario, + massMessageScenario, + fileUploadScenario, + forbiddenWordScenario, +} = require("./scenarios/chat.scenario.js"); +const { + fullProfileUpdateScenario, +} = require("./scenarios/profile.scenario.js"); function generateUserSchema() { - const timestamp = Date.now(); - const randomId = Math.random().toString(36).substring(2, 8); + const timestamp = Date.now(); + const randomId = Math.random().toString(36).substring(2, 8); - // ๊ณ ์œ ํ•œ ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ์ž ์ƒ์„ฑ - const testUser = { - email: `loadtest_${timestamp}_${randomId}@example.com`, - password: 'Password123!', - passwordConfirm: 'Password123!', - name: `Load Test User ${randomId}`, - }; - return testUser; + // ๊ณ ์œ ํ•œ ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ์ž ์ƒ์„ฑ + const testUser = { + email: `loadtest_${timestamp}_${randomId}@example.com`, + password: "Password123!", + passwordConfirm: "Password123!", + name: `Load Test User ${randomId}`, + }; + return testUser; } const allScenariosFlat = [ - // auth Scenarios - failedLoginScenario, - loginScenario, + // auth Scenarios + failedLoginScenario, + loginScenario, - // chat Scenarios - chatRoomCreationScenario, - massMessageScenario, - fileUploadScenario, - forbiddenWordScenario, + // chat Scenarios + chatRoomCreationScenario, + massMessageScenario, + fileUploadScenario, + forbiddenWordScenario, - // profile Scenarios - fullProfileUpdateScenario, + // profile Scenarios + fullProfileUpdateScenario, ]; - /** * ํ†ตํ•ฉ ์‹œ๋‚˜๋ฆฌ์˜ค ์ˆœ์ฐจ ์‹คํ–‰ * ๋ชจ๋“  ๊ฐœ๋ณ„ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ @@ -51,19 +53,28 @@ const allScenariosFlat = [ * ์—๋Ÿฌ ํƒ€์ž…/์Šคํƒ/์žฌ์‹œ๋„ ๋™์ž‘์€ ๊ทธ๋Œ€๋กœ ๋‘๊ณ  ๋ฉ”์‹œ์ง€ ํ…์ŠคํŠธ๋งŒ ํƒœ๊ทธ๋ฅผ ๋ถ™์ธ๋‹ค. */ async function allScenarios(page, vuContext) { - const testUser = generateUserSchema(); - vuContext.vars.testUser = testUser; + const testUser = generateUserSchema(); + vuContext.vars.testUser = testUser; - for (const scenario of allScenariosFlat) { - try { - await scenario(page, vuContext); - } catch (err) { - err.message = `[${scenario.name}] ${err.message}`; - throw err; - } + for (const scenario of allScenariosFlat) { + try { + await scenario(page, vuContext); + } catch (err) { + err.message = `[${scenario.name}] ${err.message}`; + throw err; } + } +} + +async function profileScenario(page, vuContext) { + const testUser = generateUserSchema(); + vuContext.vars.testUser = testUser; + + await loginScenario(page, vuContext); + await fullProfileUpdateScenario(page, vuContext); } module.exports = { - allScenarios, + allScenarios, + profileScenario, }; diff --git a/e2e/artillery/artillery-config.yaml b/e2e/artillery/artillery-config.yaml index 94af6719..51ee99cb 100644 --- a/e2e/artillery/artillery-config.yaml +++ b/e2e/artillery/artillery-config.yaml @@ -15,4 +15,4 @@ config: scenarios: - engine: playwright - flowFunction: allScenarios + flowFunction: profileScenario diff --git a/e2e/profile-100vu-before-s3.log b/e2e/profile-100vu-before-s3.log new file mode 100644 index 00000000..37508744 --- /dev/null +++ b/e2e/profile-100vu-before-s3.log @@ -0,0 +1,523 @@ +Test run id: tx9f4_6z43b4eanrajqnatkxpnkp7jmqjep_pw5y +Phase started: phase 1 (index: 0, duration: 1s) 17:39:34(+0900) + +Phase completed: phase 1 (index: 0, duration: 1s) 17:39:36(+0900) + +-------------------------------------- +Metrics for period to: 17:39:40(+0900) (width: 5.908s) +-------------------------------------- + +browser.http_requests: ......................................................... 1416 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 1188 + max: ......................................................................... 4280 + mean: ........................................................................ 2659.3 + median: ...................................................................... 2566.3 + p95: ......................................................................... 4065.2 + p99: ......................................................................... 4231.1 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 2428 + max: ......................................................................... 3344 + mean: ........................................................................ 2824.9 + median: ...................................................................... 2671 + p95: ......................................................................... 3262.4 + p99: ......................................................................... 3262.4 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 803.2 + max: ......................................................................... 1225.7 + mean: ........................................................................ 1032.9 + median: ...................................................................... 1022.7 + p95: ......................................................................... 1224.4 + p99: ......................................................................... 1224.4 +browser.page.codes.200: ........................................................ 1460 +browser.page.codes.201: ........................................................ 5 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 + + +-------------------------------------- +Metrics for period to: 17:39:50(+0900) (width: 7.64s) +-------------------------------------- + +browser.http_requests: ......................................................... 3564 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 64 + max: ......................................................................... 384 + mean: ........................................................................ 216 + median: ...................................................................... 214.9 + p95: ......................................................................... 333.7 + p99: ......................................................................... 354.3 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 4176 + max: ......................................................................... 6176 + mean: ........................................................................ 4621.5 + median: ...................................................................... 4492.8 + p95: ......................................................................... 5168 + p99: ......................................................................... 6064.7 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 8 + max: ......................................................................... 32 + mean: ........................................................................ 18.3 + median: ...................................................................... 16 + p95: ......................................................................... 32.1 + p99: ......................................................................... 32.1 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 100 + max: ......................................................................... 972 + mean: ........................................................................ 745.4 + median: ...................................................................... 804.5 + p95: ......................................................................... 944 + p99: ......................................................................... 963.1 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 2956 + max: ......................................................................... 7344 + mean: ........................................................................ 5522.4 + median: ...................................................................... 5598.4 + p95: ......................................................................... 6976.1 + p99: ......................................................................... 7407.5 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 47.1 + max: ......................................................................... 279.2 + mean: ........................................................................ 178.9 + median: ...................................................................... 190.6 + p95: ......................................................................... 273.2 + p99: ......................................................................... 278.7 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/login: + min: ......................................................................... 48 + max: ......................................................................... 858.8 + mean: ........................................................................ 203.3 + median: ...................................................................... 179.5 + p95: ......................................................................... 478.3 + p99: ......................................................................... 478.3 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 378.9 + max: ......................................................................... 3422.4 + mean: ........................................................................ 1001 + median: ...................................................................... 820.7 + p95: ......................................................................... 3072.4 + p99: ......................................................................... 3262.4 +browser.page.codes.200: ........................................................ 3426 +browser.page.codes.201: ........................................................ 44 +browser.page.codes.304: ........................................................ 49 +errors.page.goto: Navigation to "https://chat.goorm-ktb-024.goorm.team/login... 9 +errors.page.goto: Navigation to "https://chat.goorm-ktb-024.goorm.team/login... 2 +errors.page.goto: net::ERR_ABORTED at https://chat.goorm-ktb-024.goorm.team/... 4 +vusers.failed: ................................................................. 15 + + +-------------------------------------- +Metrics for period to: 17:40:00(+0900) (width: 9.362s) +-------------------------------------- + +browser.http_requests: ......................................................... 1759 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 0.2 + max: ......................................................................... 0.2 + mean: ........................................................................ 0.2 + median: ...................................................................... 0.2 + p95: ......................................................................... 0.2 + p99: ......................................................................... 0.2 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 52 + max: ......................................................................... 236 + mean: ........................................................................ 106.3 + median: ...................................................................... 87.4 + p95: ......................................................................... 169 + p99: ......................................................................... 169 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 104 + max: ......................................................................... 1036 + mean: ........................................................................ 371.4 + median: ...................................................................... 327.1 + p95: ......................................................................... 596 + p99: ......................................................................... 1002.4 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 124 + max: ......................................................................... 396 + mean: ........................................................................ 240.4 + median: ...................................................................... 252.2 + p95: ......................................................................... 295.9 + p99: ......................................................................... 295.9 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 16 + mean: ........................................................................ 9.8 + median: ...................................................................... 7.9 + p95: ......................................................................... 16 + p99: ......................................................................... 16 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 24 + max: ......................................................................... 24 + mean: ........................................................................ 24 + median: ...................................................................... 23.8 + p95: ......................................................................... 23.8 + p99: ......................................................................... 23.8 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 136 + max: ......................................................................... 676 + mean: ........................................................................ 335.4 + median: ...................................................................... 267.8 + p95: ......................................................................... 645.6 + p99: ......................................................................... 645.6 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 2328 + max: ......................................................................... 6908 + mean: ........................................................................ 4848.2 + median: ...................................................................... 4492.8 + p95: ......................................................................... 6439.7 + p99: ......................................................................... 6569.8 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 7324 + max: ......................................................................... 9868 + mean: ........................................................................ 9416.6 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9801.2 + p99: ......................................................................... 9801.2 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 32.4 + max: ......................................................................... 124.3 + mean: ........................................................................ 73.3 + median: ...................................................................... 67.4 + p95: ......................................................................... 117.9 + p99: ......................................................................... 117.9 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 27 + max: ......................................................................... 495.8 + mean: ........................................................................ 125.7 + median: ...................................................................... 76 + p95: ......................................................................... 252.2 + p99: ......................................................................... 252.2 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/login: + min: ......................................................................... 50.4 + max: ......................................................................... 214.7 + mean: ........................................................................ 108 + median: ...................................................................... 96.6 + p95: ......................................................................... 133 + p99: ......................................................................... 133 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 29.9 + max: ......................................................................... 151.8 + mean: ........................................................................ 84.5 + median: ...................................................................... 74.4 + p95: ......................................................................... 130.3 + p99: ......................................................................... 133 +browser.page.codes.200: ........................................................ 1694 +browser.page.codes.201: ........................................................ 27 +browser.page.codes.304: ........................................................ 38 + + +-------------------------------------- +Metrics for period to: 17:40:20(+0900) (width: 5.359s) +-------------------------------------- + +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 8 + max: ......................................................................... 48 + mean: ........................................................................ 20 + median: ...................................................................... 16 + p95: ......................................................................... 16 + p99: ......................................................................... 16 +browser.page.codes.200: ........................................................ 1 +vusers.completed: .............................................................. 7 +vusers.failed: ................................................................. 0 +vusers.session_length: + min: ......................................................................... 40377.9 + max: ......................................................................... 45508.9 + mean: ........................................................................ 42848.5 + median: ...................................................................... 42205.5 + p95: ......................................................................... 45720.8 + p99: ......................................................................... 45720.8 + + +-------------------------------------- +Metrics for period to: 17:40:30(+0900) (width: 5.916s) +-------------------------------------- + +browser.http_requests: ......................................................... 46 +browser.page.codes.200: ........................................................ 44 +browser.page.codes.504: ........................................................ 1 +errors.pw_failed_assertion.toBeVisible: ........................................ 5 +vusers.failed: ................................................................. 5 + + +-------------------------------------- +Metrics for period to: 17:40:40(+0900) (width: 9.387s) +-------------------------------------- + +browser.http_requests: ......................................................... 525 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 368 + max: ......................................................................... 27512 + mean: ........................................................................ 6609.5 + median: ...................................................................... 1620 + p95: ......................................................................... 27181.5 + p99: ......................................................................... 27181.5 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 24 + mean: ........................................................................ 11.2 + median: ...................................................................... 7.9 + p95: ......................................................................... 16 + p99: ......................................................................... 16 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 156 + max: ......................................................................... 1048 + mean: ........................................................................ 688 + median: ...................................................................... 742.6 + p95: ......................................................................... 804.5 + p99: ......................................................................... 804.5 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 86.8 + max: ......................................................................... 24058.1 + mean: ........................................................................ 4159.9 + median: ...................................................................... 308 + p95: ......................................................................... 24107.7 + p99: ......................................................................... 24107.7 +browser.page.codes.200: ........................................................ 516 +browser.page.codes.401: ........................................................ 1 +browser.page.codes.504: ........................................................ 4 +errors.locator.setInputFiles: Timeout 30000ms exceeded.: ....................... 1 +errors.page.waitForURL: Timeout 30000ms exceeded.: ............................. 35 +vusers.failed: ................................................................. 36 + + +-------------------------------------- +Metrics for period to: 17:40:50(+0900) (width: 0.466s) +-------------------------------------- + +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 8 + max: ......................................................................... 48 + mean: ........................................................................ 24 + median: ...................................................................... 16 + p95: ......................................................................... 23.8 + p99: ......................................................................... 23.8 +vusers.completed: .............................................................. 4 +vusers.failed: ................................................................. 0 +vusers.session_length: + min: ......................................................................... 65272.5 + max: ......................................................................... 66140.8 + mean: ........................................................................ 65847.3 + median: ...................................................................... 65533.7 + p95: ......................................................................... 65533.7 + p99: ......................................................................... 65533.7 + + +All VUs finished. Total time: 1 minute, 6 seconds + +-------------------------------- +Summary report @ 17:40:40(+0900) +-------------------------------- + +browser.http_requests: ......................................................... 7310 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 0.2 + max: ......................................................................... 0.2 + mean: ........................................................................ 0.2 + median: ...................................................................... 0.2 + p95: ......................................................................... 0.2 + p99: ......................................................................... 0.2 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.CLS.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 0 + max: ......................................................................... 0 + mean: ........................................................................ 0 + median: ...................................................................... 0 + p95: ......................................................................... 0 + p99: ......................................................................... 0 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 52 + max: ......................................................................... 384 + mean: ........................................................................ 193.4 + median: ...................................................................... 194.4 + p95: ......................................................................... 314.2 + p99: ......................................................................... 354.3 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 104 + max: ......................................................................... 27512 + mean: ........................................................................ 3490.4 + median: ...................................................................... 572.6 + p95: ......................................................................... 27181.5 + p99: ......................................................................... 27181.5 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 124 + max: ......................................................................... 396 + mean: ........................................................................ 240.4 + median: ...................................................................... 252.2 + p95: ......................................................................... 295.9 + p99: ......................................................................... 295.9 +browser.page.FCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 1188 + max: ......................................................................... 6176 + mean: ........................................................................ 3699.3 + median: ...................................................................... 4231.1 + p95: ......................................................................... 5065.6 + p99: ......................................................................... 6064.7 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 0 + max: ......................................................................... 24 + mean: ........................................................................ 10.4 + median: ...................................................................... 7.9 + p95: ......................................................................... 16 + p99: ......................................................................... 16 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 8 + max: ......................................................................... 48 + mean: ........................................................................ 21.6 + median: ...................................................................... 16 + p95: ......................................................................... 47.9 + p99: ......................................................................... 47.9 +browser.page.INP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 8 + max: ......................................................................... 32 + mean: ........................................................................ 18.6 + median: ...................................................................... 16 + p95: ......................................................................... 32.1 + p99: ......................................................................... 32.1 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 100 + max: ......................................................................... 972 + mean: ........................................................................ 601.9 + median: ...................................................................... 645.6 + p95: ......................................................................... 925.4 + p99: ......................................................................... 963.1 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 2328 + max: ......................................................................... 6908 + mean: ........................................................................ 4848.2 + median: ...................................................................... 4492.8 + p95: ......................................................................... 6439.7 + p99: ......................................................................... 6569.8 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 156 + max: ......................................................................... 1048 + mean: ........................................................................ 688 + median: ...................................................................... 742.6 + p95: ......................................................................... 804.5 + p99: ......................................................................... 804.5 +browser.page.LCP.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 2428 + max: ......................................................................... 9868 + mean: ........................................................................ 7226.8 + median: ...................................................................... 7407.5 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9801.2 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/: + min: ......................................................................... 32.4 + max: ......................................................................... 279.2 + mean: ........................................................................ 159 + median: ...................................................................... 159.2 + p95: ......................................................................... 267.8 + p99: ......................................................................... 278.7 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/chat: + min: ......................................................................... 27 + max: ......................................................................... 24058.1 + mean: ........................................................................ 2104.8 + median: ...................................................................... 172.5 + p95: ......................................................................... 23630.3 + p99: ......................................................................... 24107.7 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/login: + min: ......................................................................... 48 + max: ......................................................................... 858.8 + mean: ........................................................................ 189.7 + median: ...................................................................... 159.2 + p95: ......................................................................... 478.3 + p99: ......................................................................... 478.3 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/profile: + min: ......................................................................... 29.9 + max: ......................................................................... 151.8 + mean: ........................................................................ 84.5 + median: ...................................................................... 74.4 + p95: ......................................................................... 130.3 + p99: ......................................................................... 133 +browser.page.TTFB.https://chat.goorm-ktb-024.goorm.team/register: + min: ......................................................................... 378.9 + max: ......................................................................... 3422.4 + mean: ........................................................................ 1008.3 + median: ...................................................................... 854.2 + p95: ......................................................................... 2101.1 + p99: ......................................................................... 3262.4 +browser.page.codes.200: ........................................................ 7141 +browser.page.codes.201: ........................................................ 76 +browser.page.codes.304: ........................................................ 87 +browser.page.codes.401: ........................................................ 1 +browser.page.codes.504: ........................................................ 5 +errors.locator.setInputFiles: Timeout 30000ms exceeded.: ....................... 1 +errors.page.goto: Navigation to "https://chat.goorm-ktb-024.goorm.team/login... 9 +errors.page.goto: Navigation to "https://chat.goorm-ktb-024.goorm.team/login... 2 +errors.page.goto: net::ERR_ABORTED at https://chat.goorm-ktb-024.goorm.team/... 4 +errors.page.waitForURL: Timeout 30000ms exceeded.: ............................. 35 +errors.pw_failed_assertion.toBeVisible: ........................................ 5 +vusers.completed: .............................................................. 11 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 +vusers.failed: ................................................................. 56 +vusers.session_length: + min: ......................................................................... 40377.9 + max: ......................................................................... 66140.8 + mean: ........................................................................ 51211.7 + median: ...................................................................... 45720.8 + p95: ......................................................................... 65533.7 + p99: ......................................................................... 65533.7 From 7efd25f0088664616ec4b76918e6272e2a87476e Mon Sep 17 00:00:00 2001 From: gourmet0828 Date: Tue, 11 Aug 2026 10:11:38 +0900 Subject: [PATCH 09/56] perf: reduce chat message database operations --- .../service/MessageReadStatusService.java | 57 ++++++++++++------- .../socketio/handler/ChatMessageHandler.java | 1 - 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java index 4dacf9cc..de290d2e 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReadStatusService.java @@ -1,14 +1,19 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +/* ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ import */ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + /** * ๋ฉ”์‹œ์ง€ ์ฝ์Œ ์ƒํƒœ ๊ด€๋ฆฌ ์„œ๋น„์Šค */ @@ -17,7 +22,7 @@ @RequiredArgsConstructor public class MessageReadStatusService { - private final MessageRepository messageRepository; + private final MongoTemplate mongoTemplate; /** * ๋ฉ”์‹œ์ง€ ์ฝ์Œ ์ƒํƒœ ์—…๋ฐ์ดํŠธ @@ -36,25 +41,35 @@ public void updateReadStatus(List messageIds, String userId) { .build(); try { - for (String messageId : messageIds) { - var messageOptional = messageRepository.findById(messageId); - if (messageOptional.isPresent()) { - var message = messageOptional.get(); - if (message.getReaders() == null) { - message.setReaders(new ArrayList<>()); - } - boolean alreadyRead = message.getReaders().stream() - .anyMatch(r -> r.getUserId().equals(userId)); - if (!alreadyRead) { - message.getReaders().add(readerInfo); - } - messageRepository.save(message); - } - } - log.debug("Read status updated for {} messages by user {}", - messageIds.size(), userId); + Query query = Query.query( + Criteria.where("_id") + .in(messageIds) + .and("readers.userId") + .ne(userId) + ); + + Update update = new Update() + .push("readers", readerInfo); + + var result = mongoTemplate.updateMulti( + query, + update, + Message.class + ); + + log.debug( + "Read status updated: requested={}, modified={}, userId={}", + messageIds.size(), + result.getModifiedCount(), + userId + ); + } catch (Exception e) { - log.error("Read status update error for user {}", userId, e); + log.error( + "Read status update error for user {}", + userId, + e + ); } } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java index 08ae04c2..61b2fcc9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java @@ -173,7 +173,6 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { // AI ๋ฉ˜์…˜ ์ฒ˜๋ฆฌ aiService.handleAIMentions(roomId, socketUser.id(), messageContent); - sessionService.updateLastActivity(socketUser.id()); // Record success metrics recordMessageSuccess(messageType); From 7003ff6bf54a670bbb8b18f8fea980747206d435 Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Tue, 11 Aug 2026 10:23:20 +0900 Subject: [PATCH 10/56] =?UTF-8?q?Perf/chatroom=20list=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatapp/controller/RoomController.java | 17 +- .../com/ktb/chatapp/dto/RoomsResponse.java | 2 +- .../ktb/chatapp/event/RoomCreatedEvent.java | 6 +- .../ktb/chatapp/event/RoomUpdatedEvent.java | 6 +- .../java/com/ktb/chatapp/model/Message.java | 2 + .../main/java/com/ktb/chatapp/model/Room.java | 2 + .../com/ktb/chatapp/service/RoomService.java | 139 +++------- .../static/api/docs/socketio/asyncapi.yaml | 34 ++- .../ktb/chatapp/service/RoomServiceTest.java | 146 +++++----- .../socketio/SocketIOEventListenerTest.java | 4 +- .../features/chat/rooms/ChatRoomsView.js | 19 +- .../features/chat/rooms/RoomsTable.js | 254 ++++++++++++------ .../rooms/__tests__/ChatRoomsView.test.js | 5 + .../chat/rooms/__tests__/useRoomList.test.js | 98 ++++++- .../rooms/__tests__/useRoomsSocket.test.js | 163 +++++------ .../features/chat/rooms/useRoomList.js | 89 +++++- .../features/chat/rooms/useRoomsSocket.js | 23 +- 17 files changed, 633 insertions(+), 376 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java index 8d81ea8f..11d8b9de 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/RoomController.java @@ -81,11 +81,13 @@ public ResponseEntity healthCheck() { } } - // ์ „์ฒด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ - @Operation(summary = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ", description = "์ „์ฒด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. Rate Limit์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") + // ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํŽ˜์ด์ง€ ์กฐํšŒ + @Operation(summary = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ", description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ 20๊ฐœ์”ฉ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. Rate Limit์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", content = @Content(schema = @Schema(implementation = RoomsResponse.class))), + @ApiResponse(responseCode = "400", description = "์œ ํšจํ•˜์ง€ ์•Š์€ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", + content = @Content(schema = @Schema(implementation = StandardResponse.class))), @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content(schema = @Schema(implementation = StandardResponse.class))), @ApiResponse(responseCode = "429", description = "์š”์ฒญ ํ•œ๋„ ์ดˆ๊ณผ", @@ -96,10 +98,17 @@ public ResponseEntity healthCheck() { }) @GetMapping @RateLimit - public ResponseEntity getAllRooms(Principal principal) { + public ResponseEntity getAllRooms( + @Parameter(description = "0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", example = "0") + @RequestParam(defaultValue = "0") int page) { + + if (page < 0) { + return ResponseEntity.badRequest().body( + StandardResponse.error("ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")); + } try { - RoomsResponse response = roomService.getAllRooms(principal.getName()); + RoomsResponse response = roomService.getAllRooms(page); // ์บ์‹œ ์„ค์ • return ResponseEntity.ok() diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java index f986ee9a..d9243b16 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java @@ -13,6 +13,6 @@ @AllArgsConstructor public class RoomsResponse { private boolean success = true; - private List data; + private List data; private PageMetadata metadata; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java index 00d42aca..2a7117b5 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomCreatedEvent.java @@ -1,14 +1,14 @@ package com.ktb.chatapp.event; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import lombok.Getter; import org.springframework.context.ApplicationEvent; @Getter public class RoomCreatedEvent extends ApplicationEvent { - private final RoomResponse roomResponse; + private final RoomListItemResponse roomResponse; - public RoomCreatedEvent(Object source, RoomResponse roomResponse) { + public RoomCreatedEvent(Object source, RoomListItemResponse roomResponse) { super(source); this.roomResponse = roomResponse; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java index 7f4ec48c..0ce0cf91 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/event/RoomUpdatedEvent.java @@ -1,15 +1,15 @@ package com.ktb.chatapp.event; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import lombok.Getter; import org.springframework.context.ApplicationEvent; @Getter public class RoomUpdatedEvent extends ApplicationEvent { private final String roomId; - private final RoomResponse roomResponse; + private final RoomListItemResponse roomResponse; - public RoomUpdatedEvent(Object source, String roomId, RoomResponse roomResponse) { + public RoomUpdatedEvent(Object source, String roomId, RoomListItemResponse roomResponse) { super(source); this.roomId = roomId; this.roomResponse = roomResponse; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java index eb283faf..a2955334 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Message.java @@ -9,6 +9,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.index.CompoundIndex; import java.time.LocalDateTime; import java.util.ArrayList; @@ -26,6 +27,7 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "messages") +@CompoundIndex(name = "messages_room_timestamp", def = "{'room': 1, 'timestamp': 1}") public class Message { @Id diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java index 073cf04e..8532a460 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java @@ -12,12 +12,14 @@ import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.index.CompoundIndex; @Data @Builder @NoArgsConstructor @AllArgsConstructor @Document(collection = "rooms") +@CompoundIndex(name = "rooms_created_at_id_desc", def = "{'createdAt': -1, '_id': -1}") public class Room { @Id diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java index 9323ab2a..65515e6b 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomService.java @@ -8,17 +8,19 @@ import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.repository.UserRepository; 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.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -27,49 +29,41 @@ @RequiredArgsConstructor public class RoomService { + public static final int ROOM_LIST_PAGE_SIZE = 20; + private final RoomRepository roomRepository; private final UserRepository userRepository; private final RecentMessageCounter recentMessageCounter; private final PasswordEncoder passwordEncoder; private final ApplicationEventPublisher eventPublisher; - public RoomsResponse getAllRooms(String name) { + public RoomsResponse getAllRooms(int page) { try { - // ์ „์ฒด ๋ฐฉ์„ ์กฐํšŒํ•ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค - List rooms = roomRepository.findAll(); + PageRequest pageRequest = PageRequest.of( + page, + ROOM_LIST_PAGE_SIZE, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + Page roomPage = roomRepository.findAll(pageRequest); + List rooms = roomPage.getContent(); if (rooms.isEmpty()) { - return successfulRoomsResponse(List.of()); - } - - Set userIds = new HashSet<>(); - Set 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()); - } + return successfulRoomsResponse(List.of(), roomPage); } - Map usersById = new HashMap<>(); - userRepository.findAllById(userIds) - .forEach(user -> usersById.put(user.getId(), user)); + Set roomIds = rooms.stream() + .map(Room::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); Map recentMessageCounts = recentMessageCounter.countRecentMessagesByRoomIds(roomIds); - List roomResponses = rooms.stream() - .map(room -> mapToRoomResponse(room, name, usersById, recentMessageCounts)) - .sorted(Comparator.comparing( - RoomResponse::getCreatedAtDateTime, - Comparator.nullsLast(Comparator.reverseOrder()))) - .collect(Collectors.toList()); + List roomResponses = rooms.stream() + .map(room -> mapToRoomListItemResponse( + room, + recentMessageCounts.getOrDefault(room.getId(), 0))) + .toList(); - return successfulRoomsResponse(roomResponses); + return successfulRoomsResponse(roomResponses, roomPage); } catch (Exception e) { log.error("๋ฐฉ ๋ชฉ๋ก ์กฐํšŒ ์—๋Ÿฌ", e); @@ -144,7 +138,7 @@ public Room createRoom(CreateRoomRequest createRoomRequest, String name) { // Publish event for room created try { - RoomResponse roomResponse = mapToRoomResponse(savedRoom, name); + RoomListItemResponse roomResponse = mapToRoomListItemResponse(savedRoom, 0); eventPublisher.publishEvent(new RoomCreatedEvent(this, roomResponse)); } catch (Exception e) { log.error("roomCreated ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ", e); @@ -183,7 +177,9 @@ public Room joinRoom(String roomId, String password, String name) { // Publish event for room updated try { - RoomResponse roomResponse = mapToRoomResponse(room, name); + RoomListItemResponse roomResponse = mapToRoomListItemResponse( + room, + recentMessageCounter.countRecentMessages(room.getId())); eventPublisher.publishEvent(new RoomUpdatedEvent(this, roomId, roomResponse)); } catch (Exception e) { log.error("roomUpdate ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ", e); @@ -192,80 +188,31 @@ public Room joinRoom(String roomId, String password, String name) { return room; } - private RoomResponse mapToRoomResponse(Room room, String name) { - if (room == null) return null; - - User creator = null; - if (room.getCreator() != null) { - creator = userRepository.findById(room.getCreator()).orElse(null); - } - - List participants = room.getParticipantIds().stream() - .map(userRepository::findById) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - - int recentMessageCount = recentMessageCounter.countRecentMessages(room.getId()); - - return buildRoomResponse(room, name, creator, participants, recentMessageCount); - } - - private RoomResponse mapToRoomResponse( - Room room, - String name, - Map usersById, - Map recentMessageCounts) { - User creator = usersById.get(room.getCreator()); - List 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 participants, - int recentMessageCount) { - - return RoomResponse.builder() + private RoomListItemResponse mapToRoomListItemResponse(Room room, int recentMessageCount) { + return RoomListItemResponse.builder() .id(room.getId()) .name(room.getName() != null ? room.getName() : "์ œ๋ชฉ ์—†์Œ") .hasPassword(room.isHasPassword()) - .creator(creator != null ? UserResponse.builder() - .id(creator.getId()) - .name(creator.getName() != null ? creator.getName() : "์•Œ ์ˆ˜ ์—†์Œ") - .email(creator.getEmail() != null ? creator.getEmail() : "") - .build() : null) - .participants(participants.stream() - .filter(p -> p != null && p.getId() != null) - .map(p -> UserResponse.builder() - .id(p.getId()) - .name(p.getName() != null ? p.getName() : "์•Œ ์ˆ˜ ์—†์Œ") - .email(p.getEmail() != null ? p.getEmail() : "") - .build()) - .collect(Collectors.toList())) + .participantCount(room.getParticipantCount()) .createdAtDateTime(room.getCreatedAt()) - .isCreator(creator != null && creator.getId().equals(name)) .recentMessageCount(recentMessageCount) .build(); } - private RoomsResponse successfulRoomsResponse(List roomResponses) { + private RoomsResponse successfulRoomsResponse( + List roomResponses, + Page roomPage) { PageMetadata metadata = PageMetadata.builder() - .total(roomResponses.size()) - .page(0) - .pageSize(roomResponses.size()) - .totalPages(1) - .hasMore(false) + .total(roomPage.getTotalElements()) + .page(roomPage.getNumber()) + .pageSize(ROOM_LIST_PAGE_SIZE) + .totalPages(roomPage.getTotalPages()) + .hasMore(roomPage.hasNext()) .currentCount(roomResponses.size()) + .sort(PageMetadata.SortInfo.builder() + .field("createdAt") + .order("DESC") + .build()) .build(); return RoomsResponse.builder() diff --git a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml index ae1ecaae..1957ecbe 100644 --- a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml +++ b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml @@ -155,14 +155,14 @@ channels: address: roomCreated messages: roomCreatedResponse: - $ref: '#/components/messages/RoomResponse' + $ref: '#/components/messages/RoomListItemResponse' description: ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ์•Œ๋ฆผ roomUpdated: address: roomUpdated messages: roomUpdatedResponse: - $ref: '#/components/messages/RoomResponse' + $ref: '#/components/messages/RoomListItemResponse' description: ์ฑ„ํŒ…๋ฐฉ ์ •๋ณด ์—…๋ฐ์ดํŠธ roomActivity: @@ -551,13 +551,13 @@ components: items: $ref: '#/components/schemas/UserResponse' - RoomResponse: - name: RoomResponse - title: ์ฑ„ํŒ…๋ฐฉ ์‘๋‹ต - summary: ์ฑ„ํŒ…๋ฐฉ ์ •๋ณด ํŽ˜์ด๋กœ๋“œ + RoomListItemResponse: + name: RoomListItemResponse + title: ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํ•ญ๋ชฉ ์‘๋‹ต + summary: ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์šฉ ๊ฒฝ๋Ÿ‰ ํŽ˜์ด๋กœ๋“œ contentType: application/json payload: - $ref: '#/components/schemas/RoomResponse' + $ref: '#/components/schemas/RoomListItemResponse' RoomActivityResponse: name: RoomActivity @@ -1047,7 +1047,7 @@ components: - messages - hasMore - RoomResponse: + RoomListItemResponse: type: object properties: _id: @@ -1059,27 +1059,23 @@ components: hasPassword: type: boolean description: ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • ์—ฌ๋ถ€ - creator: - $ref: '#/components/schemas/UserResponse' - participants: - type: array - items: - $ref: '#/components/schemas/UserResponse' - description: ์ฐธ๊ฐ€์ž ๋ชฉ๋ก - participantsCount: + participantCount: type: integer description: ์ฐธ๊ฐ€์ž ์ˆ˜ + recentMessageCount: + type: integer + description: ์ตœ๊ทผ 30๋ถ„๊ฐ„ ๋ฉ”์‹œ์ง€ ์ˆ˜ createdAt: type: string format: date-time description: ์ƒ์„ฑ ๋‚ ์งœ - isCreator: - type: boolean - description: ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ์ž์ธ์ง€ ์—ฌ๋ถ€ required: - _id - name - hasPassword + - participantCount + - recentMessageCount + - createdAt ErrorPayload: type: object diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java index fce0c1bc..b514ea54 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java @@ -1,25 +1,22 @@ package com.ktb.chatapp.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import com.ktb.chatapp.dto.RoomsResponse; import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.repository.UserRepository; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.stream.StreamSupport; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,6 +25,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) @@ -53,102 +54,93 @@ void setUp() { } @Test - @DisplayName("์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์˜ ์ƒ์„ฑ์ž์™€ ์ฐธ์—ฌ์ž๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒํ•ด ์‘๋‹ต์„ ์กฐ๋ฆฝํ•œ๋‹ค") - void getAllRooms_LoadsDistinctUsersOnceAndMapsResponses() { - LocalDateTime older = LocalDateTime.of(2026, 8, 9, 10, 0); - LocalDateTime newer = older.plusHours(1); - Room olderRoom = room("room-1", "user-1", Set.of("user-1", "user-2"), older); - Room newerRoom = room("room-2", "user-2", Set.of("user-2", "user-3"), newer); - User user1 = user("user-1", "์‚ฌ์šฉ์ž 1"); - User user2 = user("user-2", "์‚ฌ์šฉ์ž 2"); - User user3 = user("user-3", "์‚ฌ์šฉ์ž 3"); - - when(roomRepository.findAll()).thenReturn(List.of(olderRoom, newerRoom)); - when(userRepository.findAllById(anySet())).thenReturn(List.of(user1, user2, user3)); + @DisplayName("์ฑ„ํŒ…๋ฐฉ์„ ์ตœ์‹ ์ˆœ์œผ๋กœ 20๊ฐœ์”ฉ ์กฐํšŒํ•˜๊ณ  ํ˜„์žฌ ํŽ˜์ด์ง€ ๋ฒ”์œ„๋งŒ ์ง‘๊ณ„ํ•œ๋‹ค") + void getAllRooms_LoadsOnlyRequestedPageInDescendingOrder() { + LocalDateTime createdAt = LocalDateTime.of(2026, 8, 10, 10, 0); + Room room2 = room("room-2", Set.of("user-1", "user-2"), createdAt); + Room room1 = room("room-1", Set.of("user-1"), createdAt); + PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE); + + when(roomRepository.findAll(any(Pageable.class))).thenReturn( + new PageImpl<>(List.of(room2, room1), requestedPage, 42)); when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())) - .thenReturn(Map.of("room-1", 3, "room-2", 5)); + .thenReturn(Map.of("room-2", 5)); - RoomsResponse response = roomService.getAllRooms("user@example.com"); + RoomsResponse response = roomService.getAllRooms(1); assertThat(response.isSuccess()).isTrue(); - assertThat(response.getData()).extracting(RoomResponse::getId) + assertThat(response.getData()).extracting(RoomListItemResponse::getId) .containsExactly("room-2", "room-1"); - assertThat(response.getData().get(0).getCreator().getId()).isEqualTo("user-2"); - assertThat(response.getData().get(0).getParticipants()) - .extracting(participant -> participant.getId()) - .containsExactlyInAnyOrder("user-2", "user-3"); - assertThat(response.getData().get(0).getRecentMessageCount()).isEqualTo(5); - assertThat(response.getMetadata().getTotal()).isEqualTo(2); + assertThat(response.getData()).extracting(RoomListItemResponse::getParticipantCount) + .containsExactly(2, 1); + assertThat(response.getData()).extracting(RoomListItemResponse::getRecentMessageCount) + .containsExactly(5, 0); + assertThat(response.getMetadata().getTotal()).isEqualTo(42); + assertThat(response.getMetadata().getPage()).isEqualTo(1); + assertThat(response.getMetadata().getPageSize()).isEqualTo(20); + assertThat(response.getMetadata().getTotalPages()).isEqualTo(3); + assertThat(response.getMetadata().isHasMore()).isTrue(); assertThat(response.getMetadata().getCurrentCount()).isEqualTo(2); - - @SuppressWarnings("unchecked") - ArgumentCaptor> userIdsCaptor = ArgumentCaptor.forClass(Iterable.class); - verify(userRepository).findAllById(userIdsCaptor.capture()); - assertThat(StreamSupport.stream(userIdsCaptor.getValue().spliterator(), false)) - .containsExactlyInAnyOrder("user-1", "user-2", "user-3"); - verify(userRepository, never()).findById(anyString()); - verify(recentMessageCounter, times(1)) - .countRecentMessagesByRoomIds(Set.of("room-1", "room-2")); - verify(recentMessageCounter, never()).countRecentMessages(anyString()); + assertThat(response.getMetadata().getSort().getField()).isEqualTo("createdAt"); + assertThat(response.getMetadata().getSort().getOrder()).isEqualTo("DESC"); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(roomRepository).findAll(pageableCaptor.capture()); + Pageable pageable = pageableCaptor.getValue(); + assertThat(pageable.getPageNumber()).isEqualTo(1); + assertThat(pageable.getPageSize()).isEqualTo(20); + assertThat(pageable.getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(Sort.Direction.DESC); + assertThat(pageable.getSort().getOrderFor("id").getDirection()) + .isEqualTo(Sort.Direction.DESC); + verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1", "room-2")); + verifyNoInteractions(userRepository); } @Test - @DisplayName("์กฐํšŒ๋˜์ง€ ์•Š์€ ์ƒ์„ฑ์ž๋Š” null๋กœ ๋‘๊ณ  ์กฐํšŒ๋˜์ง€ ์•Š์€ ์ฐธ์—ฌ์ž๋Š” ์ œ์™ธํ•œ๋‹ค") - void getAllRooms_OmitsMissingParticipantsAndKeepsMissingCreatorNull() { - Room room = room("room-1", "missing-creator", Set.of("user-1", "missing-user"), - LocalDateTime.of(2026, 8, 10, 10, 0)); - when(roomRepository.findAll()).thenReturn(List.of(room)); - when(userRepository.findAllById(anySet())).thenReturn(List.of(user("user-1", "์‚ฌ์šฉ์ž 1"))); - when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())).thenReturn(Map.of()); + @DisplayName("๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚œ ๋นˆ ํŽ˜์ด์ง€๋Š” ์ถ”๊ฐ€ ์กฐํšŒ ์—†์ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ํ•จ๊ป˜ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getAllRooms_OutOfRangePageSkipsRelatedQueries() { + PageRequest requestedPage = PageRequest.of(3, RoomService.ROOM_LIST_PAGE_SIZE); + when(roomRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requestedPage, 2)); - RoomsResponse response = roomService.getAllRooms("user@example.com"); + RoomsResponse response = roomService.getAllRooms(3); assertThat(response.isSuccess()).isTrue(); - assertThat(response.getData()).singleElement().satisfies(roomResponse -> { - assertThat(roomResponse.getCreator()).isNull(); - assertThat(roomResponse.getParticipants()) - .extracting(participant -> participant.getId()) - .containsExactly("user-1"); - assertThat(roomResponse.getRecentMessageCount()).isZero(); - }); - verify(userRepository).findAllById(anySet()); - verify(userRepository, never()).findById(anyString()); - verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1")); - verify(recentMessageCounter, never()).countRecentMessages(anyString()); + assertThat(response.getData()).isEmpty(); + assertThat(response.getMetadata().getTotal()).isEqualTo(2); + assertThat(response.getMetadata().getPage()).isEqualTo(3); + assertThat(response.getMetadata().getCurrentCount()).isZero(); + assertThat(response.getMetadata().isHasMore()).isFalse(); + verifyNoInteractions(userRepository, recentMessageCounter); } @Test - @DisplayName("์ฑ„ํŒ…๋ฐฉ์ด ์—†์œผ๋ฉด ์‚ฌ์šฉ์ž์™€ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") - void getAllRooms_EmptyRoomsSkipsUserAndMessageQueries() { - when(roomRepository.findAll()).thenReturn(List.of()); + @DisplayName("๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€๋Š” ๋‹ค์Œ ํŽ˜์ด์ง€๊ฐ€ ์—†๋‹ค๊ณ  ํ‘œ์‹œํ•œ๋‹ค") + void getAllRooms_LastPageHasNoMorePages() { + Room room = room( + "room-21", + Set.of("user-1"), + LocalDateTime.of(2026, 8, 10, 10, 0)); + PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE); + when(roomRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(room), requestedPage, 21)); + when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())).thenReturn(Map.of()); - RoomsResponse response = roomService.getAllRooms("user@example.com"); + RoomsResponse response = roomService.getAllRooms(1); - assertThat(response.isSuccess()).isTrue(); - assertThat(response.getData()).isEmpty(); - assertThat(response.getMetadata().getTotal()).isZero(); - verifyNoInteractions(userRepository, recentMessageCounter); + assertThat(response.getMetadata().getTotalPages()).isEqualTo(2); + assertThat(response.getMetadata().isHasMore()).isFalse(); + verify(userRepository, never()).findAllById(anySet()); } - private Room room( - String id, - String creatorId, - Set participantIds, - LocalDateTime createdAt) { + private Room room(String id, Set participantIds, LocalDateTime createdAt) { return Room.builder() .id(id) .name("์ฑ„ํŒ…๋ฐฉ " + id) - .creator(creatorId) + .creator("user-1") .participantIds(participantIds) .createdAt(createdAt) .build(); } - - private User user(String id, String name) { - return User.builder() - .id(id) - .name(name) - .email(id + "@example.com") - .build(); - } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java index 42453f6a..29e490ae 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java @@ -2,7 +2,7 @@ import com.corundumstudio.socketio.BroadcastOperations; import com.corundumstudio.socketio.SocketIOServer; -import com.ktb.chatapp.dto.RoomResponse; +import com.ktb.chatapp.dto.RoomListItemResponse; import com.ktb.chatapp.event.RoomActivityEvent; import com.ktb.chatapp.event.RoomUpdatedEvent; import com.ktb.chatapp.event.SessionEndedEvent; @@ -70,7 +70,7 @@ void handleSessionEndedEvent_swallowsBroadcastException() { @Test void handleRoomUpdatedEvent_sendsRoomUpdatedToRoomList() { - RoomResponse roomResponse = RoomResponse.builder() + RoomListItemResponse roomResponse = RoomListItemResponse.builder() .id("room-1") .name("Updated room") .build(); diff --git a/apps/frontend/features/chat/rooms/ChatRoomsView.js b/apps/frontend/features/chat/rooms/ChatRoomsView.js index 77c624d6..509e9f72 100644 --- a/apps/frontend/features/chat/rooms/ChatRoomsView.js +++ b/apps/frontend/features/chat/rooms/ChatRoomsView.js @@ -42,12 +42,17 @@ export default function ChatRoomsView({ router }) { const { rooms, setRooms, + metadata, + setMetadata, + currentPage, error, loading, refreshing, - joiningRoom, + joiningRo์„ om, + pageLoading, fetchRooms, refreshRooms, + changePage, handleJoinRoom, } = useRoomList({ currentUser, @@ -134,7 +139,13 @@ export default function ChatRoomsView({ router }) { }; }, [currentUserKey, connectionStatus]); - useRoomsSocket({ currentUser, setConnectionStatus, setRooms }); + useRoomsSocket({ + currentUser, + setConnectionStatus, + setRooms, + currentPage, + setMetadata, + }); return ( 0 ? ( diff --git a/apps/frontend/features/chat/rooms/RoomsTable.js b/apps/frontend/features/chat/rooms/RoomsTable.js index 94bb1422..17aa060c 100644 --- a/apps/frontend/features/chat/rooms/RoomsTable.js +++ b/apps/frontend/features/chat/rooms/RoomsTable.js @@ -4,94 +4,184 @@ import { Button, Text, VStack, HStack } from '@vapor-ui/core'; import * as Table from '@/components/Table'; import { CONNECTION_STATUS } from './useServerConnection'; -const RoomsTable = ({ rooms, connectionStatus, onJoinRoom }) => { +export const getVisiblePages = (currentPage, totalPages) => { + const visibleCount = Math.min(5, totalPages); + const start = Math.max( + 0, + Math.min(currentPage - 2, totalPages - visibleCount) + ); + return Array.from({ length: visibleCount }, (_, index) => start + index); +}; + +const RoomsTable = ({ + rooms, + metadata, + currentPage, + pageLoading, + onPageChange, + connectionStatus, + onJoinRoom, +}) => { if (!rooms || rooms.length === 0) return null; - return ( -

- - - - - - - - + const totalPages = metadata?.totalPages || 0; + const visiblePages = getVisiblePages(currentPage, totalPages); + const visibleRooms = rooms.slice(0, metadata?.pageSize || 20); - - - ์ฑ„ํŒ…๋ฐฉ - ์ฐธ์—ฌ์ž - ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ - ์ƒ์„ฑ์ผ - ์•ก์…˜ - - + return ( + +
+ + + + + + + + - - {rooms.map((room) => ( - - - - {room.name} - {room.hasPassword && ( - - - - ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„์š” - - - )} - - - - - - {room.participants?.length || 0} - - - - {room.recentMessageCount > 0 ? room.recentMessageCount : '-'} - - - - - - - + + + ์ฑ„ํŒ…๋ฐฉ + ์ฐธ์—ฌ์ž + ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ + ์ƒ์„ฑ์ผ + ์•ก์…˜ + + + + {visibleRooms.map((room) => ( + + + + {room.name} + {room.hasPassword && ( + + + + ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„์š” + + + )} + + + + + + {room.participantCount || 0} + + + + {room.recentMessageCount > 0 ? room.recentMessageCount : '-'} + + + + + + + + + ))} + + +
+ + {totalPages > 1 && ( + + + + + {visiblePages.map((page) => ( + ))} - -
-
+ + + + + )} + ); }; diff --git a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js index 5fec65b9..7874a35b 100644 --- a/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/ChatRoomsView.test.js @@ -43,12 +43,17 @@ vi.mock('../useRoomList', () => ({ useRoomList: () => ({ rooms: [], setRooms: vi.fn(), + metadata: { total: 0, page: 0, pageSize: 20, totalPages: 0 }, + setMetadata: vi.fn(), + currentPage: 0, error: mocks.error, loading: false, refreshing: false, joiningRoom: false, + pageLoading: false, fetchRooms: mocks.fetchRooms, refreshRooms: mocks.refreshRooms, + changePage: vi.fn(), handleJoinRoom: vi.fn(), }), })); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js index 9be9d1b4..892fd681 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js @@ -11,7 +11,21 @@ vi.mock('@/services/axios', () => ({ }, })); -const roomsResponse = (rooms) => ({ data: { data: rooms } }); +const roomsResponse = (rooms, metadata = {}) => ({ + data: { + data: rooms, + metadata: { + total: rooms.length, + page: 0, + pageSize: 20, + totalPages: rooms.length > 0 ? 1 : 0, + hasMore: false, + currentCount: rooms.length, + sort: { field: 'createdAt', order: 'DESC' }, + ...metadata, + }, + }, +}); const renderRoomList = () => renderHook(() => @@ -45,6 +59,7 @@ describe('useRoomList', () => { expect(result.current.rooms).toEqual([{ _id: 'room-1' }]); expect(result.current.refreshing).toBe(false); + expect(axiosInstance.get).toHaveBeenCalledWith('/api/rooms', { params: { page: 0 } }); }); it('keeps the current list and stays quiet when a silent refresh fails', async () => { @@ -102,4 +117,85 @@ describe('useRoomList', () => { expect(result.current.error).toBeNull(); expect(result.current.rooms).toEqual([{ _id: 'room-1' }]); }); + + it('loads a selected page and replaces the current rows', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 21, totalPages: 2, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-21' }], + { total: 21, page: 1, totalPages: 2 }, + )); + + await act(async () => { + await result.current.changePage(1); + }); + + expect(axiosInstance.get).toHaveBeenLastCalledWith('/api/rooms', { params: { page: 1 } }); + expect(result.current.currentPage).toBe(1); + expect(result.current.rooms).toEqual([{ _id: 'room-21' }]); + expect(result.current.pageLoading).toBe(false); + }); + + it('moves to the last valid page when the current page becomes empty', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 41, totalPages: 3, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get + .mockResolvedValueOnce(roomsResponse([], { total: 40, page: 2, totalPages: 2 })) + .mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-40' }], + { total: 40, page: 1, totalPages: 2 }, + )); + + await act(async () => { + await result.current.changePage(2); + }); + + expect(axiosInstance.get).toHaveBeenNthCalledWith(3, '/api/rooms', { params: { page: 1 } }); + expect(result.current.currentPage).toBe(1); + expect(result.current.rooms).toEqual([{ _id: 'room-40' }]); + }); + + it('returns to page zero when every room has been removed', async () => { + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [{ _id: 'room-1' }], + { total: 21, totalPages: 2, hasMore: true }, + )); + + const { result } = renderRoomList(); + + await act(async () => { + await result.current.fetchRooms(); + }); + + axiosInstance.get.mockResolvedValueOnce(roomsResponse( + [], + { total: 0, page: 1, totalPages: 0 }, + )); + + await act(async () => { + await result.current.changePage(1); + }); + + expect(result.current.currentPage).toBe(0); + expect(result.current.metadata.page).toBe(0); + expect(result.current.rooms).toEqual([]); + }); }); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js index c8106e6f..c2eed4d4 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomsSocket.test.js @@ -1,119 +1,130 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import socketClient from '@/lib/socket/socketClient'; import { useRoomsSocket } from '../useRoomsSocket'; vi.mock('@/lib/socket/socketClient', () => ({ - default: { - connect: vi.fn(), - }, + default: { connect: vi.fn() }, })); -const currentUser = { - token: 'token-1', - sessionId: 'session-1', -}; - -const renderRoomsSocket = (socket, overrides = {}) => { - socketClient.connect.mockResolvedValue(socket); - - return renderHook(() => - useRoomsSocket({ - currentUser, - router: { push: vi.fn() }, - setConnectionStatus: vi.fn(), - setRooms: vi.fn(), - ...overrides, - }) - ); -}; - -const createSocket = () => ({ - on: vi.fn(), - emit: vi.fn(), - disconnect: vi.fn(), -}); - -const handlerFor = (socket, event) => - socket.on.mock.calls.find(([registered]) => registered === event)[1]; +const currentUser = { token: 'token-1', sessionId: 'session-1' }; describe('useRoomsSocket', () => { + let handlers; + let socket; + beforeEach(() => { vi.clearAllMocks(); - }); - - it('does not emit joinRoomList because the server joins room-list on connect', async () => { - const socket = { - on: vi.fn(), + handlers = {}; + socket = { + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), emit: vi.fn(), disconnect: vi.fn(), }; + socketClient.connect.mockResolvedValue(socket); + }); - renderRoomsSocket(socket); + const renderRoomsSocket = (currentPage = 0) => { + const setRooms = vi.fn(); + const setMetadata = vi.fn(); + const setConnectionStatus = vi.fn(); + const result = renderHook( + ({ page }) => useRoomsSocket({ + currentUser, + currentPage: page, + setRooms, + setMetadata, + setConnectionStatus, + }), + { initialProps: { page: currentPage } }, + ); + return { ...result, setRooms, setMetadata }; + }; - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('connect', expect.any(Function)); - }); + it('does not emit joinRoomList because the server joins room-list on connect', async () => { + renderRoomsSocket(); + await waitFor(() => expect(handlers.connect).toBeTypeOf('function')); - const connectHandler = socket.on.mock.calls.find(([event]) => event === 'connect')[1]; - connectHandler(); + act(() => handlers.connect()); expect(socket.emit).not.toHaveBeenCalledWith('joinRoomList'); }); it('does not register roomDeleted without a server-side room delete event', async () => { - const socket = { - on: vi.fn(), - emit: vi.fn(), - disconnect: vi.fn(), - }; + renderRoomsSocket(); + await waitFor(() => expect(socket.on).toHaveBeenCalled()); - renderRoomsSocket(socket); + expect(Object.keys(handlers)).not.toContain('roomDeleted'); + }); - await waitFor(() => { - expect(socket.on).toHaveBeenCalled(); - }); + it('prepends a created room on the first page and keeps only 20 rows', async () => { + const { setRooms, setMetadata } = renderRoomsSocket(0); + await waitFor(() => expect(handlers.roomCreated).toBeTypeOf('function')); - const registeredEvents = socket.on.mock.calls.map(([event]) => event); - expect(registeredEvents).not.toContain('roomDeleted'); + act(() => handlers.roomCreated({ _id: 'new-room' })); + + const updateRooms = setRooms.mock.calls[0][0]; + const previousRooms = Array.from({ length: 20 }, (_, index) => ({ _id: `room-${index}` })); + expect(updateRooms(previousRooms)).toHaveLength(20); + expect(updateRooms(previousRooms)[0]).toEqual({ _id: 'new-room' }); + + const updateMetadata = setMetadata.mock.calls[0][0]; + expect(updateMetadata({ total: 20, pageSize: 20, totalPages: 1 })).toMatchObject({ + total: 21, + totalPages: 2, + }); }); - it('merges a roomActivity update into the matching room without dropping its other fields', async () => { - const socket = createSocket(); - const setRooms = vi.fn(); + it('does not shift rows when a room is created on a later page', async () => { + const { rerender, setRooms, setMetadata } = renderRoomsSocket(0); + await waitFor(() => expect(handlers.roomCreated).toBeTypeOf('function')); + rerender({ page: 2 }); - renderRoomsSocket(socket, { setRooms }); + act(() => handlers.roomCreated({ _id: 'new-room' })); - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('roomActivity', expect.any(Function)); - }); + expect(setRooms).not.toHaveBeenCalled(); + expect(setMetadata).toHaveBeenCalledTimes(1); + }); + + it('merges room updates only into a matching visible row', async () => { + const { setRooms } = renderRoomsSocket(1); + await waitFor(() => expect(handlers.roomUpdated).toBeTypeOf('function')); - handlerFor(socket, 'roomActivity')({ _id: 'room-2', recentMessageCount: 9 }); + act(() => handlers.roomUpdated({ _id: 'room-2', participantCount: 4 })); const updateRooms = setRooms.mock.calls[0][0]; + expect(updateRooms([ + { _id: 'room-1', participantCount: 1 }, + { _id: 'room-2', participantCount: 3 }, + ])).toEqual([ + { _id: 'room-1', participantCount: 1 }, + { _id: 'room-2', participantCount: 4 }, + ]); + }); - expect( - updateRooms([ - { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, - { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 2 }, - ]) - ).toEqual([ + it('merges a roomActivity update without dropping other fields', async () => { + const { setRooms } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomActivity).toBeTypeOf('function')); + + act(() => handlers.roomActivity({ _id: 'room-2', recentMessageCount: 9 })); + + const updateRooms = setRooms.mock.calls[0][0]; + expect(updateRooms([ + { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, + { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 2 }, + ])).toEqual([ { _id: 'room-1', name: '๋ฐฉ1', recentMessageCount: 1 }, { _id: 'room-2', name: '๋ฐฉ2', recentMessageCount: 9 }, ]); }); it('ignores a roomActivity payload without a room id', async () => { - const socket = createSocket(); - const setRooms = vi.fn(); - - renderRoomsSocket(socket, { setRooms }); - - await waitFor(() => { - expect(socket.on).toHaveBeenCalledWith('roomActivity', expect.any(Function)); - }); + const { setRooms } = renderRoomsSocket(); + await waitFor(() => expect(handlers.roomActivity).toBeTypeOf('function')); - handlerFor(socket, 'roomActivity')(undefined); + act(() => handlers.roomActivity(undefined)); expect(setRooms).not.toHaveBeenCalled(); }); diff --git a/apps/frontend/features/chat/rooms/useRoomList.js b/apps/frontend/features/chat/rooms/useRoomList.js index a4dbf82c..c608c7e0 100644 --- a/apps/frontend/features/chat/rooms/useRoomList.js +++ b/apps/frontend/features/chat/rooms/useRoomList.js @@ -2,6 +2,16 @@ import { useState, useCallback, useRef } from 'react'; import axiosInstance from '@/services/axios'; import { CONNECTION_STATUS } from './useServerConnection'; +export const DEFAULT_PAGE_METADATA = { + total: 0, + page: 0, + pageSize: 20, + totalPages: 0, + hasMore: false, + currentCount: 0, + sort: { field: 'createdAt', order: 'DESC' }, +}; + export const useRoomList = ({ currentUser, router, @@ -16,6 +26,9 @@ export const useRoomList = ({ const [refreshing, setRefreshing] = useState(false); const [isInitialLoad, setIsInitialLoad] = useState(true); const [joiningRoom, setJoiningRoom] = useState(false); + const [pageLoading, setPageLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(0); + const [metadata, setMetadata] = useState(DEFAULT_PAGE_METADATA); const isLoadingRef = useRef(false); @@ -56,17 +69,39 @@ export const useRoomList = ({ setConnectionStatus(CONNECTION_STATUS.ERROR); }, [isRetrying, setConnectionStatus]); - const loadRooms = useCallback(async () => { - await attemptConnection(); - - const response = await axiosInstance.get('/api/rooms'); + const requestRoomsPage = useCallback(async (page) => { + const response = await axiosInstance.get('/api/rooms', { params: { page } }); - if (!response?.data?.data) { + if (!response?.data?.data || !response?.data?.metadata) { throw new Error('INVALID_RESPONSE'); } - setRooms(response.data.data); - }, [attemptConnection]); + return response.data; + }, []); + + const loadRooms = useCallback(async (requestedPage = currentPage) => { + await attemptConnection(); + + let payload = await requestRoomsPage(requestedPage); + let resolvedPage = requestedPage; + + if (requestedPage > 0 && payload.data.length === 0) { + if (payload.metadata.totalPages > 0 && requestedPage >= payload.metadata.totalPages) { + resolvedPage = payload.metadata.totalPages - 1; + payload = await requestRoomsPage(resolvedPage); + } else if (payload.metadata.totalPages === 0) { + resolvedPage = 0; + payload = { + ...payload, + metadata: { ...payload.metadata, page: 0 }, + }; + } + } + + setRooms(payload.data); + setMetadata(payload.metadata); + setCurrentPage(resolvedPage); + }, [attemptConnection, currentPage, requestRoomsPage]); const fetchRooms = useCallback(async () => { if (!currentUser?.token || isLoadingRef.current) { @@ -105,7 +140,7 @@ export const useRoomList = ({ isLoadingRef.current = true; setRefreshing(true); - await loadRooms(); + await loadRooms(currentPage); setError(null); return true; @@ -124,7 +159,38 @@ export const useRoomList = ({ setRefreshing(false); isLoadingRef.current = false; } - }, [currentUser, loadRooms]); + }, [currentUser, currentPage, loadRooms]); + + const changePage = useCallback(async (page) => { + if ( + !currentUser?.token || + isLoadingRef.current || + page < 0 || + page >= metadata.totalPages || + page === currentPage + ) { + return false; + } + + try { + isLoadingRef.current = true; + setPageLoading(true); + await loadRooms(page); + setError(null); + return true; + } catch (error) { + setError({ + title: '์ฑ„ํŒ…๋ฐฉ ํŽ˜์ด์ง€ ์ด๋™ ์‹คํŒจ', + message: '์š”์ฒญํ•œ ํŽ˜์ด์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.', + type: 'warning', + showRetry: false, + }); + return false; + } finally { + setPageLoading(false); + isLoadingRef.current = false; + } + }, [currentUser, currentPage, loadRooms, metadata.totalPages]); const handleJoinRoom = useCallback(async (roomId) => { if (connectionStatus !== CONNECTION_STATUS.CONNECTED) { @@ -165,13 +231,18 @@ export const useRoomList = ({ return { rooms, setRooms, + metadata, + setMetadata, + currentPage, error, setError, loading, refreshing, joiningRoom, + pageLoading, fetchRooms, refreshRooms, + changePage, handleJoinRoom, }; }; diff --git a/apps/frontend/features/chat/rooms/useRoomsSocket.js b/apps/frontend/features/chat/rooms/useRoomsSocket.js index 6290d3c2..8029100f 100644 --- a/apps/frontend/features/chat/rooms/useRoomsSocket.js +++ b/apps/frontend/features/chat/rooms/useRoomsSocket.js @@ -11,8 +11,15 @@ export const useRoomsSocket = ({ currentUser, setConnectionStatus, setRooms, + currentPage, + setMetadata, }) => { const socketRef = useRef(null); + const currentPageRef = useRef(currentPage); + + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); useEffect(() => { if (!currentUser?.token) return; @@ -48,7 +55,21 @@ export const useRoomsSocket = ({ setConnectionStatus(CONNECTION_STATUS.ERROR); }, roomCreated: (newRoom) => { - setRooms((prev) => [newRoom, ...prev]); + setMetadata((prev) => { + const total = prev.total + 1; + return { + ...prev, + total, + totalPages: Math.ceil(total / prev.pageSize), + }; + }); + + if (currentPageRef.current === 0) { + setRooms((prev) => [ + newRoom, + ...prev.filter((room) => room._id !== newRoom._id), + ].slice(0, 20)); + } }, roomUpdated: (updatedRoom) => { setRooms((prev) => From bb219c0949af317c7beb20d3f563f653b70ca7bc Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 10:51:23 +0900 Subject: [PATCH 11/56] =?UTF-8?q?test:=20=ED=94=84=EB=A1=9C=ED=95=84=20API?= =?UTF-8?q?=20=EB=B6=80=ED=95=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/artillery/README.md | 11 +- e2e/artillery/all-scenarios.js | 9 -- e2e/artillery/artillery-config.yaml | 2 +- e2e/artillery/profile-api-spike.js | 154 ++++++++++++++++++++++++++++ e2e/package.json | 3 +- 5 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 e2e/artillery/profile-api-spike.js diff --git a/e2e/artillery/README.md b/e2e/artillery/README.md index b2bf11b1..e502cfed 100644 --- a/e2e/artillery/README.md +++ b/e2e/artillery/README.md @@ -18,6 +18,15 @@ PHASE1_ARRIVAL_COUNT=10 PHASE1_DURATION=30 make artillery ``` + Profile API๋งŒ 100๋ช… Spike๋กœ ์ธก์ •ํ•˜๋ ค๋ฉด ํšŒ์›๊ฐ€์ž…ยท๋กœ๊ทธ์ธ์„ ๋‚ฎ์€ ๋™์‹œ์„ฑ์œผ๋กœ ๋จผ์ € ์ค€๋น„ํ•œ ๋’ค ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. + ์ค€๋น„ ์š”์ฒญ์€ Profile latency ์ธก์ • ๊ตฌ๊ฐ„์—์„œ ์ œ์™ธ๋ฉ๋‹ˆ๋‹ค. + + ```bash + API_URL=https://your-backend.example.com \ + CONCURRENCY=100 SETUP_CONCURRENCY=5 \ + pnpm --filter e2e run load:profile-api + ``` + ## ํ™˜๊ฒฝ ๋ณ€์ˆ˜ Artillery ์‹คํ–‰ ์‹œ ๋‹ค์Œ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: @@ -215,4 +224,4 @@ - https://www.artillery.io/docs - https://playwright.dev/docs/intro - - https://www.artillery.io/docs/reference/engines/playwright \ No newline at end of file + - https://www.artillery.io/docs/reference/engines/playwright diff --git a/e2e/artillery/all-scenarios.js b/e2e/artillery/all-scenarios.js index 20fd4f11..e397f604 100644 --- a/e2e/artillery/all-scenarios.js +++ b/e2e/artillery/all-scenarios.js @@ -66,15 +66,6 @@ async function allScenarios(page, vuContext) { } } -async function profileScenario(page, vuContext) { - const testUser = generateUserSchema(); - vuContext.vars.testUser = testUser; - - await loginScenario(page, vuContext); - await fullProfileUpdateScenario(page, vuContext); -} - module.exports = { allScenarios, - profileScenario, }; diff --git a/e2e/artillery/artillery-config.yaml b/e2e/artillery/artillery-config.yaml index 51ee99cb..94af6719 100644 --- a/e2e/artillery/artillery-config.yaml +++ b/e2e/artillery/artillery-config.yaml @@ -15,4 +15,4 @@ config: scenarios: - engine: playwright - flowFunction: profileScenario + flowFunction: allScenarios diff --git a/e2e/artillery/profile-api-spike.js b/e2e/artillery/profile-api-spike.js new file mode 100644 index 00000000..7cc85ccd --- /dev/null +++ b/e2e/artillery/profile-api-spike.js @@ -0,0 +1,154 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); + +const API_URL = process.env.API_URL || 'http://localhost:5001'; +const CONCURRENCY = Number(process.env.CONCURRENCY || 100); +const SETUP_CONCURRENCY = Number(process.env.SETUP_CONCURRENCY || 5); +const REQUEST_TIMEOUT_MS = Number(process.env.REQUEST_TIMEOUT_MS || 10000); +const IMAGE_PATH = process.env.PROFILE_IMAGE || path.resolve(__dirname, '../fixtures/images/profile.jpg'); +const samples = new Map(); + +function record(name, ms, status, error = null) { + if (!samples.has(name)) samples.set(name, []); + samples.get(name).push({ ms, status, error }); +} + +async function measured(name, route, options = {}) { + const started = performance.now(); + try { + const response = await fetch(`${API_URL}${route}`, { + ...options, + redirect: 'follow', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = await response.text(); + const elapsed = performance.now() - started; + if (!response.ok) { + record(name, elapsed, response.status, body.slice(0, 200)); + const error = new Error(`${name} HTTP ${response.status}: ${body.slice(0, 200)}`); + error.recorded = true; + throw error; + } + record(name, elapsed, response.status); + const contentType = response.headers.get('content-type') || ''; + return { + data: body && contentType.includes('application/json') ? JSON.parse(body) : body, + finalUrl: response.url, + }; + } catch (error) { + if (!error.recorded) record(name, performance.now() - started, 0, error.message); + throw error; + } +} + +async function prepareUser(index, runId) { + const credentials = { + email: `profile_api_${runId}_${index}@example.com`, + password: 'Password123!', + passwordConfirm: 'Password123!', + name: `Profile API ${index}`, + }; + await measured('setup.register', '/api/auth/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(credentials), + }); + const login = await measured('setup.login', '/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: credentials.email, password: credentials.password }), + }); + return { index, token: login.data.token, sessionId: login.data.sessionId }; +} + +async function prepareUsers(runId) { + const users = new Array(CONCURRENCY); + let next = 0; + async function worker() { + while (next < CONCURRENCY) { + const index = next++; + users[index] = await prepareUser(index, runId); + } + } + await Promise.all(Array.from({ length: Math.min(SETUP_CONCURRENCY, CONCURRENCY) }, worker)); + return users; +} + +async function profileFlow(user, imageBytes) { + const authHeaders = { + 'x-auth-token': user.token, + 'x-session-id': user.sessionId, + }; + await measured('profile.get', '/api/users/profile', { headers: authHeaders }); + + const form = new FormData(); + form.append('profileImage', new Blob([imageBytes], { type: 'image/jpeg' }), 'profile.jpg'); + const upload = await measured('profile.upload', '/api/users/profile-image', { + method: 'POST', + headers: authHeaders, + body: form, + }); + + await measured('profile.update', '/api/users/profile', { + method: 'PUT', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ name: `Updated ${user.index}` }), + }); + + await measured('profile.image_get', upload.data.imageUrl); +} + +function percentile(values, percentileValue) { + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return sorted[index]; +} + +function buildReport(results, durationMs, imageSize) { + const report = {}; + for (const [name, rows] of samples) { + const times = rows.map(({ ms }) => ms); + report[name] = { + requests: rows.length, + success: rows.filter(({ status }) => status >= 200 && status < 400).length, + errors: rows.filter(({ status }) => status < 200 || status >= 400).length, + avg_ms: +(times.reduce((sum, value) => sum + value, 0) / times.length).toFixed(1), + p50_ms: +percentile(times, 50).toFixed(1), + p95_ms: +percentile(times, 95).toFixed(1), + p99_ms: +percentile(times, 99).toFixed(1), + max_ms: +Math.max(...times).toFixed(1), + statuses: Object.fromEntries( + [...new Set(rows.map(({ status }) => status))] + .map((status) => [status, rows.filter((row) => row.status === status).length]) + ), + }; + } + return { + target: API_URL, + concurrency: CONCURRENCY, + setup_concurrency: SETUP_CONCURRENCY, + fixture_bytes: imageSize, + profile_duration_ms: +durationMs.toFixed(1), + completed: results.filter(({ status }) => status === 'fulfilled').length, + failed: results.filter(({ status }) => status === 'rejected').length, + report, + }; +} + +(async () => { + const imageBytes = fs.readFileSync(IMAGE_PATH); + const runId = Date.now(); + + // ํšŒ์›๊ฐ€์ž…ยท๋กœ๊ทธ์ธ์€ ๋‚ฎ์€ ๋™์‹œ์„ฑ์œผ๋กœ ๋จผ์ € ๋๋‚ด๊ณ  Profile Spike ์ธก์ •์—์„œ ์ œ์™ธํ•œ๋‹ค. + const users = await prepareUsers(runId); + const started = performance.now(); + const results = await Promise.allSettled(users.map((user) => profileFlow(user, imageBytes))); + const output = buildReport(results, performance.now() - started, imageBytes.length); + + console.log(JSON.stringify(output, null, 2)); + if (output.failed > 0) process.exitCode = 1; +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/e2e/package.json b/e2e/package.json index a4bd6996..36802fd6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,8 @@ "scripts": { "test": "playwright test --grep @smoke", "test:full": "playwright test", - "test:ui": "playwright test --ui" + "test:ui": "playwright test --ui", + "load:profile-api": "node artillery/profile-api-spike.js" }, "devDependencies": { "@playwright/test": "1.61.0", From 9809483e482f91e916791275f5e83737b346856e Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Tue, 11 Aug 2026 11:05:30 +0900 Subject: [PATCH 12/56] =?UTF-8?q?Perf/chatroom=20list=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20-=20RoomListItemResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ktb/chatapp/dto/RoomListItemResponse.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java new file mode 100644 index 00000000..e612ec74 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomListItemResponse.java @@ -0,0 +1,46 @@ +package com.ktb.chatapp.dto; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ํ•ญ๋ชฉ") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomListItemResponse { + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ID", example = "60d5ec49f1b2c8b9e8c4f2a1") + @JsonProperty("_id") + private String id; + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ์ด๋ฆ„", example = "ํ”„๋กœ์ ํŠธ ๋…ผ์˜๋ฐฉ") + private String name; + + @Schema(description = "๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • ์—ฌ๋ถ€", example = "false") + private boolean hasPassword; + + @Schema(description = "์ฐธ์—ฌ์ž ์ˆ˜", example = "5") + private int participantCount; + + @Schema(description = "์ตœ๊ทผ 30๋ถ„๊ฐ„ ๋ฉ”์‹œ์ง€ ์ˆ˜", example = "23") + private int recentMessageCount; + + @JsonIgnore + private LocalDateTime createdAtDateTime; + + @Schema(description = "์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ์‹œ๊ฐ„ (ISO 8601 ํ˜•์‹)", example = "2025-11-18T12:34:56.789Z") + @JsonGetter("createdAt") + public String getCreatedAt() { + return createdAtDateTime == null + ? null + : createdAtDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toString(); + } +} From 6bd1fbd855abb7493c4d1b8f60fe2d6d747deb94 Mon Sep 17 00:00:00 2001 From: gourmet0828 Date: Tue, 11 Aug 2026 11:27:35 +0900 Subject: [PATCH 13/56] perf: reduce chat history user queries --- .../socketio/handler/MessageLoader.java | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java index ec320cc5..89dac9cd 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageLoader.java @@ -8,7 +8,8 @@ import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.MessageReadStatusService; -import jakarta.annotation.Nullable; +import java.util.HashMap; +import java.util.Objects; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -66,11 +67,22 @@ private FetchMessagesResponse loadMessagesInternal( var messageIds = sortedMessages.stream().map(Message::getId).toList(); messageReadStatusService.updateReadStatus(messageIds, userId); - - // ๋ฉ”์‹œ์ง€ ์‘๋‹ต ์ƒ์„ฑ + +// ๋ฉ”์‹œ์ง€ ์ž‘์„ฑ์ž ID๋ฅผ ์ค‘๋ณต ์—†์ด ์ˆ˜์ง‘ํ•œ๋‹ค. + var senderIds = sortedMessages.stream() + .map(Message::getSenderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + +// ๋ฉ”์‹œ์ง€๋งˆ๋‹ค ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๊ณ  ํ•œ ๋ฒˆ์— ์กฐํšŒํ•œ๋‹ค. + var usersById = new HashMap(); + userRepository.findAllById(senderIds) + .forEach(user -> usersById.put(user.getId(), user)); + +// ๋ฉ”์‹œ์ง€ ์‘๋‹ต ์ƒ์„ฑ List messageResponses = sortedMessages.stream() .map(message -> { - var user = findUserById(message.getSenderId()); + var user = usersById.get(message.getSenderId()); return messageResponseMapper.mapToMessageResponse(message, user); }) .collect(Collectors.toList()); @@ -86,15 +98,4 @@ private FetchMessagesResponse loadMessagesInternal( .build(); } - /** - * AI ๊ฒฝ์šฐ null ๋ฐ˜ํ™˜ ๊ฐ€๋Šฅ - */ - @Nullable - private User findUserById(String id) { - if (id == null) { - return null; - } - return userRepository.findById(id) - .orElse(null); - } } From 01bb126857f19dd0fd6bb584ba3c69f137cddf4b Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 11:35:15 +0900 Subject: [PATCH 14/56] perf(profile): prevent duplicate upload retries --- apps/frontend/components/ProfileImageUpload.js | 3 +++ apps/frontend/lib/api/client.js | 6 +++++- apps/frontend/package.json | 2 +- deploy-frontend.sh | 4 +++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/frontend/components/ProfileImageUpload.js b/apps/frontend/components/ProfileImageUpload.js index 3fee04b4..bc13e1c8 100644 --- a/apps/frontend/components/ProfileImageUpload.js +++ b/apps/frontend/components/ProfileImageUpload.js @@ -73,6 +73,9 @@ const ProfileImageUpload = ({ currentImage, onImageChange }) => { '/api/users/profile-image', formData, { + // ํŒŒ์ผ ์—…๋กœ๋“œ๋Š” ์„œ๋ฒ„์— ์ €์žฅ๋œ ๋’ค ์‘๋‹ต๋งŒ ์œ ์‹ค๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ž๋™ ์žฌ์‹œ๋„ํ•˜์ง€ ์•Š๋Š”๋‹ค. + // ์žฌ์‹œ๋„๋Š” ์ค‘๋ณต ๊ฐ์ฒด์™€ ์ถ”๊ฐ€ ๋ถ€ํ•˜๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค. + skipRetry: true, headers: { 'Content-Type': 'multipart/form-data', }, diff --git a/apps/frontend/lib/api/client.js b/apps/frontend/lib/api/client.js index 7e0f7c42..e9da1475 100644 --- a/apps/frontend/lib/api/client.js +++ b/apps/frontend/lib/api/client.js @@ -93,7 +93,11 @@ export const createApiClient = ({ return Promise.reject(error); } - if (isRetryableError(error) && config.retryCount < RETRY_CONFIG.maxRetries) { + if ( + config.skipRetry !== true && + isRetryableError(error) && + config.retryCount < RETRY_CONFIG.maxRetries + ) { config.retryCount++; const delay = getRetryDelay(config.retryCount); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 914e133f..f30534e9 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "test": "vitest run --passWithNoTests", "format": "prettier . --write --ignore-path .prettierignore", - "build:production": "test -f .env.production || { echo '.env.production ์ด ์—†์Šต๋‹ˆ๋‹ค. cp .env.production.example .env.production ํ›„ ๋ฐฐํฌ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ์ฑ„์šฐ์„ธ์š”.' >&2; exit 1; } && env $(grep -vE '^[[:space:]]*(#|$)' .env.production | xargs) next build && pnpm run deploy:static", + "build:production": "test -f .env.production || { echo '.env.production ์ด ์—†์Šต๋‹ˆ๋‹ค. cp .env.production.example .env.production ํ›„ ๋ฐฐํฌ ์ฃผ์†Œ๋กœ ๊ฐ’์„ ์ฑ„์šฐ์„ธ์š”.' >&2; exit 1; } && env $(grep -vE '^[[:space:]]*(#|$)' .env.production | xargs) next build --webpack && pnpm run deploy:static", "deploy:static": "cp -r .next/static .next/standalone/apps/frontend/.next/ && cp -r public .next/standalone/apps/frontend/" }, "dependencies": { diff --git a/deploy-frontend.sh b/deploy-frontend.sh index 83196972..183ae61b 100755 --- a/deploy-frontend.sh +++ b/deploy-frontend.sh @@ -7,7 +7,9 @@ REMOTE_HOST="${REMOTE_HOST:-ktb-frontend}" REMOTE_DIR="${REMOTE_DIR:-/home/ubuntu/ktb-chat-frontend}" cd "$FRONTEND_DIR" -pnpm build +# NEXT_PUBLIC_* ๊ฐ’์€ Next.js ๋นŒ๋“œ ์‹œ์ ์— ๋ฒˆ๋“ค์— ๊ณ ์ •๋œ๋‹ค. +# ๋ฐฐํฌ ์ „์šฉ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์‚ฌ์šฉํ•ด .env.production ๋ˆ„๋ฝ์„ ๋ฐฐํฌ ์ „์— ๊ฒ€์ถœํ•œ๋‹ค. +pnpm run build:production ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" rsync -az --delete .next/standalone/ "$REMOTE_HOST:$REMOTE_DIR/" From 69d7898e8aa65b9d34f7bee02d071fac76a390ba Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Tue, 11 Aug 2026 11:46:41 +0900 Subject: [PATCH 15/56] =?UTF-8?q?perf:=20=ED=86=B0=EC=BA=A3=20=EB=B0=8F=20?= =?UTF-8?q?MongoDB=20=ED=92=80=20=EC=84=A4=EC=A0=95=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/.env.template | 10 ++++++++++ .../java/com/ktb/chatapp/config/MongoConfig.java | 16 ++++++++++++++++ .../src/main/resources/application.properties | 13 +++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/backend/.env.template b/apps/backend/.env.template index 02074fa4..904beb28 100644 --- a/apps/backend/.env.template +++ b/apps/backend/.env.template @@ -5,6 +5,11 @@ ENCRYPTION_SALT=change_me_32_hex_chars________________ JWT_SECRET=change_me_secure_jwt_secret MONGO_URI=mongodb://localhost:27017/bootcamp-chat + +MONGO_MAX_POOL_SIZE=100 +MONGO_MIN_POOL_SIZE=0 +MONGO_MAX_WAIT_TIME_MS=120000 + REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=ktb @@ -23,3 +28,8 @@ S3_KEY_PREFIX= S3_PRESIGN_TTL=10m OPENAI_API_KEY=sk-... + +TOMCAT_THREADS_MAX=50 +TOMCAT_THREADS_MIN_SPARE=5 +TOMCAT_ACCEPT_COUNT=100 +TOMCAT_MAX_CONNECTIONS=200 diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java index 19757fd5..b094ff24 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/MongoConfig.java @@ -1,9 +1,25 @@ package com.ktb.chatapp.config; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.mongodb.autoconfigure.MongoClientSettingsBuilderCustomizer; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.EnableMongoAuditing; @Configuration @EnableMongoAuditing public class MongoConfig { + + @Bean + MongoClientSettingsBuilderCustomizer mongoConnectionPoolCustomizer( + @Value("${app.mongodb.pool.max-size}") int maxPoolSize, + @Value("${app.mongodb.pool.min-size}") int minPoolSize, + @Value("${app.mongodb.pool.max-wait-time-ms}") long maxWaitTimeMs) { + return builder -> builder.applyToConnectionPoolSettings(pool -> pool + .maxSize(maxPoolSize) + .minSize(minPoolSize) + .maxWaitTime(maxWaitTimeMs, TimeUnit.MILLISECONDS)); + } } diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index a0fffbe3..c88cd940 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -1,16 +1,21 @@ # Server Configuration server.port=${PORT:5001} -server.tomcat.threads.max=50 -server.tomcat.threads.min-spare=5 -server.tomcat.accept-count=100 -server.tomcat.max-connections=200 +# Tomcat Configuration +server.tomcat.threads.max=${TOMCAT_THREADS_MAX:50} +server.tomcat.threads.min-spare=${TOMCAT_THREADS_MIN_SPARE:5} +server.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100} +server.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:200} # Database Configuration # Spring Boot 4: connection settings moved from spring.data.mongodb.* to spring.mongodb.* spring.mongodb.uri=${MONGO_URI} spring.data.mongodb.auto-index-creation=true +app.mongodb.pool.max-size=${MONGO_MAX_POOL_SIZE:100} +app.mongodb.pool.min-size=${MONGO_MIN_POOL_SIZE:0} +app.mongodb.pool.max-wait-time-ms=${MONGO_MAX_WAIT_TIME_MS:120000} + # Redis Configuration spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} From 1eb4f5c8321d341751998e2f09ffd789597f10a6 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 12:59:28 +0900 Subject: [PATCH 16/56] test(profile): support resized image fixtures --- e2e/artillery/profile-api-spike.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/artillery/profile-api-spike.js b/e2e/artillery/profile-api-spike.js index 7cc85ccd..059bd44c 100644 --- a/e2e/artillery/profile-api-spike.js +++ b/e2e/artillery/profile-api-spike.js @@ -7,6 +7,8 @@ const CONCURRENCY = Number(process.env.CONCURRENCY || 100); const SETUP_CONCURRENCY = Number(process.env.SETUP_CONCURRENCY || 5); const REQUEST_TIMEOUT_MS = Number(process.env.REQUEST_TIMEOUT_MS || 10000); const IMAGE_PATH = process.env.PROFILE_IMAGE || path.resolve(__dirname, '../fixtures/images/profile.jpg'); +const IMAGE_CONTENT_TYPE = process.env.PROFILE_IMAGE_CONTENT_TYPE + || (path.extname(IMAGE_PATH).toLowerCase() === '.webp' ? 'image/webp' : 'image/jpeg'); const samples = new Map(); function record(name, ms, status, error = null) { @@ -83,7 +85,7 @@ async function profileFlow(user, imageBytes) { await measured('profile.get', '/api/users/profile', { headers: authHeaders }); const form = new FormData(); - form.append('profileImage', new Blob([imageBytes], { type: 'image/jpeg' }), 'profile.jpg'); + form.append('profileImage', new Blob([imageBytes], { type: IMAGE_CONTENT_TYPE }), path.basename(IMAGE_PATH)); const upload = await measured('profile.upload', '/api/users/profile-image', { method: 'POST', headers: authHeaders, From baad5824ec9aee8a9b03af378607426a4995132f Mon Sep 17 00:00:00 2001 From: sodam2z Date: Tue, 11 Aug 2026 13:44:58 +0900 Subject: [PATCH 17/56] =?UTF-8?q?fix:=20E2E=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/actions/auth.actions.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/actions/auth.actions.js b/e2e/actions/auth.actions.js index fd5f0a88..074754c2 100644 --- a/e2e/actions/auth.actions.js +++ b/e2e/actions/auth.actions.js @@ -29,7 +29,12 @@ async function registerAction(page, userData) { await page.getByTestId('register-password-confirm-input').fill(userData.passwordConfirm); await page.getByTestId('register-name-input').fill(userData.name); await page.getByTestId('register-submit-button').click(); - await page.waitForTimeout(1000); // ์ž ์‹œ ๋Œ€๊ธฐ + // ์„ฑ๊ณต ์‹œ ์•ฑ์ด 1000ms ๋’ค router.push('/login')๋กœ ์ด๋™ํ•˜๋ฏ€๋กœ, ๊ทธ ๋‚ด๋น„๊ฒŒ์ด์…˜์ด ๋๋‚œ ๋’ค์— + // ๋‹ค์Œ goto๊ฐ€ ์‹คํ–‰๋˜์–ด์•ผ ๊ฒฝํ•ฉ(net::ERR_ABORTED)์ด ์—†๋‹ค. ์‹คํŒจ ์‹œ์—” ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ๊นŒ์ง€ ๋Œ€๊ธฐ. + await Promise.race([ + page.waitForURL(`${BASE_URL}/login`, { timeout: 10000 }).catch(() => {}), + page.getByTestId('register-error-message').waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}), + ]); } /** From 663154e09cd7be934833e0e0cdf50c5929f33778 Mon Sep 17 00:00:00 2001 From: gourmet0828 Date: Tue, 11 Aug 2026 13:49:42 +0900 Subject: [PATCH 18/56] perf: batch room activity message counts --- .../com/ktb/chatapp/ChatAppApplication.java | 2 + .../chatapp/service/RoomActivityNotifier.java | 69 +++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java b/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java index 7bdccaf1..cbeb555c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/ChatAppApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class ChatAppApplication { public static void main(String[] args) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java index 1687bc7e..a10fc302 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java @@ -1,13 +1,19 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.event.RoomActivityEvent; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** - * ์ƒˆ ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋˜๋ฉด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก์˜ ํ™œ์„ฑ๋„ ์ง€ํ‘œ๋ฅผ ๊ฐฑ์‹ ํ•˜๋„๋ก ์•Œ๋ฆฐ๋‹ค. + * ์ƒˆ ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋œ ์ฑ„ํŒ…๋ฐฉ์„ ๋ชจ์•„ + * ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ผ๊ด„ ๊ฐฑ์‹ ํ•œ๋‹ค. */ @Slf4j @Component @@ -17,16 +23,69 @@ public class RoomActivityNotifier { private final RecentMessageCounter recentMessageCounter; private final ApplicationEventPublisher eventPublisher; + private final Set pendingRoomIds = + ConcurrentHashMap.newKeySet(); + + /** + * ๋ฉ”์‹œ์ง€ ์ €์žฅ ๊ฒฝ๋กœ์—์„œ๋Š” DB ์กฐํšŒ๋ฅผ ํ•˜์ง€ ์•Š๊ณ  + * ๊ฐฑ์‹ ์ด ํ•„์š”ํ•œ ์ฑ„ํŒ…๋ฐฉ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. + */ public void notifyMessageStored(String roomId) { if (roomId == null) { return; } + pendingRoomIds.add(roomId); + } + + /** + * ์งง์€ ์ฃผ๊ธฐ๋กœ ๋ณ€๊ฒฝ๋œ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ + * ํ•œ ๋ฒˆ์˜ ์ง‘๊ณ„ ์ฟผ๋ฆฌ๋กœ ์กฐํšŒํ•œ๋‹ค. + */ + @Scheduled(fixedDelay = 100) + void flushPendingRoomActivities() { + if (pendingRoomIds.isEmpty()) { + return; + } + + Set roomIds = new HashSet<>(); + + for (String roomId : pendingRoomIds) { + if (pendingRoomIds.remove(roomId)) { + roomIds.add(roomId); + } + } + + if (roomIds.isEmpty()) { + return; + } + try { - int recentMessageCount = recentMessageCounter.countRecentMessages(roomId); - eventPublisher.publishEvent(new RoomActivityEvent(this, roomId, recentMessageCount)); + Map recentMessageCounts = + recentMessageCounter.countRecentMessagesByRoomIds(roomIds); + + for (String roomId : roomIds) { + int recentMessageCount = + recentMessageCounts.getOrDefault(roomId, 0); + + eventPublisher.publishEvent( + new RoomActivityEvent( + this, + roomId, + recentMessageCount + ) + ); + } + } catch (Exception e) { - log.error("roomActivity ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ: roomId={}", roomId, e); + // ์ผ์‹œ์ ์œผ๋กœ ์ง‘๊ณ„์— ์‹คํŒจํ•˜๋ฉด ๋‹ค์Œ ์ฃผ๊ธฐ์— ๋‹ค์‹œ ์‹œ๋„ํ•œ๋‹ค. + pendingRoomIds.addAll(roomIds); + + log.error( + "roomActivity ์ผ๊ด„ ๊ฐฑ์‹  ์‹คํŒจ: roomIds={}", + roomIds, + e + ); } } -} +} \ No newline at end of file From fbbcb2911c00dab5690e716fe85e6d37d29a1a75 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 14:01:59 +0900 Subject: [PATCH 19/56] fix(socket): preserve room membership on reconnect --- .../socketio/UserConnectionLocks.java | 34 +++++++++++++ .../handler/ConnectionLoginHandler.java | 51 +++++++++++-------- .../handler/ConnectionLoginHandlerTest.java | 30 +++++++++-- 3 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java new file mode 100644 index 00000000..dfc2225f --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserConnectionLocks.java @@ -0,0 +1,34 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.util.concurrent.locks.ReentrantLock; +import org.springframework.stereotype.Component; + +/** + * Serializes connection lifecycle changes for the same user. + * + *

A fixed number of stripes avoids retaining one lock per user forever while + * still allowing unrelated users to connect and disconnect concurrently.

+ */ +@Component +public class UserConnectionLocks { + + private static final int STRIPE_COUNT = 256; + + private final ReentrantLock[] locks = new ReentrantLock[STRIPE_COUNT]; + + public UserConnectionLocks() { + for (int i = 0; i < locks.length; i++) { + locks[i] = new ReentrantLock(); + } + } + + public void withLock(String userId, Runnable action) { + ReentrantLock lock = locks[Math.floorMod(userId.hashCode(), locks.length)]; + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java index aa09d64b..b4d0ed24 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java @@ -5,6 +5,7 @@ import com.corundumstudio.socketio.annotation.OnDisconnect; import com.ktb.chatapp.websocket.socketio.ConnectedUsers; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import com.ktb.chatapp.websocket.socketio.UserRooms; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; @@ -31,21 +32,21 @@ public class ConnectionLoginHandler { private final SocketIOServer socketIOServer; private final ConnectedUsers connectedUsers; private final UserRooms userRooms; + private final UserConnectionLocks userConnectionLocks; private final RoomJoinHandler roomJoinHandler; - private final RoomLeaveHandler roomLeaveHandler; public ConnectionLoginHandler( SocketIOServer socketIOServer, ConnectedUsers connectedUsers, UserRooms userRooms, + UserConnectionLocks userConnectionLocks, RoomJoinHandler roomJoinHandler, - RoomLeaveHandler roomLeaveHandler, MeterRegistry meterRegistry) { this.socketIOServer = socketIOServer; this.connectedUsers = connectedUsers; this.userRooms = userRooms; + this.userConnectionLocks = userConnectionLocks; this.roomJoinHandler = roomJoinHandler; - this.roomLeaveHandler = roomLeaveHandler; // Register gauge metric for concurrent users Gauge.builder("socketio.concurrent.users", connectedUsers::size) @@ -60,16 +61,21 @@ public void onConnect(SocketIOClient client, SocketUser user) { String userId = user.id(); try { - // ๋‹ค๋ฅธ ๋…ธ๋“œ์— ์ ‘์†๋œ ์‚ฌ์šฉ์ž๋Š” ํ†ต๋ณด ๋ถˆ๊ฐ€ - notifyDuplicateLogin(client, userId); client.set("user", user); - - userRooms.get(userId).forEach(roomId -> { - // ์žฌ์ ‘์† ์‹œ ๊ธฐ์กด ์ฐธ์—ฌ ๋ฐฉ ์žฌ์ž…์žฅ ์ฒ˜๋ฆฌ - roomJoinHandler.handleJoinRoom(client, roomId); + + userConnectionLocks.withLock(userId, () -> { + // ๋‹ค๋ฅธ ๋…ธ๋“œ์— ์ ‘์†๋œ ์‚ฌ์šฉ์ž๋Š” ํ†ต๋ณด ๋ถˆ๊ฐ€ + notifyDuplicateLogin(client, userId); + + // Publish the new owner before restoring rooms. A late disconnect + // from the old socket must observe this socket as the active one. + connectedUsers.set(userId, user); + + userRooms.get(userId).forEach(roomId -> { + // ์žฌ์ ‘์† ์‹œ ๊ธฐ์กด ์ฐธ์—ฌ ๋ฐฉ ์žฌ์ž…์žฅ ์ฒ˜๋ฆฌ + roomJoinHandler.handleJoinRoom(client, roomId); + }); }); - - connectedUsers.set(userId, user); log.info("Socket.IO user connected: {} ({}) - Total concurrent users: {}", getUserName(client), userId, connectedUsers.size()); @@ -94,18 +100,19 @@ public void onDisconnect(SocketIOClient client) { return; } - userRooms.get(userId).forEach(roomId -> { - roomLeaveHandler.handleLeaveRoom(client, roomId); - }); String socketId = client.getSessionId().toString(); - - // ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ํ˜„์žฌ ํ™œ์„ฑ ์—ฐ๊ฒฐ์ธ ๊ฒฝ์šฐ์—๋งŒ ์ •๋ฆฌ - var socketUser = connectedUsers.get(userId); - if (socketUser != null && socketId.equals(socketUser.socketId())) { - connectedUsers.del(userId); - } else { - log.warn("Socket.IO disconnect: User {} has a different active connection. Skipping cleanup.", userId); - } + + userConnectionLocks.withLock(userId, () -> { + // Disconnect only releases connection ownership. Room membership + // is persistent and is removed exclusively by an explicit LEAVE_ROOM. + var socketUser = connectedUsers.get(userId); + if (socketUser != null && socketId.equals(socketUser.socketId())) { + connectedUsers.del(userId); + } else { + log.info("Socket.IO disconnect: User {} has a different active connection. " + + "Skipping connection cleanup.", userId); + } + }); client.leaveRooms(Set.of("user:" + userId, "room-list")); client.del("user"); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java index 50fb2c07..24156941 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java @@ -4,6 +4,7 @@ import com.corundumstudio.socketio.SocketIOServer; import com.ktb.chatapp.websocket.socketio.ConnectedUsers; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import com.ktb.chatapp.websocket.socketio.UserRooms; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.util.Set; @@ -15,6 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -24,7 +26,6 @@ class ConnectionLoginHandlerTest { @Mock private ConnectedUsers connectedUsers; @Mock private UserRooms userRooms; @Mock private RoomJoinHandler roomJoinHandler; - @Mock private RoomLeaveHandler roomLeaveHandler; @Mock private SocketIOClient client; private ConnectionLoginHandler handler; @@ -35,8 +36,8 @@ void setUp() { socketIOServer, connectedUsers, userRooms, + new UserConnectionLocks(), roomJoinHandler, - roomLeaveHandler, new SimpleMeterRegistry()); } @@ -57,20 +58,39 @@ void onConnect_setsUserRejoinsRoomsStoresUserAndJoinsUserRooms() { } @Test - void onDisconnect_removesCurrentConnectionAndLeavesRooms() { + void onDisconnect_removesOnlyCurrentConnectionAndPreservesRoomMembership() { UUID socketId = UUID.randomUUID(); SocketUser user = new SocketUser("user-1", "tester", "session-1", socketId.toString()); when(client.get("user")).thenReturn(user); - when(userRooms.get(user.id())).thenReturn(Set.of("room-1")); when(client.getSessionId()).thenReturn(socketId); when(connectedUsers.get(user.id())).thenReturn(user); handler.onDisconnect(client); - verify(roomLeaveHandler).handleLeaveRoom(client, "room-1"); verify(connectedUsers).del(user.id()); + verify(userRooms, never()).remove(user.id(), "room-1"); verify(client).leaveRooms(Set.of("user:" + user.id(), "room-list")); verify(client).del("user"); verify(client).disconnect(); } + + @Test + void onDisconnect_staleSocketDoesNotDeleteActiveConnectionOrRoomMembership() { + UUID staleSocketId = UUID.randomUUID(); + SocketUser staleUser = new SocketUser( + "user-1", "tester", "session-1", staleSocketId.toString()); + SocketUser activeUser = new SocketUser( + "user-1", "tester", "session-2", UUID.randomUUID().toString()); + when(client.get("user")).thenReturn(staleUser); + when(client.getSessionId()).thenReturn(staleSocketId); + when(connectedUsers.get(staleUser.id())).thenReturn(activeUser); + + handler.onDisconnect(client); + + verify(connectedUsers, never()).del(staleUser.id()); + verify(userRooms, never()).remove(staleUser.id(), "room-1"); + verify(client).leaveRooms(Set.of("user:" + staleUser.id(), "room-list")); + verify(client).del("user"); + verify(client).disconnect(); + } } From bbc01df519f3838a139eb299f0fc25fb41a273e2 Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Tue, 11 Aug 2026 14:55:15 +0900 Subject: [PATCH 20/56] =?UTF-8?q?chore:=20=ED=86=B0=EC=BA=A3,=20=EB=AA=BD?= =?UTF-8?q?=EA=B3=A0=EB=94=94=EB=B9=84=20=ED=92=80=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EB=B0=B0=ED=8F=AC=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=84=A4=EC=A0=95=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/test/resources/application.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index 862048be..579b5c90 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -6,6 +6,9 @@ app.encryption.key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa app.encryption.salt=3132333435363738 app.jwt.secret=test-jwt-secret-for-backend-modernization-0123456789 app.jwt.expiration-ms=86400000 +app.mongodb.pool.max-size=${MONGO_MAX_POOL_SIZE:100} +app.mongodb.pool.min-size=${MONGO_MIN_POOL_SIZE:0} +app.mongodb.pool.max-wait-time-ms=${MONGO_MAX_WAIT_TIME_MS:120000} spring.ai.openai.api-key=${OPENAI_API_KEY:your_openai_api_key_here} springdoc.api-docs.path=/api/v3/api-docs springdoc.swagger-ui.path=/api/swagger-ui.html @@ -16,4 +19,3 @@ management.endpoints.web.exposure.include=health,info,metrics,prometheus management.endpoints.access.default=read-only management.endpoint.prometheus.access=read-only management.prometheus.metrics.export.enabled=true - From 5586f34a43bc47b2a8a81060e31c587341ae82be Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:01:03 +0900 Subject: [PATCH 21/56] feat: enable backend horizontal scaling --- .../ktb/chatapp/config/RedissonConfig.java | 28 ++++ .../ktb/chatapp/config/SocketIOConfig.java | 25 ++- .../java/com/ktb/chatapp/model/Session.java | 2 +- .../chatapp/repository/RoomRepository.java | 7 +- .../service/MessageReactionService.java | 31 ++++ .../ktb/chatapp/service/RateLimitService.java | 67 ++------ .../ktb/chatapp/service/SessionService.java | 14 +- .../ratelimit/RateLimitConsumption.java | 6 + .../ratelimit/RateLimitMongoStore.java | 49 ++++++ .../service/ratelimit/RateLimitStore.java | 19 +++ .../service/session/SessionMongoStore.java | 14 +- .../chatapp/service/session/SessionStore.java | 2 + .../websocket/socketio/ChatDataStore.java | 37 +++++ .../websocket/socketio/ConnectedUsers.java | 8 + .../socketio/LocalChatDataStore.java | 38 +++++ .../socketio/RedisChatDataStore.java | 75 +++++++++ .../chatapp/websocket/socketio/UserRooms.java | 17 +- .../websocket/socketio/ai/AiService.java | 6 +- .../handler/ConnectionLoginHandler.java | 63 +++----- .../handler/MessageReactionHandler.java | 28 ++-- .../socketio/handler/RoomJoinHandler.java | 7 +- .../socketio/handler/RoomLeaveHandler.java | 10 +- .../src/main/resources/application.properties | 1 + .../service/RateLimitServiceUnitTest.java | 151 +++--------------- .../service/SessionServiceUnitTest.java | 7 +- .../handler/ConnectionLoginHandlerTest.java | 25 +-- .../handler/MessageReactionHandlerTest.java | 15 +- .../socketio/handler/RoomJoinHandlerTest.java | 2 +- .../handler/RoomLeaveHandlerTest.java | 2 +- .../src/test/resources/application.properties | 2 +- 30 files changed, 452 insertions(+), 306 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java new file mode 100644 index 00000000..8a2a2625 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.ktb.chatapp.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "socketio.store.type", havingValue = "redis", matchIfMissing = true) +public class RedissonConfig { + + @Bean(destroyMethod = "shutdown") + RedissonClient redissonClient( + @Value("${spring.data.redis.host:localhost}") String host, + @Value("${spring.data.redis.port:6379}") int port, + @Value("${spring.data.redis.password:}") String password) { + Config config = new Config(); + var server = config.useSingleServer().setAddress("redis://" + host + ":" + port); + if (StringUtils.hasText(password)) { + server.setPassword(password); + } + return Redisson.create(config); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java index 1ffbaf5e..2be153e2 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java @@ -7,6 +7,7 @@ import com.corundumstudio.socketio.namespace.Namespace; import com.corundumstudio.socketio.protocol.JacksonJsonSupport; import com.corundumstudio.socketio.store.MemoryStoreFactory; +import com.corundumstudio.socketio.store.RedissonStoreFactory; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.ktb.chatapp.websocket.socketio.ChatDataStore; import com.ktb.chatapp.websocket.socketio.LocalChatDataStore; @@ -14,12 +15,14 @@ import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Role; +import org.redisson.api.RedissonClient; import static org.springframework.beans.factory.config.BeanDefinition.ROLE_INFRASTRUCTURE; @@ -37,8 +40,12 @@ public class SocketIOConfig { @Value("${socketio.server.origin:*}") private String origin; + @Value("${socketio.store.type:redis}") + private String storeType; + @Bean(initMethod = "start", destroyMethod = "stop") - public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterRegistry meterRegistry) { + public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterRegistry meterRegistry, + ObjectProvider redissonClientProvider) { com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration(); config.setHostname(host); config.setPort(port); @@ -59,7 +66,18 @@ public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterR config.setUpgradeTimeout(10000); config.setJsonSupport(new JacksonJsonSupport(new JavaTimeModule())); - config.setStoreFactory(new MemoryStoreFactory()); // ๋‹จ์ผ๋…ธ๋“œ ์ „์šฉ + if ("redis".equalsIgnoreCase(storeType)) { + RedissonClient redissonClient = redissonClientProvider.getIfAvailable(); + if (redissonClient == null) { + throw new IllegalStateException("socketio.store.type=redis requires a RedissonClient"); + } + config.setStoreFactory(new RedissonStoreFactory(redissonClient)); + } else if ("memory".equalsIgnoreCase(storeType)) { + log.warn("Socket.IO is using the single-node memory store"); + config.setStoreFactory(new MemoryStoreFactory()); + } else { + throw new IllegalArgumentException("Unsupported socketio.store.type: " + storeType); + } log.info("Socket.IO server configured on {}:{} with {} boss threads and {} worker threads", host, port, config.getBossThreads(), config.getWorkerThreads()); @@ -89,9 +107,8 @@ public BeanPostProcessor springAnnotationScanner(@Lazy SocketIOServer socketIOSe return new SpringAnnotationScanner(socketIOServer); } - // ์ธ๋ฉ”๋ชจ๋ฆฌ ์ €์žฅ์†Œ, ๋‹จ์ผ ๋…ธ๋“œ ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉ @Bean - @ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty(name = "socketio.store.type", havingValue = "memory") public ChatDataStore chatDataStore() { return new LocalChatDataStore(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java index eb6ceff5..8962eede 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Session.java @@ -32,6 +32,6 @@ public class Session { private SessionMetadata metadata; - @Indexed(expireAfter = SESSION_TTL) + @Indexed(expireAfter = "0s") private Instant expiresAt; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java index dcf7f335..0f6c9c86 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/repository/RoomRepository.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; @Repository public interface RoomRepository extends MongoRepository { @@ -19,11 +20,13 @@ public interface RoomRepository extends MongoRepository { @Query(value = "{}", fields = "{ '_id': 1 }") Optional findOneForHealthCheck(); + List findByParticipantIdsContaining(String userId); + @Query("{'_id': ?0}") @Update("{'$addToSet': {'participantIds': ?1}}") - void addParticipant(String roomId, String userId); + long addParticipant(String roomId, String userId); @Query("{'_id': ?0}") @Update("{'$pull': {'participantIds': ?1}}") - void removeParticipant(String roomId, String userId); + long removeParticipant(String roomId, String userId); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java new file mode 100644 index 00000000..da5fb0ac --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/MessageReactionService.java @@ -0,0 +1,31 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.model.Message; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MessageReactionService { + + private final MongoTemplate mongoTemplate; + + public Optional update(String messageId, String reaction, String userId, boolean add) { + if (reaction == null || reaction.isBlank() || reaction.contains(".") || reaction.startsWith("$")) { + throw new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฆฌ์•ก์…˜์ž…๋‹ˆ๋‹ค."); + } + + Query query = Query.query(Criteria.where("_id").is(messageId)); + String field = "reactions." + reaction; + Update update = add ? new Update().addToSet(field, userId) : new Update().pull(field, userId); + Message updated = mongoTemplate.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), Message.class); + return Optional.ofNullable(updated); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java index bc1d9a72..5bc05cce 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RateLimitService.java @@ -1,17 +1,11 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.model.RateLimit; import com.ktb.chatapp.service.ratelimit.RateLimitStore; -import jakarta.annotation.PostConstruct; import java.time.Duration; import java.time.Instant; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static java.net.InetAddress.*; @Slf4j @Service @@ -19,68 +13,27 @@ public class RateLimitService { private final RateLimitStore rateLimitStore; - @Value("${HOSTNAME:''}") - private String hostName; - - @PostConstruct - public void init() { - if (!hostName.isEmpty()) { - return; - } - hostName = generateHostname(); - } - - private String generateHostname() { - try { - return getLocalHost().getHostName(); - } catch (Exception e) { - return "unknown-" + java.util.UUID.randomUUID().toString().substring(0, 8); - } - } - - - @Transactional public RateLimitCheckResult checkRateLimit(String _clientId, int maxRequests, Duration window) { - String actualClientId = hostName + ":" + _clientId; - Duration effectiveWindow = window != null ? window : Duration.ofSeconds(1); + String actualClientId = String.valueOf(_clientId); + Duration effectiveWindow = window == null || window.isZero() || window.isNegative() + ? Duration.ofSeconds(1) : window; long windowSeconds = Math.max(1L, effectiveWindow.getSeconds()); Instant now = Instant.now(); long nowEpochSeconds = now.getEpochSecond(); - Instant expiresAt = now.plusSeconds(windowSeconds); try { - RateLimit rateLimit = rateLimitStore.findByClientId(actualClientId).orElse(null); - if (rateLimit != null && !rateLimit.getExpiresAt().isAfter(now)) { - rateLimit.setCount(0); - rateLimit.setExpiresAt(expiresAt); - } - - int currentCount = rateLimit != null ? rateLimit.getCount() : 0; - - if (rateLimit != null && currentCount >= maxRequests) { + var consumption = rateLimitStore.consume(actualClientId, maxRequests, effectiveWindow, now); + if (!consumption.allowed()) { long retryAfterSeconds = Math.max(1L, - rateLimit.getExpiresAt().getEpochSecond() - nowEpochSeconds); - long resetEpochSeconds = rateLimit.getExpiresAt().getEpochSecond(); + consumption.expiresAt().getEpochSecond() - nowEpochSeconds); + long resetEpochSeconds = consumption.expiresAt().getEpochSecond(); return RateLimitCheckResult.rejected( maxRequests, windowSeconds, resetEpochSeconds, retryAfterSeconds); } - // Create or update rate limit - if (rateLimit == null) { - rateLimit = RateLimit.builder() - .clientId(actualClientId) - .count(1) - .expiresAt(expiresAt) - .build(); - } else { - rateLimit.setCount(currentCount + 1); - } - rateLimitStore.save(rateLimit); - - int newCount = currentCount + 1; - int remaining = Math.max(0, maxRequests - newCount); - long ttlSeconds = Math.max(1L, rateLimit.getExpiresAt().getEpochSecond() - nowEpochSeconds); - long resetEpochSeconds = rateLimit.getExpiresAt().getEpochSecond(); + int remaining = Math.max(0, maxRequests - consumption.count()); + long ttlSeconds = Math.max(1L, consumption.expiresAt().getEpochSecond() - nowEpochSeconds); + long resetEpochSeconds = consumption.expiresAt().getEpochSecond(); return RateLimitCheckResult.allowed( maxRequests, remaining, windowSeconds, resetEpochSeconds, ttlSeconds); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java index 8effbbe1..d6d944f8 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/SessionService.java @@ -95,9 +95,11 @@ public SessionValidationResult validateSession(String userId, String sessionId) // ์ธ์ฆ ์š”์ฒญ๋งˆ๋‹ค MongoDB write๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค. ์ตœ๊ทผ์— ๊ฐฑ์‹ ๋œ ์„ธ์…˜์€ ์ฝ๊ธฐ๋งŒ ํ•˜๊ณ  // ์ผ์ • ์‹œ๊ฐ„์ด ์ง€๋‚œ ๊ฒฝ์šฐ์—๋งŒ lastActivity์™€ TTL์„ ์—ฐ์žฅํ•œ๋‹ค. if (now - session.getLastActivity() >= ACTIVITY_WRITE_INTERVAL_MS) { - session.setLastActivity(now); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - session = sessionStore.save(session); + session = sessionStore.touch(userId, sessionId, now, + Instant.now().plusSeconds(SESSION_TTL_SEC)).orElse(null); + if (session == null) { + return SessionValidationResult.invalid("INVALID_SESSION", "์„ธ์…˜์ด ๊ต์ฒด๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } } SessionData sessionData = toSessionData(session); @@ -122,9 +124,9 @@ public void updateLastActivity(String userId) { return; } - session.setLastActivity(Instant.now().toEpochMilli()); - session.setExpiresAt(Instant.now().plusSeconds(SESSION_TTL_SEC)); - sessionStore.save(session); + long now = Instant.now().toEpochMilli(); + sessionStore.touch(userId, session.getSessionId(), now, + Instant.now().plusSeconds(SESSION_TTL_SEC)); } catch (Exception e) { log.error("Failed to update session activity for user: {}", userId, e); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java new file mode 100644 index 00000000..02c4b730 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitConsumption.java @@ -0,0 +1,6 @@ +package com.ktb.chatapp.service.ratelimit; + +import java.time.Instant; + +public record RateLimitConsumption(int count, Instant expiresAt, boolean allowed) { +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java index 7a363d3b..9c0b35a6 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java @@ -3,7 +3,15 @@ import com.ktb.chatapp.model.RateLimit; import com.ktb.chatapp.repository.RateLimitRepository; import java.util.Optional; +import java.time.Duration; +import java.time.Instant; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Component; /** @@ -15,6 +23,7 @@ public class RateLimitMongoStore implements RateLimitStore { private final RateLimitRepository rateLimitRepository; + private final MongoTemplate mongoTemplate; @Override public Optional findByClientId(String clientId) { @@ -25,4 +34,44 @@ public Optional findByClientId(String clientId) { public RateLimit save(RateLimit rateLimit) { return rateLimitRepository.save(rateLimit); } + + @Override + public RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now) { + Instant expiresAt = now.plus(window); + + Query activeBelowLimit = Query.query(Criteria.where("clientId").is(clientId) + .and("expiresAt").gt(now).and("count").lt(maxRequests)); + RateLimit updated = mongoTemplate.findAndModify(activeBelowLimit, + new Update().inc("count", 1), + FindAndModifyOptions.options().returnNew(true), RateLimit.class); + if (updated != null) { + return result(updated, true); + } + + Query expired = Query.query(Criteria.where("clientId").is(clientId) + .and("expiresAt").lte(now)); + updated = mongoTemplate.findAndModify(expired, + new Update().set("count", 1).set("expiresAt", expiresAt), + FindAndModifyOptions.options().returnNew(true), RateLimit.class); + if (updated != null) { + return result(updated, true); + } + + RateLimit existing = rateLimitRepository.findByClientId(clientId).orElse(null); + if (existing != null) { + return result(existing, false); + } + + try { + RateLimit created = rateLimitRepository.insert(RateLimit.builder() + .clientId(clientId).count(1).expiresAt(expiresAt).build()); + return result(created, true); + } catch (DuplicateKeyException race) { + return consume(clientId, maxRequests, window, now); + } + } + + private RateLimitConsumption result(RateLimit value, boolean allowed) { + return new RateLimitConsumption(value.getCount(), value.getExpiresAt(), allowed); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java index d30b50a8..0e24a609 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java @@ -2,6 +2,8 @@ import com.ktb.chatapp.model.RateLimit; import java.util.Optional; +import java.time.Duration; +import java.time.Instant; /** * Data store interface for rate limit storage. @@ -24,4 +26,21 @@ public interface RateLimitStore { * @return the saved rate limit */ RateLimit save(RateLimit rateLimit); + + default RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now) { + RateLimit rateLimit = findByClientId(clientId).orElse(null); + Instant expiresAt = now.plus(window); + if (rateLimit == null) { + rateLimit = RateLimit.builder().clientId(clientId).count(1).expiresAt(expiresAt).build(); + } else if (!rateLimit.getExpiresAt().isAfter(now)) { + rateLimit.setCount(1); + rateLimit.setExpiresAt(expiresAt); + } else if (rateLimit.getCount() >= maxRequests) { + return new RateLimitConsumption(rateLimit.getCount(), rateLimit.getExpiresAt(), false); + } else { + rateLimit.setCount(rateLimit.getCount() + 1); + } + RateLimit saved = save(rateLimit); + return new RateLimitConsumption(saved.getCount(), saved.getExpiresAt(), true); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java index d5d0d479..bfe94f79 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java @@ -50,13 +50,19 @@ public Session replaceByUserId(Session session) { Session.class ); } + + @Override + public Optional touch(String userId, String sessionId, long lastActivity, java.time.Instant expiresAt) { + Query query = Query.query(Criteria.where("userId").is(userId).and("sessionId").is(sessionId)); + Update update = new Update().set("lastActivity", lastActivity).set("expiresAt", expiresAt); + return Optional.ofNullable(mongoTemplate.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), Session.class)); + } @Override public void delete(String userId, String sessionId) { - Session session = sessionRepository.findByUserId(userId).orElse(null); - if (session != null && sessionId.equals(session.getSessionId())) { - sessionRepository.delete(session); - } + Query query = Query.query(Criteria.where("userId").is(userId).and("sessionId").is(sessionId)); + mongoTemplate.remove(query, Session.class); } @Override diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java index b3e3d666..1fe38c57 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java @@ -32,6 +32,8 @@ public interface SessionStore { * @return the stored session */ Session replaceByUserId(Session session); + + Optional touch(String userId, String sessionId, long lastActivity, java.time.Instant expiresAt); /** * Delete all sessions for a user diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java index 4766e052..9c1ed0c3 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java @@ -25,6 +25,12 @@ public interface ChatDataStore { * @param value the value to store */ void set(String key, Object value); + + default T getAndSet(String key, T value, Class type) { + T previous = get(key, type).orElse(null); + set(key, value); + return previous; + } /** * Delete a value by key @@ -32,6 +38,37 @@ public interface ChatDataStore { * @param key the storage key */ void delete(String key); + + default boolean compareAndDelete(String key, Object expectedValue) { + Optional current = get(key, Object.class); + if (current.isPresent() && current.get().equals(expectedValue)) { + delete(key); + return true; + } + return false; + } + + default java.util.Set getSet(String key) { + return get(key, java.util.Set.class) + .map(value -> new java.util.HashSet<>((java.util.Set) value)) + .orElseGet(java.util.HashSet::new); + } + + default void addToSet(String key, String value) { + java.util.Set values = getSet(key); + values.add(value); + set(key, values); + } + + default void removeFromSet(String key, String value) { + java.util.Set values = getSet(key); + values.remove(value); + if (values.isEmpty()) { + delete(key); + } else { + set(key, values); + } + } int size(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java index d4c4e9c3..c7f32dc5 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java @@ -20,10 +20,18 @@ public SocketUser get(String userId) { public void set(String userId, SocketUser sockerUser) { chatDataStore.set(buildKey(userId), sockerUser); } + + public SocketUser replace(String userId, SocketUser socketUser) { + return chatDataStore.getAndSet(buildKey(userId), socketUser, SocketUser.class); + } public void del(String userId) { chatDataStore.delete(buildKey(userId)); } + + public boolean delIfCurrent(String userId, SocketUser socketUser) { + return chatDataStore.compareAndDelete(buildKey(userId), socketUser); + } public int size() { return chatDataStore.size(); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java index 3fbb7efb..1fd92993 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/LocalChatDataStore.java @@ -1,6 +1,8 @@ package com.ktb.chatapp.websocket.socketio; import java.util.Optional; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** @@ -29,6 +31,42 @@ public Optional get(String key, Class type) { public void set(String key, Object value) { storage.put(key, value); } + + @Override + public T getAndSet(String key, T value, Class type) { + Object previous = storage.put(key, value); + return type.isInstance(previous) ? type.cast(previous) : null; + } + + @Override + public boolean compareAndDelete(String key, Object expectedValue) { + return storage.remove(key, expectedValue); + } + + @Override + public Set getSet(String key) { + Object value = storage.get(key); + return value instanceof Set set ? new HashSet<>((Set) set) : new HashSet<>(); + } + + @Override + public void addToSet(String key, String value) { + storage.compute(key, (ignored, current) -> { + Set values = current instanceof Set set + ? new HashSet<>((Set) set) : new HashSet<>(); + values.add(value); + return values; + }); + } + + @Override + public void removeFromSet(String key, String value) { + storage.computeIfPresent(key, (ignored, current) -> { + Set values = new HashSet<>((Set) current); + values.remove(value); + return values.isEmpty() ? null : values; + }); + } @Override public void delete(String key) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java new file mode 100644 index 00000000..673d3141 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java @@ -0,0 +1,75 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.time.Duration; +import java.util.Optional; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "socketio.store.type", havingValue = "redis", matchIfMissing = true) +public class RedisChatDataStore implements ChatDataStore { + + private static final Duration PRESENCE_TTL = Duration.ofMinutes(35); + private final RedissonClient redissonClient; + + public RedisChatDataStore(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Override + public Optional get(String key, Class type) { + Object value = redissonClient.getBucket(key).get(); + return type.isInstance(value) ? Optional.of(type.cast(value)) : Optional.empty(); + } + + @Override + public void set(String key, Object value) { + redissonClient.getBucket(key).set(value, PRESENCE_TTL); + } + + @Override + public T getAndSet(String key, T value, Class type) { + Object previous = redissonClient.getBucket(key).getAndSet(value, PRESENCE_TTL); + return type.isInstance(previous) ? type.cast(previous) : null; + } + + @Override + public void delete(String key) { + redissonClient.getBucket(key).delete(); + } + + @Override + public boolean compareAndDelete(String key, Object expectedValue) { + return redissonClient.getBucket(key).compareAndSet(expectedValue, null); + } + + @Override + public java.util.Set getSet(String key) { + return redissonClient.getSet(key).readAll(); + } + + @Override + public void addToSet(String key, String value) { + var values = redissonClient.getSet(key); + values.add(value); + } + + @Override + public void removeFromSet(String key, String value) { + var values = redissonClient.getSet(key); + values.remove(value); + if (values.isEmpty()) { + values.delete(); + } + } + + @Override + public int size() { + int count = 0; + for (String ignored : redissonClient.getKeys().getKeysByPattern("conn_users:userid:*")) { + count++; + } + return count; + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java index bcacb5a4..3fe8a287 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java @@ -1,6 +1,5 @@ package com.ktb.chatapp.websocket.socketio; -import java.util.HashSet; import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -28,9 +27,7 @@ public class UserRooms { */ @SuppressWarnings("unchecked") public Set get(String userId) { - return chatDataStore.get(buildKey(userId), Set.class) - .map(obj -> (Set) obj) - .orElse(new HashSet<>()); + return chatDataStore.getSet(buildKey(userId)); } /** @@ -40,9 +37,7 @@ public Set get(String userId) { * @param roomId the room ID to add to the user's room set */ public void add(String userId, String roomId) { - Set rooms = new HashSet<>(get(userId)); - rooms.add(roomId); - chatDataStore.set(buildKey(userId), rooms); + chatDataStore.addToSet(buildKey(userId), roomId); } /** @@ -52,13 +47,7 @@ public void add(String userId, String roomId) { * @param roomId the room ID to remove */ public void remove(String userId, String roomId) { - Set rooms = new HashSet<>(get(userId)); - rooms.remove(roomId); - if (rooms.isEmpty()) { - chatDataStore.delete(buildKey(userId)); - } else { - chatDataStore.set(buildKey(userId), rooms); - } + chatDataStore.removeFromSet(buildKey(userId), roomId); } /** diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java index 70642f44..45c92053 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ai/AiService.java @@ -11,6 +11,7 @@ import com.ktb.chatapp.websocket.socketio.handler.StreamingSession; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -50,9 +51,9 @@ public void handleAIMentions(String roomId, String userId, MessageContent messag private void startStreaming(String roomId, String userId, String aiType, String query) { - // AI ์ŠคํŠธ๋ฆฌ๋ฐ ์„ธ์…˜ ์ƒ์„ฑ - messageId๋Š” ํƒ€์ž…๊ณผ ํƒ€์ž„์Šคํƒฌํ”„ ์กฐํ•ฉ + // ์ธ์Šคํ„ด์Šค์™€ ์‹œ๊ณ„์— ๋…๋ฆฝ์ ์ธ ์ „์—ญ ์‹๋ณ„์ž๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. var timestamp = System.currentTimeMillis(); - String messageId = aiType + "-" + timestamp; + String messageId = UUID.randomUUID().toString(); log.info("AI response started - messageId: {}, room: {}, aiType: {}, query: {}", messageId, roomId, aiType, query); @@ -123,6 +124,7 @@ public void onAiMessageCompleteEvent(AiMessageCompleteEvent event) { private Message getMessage(AiMessageCompleteEvent event) { Message aiMessage = new Message(); + aiMessage.setId(event.getMessageId()); aiMessage.setRoomId(event.getRoomId()); aiMessage.setContent(event.getContent()); aiMessage.setType(MessageType.ai); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java index b4d0ed24..2adc687c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java @@ -7,12 +7,13 @@ import com.ktb.chatapp.websocket.socketio.SocketUser; import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import com.ktb.chatapp.websocket.socketio.UserRooms; +import com.ktb.chatapp.repository.RoomRepository; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import java.time.Duration; import java.util.Map; import java.util.Set; -import java.util.UUID; +import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -34,6 +35,7 @@ public class ConnectionLoginHandler { private final UserRooms userRooms; private final UserConnectionLocks userConnectionLocks; private final RoomJoinHandler roomJoinHandler; + private final RoomRepository roomRepository; public ConnectionLoginHandler( SocketIOServer socketIOServer, @@ -41,12 +43,14 @@ public ConnectionLoginHandler( UserRooms userRooms, UserConnectionLocks userConnectionLocks, RoomJoinHandler roomJoinHandler, + RoomRepository roomRepository, MeterRegistry meterRegistry) { this.socketIOServer = socketIOServer; this.connectedUsers = connectedUsers; this.userRooms = userRooms; this.userConnectionLocks = userConnectionLocks; this.roomJoinHandler = roomJoinHandler; + this.roomRepository = roomRepository; // Register gauge metric for concurrent users Gauge.builder("socketio.concurrent.users", connectedUsers::size) @@ -64,14 +68,13 @@ public void onConnect(SocketIOClient client, SocketUser user) { client.set("user", user); userConnectionLocks.withLock(userId, () -> { - // ๋‹ค๋ฅธ ๋…ธ๋“œ์— ์ ‘์†๋œ ์‚ฌ์šฉ์ž๋Š” ํ†ต๋ณด ๋ถˆ๊ฐ€ - notifyDuplicateLogin(client, userId); + // Atomic replacement works across nodes when RedisChatDataStore is active. + SocketUser previousUser = connectedUsers.replace(userId, user); + notifyDuplicateLogin(client, previousUser); - // Publish the new owner before restoring rooms. A late disconnect - // from the old socket must observe this socket as the active one. - connectedUsers.set(userId, user); - - userRooms.get(userId).forEach(roomId -> { + roomRepository.findByParticipantIdsContaining(userId).stream() + .map(com.ktb.chatapp.model.Room::getId).forEach(roomId -> { + userRooms.add(userId, roomId); // ์žฌ์ ‘์† ์‹œ ๊ธฐ์กด ์ฐธ์—ฌ ๋ฐฉ ์žฌ์ž…์žฅ ์ฒ˜๋ฆฌ roomJoinHandler.handleJoinRoom(client, roomId); }); @@ -80,7 +83,7 @@ public void onConnect(SocketIOClient client, SocketUser user) { log.info("Socket.IO user connected: {} ({}) - Total concurrent users: {}", getUserName(client), userId, connectedUsers.size()); - client.joinRooms(Set.of("user:" + userId, "room-list")); + client.joinRooms(Set.of("user:" + userId, "socket:" + user.socketId(), "room-list")); } catch (Exception e) { log.error("Error handling Socket.IO connection", e); @@ -105,16 +108,13 @@ public void onDisconnect(SocketIOClient client) { userConnectionLocks.withLock(userId, () -> { // Disconnect only releases connection ownership. Room membership // is persistent and is removed exclusively by an explicit LEAVE_ROOM. - var socketUser = connectedUsers.get(userId); - if (socketUser != null && socketId.equals(socketUser.socketId())) { - connectedUsers.del(userId); - } else { + if (!connectedUsers.delIfCurrent(userId, getUserDto(client))) { log.info("Socket.IO disconnect: User {} has a different active connection. " + "Skipping connection cleanup.", userId); } }); - client.leaveRooms(Set.of("user:" + userId, "room-list")); + client.leaveRooms(Set.of("user:" + userId, "socket:" + socketId, "room-list")); client.del("user"); client.disconnect(); @@ -143,40 +143,23 @@ private String getUserName(SocketIOClient client) { return user != null ? user.name() : null; } - /** - * TODO ๋ฉ€ํ‹ฐ ํด๋Ÿฌ์Šคํ„ฐ์—์„œ ๋™์ž‘ ์•ˆํ•จ - * socketIOServer.getRoomOperations("user:" + userId) ๋กœ ์ฒ˜๋ฆฌ ๋ณ€๊ฒฝ. - */ - private void notifyDuplicateLogin(SocketIOClient client, String userId) { - var socketUser = connectedUsers.get(userId); - if (socketUser == null) { - return; - } - String existingSocketId = socketUser.socketId(); - SocketIOClient existingClient = socketIOServer.getClient(UUID.fromString(existingSocketId)); - if (existingClient == null) { + private void notifyDuplicateLogin(SocketIOClient client, SocketUser previousUser) { + if (previousUser == null) { return; } - - // Send duplicate login notification - existingClient.sendEvent(DUPLICATE_LOGIN, Map.of( + + String targetRoom = "socket:" + previousUser.socketId(); + socketIOServer.getRoomOperations(targetRoom).sendEvent(DUPLICATE_LOGIN, Map.of( "type", "new_login_attempt", "deviceInfo", client.getHandshakeData().getHttpHeaders().get("User-Agent"), "ipAddress", client.getRemoteAddress().toString(), "timestamp", System.currentTimeMillis() )); - - new Thread(() -> { - try { - Thread.sleep(Duration.ofSeconds(10)); - existingClient.sendEvent(SESSION_ENDED, Map.of( + + CompletableFuture.delayedExecutor(Duration.ofSeconds(10).toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .execute(() -> socketIOServer.getRoomOperations(targetRoom).sendEvent(SESSION_ENDED, Map.of( "reason", "duplicate_login", "message", "๋‹ค๋ฅธ ๊ธฐ๊ธฐ์—์„œ ๋กœ๊ทธ์ธํ•˜์—ฌ ํ˜„์žฌ ์„ธ์…˜์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." - )); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Error in duplicate login notification thread", e); - } - }).start(); + ))); } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java index cecfc5b5..9cdc99d7 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandler.java @@ -6,7 +6,7 @@ import com.ktb.chatapp.dto.MessageReactionRequest; import com.ktb.chatapp.dto.MessageReactionResponse; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.service.MessageReactionService; import com.ktb.chatapp.websocket.socketio.SocketUser; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -27,7 +27,7 @@ public class MessageReactionHandler { private final SocketIOServer socketIOServer; - private final MessageRepository messageRepository; + private final MessageReactionService messageReactionService; @OnEvent(MESSAGE_REACTION) public void handleMessageReaction(SocketIOClient client, MessageReactionRequest data) { @@ -38,26 +38,26 @@ public void handleMessageReaction(SocketIOClient client, MessageReactionRequest return; } - Message message = messageRepository.findById(data.getMessageId()).orElse(null); - if (message == null) { - client.sendEvent(ERROR, Map.of("message", "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - return; - } - - switch (data.getType()) { - case "add" -> message.addReaction(data.getReaction(), userId); - case "remove" -> message.removeReaction(data.getReaction(), userId); + boolean add = switch (data.getType()) { + case "add" -> true; + case "remove" -> false; case null, default -> { client.sendEvent(ERROR, Map.of("message", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋ฆฌ์•ก์…˜ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.")); - return; + yield false; } + }; + if (!"add".equals(data.getType()) && !"remove".equals(data.getType())) return; + + Message message = messageReactionService + .update(data.getMessageId(), data.getReaction(), userId, add).orElse(null); + if (message == null) { + client.sendEvent(ERROR, Map.of("message", "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + return; } log.debug("Message reaction processed - type: {}, reaction: {}, messageId: {}, userId: {}", data.getType(), data.getReaction(), message.getId(), userId); - messageRepository.save(message); - MessageReactionResponse response = new MessageReactionResponse( message.getId(), message.getReactions() diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java index fe0e4f7b..0d86f25e 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandler.java @@ -64,16 +64,15 @@ public void handleJoinRoom(SocketIOClient client, String roomId) { return; } - // ์ด๋ฏธ ํ•ด๋‹น ๋ฐฉ์— ์ฐธ์—ฌ ์ค‘์ธ์ง€ ํ™•์ธ - if (userRooms.isInRoom(userId, roomId)) { + // MongoDB์˜ ์›์ž์  $addToSet ๊ฒฐ๊ณผ๋ฅผ ์ฐธ๊ฐ€ ์—ฌ๋ถ€์˜ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + if (roomRepository.addParticipant(roomId, userId) == 0) { log.debug("User {} already in room {}", userId, roomId); client.joinRoom(roomId); + userRooms.add(userId, roomId); client.sendEvent(JOIN_ROOM_SUCCESS, Map.of("roomId", roomId)); return; } - roomRepository.addParticipant(roomId, userId); - // Join socket room and add to user's room set client.joinRoom(roomId); userRooms.add(userId, roomId); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java index 0d9bab6c..360afc7f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java @@ -54,11 +54,6 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { return; } - if (!userRooms.isInRoom(userId, roomId)) { - log.debug("User {} is not in room {}", userId, roomId); - return; - } - User user = userRepository.findById(userId).orElse(null); Room room = roomRepository.findById(roomId).orElse(null); @@ -67,7 +62,10 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) { return; } - roomRepository.removeParticipant(roomId, userId); + if (roomRepository.removeParticipant(roomId, userId) == 0) { + log.debug("User {} is not in room {}", userId, roomId); + return; + } client.leaveRoom(roomId); userRooms.remove(userId, roomId); diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index c88cd940..dce17606 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -52,6 +52,7 @@ spring.ai.openai.chat.options.temperature=${OPENAI_TEMPERATURE:0.7} socketio.server.host=0.0.0.0 socketio.server.port=${WS_PORT:5002} socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} +socketio.store.type=${SOCKETIO_STORE_TYPE:redis} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java index e4ac1b4d..d314ac4e 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java @@ -1,178 +1,69 @@ package com.ktb.chatapp.service; -import com.ktb.chatapp.model.RateLimit; +import com.ktb.chatapp.service.ratelimit.RateLimitConsumption; import com.ktb.chatapp.service.ratelimit.RateLimitStore; import java.time.Duration; import java.time.Instant; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("RateLimitService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") class RateLimitServiceUnitTest { - private static final String HOST_NAME = "test-host"; - private static final String CLIENT_ID = "client-1"; - private static final String STORE_CLIENT_ID = HOST_NAME + ":" + CLIENT_ID; - - @Mock - private RateLimitStore rateLimitStore; - - private RateLimitService rateLimitService; + @Mock private RateLimitStore rateLimitStore; + private RateLimitService service; @BeforeEach void setUp() { - rateLimitService = new RateLimitService(rateLimitStore); - ReflectionTestUtils.setField(rateLimitService, "hostName", HOST_NAME); + service = new RateLimitService(rateLimitStore); } @Test - @DisplayName("์ตœ์ดˆ ์š”์ฒญ์€ host-prefixed clientId๋กœ ์ €์žฅ๋˜๊ณ  ๋‚จ์€ ํšŸ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void checkRateLimit_FirstRequest_SavesHostPrefixedClientId() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class); + void usesGlobalClientIdAndReturnsRemainingCount() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(30), true)); - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isTrue(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isEqualTo(2); - assertThat(result.windowSeconds()).isEqualTo(30); - assertThat(result.retryAfterSeconds()).isBetween(1L, 30L); - verify(rateLimitStore).save(rateLimitCaptor.capture()); - assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(STORE_CLIENT_ID); - assertThat(rateLimitCaptor.getValue().getCount()).isEqualTo(1); - assertThat(rateLimitCaptor.getValue().getExpiresAt()).isAfter(Instant.now()); } @Test - @DisplayName("๊ธฐ์กด ์นด์šดํŠธ๊ฐ€ ํ•œ๋„ ๋ฏธ๋งŒ์ด๋ฉด ์ฆ๊ฐ€์‹œ์ผœ ์ €์žฅํ•œ๋‹ค") - void checkRateLimit_ExistingBelowLimit_IncrementsCount() { - RateLimit existing = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(1) - .expiresAt(Instant.now().plusSeconds(20)) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing)); - when(rateLimitStore.save(existing)).thenReturn(existing); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + void rejectsWhenAtomicStoreRejects() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenReturn(new RateLimitConsumption(3, Instant.now().plusSeconds(10), false)); - assertThat(result.allowed()).isTrue(); - assertThat(result.remaining()).isEqualTo(1); - assertThat(existing.getCount()).isEqualTo(2); - verify(rateLimitStore).save(existing); - } - - @Test - @DisplayName("๊ธฐ์กด ์นด์šดํŠธ๊ฐ€ ํ•œ๋„์— ๋„๋‹ฌํ•˜๋ฉด retry-after์™€ reset epoch๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") - void checkRateLimit_LimitReached_ReturnsRetryAfterWithoutSaving() { - Instant expiresAt = Instant.now().plusSeconds(10); - RateLimit existing = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(3) - .expiresAt(expiresAt) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isFalse(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isZero(); - assertThat(result.retryAfterSeconds()).isBetween(1L, 10L); - assertThat(result.resetEpochSeconds()).isEqualTo(expiresAt.getEpochSecond()); - verify(rateLimitStore, never()).save(any(RateLimit.class)); - } - - @Test - @DisplayName("0์ดˆ window๋Š” ์ตœ์†Œ 1์ดˆ window๋กœ ์ •๊ทœํ™”๋œ๋‹ค") - void checkRateLimit_ZeroWindow_NormalizesToOneSecond() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ZERO); - - assertThat(result.allowed()).isTrue(); - assertThat(result.windowSeconds()).isEqualTo(1); - assertThat(result.retryAfterSeconds()).isPositive(); - } - - @Test - @DisplayName("null window๋Š” ์ตœ์†Œ 1์ดˆ window๋กœ ์ •๊ทœํ™”๋œ๋‹ค") - void checkRateLimit_NullWindow_NormalizesToOneSecond() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, null); - - assertThat(result.allowed()).isTrue(); - assertThat(result.windowSeconds()).isEqualTo(1); - assertThat(result.retryAfterSeconds()).isPositive(); } @Test - @DisplayName("๋งŒ๋ฃŒ๋œ ์ €์žฅ์†Œ ๋ฌธ์„œ๋Š” ์ƒˆ window๋กœ ๋ฆฌ์…‹๋œ๋‹ค") - void checkRateLimit_ExpiredStoredRateLimit_StartsNewWindow() { - RateLimit expired = RateLimit.builder() - .clientId(STORE_CLIENT_ID) - .count(3) - .expiresAt(Instant.now().minusSeconds(10)) - .build(); - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(expired)); - when(rateLimitStore.save(expired)).thenReturn(expired); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + void normalizesZeroWindowToOneSecond() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(1)), any())) + .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(1), true)); - assertThat(result.allowed()).isTrue(); - assertThat(result.remaining()).isEqualTo(2); - assertThat(result.retryAfterSeconds()).isBetween(1L, 30L); - assertThat(result.resetEpochSeconds()).isGreaterThan(Instant.now().getEpochSecond()); - assertThat(expired.getCount()).isEqualTo(1); - assertThat(expired.getExpiresAt()).isAfter(Instant.now()); - verify(rateLimitStore).save(expired); + assertThat(service.checkRateLimit("client-1", 3, Duration.ZERO).windowSeconds()).isEqualTo(1); } @Test - @DisplayName("์ €์žฅ์†Œ ์‹คํŒจ ์‹œ ์š”์ฒญ์€ ํ—ˆ์šฉํ•˜๊ณ  ์ „์ฒด ํ•œ๋„๋ฅผ ๋‚จ๊ธด๋‹ค") - void checkRateLimit_StoreFailure_FailsOpenDeterministically() { - when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenThrow(new IllegalStateException("store down")); + void failsOpenWhenStoreIsUnavailable() { + when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any())) + .thenThrow(new IllegalStateException("store down")); - RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30)); + RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30)); assertThat(result.allowed()).isTrue(); - assertThat(result.limit()).isEqualTo(3); assertThat(result.remaining()).isEqualTo(3); - assertThat(result.windowSeconds()).isEqualTo(30); - assertThat(result.retryAfterSeconds()).isEqualTo(30); - } - - @Test - @DisplayName("null clientId๋„ host prefix๊ฐ€ ์ ์šฉ๋œ ์ €์žฅ์†Œ key๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค") - void checkRateLimit_NullClientId_UsesHostPrefixedNullKey() { - String storeClientId = HOST_NAME + ":null"; - when(rateLimitStore.findByClientId(storeClientId)).thenReturn(Optional.empty()); - when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class); - - RateLimitCheckResult result = rateLimitService.checkRateLimit(null, 3, Duration.ofSeconds(30)); - - assertThat(result.allowed()).isTrue(); - verify(rateLimitStore).save(rateLimitCaptor.capture()); - assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(storeClientId); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java index 9a126ee2..bdf587d4 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java @@ -16,6 +16,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -151,12 +153,13 @@ void validateSession_StaleActivity_UpdatesSession() { .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC)) .build(); when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session)); - when(sessionStore.save(session)).thenReturn(session); + when(sessionStore.touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class))) + .thenReturn(Optional.of(session)); SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID); assertThat(result.isValid()).isTrue(); - verify(sessionStore).save(session); + verify(sessionStore).touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class)); } @Test diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java index 24156941..7ea9b399 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandlerTest.java @@ -6,6 +6,8 @@ import com.ktb.chatapp.websocket.socketio.SocketUser; import com.ktb.chatapp.websocket.socketio.UserConnectionLocks; import com.ktb.chatapp.websocket.socketio.UserRooms; +import com.ktb.chatapp.repository.RoomRepository; +import com.ktb.chatapp.model.Room; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.util.Set; import java.util.UUID; @@ -26,6 +28,7 @@ class ConnectionLoginHandlerTest { @Mock private ConnectedUsers connectedUsers; @Mock private UserRooms userRooms; @Mock private RoomJoinHandler roomJoinHandler; + @Mock private RoomRepository roomRepository; @Mock private SocketIOClient client; private ConnectionLoginHandler handler; @@ -38,23 +41,25 @@ void setUp() { userRooms, new UserConnectionLocks(), roomJoinHandler, + roomRepository, new SimpleMeterRegistry()); } @Test void onConnect_setsUserRejoinsRoomsStoresUserAndJoinsUserRooms() { SocketUser user = new SocketUser("user-1", "tester", "session-1", "socket-1"); - when(connectedUsers.get(user.id())).thenReturn(null); + when(connectedUsers.replace(user.id(), user)).thenReturn(null); when(client.get("user")).thenReturn(user); - when(userRooms.get(user.id())).thenReturn(Set.of("room-1", "room-2")); + when(roomRepository.findByParticipantIdsContaining(user.id())).thenReturn(java.util.List.of( + Room.builder().id("room-1").build(), Room.builder().id("room-2").build())); handler.onConnect(client, user); verify(client).set("user", user); verify(roomJoinHandler).handleJoinRoom(client, "room-1"); verify(roomJoinHandler).handleJoinRoom(client, "room-2"); - verify(connectedUsers).set(user.id(), user); - verify(client).joinRooms(Set.of("user:" + user.id(), "room-list")); + verify(connectedUsers).replace(user.id(), user); + verify(client).joinRooms(Set.of("user:" + user.id(), "socket:" + user.socketId(), "room-list")); } @Test @@ -63,13 +68,13 @@ void onDisconnect_removesOnlyCurrentConnectionAndPreservesRoomMembership() { SocketUser user = new SocketUser("user-1", "tester", "session-1", socketId.toString()); when(client.get("user")).thenReturn(user); when(client.getSessionId()).thenReturn(socketId); - when(connectedUsers.get(user.id())).thenReturn(user); + when(connectedUsers.delIfCurrent(user.id(), user)).thenReturn(true); handler.onDisconnect(client); - verify(connectedUsers).del(user.id()); + verify(connectedUsers).delIfCurrent(user.id(), user); verify(userRooms, never()).remove(user.id(), "room-1"); - verify(client).leaveRooms(Set.of("user:" + user.id(), "room-list")); + verify(client).leaveRooms(Set.of("user:" + user.id(), "socket:" + socketId, "room-list")); verify(client).del("user"); verify(client).disconnect(); } @@ -83,13 +88,13 @@ void onDisconnect_staleSocketDoesNotDeleteActiveConnectionOrRoomMembership() { "user-1", "tester", "session-2", UUID.randomUUID().toString()); when(client.get("user")).thenReturn(staleUser); when(client.getSessionId()).thenReturn(staleSocketId); - when(connectedUsers.get(staleUser.id())).thenReturn(activeUser); + when(connectedUsers.delIfCurrent(staleUser.id(), staleUser)).thenReturn(false); handler.onDisconnect(client); - verify(connectedUsers, never()).del(staleUser.id()); + verify(connectedUsers).delIfCurrent(staleUser.id(), staleUser); verify(userRooms, never()).remove(staleUser.id(), "room-1"); - verify(client).leaveRooms(Set.of("user:" + staleUser.id(), "room-list")); + verify(client).leaveRooms(Set.of("user:" + staleUser.id(), "socket:" + staleSocketId, "room-list")); verify(client).del("user"); verify(client).disconnect(); } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java index 3345fe0d..9389a039 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/MessageReactionHandlerTest.java @@ -6,7 +6,7 @@ import com.ktb.chatapp.dto.MessageReactionRequest; import com.ktb.chatapp.dto.MessageReactionResponse; import com.ktb.chatapp.model.Message; -import com.ktb.chatapp.repository.MessageRepository; +import com.ktb.chatapp.service.MessageReactionService; import com.ktb.chatapp.websocket.socketio.SocketUser; import java.util.Optional; import java.util.Set; @@ -30,7 +30,7 @@ class MessageReactionHandlerTest { @Mock private SocketIOServer socketIOServer; - @Mock private MessageRepository messageRepository; + @Mock private MessageReactionService messageReactionService; @Mock private SocketIOClient client; @Mock private BroadcastOperations roomOperations; @@ -38,7 +38,7 @@ class MessageReactionHandlerTest { @BeforeEach void setUp() { - handler = new MessageReactionHandler(socketIOServer, messageRepository); + handler = new MessageReactionHandler(socketIOServer, messageReactionService); } @Test @@ -48,7 +48,7 @@ void handleMessageReaction_rejectsUnauthorizedClient() { handler.handleMessageReaction(client, new MessageReactionRequest("๐Ÿ‘", "message-1", "add", "๐Ÿ‘")); verify(client).sendEvent(eq(ERROR), any()); - verify(messageRepository, never()).save(any()); + verify(messageReactionService, never()).update(any(), any(), any(), eq(true)); } @Test @@ -59,13 +59,14 @@ void handleMessageReaction_addsReactionAndBroadcasts() { when(client.get("user")) .thenReturn(new SocketUser("user-1", "tester", "session-1", "socket-1")); - when(messageRepository.findById("message-1")).thenReturn(Optional.of(message)); - when(messageRepository.save(message)).thenReturn(message); + message.addReaction("๐Ÿ‘", "user-1"); + when(messageReactionService.update("message-1", "๐Ÿ‘", "user-1", true)) + .thenReturn(Optional.of(message)); when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); handler.handleMessageReaction(client, request); - verify(messageRepository).save(message); + verify(messageReactionService).update("message-1", "๐Ÿ‘", "user-1", true); ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Object.class); verify(roomOperations).sendEvent(eq(MESSAGE_REACTION_UPDATE), responseCaptor.capture()); MessageReactionResponse response = (MessageReactionResponse) responseCaptor.getValue(); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java index 372e2925..655a4760 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomJoinHandlerTest.java @@ -92,7 +92,7 @@ void handleJoinRoom_addsParticipantLoadsMessagesAndBroadcasts() { when(client.get("user")).thenReturn(socketUser); when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); - when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); + when(roomRepository.addParticipant("room-1", "user-1")).thenReturn(1L); when(messageRepository.save(any(Message.class))).thenAnswer(invocation -> { Message message = invocation.getArgument(0); message.setId("message-1"); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java index 76e6b007..9a897581 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandlerTest.java @@ -94,7 +94,7 @@ void handleLeaveRoom_removesParticipantAndBroadcasts() { .build(); when(client.get("user")).thenReturn(socketUser); - when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); + when(roomRepository.removeParticipant("room-1", "user-1")).thenReturn(1L); when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); when(userRepository.findById("user-2")).thenReturn(Optional.of(remainingUser)); when(roomRepository.findById("room-1")) diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index 862048be..2e583591 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -2,6 +2,7 @@ server.port=5001 # make dev๋Š” ์ด ํŒŒ์ผ์ด main ์„ค์ •์„ ๋ฎ์œผ๋ฏ€๋กœ, main๊ณผ ๊ฐ™์€ ๋ฐ”์ธ๋”ฉ์„ ์—ฌ๊ธฐ์„œ ๋‹ค์‹œ ์ง€์ •ํ•œ๋‹ค. # ๋น ์ง€๋ฉด SocketIOConfig์˜ ๊ธฐ๋ณธ๊ฐ’ localhost๊ฐ€ ๊ฑธ๋ ค 127.0.0.1์—๋งŒ ๋ถ™๊ณ , ๊ฐ™์€ LAN์˜ ๋‹ค๋ฅธ ๊ธฐ๊ธฐ๊ฐ€ ์ ‘์†ํ•˜์ง€ ๋ชปํ•œ๋‹ค. socketio.server.host=0.0.0.0 +socketio.store.type=memory app.encryption.key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa app.encryption.salt=3132333435363738 app.jwt.secret=test-jwt-secret-for-backend-modernization-0123456789 @@ -16,4 +17,3 @@ management.endpoints.web.exposure.include=health,info,metrics,prometheus management.endpoints.access.default=read-only management.endpoint.prometheus.access=read-only management.prometheus.metrics.export.enabled=true - From b023a5a967c37085e4d85b0a0df14ec65e4a605c Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:04:15 +0900 Subject: [PATCH 22/56] test: verify distributed backend state --- .../chatapp/service/RateLimitServiceTest.java | 24 ++++++ .../service/RoomActivityNotifierTest.java | 57 ++++++++------ .../RedisChatDataStoreIntegrationTest.java | 74 +++++++++++++++++++ .../src/test/resources/application.properties | 4 + 4 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java index 6e6c2ac4..b2113cbb 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java @@ -4,6 +4,9 @@ import com.ktb.chatapp.repository.RateLimitRepository; import java.time.Duration; import java.time.Instant; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -131,4 +134,25 @@ void checkRateLimit_IndependentLimitsPerClient() { assertThat(result2.allowed()).isTrue(); assertThat(result2.remaining()).isEqualTo(1); } + + @Test + @DisplayName("๋™์‹œ ์š”์ฒญ๋„ ์ธ์Šคํ„ด์Šค ๊ณตํ†ต ํ•œ๋„๋ฅผ ์ดˆ๊ณผํ•ด ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + void checkRateLimit_ConcurrentRequestsRespectGlobalLimit() throws Exception { + int maxRequests = 5; + var executor = Executors.newFixedThreadPool(10); + try { + var futures = IntStream.range(0, 20) + .mapToObj(ignored -> executor.submit(() -> rateLimitService.checkRateLimit( + "concurrent-client", maxRequests, Duration.ofSeconds(60)))) + .toList(); + + long allowed = 0; + for (var future : futures) { + if (future.get(10, TimeUnit.SECONDS).allowed()) allowed++; + } + assertThat(allowed).isEqualTo(maxRequests); + } finally { + executor.shutdownNow(); + } + } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java index 97d8c7ef..3708e33b 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java @@ -1,6 +1,8 @@ package com.ktb.chatapp.service; import com.ktb.chatapp.event.RoomActivityEvent; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -8,7 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -27,46 +29,57 @@ private RoomActivityNotifier notifier() { } @Test - void notifyMessageStored_firstMessageOfRoom_publishesRecentMessageCount() { - when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(7); + void flushPublishesRecentMessageCount() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenReturn(Map.of("room-1", 7)); + RoomActivityNotifier notifier = notifier(); - notifier().notifyMessageStored("room-1"); + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); - ArgumentCaptor eventCaptor = - ArgumentCaptor.forClass(RoomActivityEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertEquals("room-1", eventCaptor.getValue().getRoomId()); - assertEquals(7, eventCaptor.getValue().getRecentMessageCount()); + ArgumentCaptor event = ArgumentCaptor.forClass(RoomActivityEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().getRoomId()).isEqualTo("room-1"); + assertThat(event.getValue().getRecentMessageCount()).isEqualTo(7); } @Test - void notifyMessageStored_everyMessage_publishes() { - when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(1); + void flushCoalescesRepeatedNotificationsForSameRoom() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenReturn(Map.of("room-1", 3)); RoomActivityNotifier notifier = notifier(); notifier.notifyMessageStored("room-1"); notifier.notifyMessageStored("room-1"); notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); - verify(eventPublisher, times(3)).publishEvent(any(RoomActivityEvent.class)); - verify(recentMessageCounter, times(3)).countRecentMessages("room-1"); + verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1")); + verify(eventPublisher).publishEvent(any(RoomActivityEvent.class)); } @Test - void notifyMessageStored_nullRoomId_doesNothing() { - notifier().notifyMessageStored(null); + void nullRoomIdDoesNothing() { + RoomActivityNotifier notifier = notifier(); + notifier.notifyMessageStored(null); + notifier.flushPendingRoomActivities(); - verifyNoInteractions(recentMessageCounter); - verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class)); + verifyNoInteractions(recentMessageCounter, eventPublisher); } @Test - void notifyMessageStored_counterFails_swallowsException() { - when(recentMessageCounter.countRecentMessages("room-1")) - .thenThrow(new RuntimeException("mongo down")); - - notifier().notifyMessageStored("room-1"); + void failedBatchIsRetriedOnNextFlush() { + when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) + .thenThrow(new RuntimeException("mongo down")) + .thenReturn(Map.of("room-1", 1)); + RoomActivityNotifier notifier = notifier(); + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class)); + + notifier.flushPendingRoomActivities(); + verify(recentMessageCounter, times(2)).countRecentMessagesByRoomIds(Set.of("room-1")); + verify(eventPublisher).publishEvent(any(RoomActivityEvent.class)); } } diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java new file mode 100644 index 00000000..767a44b9 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java @@ -0,0 +1,74 @@ +package com.ktb.chatapp.websocket.socketio; + +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class RedisChatDataStoreIntegrationTest { + + @Container + static final GenericContainer REDIS = new GenericContainer<>("redis:8.8.0-alpine") + .withExposedPorts(6379); + + private static RedissonClient firstClient; + private static RedissonClient secondClient; + private static RedisChatDataStore firstStore; + private static RedisChatDataStore secondStore; + + @BeforeAll + static void setUp() { + firstClient = createClient(); + secondClient = createClient(); + firstStore = new RedisChatDataStore(firstClient); + secondStore = new RedisChatDataStore(secondClient); + } + + @AfterAll + static void tearDown() { + if (firstClient != null) firstClient.shutdown(); + if (secondClient != null) secondClient.shutdown(); + } + + @Test + void connectionOwnershipIsSharedAndConditionallyDeletedAcrossInstances() { + String key = "conn_users:userid:integration-user"; + SocketUser first = new SocketUser("integration-user", "name", "session-1", "socket-1"); + SocketUser second = new SocketUser("integration-user", "name", "session-2", "socket-2"); + + firstStore.set(key, first); + assertThat(secondStore.getAndSet(key, second, SocketUser.class)).isEqualTo(first); + assertThat(firstStore.compareAndDelete(key, first)).isFalse(); + assertThat(firstStore.compareAndDelete(key, second)).isTrue(); + assertThat(secondStore.get(key, SocketUser.class)).isEmpty(); + } + + @Test + void roomSetUpdatesAreVisibleAcrossInstances() { + String key = "userroom:roomids:integration-user"; + + firstStore.addToSet(key, "room-1"); + secondStore.addToSet(key, "room-2"); + assertThat(firstStore.getSet(key)).isEqualTo(Set.of("room-1", "room-2")); + + secondStore.removeFromSet(key, "room-1"); + assertThat(firstStore.getSet(key)).containsExactly("room-2"); + firstStore.delete(key); + } + + private static RedissonClient createClient() { + Config config = new Config(); + config.useSingleServer().setAddress( + "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379)); + return Redisson.create(config); + } +} diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index 2e583591..e5881d65 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -3,6 +3,10 @@ server.port=5001 # ๋น ์ง€๋ฉด SocketIOConfig์˜ ๊ธฐ๋ณธ๊ฐ’ localhost๊ฐ€ ๊ฑธ๋ ค 127.0.0.1์—๋งŒ ๋ถ™๊ณ , ๊ฐ™์€ LAN์˜ ๋‹ค๋ฅธ ๊ธฐ๊ธฐ๊ฐ€ ์ ‘์†ํ•˜์ง€ ๋ชปํ•œ๋‹ค. socketio.server.host=0.0.0.0 socketio.store.type=memory +app.mongodb.pool.max-size=20 +app.mongodb.pool.min-size=0 +app.mongodb.pool.max-wait-time-ms=10000 +spring.data.mongodb.auto-index-creation=true app.encryption.key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa app.encryption.salt=3132333435363738 app.jwt.secret=test-jwt-secret-for-backend-modernization-0123456789 From ef9506880a69537d32d548ed49a3832d59c8e801 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:16:43 +0900 Subject: [PATCH 23/56] fix: refresh distributed socket presence lease --- .../websocket/socketio/ChatDataStore.java | 4 ++ .../websocket/socketio/ConnectedUsers.java | 7 +++ .../socketio/PresenceLeaseRefresher.java | 29 ++++++++++++ .../socketio/RedisChatDataStore.java | 5 ++ .../socketio/ConnectedUsersTest.java | 41 ++++++++++++++++ .../socketio/PresenceLeaseRefresherTest.java | 47 +++++++++++++++++++ 6 files changed, 133 insertions(+) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java index 9c1ed0c3..2fe78a6f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java @@ -39,6 +39,10 @@ default T getAndSet(String key, T value, Class type) { */ void delete(String key); + default boolean refresh(String key) { + return get(key, Object.class).isPresent(); + } + default boolean compareAndDelete(String key, Object expectedValue) { Optional current = get(key, Object.class); if (current.isPresent() && current.get().equals(expectedValue)) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java index c7f32dc5..1ae2a382 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ConnectedUsers.java @@ -32,6 +32,13 @@ public void del(String userId) { public boolean delIfCurrent(String userId, SocketUser socketUser) { return chatDataStore.compareAndDelete(buildKey(userId), socketUser); } + + public boolean refreshIfCurrent(SocketUser socketUser) { + if (socketUser == null || !socketUser.equals(get(socketUser.id()))) { + return false; + } + return chatDataStore.refresh(buildKey(socketUser.id())); + } public int size() { return chatDataStore.size(); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java new file mode 100644 index 00000000..befaa677 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresher.java @@ -0,0 +1,29 @@ +package com.ktb.chatapp.websocket.socketio; + +import com.corundumstudio.socketio.SocketIOClient; +import com.corundumstudio.socketio.SocketIOServer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Keeps Redis presence alive while the owning Socket.IO connection responds to pings. */ +@Slf4j +@Component +@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true) +public class PresenceLeaseRefresher { + + private final ConnectedUsers connectedUsers; + + public PresenceLeaseRefresher(SocketIOServer socketIOServer, ConnectedUsers connectedUsers) { + this.connectedUsers = connectedUsers; + socketIOServer.addPongListener(this::onPong); + } + + void onPong(SocketIOClient client) { + SocketUser socketUser = client.get("user"); + if (socketUser != null && !connectedUsers.refreshIfCurrent(socketUser)) { + log.debug("Skipped stale presence lease refresh: userId={}, socketId={}", + socketUser.id(), socketUser.socketId()); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java index 673d3141..eb97e086 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java @@ -39,6 +39,11 @@ public void delete(String key) { redissonClient.getBucket(key).delete(); } + @Override + public boolean refresh(String key) { + return redissonClient.getBucket(key).expire(PRESENCE_TTL); + } + @Override public boolean compareAndDelete(String key, Object expectedValue) { return redissonClient.getBucket(key).compareAndSet(expectedValue, null); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java new file mode 100644 index 00000000..b077a01d --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java @@ -0,0 +1,41 @@ +package com.ktb.chatapp.websocket.socketio; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConnectedUsersTest { + + @Mock private ChatDataStore store; + + @Test + void refreshesLeaseOnlyForCurrentOwner() { + SocketUser current = new SocketUser("user-1", "name", "session-1", "socket-1"); + String key = "conn_users:userid:user-1"; + when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current)); + when(store.refresh(key)).thenReturn(true); + + assertThat(new ConnectedUsers(store).refreshIfCurrent(current)).isTrue(); + verify(store).refresh(key); + } + + @Test + void staleSocketCannotRefreshNewOwnersLease() { + SocketUser stale = new SocketUser("user-1", "name", "session-1", "socket-1"); + SocketUser current = new SocketUser("user-1", "name", "session-2", "socket-2"); + String key = "conn_users:userid:user-1"; + when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current)); + + assertThat(new ConnectedUsers(store).refreshIfCurrent(stale)).isFalse(); + verify(store, never()).refresh(key); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java new file mode 100644 index 00000000..9ae0ad9e --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java @@ -0,0 +1,47 @@ +package com.ktb.chatapp.websocket.socketio; + +import com.corundumstudio.socketio.SocketIOClient; +import com.corundumstudio.socketio.SocketIOServer; +import com.corundumstudio.socketio.listener.PongListener; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PresenceLeaseRefresherTest { + + @Mock private SocketIOServer socketIOServer; + @Mock private ConnectedUsers connectedUsers; + @Mock private SocketIOClient client; + + @Test + void pongRefreshesOnlyAuthenticatedSocketPresence() { + SocketUser user = new SocketUser("user-1", "name", "session-1", "socket-1"); + ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class); + new PresenceLeaseRefresher(socketIOServer, connectedUsers); + verify(socketIOServer).addPongListener(listener.capture()); + + when(client.get("user")).thenReturn(user); + listener.getValue().onPong(client); + + verify(connectedUsers).refreshIfCurrent(user); + } + + @Test + void pongWithoutAuthenticatedUserDoesNotRefreshPresence() { + ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class); + new PresenceLeaseRefresher(socketIOServer, connectedUsers); + verify(socketIOServer).addPongListener(listener.capture()); + + when(client.get("user")).thenReturn(null); + listener.getValue().onPong(client); + + verify(connectedUsers, never()).refreshIfCurrent(org.mockito.ArgumentMatchers.any()); + } +} From 2088fb93c82c318fc9d28eaf63a7d07cfe316c45 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:19:28 +0900 Subject: [PATCH 24/56] perf: avoid scanning Redis for connection metrics --- .../handler/ConnectionLoginHandler.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java index 2adc687c..6ade9b68 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ConnectionLoginHandler.java @@ -53,8 +53,8 @@ public ConnectionLoginHandler( this.roomRepository = roomRepository; // Register gauge metric for concurrent users - Gauge.builder("socketio.concurrent.users", connectedUsers::size) - .description("Current number of concurrent Socket.IO users") + Gauge.builder("socketio.concurrent.connections", this::localConnectionCount) + .description("Current Socket.IO connections on this instance") .register(meterRegistry); } @@ -80,8 +80,8 @@ public void onConnect(SocketIOClient client, SocketUser user) { }); }); - log.info("Socket.IO user connected: {} ({}) - Total concurrent users: {}", - getUserName(client), userId, connectedUsers.size()); + log.info("Socket.IO user connected: {} ({}) - Local connections: {}", + getUserName(client), userId, localConnectionCount()); client.joinRooms(Set.of("user:" + userId, "socket:" + user.socketId(), "room-list")); @@ -118,8 +118,8 @@ public void onDisconnect(SocketIOClient client) { client.del("user"); client.disconnect(); - log.info("Socket.IO user disconnected: {} ({}) - Total concurrent users: {}", - userName, userId, connectedUsers.size()); + log.info("Socket.IO user disconnected: {} ({}) - Local connections: {}", + userName, userId, localConnectionCount()); } catch (Exception e) { log.error("Error handling Socket.IO disconnection", e); client.sendEvent(ERROR, Map.of( @@ -132,6 +132,11 @@ public void onDisconnect(SocketIOClient client) { private SocketUser getUserDto(SocketIOClient client) { return client.get("user"); } + + private int localConnectionCount() { + var clients = socketIOServer.getAllClients(); + return clients != null ? clients.size() : 0; + } private String getUserId(SocketIOClient client) { SocketUser user = getUserDto(client); From 7074fd20b0cd558467a580f576b2fe38e89de28b Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:23:03 +0900 Subject: [PATCH 25/56] perf: move rate limiting to Redis --- .../ratelimit/RateLimitMongoStore.java | 13 +--- .../ratelimit/RateLimitRedisStore.java | 63 +++++++++++++++ .../service/ratelimit/RateLimitStore.java | 36 +-------- .../src/main/resources/application.properties | 1 + .../RateLimitRedisStoreIntegrationTest.java | 78 +++++++++++++++++++ .../src/test/resources/application.properties | 1 + 6 files changed, 146 insertions(+), 46 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java index 9c0b35a6..a6be7d7f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitMongoStore.java @@ -2,11 +2,11 @@ import com.ktb.chatapp.model.RateLimit; import com.ktb.chatapp.repository.RateLimitRepository; -import java.util.Optional; import java.time.Duration; import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; @@ -19,22 +19,13 @@ * Uses RateLimitRepository for persistence. */ @Component +@ConditionalOnProperty(name = "rate-limit.store.type", havingValue = "mongo") @RequiredArgsConstructor public class RateLimitMongoStore implements RateLimitStore { private final RateLimitRepository rateLimitRepository; private final MongoTemplate mongoTemplate; - @Override - public Optional findByClientId(String clientId) { - return rateLimitRepository.findByClientId(clientId); - } - - @Override - public RateLimit save(RateLimit rateLimit) { - return rateLimitRepository.save(rateLimit); - } - @Override public RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now) { Instant expiresAt = now.plus(window); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java new file mode 100644 index 00000000..fb8aa56c --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitRedisStore.java @@ -0,0 +1,63 @@ +package com.ktb.chatapp.service.ratelimit; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "rate-limit.store.type", havingValue = "redis", matchIfMissing = true) +public class RateLimitRedisStore implements RateLimitStore { + + private static final String KEY_PREFIX = "rate_limit:"; + private static final DefaultRedisScript CONSUME_SCRIPT = new DefaultRedisScript<>(""" + local current = redis.call('GET', KEYS[1]) + if not current then + redis.call('SET', KEYS[1], 1, 'PX', ARGV[2]) + return {1, 1, tonumber(ARGV[2])} + end + + local ttl = redis.call('PTTL', KEYS[1]) + if tonumber(current) >= tonumber(ARGV[1]) then + return {0, tonumber(current), ttl} + end + + local count = redis.call('INCR', KEYS[1]) + return {1, count, ttl} + """, List.class); + + private final StringRedisTemplate redisTemplate; + + @Override + public RateLimitConsumption consume( + String clientId, int maxRequests, Duration window, Instant now) { + long windowMillis = Math.max(1L, window.toMillis()); + List result = redisTemplate.execute( + CONSUME_SCRIPT, + Collections.singletonList(KEY_PREFIX + clientId), + Integer.toString(maxRequests), + Long.toString(windowMillis)); + + if (result == null || result.size() != 3) { + throw new IllegalStateException("Redis rate limit script returned an invalid result"); + } + + boolean allowed = number(result.get(0)).longValue() == 1L; + int count = number(result.get(1)).intValue(); + long ttlMillis = Math.max(1L, number(result.get(2)).longValue()); + return new RateLimitConsumption(count, now.plusMillis(ttlMillis), allowed); + } + + private Number number(Object value) { + if (value instanceof Number number) { + return number; + } + throw new IllegalStateException("Redis rate limit script returned a non-numeric value"); + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java index 0e24a609..8d6ce98d 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ratelimit/RateLimitStore.java @@ -1,7 +1,5 @@ package com.ktb.chatapp.service.ratelimit; -import com.ktb.chatapp.model.RateLimit; -import java.util.Optional; import java.time.Duration; import java.time.Instant; @@ -10,37 +8,5 @@ * Provides operations for storing and retrieving rate limit data. */ public interface RateLimitStore { - - /** - * Find rate limit by client ID - * - * @param clientId the client identifier - * @return Optional containing the RateLimit if found, empty otherwise - */ - Optional findByClientId(String clientId); - - /** - * Save or update rate limit - * - * @param rateLimit the rate limit to save - * @return the saved rate limit - */ - RateLimit save(RateLimit rateLimit); - - default RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now) { - RateLimit rateLimit = findByClientId(clientId).orElse(null); - Instant expiresAt = now.plus(window); - if (rateLimit == null) { - rateLimit = RateLimit.builder().clientId(clientId).count(1).expiresAt(expiresAt).build(); - } else if (!rateLimit.getExpiresAt().isAfter(now)) { - rateLimit.setCount(1); - rateLimit.setExpiresAt(expiresAt); - } else if (rateLimit.getCount() >= maxRequests) { - return new RateLimitConsumption(rateLimit.getCount(), rateLimit.getExpiresAt(), false); - } else { - rateLimit.setCount(rateLimit.getCount() + 1); - } - RateLimit saved = save(rateLimit); - return new RateLimitConsumption(saved.getCount(), saved.getExpiresAt(), true); - } + RateLimitConsumption consume(String clientId, int maxRequests, Duration window, Instant now); } diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index dce17606..461c9fd5 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -53,6 +53,7 @@ socketio.server.host=0.0.0.0 socketio.server.port=${WS_PORT:5002} socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} socketio.store.type=${SOCKETIO_STORE_TYPE:redis} +rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java new file mode 100644 index 00000000..6d34e97d --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java @@ -0,0 +1,78 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import com.ktb.chatapp.service.ratelimit.RateLimitRedisStore; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +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.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "socketio.enabled=false", + "rate-limit.store.type=redis" +}) +class RateLimitRedisStoreIntegrationTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private RedisConnectionFactory connectionFactory; + + @BeforeEach + void clearRedis() { + try (var connection = connectionFactory.getConnection()) { + connection.serverCommands().flushDb(); + } + } + + @Test + void rejectsRequestsAfterTheSharedLimit() { + var service = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue(); + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue(); + assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isFalse(); + } + + @Test + void concurrentInstancesNeverAllowMoreThanTheSharedLimit() throws Exception { + int maxRequests = 25; + var firstInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + var secondInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate)); + var executor = Executors.newFixedThreadPool(20); + try { + var futures = IntStream.range(0, 100) + .mapToObj(index -> executor.submit(() -> { + var instance = index % 2 == 0 ? firstInstance : secondInstance; + return instance.checkRateLimit( + "shared-client", maxRequests, Duration.ofMinutes(1)); + })) + .toList(); + + long allowed = 0; + for (var future : futures) { + if (future.get(10, TimeUnit.SECONDS).allowed()) { + allowed++; + } + } + + assertThat(allowed).isEqualTo(maxRequests); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index e5881d65..f510ee5a 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -3,6 +3,7 @@ server.port=5001 # ๋น ์ง€๋ฉด SocketIOConfig์˜ ๊ธฐ๋ณธ๊ฐ’ localhost๊ฐ€ ๊ฑธ๋ ค 127.0.0.1์—๋งŒ ๋ถ™๊ณ , ๊ฐ™์€ LAN์˜ ๋‹ค๋ฅธ ๊ธฐ๊ธฐ๊ฐ€ ์ ‘์†ํ•˜์ง€ ๋ชปํ•œ๋‹ค. socketio.server.host=0.0.0.0 socketio.store.type=memory +rate-limit.store.type=mongo app.mongodb.pool.max-size=20 app.mongodb.pool.min-size=0 app.mongodb.pool.max-wait-time-ms=10000 From e5973edc386625b70a9dde132a651ccdd342bee8 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:24:29 +0900 Subject: [PATCH 26/56] perf: index room participant lookups --- .../main/java/com/ktb/chatapp/model/Room.java | 1 + .../RoomRepositoryIntegrationTest.java | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java index 7a83ae13..b2dbcb83 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/Room.java @@ -42,6 +42,7 @@ public class Room { private LocalDateTime createdAt; @Field("participantIds") + @Indexed(name = "participant_ids_idx") @Builder.Default private Set participantIds = new HashSet<>(); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java new file mode 100644 index 00000000..049bdf7b --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java @@ -0,0 +1,56 @@ +package com.ktb.chatapp.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.model.Room; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +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.data.mongodb.core.MongoTemplate; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@Import(MongoTestContainer.class) +@TestPropertySource(properties = { + "spring.data.mongodb.auto-index-creation=true", + "socketio.enabled=false" +}) +class RoomRepositoryIntegrationTest { + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MongoTemplate mongoTemplate; + + @AfterEach + void tearDown() { + roomRepository.deleteAll(); + } + + @Test + void participantLookupUsesAnExplicitMultikeyIndex() { + roomRepository.save(Room.builder() + .name("indexed-room") + .participantIds(Set.of("user-1", "user-2")) + .build()); + + assertThat(roomRepository.findByParticipantIdsContaining("user-2")) + .extracting(Room::getName) + .containsExactly("indexed-room"); + + assertThat(mongoTemplate.indexOps(Room.class).getIndexInfo()) + .anySatisfy(index -> { + if ("participant_ids_idx".equals(index.getName())) { + assertThat(index.getIndexFields()) + .anySatisfy(field -> assertThat(field.getKey()).isEqualTo("participantIds")); + } else { + throw new AssertionError("not the participant index"); + } + }); + } +} From e94e46c4664c60c1d8077ca40c523466b88a729c Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:30:09 +0900 Subject: [PATCH 27/56] perf: move active sessions to Redis --- .../service/session/SessionMongoStore.java | 6 +- .../service/session/SessionRedisStore.java | 136 ++++++++++++++++++ .../chatapp/service/session/SessionStore.java | 10 +- .../src/main/resources/application.properties | 1 + .../SessionRedisStoreIntegrationTest.java | 74 ++++++++++ .../src/test/resources/application.properties | 1 + 6 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java index bfe94f79..9cfaef3c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionMongoStore.java @@ -4,6 +4,7 @@ import com.ktb.chatapp.repository.SessionRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; @@ -16,9 +17,10 @@ * Uses SessionRepository for persistence. */ @Component +@ConditionalOnProperty(name = "session.store.type", havingValue = "mongo") @RequiredArgsConstructor public class SessionMongoStore implements SessionStore { - + private final SessionRepository sessionRepository; private final MongoTemplate mongoTemplate; @@ -26,7 +28,7 @@ public class SessionMongoStore implements SessionStore { public Optional findByUserId(String userId) { return sessionRepository.findByUserId(userId); } - + @Override public Session save(Session session) { return sessionRepository.save(session); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java new file mode 100644 index 00000000..a834b2c4 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionRedisStore.java @@ -0,0 +1,136 @@ +package com.ktb.chatapp.service.session; + +import com.ktb.chatapp.model.Session; +import com.ktb.chatapp.service.SessionMetadata; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "session.store.type", havingValue = "redis", matchIfMissing = true) +public class SessionRedisStore implements SessionStore { + + private static final String KEY_PREFIX = "session:user:"; + private static final DefaultRedisScript REPLACE_SCRIPT = new DefaultRedisScript<>(""" + redis.call('HSET', KEYS[1], + 'userId', ARGV[1], 'sessionId', ARGV[2], + 'createdAt', ARGV[3], 'lastActivity', ARGV[4], + 'metadata', ARGV[5], 'expiresAt', ARGV[6]) + redis.call('PEXPIREAT', KEYS[1], ARGV[6]) + return 1 + """, Long.class); + private static final DefaultRedisScript TOUCH_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('HGET', KEYS[1], 'sessionId') ~= ARGV[1] then + return 0 + end + redis.call('HSET', KEYS[1], 'lastActivity', ARGV[2], 'expiresAt', ARGV[3]) + redis.call('PEXPIREAT', KEYS[1], ARGV[3]) + return 1 + """, Long.class); + private static final DefaultRedisScript DELETE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('HGET', KEYS[1], 'sessionId') ~= ARGV[1] then + return 0 + end + return redis.call('DEL', KEYS[1]) + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional findByUserId(String userId) { + Map values = redisTemplate.opsForHash().entries(key(userId)); + if (values.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toSession(values)); + } + + @Override + public Session save(Session session) { + return replaceByUserId(session); + } + + @Override + public Session replaceByUserId(Session session) { + redisTemplate.execute(REPLACE_SCRIPT, List.of(key(session.getUserId())), + session.getUserId(), session.getSessionId(), + Long.toString(session.getCreatedAt()), Long.toString(session.getLastActivity()), + serializeMetadata(session.getMetadata()), + Long.toString(session.getExpiresAt().toEpochMilli())); + return session; + } + + @Override + public Optional touch( + String userId, String sessionId, long lastActivity, Instant expiresAt) { + Long updated = redisTemplate.execute(TOUCH_SCRIPT, List.of(key(userId)), + sessionId, Long.toString(lastActivity), Long.toString(expiresAt.toEpochMilli())); + return updated != null && updated == 1L ? findByUserId(userId) : Optional.empty(); + } + + @Override + public void deleteAll(String userId) { + redisTemplate.delete(key(userId)); + } + + @Override + public void delete(String userId, String sessionId) { + redisTemplate.execute(DELETE_SCRIPT, List.of(key(userId)), sessionId); + } + + private Session toSession(Map values) { + String metadata = string(values, "metadata"); + return Session.builder() + .userId(string(values, "userId")) + .sessionId(string(values, "sessionId")) + .createdAt(Long.parseLong(string(values, "createdAt"))) + .lastActivity(Long.parseLong(string(values, "lastActivity"))) + .metadata(deserializeMetadata(metadata)) + .expiresAt(Instant.ofEpochMilli(Long.parseLong(string(values, "expiresAt")))) + .build(); + } + + private String key(String userId) { + return KEY_PREFIX + userId; + } + + private String string(Map values, String field) { + Object value = values.get(field); + if (value == null) { + throw new IllegalStateException("Redis session is missing field: " + field); + } + return value.toString(); + } + + private String serializeMetadata(SessionMetadata metadata) { + if (metadata == null) { + return ""; + } + try { + return objectMapper.writeValueAsString(metadata); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize session metadata", e); + } + } + + private SessionMetadata deserializeMetadata(String value) { + if (value.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(value, SessionMetadata.class); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to deserialize session metadata", e); + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java index 1fe38c57..1504729c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/session/SessionStore.java @@ -8,7 +8,7 @@ * Provides operations for storing and retrieving session data. */ public interface SessionStore { - + /** * Find session by user ID * @@ -16,13 +16,7 @@ public interface SessionStore { * @return Optional containing the Session if found, empty otherwise */ Optional findByUserId(String userId); - - /** - * Save or update session - * - * @param session the session to save - * @return the saved session - */ + Session save(Session session); /** diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 461c9fd5..3824652f 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -54,6 +54,7 @@ socketio.server.port=${WS_PORT:5002} socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} socketio.store.type=${SOCKETIO_STORE_TYPE:redis} rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis} +session.store.type=${SESSION_STORE_TYPE:redis} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java new file mode 100644 index 00000000..43176496 --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java @@ -0,0 +1,74 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import com.ktb.chatapp.service.session.SessionRedisStore; +import org.junit.jupiter.api.BeforeEach; +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.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.TestPropertySource; +import tools.jackson.databind.ObjectMapper; + +@SpringBootTest +@Import({MongoTestContainer.class, RedisTestContainer.class}) +@TestPropertySource(properties = { + "socketio.enabled=false", + "session.store.type=redis" +}) +class SessionRedisStoreIntegrationTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private RedisConnectionFactory connectionFactory; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void clearRedis() { + try (var connection = connectionFactory.getConnection()) { + connection.serverCommands().flushDb(); + } + } + + @Test + void sessionCreatedOnOneInstanceIsValidatedOnAnother() { + var firstInstance = service(); + var secondInstance = service(); + + var created = firstInstance.createSession( + "shared-user", new SessionMetadata("agent", "127.0.0.1", "device")); + + var validation = secondInstance.validateSession("shared-user", created.getSessionId()); + + assertThat(validation.isValid()).isTrue(); + assertThat(validation.getSession().getMetadata().deviceInfo()).isEqualTo("device"); + } + + @Test + void replacingSessionPreventsStaleInstanceFromDeletingTheNewSession() { + var firstInstance = service(); + var secondInstance = service(); + var oldSession = firstInstance.createSession("shared-user", null); + var newSession = secondInstance.createSession("shared-user", null); + + firstInstance.removeSession("shared-user", oldSession.getSessionId()); + + assertThat(secondInstance.validateSession("shared-user", oldSession.getSessionId()).isValid()) + .isFalse(); + assertThat(secondInstance.validateSession("shared-user", newSession.getSessionId()).isValid()) + .isTrue(); + } + + private SessionService service() { + return new SessionService(new SessionRedisStore(redisTemplate, objectMapper)); + } +} diff --git a/apps/backend/src/test/resources/application.properties b/apps/backend/src/test/resources/application.properties index f510ee5a..319947e3 100644 --- a/apps/backend/src/test/resources/application.properties +++ b/apps/backend/src/test/resources/application.properties @@ -4,6 +4,7 @@ server.port=5001 socketio.server.host=0.0.0.0 socketio.store.type=memory rate-limit.store.type=mongo +session.store.type=mongo app.mongodb.pool.max-size=20 app.mongodb.pool.min-size=0 app.mongodb.pool.max-wait-time-ms=10000 From f705867c6471465ef8694964b412255c55451705 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:37:09 +0900 Subject: [PATCH 28/56] perf: remove message-path Mongo lookups --- .../com/ktb/chatapp/dto/UserResponse.java | 11 ++++ .../socketio/AuthTokenListenerImpl.java | 4 +- .../websocket/socketio/ChatDataStore.java | 4 ++ .../socketio/RedisChatDataStore.java | 5 ++ .../websocket/socketio/SocketUser.java | 12 ++++- .../chatapp/websocket/socketio/UserRooms.java | 2 +- .../socketio/handler/ChatMessageHandler.java | 24 ++------- .../RedisChatDataStoreIntegrationTest.java | 2 + .../handler/ChatMessageHandlerTest.java | 54 +++++++++---------- 9 files changed, 69 insertions(+), 49 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java index 5ecb23de..75ab1b0f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/UserResponse.java @@ -2,6 +2,7 @@ import com.ktb.chatapp.model.User; import com.ktb.chatapp.service.FileUrl; +import com.ktb.chatapp.websocket.socketio.SocketUser; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,4 +27,14 @@ public static UserResponse from(User user) { .profileImage(profileImageUrl != null ? profileImageUrl : "") .build(); } + + public static UserResponse from(SocketUser user) { + String profileImageUrl = FileUrl.of(user.profileImage()); + return UserResponse.builder() + .id(user.id()) + .name(user.name()) + .email(user.email()) + .profileImage(profileImageUrl != null ? profileImageUrl : "") + .build(); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java index 6357e1c6..bbea8635 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/AuthTokenListenerImpl.java @@ -70,7 +70,9 @@ public AuthTokenResult getAuthTokenResult(Object _authToken, SocketIOClient clie log.info("Socket.IO connection authorized for user: {} ({})", user.getName(), userId); - var socketUser = new SocketUser(user.getId(), user.getName(), sessionId, client.getSessionId().toString()); + var socketUser = new SocketUser( + user.getId(), user.getName(), sessionId, client.getSessionId().toString(), + user.getEmail(), user.getProfileImage()); socketIOChatHandlerProvider.getObject().onConnect(client, socketUser); return AuthTokenResult.AuthTokenResultSuccess; } catch (Exception e) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java index 2fe78a6f..4b562e4f 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/ChatDataStore.java @@ -64,6 +64,10 @@ default void addToSet(String key, String value) { set(key, values); } + default boolean setContains(String key, String value) { + return getSet(key).contains(value); + } + default void removeFromSet(String key, String value) { java.util.Set values = getSet(key); values.remove(value); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java index eb97e086..73be0a13 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStore.java @@ -60,6 +60,11 @@ public void addToSet(String key, String value) { values.add(value); } + @Override + public boolean setContains(String key, String value) { + return redissonClient.getSet(key).contains(value); + } + @Override public void removeFromSet(String key, String value) { var values = redissonClient.getSet(key); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java index 5c29a4d4..bd8910b6 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/SocketUser.java @@ -7,5 +7,15 @@ * @param authSessionId user auth session id * @param socketId user websocket session id */ -public record SocketUser(String id, String name, String authSessionId, String socketId) { +public record SocketUser( + String id, + String name, + String authSessionId, + String socketId, + String email, + String profileImage) { + + public SocketUser(String id, String name, String authSessionId, String socketId) { + this(id, name, authSessionId, socketId, null, null); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java index 3fe8a287..cb5365d9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/UserRooms.java @@ -67,7 +67,7 @@ public void clear(String userId) { * @return true if the user is in the room, false otherwise */ public boolean isInRoom(String userId, String roomId) { - return get(userId).contains(roomId); + return chatDataStore.setContains(buildKey(userId), roomId); } private String buildKey(String userId) { diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java index 61b2fcc9..cf684d50 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandler.java @@ -11,8 +11,6 @@ import com.ktb.chatapp.model.*; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; -import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.util.BannedWordChecker; import com.ktb.chatapp.websocket.socketio.ai.AiService; import com.ktb.chatapp.service.RoomActivityNotifier; @@ -21,6 +19,7 @@ import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; @@ -41,14 +40,13 @@ public class ChatMessageHandler { private final SocketIOServer socketIOServer; private final MessageRepository messageRepository; - private final RoomRepository roomRepository; - private final UserRepository userRepository; private final FileRepository fileRepository; private final AiService aiService; private final SessionService sessionService; private final RoomActivityNotifier roomActivityNotifier; private final BannedWordChecker bannedWordChecker; private final RateLimitService rateLimitService; + private final UserRooms userRooms; private final MeterRegistry meterRegistry; @OnEvent(CHAT_MESSAGE) @@ -110,20 +108,8 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } try { - User sender = userRepository.findById(socketUser.id()).orElse(null); - if (sender == null) { - recordError("user_not_found"); - client.sendEvent(ERROR, Map.of( - "code", "MESSAGE_ERROR", - "message", "User not found" - )); - timerSample.stop(createTimer("error", "user_not_found")); - return; - } - String roomId = data.getRoom(); - Room room = roomRepository.findById(roomId).orElse(null); - if (room == null || !room.getParticipantIds().contains(socketUser.id())) { + if (!userRooms.isInRoom(socketUser.id(), roomId)) { recordError("room_access_denied"); client.sendEvent(ERROR, Map.of( "code", "MESSAGE_ERROR", @@ -162,7 +148,7 @@ public void handleChatMessage(SocketIOClient client, ChatMessageRequest data) { } Message savedMessage = messageRepository.save(message); - MessageResponse messageResponse = createMessageResponse(savedMessage, sender); + MessageResponse messageResponse = createMessageResponse(savedMessage, socketUser); socketIOServer.getRoomOperations(roomId) .sendEvent(MESSAGE, messageResponse); @@ -239,7 +225,7 @@ private Message handleTextMessage(String roomId, String userId, MessageContent m return message; } - private MessageResponse createMessageResponse(Message message, User sender) { + private MessageResponse createMessageResponse(Message message, SocketUser sender) { var messageResponse = new MessageResponse(); messageResponse.setId(message.getId()); messageResponse.setRoomId(message.getRoomId()); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java index 767a44b9..8ac4b882 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java @@ -59,6 +59,8 @@ void roomSetUpdatesAreVisibleAcrossInstances() { firstStore.addToSet(key, "room-1"); secondStore.addToSet(key, "room-2"); assertThat(firstStore.getSet(key)).isEqualTo(Set.of("room-1", "room-2")); + assertThat(secondStore.setContains(key, "room-1")).isTrue(); + assertThat(secondStore.setContains(key, "missing-room")).isFalse(); secondStore.removeFromSet(key, "room-1"); assertThat(firstStore.getSet(key)).containsExactly("room-2"); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java index be9e2a56..b4e1935c 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/handler/ChatMessageHandlerTest.java @@ -7,12 +7,8 @@ import com.ktb.chatapp.dto.MessageResponse; import com.ktb.chatapp.model.Message; import com.ktb.chatapp.model.MessageType; -import com.ktb.chatapp.model.Room; -import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.FileRepository; import com.ktb.chatapp.repository.MessageRepository; -import com.ktb.chatapp.repository.RoomRepository; -import com.ktb.chatapp.repository.UserRepository; import com.ktb.chatapp.service.RateLimitCheckResult; import com.ktb.chatapp.service.RateLimitService; import com.ktb.chatapp.service.RoomActivityNotifier; @@ -20,13 +16,12 @@ import com.ktb.chatapp.service.SessionValidationResult; import com.ktb.chatapp.util.BannedWordChecker; import com.ktb.chatapp.websocket.socketio.SocketUser; +import com.ktb.chatapp.websocket.socketio.UserRooms; import com.ktb.chatapp.websocket.socketio.ai.AiService; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.time.LocalDateTime; -import java.util.HashSet; import java.util.Map; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,14 +40,13 @@ class ChatMessageHandlerTest { @Mock private SocketIOServer socketIOServer; @Mock private MessageRepository messageRepository; - @Mock private RoomRepository roomRepository; - @Mock private UserRepository userRepository; @Mock private FileRepository fileRepository; @Mock private AiService aiService; @Mock private SessionService sessionService; @Mock private RoomActivityNotifier roomActivityNotifier; @Mock private BannedWordChecker bannedWordChecker; @Mock private RateLimitService rateLimitService; + @Mock private UserRooms userRooms; private MeterRegistry meterRegistry = new SimpleMeterRegistry(); private ChatMessageHandler handler; @@ -63,14 +57,13 @@ void setUp() { new ChatMessageHandler( socketIOServer, messageRepository, - roomRepository, - userRepository, fileRepository, aiService, sessionService, roomActivityNotifier, bannedWordChecker, rateLimitService, + userRooms, meterRegistry); } @@ -88,14 +81,7 @@ void handleChatMessage_blocksMessagesContainingBannedWords() { when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) .thenReturn(allowedResult); - User user = new User(); - user.setId("user-1"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - Room room = new Room(); - room.setId("room-1"); - room.setParticipantIds(new HashSet<>(java.util.List.of("user-1"))); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); ChatMessageRequest request = ChatMessageRequest.builder() @@ -128,15 +114,7 @@ void handleChatMessage_echoesSavedMessageToSenderSocket() { when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) .thenReturn(RateLimitCheckResult.allowed(10000, 9999, 60, System.currentTimeMillis() / 1000 + 60, 60)); - User user = new User(); - user.setId("user-1"); - user.setName("Tester"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - Room room = new Room(); - room.setId("room-1"); - room.setParticipantIds(new HashSet<>(java.util.List.of("user-1"))); - when(roomRepository.findById("room-1")).thenReturn(Optional.of(room)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(true); when(bannedWordChecker.containsBannedWord("hello")).thenReturn(false); when(socketIOServer.getRoomOperations("room-1")).thenReturn(roomOperations); when(messageRepository.save(any(Message.class))).thenAnswer(invocation -> { @@ -163,4 +141,26 @@ void handleChatMessage_echoesSavedMessageToSenderSocket() { org.junit.jupiter.api.Assertions.assertEquals("message-1", payloadCaptor.getValue().getId()); org.junit.jupiter.api.Assertions.assertEquals("hello", payloadCaptor.getValue().getContent()); } + + @Test + void handleChatMessage_rejectsWhenSharedMembershipIsMissing() { + SocketIOClient client = mock(SocketIOClient.class); + SocketUser socketUser = new SocketUser("user-1", "tester", "session-1", "socket-1"); + when(client.get("user")).thenReturn(socketUser); + when(sessionService.validateSession(socketUser.id(), socketUser.authSessionId())) + .thenReturn(SessionValidationResult.valid(null)); + when(rateLimitService.checkRateLimit(eq(socketUser.id()), anyInt(), any())) + .thenReturn(RateLimitCheckResult.allowed(10000, 9999, 60, + System.currentTimeMillis() / 1000 + 60, 60)); + when(userRooms.isInRoom("user-1", "room-1")).thenReturn(false); + + handler.handleChatMessage(client, ChatMessageRequest.builder() + .room("room-1") + .type("text") + .content("hello") + .build()); + + verify(client).sendEvent(eq(ERROR), any(Map.class)); + verifyNoInteractions(messageRepository); + } } From 1c6eb9176ce230c754db03ce118a113976184cc0 Mon Sep 17 00:00:00 2001 From: gourmet0828 Date: Tue, 11 Aug 2026 16:47:43 +0900 Subject: [PATCH 29/56] wip: add chat S3 direct upload and CloudFront support --- apps/backend/.env.template | 7 + apps/backend/pom.xml | 4 + .../chatapp/controller/FileController.java | 112 ++++++++++- .../main/java/com/ktb/chatapp/model/File.java | 4 + .../service/ChatFileDirectUploadService.java | 142 +++++++++++++ .../service/CloudFrontSignedUrlService.java | 115 +++++++++++ .../ktb/chatapp/service/LocalFileService.java | 46 +++-- .../com/ktb/chatapp/storage/S3Storage.java | 52 +++++ .../com/ktb/chatapp/storage/StorageKey.java | 8 + .../com/ktb/chatapp/storage/StoragePort.java | 12 ++ .../chatapp/storage/StoredObjectMetadata.java | 7 + .../java/com/ktb/chatapp/util/FileUtil.java | 50 +++++ .../src/main/resources/application.properties | 7 + apps/frontend/services/fileService.js | 187 +++++++++++++----- 14 files changed, 677 insertions(+), 76 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java diff --git a/apps/backend/.env.template b/apps/backend/.env.template index 904beb28..66ac8a7d 100644 --- a/apps/backend/.env.template +++ b/apps/backend/.env.template @@ -33,3 +33,10 @@ TOMCAT_THREADS_MAX=50 TOMCAT_THREADS_MIN_SPARE=5 TOMCAT_ACCEPT_COUNT=100 TOMCAT_MAX_CONNECTIONS=200 + +# CloudFront +CLOUDFRONT_ENABLED=false +CLOUDFRONT_BASE_URL=https://chat.goorm-ktb-012.goorm.team +CLOUDFRONT_KEY_PAIR_ID= +CLOUDFRONT_PRIVATE_KEY_PATH=/run/secrets/cloudfront-private-key.pem +CLOUDFRONT_URL_TTL=5m \ No newline at end of file diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index befc92ae..717be2d7 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -34,6 +34,10 @@ pom import + + software.amazon.awssdk + cloudfront + diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java index db90ecba..da941def 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java @@ -1,13 +1,11 @@ package com.ktb.chatapp.controller; import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; -import com.ktb.chatapp.service.FileAccess; -import com.ktb.chatapp.service.FileAccessService; -import com.ktb.chatapp.service.FileService; -import com.ktb.chatapp.service.FileUploadResult; -import com.ktb.chatapp.service.PreviewNotSupportedException; +import com.ktb.chatapp.service.*; +import com.ktb.chatapp.service.ChatFileDirectUploadService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -41,6 +39,7 @@ public class FileController { private final FileService fileService; private final FileAccessService fileAccessService; private final UserRepository userRepository; + private final ChatFileDirectUploadService chatFileDirectUploadService; /** * ํŒŒ์ผ ์—…๋กœ๋“œ @@ -100,6 +99,95 @@ public ResponseEntity uploadFile( } } + @PostMapping("/upload/presign") + public ResponseEntity createUploadUrl( + @RequestBody DirectUploadPresignRequest request, + Principal principal + ) { + try { + User user = userRepository + .findByEmail(principal.getName()) + .orElseThrow(() -> + new UsernameNotFoundException( + "User not found: " + + principal.getName() + ) + ); + + var prepared = + chatFileDirectUploadService.prepare( + request.originalname(), + request.mimetype(), + request.size(), + user.getId() + ); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("uploadUrl", prepared.uploadUrl()); + response.put("key", prepared.key()); + response.put("filename", prepared.filename()); + + return ResponseEntity.ok(response); + + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body( + Map.of( + "success", false, + "message", e.getMessage() + ) + ); + } + } + + @PostMapping("/upload/complete") + public ResponseEntity completeUpload( + @RequestBody DirectUploadCompleteRequest request, + Principal principal + ) { + try { + User user = userRepository + .findByEmail(principal.getName()) + .orElseThrow(() -> + new UsernameNotFoundException( + "User not found: " + + principal.getName() + ) + ); + + File savedFile = + chatFileDirectUploadService.complete( + request.key(), + request.originalname(), + request.mimetype(), + request.size(), + user.getId() + ); + + Map fileData = new HashMap<>(); + fileData.put("_id", savedFile.getId()); + fileData.put("filename", savedFile.getFilename()); + fileData.put("originalname", savedFile.getOriginalname()); + fileData.put("mimetype", savedFile.getMimetype()); + fileData.put("size", savedFile.getSize()); + fileData.put("uploadDate", savedFile.getUploadDate()); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("file", fileData); + + return ResponseEntity.ok(response); + + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body( + Map.of( + "success", false, + "message", e.getMessage() + ) + ); + } + } + /** * ๋ณด์•ˆ์ด ๊ฐ•ํ™”๋œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ */ @@ -278,4 +366,18 @@ public ResponseEntity deleteFile(@PathVariable String id, Principal principal return ResponseEntity.status(500).body(errorResponse); } } + public record DirectUploadPresignRequest( + String originalname, + String mimetype, + long size + ) { + } + + public record DirectUploadCompleteRequest( + String key, + String originalname, + String mimetype, + long size + ) { + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java index e04fd0dc..3604f584 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/File.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/File.java @@ -45,6 +45,10 @@ public class File { @CreatedDate private LocalDateTime uploadDate; + @Builder.Default + @Field("deleted") + private boolean deleted = false; + /** * ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง€์› ์—ฌ๋ถ€ ํ™•์ธ */ diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java new file mode 100644 index 00000000..f05b20e9 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/ChatFileDirectUploadService.java @@ -0,0 +1,142 @@ +package com.ktb.chatapp.service; + +import com.ktb.chatapp.model.File; +import com.ktb.chatapp.repository.FileRepository; +import com.ktb.chatapp.storage.StorageKey; +import com.ktb.chatapp.storage.StoragePort; +import com.ktb.chatapp.util.FileUtil; +import java.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatFileDirectUploadService { + + private final StoragePort storagePort; + private final FileRepository fileRepository; + + @Value("${app.s3.presign-ttl:10m}") + private Duration presignTtl; + + public PreparedUpload prepare( + String originalname, + String mimetype, + long size, + String uploaderId + ) { + FileUtil.validateFileMetadata( + originalname, + mimetype, + size + ); + + String normalizedOriginalname = + FileUtil.normalizeOriginalFilename(originalname); + + String safeFileName = + FileUtil.generateSafeFileName(normalizedOriginalname); + + String key = + StorageKey.chat(uploaderId, safeFileName); + + URI uploadUrl = storagePort + .presignUploadUrl( + key, + mimetype, + presignTtl + ) + .orElseThrow(() -> + new IllegalStateException( + "ํ˜„์žฌ ์Šคํ† ๋ฆฌ์ง€๋Š” ์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ) + ); + + return new PreparedUpload( + uploadUrl.toString(), + key, + safeFileName + ); + } + + public File complete( + String key, + String originalname, + String mimetype, + long size, + String uploaderId + ) { + FileUtil.validateFileMetadata( + originalname, + mimetype, + size + ); + + String safeFileName = + FileUtil.getFileNameFromPath(key); + + String expectedKey = + StorageKey.chat( + uploaderId, + safeFileName + ); + + if (!expectedKey.equals(key)) { + throw new RuntimeException( + "ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์—…๋กœ๋“œ key์ž…๋‹ˆ๋‹ค." + ); + } + + if (!FileUtil.isValidFilename(safeFileName)) { + throw new RuntimeException( + "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค." + ); + } + + var storedObject = storagePort + .stat(key) + .orElseThrow(() -> + new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + + if (storedObject.size() != size) { + throw new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + } + + if (storedObject.contentType() != null + && !storedObject.contentType().equals(mimetype)) { + throw new RuntimeException( + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ ํƒ€์ž…์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + } + + String normalizedOriginalname = + FileUtil.normalizeOriginalFilename(originalname); + + File fileEntity = File.builder() + .filename(safeFileName) + .originalname(normalizedOriginalname) + .mimetype(mimetype) + .size(size) + .path(key) + .user(uploaderId) + .uploadDate(LocalDateTime.now()) + .build(); + + return fileRepository.save(fileEntity); + } + + public record PreparedUpload( + String uploadUrl, + String key, + String filename + ) { + } +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java new file mode 100644 index 00000000..8ef668ba --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java @@ -0,0 +1,115 @@ +package com.ktb.chatapp.service; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import software.amazon.awssdk.services.cloudfront.CloudFrontUtilities; +import software.amazon.awssdk.services.cloudfront.model.CannedSignerRequest; + +@Service +@ConditionalOnProperty( + name = "app.cloudfront.enabled", + havingValue = "true" +) +public class CloudFrontSignedUrlService { + + private final CloudFrontUtilities cloudFrontUtilities; + private final String baseUrl; + private final String keyPairId; + private final Path privateKeyPath; + private final Duration urlTtl; + + public CloudFrontSignedUrlService( + @Value("${app.cloudfront.base-url}") String baseUrl, + @Value("${app.cloudfront.key-pair-id}") String keyPairId, + @Value("${app.cloudfront.private-key-path}") String privateKeyPath, + @Value("${app.cloudfront.url-ttl:5m}") Duration urlTtl + ) { + if (!StringUtils.hasText(baseUrl)) { + throw new IllegalStateException( + "CLOUDFRONT_BASE_URL์€ CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + if (!StringUtils.hasText(keyPairId)) { + throw new IllegalStateException( + "CLOUDFRONT_KEY_PAIR_ID๋Š” CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + if (!StringUtils.hasText(privateKeyPath)) { + throw new IllegalStateException( + "CLOUDFRONT_PRIVATE_KEY_PATH๋Š” CloudFront ํ™œ์„ฑํ™” ์‹œ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); + } + + Path keyPath = Path.of(privateKeyPath); + + if (!Files.isRegularFile(keyPath)) { + throw new IllegalStateException( + "CloudFront private key ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + + privateKeyPath + ); + } + + this.cloudFrontUtilities = CloudFrontUtilities.create(); + this.baseUrl = normalizeBaseUrl(baseUrl); + this.keyPairId = keyPairId; + this.privateKeyPath = keyPath; + this.urlTtl = urlTtl; + } + + public URI sign(String objectKey) { + String resourceUrl = + baseUrl + "/" + normalizeObjectKey(objectKey); + + CannedSignerRequest request = + CannedSignerRequest.builder() + .resourceUrl(resourceUrl) + .privateKey(privateKeyPath) + .keyPairId(keyPairId) + .expirationDate( + Instant.now().plus(urlTtl) + ) + .build(); + + String signedUrl = + cloudFrontUtilities + .getSignedUrlWithCannedPolicy(request) + .url(); + + return URI.create(signedUrl); + } + + private static String normalizeBaseUrl(String baseUrl) { + String normalized = baseUrl.strip(); + + while (normalized.endsWith("/")) { + normalized = + normalized.substring( + 0, + normalized.length() - 1 + ); + } + + return normalized; + } + + private static String normalizeObjectKey(String objectKey) { + String normalized = objectKey; + + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + + return normalized; + } +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java index 7c3a1098..7a141cef 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/LocalFileService.java @@ -104,28 +104,36 @@ public String storeFile(MultipartFile file, String subDirectory) { } @Override - public boolean deleteFile(String fileId, String requesterId) { - try { - File fileEntity = fileRepository.findById(fileId) - .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - // ์‚ญ์ œ ๊ถŒํ•œ ๊ฒ€์ฆ (์—…๋กœ๋”๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) - if (!fileEntity.getUser().equals(requesterId)) { - throw new RuntimeException("ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); - } + public boolean deleteFile( + String fileId, + String requesterId + ) { + File fileEntity = fileRepository.findById(fileId) + .orElseThrow(() -> + new RuntimeException( + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + + if (!fileEntity.getUser().equals(requesterId)) { + throw new RuntimeException( + "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค." + ); + } - // ๋ฌผ๋ฆฌ์  ํŒŒ์ผ ์‚ญ์ œ - storagePort.delete(fileEntity.getPath()); + if (fileEntity.isDeleted()) { + return true; + } - // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ œ๊ฑฐ - fileRepository.delete(fileEntity); + fileEntity.setDeleted(true); + fileRepository.save(fileEntity); - log.info("ํŒŒ์ผ ์‚ญ์ œ ์™„๋ฃŒ: {} (์‚ฌ์šฉ์ž: {})", fileId, requesterId); - return true; + log.info( + "ํŒŒ์ผ soft delete ์™„๋ฃŒ: {} (์‚ฌ์šฉ์ž: {})", + fileId, + requesterId + ); - } catch (Exception e) { - log.error("ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ: {}", e.getMessage(), e); - throw new RuntimeException("ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", e); - } + return true; } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java index b5a11134..42841b59 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/S3Storage.java @@ -23,6 +23,9 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + @Component @ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") public class S3Storage implements StoragePort { @@ -109,6 +112,55 @@ public Optional offloadUrl(String key, Duration ttl, ContentDisposition dis .map(URI::create); } + @Override + public Optional presignUploadUrl( + String key, + String contentType, + Duration ttl + ) { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .contentType(contentType) + .cacheControl(PRIVATE_CACHE_CONTROL) + .build(); + + return Optional.of( + presigner.presignPutObject( + PutObjectPresignRequest.builder() + .signatureDuration(ttl) + .putObjectRequest(putRequest) + .build() + ).url().toExternalForm() + ).map(URI::create); + } + + @Override + public Optional stat(String key) { + try { + HeadObjectResponse response = + s3Client.headObject( + HeadObjectRequest.builder() + .bucket(bucket) + .key(objectKey(key)) + .build() + ); + + return Optional.of( + new StoredObjectMetadata( + response.contentLength(), + response.contentType() + ) + ); + + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return Optional.empty(); + } + throw e; + } + } + private String objectKey(String key) { return keyPrefix + key; } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java index 78fe2362..dda88eed 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StorageKey.java @@ -19,6 +19,14 @@ public static String chat(String fileName) { return CHAT_PREFIX + fileName; } + public static String chat(String userId, String fileName) { + return CHAT_PREFIX + userId + "/" + fileName; + } + + public static String chatUserPrefix(String userId) { + return CHAT_PREFIX + userId + "/"; + } + public static boolean isProfile(String key) { return key != null && key.startsWith(PROFILE_PREFIX); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java index ea492de8..726e51d9 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoragePort.java @@ -20,4 +20,16 @@ public interface StoragePort { default Optional offloadUrl(String key, Duration ttl, ContentDisposition disposition) { return Optional.empty(); } + + default Optional presignUploadUrl( + String key, + String contentType, + Duration ttl + ) { + return Optional.empty(); + } + + default Optional stat(String key) { + return Optional.empty(); + } } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java new file mode 100644 index 00000000..30f6b912 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/storage/StoredObjectMetadata.java @@ -0,0 +1,7 @@ +package com.ktb.chatapp.storage; + +public record StoredObjectMetadata( + long size, + String contentType +) { +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java b/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java index 4c892dcc..5ead59ab 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/util/FileUtil.java @@ -87,6 +87,56 @@ public static void validateFile(MultipartFile file) { } } + public static void validateFileMetadata( + String originalFilename, + String contentType, + long size + ) { + if (originalFilename == null || originalFilename.trim().isEmpty()) { + throw new RuntimeException("ํŒŒ์ผ๋ช…์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + int filenameBytes = + originalFilename.getBytes(StandardCharsets.UTF_8).length; + + if (filenameBytes > 255) { + throw new RuntimeException("ํŒŒ์ผ๋ช…์ด ๋„ˆ๋ฌด ๊น๋‹ˆ๋‹ค."); + } + + if (contentType == null || !ALLOWED_TYPES.containsKey(contentType)) { + throw new RuntimeException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); + } + + String extension = + getFileExtension(originalFilename).toLowerCase(); + + List allowedExtensions = + ALLOWED_TYPES.get(contentType); + + if (allowedExtensions == null || + !allowedExtensions.contains(extension)) { + throw new RuntimeException("ํŒŒ์ผ ํ™•์žฅ์ž๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + String type = contentType.split("/")[0]; + + long limit = FILE_SIZE_LIMITS.getOrDefault( + type, + FILE_SIZE_LIMITS.get("application") + ); + + if (size <= 0 || size > limit) { + int limitInMB = (int) (limit / 1024 / 1024); + + throw new RuntimeException( + getFileType(contentType) + + " ํŒŒ์ผ์€ " + + limitInMB + + "MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ); + } + } + /** * ํŒŒ์ผ ํƒ€์ž… ํ•œ๊ธ€๋ช… ๋ฐ˜ํ™˜ */ diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index c88cd940..3619439d 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -85,3 +85,10 @@ management.endpoint.prometheus.access=read_only management.prometheus.metrics.export.enabled=true management.metrics.tags.application=${spring.application.name:ktb-chat-backend} management.metrics.tags.host=${HOSTNAME:unknown} + +# CloudFront signed URL +app.cloudfront.enabled=${CLOUDFRONT_ENABLED:false} +app.cloudfront.base-url=${CLOUDFRONT_BASE_URL:} +app.cloudfront.key-pair-id=${CLOUDFRONT_KEY_PAIR_ID:} +app.cloudfront.private-key-path=${CLOUDFRONT_PRIVATE_KEY_PATH:} +app.cloudfront.url-ttl=${CLOUDFRONT_URL_TTL:5m} \ No newline at end of file diff --git a/apps/frontend/services/fileService.js b/apps/frontend/services/fileService.js index 97d48c45..75986a7b 100644 --- a/apps/frontend/services/fileService.js +++ b/apps/frontend/services/fileService.js @@ -74,81 +74,164 @@ class FileService { return { success: true }; } - async uploadFile(file, onProgress, token, sessionId) { - const validationResult = await this.validateFile(file); - if (!validationResult.success) { - return validationResult; - } - - try { - const formData = new FormData(); - formData.append('file', file); +async uploadFile(file, onProgress, token, sessionId) { + const validationResult = await this.validateFile(file); - const source = CancelToken.source(); - this.activeUploads.set(file.name, source); + if (!validationResult.success) { + return validationResult; + } - const uploadUrl = this.baseUrl ? - `${this.baseUrl}/api/files/upload` : - '/api/files/upload'; + const source = CancelToken.source(); + this.activeUploads.set(file.name, source); + + const presignUrl = this.baseUrl + ? `${this.baseUrl}/api/files/upload/presign` + : '/api/files/upload/presign'; + + const completeUrl = this.baseUrl + ? `${this.baseUrl}/api/files/upload/complete` + : '/api/files/upload/complete'; + + try { + /* + * 1. Backend์— presigned PUT URL ์š”์ฒญ + * + * ํŒŒ์ผ ๋ณธ๋ฌธ์€ ๋ณด๋‚ด์ง€ ์•Š๊ณ  + * ์ด๋ฆ„ / ํƒ€์ž… / ํฌ๊ธฐ๋งŒ ์ „๋‹ฌํ•œ๋‹ค. + */ + const presignResponse = await axiosInstance.post( + presignUrl, + { + originalname: file.name, + mimetype: file.type, + size: file.size + }, + { + timeout: 10000, + cancelToken: source.token, + withCredentials: true + } + ); + + const presignData = presignResponse.data; + + if ( + !presignData?.success || + !presignData?.uploadUrl || + !presignData?.key + ) { + throw new Error( + presignData?.message || + 'S3 ์—…๋กœ๋“œ ์ฃผ์†Œ๋ฅผ ๋ฐœ๊ธ‰๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.' + ); + } - // token๊ณผ sessionId๋Š” axios ์ธํ„ฐ์…‰ํ„ฐ์—์„œ ์ž๋™์œผ๋กœ ์ถ”๊ฐ€๋˜๋ฏ€๋กœ - // ์—ฌ๊ธฐ์„œ๋Š” ๋ช…์‹œ์ ์œผ๋กœ ์ „๋‹ฌํ•˜์ง€ ์•Š์•„๋„ ๋ฉ๋‹ˆ๋‹ค - const response = await axiosInstance.post(uploadUrl, formData, { + /* + * 2. Browser โ†’ S3 ์ง์ ‘ ์—…๋กœ๋“œ + * + * axiosInstance๊ฐ€ ์•„๋‹ˆ๋ผ ์ผ๋ฐ˜ axios๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. + * + * ์ด์œ : + * S3 ์š”์ฒญ์—๋Š” ์šฐ๋ฆฌ Backend์˜ + * Authorization / session header๋ฅผ ๋ถ™์ด๋ฉด ์•ˆ ๋œ๋‹ค. + */ + await axios.put( + presignData.uploadUrl, + file, + { headers: { - 'Content-Type': 'multipart/form-data' + 'Content-Type': file.type, + 'Cache-Control': + 'private, no-cache, no-store, must-revalidate' }, - // ์—…๋กœ๋“œ๋Š” ํ•œ๋„๊ฐ€ 50MB ๋ผ ๊ณตํ†ต ํƒ€์ž„์•„์›ƒ์œผ๋กœ๋Š” ์ •์ƒ ์ „์†ก๋„ ๋Š๊ธด๋‹ค. timeout: 30000, cancelToken: source.token, - withCredentials: true, + onUploadProgress: (progressEvent) => { - if (onProgress) { + if ( + onProgress && + progressEvent.total + ) { const percentCompleted = Math.round( - (progressEvent.loaded * 100) / progressEvent.total + (progressEvent.loaded * 100) / + progressEvent.total ); + onProgress(percentCompleted); } } - }); + } + ); + + /* + * 3. S3 ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ Backend์— metadata ์ €์žฅ ์š”์ฒญ + * + * Backend๋Š” S3 HEAD๋กœ ์‹ค์ œ ๊ฐ์ฒด๋ฅผ ํ™•์ธํ•˜๊ณ  + * MongoDB files ์ปฌ๋ ‰์…˜์— metadata๋ฅผ ์ €์žฅํ•œ๋‹ค. + */ + const completeResponse = await axiosInstance.post( + completeUrl, + { + key: presignData.key, + originalname: file.name, + mimetype: file.type, + size: file.size + }, + { + timeout: 10000, + cancelToken: source.token, + withCredentials: true + } + ); + + const completeData = completeResponse.data; + + if ( + !completeData?.success || + !completeData?.file + ) { + throw new Error( + completeData?.message || + 'ํŒŒ์ผ ์ •๋ณด๋ฅผ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.' + ); + } - this.activeUploads.delete(file.name); + const fileData = completeData.file; - if (!response.data || !response.data.success) { - return { - success: false, - message: response.data?.message || 'ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.' - }; + return { + success: true, + data: { + ...completeData, + file: { + ...fileData, + url: this.getFileUrl( + fileData.filename, + true + ) + } } + }; - const fileData = response.data.file; + } catch (error) { + if (isCancel(error)) { return { - success: true, - data: { - ...response.data, - file: { - ...fileData, - url: this.getFileUrl(fileData.filename, true) - } - } + success: false, + message: '์—…๋กœ๋“œ๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' }; + } - } catch (error) { - this.activeUploads.delete(file.name); - - if (isCancel(error)) { - return { - success: false, - message: '์—…๋กœ๋“œ๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' - }; - } + if (error.response?.status === 401) { + throw new Error( + 'Authentication expired. Please login again.' + ); + } - if (error.response?.status === 401) { - throw new Error('Authentication expired. Please login again.'); - } + return this.handleUploadError(error); - return this.handleUploadError(error); - } + } finally { + this.activeUploads.delete(file.name); } +} getFileUrl(filename, forPreview = false) { if (!filename) return ''; From 255ece231040f0f55ba3dadf74a01beb4f942fa6 Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 16:51:42 +0900 Subject: [PATCH 30/56] perf: debounce room activity aggregation --- .../service/RoomActivityDebouncer.java | 44 +++++++++++++++++++ .../chatapp/service/RoomActivityNotifier.java | 10 ++++- .../src/main/resources/application.properties | 2 + .../RoomActivityDebouncerIntegrationTest.java | 40 +++++++++++++++++ .../service/RoomActivityNotifierTest.java | 17 ++++++- 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java create mode 100644 apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java new file mode 100644 index 00000000..1389443e --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityDebouncer.java @@ -0,0 +1,44 @@ +package com.ktb.chatapp.service; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RoomActivityDebouncer { + + private static final String KEY_PREFIX = "room_activity:debounce:"; + + private final ObjectProvider redissonClientProvider; + private final Duration debounceWindow; + private final AtomicBoolean fallbackWarningLogged = new AtomicBoolean(); + + public RoomActivityDebouncer( + ObjectProvider redissonClientProvider, + @Value("${room-activity.debounce-window:1s}") Duration debounceWindow) { + this.redissonClientProvider = redissonClientProvider; + this.debounceWindow = debounceWindow; + } + + public boolean tryAcquire(String roomId) { + RedissonClient redissonClient = redissonClientProvider.getIfAvailable(); + if (redissonClient == null) { + return true; + } + + try { + return redissonClient.getBucket(KEY_PREFIX + roomId) + .setIfAbsent("1", debounceWindow); + } catch (RuntimeException e) { + if (fallbackWarningLogged.compareAndSet(false, true)) { + log.warn("Redis room activity debounce unavailable; using local batching", e); + } + return true; + } + } +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java index a10fc302..a66efc1c 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/RoomActivityNotifier.java @@ -22,6 +22,7 @@ public class RoomActivityNotifier { private final RecentMessageCounter recentMessageCounter; private final ApplicationEventPublisher eventPublisher; + private final RoomActivityDebouncer roomActivityDebouncer; private final Set pendingRoomIds = ConcurrentHashMap.newKeySet(); @@ -42,7 +43,7 @@ public void notifyMessageStored(String roomId) { * ์งง์€ ์ฃผ๊ธฐ๋กœ ๋ณ€๊ฒฝ๋œ ์ฑ„ํŒ…๋ฐฉ์˜ ์ตœ๊ทผ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ * ํ•œ ๋ฒˆ์˜ ์ง‘๊ณ„ ์ฟผ๋ฆฌ๋กœ ์กฐํšŒํ•œ๋‹ค. */ - @Scheduled(fixedDelay = 100) + @Scheduled(fixedDelayString = "${room-activity.flush-delay:1s}") void flushPendingRoomActivities() { if (pendingRoomIds.isEmpty()) { return; @@ -60,6 +61,11 @@ void flushPendingRoomActivities() { return; } + roomIds.removeIf(roomId -> !roomActivityDebouncer.tryAcquire(roomId)); + if (roomIds.isEmpty()) { + return; + } + try { Map recentMessageCounts = recentMessageCounter.countRecentMessagesByRoomIds(roomIds); @@ -88,4 +94,4 @@ void flushPendingRoomActivities() { ); } } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 3824652f..95b9795e 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -55,6 +55,8 @@ socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} socketio.store.type=${SOCKETIO_STORE_TYPE:redis} rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis} session.store.type=${SESSION_STORE_TYPE:redis} +room-activity.flush-delay=${ROOM_ACTIVITY_FLUSH_DELAY:1s} +room-activity.debounce-window=${ROOM_ACTIVITY_DEBOUNCE_WINDOW:1s} # CORS Configuration app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java new file mode 100644 index 00000000..ae5e479f --- /dev/null +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java @@ -0,0 +1,40 @@ +package com.ktb.chatapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ktb.chatapp.config.MongoTestContainer; +import com.ktb.chatapp.config.RedisTestContainer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +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 = { + "socketio.enabled=false", + "socketio.store.type=redis" +}) +class RoomActivityDebouncerIntegrationTest { + + @Autowired + private RoomActivityDebouncer debouncer; + + @Autowired + private RedissonClient redissonClient; + + @BeforeEach + void clearKeys() { + redissonClient.getKeys().deleteByPattern("room_activity:debounce:*"); + } + + @Test + void onlyOneInstanceCanAggregateTheSameRoomDuringTheWindow() { + assertThat(debouncer.tryAcquire("room-1")).isTrue(); + assertThat(debouncer.tryAcquire("room-1")).isFalse(); + assertThat(debouncer.tryAcquire("room-2")).isTrue(); + } +} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java index 3708e33b..18042a15 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java @@ -23,15 +23,17 @@ class RoomActivityNotifierTest { @Mock private RecentMessageCounter recentMessageCounter; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private RoomActivityDebouncer roomActivityDebouncer; private RoomActivityNotifier notifier() { - return new RoomActivityNotifier(recentMessageCounter, eventPublisher); + return new RoomActivityNotifier(recentMessageCounter, eventPublisher, roomActivityDebouncer); } @Test void flushPublishesRecentMessageCount() { when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) .thenReturn(Map.of("room-1", 7)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); RoomActivityNotifier notifier = notifier(); notifier.notifyMessageStored("room-1"); @@ -47,6 +49,7 @@ void flushPublishesRecentMessageCount() { void flushCoalescesRepeatedNotificationsForSameRoom() { when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) .thenReturn(Map.of("room-1", 3)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); RoomActivityNotifier notifier = notifier(); notifier.notifyMessageStored("room-1"); @@ -72,6 +75,7 @@ void failedBatchIsRetriedOnNextFlush() { when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1"))) .thenThrow(new RuntimeException("mongo down")) .thenReturn(Map.of("room-1", 1)); + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true); RoomActivityNotifier notifier = notifier(); notifier.notifyMessageStored("room-1"); @@ -82,4 +86,15 @@ void failedBatchIsRetriedOnNextFlush() { verify(recentMessageCounter, times(2)).countRecentMessagesByRoomIds(Set.of("room-1")); verify(eventPublisher).publishEvent(any(RoomActivityEvent.class)); } + + @Test + void distributedDebounceSkipsDuplicateAggregation() { + when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(false); + RoomActivityNotifier notifier = notifier(); + + notifier.notifyMessageStored("room-1"); + notifier.flushPendingRoomActivities(); + + verifyNoInteractions(recentMessageCounter, eventPublisher); + } } From cd78190a4a2336fbdfd051b1aaffba0d5918aebe Mon Sep 17 00:00:00 2001 From: yang-yun-yeong Date: Tue, 11 Aug 2026 17:00:05 +0900 Subject: [PATCH 31/56] perf: tune Socket.IO for connection spikes --- .../ktb/chatapp/config/SocketIOConfig.java | 26 ++++++++++++++----- .../src/main/resources/application.properties | 2 ++ .../chatapp/config/SocketIOConfigTest.java | 16 ++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java index 2be153e2..b6632542 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/config/SocketIOConfig.java @@ -43,6 +43,12 @@ public class SocketIOConfig { @Value("${socketio.store.type:redis}") private String storeType; + @Value("${socketio.server.accept-backlog:1024}") + private int acceptBacklog; + + @Value("${socketio.server.tcp-no-delay:true}") + private boolean tcpNoDelay; + @Bean(initMethod = "start", destroyMethod = "stop") public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterRegistry meterRegistry, ObjectProvider redissonClientProvider) { @@ -50,12 +56,7 @@ public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterR config.setHostname(host); config.setPort(port); - var socketConfig = new SocketConfig(); - socketConfig.setReuseAddress(true); - socketConfig.setTcpNoDelay(false); - socketConfig.setAcceptBackLog(10); - socketConfig.setTcpSendBufferSize(4096); - socketConfig.setTcpReceiveBufferSize(4096); + var socketConfig = createSocketConfig(acceptBacklog, tcpNoDelay); config.setSocketConfig(socketConfig); config.setOrigin(origin); @@ -94,6 +95,19 @@ public SocketIOServer socketIOServer(AuthTokenListener authTokenListener, MeterR return socketIOServer; } + + static SocketConfig createSocketConfig(int acceptBacklog, boolean tcpNoDelay) { + if (acceptBacklog < 1) { + throw new IllegalArgumentException("socketio.server.accept-backlog must be positive"); + } + var socketConfig = new SocketConfig(); + socketConfig.setReuseAddress(true); + socketConfig.setTcpNoDelay(tcpNoDelay); + socketConfig.setAcceptBackLog(acceptBacklog); + socketConfig.setTcpSendBufferSize(4096); + socketConfig.setTcpReceiveBufferSize(4096); + return socketConfig; + } /** * SpringAnnotationScanner๋Š” BeanPostProcessor๋กœ์„œ diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 95b9795e..adce0e1f 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -52,6 +52,8 @@ spring.ai.openai.chat.options.temperature=${OPENAI_TEMPERATURE:0.7} socketio.server.host=0.0.0.0 socketio.server.port=${WS_PORT:5002} socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*} +socketio.server.accept-backlog=${SOCKETIO_ACCEPT_BACKLOG:1024} +socketio.server.tcp-no-delay=${SOCKETIO_TCP_NO_DELAY:true} socketio.store.type=${SOCKETIO_STORE_TYPE:redis} rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis} session.store.type=${SESSION_STORE_TYPE:redis} diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java index 0270240a..0a4b7c62 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java @@ -9,6 +9,7 @@ import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @TestPropertySource(properties = "socketio.enabled=false") @@ -21,4 +22,19 @@ void shouldNotLoadSocketIOBeansWhenDisabled(ApplicationContext context) { assertThrows(NoSuchBeanDefinitionException.class, () -> context.getBean(SocketIOServer.class)); } + + @Test + void createsSpikeReadySocketTransportSettings() { + var socketConfig = SocketIOConfig.createSocketConfig(1024, true); + + assertThat(socketConfig.getAcceptBackLog()).isEqualTo(1024); + assertThat(socketConfig.isTcpNoDelay()).isTrue(); + assertThat(socketConfig.isReuseAddress()).isTrue(); + } + + @Test + void rejectsInvalidAcceptBacklog() { + assertThrows(IllegalArgumentException.class, + () -> SocketIOConfig.createSocketConfig(0, true)); + } } From 845a989790ac76cb74f1cb25664be95ad08414ac Mon Sep 17 00:00:00 2001 From: JIHYUNG KIM Date: Tue, 11 Aug 2026 18:00:37 +0900 Subject: [PATCH 32/56] =?UTF-8?q?perf:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=C2=B7`/chat`=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/chat/rooms/ChatRoomsView.js | 2 +- .../chat/rooms/__tests__/useRoomList.test.js | 22 ++++++++++ .../features/chat/rooms/useRoomList.js | 5 +-- .../frontend/lib/api/__tests__/client.test.js | 18 ++++++++ apps/frontend/lib/api/client.js | 6 ++- apps/frontend/pages/index.js | 41 ++++--------------- apps/frontend/pages/register.js | 6 +-- apps/frontend/services/authService.js | 6 ++- 8 files changed, 62 insertions(+), 44 deletions(-) diff --git a/apps/frontend/features/chat/rooms/ChatRoomsView.js b/apps/frontend/features/chat/rooms/ChatRoomsView.js index 509e9f72..b0a3a650 100644 --- a/apps/frontend/features/chat/rooms/ChatRoomsView.js +++ b/apps/frontend/features/chat/rooms/ChatRoomsView.js @@ -60,7 +60,6 @@ export default function ChatRoomsView({ router }) { connectionStatus, setConnectionStatus, isRetrying, - attemptConnection, }); const connectionCheckTimerRef = useRef(null); @@ -109,6 +108,7 @@ export default function ChatRoomsView({ router }) { useEffect(() => { if (!currentUserKey || connectionStatus !== CONNECTION_STATUS.CHECKING) return; + attemptConnection(); connectionCheckTimerRef.current = setInterval(() => { attemptConnection(); }, 5000); diff --git a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js index 892fd681..b39daa57 100644 --- a/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js +++ b/apps/frontend/features/chat/rooms/__tests__/useRoomList.test.js @@ -62,6 +62,28 @@ describe('useRoomList', () => { expect(axiosInstance.get).toHaveBeenCalledWith('/api/rooms', { params: { page: 0 } }); }); + it('requests rooms without waiting for the socket connection', async () => { + axiosInstance.get.mockResolvedValue(roomsResponse([])); + const neverConnects = new Promise(() => {}); + + const { result } = renderHook(() => + useRoomList({ + currentUser: { token: 'token-1' }, + router: { push: vi.fn() }, + connectionStatus: CONNECTION_STATUS.CHECKING, + setConnectionStatus: vi.fn(), + isRetrying: false, + attemptConnection: vi.fn(() => neverConnects), + }) + ); + + await act(async () => { + await result.current.fetchRooms(); + }); + + expect(axiosInstance.get).toHaveBeenCalledWith('/api/rooms', { params: { page: 0 } }); + }); + it('keeps the current list and stays quiet when a silent refresh fails', async () => { axiosInstance.get.mockResolvedValueOnce(roomsResponse([{ _id: 'room-1' }])); diff --git a/apps/frontend/features/chat/rooms/useRoomList.js b/apps/frontend/features/chat/rooms/useRoomList.js index c608c7e0..a9ce5ef2 100644 --- a/apps/frontend/features/chat/rooms/useRoomList.js +++ b/apps/frontend/features/chat/rooms/useRoomList.js @@ -18,7 +18,6 @@ export const useRoomList = ({ connectionStatus, setConnectionStatus, isRetrying, - attemptConnection, }) => { const [rooms, setRooms] = useState([]); const [error, setError] = useState(null); @@ -80,8 +79,6 @@ export const useRoomList = ({ }, []); const loadRooms = useCallback(async (requestedPage = currentPage) => { - await attemptConnection(); - let payload = await requestRoomsPage(requestedPage); let resolvedPage = requestedPage; @@ -101,7 +98,7 @@ export const useRoomList = ({ setRooms(payload.data); setMetadata(payload.metadata); setCurrentPage(resolvedPage); - }, [attemptConnection, currentPage, requestRoomsPage]); + }, [currentPage, requestRoomsPage]); const fetchRooms = useCallback(async () => { if (!currentUser?.token || isLoadingRef.current) { diff --git a/apps/frontend/lib/api/__tests__/client.test.js b/apps/frontend/lib/api/__tests__/client.test.js index 983eacb7..0b6ad5ae 100644 --- a/apps/frontend/lib/api/__tests__/client.test.js +++ b/apps/frontend/lib/api/__tests__/client.test.js @@ -47,6 +47,24 @@ describe('api client', () => { expect(readHeader(response.config.headers, 'x-session-id')).toBe('session-1'); }); + it('does not retry requests marked with skipRetry', async () => { + const client = createApiClient({ baseURL: 'http://api.test', getSession: () => null }); + let attempts = 0; + + client.defaults.adapter = async (config) => { + attempts++; + const error = new Error('Gateway Timeout'); + error.config = config; + error.response = { config, data: {}, headers: {}, status: 504, statusText: 'Gateway Timeout' }; + throw error; + }; + + await expect(client.post('/api/auth/login', {}, { skipRetry: true })).rejects.toMatchObject({ + status: 504, + }); + expect(attempts).toBe(1); + }); + it('respects skipAuth requests', async () => { const client = createApiClient({ baseURL: 'http://api.test', diff --git a/apps/frontend/lib/api/client.js b/apps/frontend/lib/api/client.js index 7e0f7c42..e9da1475 100644 --- a/apps/frontend/lib/api/client.js +++ b/apps/frontend/lib/api/client.js @@ -93,7 +93,11 @@ export const createApiClient = ({ return Promise.reject(error); } - if (isRetryableError(error) && config.retryCount < RETRY_CONFIG.maxRetries) { + if ( + config.skipRetry !== true && + isRetryableError(error) && + config.retryCount < RETRY_CONFIG.maxRetries + ) { config.retryCount++; const delay = getRetryDelay(config.retryCount); diff --git a/apps/frontend/pages/index.js b/apps/frontend/pages/index.js index bd1f05aa..f6323c1f 100644 --- a/apps/frontend/pages/index.js +++ b/apps/frontend/pages/index.js @@ -81,12 +81,6 @@ const Login = () => { const handleSubmit = async (e) => { e.preventDefault(); - // ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์ƒํƒœ ํ™•์ธ โ€” ๋กœ๊ทธ์ธ์„ ์‹œ๋„์กฐ์ฐจ ๋ชป ํ•œ ๊ฒƒ์ด๋ฏ€๋กœ ์„œ๋ฒ„ ์•ˆ๋‚ด๋กœ ์•Œ๋ฆฐ๋‹ค. - if (!serverStatus.connected) { - setServerNotice('์„œ๋ฒ„์™€ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์„ ํ™•์ธํ•˜๊ณ  ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); - return; - } - // ํผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ if (!validateForm()) { return; @@ -107,7 +101,7 @@ const Login = () => { // ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ const redirectUrl = router.query.redirect || '/chat'; - router.push(redirectUrl); + await router.push(redirectUrl); } catch (err) { setError(err.message || '๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); @@ -116,29 +110,6 @@ const Login = () => { } }; - if (serverStatus.checking) { - return ( -
- -
- KTB Chat ๋กœ๊ณ  -
-
- ์„œ๋ฒ„ ์—ฐ๊ฒฐ ํ™•์ธ ์ค‘... -
-
-
- ); - } - return (
{ )} + {serverStatus.checking && ( + + ์„œ๋ฒ„ ์—ฐ๊ฒฐ์„ ํ™•์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ์€ ๊ณ„์† ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + )} + {error && ( @@ -224,7 +201,7 @@ const Login = () => { diff --git a/apps/frontend/pages/register.js b/apps/frontend/pages/register.js index 57f36a2c..42a6581a 100644 --- a/apps/frontend/pages/register.js +++ b/apps/frontend/pages/register.js @@ -53,11 +53,7 @@ const Register = () => { await registerContext({ name, email, password }); setSuccess(true); - setLoading(false); - - setTimeout(() => { - router.push('/login'); - }, 1000); + await router.replace('/login'); } catch (err) { setError(err.message || 'ํšŒ์›๊ฐ€์ž… ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); setLoading(false); diff --git a/apps/frontend/services/authService.js b/apps/frontend/services/authService.js index d8a4bb9b..9bcf4d84 100644 --- a/apps/frontend/services/authService.js +++ b/apps/frontend/services/authService.js @@ -43,6 +43,7 @@ class AuthService { const response = await api.post('/api/auth/login', credentials, { skipAuth: true, handleAuthError: false, + skipRetry: true, }); if (response.data?.success && response.data?.token) { @@ -101,7 +102,10 @@ class AuthService { */ async register(userData) { try { - const response = await api.post('/api/auth/register', userData); + const response = await api.post('/api/auth/register', userData, { + skipAuth: true, + skipRetry: true, + }); if (response.data?.success) { return response.data; From 8f0894b53a6e8de1f0d28eb19b080030c42c028e Mon Sep 17 00:00:00 2001 From: gourmet0828 Date: Tue, 11 Aug 2026 19:56:17 +0900 Subject: [PATCH 33/56] perf: add direct S3 upload and CloudFront file delivery --- apps/backend/pom.xml | 8 +- .../chatapp/controller/FileController.java | 897 +++++++++++++----- .../com/ktb/chatapp/dto/FileResponse.java | 3 + .../com/ktb/chatapp/dto/RoomsResponse.java | 1 + .../exception/DeletedFileException.java | 9 + .../main/java/com/ktb/chatapp/model/User.java | 3 +- .../service/CloudFrontSignedUrlService.java | 41 +- .../chatapp/service/FileAccessService.java | 43 +- .../handler/MessageResponseMapper.java | 1 + .../service/FileAccessServiceTest.java | 4 +- apps/frontend/app/chat/[room]/page.js | 13 +- apps/frontend/app/chat/page.js | 13 +- .../components/AuthRequiredFallback.js | 40 + apps/frontend/components/FileMessage.js | 193 ++-- apps/frontend/contexts/AuthContext.js | 13 +- apps/frontend/pages/_error.js | 2 +- 16 files changed, 923 insertions(+), 361 deletions(-) create mode 100644 apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java create mode 100644 apps/frontend/components/AuthRequiredFallback.js diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index 717be2d7..9c6e247a 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -34,10 +34,6 @@ pom import - - software.amazon.awssdk - cloudfront - @@ -81,6 +77,10 @@ software.amazon.awssdk s3 + + software.amazon.awssdk + cloudfront + software.amazon.awssdk url-connection-client diff --git a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java index da941def..e214c80d 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/controller/FileController.java @@ -1,11 +1,16 @@ package com.ktb.chatapp.controller; import com.ktb.chatapp.dto.StandardResponse; +import com.ktb.chatapp.exception.DeletedFileException; import com.ktb.chatapp.model.File; import com.ktb.chatapp.model.User; import com.ktb.chatapp.repository.UserRepository; -import com.ktb.chatapp.service.*; import com.ktb.chatapp.service.ChatFileDirectUploadService; +import com.ktb.chatapp.service.FileAccess; +import com.ktb.chatapp.service.FileAccessService; +import com.ktb.chatapp.service.FileService; +import com.ktb.chatapp.service.FileUploadResult; +import com.ktb.chatapp.service.PreviewNotSupportedException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -26,10 +31,20 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -@Tag(name = "ํŒŒ์ผ (Files)", description = "ํŒŒ์ผ ์—…๋กœ๋“œ ๋ฐ ๋‹ค์šด๋กœ๋“œ API") +@Tag( + name = "ํŒŒ์ผ (Files)", + description = "ํŒŒ์ผ ์—…๋กœ๋“œ ๋ฐ ๋‹ค์šด๋กœ๋“œ API" +) @Slf4j @RequiredArgsConstructor @RestController @@ -39,82 +54,157 @@ public class FileController { private final FileService fileService; private final FileAccessService fileAccessService; private final UserRepository userRepository; - private final ChatFileDirectUploadService chatFileDirectUploadService; + private final ChatFileDirectUploadService + chatFileDirectUploadService; /** - * ํŒŒ์ผ ์—…๋กœ๋“œ + * ๊ธฐ์กด Multipart ์—…๋กœ๋“œ. + * + * ๊ธฐ์กด ๊ธฐ๋Šฅ ๋ณด์กด์šฉ์œผ๋กœ ๋‚จ๊ฒจ๋‘”๋‹ค. */ - @Operation(summary = "ํŒŒ์ผ ์—…๋กœ๋“œ", description = "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ์ตœ๋Œ€ 50MB๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "ํŒŒ์ผ ์—…๋กœ๋“œ", + description = "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ์ตœ๋Œ€ 50MB๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต"), - @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ํŒŒ์ผ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "413", description = "ํŒŒ์ผ ํฌ๊ธฐ ์ดˆ๊ณผ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", - content = @Content(schema = @Schema(implementation = StandardResponse.class))) + @ApiResponse( + responseCode = "200", + description = "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต" + ), + @ApiResponse( + responseCode = "400", + description = "์ž˜๋ชป๋œ ํŒŒ์ผ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "413", + description = "ํŒŒ์ผ ํฌ๊ธฐ ์ดˆ๊ณผ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ) }) @PostMapping("/upload") public ResponseEntity uploadFile( - @Parameter(description = "์—…๋กœ๋“œํ•  ํŒŒ์ผ") @RequestParam("file") MultipartFile file, - Principal principal) { + @Parameter(description = "์—…๋กœ๋“œํ•  ํŒŒ์ผ") + @RequestParam("file") + MultipartFile file, + Principal principal + ) { try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); - - FileUploadResult result = fileService.uploadFile(file, user.getId()); - - if (result.isSuccess()) { - Map response = new HashMap<>(); - response.put("success", true); - response.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต"); - - Map fileData = new HashMap<>(); - fileData.put("_id", result.getFile().getId()); - fileData.put("filename", result.getFile().getFilename()); - fileData.put("originalname", result.getFile().getOriginalname()); - fileData.put("mimetype", result.getFile().getMimetype()); - fileData.put("size", result.getFile().getSize()); - fileData.put("uploadDate", result.getFile().getUploadDate()); - - response.put("file", fileData); - - return ResponseEntity.ok(response); - } else { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(500).body(errorResponse); + User user = getUser(principal); + + FileUploadResult result = + fileService.uploadFile( + file, + user.getId() + ); + + if (!result.isSuccess()) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + ); } + Map response = + new HashMap<>(); + + response.put("success", true); + response.put( + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต" + ); + + response.put( + "file", + toFileResponse(result.getFile()) + ); + + return ResponseEntity.ok(response); + } catch (Exception e) { - log.error("ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ", e); - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); - errorResponse.put("error", e.getMessage()); - return ResponseEntity.status(500).body(errorResponse); + log.error( + "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ", + e + ); + + Map errorResponse = + new HashMap<>(); + + errorResponse.put( + "success", + false + ); + + errorResponse.put( + "message", + "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ); + + errorResponse.put( + "error", + e.getMessage() + ); + + return ResponseEntity + .status( + HttpStatus.INTERNAL_SERVER_ERROR + ) + .body(errorResponse); } } + /** + * S3 ์ง์ ‘ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ presigned PUT URL ๋ฐœ๊ธ‰. + * + * ํŒŒ์ผ ๋ณธ๋ฌธ์€ Backend๋กœ ๋“ค์–ด์˜ค์ง€ ์•Š๋Š”๋‹ค. + */ @PostMapping("/upload/presign") public ResponseEntity createUploadUrl( - @RequestBody DirectUploadPresignRequest request, + @RequestBody + DirectUploadPresignRequest request, Principal principal ) { try { - User user = userRepository - .findByEmail(principal.getName()) - .orElseThrow(() -> - new UsernameNotFoundException( - "User not found: " - + principal.getName() - ) - ); + User user = getUser(principal); - var prepared = + ChatFileDirectUploadService.PreparedUpload + prepared = chatFileDirectUploadService.prepare( request.originalname(), request.mimetype(), @@ -122,38 +212,58 @@ public ResponseEntity createUploadUrl( user.getId() ); - Map response = new HashMap<>(); + Map response = + new HashMap<>(); + response.put("success", true); - response.put("uploadUrl", prepared.uploadUrl()); - response.put("key", prepared.key()); - response.put("filename", prepared.filename()); + response.put( + "uploadUrl", + prepared.uploadUrl() + ); + response.put( + "key", + prepared.key() + ); + response.put( + "filename", + prepared.filename() + ); return ResponseEntity.ok(response); } catch (RuntimeException e) { - return ResponseEntity.badRequest().body( - Map.of( - "success", false, - "message", e.getMessage() - ) + log.warn( + "ํŒŒ์ผ presign ๋ฐœ๊ธ‰ ์‹คํŒจ", + e ); + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); } } + /** + * ๋ธŒ๋ผ์šฐ์ €๊ฐ€ S3 ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ํ˜ธ์ถœ. + * + * Backend๋Š” ์‹ค์ œ S3 ๊ฐ์ฒด๋ฅผ ํ™•์ธํ•œ ํ›„ + * MongoDB metadata๋งŒ ์ €์žฅํ•œ๋‹ค. + */ @PostMapping("/upload/complete") public ResponseEntity completeUpload( - @RequestBody DirectUploadCompleteRequest request, + @RequestBody + DirectUploadCompleteRequest request, Principal principal ) { try { - User user = userRepository - .findByEmail(principal.getName()) - .orElseThrow(() -> - new UsernameNotFoundException( - "User not found: " - + principal.getName() - ) - ); + User user = getUser(principal); File savedFile = chatFileDirectUploadService.complete( @@ -164,208 +274,541 @@ public ResponseEntity completeUpload( user.getId() ); - Map fileData = new HashMap<>(); - fileData.put("_id", savedFile.getId()); - fileData.put("filename", savedFile.getFilename()); - fileData.put("originalname", savedFile.getOriginalname()); - fileData.put("mimetype", savedFile.getMimetype()); - fileData.put("size", savedFile.getSize()); - fileData.put("uploadDate", savedFile.getUploadDate()); + Map response = + new HashMap<>(); - Map response = new HashMap<>(); response.put("success", true); - response.put("file", fileData); + response.put( + "file", + toFileResponse(savedFile) + ); return ResponseEntity.ok(response); } catch (RuntimeException e) { - return ResponseEntity.badRequest().body( - Map.of( - "success", false, - "message", e.getMessage() - ) + log.warn( + "S3 ์ง์ ‘ ์—…๋กœ๋“œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ ์‹คํŒจ", + e ); + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); } } /** - * ๋ณด์•ˆ์ด ๊ฐ•ํ™”๋œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + * ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ. */ - @Operation(summary = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ", description = "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ํŒŒ์ผ๋งŒ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ", + description = + "์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค." + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์„ฑ๊ณต"), - @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "403", description = "๊ถŒํ•œ ์—†์Œ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "404", description = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", - content = @Content(schema = @Schema(implementation = StandardResponse.class))), - @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", - content = @Content(schema = @Schema(implementation = StandardResponse.class))) + @ApiResponse( + responseCode = "200", + description = "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์„ฑ๊ณต" + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "๊ถŒํ•œ ์—†์Œ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content( + schema = @Schema( + implementation = + StandardResponse.class + ) + ) + ) }) @GetMapping("/download/{filename:.+}") public ResponseEntity downloadFile( - @Parameter(description = "๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ๋ช…") @PathVariable String filename, + @Parameter( + description = "๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ๋ช…" + ) + @PathVariable + String filename, HttpServletRequest request, - Principal principal) { + Principal principal + ) { try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + User user = getUser(principal); - return switch (fileAccessService.forDownload(filename, user.getId())) { - case FileAccess.Stream stream -> attachmentResponse(stream); - case FileAccess.Redirect redirect -> redirectResponse(redirect); + return switch ( + fileAccessService.forDownload( + filename, + user.getId() + ) + ) { + case FileAccess.Stream stream -> + attachmentResponse(stream); + + case FileAccess.Redirect redirect -> + redirectResponse(redirect); }; } catch (Exception e) { - log.error("ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", filename, e); + log.error( + "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + filename, + e + ); + return handleFileError(e); } } - private ResponseEntity attachmentResponse(FileAccess.Stream stream) { - String contentDisposition = String.format( - "attachment; filename*=UTF-8''%s", - encodeFilename(stream.originalname()) - ); + /** + * ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ. + * + * CloudFront ํ™œ์„ฑํ™” ์‹œ FileAccessService์—์„œ + * Signed URL์„ ๋งŒ๋“  ๋’ค 302 Redirectํ•œ๋‹ค. + */ + @GetMapping("/view/{filename:.+}") + public ResponseEntity viewFile( + @PathVariable + String filename, + HttpServletRequest request, + Principal principal + ) { + try { + User user = getUser(principal); + + return switch ( + fileAccessService.forView( + filename, + user.getId() + ) + ) { + case FileAccess.Stream stream -> + inlineResponse(stream); + + case FileAccess.Redirect redirect -> + redirectResponse(redirect); + }; + + } catch ( + PreviewNotSupportedException e + ) { + return ResponseEntity + .status( + HttpStatus.UNSUPPORTED_MEDIA_TYPE + ) + .body( + Map.of( + "success", + false, + "message", + safeMessage(e) + ) + ); + + } catch (Exception e) { + log.error( + "ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + filename, + e + ); + + return handleFileError(e); + } + } + + /** + * Soft delete. + * + * ์‹ค์ œ ์‚ญ์ œ ์ •์ฑ…์€ FileService ๊ตฌํ˜„์—์„œ ์ˆ˜ํ–‰ํ•œ๋‹ค. + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteFile( + @PathVariable String id, + Principal principal + ) { + try { + User user = getUser(principal); - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(stream.contentType())) + boolean deleted = + fileService.deleteFile( + id, + user.getId() + ); + + if (deleted) { + return ResponseEntity.ok( + Map.of( + "success", + true, + "message", + "ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + ); + } + + return ResponseEntity + .badRequest() + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + ); + + } catch (RuntimeException e) { + log.error( + "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", + id, + e + ); + + String errorMessage = + e.getMessage(); + + if (errorMessage != null + && errorMessage.contains( + "์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + )) { + + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + } + + if (errorMessage != null + && errorMessage.contains( + "๊ถŒํ•œ" + )) { + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body( + Map.of( + "success", + false, + "message", + "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค." + ) + ); + } + + Map errorResponse = + new HashMap<>(); + + errorResponse.put( + "success", + false + ); + + errorResponse.put( + "message", + "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ); + + errorResponse.put( + "error", + errorMessage + ); + + return ResponseEntity + .status( + HttpStatus.INTERNAL_SERVER_ERROR + ) + .body(errorResponse); + } + } + + private ResponseEntity attachmentResponse( + FileAccess.Stream stream + ) { + String contentDisposition = + String.format( + "attachment; filename*=UTF-8''%s", + encodeFilename( + stream.originalname() + ) + ); + + return ResponseEntity + .ok() + .contentType( + MediaType.parseMediaType( + stream.contentType() + ) + ) + .contentLength(stream.size()) + .header( + HttpHeaders.CONTENT_DISPOSITION, + contentDisposition + ) + .header( + HttpHeaders.CACHE_CONTROL, + "private, no-cache, no-store, must-revalidate" + ) + .header( + HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, + "Content-Disposition" + ) + .body(stream.resource()); + } + + private ResponseEntity inlineResponse( + FileAccess.Stream stream + ) { + String contentDisposition = + String.format( + "inline; filename=\"%s\"; filename*=UTF-8''%s", + stream.originalname(), + encodeFilename( + stream.originalname() + ) + ); + + return ResponseEntity + .ok() + .contentType( + MediaType.parseMediaType( + stream.contentType() + ) + ) .contentLength(stream.size()) - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .header(HttpHeaders.CACHE_CONTROL, "private, no-cache, no-store, must-revalidate") - .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Disposition") + .header( + HttpHeaders.CONTENT_DISPOSITION, + contentDisposition + ) .body(stream.resource()); } - private ResponseEntity redirectResponse(FileAccess.Redirect redirect) { - return ResponseEntity.status(HttpStatus.FOUND) + private ResponseEntity redirectResponse( + FileAccess.Redirect redirect + ) { + return ResponseEntity + .status(HttpStatus.FOUND) .location(redirect.location()) .build(); } - private String encodeFilename(String originalFilename) { - return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8) - .replaceAll("\\+", "%20"); - } + /** + * ์ตœ์‹  main์˜ ๊ธฐ์กด ์—๋Ÿฌ ๋งคํ•‘์„ ์œ ์ง€ํ•˜๋ฉด์„œ + * DeletedFileException๋งŒ 410์œผ๋กœ ์ถ”๊ฐ€ํ•œ๋‹ค. + */ + private ResponseEntity handleFileError( + Exception e + ) { + String errorMessage = + e.getMessage(); + + HttpStatus status = + HttpStatus.INTERNAL_SERVER_ERROR; + + String responseMessage = + "ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; + + if (e instanceof DeletedFileException) { + status = HttpStatus.GONE; + responseMessage = + "์‚ญ์ œ๋œ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค."; + + } else if (errorMessage != null) { + + if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…") + || errorMessage.contains( + "Invalid filename" + )) { + + status = HttpStatus.BAD_REQUEST; + responseMessage = + "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค."; + + } else if (errorMessage.contains("์ธ์ฆ") + || errorMessage.contains( + "Authentication" + )) { + + status = HttpStatus.UNAUTHORIZED; + responseMessage = + "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ" + ) + || errorMessage.contains( + "Invalid file path" + ) + ) { - private ResponseEntity handleFileError(Exception e) { - String errorMessage = e.getMessage(); - int statusCode = 500; - String responseMessage = "ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; - - if (errorMessage != null) { - if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…") || errorMessage.contains("Invalid filename")) { - statusCode = 400; - responseMessage = "์ž˜๋ชป๋œ ํŒŒ์ผ๋ช…์ž…๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ธ์ฆ") || errorMessage.contains("Authentication")) { - statusCode = 401; - responseMessage = "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ") || errorMessage.contains("Invalid file path")) { - statusCode = 400; - responseMessage = "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") || errorMessage.contains("not found")) { - statusCode = 404; - responseMessage = "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")) { - statusCode = 404; - responseMessage = "ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; - } else if (errorMessage.contains("๊ถŒํ•œ") || errorMessage.contains("Unauthorized")) { - statusCode = 403; - responseMessage = "ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."; + status = HttpStatus.BAD_REQUEST; + responseMessage = + "์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + ) + || errorMessage.contains( + "not found" + ) + ) { + + status = HttpStatus.NOT_FOUND; + responseMessage = + "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains( + "๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + ) + ) { + + status = HttpStatus.NOT_FOUND; + responseMessage = + "ํŒŒ์ผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; + + } else if ( + errorMessage.contains("๊ถŒํ•œ") + || errorMessage.contains( + "Unauthorized" + ) + ) { + + status = HttpStatus.FORBIDDEN; + responseMessage = + "ํŒŒ์ผ์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."; } } - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", responseMessage); + return ResponseEntity + .status(status) + .body( + Map.of( + "success", + false, + "message", + responseMessage + ) + ); + } + + private User getUser( + Principal principal + ) { + if (principal == null) { + throw new RuntimeException( + "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + ); + } - return ResponseEntity.status(statusCode).body(errorResponse); + return userRepository + .findByEmail(principal.getName()) + .orElseThrow(() -> + new UsernameNotFoundException( + "User not found: " + + principal.getName() + ) + ); } - @GetMapping("/view/{filename:.+}") - public ResponseEntity viewFile( - @PathVariable String filename, - HttpServletRequest request, - Principal principal) { - try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + private Map toFileResponse( + File file + ) { + Map fileData = + new HashMap<>(); - return switch (fileAccessService.forView(filename, user.getId())) { - case FileAccess.Stream stream -> inlineResponse(stream); - case FileAccess.Redirect redirect -> redirectResponse(redirect); - }; + fileData.put( + "_id", + file.getId() + ); + + fileData.put( + "filename", + file.getFilename() + ); - } catch (PreviewNotSupportedException e) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", e.getMessage()); - return ResponseEntity.status(415).body(errorResponse); + fileData.put( + "originalname", + file.getOriginalname() + ); - } catch (Exception e) { - log.error("ํŒŒ์ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", filename, e); - return handleFileError(e); - } - } + fileData.put( + "mimetype", + file.getMimetype() + ); - private ResponseEntity inlineResponse(FileAccess.Stream stream) { - String contentDisposition = String.format( - "inline; filename=\"%s\"; filename*=UTF-8''%s", - stream.originalname(), - encodeFilename(stream.originalname()) + fileData.put( + "size", + file.getSize() ); - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(stream.contentType())) - .contentLength(stream.size()) - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .body(stream.resource()); - } + fileData.put( + "uploadDate", + file.getUploadDate() + ); - @DeleteMapping("/{id}") - public ResponseEntity deleteFile(@PathVariable String id, Principal principal) { - try { - User user = userRepository.findByEmail(principal.getName()) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + principal.getName())); + fileData.put( + "deleted", + file.isDeleted() + ); - boolean deleted = fileService.deleteFile(id, user.getId()); + return fileData; + } - if (deleted) { - Map response = new HashMap<>(); - response.put("success", true); - response.put("message", "ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.ok(response); - } else { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(400).body(errorResponse); - } + private String encodeFilename( + String originalFilename + ) { + return URLEncoder + .encode( + originalFilename, + StandardCharsets.UTF_8 + ) + .replaceAll( + "\\+", + "%20" + ); + } - } catch (RuntimeException e) { - log.error("ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ: {}", id, e); - String errorMessage = e.getMessage(); - - if (errorMessage != null && errorMessage.contains("์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(404).body(errorResponse); - } else if (errorMessage != null && errorMessage.contains("๊ถŒํ•œ")) { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."); - return ResponseEntity.status(403).body(errorResponse); - } - - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); - errorResponse.put("error", errorMessage); - return ResponseEntity.status(500).body(errorResponse); - } + private String safeMessage( + Exception e + ) { + return e.getMessage() != null + ? e.getMessage() + : "์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; } + public record DirectUploadPresignRequest( String originalname, String mimetype, @@ -380,4 +823,4 @@ public record DirectUploadCompleteRequest( long size ) { } -} +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java index e09b76ed..7808ca1b 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/FileResponse.java @@ -22,6 +22,8 @@ public class FileResponse { private long size; private String user; private LocalDateTime uploadDate; + private boolean deleted; + // File ์—”ํ‹ฐํ‹ฐ์—์„œ FileResponse๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์ •์  ๋ฉ”์„œ๋“œ public static FileResponse from(File file) { @@ -32,6 +34,7 @@ public static FileResponse from(File file) { .mimetype(file.getMimetype()) .size(file.getSize()) .user(file.getUser()) + .deleted(file.isDeleted()) .uploadDate(file.getUploadDate()) .build(); } diff --git a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java index d9243b16..75c3ea57 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/dto/RoomsResponse.java @@ -12,6 +12,7 @@ @NoArgsConstructor @AllArgsConstructor public class RoomsResponse { + @Builder.Default private boolean success = true; private List data; private PageMetadata metadata; diff --git a/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java b/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java new file mode 100644 index 00000000..5aff45c8 --- /dev/null +++ b/apps/backend/src/main/java/com/ktb/chatapp/exception/DeletedFileException.java @@ -0,0 +1,9 @@ +package com.ktb.chatapp.exception; + +public class DeletedFileException + extends RuntimeException { + + public DeletedFileException() { + super("์‚ญ์ œ๋œ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค."); + } +} \ No newline at end of file diff --git a/apps/backend/src/main/java/com/ktb/chatapp/model/User.java b/apps/backend/src/main/java/com/ktb/chatapp/model/User.java index fb16bc6a..3eb3b097 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/model/User.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/model/User.java @@ -47,6 +47,7 @@ public class User { private LocalDateTime lastLogin; + @Builder.Default private boolean isOnline = false; /** @@ -82,4 +83,4 @@ public void onBeforeConvert(BeforeConvertEvent event) { } } } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java index 8ef668ba..0d3c02ff 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/CloudFrontSignedUrlService.java @@ -71,22 +71,29 @@ public URI sign(String objectKey) { String resourceUrl = baseUrl + "/" + normalizeObjectKey(objectKey); - CannedSignerRequest request = - CannedSignerRequest.builder() - .resourceUrl(resourceUrl) - .privateKey(privateKeyPath) - .keyPairId(keyPairId) - .expirationDate( - Instant.now().plus(urlTtl) - ) - .build(); - - String signedUrl = - cloudFrontUtilities - .getSignedUrlWithCannedPolicy(request) - .url(); - - return URI.create(signedUrl); + try { + CannedSignerRequest request = + CannedSignerRequest.builder() + .resourceUrl(resourceUrl) + .privateKey(privateKeyPath) + .keyPairId(keyPairId) + .expirationDate( + Instant.now().plus(urlTtl) + ) + .build(); + + String signedUrl = + cloudFrontUtilities + .getSignedUrlWithCannedPolicy(request) + .url(); + + return URI.create(signedUrl); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create CloudFront signed URL.", + e + ); + } } private static String normalizeBaseUrl(String baseUrl) { @@ -112,4 +119,4 @@ private static String normalizeObjectKey(String objectKey) { return normalized; } -} \ No newline at end of file +} diff --git a/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java b/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java index 3cb9b0c4..dac0d103 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/service/FileAccessService.java @@ -7,6 +7,7 @@ import com.ktb.chatapp.repository.MessageRepository; import com.ktb.chatapp.repository.RoomRepository; import com.ktb.chatapp.storage.StoragePort; +import com.ktb.chatapp.exception.DeletedFileException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -35,17 +36,47 @@ public class FileAccessService { private final FileRepository fileRepository; private final MessageRepository messageRepository; private final RoomRepository roomRepository; + private final Optional + cloudFrontSignedUrlService; public FileAccess forDownload(String fileName, String requesterId) { return issue(authorize(fileName, requesterId), Delivery.ATTACHMENT, fileName, requesterId); } - public FileAccess forView(String fileName, String requesterId) { - File fileEntity = authorize(fileName, requesterId); + public FileAccess forView( + String fileName, + String requesterId + ) { + File fileEntity = + authorize(fileName, requesterId); + if (!fileEntity.isPreviewable()) { - throw new PreviewNotSupportedException("๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); + throw new PreviewNotSupportedException( + "๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค." + ); + } + + if (cloudFrontSignedUrlService.isPresent()) { + URI signedUrl = + cloudFrontSignedUrlService + .get() + .sign(fileEntity.getPath()); + + log.info( + "CloudFront signed URL ๋ฐœ๊ธ‰: {} (์‚ฌ์šฉ์ž: {})", + fileName, + requesterId + ); + + return new FileAccess.Redirect(signedUrl); } - return issue(fileEntity, Delivery.INLINE, fileName, requesterId); + + return issue( + fileEntity, + Delivery.INLINE, + fileName, + requesterId + ); } /** @@ -68,6 +99,10 @@ private File authorize(String fileName, String requesterId) { File fileEntity = fileRepository.findByFilename(fileName) .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + fileName)); + if (fileEntity.isDeleted()) { + throw new DeletedFileException(); + } + // 2. ๋ฉ”์‹œ์ง€ ์กฐํšŒ (ํŒŒ์ผ๊ณผ ๋ฉ”์‹œ์ง€ ์—ฐ๊ฒฐ ํ™•์ธ) - ํšจ์œจ์ ์ธ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ Message message = messageRepository.findByFileId(fileEntity.getId()) .orElseThrow(() -> new RuntimeException("ํŒŒ์ผ๊ณผ ์—ฐ๊ฒฐ๋œ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java index 11dfc01b..ce81e2de 100644 --- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java +++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/MessageResponseMapper.java @@ -63,6 +63,7 @@ public MessageResponse mapToMessageResponse(Message message, User sender) { .originalname(file.getOriginalname()) .mimetype(file.getMimetype()) .size(file.getSize()) + .deleted(file.isDeleted()) .build()) .ifPresent(builder::file); diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java index e3ecd5ed..96a890b7 100644 --- a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java +++ b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java @@ -188,7 +188,7 @@ void forView_nonParticipant_failsAuthorizationBeforePreviewCheck() { void forDownload_missingFileEntity_throwsNotFound() { when(fileRepository.findByFilename(FILE_NAME)).thenReturn(Optional.empty()); FileAccessService service = new FileAccessService( - new DirectStorage(), fileRepository, messageRepository, roomRepository); + new DirectStorage(), fileRepository, messageRepository, roomRepository, Optional.empty()); assertThatThrownBy(() -> service.forDownload(FILE_NAME, PARTICIPANT)) .hasMessage("ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + FILE_NAME); @@ -211,7 +211,7 @@ private FileAccessService serviceWith(StoragePort storagePort, String mimetype) Message.builder().id("message-id").roomId(ROOM_ID).fileId(FILE_ID).build())); when(roomRepository.findById(ROOM_ID)).thenReturn(Optional.of( Room.builder().id(ROOM_ID).participantIds(Set.of(PARTICIPANT)).build())); - return new FileAccessService(storagePort, fileRepository, messageRepository, roomRepository); + return new FileAccessService(storagePort, fileRepository, messageRepository, roomRepository, Optional.empty()); } private File fileEntity(String mimetype) { diff --git a/apps/frontend/app/chat/[room]/page.js b/apps/frontend/app/chat/[room]/page.js index 193c8d2f..745a1b82 100644 --- a/apps/frontend/app/chat/[room]/page.js +++ b/apps/frontend/app/chat/[room]/page.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; +import AuthRequiredFallback from '@/components/AuthRequiredFallback'; import ChatHeader from '@/components/ChatHeader'; import { useAuth } from '@/contexts/AuthContext'; import ChatRoomView from '@/features/chat/room/ChatRoomView'; @@ -30,14 +31,22 @@ export default function ChatRoomPage() { useEffect(() => { if (!isLoading && !isAuthenticated) { - router.replace(`/?redirect=${pathname}`); + const redirectTimer = window.setTimeout(() => { + router.replace(`/?redirect=${pathname}`); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, pathname, router]); - if (isLoading || !isAuthenticated) { + if (isLoading) { return ; } + if (!isAuthenticated) { + return ; + } + return ( <> diff --git a/apps/frontend/app/chat/page.js b/apps/frontend/app/chat/page.js index 2c053a88..d7b61f9a 100644 --- a/apps/frontend/app/chat/page.js +++ b/apps/frontend/app/chat/page.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; +import AuthRequiredFallback from '@/components/AuthRequiredFallback'; import ChatHeader from '@/components/ChatHeader'; import { useAuth } from '@/contexts/AuthContext'; import ChatRoomsView from '@/features/chat/rooms/ChatRoomsView'; @@ -28,14 +29,22 @@ export default function ChatPage() { useEffect(() => { if (!isLoading && !isAuthenticated) { - router.replace(`/?redirect=${pathname}`); + const redirectTimer = window.setTimeout(() => { + router.replace(`/?redirect=${pathname}`); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, pathname, router]); - if (isLoading || !isAuthenticated) { + if (isLoading) { return ; } + if (!isAuthenticated) { + return ; + } + return ( <> diff --git a/apps/frontend/components/AuthRequiredFallback.js b/apps/frontend/components/AuthRequiredFallback.js new file mode 100644 index 00000000..97e52740 --- /dev/null +++ b/apps/frontend/components/AuthRequiredFallback.js @@ -0,0 +1,40 @@ +import { VStack } from '@vapor-ui/core'; + +const AuthRequiredFallback = () => ( +
+ + + + + +
+); + +export default AuthRequiredFallback; diff --git a/apps/frontend/components/FileMessage.js b/apps/frontend/components/FileMessage.js index 3af1edc9..2a90e32c 100644 --- a/apps/frontend/components/FileMessage.js +++ b/apps/frontend/components/FileMessage.js @@ -6,7 +6,7 @@ import { PlayIcon as Music, ErrorCircleIcon as AlertCircle } from '@vapor-ui/icons'; -import { Button, VStack, HStack } from '@vapor-ui/core'; +import { VStack, HStack } from '@vapor-ui/core'; import CustomAvatar from './CustomAvatar'; import MessageContent from './MessageContent'; import MessageActions from './MessageActions'; @@ -27,16 +27,32 @@ const FileMessage = ({ const [error, setError] = useState(null); const [previewUrl, setPreviewUrl] = useState(''); const messageDomRef = useRef(null); + useEffect(() => { - if (msg?.file) { - const url = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); - setPreviewUrl(url); - console.debug('Preview URL generated:', { - filename: msg.file.filename, - url - }); + if (msg?.file?.deleted) { + setPreviewUrl(''); + setError(null); + return; + } + + if (!msg?.file) { + setPreviewUrl(''); + return; } - }, [msg?.file, user?.token, user?.sessionId]); + + const url = fileService.getPreviewUrl( + msg.file, + user?.token, + user?.sessionId, + true + ); + + setPreviewUrl(url); + }, [ + msg?.file, + user?.token, + user?.sessionId + ]); if (!msg?.file) { console.error('File data is missing:', msg); @@ -51,11 +67,11 @@ const FileMessage = ({ minute: '2-digit', second: '2-digit', hour12: false - }).replace(/\./g, '๋…„').replace(/\s/g, ' ').replace('์ผ ', '์ผ '); + }); const getFileIcon = () => { const mimetype = msg.file?.mimetype || ''; - const iconProps = { className: "w-5 h-5 flex-shrink-0" }; + const iconProps = { className: 'w-5 h-5 flex-shrink-0' }; if (mimetype.startsWith('image/')) return ; if (mimetype.startsWith('video/')) return ; @@ -66,21 +82,22 @@ const FileMessage = ({ const getDecodedFilename = (encodedFilename) => { try { if (!encodedFilename) return 'Unknown File'; - + const base64 = encodedFilename .replace(/-/g, '+') .replace(/_/g, '/'); - + const pad = base64.length % 4; const paddedBase64 = pad ? base64 + '='.repeat(4 - pad) : base64; - + if (paddedBase64.match(/^[A-Za-z0-9+/=]+$/)) { - return Buffer.from(paddedBase64, 'base64').toString('utf8'); + const bytes = Uint8Array.from(atob(paddedBase64), (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); } return decodeURIComponent(encodedFilename); - } catch (error) { - console.error('Filename decoding error:', error); + } catch (decodeError) { + console.error('Filename decoding error:', decodeError); return encodedFilename; } }; @@ -95,35 +112,40 @@ const FileMessage = ({ /> ); + const requireFileAuth = () => { + if (!msg.file?.filename) { + throw new Error('File information is missing.'); + } + + if (!user?.token || !user?.sessionId) { + throw new Error('Authentication information is missing.'); + } + }; + const handleFileDownload = async (e) => { e.preventDefault(); e.stopPropagation(); setError(null); try { - if (!msg.file?.filename) { - throw new Error('ํŒŒ์ผ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } - - if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } + requireFileAuth(); const baseUrl = fileService.getFileUrl(msg.file.filename, false); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}&download=true`; - + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user.token)}&sessionId=${encodeURIComponent(user.sessionId)}&download=true`; + const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = authenticatedUrl; document.body.appendChild(iframe); setTimeout(() => { - document.body.removeChild(iframe); + if (iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } }, 5000); - - } catch (error) { - console.error('File download error:', error); - setError(error.message || 'ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (downloadError) { + console.error('File download error:', downloadError); + setError(downloadError.message || 'An error occurred while downloading the file.'); } }; @@ -133,25 +155,19 @@ const FileMessage = ({ setError(null); try { - if (!msg.file?.filename) { - throw new Error('ํŒŒ์ผ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } - - if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); - } + requireFileAuth(); const baseUrl = fileService.getFileUrl(msg.file.filename, true); - const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user?.token)}&sessionId=${encodeURIComponent(user?.sessionId)}`; + const authenticatedUrl = `${baseUrl}?token=${encodeURIComponent(user.token)}&sessionId=${encodeURIComponent(user.sessionId)}`; const newWindow = window.open(authenticatedUrl, '_blank'); if (!newWindow) { - throw new Error('ํŒ์—…์ด ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŒ์—… ์ฐจ๋‹จ์„ ํ•ด์ œํ•ด์ฃผ์„ธ์š”.'); + throw new Error('Popup was blocked. Please allow popups and try again.'); } newWindow.opener = null; - } catch (error) { - console.error('File view error:', error); - setError(error.message || 'ํŒŒ์ผ ๋ณด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (viewError) { + console.error('File view error:', viewError); + setError(viewError.message || 'An error occurred while opening the file.'); } }; @@ -166,37 +182,29 @@ const FileMessage = ({ } if (!user?.token || !user?.sessionId) { - throw new Error('์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + throw new Error('Authentication information is missing.'); } - const previewUrl = fileService.getPreviewUrl(msg.file, user?.token, user?.sessionId, true); - return (
{originalname} { - console.debug('Image loaded successfully:', originalname); - }} onError={(e) => { - console.error('Image load error:', { - error: e.error, - originalname - }); + console.error('Image load error:', { originalname }); e.target.onerror = null; e.target.src = '/images/placeholder-image.png'; - setError('์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + setError('Failed to load image preview.'); }} loading="lazy" data-testid="file-image-preview" />
); - } catch (error) { - console.error('Image preview error:', error); - setError(error.message || '์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } catch (previewError) { + console.error('Image preview error:', previewError); + setError(previewError.message || 'Failed to load image preview.'); return (
@@ -205,24 +213,36 @@ const FileMessage = ({ } }; + const renderFileMetadata = (originalname, size) => ( +
+ {getFileIcon()} +
+
{originalname}
+
{size}
+
+
+ ); + const renderFilePreview = () => { const mimetype = msg.file?.mimetype || ''; const originalname = getDecodedFilename(msg.file?.originalname || 'Unknown File'); const size = fileService.formatFileSize(msg.file?.size || 0); + const previewWrapperClass = 'overflow-hidden'; - const previewWrapperClass = "overflow-hidden"; + if (msg.file?.deleted) { + return ( +
+ + Deleted file. +
+ ); + } if (mimetype.startsWith('image/')) { return (
{renderImagePreview(originalname)} -
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
); @@ -237,12 +257,12 @@ const FileMessage = ({ className="max-w-[400px] max-h-[400px] object-cover rounded-md" controls preload="metadata" - aria-label={`${originalname} ๋น„๋””์˜ค`} + aria-label={`${originalname} video`} crossOrigin="use-credentials" > - ๋น„๋””์˜ค๋ฅผ ์žฌ์ƒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + Video playback is not supported. ) : (
@@ -250,13 +270,7 @@ const FileMessage = ({
)}
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
); @@ -265,24 +279,18 @@ const FileMessage = ({ if (mimetype.startsWith('audio/')) { return (
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
{previewUrl && ( )}
@@ -293,13 +301,7 @@ const FileMessage = ({ return (
-
- {getFileIcon()} -
-
{originalname}
-
{size}
-
-
+ {renderFileMetadata(originalname, size)}
); @@ -312,15 +314,13 @@ const FileMessage = ({ align={isMine ? 'flex-end' : 'flex-start'} $css={{ gap: '$100' }} > - {/* Sender Info */} {renderAvatar()} - {isMine ? '๋‚˜' : msg.sender?.name} + {isMine ? 'Me' : msg.sender?.name || 'Unknown User'} - {/* Message Bubble - Outline Based */}
- {/* Message Content */} -
+
{error && (
{error}
)} @@ -345,7 +342,6 @@ const FileMessage = ({ )}
- {/* Message Footer */}
- {/* Message Actions */} { useEffect(() => { // ๋กœ๋”ฉ์ด ๋๋‚˜๊ณ  ์ธ์ฆ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ if (!isLoading && !isAuthenticated) { - router.replace('/?redirect=' + router.asPath); + const redirectTimer = window.setTimeout(() => { + router.replace('/?redirect=' + router.asPath); + }, 100); + + return () => window.clearTimeout(redirectTimer); } }, [isAuthenticated, isLoading, router]); // ๋กœ๋”ฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ธ์ฆ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋กœ๋”ฉ ํ™”๋ฉด ํ‘œ์‹œ - if (isLoading || !isAuthenticated) { + if (isLoading) { return (
{ ); } + if (!isAuthenticated) { + return ; + } + return ; }; diff --git a/apps/frontend/pages/_error.js b/apps/frontend/pages/_error.js index 812b951f..dfa02ebd 100644 --- a/apps/frontend/pages/_error.js +++ b/apps/frontend/pages/_error.js @@ -9,7 +9,7 @@ const ErrorPage = () => { -