Repository: parking-10easy/ParkEZ
Pull request #104: Feat/parking lot cache
Author: login: Jangyounhyuk
Created: 2025-04-21T08:24:07Z | Merged: 2025-04-22T06:58:09Z

주차장 다건 조회 시 캐싱 적용
close #84 

Linked issue #84: [FEATURE] 주차장 다건 조회 시 redis를 사용하여 캐싱
## 📝 Issue
주차장 다건 조회 시 redis를 사용하여 캐싱

---

## 📄 설명

- 주차장 다건 조회 시 redis를 사용하여 캐싱
- 캐시를 활용하여 주차장 조회 응답 속도 향상

---

## ✅ 작업할 내용
- [ ] 주차장 다건 조회 시 redis를 사용하여 캐싱

---

## 🙋🏻 참고 자료

Diff:
diff --git a/src/main/java/com/parkez/auth/web/OwnerAuthController.java b/src/main/java/com/parkez/auth/web/OwnerAuthController.java
index 483a4c38..33070542 100644
--- a/src/main/java/com/parkez/auth/web/OwnerAuthController.java
+++ b/src/main/java/com/parkez/auth/web/OwnerAuthController.java
@@ -20,7 +20,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "오너 인증 API", description = "오너의 회원가입 및 로그인을 처리합니다.")
+@Tag(name = "01. 오너 인증 API", description = "오너의 회원가입 및 로그인을 처리합니다.")
 public class OwnerAuthController {
 
 	private final AuthService authService;
diff --git a/src/main/java/com/parkez/auth/web/SocialAuthController.java b/src/main/java/com/parkez/auth/web/SocialAuthController.java
index 61ce065e..90f35171 100644
--- a/src/main/java/com/parkez/auth/web/SocialAuthController.java
+++ b/src/main/java/com/parkez/auth/web/SocialAuthController.java
@@ -32,7 +32,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "소셜 로그인 API", description = " 소셜 로그인을 처리합니다.")
+@Tag(name = "03. 소셜 로그인 API", description = " 소셜 로그인을 처리합니다.")
 public class SocialAuthController {
 
 	private final SocialAuthService socialAuthService;
diff --git a/src/main/java/com/parkez/auth/web/TokenController.java b/src/main/java/com/parkez/auth/web/TokenController.java
index f4c24aae..fdad905b 100644
--- a/src/main/java/com/parkez/auth/web/TokenController.java
+++ b/src/main/java/com/parkez/auth/web/TokenController.java
@@ -18,7 +18,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "Token API", description = "인증 토큰 재발급 API")
+@Tag(name = "00. Token API", description = "인증 토큰 재발급 API")
 public class TokenController {
 
 	private final AuthService authService;
diff --git a/src/main/java/com/parkez/auth/web/UserAuthController.java b/src/main/java/com/parkez/auth/web/UserAuthController.java
index 5ce1cf41..c55acd0b 100644
--- a/src/main/java/com/parkez/auth/web/UserAuthController.java
+++ b/src/main/java/com/parkez/auth/web/UserAuthController.java
@@ -20,7 +20,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "일반 사용자 인증 API", description = "일반 사용자의 회원가입 및 로그인을 처리합니다.")
+@Tag(name = "02. 일반 사용자 인증 API", description = "일반 사용자의 회원가입 및 로그인을 처리합니다.")
 public class UserAuthController {
 
 	private final AuthService authService;
diff --git a/src/main/java/com/parkez/common/config/RedisConfig.java b/src/main/java/com/parkez/common/config/RedisConfig.java
new file mode 100644
index 00000000..7a7701e9
--- /dev/null
+++ b/src/main/java/com/parkez/common/config/RedisConfig.java
@@ -0,0 +1,34 @@
+package com.parkez.common.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+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.StringRedisSerializer;
+
+import java.time.Duration;
+
+@Configuration
+public class RedisConfig {
+
+    public static final Duration PARKING_LOT_SEARCH_TTL = Duration.ofMinutes(5);
+
+    @Bean
+    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(factory);
+        template.setKeySerializer(new StringRedisSerializer());
+
+        ObjectMapper redisObjectMapper = new ObjectMapper();
+        redisObjectMapper.registerModule(new JavaTimeModule()); // LocalDateTime, LocalDate, LocalTime 지원
+        redisObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // 사람이 읽기 쉬운 ISO-8601 형식 유지
+
+        template.setValueSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper));
+
+        return template;
+    }
+}
diff --git a/src/main/java/com/parkez/common/config/SwaggerConfig.java b/src/main/java/com/parkez/common/config/SwaggerConfig.java
index 5a109b6f..26bf10b5 100644
--- a/src/main/java/com/parkez/common/config/SwaggerConfig.java
+++ b/src/main/java/com/parkez/common/config/SwaggerConfig.java
@@ -1,11 +1,5 @@
 package com.parkez.common.config;
 
-import java.util.Collections;
-
-import org.springdoc.core.models.GroupedOpenApi;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
 import io.swagger.v3.oas.annotations.OpenAPIDefinition;
 import io.swagger.v3.oas.annotations.info.Info;
 import io.swagger.v3.oas.annotations.servers.Server;
@@ -14,6 +8,11 @@
 import io.swagger.v3.oas.models.security.SecurityRequirement;
 import io.swagger.v3.oas.models.security.SecurityScheme;
 import lombok.RequiredArgsConstructor;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Collections;
 
 @Configuration
 @OpenAPIDefinition(
diff --git a/src/main/java/com/parkez/image/web/ImageController.java b/src/main/java/com/parkez/image/web/ImageController.java
index 8bbe9c4a..21768d5d 100644
--- a/src/main/java/com/parkez/image/web/ImageController.java
+++ b/src/main/java/com/parkez/image/web/ImageController.java
@@ -16,7 +16,7 @@
 @RestController
 @RequestMapping("/api/v1/images")
 @RequiredArgsConstructor
-@Tag(name = "이미지 업로드/수정/삭제 API", description = "AWS S3와 연동된 이미지 업로드/수정/삭제 기능입니다.")
+@Tag(name = "12. 이미지 업로드/수정/삭제 API", description = "AWS S3와 연동된 이미지 업로드/수정/삭제 기능입니다.")
 public class ImageController {
 
     private final ImageService imageService;
diff --git a/src/main/java/com/parkez/parkinglot/dto/response/ParkingLotSearchResponse.java b/src/main/java/com/parkez/parkinglot/dto/response/ParkingLotSearchResponse.java
index e67f9dca..524cf5d2 100644
--- a/src/main/java/com/parkez/parkinglot/dto/response/ParkingLotSearchResponse.java
+++ b/src/main/java/com/parkez/parkinglot/dto/response/ParkingLotSearchResponse.java
@@ -7,8 +7,10 @@
 import com.parkez.parkinglot.domain.enums.ParkingLotStatus;
 import com.parkez.parkinglot.domain.enums.SourceType;
 import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AccessLevel;
 import lombok.Builder;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 
 import java.math.BigDecimal;
 import java.time.LocalTime;
@@ -16,9 +18,9 @@
 
 @Getter
 @Schema(description = "주차장 조회 응답 DTO")
+@NoArgsConstructor
 public class ParkingLotSearchResponse {
 
-
     @Schema(description = "주차장 ID", example = "1")
     private Long parkingLotId;
 
diff --git a/src/main/java/com/parkez/parkinglot/rediscache/ParkingLotSearchRedisKey.java b/src/main/java/com/parkez/parkinglot/rediscache/ParkingLotSearchRedisKey.java
new file mode 100644
index 00000000..fa5d756b
--- /dev/null
+++ b/src/main/java/com/parkez/parkinglot/rediscache/ParkingLotSearchRedisKey.java
@@ -0,0 +1,19 @@
+package com.parkez.parkinglot.rediscache;
+
+public class ParkingLotSearchRedisKey {
+
+    public static String generateRedisKey(String name, String address,
+                                          Double userLatitude, Double userLongitude, Integer radiusInMeters,
+                                          int page, int size
+    ) {
+        return String.format("parking-lots-name:%s-addr:%s-lat:%s-lng:%s-radius:%s-page:%d-size:%d",
+                name != null ? "val:" + name : "null",
+                address != null ? "val:" + address : "null",
+                userLatitude != null ? (int) (userLatitude * 1000) : "null", // 위도/경도는 0.001 단위로 라운딩 (약 100m 단위)
+                userLongitude != null ? (int) (userLongitude * 1000) : "null", // 위도/경도는 0.001 단위로 라운딩 (약 100m 단위)
+                radiusInMeters != null ? radiusInMeters : "null",
+                page,
+                size
+        );
+    }
+}
diff --git a/src/main/java/com/parkez/parkinglot/rediscache/RestPage.java b/src/main/java/com/parkez/parkinglot/rediscache/RestPage.java
new file mode 100644
index 00000000..51105c59
--- /dev/null
+++ b/src/main/java/com/parkez/parkinglot/rediscache/RestPage.java
@@ -0,0 +1,40 @@
+package com.parkez.parkinglot.rediscache;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RestPage<T> extends PageImpl<T> {
+
+    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+    public RestPage(
+            @JsonProperty("content") List<T> content,
+            @JsonProperty("number") int page,
+            @JsonProperty("size") int size,
+            @JsonProperty("totalElements") long totalElements
+    ) {
+        super(content, PageRequest.of(page, size), totalElements);
+    }
+
+    @JsonIgnore
+    public Pageable getPageable() {
+        return super.getPageable();
+    }
+
+    public static <T> RestPage<T> from(Page<T> page) {
+        return new RestPage<>(
+                page.getContent(),
+                page.getNumber(),
+                page.getSize(),
+                page.getTotalElements()
+        );
+    }
+}
diff --git a/src/main/java/com/parkez/parkinglot/service/ParkingLotService.java b/src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
index f463bfeb..d5807286 100644
--- a/src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
+++ b/src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
@@ -1,5 +1,8 @@
 package com.parkez.parkinglot.service;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.parkez.common.config.RedisConfig;
 import com.parkez.common.dto.request.PageRequest;
 import com.parkez.common.exception.ParkingEasyException;
 import com.parkez.common.principal.AuthUser;
@@ -18,21 +21,26 @@
 import com.parkez.parkinglot.dto.response.ParkingLotResponse;
 import com.parkez.parkinglot.dto.response.ParkingLotSearchResponse;
 import com.parkez.parkinglot.exception.ParkingLotErrorCode;
+import com.parkez.parkinglot.rediscache.ParkingLotSearchRedisKey;
+import com.parkez.parkinglot.rediscache.RestPage;
 import com.parkez.parkingzone.domain.entity.ParkingZone;
 import com.parkez.parkingzone.domain.enums.ParkingZoneStatus;
 import com.parkez.parkingzone.service.ParkingZoneReader;
 import com.parkez.user.domain.entity.User;
 import com.parkez.user.service.UserReader;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.data.domain.Page;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.util.List;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class ParkingLotService {
@@ -42,6 +50,8 @@ public class ParkingLotService {
     private final UserReader userReader;
     private final KakaoGeocodeClient kakaoGeocodeClient;
     private final ParkingZoneReader parkingZoneReader;
+    private final RedisTemplate<String, Object> redisTemplate;
+    private final ObjectMapper redisObjectMapper;
 
     @Value("${parking-lot.default-image-url}")
     private String defaultParkingLotImageUrl;
@@ -82,9 +92,36 @@ public ParkingLotResponse createParkingLot(AuthUser authUser, ParkingLotRequest
 
     // 주차장 다건 조회 (이름, 주소)
     public Page<ParkingLotSearchResponse> searchParkingLotsByConditions(ParkingLotSearchRequest request, PageRequest pageRequest) {
-        return parkingLotReader.searchParkingLotsByConditions(request.getName(), request.getAddress(),
+        String redisKey = ParkingLotSearchRedisKey.generateRedisKey(request.getName(), request.getAddress(),
+                request.getUserLatitude(), request.getUserLongitude(), request.getRadiusInMeters(),
+                pageRequest.getPage() - 1, pageRequest.getSize());
+
+        // 캐시 조회
+        Object cache = redisTemplate.opsForValue().get(redisKey);
+        if (cache != null) {
+            log.info("Redis cache hit for key : {}", redisKey);
+            // 역직렬화 타입 명시
+            try {
+                return redisObjectMapper.convertValue(cache, new TypeReference<RestPage<ParkingLotSearchResponse>>() {
+                });
+
+            } catch (IllegalArgumentException e) {
+                log.error("Redis 역직렬화 오류 : {}", e.getMessage());
+                redisTemplate.delete(redisKey);
+            }
+        } else {
+            log.info("Redis cache miss for key : {}", redisKey);
+        }
+
+        // DB 조회
+        Page<ParkingLotSearchResponse> page = parkingLotReader.searchParkingLotsByConditions(request.getName(), request.getAddress(),
                 request.getUserLatitude(), request.getUserLongitude(), request.getRadiusInMeters(),
                 pageRequest.getPage(), pageRequest.getSize());
+
+        // 캐시 저장
+        redisTemplate.opsForValue().set(redisKey, RestPage.from(page), RedisConfig.PARKING_LOT_SEARCH_TTL);
+
+        return page;
     }
 
     // 주차장 단건 조회
diff --git a/src/main/java/com/parkez/parkinglot/web/ParkingLotController.java b/src/main/java/com/parkez/parkinglot/web/ParkingLotController.java
index e1fa636a..3bf9c4e3 100644
--- a/src/main/java/com/parkez/parkinglot/web/ParkingLotController.java
+++ b/src/main/java/com/parkez/parkinglot/web/ParkingLotController.java
@@ -28,7 +28,7 @@
 @RequiredArgsConstructor
 @Secured(UserRole.Authority.OWNER)
 @CheckMemberStatus
-@Tag(name = "주차장 관리 API", description = "주차장 관리 및 조회 API입니다.")
+@Tag(name = "05. 주차장 관리 API", description = "주차장 관리 및 조회 API입니다.")
 public class ParkingLotController {
 
     private final ParkingLotService parkingLotService;
diff --git a/src/main/java/com/parkez/parkingzone/web/ParkingZoneController.java b/src/main/java/com/parkez/parkingzone/web/ParkingZoneController.java
index 97a278fb..1c4bc434 100644
--- a/src/main/java/com/parkez/parkingzone/web/ParkingZoneController.java
+++ b/src/main/java/com/parkez/parkingzone/web/ParkingZoneController.java
@@ -27,7 +27,7 @@
 @RequestMapping("/api")
 @RequiredArgsConstructor
 @Secured(UserRole.Authority.OWNER)
-@Tag(name = "주차공간 API")
+@Tag(name = "06. 주차공간 API")
 @CheckMemberStatus
 public class ParkingZoneController {
 
diff --git a/src/main/java/com/parkez/payment/web/PaymentController.java b/src/main/java/com/parkez/payment/web/PaymentController.java
index 39154612..a5e63c6f 100644
--- a/src/main/java/com/parkez/payment/web/PaymentController.java
+++ b/src/main/java/com/parkez/payment/web/PaymentController.java
@@ -24,7 +24,7 @@
 @RestController
 @RequestMapping("/api/v1")
 @RequiredArgsConstructor
-@Tag(name = "결제 - 요청/조회 API", description = "결제 요청 생성, 결제 내역 조회 기능입니다.")
+@Tag(name = "08. 결제 - 요청/조회 API", description = "결제 요청 생성, 결제 내역 조회 기능입니다.")
 public class PaymentController {
 
     private final PaymentService paymentService;
diff --git a/src/main/java/com/parkez/payment/web/PaymentResultController.java b/src/main/java/com/parkez/payment/web/PaymentResultController.java
index b4fcd884..17b93727 100644
--- a/src/main/java/com/parkez/payment/web/PaymentResultController.java
+++ b/src/main/java/com/parkez/payment/web/PaymentResultController.java
@@ -20,7 +20,7 @@
 @RestController
 @RequestMapping("/api/v1/payment")
 @RequiredArgsConstructor
-@Tag(name = "결제 - 승인/실패 API", description = "결제 승인/실패 기능입니다. 실제로는 클라이언트 측에서 실행됩니다.")
+@Tag(name = "09. 결제 - 승인/실패 API", description = "결제 승인/실패 기능입니다. 실제로는 클라이언트 측에서 실행됩니다.")
 public class PaymentResultController {
 
     private final PaymentService paymentService;
diff --git a/src/main/java/com/parkez/reservation/domain/entity/Reservation.java b/src/main/java/com/parkez/reservation/domain/entity/Reservation.java
index a65ce09b..7e5fb8ef 100644
--- a/src/main/java/com/parkez/reservation/domain/entity/Reservation.java
+++ b/src/main/java/com/parkez/reservation/domain/entity/Reservation.java
@@ -105,7 +105,6 @@ public void confirm(){
         this.status = ReservationStatus.CONFIRMED;
     }
 
-
     public boolean isTimeout(LocalDateTime currentTime, long timeoutMinutes) {
         long elapsedMillis = Duration.between(this.getCreatedAt(), currentTime).toMillis();
         return elapsedMillis > timeoutMinutes * 60 * 1000;
diff --git a/src/main/java/com/parkez/reservation/web/ReservationController.java b/src/main/java/com/parkez/reservation/web/ReservationController.java
index b0b4ba63..3228f5b6 100644
--- a/src/main/java/com/parkez/reservation/web/ReservationController.java
+++ b/src/main/java/com/parkez/reservation/web/ReservationController.java
@@ -22,7 +22,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "주차공간 예약 API", description = "주차공간에 대한 예약 API 입니다.")
+@Tag(name = "07. 주차공간 예약 API", description = "주차공간에 대한 예약 API 입니다.")
 @Secured(UserRole.Authority.USER)
 @CheckMemberStatus
 public class ReservationController {
diff --git a/src/main/java/com/parkez/review/web/ReviewController.java b/src/main/java/com/parkez/review/web/ReviewController.java
index d0295c6b..36592f0b 100644
--- a/src/main/java/com/parkez/review/web/ReviewController.java
+++ b/src/main/java/com/parkez/review/web/ReviewController.java
@@ -21,7 +21,7 @@
 @RestController
 @RequestMapping("/api")
 @RequiredArgsConstructor
-@Tag(name = "리뷰 API")
+@Tag(name = "10. 리뷰 API")
 @CheckMemberStatus
 public class ReviewController {
 
diff --git a/src/main/java/com/parkez/user/web/UserController.java b/src/main/java/com/parkez/user/web/UserController.java
index cd437f0a..e78ba63f 100644
--- a/src/main/java/com/parkez/user/web/UserController.java
+++ b/src/main/java/com/parkez/user/web/UserController.java
@@ -30,7 +30,7 @@
 @RequestMapping("/api")
 @RequiredArgsConstructor
 @CheckMemberStatus
-@Tag(name = "유저 API", description = "유저 프로필 조회, 수정, 탈퇴 관련 API")
+@Tag(name = "04. 유저 API", description = "유저 프로필 조회, 수정, 탈퇴 관련 API")
 public class UserController {
 
 	private final UserService userService;
diff --git a/src/test/java/com/parkez/parkinglot/service/ParkingLotServiceTest.java b/src/test/java/com/parkez/parkinglot/service/ParkingLotServiceTest.java
index 193b1dc1..3fa6e7f8 100644
--- a/src/test/java/com/parkez/parkinglot/service/ParkingLotServiceTest.java
+++ b/src/test/java/com/parkez/parkinglot/service/ParkingLotServiceTest.java
@@ -1,5 +1,7 @@
 package com.parkez.parkinglot.service;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.parkez.common.dto.request.PageRequest;
 import com.parkez.common.exception.ParkingEasyException;
 import com.parkez.common.principal.AuthUser;
@@ -16,8 +18,8 @@
 import com.parkez.parkinglot.dto.response.ParkingLotResponse;
 import com.parkez.parkinglot.dto.response.ParkingLotSearchResponse;
 import com.parkez.parkinglot.exception.ParkingLotErrorCode;
-import com.parkez.parkingzone.domain.entity.ParkingZone;
-import com.parkez.parkingzone.domain.enums.ParkingZoneStatus;
+import com.parkez.parkinglot.rediscache.ParkingLotSearchRedisKey;
+import com.parkez.parkinglot.rediscache.RestPage;
 import com.parkez.parkingzone.service.ParkingZoneReader;
 import com.parkez.user.domain.entity.User;
 import com.parkez.user.domain.enums.UserRole;
@@ -31,8 +33,11 @@
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageImpl;
 import org.springframework.data.domain.Pageable;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
 import org.springframework.test.util.ReflectionTestUtils;
 
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -41,6 +46,7 @@
 import static org.assertj.core.api.Assertions.tuple;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 @ExtendWith(MockitoExtension.class)
@@ -58,6 +64,15 @@ public class ParkingLotServiceTest {
     @Mock
     private KakaoGeocodeClient kakaoGeocodeClient;
 
+    @Mock
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Mock
+    private ValueOperations<String, Object> valueOperations;
+
+    @Mock
+    private ObjectMapper redisObjectMapper;
+
     @Mock
     private UserReader userReader;
 
@@ -149,6 +164,11 @@ private MyParkingLotSearchResponse getMyParkingLotResponse2() {
                 .build();
     }
 
+    private String generateRedisKey(ParkingLotSearchRequest request) {
+        return ParkingLotSearchRedisKey.generateRedisKey(request.getName(), request.getAddress(),
+                request.getUserLatitude(), request.getUserLongitude(), request.getRadiusInMeters(),
+                pageRequest.getPage() - 1, pageRequest.getSize());
+    }
 
     @Nested
     class CreateParkingLot {
@@ -218,223 +238,438 @@ class CreateParkingLot {
     @Nested
     class searchParkingLotsByConditions {
 
-        @Test
-        void 검색_조건이_없을_때_주차장을_조회한다() {
-            // given
-            ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
-            ParkingLotSearchResponse searchResponse2 = getParkingLotSearchResponse2();
-
-            List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1, searchResponse2);
-            Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
-
-            ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
-                    .name(null)
-                    .address(null)
-                    .userLatitude(null)
-                    .userLongitude(null)
-                    .radiusInMeters(null)
-                    .build();
-
-            when(parkingLotReader.searchParkingLotsByConditions(
-                    searchRequest.getName(), searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
-            )).thenReturn(page);
-
-            // when
-            Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
-
-            // then
-            assertNotNull(result);
-            assertEquals(2, result.getTotalElements());
-            assertThat(result.getContent())
-                    .extracting("name", "address")
-                    .containsExactly(
-                            tuple(searchResponse1.getName(), searchResponse1.getAddress()),
-                            tuple(searchResponse2.getName(), searchResponse2.getAddress())
-                    );
-
-            verify(parkingLotReader).searchParkingLotsByConditions(
-                    searchRequest.getName(),
-                    searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(),
-                    searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(),
-                    pageRequest.getPage(),
-                    pageRequest.getSize()
-            );
-        }
-
-        @Test
-        void 이름으로_주차장을_조회한다() {
-            // given
-            ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
-            List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
-            Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
-
-            ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
-                    .name(searchResponse1.getName())
-                    .address(null)
-                    .userLatitude(null)
-                    .userLongitude(null)
-                    .radiusInMeters(null)
-                    .build();
-
-            when(parkingLotReader.searchParkingLotsByConditions(
-                    searchRequest.getName(), searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
-            )).thenReturn(page);
-
-            // when
-            Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
-
-            // then
-            assertNotNull(result);
-            assertEquals(1, result.getTotalElements());
-            assertThat(result.getContent())
-                    .extracting("name", "address")
-                    .containsExactly(
-                            tuple(searchResponse1.getName(), searchResponse1.getAddress())
-                    );
-            verify(parkingLotReader).searchParkingLotsByConditions(
-                    searchRequest.getName(),
-                    searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(),
-                    searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(),
-                    pageRequest.getPage(),
-                    pageRequest.getSize()
-            );
-        }
-
-        @Test
-        void 주소로_주차장을_조회한다() {
-            // given
-            ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
-            List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
-            Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
-
-            ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
-                    .name(null)
-                    .address(searchResponse1.getAddress())
-                    .userLatitude(null)
-                    .userLongitude(null)
-                    .radiusInMeters(null)
-                    .build();
-
-            when(parkingLotReader.searchParkingLotsByConditions(
-                    searchRequest.getName(), searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
-            )).thenReturn(page);
-
-            // when
-            Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
-
-            // then
-            assertNotNull(result);
-            assertEquals(1, result.getTotalElements());
-            assertThat(result.getContent())
-                    .extracting("name", "address")
-                    .containsExactly(
-                            tuple(searchResponse1.getName(), searchResponse1.getAddress())
-                    );
-            verify(parkingLotReader).searchParkingLotsByConditions(
-                    searchRequest.getName(),
-                    searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(),
-                    searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(),
-                    pageRequest.getPage(),
-                    pageRequest.getSize()
-            );
-        }
-
-        @Test
-        void 이름과_주소로_주차장을_조회한다() {
-            // given
-            ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
-            List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
-            Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
-
-            ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
-                    .name(searchResponse1.getName())
-                    .address(searchResponse1.getAddress())
-                    .userLatitude(null)
-                    .userLongitude(null)
-                    .radiusInMeters(null)
-                    .build();
-
-            when(parkingLotReader.searchParkingLotsByConditions(
-                    searchRequest.getName(), searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
-            )).thenReturn(page);
-
-            // when
-            Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
-
-            // then
-            assertNotNull(result);
-            assertEquals(1, result.getTotalElements());
-            assertThat(result.getContent())
-                    .extracting("name", "address")
-                    .containsExactly(
-                            tuple(searchResponse1.getName(), searchResponse1.getAddress())
-                    );
-            verify(parkingLotReader).searchParkingLotsByConditions(
-                    searchRequest.getName(),
-                    searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(),
-                    searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(),
-                    pageRequest.getPage(),
-                    pageRequest.getSize()
-            );
+        @Nested
+        class RedisKeyHit {
+
+            @Test
+            void 검색_조건이_없을_때_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                ParkingLotSearchResponse searchResponse2 = getParkingLotSearchResponse2();
+
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1, searchResponse2);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+                RestPage<ParkingLotSearchResponse> cachedPage = RestPage.from(page);
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(null)
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(cachedPage);
+                when(redisObjectMapper.convertValue(any(Object.class), any(TypeReference.class))).thenReturn(cachedPage);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(2, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress()),
+                                tuple(searchResponse2.getName(), searchResponse2.getAddress())
+                        );
+            }
+
+            @Test
+            void 이름으로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+                RestPage<ParkingLotSearchResponse> cachedPage = RestPage.from(page);
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(searchResponse1.getName())
+                        .address(null)
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(cachedPage);
+                when(redisObjectMapper.convertValue(any(Object.class), any(TypeReference.class))).thenReturn(cachedPage);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+            }
+
+            @Test
+            void 주소로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+                RestPage<ParkingLotSearchResponse> cachedPage = RestPage.from(page);
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(searchResponse1.getAddress())
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(cachedPage);
+                when(redisObjectMapper.convertValue(any(Object.class), any(TypeReference.class))).thenReturn(cachedPage);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+            }
+
+            @Test
+            void 이름과_주소로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+                RestPage<ParkingLotSearchResponse> cachedPage = RestPage.from(page);
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(searchResponse1.getName())
+                        .address(searchResponse1.getAddress())
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(cachedPage);
+                when(redisObjectMapper.convertValue(any(Object.class), any(TypeReference.class))).thenReturn(cachedPage);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+            }
+
+            @Test
+            void 이름과_주소_없이_사용자_위치로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+                RestPage<ParkingLotSearchResponse> cachedPage = RestPage.from(page);
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(null)
+                        .userLatitude(37.500066200)
+                        .userLongitude(127.032926912)
+                        .radiusInMeters(10000)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(cachedPage);
+                when(redisObjectMapper.convertValue(any(Object.class), any(TypeReference.class))).thenReturn(cachedPage);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+            }
         }
 
-        @Test
-        void 이름과_주소_없이_사용자_위치로_주차장을_조회한다() {
-            // given
-            ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
-            List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
-            Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
-
-            ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
-                    .name(null)
-                    .address(null)
-                    .userLatitude(37.500066200)
-                    .userLongitude(127.032926912)
-                    .radiusInMeters(10000)
-                    .build();
-
-            when(parkingLotReader.searchParkingLotsByConditions(
-                    searchRequest.getName(), searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
-            )).thenReturn(page);
-
-            // when
-            Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
-
-            // then
-            assertNotNull(result);
-            assertEquals(1, result.getTotalElements());
-            assertThat(result.getContent())
-                    .extracting("name", "address")
-                    .containsExactly(
-                            tuple(searchResponse1.getName(), searchResponse1.getAddress())
-                    );
-            verify(parkingLotReader).searchParkingLotsByConditions(
-                    searchRequest.getName(),
-                    searchRequest.getAddress(),
-                    searchRequest.getUserLatitude(),
-                    searchRequest.getUserLongitude(),
-                    searchRequest.getRadiusInMeters(),
-                    pageRequest.getPage(),
-                    pageRequest.getSize()
-            );
+        @Nested
+        class RedisKeyMiss {
+
+            @Test
+            void 검색_조건이_없을_때_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                ParkingLotSearchResponse searchResponse2 = getParkingLotSearchResponse2();
+
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1, searchResponse2);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(null)
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(null);
+                doNothing().when(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+                when(parkingLotReader.searchParkingLotsByConditions(
+                        searchRequest.getName(), searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
+                )).thenReturn(page);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(2, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress()),
+                                tuple(searchResponse2.getName(), searchResponse2.getAddress())
+                        );
+                verify(parkingLotReader).searchParkingLotsByConditions(
+                        searchRequest.getName(),
+                        searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(),
+                        searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(),
+                        pageRequest.getPage(),
+                        pageRequest.getSize()
+                );
+                verify(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+            }
+
+            @Test
+            void 이름으로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(searchResponse1.getName())
+                        .address(null)
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(null);
+                doNothing().when(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+                when(parkingLotReader.searchParkingLotsByConditions(
+                        searchRequest.getName(), searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
+                )).thenReturn(page);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+                verify(parkingLotReader).searchParkingLotsByConditions(
+                        searchRequest.getName(),
+                        searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(),
+                        searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(),
+                        pageRequest.getPage(),
+                        pageRequest.getSize()
+                );
+                verify(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+            }
+
+            @Test
+            void 주소로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(searchResponse1.getAddress())
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(null);
+                doNothing().when(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+                when(parkingLotReader.searchParkingLotsByConditions(
+                        searchRequest.getName(), searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
+                )).thenReturn(page);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+                verify(parkingLotReader).searchParkingLotsByConditions(
+                        searchRequest.getName(),
+                        searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(),
+                        searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(),
+                        pageRequest.getPage(),
+                        pageRequest.getSize()
+                );
+                verify(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+            }
+
+            @Test
+            void 이름과_주소로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(searchResponse1.getName())
+                        .address(searchResponse1.getAddress())
+                        .userLatitude(null)
+                        .userLongitude(null)
+                        .radiusInMeters(null)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(null);
+                doNothing().when(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+                when(parkingLotReader.searchParkingLotsByConditions(
+                        searchRequest.getName(), searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
+                )).thenReturn(page);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+                verify(parkingLotReader).searchParkingLotsByConditions(
+                        searchRequest.getName(),
+                        searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(),
+                        searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(),
+                        pageRequest.getPage(),
+                        pageRequest.getSize()
+                );
+                verify(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+            }
+
+            @Test
+            void 이름과_주소_없이_사용자_위치로_주차장을_조회한다() {
+                // given
+                ParkingLotSearchResponse searchResponse1 = getParkingLotSearchResponse1();
+                List<ParkingLotSearchResponse> responses = Arrays.asList(searchResponse1);
+                Page<ParkingLotSearchResponse> page = new PageImpl<>(responses, pageable, responses.size());
+
+                ParkingLotSearchRequest searchRequest = ParkingLotSearchRequest.builder()
+                        .name(null)
+                        .address(null)
+                        .userLatitude(37.500066200)
+                        .userLongitude(127.032926912)
+                        .radiusInMeters(10000)
+                        .build();
+
+                String redisKey = generateRedisKey(searchRequest);
+
+                when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+                when(valueOperations.get(redisKey)).thenReturn(null);
+                doNothing().when(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+                when(parkingLotReader.searchParkingLotsByConditions(
+                        searchRequest.getName(), searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(), searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(), pageRequest.getPage(), pageRequest.getSize()
+                )).thenReturn(page);
+
+                // when
+                Page<ParkingLotSearchResponse> result = parkingLotService.searchParkingLotsByConditions(searchRequest, pageRequest);
+
+                // then
+                assertNotNull(result);
+                assertEquals(1, result.getTotalElements());
+                assertThat(result.getContent())
+                        .extracting("name", "address")
+                        .containsExactly(
+                                tuple(searchResponse1.getName(), searchResponse1.getAddress())
+                        );
+                verify(parkingLotReader).searchParkingLotsByConditions(
+                        searchRequest.getName(),
+                        searchRequest.getAddress(),
+                        searchRequest.getUserLatitude(),
+                        searchRequest.getUserLongitude(),
+                        searchRequest.getRadiusInMeters(),
+                        pageRequest.getPage(),
+                        pageRequest.getSize()
+                );
+                verify(valueOperations).set(eq(redisKey), any(Object.class), any(Duration.class));
+            }
         }
     }
 


Reviews:
user: login: codingTrip-IT
body: 고생하셨습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T02:38:15Z
user: login: MythologyDevSeoyeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T03:01:03Z
user: login: Jangyounhyuk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T05:14:31Z
user: login: Jangyounhyuk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T05:21:35Z
user: login: minjonyyy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T06:03:16Z
user: login: MythologyDevSeoyeon
body: 수고하셨습니다!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T06:03:53Z

Review comments:
diff_hunk: @@ -37,6 +45,10 @@ public class ParkingLotService {
     private final ParkingLotReader parkingLotReader;
     private final UserReader userReader;
     private final KakaoGeocodeClient kakaoGeocodeClient;
+    private final RedisTemplate<String, Object> redisTemplate;
+    private final ObjectMapper redisObjectMapper;
+
+    private static final long CACHE_TTL = 5L;
path: src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
user: login: MythologyDevSeoyeon
body: redis ttl을 config가 아닌 service측에 설정한 이유가 있을까요?
ttl은 캐시 정책이라 service 보다 config에 있는게 적합해 보입니다!
created_at: 2025-04-22T02:56:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,9 +89,35 @@ public ParkingLotResponse createParkingLot(AuthUser authUser, ParkingLotRequest
 
     // 주차장 다건 조회 (이름, 주소)
     public Page<ParkingLotSearchResponse> searchParkingLotsByConditions(ParkingLotSearchRequest request, PageRequest pageRequest) {
-        return parkingLotReader.searchParkingLotsByConditions(request.getName(), request.getAddress(),
+        String redisKey = ParkingLotSearchRedisKey.generateRedisKey(request.getName(), request.getAddress(),
+                request.getUserLatitude(), request.getUserLongitude(), request.getRadiusInMeters(),
+                pageRequest.getPage() - 1, pageRequest.getSize());
+
+        // 캐시 조회
+        Object cache = redisTemplate.opsForValue().get(redisKey);
+        if (cache != null) {
+            log.info("Redis cache hit for key : {}", redisKey);
+            // 역직렬화 타입 명시
+            try {
+                return redisObjectMapper.convertValue(cache, new TypeReference<RestPage<ParkingLotSearchResponse>>() {
+                });
+
+            } catch (IllegalArgumentException e) {
+                log.error("Redis 역직렬화 오류 : {}", e.getMessage());
path: src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
user: login: MythologyDevSeoyeon
body: 역직렬화 오류가 발생했을 때, 잘못된 데이터 처리는 어떻게 되나요?
없다면 제거해주는 로직이 필요할 것 같습니다!
created_at: 2025-04-22T03:00:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,6 +45,10 @@ public class ParkingLotService {
     private final ParkingLotReader parkingLotReader;
     private final UserReader userReader;
     private final KakaoGeocodeClient kakaoGeocodeClient;
+    private final RedisTemplate<String, Object> redisTemplate;
+    private final ObjectMapper redisObjectMapper;
+
+    private static final long CACHE_TTL = 5L;
path: src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
user: login: Jangyounhyuk
body: redisTemplate을 사용하여 캐시를 적용하는 것의 장점 중 하나가 redisKey마다 별도의 TTL을 적용할 수 있는 점이 있는데 config에서 ttl을 적용하면 정적인 ttl값을 갖는다고 생각했습니다.
ttl 상수값은 config에서 선언 및 관리하고 service에서 key에 대한 ttl을 설정할 때 config에서 값을 불러오는 방식으로 refactoring 하겠습니다!
created_at: 2025-04-22T05:14:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,9 +89,35 @@ public ParkingLotResponse createParkingLot(AuthUser authUser, ParkingLotRequest
 
     // 주차장 다건 조회 (이름, 주소)
     public Page<ParkingLotSearchResponse> searchParkingLotsByConditions(ParkingLotSearchRequest request, PageRequest pageRequest) {
-        return parkingLotReader.searchParkingLotsByConditions(request.getName(), request.getAddress(),
+        String redisKey = ParkingLotSearchRedisKey.generateRedisKey(request.getName(), request.getAddress(),
+                request.getUserLatitude(), request.getUserLongitude(), request.getRadiusInMeters(),
+                pageRequest.getPage() - 1, pageRequest.getSize());
+
+        // 캐시 조회
+        Object cache = redisTemplate.opsForValue().get(redisKey);
+        if (cache != null) {
+            log.info("Redis cache hit for key : {}", redisKey);
+            // 역직렬화 타입 명시
+            try {
+                return redisObjectMapper.convertValue(cache, new TypeReference<RestPage<ParkingLotSearchResponse>>() {
+                });
+
+            } catch (IllegalArgumentException e) {
+                log.error("Redis 역직렬화 오류 : {}", e.getMessage());
path: src/main/java/com/parkez/parkinglot/service/ParkingLotService.java
user: login: Jangyounhyuk
body: 캐시 삭제하는 로직 추가하였습니다!
created_at: 2025-04-22T05:21:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-21T08:25:48Z
body: ### Test Coverage Report
|Overall Project|70.02% **`-0.18%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|92.44%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/9bd78a08b6202f5eb88b91f2e7222a2bf7e72d8d/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/9bd78a08b6202f5eb88b91f2e7222a2bf7e72d8d/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|95.75% **`-2.29%`**|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/9bd78a08b6202f5eb88b91f2e7222a2bf7e72d8d/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.59% **`-4.41%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/9bd78a08b6202f5eb88b91f2e7222a2bf7e72d8d/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-21T12:02:51Z
body: ### Test Coverage Report
|Overall Project|70.03% **`-0.18%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|92.53%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/452cdc7230c7aa94dce74c940f38267b4dcc72cb/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/452cdc7230c7aa94dce74c940f38267b4dcc72cb/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|95.75% **`-2.29%`**|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/452cdc7230c7aa94dce74c940f38267b4dcc72cb/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.71% **`-4.29%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/452cdc7230c7aa94dce74c940f38267b4dcc72cb/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-22T05:03:18Z
body: ### Test Coverage Report
|Overall Project|70.03% **`-0.18%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|92.53%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/c0029d6b79ed27fa9384e392d6cad97d701a1fc8/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/c0029d6b79ed27fa9384e392d6cad97d701a1fc8/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|95.75% **`-2.29%`**|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/c0029d6b79ed27fa9384e392d6cad97d701a1fc8/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.71% **`-4.29%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/c0029d6b79ed27fa9384e392d6cad97d701a1fc8/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-22T05:26:30Z
body: ### Test Coverage Report
|Overall Project|69.97% **`-0.26%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|89.89%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/b207e0f959cee048b5b9a64a7aa56e5f953426a5/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/b207e0f959cee048b5b9a64a7aa56e5f953426a5/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.71% **`-4.29%`**|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/b207e0f959cee048b5b9a64a7aa56e5f953426a5/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|94.19% **`-3.87%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/b207e0f959cee048b5b9a64a7aa56e5f953426a5/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-22T06:23:41Z
body: ### Test Coverage Report
|Overall Project|70.62% **`-0.25%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|89.89%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/881642be1d54fbc36705a1ec2cef03b70fcaac8b/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/881642be1d54fbc36705a1ec2cef03b70fcaac8b/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.71% **`-4.29%`**|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/881642be1d54fbc36705a1ec2cef03b70fcaac8b/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|88.45% **`-3.38%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/881642be1d54fbc36705a1ec2cef03b70fcaac8b/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-22T06:55:22Z
body: ### Test Coverage Report
|Overall Project|70.62% **`-0.25%`**|:green_apple:|
|:-|:-|:-:|
|Files changed|89.89%|:green_apple:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Reservation.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fdomain%2Fentity%2FReservation.java)|98.29%|:green_apple:|
|[ParkingLotSearchRedisKey.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FParkingLotSearchRedisKey.java)|95.71% **`-4.29%`**|:green_apple:|
|[ParkingLotService.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fservice%2FParkingLotService.java)|88.45% **`-3.38%`**|:green_apple:|
|[RestPage.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Frediscache%2FRestPage.java)|86.96% **`-13.04%`**|:green_apple:|
|[PaymentResultController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fpayment%2Fweb%2FPaymentResultController.java)|19.05%|:green_apple:|
|[PaymentController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fpayment%2Fweb%2FPaymentController.java)|16%|:green_apple:|
|[ImageController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fimage%2Fweb%2FImageController.java)|0%|:green_apple:|
|[ReviewController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freview%2Fweb%2FReviewController.java)|0%|:green_apple:|
|[ParkingLotController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkinglot%2Fweb%2FParkingLotController.java)|0%|:green_apple:|
|[UserAuthController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fauth%2Fweb%2FUserAuthController.java)|0%|:green_apple:|
|[OwnerAuthController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fauth%2Fweb%2FOwnerAuthController.java)|0%|:green_apple:|
|[SocialAuthController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fauth%2Fweb%2FSocialAuthController.java)|0%|:green_apple:|
|[TokenController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fauth%2Fweb%2FTokenController.java)|0%|:green_apple:|
|[ParkingZoneController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fparkingzone%2Fweb%2FParkingZoneController.java)|0%|:green_apple:|
|[UserController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Fuser%2Fweb%2FUserController.java)|0%|:green_apple:|
|[ReservationController.java](https://github.com/parking-10easy/ParkEZ/blob/02116d4255f379fea96d880578d0c7f43ab57a90/src%2Fmain%2Fjava%2Fcom%2Fparkez%2Freservation%2Fweb%2FReservationController.java)|0%|:green_apple:|
author_association: NONE