diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java deleted file mode 100644 index c3c5d47e..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.ezcode.codetest.infrastructure.notification.config; - -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; - -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.EnableCaching; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.cache.RedisCacheConfiguration; -import org.springframework.data.redis.cache.RedisCacheManager; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - -@Configuration -@EnableCaching -public class NotificationRedisCacheConfig { - - @Bean("notificationRedisCacheManager") - public CacheManager notificationRedisCacheManager(RedisConnectionFactory redisConnectionFactory) { - - PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() - .allowIfSubType("org.ezcode.codetest.application.notification.event.payload") - .allowIfSubType("org.ezcode.codetest.infrastructure.notification") - .allowIfSubType("java.util") - .allowIfSubType("org.springframework.data.domain") - .build(); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); - - GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); - - RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) - .entryTtl(Duration.ofMinutes(30)); - - // 특정 캐시를 위한 별도 설정 정의 - RedisCacheConfiguration notificationCacheConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) - .entryTtl(Duration.ofMinutes(5)); - - Map cacheConfigurations = new HashMap<>(); - cacheConfigurations.put("notificationList", notificationCacheConfig); - - return RedisCacheManager.builder(redisConnectionFactory) - .cacheDefaults(defaultConfig) - .withInitialCacheConfigurations(cacheConfigurations) - .build(); - } - - @Bean("notificationRedisTemplate") - public RedisTemplate notificationRedisTemplate(RedisConnectionFactory redisConnectionFactory) { - RedisTemplate redisTemplate = new RedisTemplate<>(); - - redisTemplate.setConnectionFactory(redisConnectionFactory); - - redisTemplate.setKeySerializer(new StringRedisSerializer()); - - redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - - redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); - - return redisTemplate; - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java index fb553458..6f8441f4 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java @@ -6,6 +6,9 @@ import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; import org.ezcode.codetest.application.notification.event.payload.NotificationPayload; 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; import com.fasterxml.jackson.annotation.JsonProperty; @@ -18,6 +21,9 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@CompoundIndexes({ + @CompoundIndex(name = "user_created_idx", def = "{'principalName' : 1, 'createdAt' : -1}") +}) public class NotificationDocument { @Id @@ -32,6 +38,7 @@ public class NotificationDocument { @JsonProperty("read") private boolean isRead; + @Indexed(expireAfter = "180d") // 약 6개월 동안만 보관 private LocalDateTime createdAt; public static NotificationDocument from(NotificationCreateEvent event) { diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java index 4f55e88e..4ac21bdb 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -1,8 +1,6 @@ package org.ezcode.codetest.infrastructure.notification.service; -import java.util.HashSet; import java.util.List; -import java.util.Set; import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; @@ -13,15 +11,9 @@ import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse; import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Service; @Service @@ -29,26 +21,12 @@ public class NotificationService { private final NotificationMongoRepository mongoRepository; - private final RedisTemplate redisTemplate; - public NotificationService( - NotificationMongoRepository mongoRepository, - @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate + NotificationMongoRepository mongoRepository ) { this.mongoRepository = mongoRepository; - this.redisTemplate = redisTemplate; } - public NotificationDocument createNewNotification(NotificationCreateEvent event) { - - NotificationDocument document = mongoRepository.save(NotificationDocument.from(event)); - - evictNotificationListCache(event.principalName()); - - return document; - } - - @Cacheable(value = "notificationList", key = "#event.principalName + ':' + #event.page + ':' + #event.size", cacheManager = "notificationRedisCacheManager") public NotificationPageResponse getNotifications(NotificationListRequestEvent event) { PageRequest pageRequest = PageRequest.of(event.page(), event.size(), Sort.by("createdAt").descending()); @@ -67,6 +45,11 @@ public NotificationPageResponse getNotifications(Notificat ); } + public NotificationDocument createNewNotification(NotificationCreateEvent event) { + + return mongoRepository.save(NotificationDocument.from(event)); + } + public void markAsRead(NotificationMarkReadEvent event) { NotificationDocument notificationDocument = mongoRepository @@ -75,26 +58,5 @@ public void markAsRead(NotificationMarkReadEvent event) { notificationDocument.markAsRead(); mongoRepository.save(notificationDocument); - - evictNotificationListCache(event.principalName()); - } - - private void evictNotificationListCache(String principalName) { - - String pattern = "notificationList::" + principalName.replaceAll("[*?\\[\\]]", "\\\\$0") + ":*"; - - Set keys = redisTemplate.execute((RedisCallback>)connection -> { - Set matchingKeys = new HashSet<>(); - ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build(); - Cursor cursor = connection.scan(options); - while (cursor.hasNext()) { - matchingKeys.add(new String(cursor.next())); - } - return matchingKeys; - }); - - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); - } } } diff --git a/src/main/resources/templates/chat-page.html b/src/main/resources/templates/chat-page.html index e369ca8a..da0ba29b 100644 --- a/src/main/resources/templates/chat-page.html +++ b/src/main/resources/templates/chat-page.html @@ -185,6 +185,24 @@

채팅방 목록

initChat(); }); + let wsLatencyStart = 0; + + /** + * @param {number} page 조회할 페이지 (default: 1) + * 호출 시각을 기록하고 GET 요청을 보냄 → WS 콜백에서 latency 계산 + */ + function requestNotifications(page = 1) { + wsLatencyStart = performance.now(); + fetch(`/api/notifications?page=${page}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }).catch(err => console.error('Fetch error:', err)); + } + + // 전역으로 노출해두면 콘솔에서 직접 호출 가능 + window.requestNotifications = requestNotifications; + function initChat() { const socket = new SockJS('/ws?chat-token=' + encodeURIComponent(token)); stompClient = Stomp.over(socket); @@ -203,11 +221,13 @@

채팅방 목록

const receiptId = 'sub-1'; stompClient.subscribe('/user/queue/notification', msg => { - console.log(msg); + console.log('Payload:', msg); }); - stompClient.subscribe('/user/queue/notifications', msg => { - console.log(msg); + const latency = performance.now() - wsLatencyStart; + console.log(`⏱ Notification round-trip: ${latency.toFixed(2)} ms`); + wsLatencyStart = 0; + console.log('Bulk notifications:', msg); }); // 채팅방 목록 초기 구독 및 오름차순 정렬 diff --git a/src/test/resources/send_notifications.py b/src/test/resources/send_notifications.py new file mode 100644 index 00000000..ae3bac7f --- /dev/null +++ b/src/test/resources/send_notifications.py @@ -0,0 +1,50 @@ +# seed_notifications.py +import random +from datetime import datetime +from pymongo import MongoClient + +MONGO_URI = "mongodb://root:1234@localhost:27017/codetest?authSource=admin" +DB_NAME = "codetest" # 실제 DB 이름으로 변경 +COLLECTION_NAME = "notifications" # 실제 컬렉션 이름으로 변경 + +# payload 템플릿 +base_payload = { + "problemId": 1, + "discussionId": 1, + "replyId": 7, + "authorId": 18, + "authorNickname": "정직한펭귄908", + "content": "대충 토론에 대한 댓글 내용", + "_class": "org.ezcode.codetest.application.notification.event.payload.ReplyCreatePayload" +} + +def random_email(): + # 1부터 10까지 랜덤, 1일 때는 ttest@test.com + idx = random.randint(1, 10) + return "ttest@test.com" if idx == 1 else f"ttest{idx}@test.com" + +def gen_docs(count=100): + docs = [] + for _ in range(count): + docs.append({ + "createdAt": datetime.utcnow(), + "isRead": False, + "notificationType": "COMMUNITY_DISCUSSION_REPLY", + "payload": base_payload, + "principalName": random_email(), + "_class": "org.ezcode.codetest.infrastructure.notification.model.NotificationDocument" + }) + return docs + +def main(): + client = MongoClient(MONGO_URI) + db = client[DB_NAME] + coll = db[COLLECTION_NAME] + + # docs = gen_docs(1000000) + docs = gen_docs(100) + result = coll.insert_many(docs) + print(f"Inserted {len(result.inserted_ids)} documents into `{DB_NAME}.{COLLECTION_NAME}`") + +if __name__ == "__main__": + main()