diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 9bbccce..e3fb80c 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -18,6 +18,8 @@ jobs: DB_URL: ${{ secrets.DB_URL }} DB_USERNAME: ${{ secrets.DB_USERNAME }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + REDIS_HOST: ${{ secrets.REDIS_HOST }} + REDIS_PORT: ${{ secrets.REDIS_PORT }} steps: - name: 체크아웃 uses: actions/checkout@v4 diff --git a/build.gradle b/build.gradle index ace861b..10cef0d 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,13 @@ dependencies { // Hibernate Spatial implementation 'org.hibernate.orm:hibernate-spatial:6.5.2.Final' + // localdatetime 처리 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.3' + + + // localdatetime 처리 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.3' + // Apache Commons CSV implementation 'org.apache.commons:commons-csv:1.11.0' diff --git a/src/main/java/oz/yamyam_map/common/entity/BaseEntity.java b/src/main/java/oz/yamyam_map/common/entity/BaseEntity.java index ff49eb9..e90421b 100644 --- a/src/main/java/oz/yamyam_map/common/entity/BaseEntity.java +++ b/src/main/java/oz/yamyam_map/common/entity/BaseEntity.java @@ -7,6 +7,10 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; @@ -20,11 +24,15 @@ public abstract class BaseEntity { @Column(nullable = false, updatable = false) @CreatedDate @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) private LocalDateTime createdAt; @Column(nullable = false) @LastModifiedDate @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) private LocalDateTime updatedAt; } diff --git a/src/main/java/oz/yamyam_map/common/util/GeoUtils.java b/src/main/java/oz/yamyam_map/common/util/GeoUtils.java index c52e8fc..41e9e0f 100644 --- a/src/main/java/oz/yamyam_map/common/util/GeoUtils.java +++ b/src/main/java/oz/yamyam_map/common/util/GeoUtils.java @@ -8,6 +8,10 @@ import org.locationtech.jts.geom.PrecisionModel; import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; @@ -28,7 +32,6 @@ public static Point createPoint(double longitude, double latitude) { return point; } - /** * 위, 경도 소수점 6자리만 들어가도록 설정하는 메서드 **/ @@ -48,4 +51,21 @@ public void serialize(Point value, JsonGenerator gen, SerializerProvider seriali gen.writeEndObject(); } } + + /** + * JSON을 Point 객체로 역직렬화하는 메서드 (redis에서 데이터를 가져올 때 필요) + **/ + public static class PointDeserializer extends JsonDeserializer { + private final GeometryFactory geometryFactory = new GeometryFactory(); + + @Override + public Point deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + double x = node.get("x").asDouble(); + double y = node.get("y").asDouble(); + + return geometryFactory.createPoint(new Coordinate(x, y)); + } + } } diff --git a/src/main/java/oz/yamyam_map/core/config/RedisConfig.java b/src/main/java/oz/yamyam_map/core/config/RedisConfig.java new file mode 100644 index 0000000..036f20a --- /dev/null +++ b/src/main/java/oz/yamyam_map/core/config/RedisConfig.java @@ -0,0 +1,47 @@ +package oz.yamyam_map.core.config; + +import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import oz.yamyam_map.common.util.GeoUtils; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addSerializer(Point.class, new GeoUtils.PointSerializer()); + mapper.registerModule(module); + return mapper; + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())); + return redisTemplate; + } +} diff --git a/src/main/java/oz/yamyam_map/module/restaurant/dto/response/RestaurantDetailRes.java b/src/main/java/oz/yamyam_map/module/restaurant/dto/response/RestaurantDetailRes.java index 4ebdf58..399ea9f 100644 --- a/src/main/java/oz/yamyam_map/module/restaurant/dto/response/RestaurantDetailRes.java +++ b/src/main/java/oz/yamyam_map/module/restaurant/dto/response/RestaurantDetailRes.java @@ -1,5 +1,7 @@ package oz.yamyam_map.module.restaurant.dto.response; +import org.locationtech.jts.geom.Point; + import lombok.Builder; import lombok.Getter; import oz.yamyam_map.common.enums.RestaurantType; @@ -13,8 +15,7 @@ public class RestaurantDetailRes { private String name; private RestaurantType restaurantType; private String phoneNumber; - private double pointX; - private double pointY; + private Point location; private String oldAddressFull; private String roadAddressFull; private ReviewRating reviewRating; @@ -25,8 +26,7 @@ public static RestaurantDetailRes from(Restaurant restaurant) { .name(restaurant.getName()) .restaurantType(restaurant.getRestaurantType()) .phoneNumber(restaurant.getPhoneNumber()) - .pointX(restaurant.getLocation().getX()) // Point의 x 좌표 - .pointY(restaurant.getLocation().getY()) // Point의 y 좌표 + .location(restaurant.getLocation()) .oldAddressFull(restaurant.getOldAddressFull()) .roadAddressFull(restaurant.getRoadAddressFull()) .reviewRating(restaurant.getReviewRating()) diff --git a/src/main/java/oz/yamyam_map/module/restaurant/entity/Restaurant.java b/src/main/java/oz/yamyam_map/module/restaurant/entity/Restaurant.java index d54dd89..c096275 100644 --- a/src/main/java/oz/yamyam_map/module/restaurant/entity/Restaurant.java +++ b/src/main/java/oz/yamyam_map/module/restaurant/entity/Restaurant.java @@ -2,6 +2,8 @@ import org.locationtech.jts.geom.Point; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -19,7 +21,11 @@ import lombok.NoArgsConstructor; import oz.yamyam_map.common.entity.BaseEntity; import oz.yamyam_map.common.enums.RestaurantType; + +import oz.yamyam_map.common.util.GeoUtils; + import oz.yamyam_map.module.region.entity.Region; +import oz.yamyam_map.common.util.GeoUtils; @Entity @Getter @@ -45,7 +51,8 @@ public class Restaurant extends BaseEntity { private String phoneNumber; - @Column(nullable = false, columnDefinition = "Point") + @Column(nullable = false, columnDefinition = "GEOMETRY") + @JsonDeserialize(using = GeoUtils.PointDeserializer.class) private Point location; private String oldAddressFull; diff --git a/src/main/java/oz/yamyam_map/module/restaurant/entity/ReviewRating.java b/src/main/java/oz/yamyam_map/module/restaurant/entity/ReviewRating.java index 5b7a2df..b3468d1 100644 --- a/src/main/java/oz/yamyam_map/module/restaurant/entity/ReviewRating.java +++ b/src/main/java/oz/yamyam_map/module/restaurant/entity/ReviewRating.java @@ -4,6 +4,7 @@ import lombok.Getter; @Embeddable +@Getter public class ReviewRating { private Long totalReviews; diff --git a/src/main/java/oz/yamyam_map/module/restaurant/service/RestaurantService.java b/src/main/java/oz/yamyam_map/module/restaurant/service/RestaurantService.java index 2193e2b..7bee9f7 100644 --- a/src/main/java/oz/yamyam_map/module/restaurant/service/RestaurantService.java +++ b/src/main/java/oz/yamyam_map/module/restaurant/service/RestaurantService.java @@ -1,13 +1,18 @@ package oz.yamyam_map.module.restaurant.service; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import oz.yamyam_map.common.code.StatusCode; import oz.yamyam_map.exception.custom.BusinessException; import oz.yamyam_map.exception.custom.DataNotFoundException; @@ -26,11 +31,14 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor +@Slf4j public class RestaurantService { + private static final String CACHE_PREFIX = "restaurant:"; + private static final int TTL = 24; private final ReviewRepository reviewRepository; private final MemberRepository memberRepository; private final RestaurantRepository restaurantRepository; - + private final RedisTemplate redisTemplate; @Transactional public void uploadReview(Long memberId, Long restaurantId, ReviewUploadReq req) { @@ -48,8 +56,26 @@ public void uploadReview(Long memberId, Long restaurantId, ReviewUploadReq req) } public RestaurantDetailRes getRestaurantDetails(Long restaurantId) { + ObjectMapper objectMapper = new ObjectMapper(); + String cacheKey = CACHE_PREFIX + restaurantId; + Restaurant cachedRestaurant = objectMapper.convertValue(redisTemplate.opsForValue().get(cacheKey), + new TypeReference() { + }); + if (cachedRestaurant != null) { + log.info("{}번 맛집 데이터를 캐시에서 가져옵니다.", restaurantId); + return RestaurantDetailRes.from(cachedRestaurant); + } + + log.info("{}번 맛집 데이터가 캐시에 존재하지 않아 DB에서 조회합니다.", restaurantId); Restaurant restaurant = restaurantRepository.findById(restaurantId) .orElseThrow(() -> new DataNotFoundException(StatusCode.RESTAURANT_NOT_FOUND)); + + if (restaurant.getReviewRating().getTotalReviews() >= 10) { + log.info("{}번 맛집 캐시 저장 작업을 시작합니다.", restaurantId); + redisTemplate.opsForValue().set(cacheKey, restaurant, TTL, TimeUnit.HOURS); + log.info("{}번 맛집 캐시 저장 작업을 완료했습니다.", restaurantId); + } + return RestaurantDetailRes.from(restaurant); } diff --git a/src/main/resources/application-template.yml b/src/main/resources/application-template.yml index faed8b6..2a9c9cd 100644 --- a/src/main/resources/application-template.yml +++ b/src/main/resources/application-template.yml @@ -22,6 +22,11 @@ spring: job: enabled: false # 애플리케이션 실행 시 자동으로 Job이 실행되지 않도록 설정 + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + jwt: secret: ${JWT_SECRET} expiration: 3600000 # 1 hour in milliseconds