배경

@Service
public class NotificationService {

	private final NotificationMongoRepository mongoRepository;

	private final RedisTemplate<String, Object> redisTemplate;

	public NotificationService(
		NotificationMongoRepository mongoRepository,
		@Qualifier("notificationRedisTemplate") RedisTemplate<String, Object> redisTemplate
	) {
		this.mongoRepository = mongoRepository;
		this.redisTemplate = redisTemplate;
	}

	@Cacheable(value = "notificationList", key = "#event.principalName + ':' + #event.page + ':' + #event.size", cacheManager = "notificationRedisCacheManager")
	public NotificationPageResponse<NotificationResponse> getNotifications(NotificationListRequestEvent event) {

		PageRequest pageRequest = PageRequest.of(event.page(), event.size(), Sort.by("createdAt").descending());

		Page<NotificationDocument> page = mongoRepository.findAllByPrincipalName(event.principalName(), pageRequest);

		List<NotificationResponse> notificationList = page.stream()
			.map(NotificationResponse::from)
			.toList();

		return new NotificationPageResponse<>(
			notificationList,
			page.getNumber(),
			page.getSize(),
			page.getTotalElements()
		);
	}

	public NotificationDocument createNewNotification(NotificationCreateEvent event) {

		NotificationDocument document = mongoRepository.save(NotificationDocument.from(event));

		evictNotificationListCache(event.principalName());

		return document;
	}

	public void markAsRead(NotificationMarkReadEvent event) {

		NotificationDocument notificationDocument = mongoRepository
			.findByIdAndPrincipalName(event.notificationId(), event.principalName())
			.orElseThrow(() -> new NotificationException(NotificationExceptionCode.NOTIFICATION_NOT_FOUND));

		notificationDocument.markAsRead();
		mongoRepository.save(notificationDocument);

		evictNotificationListCache(event.principalName());
	}

	private void evictNotificationListCache(String principalName) {

		String pattern = "notificationList::" + principalName.replaceAll("[*?\\\\[\\\\]]", "\\\\\\\\$0") + ":*";

		Set<String> keys = redisTemplate.execute((RedisCallback<Set<String>>)connection -> {
			Set<String> matchingKeys = new HashSet<>();
			ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
			Cursor<byte[]> cursor = connection.scan(options);
			while (cursor.hasNext()) {
				matchingKeys.add(new String(cursor.next()));
			}
			return matchingKeys;
		});

		if (keys != null && !keys.isEmpty()) {
			redisTemplate.delete(keys);
		}
	}
}

성능 비교

캐시를 적용했을 때 얼마나 많은 성능적 이점을 얻을 수 있는지 확인하기 위해 캐시가 적용됐을 때와 되지 않았을 때의 성능을 측정해 비교해보려고 한다.

테스트 환경 세팅

응답 시간 측정

현재 캐시를 사용하는 “알림 목록 조회” API의 경우, 요청은 REST API를 사용하지만 응답은 웹소켓을 통해 전달받는다.

따라서 응답 시간 측정은 프론트 페이지에서 수행하도록 했다.

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);

    // ─── Heartbeat 5분(300,000ms) 설정 ───
    stompClient.heartbeat.outgoing = 300000; // 클라이언트→서버 heartbeat 전송 간격
    stompClient.heartbeat.incoming = 300000; // 서버→클라이언트 heartbeat 수신 허용 최대 간격
    const hbHeader = {'heart-beat': '300000,300000'};
    // ────────────────────────────────────

    stompClient.connect(
        hbHeader,
        () => {
            const messagesEl = document.getElementById('messages');
            const roomListEl = document.getElementById('roomList');
            const receiptId = 'sub-1';

            stompClient.subscribe('/user/queue/notification', msg => {
                console.log('Payload:', msg);
            });
            stompClient.subscribe('/user/queue/notifications', msg => {
                const latency = performance.now() - wsLatencyStart;
                console.log(`⏱ Notification round-trip: ${latency.toFixed(2)} ms`);
                wsLatencyStart = 0;
                console.log('Bulk notifications:', msg);
            });
        },
        error => {
            console.error('STOMP 연결 에러', error);
        }
    );
}

테스트용 데이터 삽입

몽고 DB에서 조회 시 principalName을 기준으로 알림 목록을 조회하므로, principalName 값만 다르게 더미 데이터를 생성하도록 한다.

10개의 계정을 사용하므로, 테스트 데이터 100개를 삽입하면 그 중 10개를 꺼내서 조회하게 된다.

# seed_notifications.py
import random
from datetime import datetime
from pymongo import MongoClient

# MongoDB 접속 정보 (Docker 환경에 맞게 수정)
MONGO_URI = "mongodb://root:1234@localhost:27017/codetest?authSource=admin"
DB_NAME = "mydb"  # 실제 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(100)
    result = coll.insert_many(docs)
    print(f"Inserted {len(result.inserted_ids)} documents into `{DB_NAME}.{COLLECTION_NAME}`")

if __name__ == "__main__":
    main()

성능 측정