Repository: saero-jinro/saerojinro-server
Pull request #110: refactor/#109 강의 추천 로직 최적화
Author: login: minjo-on
Labels: name: refactor
description: Refactor something
Created: 2025-03-14T02:03:58Z | Merged: 2025-03-14T07:58:54Z

## ⚡️ 관련 이슈
> 관련 있는 이슈를 태그해주세요.
- close #109

## 📍주요 변경 사항
> - lecture index 설정
> - LogEvent index 설정
> - 유저 최근 로그 조회 캐싱 구현

## 🌁 Screenshot
### lecture Idx 설정 전
<img width="1115" alt="스크린샷 2025-03-14 오전 10 30 16" src="https://github.com/user-attachments/assets/d35edbf1-703c-4813-8c84-c31689ae1b4d" />

### 후
<img width="1110" alt="스크린샷 2025-03-14 오전 10 30 00" src="https://github.com/user-attachments/assets/8b27f6eb-ba6a-4e0e-bf3a-a8d2eadd7a66" />

### LogEvent Idx 설정
<img width="877" alt="스크린샷 2025-03-14 오전 10 46 19" src="https://github.com/user-attachments/assets/897e92a7-507c-46f6-87c1-bc1ac08ebc6e" />


Linked issue #109: [REFACTOR] 강의 추천 알고리즘 성능 최적화
## 📌 어떤 기능을 리팩터링 하나요?
> 현재 강의 추천 알고리즘이 로그 이벤트 데이터를 조회하여 추천을 수행하는 방식입니다. 하지만, 로그 이벤트 데이터가 많아질수록 조회 부하가 증가하고 성능이 저하되는 문제가 있습니다.
해결책으로 자주 조회되는 로그 데이터를 Redis에 캐싱하여 부하를 줄이는 방식을 고려하고 있습니다.

## AS-IS
> 로그 이벤트 데이터를 DB에서 직접 조회하여 추천을 수행.
추천 알고리즘 실행 시, 로그 테이블에서 많은 데이터가 읽히며 성능 저하 발생.
로그 이벤트가 많아질수록 쿼리 부하 증가 및 응답 속도 저하.

## TO-BE
> 자주 조회되는 로그 데이터를 Redis에 캐싱하여 빠르게 조회 가능하도록 변경.
DB 조회 부담을 줄이고, Redis TTL을 설정하여 캐싱 데이터 관리.
추천 알고리즘에서 Redis를 먼저 조회하고, 없을 경우만 DB 조회하도록 변경.

Diff:
diff --git a/saerojinro-admin/src/testFixtures/java/lecture.application/LectureUserAdminFacadeTest.java b/saerojinro-admin/src/testFixtures/java/lecture.application/LectureAdminFacadeTest.java
similarity index 99%
rename from saerojinro-admin/src/testFixtures/java/lecture.application/LectureUserAdminFacadeTest.java
rename to saerojinro-admin/src/testFixtures/java/lecture.application/LectureAdminFacadeTest.java
index d374bb5b..8b5cd9a4 100644
--- a/saerojinro-admin/src/testFixtures/java/lecture.application/LectureUserAdminFacadeTest.java
+++ b/saerojinro-admin/src/testFixtures/java/lecture.application/LectureAdminFacadeTest.java
@@ -19,7 +19,7 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
-public class LectureUserAdminFacadeTest {
+public class LectureAdminFacadeTest {
 
 	private LectureAdminFacade lectureAdminFacade;
 	private LectureCommandService lectureCommandService;
diff --git a/saerojinro-api/src/main/java/goorm/saerojinro/api/lecture/application/LectureFacade.java b/saerojinro-api/src/main/java/goorm/saerojinro/api/lecture/application/LectureFacade.java
index 1f802d7f..decc226f 100644
--- a/saerojinro-api/src/main/java/goorm/saerojinro/api/lecture/application/LectureFacade.java
+++ b/saerojinro-api/src/main/java/goorm/saerojinro/api/lecture/application/LectureFacade.java
@@ -8,7 +8,7 @@
 import goorm.saerojinro.domain.lecture.application.LectureRecommendationService;
 import goorm.saerojinro.domain.logevent.application.LogEventService;
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventProducer;
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
@@ -38,8 +38,8 @@ public LectureDetailResponse getById(Long id) {
 		User user = userQueryService.me();
 
 		if (user != null && user.getRole().equals(ATTENDEE)) {
-			LogEventDto logEventDto = LogEventDto.of(user.getId(), lecture.getId(), LECTURE_VIEW, lecture.getCategory());
-			logEventProducer.sendMessage(logEventDto);
+			RedisLogEvent redisLogEvent = RedisLogEvent.of(user.getId(), lecture.getId(), LECTURE_VIEW, lecture.getCategory(), LocalDateTime.now());
+			logEventProducer.sendMessage(redisLogEvent);
 		}
 
 		return LectureDetailResponse.from(lecture);
@@ -55,8 +55,17 @@ public LectureListResponse getByDate(LocalDate localDate) {
 
 	@Transactional(readOnly = true)
 	public LectureSummaryListResponse getRecommendationLectures(LocalDateTime lectureStartTime) {
-		List<LogEvent> userLogEvents = logEventService.getLogEventsByUser();
-		Map<Category, Integer> categoryPriortyMap = lectureRecommendationService.getRecommendationCategories(userLogEvents);
+		Long userId = userQueryService.me().getId();
+		Map<Category, Integer> categoryPriortyMap;
+		List<RedisLogEvent> userRedisLogEvents = logEventService.getLogEventsByUserFromRedis(userId);
+
+		if (userRedisLogEvents.isEmpty()) {
+			List<LogEvent> userLogEvents = logEventService.getLogEventsByUser(userId);
+			categoryPriortyMap = lectureRecommendationService.getRecommendationCategoriesByEntity(userLogEvents);
+		} else {
+			categoryPriortyMap = lectureRecommendationService.getRecommendationCategoriesByCache(userRedisLogEvents);
+		}
+
 		List<Lecture> getRecommendationLectures = lectureQueryService.getRecommendedLectureByDate(categoryPriortyMap, lectureStartTime);
 		return LectureSummaryListResponse.from(getRecommendationLectures);
 	}
diff --git a/saerojinro-api/src/main/java/goorm/saerojinro/api/reservation/application/ReservationFacade.java b/saerojinro-api/src/main/java/goorm/saerojinro/api/reservation/application/ReservationFacade.java
index 74848371..84ddf3de 100644
--- a/saerojinro-api/src/main/java/goorm/saerojinro/api/reservation/application/ReservationFacade.java
+++ b/saerojinro-api/src/main/java/goorm/saerojinro/api/reservation/application/ReservationFacade.java
@@ -3,12 +3,14 @@
 import static goorm.saerojinro.domain.logevent.domain.enums.LogEventType.LECTURE_RESERVATION_FAIL;
 import static goorm.saerojinro.domain.logevent.domain.enums.LogEventType.LECTURE_RESERVATION_SUCCESS;
 
+import java.time.LocalDateTime;
+
 import goorm.saerojinro.api.reservation.presentation.response.ReservationCreateResponse;
 import goorm.saerojinro.common.exception.CustomException;
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
 import goorm.saerojinro.domain.logevent.domain.LogEventProducer;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.logevent.domain.enums.LogEventType;
 import goorm.saerojinro.domain.reservation.application.ReservationCommandService;
 import goorm.saerojinro.domain.reservation.application.ReservationQueryService;
@@ -35,18 +37,33 @@ public ReservationCreateResponse create(Long lectureId) {
         LogEventType logEventType;
 
         try {
-            reservationQueryService.validateReservationByUserAndStartTime(user.getId(), lecture.getStartTime());
+            reservationQueryService.validateReservationByUserAndStartTime(
+                user.getId(),
+                lecture.getStartTime()
+            );
             Reservation reservation = reservationCommandService.create(user, lecture);
 
             logEventType = LECTURE_RESERVATION_SUCCESS;
-            LogEventDto logEventDto = LogEventDto.of(user.getId(), lecture.getId(), logEventType, lecture.getCategory());
-            logEventProducer.sendMessage(logEventDto);
+            RedisLogEvent redisLogEvent = RedisLogEvent.of(
+                user.getId(),
+                lecture.getId(),
+                logEventType,
+                lecture.getCategory(),
+                LocalDateTime.now()
+            );
+            logEventProducer.sendMessage(redisLogEvent);
 
             return ReservationCreateResponse.from(reservation);
         } catch (CustomException e) {
             logEventType = LECTURE_RESERVATION_FAIL;
-            LogEventDto logEventDto = LogEventDto.of(user.getId(), lecture.getId(), logEventType, lecture.getCategory());
-            logEventProducer.sendMessage(logEventDto);
+            RedisLogEvent redisLogEvent = RedisLogEvent.of(
+                user.getId(),
+                lecture.getId(),
+                logEventType,
+                lecture.getCategory(),
+                LocalDateTime.now()
+            );
+            logEventProducer.sendMessage(redisLogEvent);
 
             throw e;
         }
@@ -56,7 +73,10 @@ public ReservationCreateResponse create(Long lectureId) {
     public void cancel(Long lectureId){
         User user = userQueryService.me();
 
-        Reservation reservation = reservationQueryService.getByUserAndLecture(user.getId(), lectureId);
+        Reservation reservation = reservationQueryService.getByUserAndLecture(
+            user.getId(),
+            lectureId
+        );
         reservationCommandService.cancel(reservation);
     }
 
diff --git a/saerojinro-api/src/main/java/goorm/saerojinro/api/wishlist/application/WishListFacade.java b/saerojinro-api/src/main/java/goorm/saerojinro/api/wishlist/application/WishListFacade.java
index 79c8f211..1ff8631b 100644
--- a/saerojinro-api/src/main/java/goorm/saerojinro/api/wishlist/application/WishListFacade.java
+++ b/saerojinro-api/src/main/java/goorm/saerojinro/api/wishlist/application/WishListFacade.java
@@ -1,6 +1,5 @@
 package goorm.saerojinro.api.wishlist.application;
 
-import static goorm.saerojinro.domain.logevent.domain.enums.LogEventType.LECTURE_VIEW;
 import static goorm.saerojinro.domain.logevent.domain.enums.LogEventType.LECTURE_WISHLIST;
 
 import goorm.saerojinro.api.wishlist.presentation.response.WishListCreateResponse;
@@ -8,7 +7,7 @@
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
 import goorm.saerojinro.domain.logevent.domain.LogEventProducer;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.user.application.UserQueryService;
 import goorm.saerojinro.domain.user.domain.User;
 import goorm.saerojinro.domain.wishlist.application.WishListCommandService;
@@ -50,8 +49,14 @@ public WishListCreateResponse create(Long lectureId) {
 		Lecture lecture = lectureQueryService.getById(lectureId);
 		WishList wishList = wishListCommandService.create(user, lecture);
 
-		LogEventDto logEventDto = LogEventDto.of(user.getId(), lecture.getId(), LECTURE_WISHLIST, lecture.getCategory());
-		logEventProducer.sendMessage(logEventDto);
+		RedisLogEvent redisLogEvent = RedisLogEvent.of(
+			user.getId(),
+			lecture.getId(),
+			LECTURE_WISHLIST,
+			lecture.getCategory(),
+			LocalDateTime.now()
+		);
+		logEventProducer.sendMessage(redisLogEvent);
 
 		return WishListCreateResponse.from(wishList);
 	}
diff --git a/saerojinro-api/src/testFixtures/java/lecture/LectureFacadeTest.java b/saerojinro-api/src/testFixtures/java/lecture/LectureFacadeTest.java
index 258d3f9f..25b573a3 100644
--- a/saerojinro-api/src/testFixtures/java/lecture/LectureFacadeTest.java
+++ b/saerojinro-api/src/testFixtures/java/lecture/LectureFacadeTest.java
@@ -9,13 +9,12 @@
 import goorm.saerojinro.api.lecture.presentation.response.LectureDetailResponse;
 import goorm.saerojinro.api.lecture.presentation.response.LectureListResponse;
 import goorm.saerojinro.api.lecture.presentation.response.LectureSummaryListResponse;
-import goorm.saerojinro.domain.file.domain.File;
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.application.LectureRecommendationService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
 import goorm.saerojinro.domain.lecture.exception.LectureNotFoundException;
 import goorm.saerojinro.domain.logevent.application.LogEventService;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.user.application.UserQueryService;
 import goorm.saerojinro.domain.user.domain.User;
 import mock.producer.FakeLogEventProducer;
@@ -39,10 +38,14 @@ public class LectureFacadeTest {
 	private LectureFacade lectureFacade;
 	private LectureQueryService lectureQueryService;
 	private final FakeLogEventProducer fakeEventLogProducer = new FakeLogEventProducer();
-
+	private FakeLogEventRepository fakeLogEventRepository;
 	private Lecture lecture1;
 	private Lecture lecture2;
 
+	private RedisLogEvent redisLogEvent1;
+	private RedisLogEvent redisLogEvent2;
+	private RedisLogEvent redisLogEvent3;
+
 	private static final String NAME = "Cole Palmer";
 	private static final String EMAIL = "google@mail.com";
 	private static final String POSITION = "00 기업 CEO";
@@ -54,7 +57,7 @@ public class LectureFacadeTest {
 	void setUp() {
 		FakeLectureRepository lectureRepository = new FakeLectureRepository();
 		lectureQueryService = new LectureQueryService(lectureRepository);
-		FakeLogEventRepository fakeLogEventRepository = new FakeLogEventRepository();
+		fakeLogEventRepository = new FakeLogEventRepository();
 		FakeUserRepository fakeUserRepository = new FakeUserRepository();
 		UserQueryService userQueryService = new UserQueryService(fakeUserRepository, new BCryptPasswordEncoder());
 		LogEventService logEventService = new LogEventService(fakeLogEventRepository, userQueryService, lectureQueryService);
@@ -103,13 +106,33 @@ void setUp() {
 		lecture2 = lectureRepository.save(lecture2);
 
 		String record = "record";
-		LogEventDto logEventDto1 = LogEventDto.of(user1.getId(), lecture1.getId(), LECTURE_RESERVATION_SUCCESS, lecture1.getCategory());
-		LogEventDto logEventDto2 = LogEventDto.of(user1.getId(), lecture1.getId(), LECTURE_RESERVATION_SUCCESS, lecture1.getCategory());
-		LogEventDto logEventDto3 = LogEventDto.of(user1.getId(), lecture2.getId(), LECTURE_RESERVATION_SUCCESS, lecture2.getCategory());
+		redisLogEvent1 = RedisLogEvent.of(
+			user1.getId(),
+			lecture1.getId(),
+			LECTURE_RESERVATION_SUCCESS,
+			lecture1.getCategory(),
+			LocalDateTime.now()
+		);
+
+		redisLogEvent2 = RedisLogEvent.of(
+			user1.getId(),
+			lecture1.getId(),
+			LECTURE_RESERVATION_SUCCESS,
+			lecture1.getCategory(),
+			LocalDateTime.now()
+		);
 
-		logEventService.save(record + 1, logEventDto1);
-		logEventService.save(record + 2, logEventDto2);
-		logEventService.save(record + 3, logEventDto3);
+		redisLogEvent3 = RedisLogEvent.of(
+			user1.getId(),
+			lecture2.getId(),
+			LECTURE_RESERVATION_SUCCESS,
+			lecture2.getCategory(),
+			LocalDateTime.now()
+		);
+
+		logEventService.save(record + 1, redisLogEvent1);
+		logEventService.save(record + 2, redisLogEvent2);
+		logEventService.save(record + 3, redisLogEvent3);
 
 		UserDetails user = userQueryService.getByEmail("email@email.com");
 		SecurityContext context = SecurityContextHolder.getContext();
@@ -163,4 +186,22 @@ void getRecommendationLectures_Success() {
 		assertEquals(lecture1.getTitle(), response.responses().get(0).title());
 		assertEquals(lecture2.getTitle(), response.responses().get(1).title());
 	}
+
+	@Test
+	@DisplayName("getRecommendationLectures는 Redis에 데이터가 있는 경우, Redis에 있는 데이터를 참고하여 우선순위 대로 조회된 List가 반환된다.")
+	void getRecommendationLecturesFromRedis_Success() {
+		// given
+		LocalDateTime startTime = LocalDateTime.of(2025, 3, 1, 10, 0);
+		fakeLogEventRepository.cache(redisLogEvent1);
+		fakeLogEventRepository.cache(redisLogEvent2);
+		fakeLogEventRepository.cache(redisLogEvent3);
+
+		// when
+		LectureSummaryListResponse response = lectureFacade.getRecommendationLectures(startTime);
+
+		// then
+		assertEquals(2, response.responses().size());
+		assertEquals(lecture1.getTitle(), response.responses().get(0).title());
+		assertEquals(lecture2.getTitle(), response.responses().get(1).title());
+	}
 }
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureQueryService.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureQueryService.java
index 3c087694..f7e79b6b 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureQueryService.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureQueryService.java
@@ -30,7 +30,7 @@ public Lecture getById(Long lectureId) {
 
 	public List<Lecture> getByDate(LocalDate localDate) {
 		LocalDateTime start = localDate.atStartOfDay();
-		LocalDateTime end   = localDate.plusDays(1).atStartOfDay();
+		LocalDateTime end = localDate.plusDays(1).atStartOfDay();
 		return lectureRepository.findByStartTimeBetween(start, end);
 	}
 
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureRecommendationService.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureRecommendationService.java
index 239d64ef..45777b44 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureRecommendationService.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/application/LectureRecommendationService.java
@@ -9,18 +9,27 @@
 import org.springframework.stereotype.Service;
 
 import goorm.saerojinro.common.domain.Category;
-import goorm.saerojinro.domain.lecture.domain.LectureRepository;
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import lombok.RequiredArgsConstructor;
 
 @Service
 @RequiredArgsConstructor
 public class LectureRecommendationService {
-	public Map<Category, Integer> getRecommendationCategories(List<LogEvent> logEvents) {
-		List<Category> sortedCategories = logEvents.stream()
+	public Map<Category, Integer> getRecommendationCategoriesByCache(List<RedisLogEvent> redisLogEvents) {
+		return getRecommendationCategories(redisLogEvents.stream()
+			.collect(Collectors.groupingBy(RedisLogEvent::category,
+				Collectors.summingInt(redisLogEvent -> redisLogEvent.logEventType().getWeight()))));
+	}
+
+	public Map<Category, Integer> getRecommendationCategoriesByEntity(List<LogEvent> logEvents) {
+		return getRecommendationCategories(logEvents.stream()
 			.collect(Collectors.groupingBy(LogEvent::getCategory,
-				Collectors.summingInt(logEvent -> logEvent.getLogEventType().getWeight())))
-			.entrySet().stream()
+				Collectors.summingInt(logEvent -> logEvent.getLogEventType().getWeight()))));
+	}
+
+	private Map<Category, Integer> getRecommendationCategories(Map<Category, Integer> categoryWeights) {
+		List<Category> sortedCategories = categoryWeights.entrySet().stream()
 			.sorted(Map.Entry.<Category, Integer>comparingByValue().reversed())
 			.map(Map.Entry::getKey)
 			.toList();
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/domain/Lecture.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/domain/Lecture.java
index 56af59b6..dc94828e 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/domain/Lecture.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/lecture/domain/Lecture.java
@@ -11,7 +11,12 @@
 @Entity
 @Getter
 @Builder
-@Table(name = "lecture")
+@Table(
+	indexes = {
+		@Index(name = "idx_category_time", columnList = "category, startTime"),
+		@Index(name = "idx_start_time", columnList = "startTime")
+	}
+)
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
 @AllArgsConstructor(access = AccessLevel.PROTECTED)
 public class Lecture extends BaseTimeEntity {
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/application/LogEventService.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/application/LogEventService.java
index 0998632f..35dfcdf0 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/application/LogEventService.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/application/LogEventService.java
@@ -7,7 +7,7 @@
 
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventRepository;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
 import goorm.saerojinro.domain.user.application.UserQueryService;
@@ -22,18 +22,30 @@ public class LogEventService {
 	private final LectureQueryService lectureQueryService;
 
 	@Transactional
-	public LogEvent save(String record, LogEventDto logEventDto) {
-		User user = userQueryService.getById(logEventDto.userId());
-		Lecture lecture = lectureQueryService.getById(logEventDto.lectureId());
-		LogEvent logEvent = LogEvent.create(record, user, lecture, logEventDto.logEventType(), logEventDto.category());
-
+	public LogEvent save(String record, RedisLogEvent redisLogEvent) {
+		User user = userQueryService.getById(redisLogEvent.userId());
+		Lecture lecture = lectureQueryService.getById(redisLogEvent.lectureId());
+		LogEvent logEvent = LogEvent.create(
+			record,
+			user,
+			lecture,
+			redisLogEvent.logEventType(),
+			redisLogEvent.category(),
+			redisLogEvent.timestamp()
+		);
 		return logEventRepository.save(logEvent);
 	}
 
 	@Transactional(readOnly = true)
-	public List<LogEvent> getLogEventsByUser() {
-		User user = userQueryService.me();
-		return logEventRepository.findRecentLogByUserId(user.getId());
+	public List<LogEvent> getLogEventsByUser(Long userId) {
+		return logEventRepository.findRecentLogByUserId(userId);
+	}
+
+	public List<RedisLogEvent> getLogEventsByUserFromRedis(Long userId) {
+		return logEventRepository.findRecentFromRedis(userId);
 	}
 
+	public void cache(RedisLogEvent logEvent) {
+		logEventRepository.cache(logEvent);
+	}
 }
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEvent.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEvent.java
index 28b3a92a..7c911995 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEvent.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEvent.java
@@ -13,8 +13,10 @@
 import jakarta.persistence.Entity;
 import jakarta.persistence.Enumerated;
 import jakarta.persistence.Id;
+import jakarta.persistence.Index;
 import jakarta.persistence.JoinColumn;
 import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
@@ -24,6 +26,11 @@
 @Entity
 @Getter
 @Builder
+@Table(
+	indexes = {
+		@Index(name = "idx_user_log", columnList = "userId, timestamp DESC")
+	}
+)
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
 @AllArgsConstructor(access = AccessLevel.PROTECTED)
 public class LogEvent {
@@ -49,14 +56,21 @@ public class LogEvent {
 	@Column(nullable = false)
 	private LocalDateTime timestamp;
 
-	public static LogEvent create(String record, User user, Lecture lecture, LogEventType logEventType, Category category) {
+	public static LogEvent create(
+		String record,
+		User user,
+		Lecture lecture,
+		LogEventType logEventType,
+		Category category,
+		LocalDateTime timestamp
+	) {
 		return LogEvent.builder()
 			.record(record)
 			.user(user)
 			.lecture(lecture)
 			.logEventType(logEventType)
 			.category(category)
-			.timestamp(LocalDateTime.now())
+			.timestamp(timestamp)
 			.build();
 	}
 }
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventProducer.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventProducer.java
index b9064d3c..bd4743be 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventProducer.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventProducer.java
@@ -1,7 +1,7 @@
 package goorm.saerojinro.domain.logevent.domain;
 
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 
 public interface LogEventProducer {
-	void sendMessage(LogEventDto logEventDto);
+	void sendMessage(RedisLogEvent redisLogEvent);
 }
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventRepository.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventRepository.java
index 7c1ba34b..a902f6e8 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventRepository.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/LogEventRepository.java
@@ -2,8 +2,14 @@
 
 import java.util.List;
 
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
+
 public interface LogEventRepository {
 	LogEvent save(LogEvent logEvent);
 
 	List<LogEvent> findRecentLogByUserId(Long userId);
+
+	List<RedisLogEvent> findRecentFromRedis(Long userId);
+
+	void cache(RedisLogEvent redisLogEvent);
 }
diff --git a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/LogEventDto.java b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/RedisLogEvent.java
similarity index 56%
rename from saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/LogEventDto.java
rename to saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/RedisLogEvent.java
index bca99878..c93788c3 100644
--- a/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/LogEventDto.java
+++ b/saerojinro-domain/src/main/java/goorm/saerojinro/domain/logevent/domain/dto/RedisLogEvent.java
@@ -1,23 +1,27 @@
 package goorm.saerojinro.domain.logevent.domain.dto;
 
+import java.time.LocalDateTime;
+
 import goorm.saerojinro.common.domain.Category;
 
 import goorm.saerojinro.domain.logevent.domain.enums.LogEventType;
 import lombok.Builder;
 
 @Builder
-public record LogEventDto(
+public record RedisLogEvent(
 	Long userId,
 	Long lectureId,
 	LogEventType logEventType,
-	Category category
+	Category category,
+	LocalDateTime timestamp
 ) {
-	public static LogEventDto of(Long userId, Long lectureId, LogEventType logEventType, Category category) {
-		return LogEventDto.builder()
+	public static RedisLogEvent of(Long userId, Long lectureId, LogEventType logEventType, Category category, LocalDateTime timestamp) {
+		return RedisLogEvent.builder()
 			.userId(userId)
 			.lectureId(lectureId)
 			.logEventType(logEventType)
 			.category(category)
+			.timestamp(timestamp)
 			.build();
 	}
 }
diff --git a/saerojinro-domain/src/testFixtures/java/lecture/application/LectureRecommendationServiceTest.java b/saerojinro-domain/src/testFixtures/java/lecture/application/LectureRecommendationServiceTest.java
index b60342c9..32f42f62 100644
--- a/saerojinro-domain/src/testFixtures/java/lecture/application/LectureRecommendationServiceTest.java
+++ b/saerojinro-domain/src/testFixtures/java/lecture/application/LectureRecommendationServiceTest.java
@@ -7,6 +7,7 @@
 import static goorm.saerojinro.domain.logevent.domain.enums.LogEventType.LECTURE_RESERVATION_SUCCESS;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -18,6 +19,7 @@
 import goorm.saerojinro.common.domain.Category;
 import goorm.saerojinro.domain.lecture.application.LectureRecommendationService;
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 
 public class LectureRecommendationServiceTest {
 	private LectureRecommendationService lectureRecommendationService;
@@ -28,17 +30,80 @@ public void init() {
 	}
 
 	@Test
-	@DisplayName("getRecommendationCategories는 logEvents를 받아 각 카테고리에 대한 가중치 총합을 도출한다.")
-	public void getRecommendationCategories_Success() {
+	@DisplayName("getRecommendationCategoriesByEntity는 logEvents를 받아 각 카테고리에 대한 가중치 총합을 도출한다.")
+	public void getRecommendationCategoriesByEntity_Success() {
 		// given
 		List<LogEvent> logEvents = new ArrayList<>();
 
-		logEvents.add(LogEvent.create("record0", null, null, LECTURE_RESERVATION_FAIL, DEVOPS));
-		logEvents.add(LogEvent.create("record1", null, null, LECTURE_RESERVATION_FAIL, DEVOPS));
-		logEvents.add(LogEvent.create("record2", null, null, LECTURE_RESERVATION_SUCCESS, BACKEND));
+		logEvents.add(LogEvent.create(
+			"record0",
+			null,
+			null,
+			LECTURE_RESERVATION_FAIL,
+			DEVOPS,
+			LocalDateTime.now())
+		);
+
+		logEvents.add(LogEvent.create(
+			"record1",
+			null,
+			null,
+			LECTURE_RESERVATION_FAIL,
+			DEVOPS,
+			LocalDateTime.now())
+		);
+
+		logEvents.add(LogEvent.create(
+			"record2",
+			null,
+			null,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now())
+		);
+
+		// when
+		Map<Category, Integer> result = lectureRecommendationService
+			.getRecommendationCategoriesByEntity(logEvents);
+
+		// then
+		assertEquals(result.get(BACKEND), 2);
+		assertEquals(result.get(DEVOPS), 1);
+	}
+
+	@Test
+	@DisplayName("getRecommendationCategoriesByCache는 RedislogEvents를 받아 각 카테고리에 대한 가중치 총합을 도출한다.")
+	public void getRecommendationCategoriesByCache_Success() {
+		// given
+		List<RedisLogEvent> logEvents = new ArrayList<>();
+
+		logEvents.add(RedisLogEvent.of(
+			null,
+			null,
+			LECTURE_RESERVATION_FAIL,
+			DEVOPS,
+			LocalDateTime.now())
+		);
+
+		logEvents.add(RedisLogEvent.of(
+			null,
+			null,
+			LECTURE_RESERVATION_FAIL,
+			DEVOPS,
+			LocalDateTime.now())
+		);
+
+		logEvents.add(RedisLogEvent.of(
+			null,
+			null,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now())
+		);
 
 		// when
-		Map<Category, Integer> result = lectureRecommendationService.getRecommendationCategories(logEvents);
+		Map<Category, Integer> result = lectureRecommendationService
+			.getRecommendationCategoriesByCache(logEvents);
 
 		// then
 		assertEquals(result.get(BACKEND), 2);
diff --git a/saerojinro-domain/src/testFixtures/java/logevent/application/LogEventServiceTest.java b/saerojinro-domain/src/testFixtures/java/logevent/application/LogEventServiceTest.java
index a1fa649c..d46e277d 100644
--- a/saerojinro-domain/src/testFixtures/java/logevent/application/LogEventServiceTest.java
+++ b/saerojinro-domain/src/testFixtures/java/logevent/application/LogEventServiceTest.java
@@ -20,7 +20,7 @@
 import goorm.saerojinro.common.domain.Category;
 import goorm.saerojinro.domain.logevent.application.LogEventService;
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.lecture.application.LectureQueryService;
 import goorm.saerojinro.domain.lecture.domain.Lecture;
 import goorm.saerojinro.domain.speaker.domain.Speaker;
@@ -82,15 +82,15 @@ public void init() {
 	public void save_Success() {
 		// given
 		String record = "record";
-		LogEventDto logEventDto = LogEventDto.of(1L, 1L, LECTURE_RESERVATION_SUCCESS, BACKEND);
+		RedisLogEvent redisLogEvent = RedisLogEvent.of(1L, 1L, LECTURE_RESERVATION_SUCCESS, BACKEND, LocalDateTime.now());
 
 		// when
-		LogEvent response = logEventService.save(record, logEventDto);
+		LogEvent response = logEventService.save(record, redisLogEvent);
 
 		// then
 		assertEquals(record, response.getRecord());
-		assertEquals(logEventDto.logEventType(), response.getLogEventType());
-		assertEquals(logEventDto.category(), response.getCategory());
+		assertEquals(redisLogEvent.logEventType(), response.getLogEventType());
+		assertEquals(redisLogEvent.category(), response.getCategory());
 		assertEquals(user, response.getUser());
 		assertEquals(lecture, response.getLecture());
 	}
@@ -100,17 +100,97 @@ public void save_Success() {
 	public void getLogEventsByUser_Success() {
 		// given
 		String record = "record";
-		LogEventDto logEventDto1 = LogEventDto.of(1L, 1L, LECTURE_RESERVATION_SUCCESS, BACKEND);
-		LogEventDto logEventDto2 = LogEventDto.of(1L, 1L, LECTURE_RESERVATION_SUCCESS, BACKEND);
-		LogEventDto logEventDto3 = LogEventDto.of(1L, 1L, LECTURE_RESERVATION_SUCCESS, BACKEND);
+		RedisLogEvent redisLogEvent1 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		RedisLogEvent redisLogEvent2 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		RedisLogEvent redisLogEvent3 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		logEventService.save(record + 1, redisLogEvent1);
+		logEventService.save(record + 2, redisLogEvent2);
+		logEventService.save(record + 3, redisLogEvent3);
+
+		// when
+		List<LogEvent> response = logEventService.getLogEventsByUser(1L);
+
+		// then
+		assertEquals(3, response.size());
+	}
+
+	@Test
+	@DisplayName("getLogEventsByUserFromRedis는 redis에 저장되어 있던 유저 로그를 조회한다.")
+	public void getLogEventsByUserFromRedis_Success() {
+		// given
+		RedisLogEvent redisLogEvent1 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		RedisLogEvent redisLogEvent2 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		RedisLogEvent redisLogEvent3 = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
 
-		logEventService.save(record + 1, logEventDto1);
-		logEventService.save(record + 2, logEventDto2);
-		logEventService.save(record + 3, logEventDto3);
+		logEventService.cache(redisLogEvent1);
+		logEventService.cache(redisLogEvent2);
+		logEventService.cache(redisLogEvent3);
 
 		// when
-		List<LogEvent> response = logEventService.getLogEventsByUser();
+		List<RedisLogEvent> response = logEventService.getLogEventsByUserFromRedis(1L);
+
 		// then
 		assertEquals(3, response.size());
 	}
+
+	@Test
+	@DisplayName("cache는 RedisEventLog를 저장한다.")
+	public void cache_Success() {
+		// given
+		RedisLogEvent redisLogEvent = RedisLogEvent.of(
+			1L,
+			1L,
+			LECTURE_RESERVATION_SUCCESS,
+			BACKEND,
+			LocalDateTime.now()
+		);
+
+		// when
+		logEventService.cache(redisLogEvent);
+		RedisLogEvent result = logEventService.getLogEventsByUserFromRedis(1L).get(0);
+
+		// then
+		assertEquals(redisLogEvent, result);
+	}
 }
diff --git a/saerojinro-domain/src/testFixtures/java/logevent/domain/LogEventDomainTest.java b/saerojinro-domain/src/testFixtures/java/logevent/domain/LogEventDomainTest.java
index 05f9bcfc..4445bc31 100644
--- a/saerojinro-domain/src/testFixtures/java/logevent/domain/LogEventDomainTest.java
+++ b/saerojinro-domain/src/testFixtures/java/logevent/domain/LogEventDomainTest.java
@@ -4,6 +4,8 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
+import java.time.LocalDateTime;
+
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
@@ -25,7 +27,7 @@ public void create_Success(){
 		Category category = BACKEND;
 
 		// when
-		LogEvent result = LogEvent.create(record, user, lecture, logEventType, category);
+		LogEvent result = LogEvent.create(record, user, lecture, logEventType, category, LocalDateTime.now());
 
 		// then
 		assertNotNull(record);
diff --git a/saerojinro-domain/src/testFixtures/java/mock/producer/FakeLogEventProducer.java b/saerojinro-domain/src/testFixtures/java/mock/producer/FakeLogEventProducer.java
index 6f59c4ac..3c344575 100644
--- a/saerojinro-domain/src/testFixtures/java/mock/producer/FakeLogEventProducer.java
+++ b/saerojinro-domain/src/testFixtures/java/mock/producer/FakeLogEventProducer.java
@@ -3,15 +3,15 @@
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.LinkedBlockingQueue;
 
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventProducer;
 
 public class FakeLogEventProducer implements LogEventProducer {
-	private final BlockingQueue<LogEventDto> queue = new LinkedBlockingQueue<LogEventDto>();
+	private final BlockingQueue<RedisLogEvent> queue = new LinkedBlockingQueue<RedisLogEvent>();
 
 	@Override
-	public void sendMessage(LogEventDto logEventDto) {
-		queue.add(logEventDto);
+	public void sendMessage(RedisLogEvent redisLogEvent) {
+		queue.add(redisLogEvent);
 	}
 
 	public int getQueueSize() {
diff --git a/saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java b/saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java
index 2ba9c0e2..6a85e8b2 100644
--- a/saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java
+++ b/saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java
@@ -3,12 +3,16 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventRepository;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 
 public class FakeLogEventRepository implements LogEventRepository {
 	private final List<LogEvent> data = Collections.synchronizedList(new ArrayList<>());
+	private final Map<Long, List<RedisLogEvent>> map = new ConcurrentHashMap<>();
 
 	@Override
 	public LogEvent save(LogEvent logEvent) {
@@ -22,4 +26,21 @@ public List<LogEvent> findRecentLogByUserId(Long userId) {
 			.filter(logEvent -> logEvent.getUser().getId().equals(userId))
 			.toList();
 	}
+
+	@Override
+	public List<RedisLogEvent> findRecentFromRedis(Long userId) {
+		return map.getOrDefault(userId, Collections.emptyList());
+	}
+
+	@Override
+	public void cache(RedisLogEvent redisLogEvent) {
+		map.compute(redisLogEvent.userId(), (userId, logList) -> {
+			if (logList == null) {
+				logList = new ArrayList<>();
+			}
+
+			logList.add(redisLogEvent);
+			return logList;
+		});
+	}
 }
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/LogEventProducerImpl.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/LogEventProducerImpl.java
index 05a87656..16ed7cb6 100644
--- a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/LogEventProducerImpl.java
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/LogEventProducerImpl.java
@@ -4,7 +4,7 @@
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Component;
 
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventProducer;
 import goorm.saerojinro.infra.config.redis.RedisProperties;
 import goorm.saerojinro.infra.messaging.exception.InvalidMessageFormatException;
@@ -27,9 +27,9 @@ public class LogEventProducerImpl implements LogEventProducer {
 	private final RedisProperties redisProperties;
 
 	@Override
-	public void sendMessage(LogEventDto logEventDto) {
+	public void sendMessage(RedisLogEvent redisLogEvent) {
 		try {
-			String eventLogJson = objectMapper.writeValueAsString(logEventDto);
+			String eventLogJson = objectMapper.writeValueAsString(redisLogEvent);
 
 			ObjectRecord<String, String> record = StreamRecords.newRecord()
 				.in(redisProperties.getLogEventStreamKey())
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/RedisStreamListener.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/RedisStreamListener.java
index 63e6709d..a056c4d3 100644
--- a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/RedisStreamListener.java
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/messaging/eventlog/RedisStreamListener.java
@@ -9,7 +9,7 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 
 import goorm.saerojinro.domain.logevent.application.LogEventService;
-import goorm.saerojinro.domain.logevent.domain.dto.LogEventDto;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
 import goorm.saerojinro.infra.config.redis.RedisProperties;
 import goorm.saerojinro.infra.messaging.exception.InvalidMessageFormatException;
 import lombok.RequiredArgsConstructor;
@@ -26,10 +26,10 @@ public class RedisStreamListener implements StreamListener<String, ObjectRecord<
 	public void onMessage(ObjectRecord<String, String> message) {
 		try {
 			String record = String.valueOf(message.getId());
-			LogEventDto logEventDto = objectMapper.readValue(message.getValue(), LogEventDto.class);
-
-			logEventService.save(record, logEventDto);
+			RedisLogEvent redisLogEvent = objectMapper.readValue(message.getValue(), RedisLogEvent.class);
 
+			logEventService.save(record, redisLogEvent);
+			logEventService.cache(redisLogEvent);
 			redisTemplate.opsForStream().trim(redisProperties.getLogEventStreamKey(), 1000);
 		} catch (JsonProcessingException e) {
 			throw new InvalidMessageFormatException();
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/impl/LogEventRepositoryImpl.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/impl/LogEventRepositoryImpl.java
index d96baad0..385d06e2 100644
--- a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/impl/LogEventRepositoryImpl.java
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/impl/LogEventRepositoryImpl.java
@@ -6,21 +6,34 @@
 
 import goorm.saerojinro.domain.logevent.domain.LogEvent;
 import goorm.saerojinro.domain.logevent.domain.LogEventRepository;
-import goorm.saerojinro.infra.repository.jpa.EventLogJpaRepository;
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
+import goorm.saerojinro.infra.repository.jpa.LogEventJpaRepository;
+import goorm.saerojinro.infra.repository.redis.RedisLogEventRepository;
 import lombok.RequiredArgsConstructor;
 
 @Repository
 @RequiredArgsConstructor
 public class LogEventRepositoryImpl implements LogEventRepository {
-	private final EventLogJpaRepository eventLogJpaRepository;
+	private final LogEventJpaRepository logEventJpaRepository;
+	private final RedisLogEventRepository redisLogEventRepository;
 
 	@Override
 	public LogEvent save(LogEvent logEvent) {
-		return eventLogJpaRepository.save(logEvent);
+		return logEventJpaRepository.save(logEvent);
 	}
 
 	@Override
 	public List<LogEvent> findRecentLogByUserId(Long userId) {
-		return eventLogJpaRepository.findRecentLogByUserId(userId);
+		return logEventJpaRepository.findTop50ByUserIdOrderByTimestampDesc(userId);
+	}
+
+	@Override
+	public List<RedisLogEvent> findRecentFromRedis(Long userId) {
+		return redisLogEventRepository.findRecentFromCache(userId);
+	}
+
+	@Override
+	public void cache(RedisLogEvent redisLogEvent) {
+		redisLogEventRepository.addLogEvent(redisLogEvent);
 	}
 }
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/EventLogJpaRepository.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/EventLogJpaRepository.java
deleted file mode 100644
index 6c353a77..00000000
--- a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/EventLogJpaRepository.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package goorm.saerojinro.infra.repository.jpa;
-
-import java.util.List;
-
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Query;
-
-import goorm.saerojinro.domain.logevent.domain.LogEvent;
-
-public interface EventLogJpaRepository extends JpaRepository<LogEvent, Long> {
-	@Query("SELECT l FROM LogEvent l WHERE l.user.id = :userId ORDER BY l.timestamp DESC LIMIT 50")
-	List<LogEvent> findRecentLogByUserId(Long userId);
-}
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LectureJpaRepository.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LectureJpaRepository.java
index 61a8bbad..f5f0edad 100644
--- a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LectureJpaRepository.java
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LectureJpaRepository.java
@@ -11,7 +11,7 @@
 import java.util.Optional;
 
 public interface LectureJpaRepository extends JpaRepository<Lecture, Long> {
-	@Query("SELECT DISTINCT l FROM Lecture l JOIN FETCH l.speaker " +
+	@Query("SELECT l FROM Lecture l JOIN FETCH l.speaker " +
 		"WHERE l.startTime >= :start AND l.startTime < :end")
 	List<Lecture> findByStartTimeBetween(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
 
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LogEventJpaRepository.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LogEventJpaRepository.java
new file mode 100644
index 00000000..a10edfb4
--- /dev/null
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/jpa/LogEventJpaRepository.java
@@ -0,0 +1,11 @@
+package goorm.saerojinro.infra.repository.jpa;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import goorm.saerojinro.domain.logevent.domain.LogEvent;
+
+public interface LogEventJpaRepository extends JpaRepository<LogEvent, Long> {
+	List<LogEvent> findTop50ByUserIdOrderByTimestampDesc(Long userId);
+}
diff --git a/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/redis/RedisLogEventRepository.java b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/redis/RedisLogEventRepository.java
new file mode 100644
index 00000000..84702b99
--- /dev/null
+++ b/saerojinro-infra/src/main/java/goorm/saerojinro/infra/repository/redis/RedisLogEventRepository.java
@@ -0,0 +1,63 @@
+package goorm.saerojinro.infra.repository.redis;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Repository;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import goorm.saerojinro.domain.logevent.domain.dto.RedisLogEvent;
+import goorm.saerojinro.infra.messaging.exception.StreamProcessingException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class RedisLogEventRepository {
+	private final RedisTemplate<String, String> redisTemplate;
+	private final ObjectMapper objectMapper;
+
+	private static final String USER_LOG_KEY_PREFIX = "user:logs:";
+
+	public List<RedisLogEvent> findRecentFromCache(Long userId) {
+		String redisKey = USER_LOG_KEY_PREFIX + userId;
+
+		Set<String> cachedLogs = redisTemplate.opsForZSet().range(redisKey, 0, -1);
+		List<RedisLogEvent> logEvents = new ArrayList<>();
+
+		if (cachedLogs != null) {
+			for (String json : cachedLogs) {
+				try {
+					logEvents.add(objectMapper.readValue(json, RedisLogEvent.class));
+				} catch (JsonProcessingException e) {
+					throw new StreamProcessingException();
+				}
+			}
+		}
+
+		return logEvents;
+	}
+
+	public void addLogEvent(RedisLogEvent redisLogEvent) {
+		String redisKey = USER_LOG_KEY_PREFIX + redisLogEvent.userId();
+
+		try {
+			String json = objectMapper.writeValueAsString(redisLogEvent);
+			long score = redisLogEvent.timestamp().toInstant(ZoneOffset.UTC).toEpochMilli();
+
+			redisTemplate.opsForZSet().add(redisKey, json, score);
+			redisTemplate.opsForZSet().removeRange(redisKey, 0, -51);
+			redisTemplate.expire(redisKey, 6, HOURS);
+		} catch (JsonProcessingException e) {
+			throw new StreamProcessingException();
+		}
+	}
+}


Reviews:
user: login: LeeShinHaeng
body: LGTM! 
고생하셨어요! 👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T02:17:51Z
user: login: LeeShinHaeng
body: 레디스를 이용해 캐싱한 부분까지 고려해서 설계를 해주셨군요!
고생 많으셨어요! 👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T07:53:43Z
user: login: minjo-on
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T07:57:33Z

Review comments:
diff_hunk: @@ -22,4 +26,26 @@ public List<LogEvent> findRecentLogByUserId(Long userId) {
 			.filter(logEvent -> logEvent.getUser().getId().equals(userId))
 			.toList();
 	}
+
+	@Override
+	public List<RedisLogEvent> findRecentFromRedis(Long userId) {
+		return map.getOrDefault(userId, Collections.emptyList());
+	}
+
+	@Override
+	public void cache(RedisLogEvent redisLogEvent) {
+		map.compute(redisLogEvent.userId(), (userId, logList) -> {
+			if (logList == null) {
+				logList = new ArrayList<>();
+			}
+
+			logList.add(redisLogEvent);
+
+			if (logList.size() > 50) {
+				logList.remove(0);
+			}
path: saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java
user: login: LeeShinHaeng
body: Fake 객체는 테스트 환경에서 사용하기 때문에 매 테스트마다 새로 생성되는 것으로 알고 있습니다.
이런 상황에서 size에 대한 검사와 처리가 필요한지 궁금합니다!
created_at: 2025-03-14T07:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,4 +26,26 @@ public List<LogEvent> findRecentLogByUserId(Long userId) {
 			.filter(logEvent -> logEvent.getUser().getId().equals(userId))
 			.toList();
 	}
+
+	@Override
+	public List<RedisLogEvent> findRecentFromRedis(Long userId) {
+		return map.getOrDefault(userId, Collections.emptyList());
+	}
+
+	@Override
+	public void cache(RedisLogEvent redisLogEvent) {
+		map.compute(redisLogEvent.userId(), (userId, logList) -> {
+			if (logList == null) {
+				logList = new ArrayList<>();
+			}
+
+			logList.add(redisLogEvent);
+
+			if (logList.size() > 50) {
+				logList.remove(0);
+			}
path: saerojinro-domain/src/testFixtures/java/mock/repository/FakeLogEventRepository.java
user: login: minjo-on
body: 생각없이 기능에 맞춰 작성하다보니 구현했네요 삭제하도록 하겠습니다 !
created_at: 2025-03-14T07:57:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-14T02:05:18Z
body: ### Test Coverage Report
|Overall Project|96.1% **`-0.85%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|91.91%|:green_apple:|
<br>

|Module|Coverage||
|:-|:-|:-:|
|saerojinro-domain|98.82%|:green_apple:|
|saerojinro-api|96.84% **`-3.16%`**|:green_apple:|

<details>
<summary>Files</summary>

|Module|File|Coverage||
|:-|:-|:-|:-:|
|saerojinro-domain|[Lecture.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-domain%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fdomain%2Flecture%2Fdomain%2FLecture.java)|100%|:green_apple:|
||[LectureQueryService.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-domain%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fdomain%2Flecture%2Fapplication%2FLectureQueryService.java)|100%|:green_apple:|
||[LogEventService.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-domain%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fdomain%2Flogevent%2Fapplication%2FLogEventService.java)|100%|:green_apple:|
||[LogEvent.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-domain%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fdomain%2Flogevent%2Fdomain%2FLogEvent.java)|100%|:green_apple:|
||[LectureRecommendationService.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-domain%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fdomain%2Flecture%2Fapplication%2FLectureRecommendationService.java)|96.92%|:green_apple:|
|saerojinro-api|[WishListFacade.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-api%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fapi%2Fwishlist%2Fapplication%2FWishListFacade.java)|100%|:green_apple:|
||[ReservationFacade.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-api%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fapi%2Freservation%2Fapplication%2FReservationFacade.java)|100%|:green_apple:|
||[LectureFacade.java](https://github.com/saero-jinro/saerojinro-server/blob/6a8c98a12f551e7c352eaed98ddcd36ddf540df7/saerojinro-api%2Fsrc%2Fmain%2Fjava%2Fgoorm%2Fsaerojinro%2Fapi%2Flecture%2Fapplication%2FLectureFacade.java)|83.13% **`-16.87%`**|:x:|

</details>
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-14T02:06:11Z
body: ## [Codecov](https://app.codecov.io/gh/saero-jinro/saerojinro-server/pull/110?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saero-jinro) Report
Attention: Patch coverage is `96.00000%` with `2 lines` in your changes missing coverage. Please review.
| [Files with missing lines](https://app.codecov.io/gh/saero-jinro/saerojinro-server/pull/110?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saero-jinro) | Patch % | Lines |
|---|---|---|
| [...rojinro/api/lecture/application/LectureFacade.java](https://app.codecov.io/gh/saero-jinro/saerojinro-server/pull/110?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saero-jinro#diff-c2Flcm9qaW5yby1hcGkvc3JjL21haW4vamF2YS9nb29ybS9zYWVyb2ppbnJvL2FwaS9sZWN0dXJlL2FwcGxpY2F0aW9uL0xlY3R1cmVGYWNhZGUuamF2YQ==) | 77.77% | [2 Missing :warning: ](https://app.codecov.io/gh/saero-jinro/saerojinro-server/pull/110?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saero-jinro) |

:loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)
author_association: NONE