From 3254a0663d8aca39b8ac435f532ebf4251dac808 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 21:47:11 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=ED=95=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20Elastic=20Cache=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/PinLocationRepository.java | 29 +++ .../map/cache/PinGeoRedisInitializer.java | 39 ++++ .../domain/map/cache/PinGeoRedisService.java | 208 ++++++++++++++++++ .../domain/map/cache/PinGeoScheduler.java | 37 ++++ .../service/query/MapPinQueryServiceImpl.java | 127 +++++++---- .../PinCommunicationCommandServiceImpl.java | 8 + .../command/PinDeleteCommandServiceImpl.java | 5 + .../command/PinStoreCommandServiceImpl.java | 8 + 8 files changed, 423 insertions(+), 38 deletions(-) create mode 100644 src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java create mode 100644 src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java create mode 100644 src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java diff --git a/src/main/java/issueissyu/backend/domain/location/repository/PinLocationRepository.java b/src/main/java/issueissyu/backend/domain/location/repository/PinLocationRepository.java index ae7b062..f163a93 100644 --- a/src/main/java/issueissyu/backend/domain/location/repository/PinLocationRepository.java +++ b/src/main/java/issueissyu/backend/domain/location/repository/PinLocationRepository.java @@ -114,4 +114,33 @@ List findPinClustersInBoundingBox( ); Optional findFirstByPin_PinId(Long pinId); + + // Redis GEO 초기 적재 / 야간 재적재용: BBox 제약 없이 현재 활성 핀을 모두 조회합니다. + // 활성 조건은 findPinsInBoundingBox 와 동일 (ISSUE 1년, COMMUNICATION 1개월, STORE/FESTIVAL 이벤트 기간). + @Query(value = """ + SELECT + p.pin_id AS pinId, + p.pin_type AS pinType, + ST_Y(pl.pin_point) AS lat, + ST_X(pl.pin_point) AS lng, + pl.detail_address AS detailAddress, + l.location AS region, + CASE WHEN p.pin_type = 'STORE' THEN ep.discount ELSE NULL END AS discount + FROM pin_location pl + INNER JOIN pin p ON pl.pin_id = p.pin_id + INNER JOIN location l ON pl.location_id = l.location_id + LEFT JOIN communication_pin cp ON cp.pin_id = p.pin_id + LEFT JOIN event_pin ep ON ep.pin_id = p.pin_id + WHERE p.created_at >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '1 year' + AND ( + p.pin_type = 'ISSUE' + OR (p.pin_type = 'COMMUNICATION' + AND cp.communication_pin_id IS NOT NULL + AND COALESCE(cp.updated_at, cp.created_at) >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '1 month') + OR (p.pin_type IN ('STORE', 'FESTIVAL') + AND ep.event_pin_id IS NOT NULL + AND (NOW() AT TIME ZONE 'Asia/Seoul') BETWEEN ep.event_start_time AND ep.event_end_time) + ) + """, nativeQuery = true) + List findAllActivePins(); } diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java new file mode 100644 index 0000000..39c55f2 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java @@ -0,0 +1,39 @@ +package issueissyu.backend.domain.map.cache; + +import issueissyu.backend.domain.location.repository.PinLocationRepository; +import issueissyu.backend.domain.map.dto.res.MapPinView; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; + +// 서버 시작 시 DB의 활성 핀을 Redis GEO 캐시에 전체 적재합니다. +// {@link ApplicationReadyEvent} 이후 비동기로 실행되어 서버 기동 지연이 없습니다. +// 초기화 도중 핀 조회 API 호출이 오면 isGeoSetReady() == false 이므로 DB에서 응답합니다. +@Slf4j +@Component +@RequiredArgsConstructor +public class PinGeoRedisInitializer { + + private final PinLocationRepository pinLocationRepository; + private final PinGeoRedisService pinGeoRedisService; + + @Async + @Order(1) + @EventListener(ApplicationReadyEvent.class) + public void initialize() { + try { + log.info("[Redis GEO] 캐시 초기화 시작 ..."); + List views = pinLocationRepository.findAllActivePins(); + pinGeoRedisService.bulkPopulate(views); + log.info("[Redis GEO] 캐시 초기화 완료: {}개 핀 적재", views.size()); + } catch (Exception e) { + log.warn("[Redis GEO] 캐시 초기화 실패 (서버는 정상 기동, DB 폴백 사용): {}", e.getMessage()); + } + } +} diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java new file mode 100644 index 0000000..e900a31 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -0,0 +1,208 @@ +package issueissyu.backend.domain.map.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import issueissyu.backend.domain.map.dto.res.MapPinResDTO; +import issueissyu.backend.domain.map.dto.res.MapPinView; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.geo.GeoResults; +import org.springframework.data.geo.Metrics; +import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.RedisGeoCommands; +import org.springframework.data.redis.connection.RedisGeoCommands.GeoSearchCommandArgs; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.domain.geo.BoundingBox; +import org.springframework.data.redis.domain.geo.GeoReference; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +// Redis GEO를 활용한 핀 위치 캐싱 서비스. + +// 두 트랙 전략입니다. +// - geo:pins : 전체 핀 GEO Set (member = pinId 문자열) +// - geo:pins:{TYPE} : 타입별 GEO Set (ISSUE / COMMUNICATION / STORE / FESTIVAL) +// - pin:info:{pinId} : 핀 렌더링 메타데이터 JSON (48시간 TTL) + +// GEO Set 자체는 TTL이 없으며, 매일 새벽 스케줄러가 DB 기준으로 전체 재적재합니다. +@Slf4j +@Component +@RequiredArgsConstructor +public class PinGeoRedisService { + + static final String GEO_KEY_ALL = "geo:pins"; + static final String GEO_KEY_PREFIX = "geo:pins:"; + static final String PIN_INFO_KEY_PREFIX = "pin:info:"; + private static final Duration PIN_INFO_TTL = Duration.ofHours(48); + private static final int GEO_SEARCH_LIMIT = 1000; + + // 위도 1도 ≈ 111 km + private static final double DEGREE_TO_KM = 111.0; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // ───────────────────────────────────────────────────────────────── + // Write 흐름 + // ───────────────────────────────────────────────────────────────── + + // 핀을 GEO Set 과 pin:info 에 저장합니다. + // 예외 발생 시 경고 로그만 남기고 DB 응답에 영향을 주지 않습니다. + public void addPin(Long pinId, String pinType, double lat, double lng, + String detailAddress, String region, String discount) { + try { + String member = pinId.toString(); + Point point = new Point(lng, lat); // Redis GEO: (경도, 위도) + + redisTemplate.opsForGeo().add(GEO_KEY_ALL, point, member); + redisTemplate.opsForGeo().add(GEO_KEY_PREFIX + pinType, point, member); + + MapPinResDTO.PinItemDTO dto = + new MapPinResDTO.PinItemDTO(pinId, pinType, lat, lng, detailAddress, region, discount); + String json = objectMapper.writeValueAsString(dto); + redisTemplate.opsForValue().set(PIN_INFO_KEY_PREFIX + pinId, json, PIN_INFO_TTL); + } catch (Exception e) { + log.warn("Redis GEO 핀 추가 실패 pinId={}: {}", pinId, e.getMessage()); + } + } + + // GEO Set 과 pin:info 에서 핀을 삭제합니다. + public void removePin(Long pinId, String pinType) { + try { + String member = pinId.toString(); + redisTemplate.opsForGeo().remove(GEO_KEY_ALL, member); + redisTemplate.opsForGeo().remove(GEO_KEY_PREFIX + pinType, member); + redisTemplate.delete(PIN_INFO_KEY_PREFIX + pinId); + } catch (Exception e) { + log.warn("Redis GEO 핀 삭제 실패 pinId={}: {}", pinId, e.getMessage()); + } + } + + + + // ───────────────────────────────────────────────────────────────── + // Read 흐름 + // ───────────────────────────────────────────────────────────────── + + // GEO Set 이 초기화되어 있는지 확인합니다. + // (Sorted Set 기반이므로 ZSet 크기로 확인) + public boolean isGeoSetReady() { + try { + Long size = redisTemplate.opsForZSet().size(GEO_KEY_ALL); + return size != null && size > 0; + } catch (Exception e) { + log.warn("Redis GEO 상태 확인 실패: {}", e.getMessage()); + return false; + } + } + + // BBox(남서~북동 경위도)로 핀을 검색합니다. + + // 내부적으로 BBox 중심을 구하고 BYBOX GEOSEARCH 를 수행한 뒤, + // BBox 경계 안에 있는 핀만 정확히 필터링하여 반환합니다. + + // @return 검색 성공 시 Optional, Redis 오류 시 Optional.empty() + public Optional> searchByBBox( + double swLng, double swLat, double neLng, double neLat, String pinTypeFilter) { + try { + double centerLng = (swLng + neLng) / 2.0; + double centerLat = (swLat + neLat) / 2.0; + + // 도→km 변환 (경도는 위도에 따라 보정, 5 % 버퍼) + double widthKm = (neLng - swLng) * DEGREE_TO_KM + * Math.cos(Math.toRadians(centerLat)) * 1.05; + double heightKm = (neLat - swLat) * DEGREE_TO_KM * 1.05; + + String geoKey = (pinTypeFilter != null) + ? GEO_KEY_PREFIX + pinTypeFilter + : GEO_KEY_ALL; + + GeoResults> results = + redisTemplate.opsForGeo().search( + geoKey, + GeoReference.fromCoordinate(new Point(centerLng, centerLat)), + new BoundingBox(widthKm, heightKm, Metrics.KILOMETERS), + GeoSearchCommandArgs.newGeoSearchArgs() + .sortAscending() + .limit(GEO_SEARCH_LIMIT) + ); + + if (results == null) { + return Optional.of(Collections.emptyList()); + } + + List pinIdStrings = results.getContent().stream() + .map(r -> r.getContent().getName()) + .toList(); + + if (pinIdStrings.isEmpty()) { + return Optional.of(Collections.emptyList()); + } + + List keys = pinIdStrings.stream() + .map(id -> PIN_INFO_KEY_PREFIX + id) + .toList(); + List jsonList = redisTemplate.opsForValue().multiGet(keys); + + List pins = new ArrayList<>(); + if (jsonList != null) { + for (String json : jsonList) { + if (json == null) continue; // pin:info TTL 만료된 경우 skip + MapPinResDTO.PinItemDTO dto = + objectMapper.readValue(json, MapPinResDTO.PinItemDTO.class); + // BYBOX 검색 결과에 미세한 오차가 있을 수 있으므로 BBox 정확 필터링 + if (dto.latitude() >= swLat && dto.latitude() <= neLat + && dto.longitude() >= swLng && dto.longitude() <= neLng) { + pins.add(dto); + } + } + } + return Optional.of(pins); + + } catch (Exception e) { + log.warn("Redis GEO BBox 검색 실패: {}", e.getMessage()); + return Optional.empty(); + } + } + + + + // ───────────────────────────────────────────────────────────────── + // Bulk 초기화 / 스케줄러 + // ───────────────────────────────────────────────────────────────── + + // GEO Set 을 초기화한 뒤, DB에서 조회한 활성 핀 목록으로 전체 재적재합니다. + // 서버 시작 및 매일 새벽 스케줄러에서 호출됩니다. + public void bulkPopulate(List views) { + clearGeoKeys(); + for (MapPinView view : views) { + if (view.getPinId() == null || view.getLat() == null || view.getLng() == null) { + continue; + } + addPin(view.getPinId(), view.getPinType(), + view.getLat(), view.getLng(), + view.getDetailAddress(), view.getRegion(), view.getDiscount()); + } + } + + // 모든 geo:pins, geo:pins:{TYPE} 키를 삭제합니다. + // pin:info 키는 48h TTL 으로 자연 만료되므로 별도 삭제하지 않습니다. + // (재적재 시 addPin 이 TTL 을 갱신합니다.) + private void clearGeoKeys() { + try { + redisTemplate.delete(GEO_KEY_ALL); + // geo:pins:* 키는 핀 타입 수 만큼이므로 keys() 사용이 안전 + Set typeKeys = redisTemplate.keys(GEO_KEY_PREFIX + "*"); + if (typeKeys != null && !typeKeys.isEmpty()) { + redisTemplate.delete(typeKeys); + } + } catch (Exception e) { + log.warn("Redis GEO 키 전체 삭제 실패: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java new file mode 100644 index 0000000..841169a --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java @@ -0,0 +1,37 @@ +package issueissyu.backend.domain.map.cache; + +import issueissyu.backend.domain.location.repository.PinLocationRepository; +import issueissyu.backend.domain.map.dto.res.MapPinView; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +// Redis GEO 캐시 정합성 유지를 위한 야간 배치 스케줄러. + +// 동작 조건 : +// 매일 새벽 1:30 에 DB 기준 활성 핀 목록으로 캐시 전체 재구성 +// 만료 조건: ISSUE 1년, COMMUNICATION 1개월 미반응, STORE/FESTIVAL 이벤트 종료 +// 재적재 후 pin:info TTL 이 48시간으로 갱신되어 메모리 낭비를 방지 +@Slf4j +@Component +@RequiredArgsConstructor +public class PinGeoScheduler { + + private final PinLocationRepository pinLocationRepository; + private final PinGeoRedisService pinGeoRedisService; + + @Scheduled(cron = "0 30 1 * * *", zone = "Asia/Seoul") + public void rebuildGeoCache() { + try { + log.info("[Redis GEO] 야간 캐시 재구성 시작 ..."); + List views = pinLocationRepository.findAllActivePins(); + pinGeoRedisService.bulkPopulate(views); + log.info("[Redis GEO] 야간 캐시 재구성 완료: {}개 핀", views.size()); + } catch (Exception e) { + log.error("[Redis GEO] 야간 캐시 재구성 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/issueissyu/backend/domain/map/service/query/MapPinQueryServiceImpl.java b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinQueryServiceImpl.java index 86d37b0..401ad32 100644 --- a/src/main/java/issueissyu/backend/domain/map/service/query/MapPinQueryServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinQueryServiceImpl.java @@ -1,6 +1,7 @@ package issueissyu.backend.domain.map.service.query; import issueissyu.backend.domain.location.repository.PinLocationRepository; +import issueissyu.backend.domain.map.cache.PinGeoRedisService; import issueissyu.backend.domain.map.dto.res.MapPinClusterResDTO; import issueissyu.backend.domain.map.dto.res.MapPinClusterView; import issueissyu.backend.domain.map.dto.res.MapPinResDTO; @@ -11,9 +12,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -21,11 +24,28 @@ public class MapPinQueryServiceImpl implements MapPinQueryService { private final PinLocationRepository pinLocationRepository; + private final PinGeoRedisService pinGeoRedisService; + + // ───────────────────────────────────────────────────────────────── + // 화면 핀 조회 (Cache-Aside) + // ───────────────────────────────────────────────────────────────── @Override - public MapPinResDTO getPinsInBoundingBox(double swLng, double swLat, double neLng, double neLat, String pinTypeFilter) { + public MapPinResDTO getPinsInBoundingBox( + double swLng, double swLat, double neLng, double neLat, String pinTypeFilter) { + // 1. Redis 캐시 시도 + if (pinGeoRedisService.isGeoSetReady()) { + Optional> cached = + pinGeoRedisService.searchByBBox(swLng, swLat, neLng, neLat, pinTypeFilter); + if (cached.isPresent()) { + return new MapPinResDTO(cached.get()); + } + } + + // 2. 캐시 미스 → DB 폴백 try { - List views = pinLocationRepository.findPinsInBoundingBox(swLng, swLat, neLng, neLat, pinTypeFilter); + List views = pinLocationRepository.findPinsInBoundingBox( + swLng, swLat, neLng, neLat, pinTypeFilter); List pins = views.stream() .map(this::toDto) .toList(); @@ -35,52 +55,83 @@ public MapPinResDTO getPinsInBoundingBox(double swLng, double swLat, double neLn } } + // ───────────────────────────────────────────────────────────────── + // 클러스터링 핀 조회 (Cache-Aside + Java-side 클러스터링) + // ───────────────────────────────────────────────────────────────── + @Override public MapPinClusterResDTO getPinClustersInBoundingBox( - double swLng, - double swLat, - double neLng, - double neLat, - String pinTypeFilter, - int zoomLevel - ) { - try { - double gridSize = resolveGridSize(zoomLevel); - List views = pinLocationRepository.findPinClustersInBoundingBox( - swLng, swLat, neLng, neLat, pinTypeFilter, gridSize); + double swLng, double swLat, double neLng, double neLat, + String pinTypeFilter, int zoomLevel) { - Map> grouped = new LinkedHashMap<>(); - for (MapPinClusterView view : views) { - Double clusterLat = view.getClusterLat(); - Double clusterLng = view.getClusterLng(); - if (clusterLat == null || clusterLng == null) { - continue; - } - ClusterKey key = new ClusterKey(clusterLat, clusterLng); - grouped.computeIfAbsent(key, ignored -> new java.util.ArrayList<>()) - .add(toDto(view)); - } + double gridSize = resolveGridSize(zoomLevel); - List clusters = new java.util.ArrayList<>(grouped.size()); - long clusterId = 1L; - for (Map.Entry> entry : grouped.entrySet()) { - ClusterKey key = entry.getKey(); - List pins = entry.getValue(); - clusters.add(new MapPinClusterResDTO.ClusterItemDTO( - clusterId++, - key.clusterLat(), - key.clusterLng(), - pins.size(), - pins - )); + // 1. Redis 캐시 시도 → Java-side 클러스터링 + if (pinGeoRedisService.isGeoSetReady()) { + Optional> cached = + pinGeoRedisService.searchByBBox(swLng, swLat, neLng, neLat, pinTypeFilter); + if (cached.isPresent()) { + return clusterPins(cached.get(), gridSize); } + } - return new MapPinClusterResDTO(clusters); + // 2. 캐시 미스 → DB 폴백 (PostGIS ST_SnapToGrid 사용) + try { + List views = pinLocationRepository.findPinClustersInBoundingBox( + swLng, swLat, neLng, neLat, pinTypeFilter, gridSize); + return buildClusterDtoFromViews(views); } catch (Exception e) { throw MapException.of(MapErrorCode.MAP_400_3); } } + // ───────────────────────────────────────────────────────────────── + // 내부 헬퍼 + // ───────────────────────────────────────────────────────────────── + + // Redis에서 가져온 핀 목록을 PostGIS ST_SnapToGrid 와 동일한 방식으로 클러스터링합니다. + // snappedLat = round(lat / gridSize) * gridSize + private MapPinClusterResDTO clusterPins(List pins, double gridSize) { + Map> grouped = new LinkedHashMap<>(); + for (MapPinResDTO.PinItemDTO pin : pins) { + double snappedLat = Math.round(pin.latitude() / gridSize) * gridSize; + double snappedLng = Math.round(pin.longitude() / gridSize) * gridSize; + grouped.computeIfAbsent(new ClusterKey(snappedLat, snappedLng), k -> new ArrayList<>()) + .add(pin); + } + return buildClusterDto(grouped); + } + + private MapPinClusterResDTO buildClusterDtoFromViews(List views) { + Map> grouped = new LinkedHashMap<>(); + for (MapPinClusterView view : views) { + Double clusterLat = view.getClusterLat(); + Double clusterLng = view.getClusterLng(); + if (clusterLat == null || clusterLng == null) continue; + grouped.computeIfAbsent(new ClusterKey(clusterLat, clusterLng), k -> new ArrayList<>()) + .add(toDto(view)); + } + return buildClusterDto(grouped); + } + + private MapPinClusterResDTO buildClusterDto( + Map> grouped) { + List clusters = new ArrayList<>(grouped.size()); + long clusterId = 1L; + for (Map.Entry> entry : grouped.entrySet()) { + ClusterKey key = entry.getKey(); + List pinList = entry.getValue(); + clusters.add(new MapPinClusterResDTO.ClusterItemDTO( + clusterId++, + key.clusterLat(), + key.clusterLng(), + pinList.size(), + pinList + )); + } + return new MapPinClusterResDTO(clusters); + } + private MapPinResDTO.PinItemDTO toDto(MapPinView view) { return new MapPinResDTO.PinItemDTO( view.getPinId(), diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java index e750662..83a666f 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java @@ -1,6 +1,7 @@ package issueissyu.backend.domain.pin.service.command; import issueissyu.backend.domain.location.dto.res.CoordinateLocationResolveResDTO; +import issueissyu.backend.domain.map.cache.PinGeoRedisService; import issueissyu.backend.domain.location.dto.res.UserLocationResDTO; import issueissyu.backend.domain.location.entity.Location; import issueissyu.backend.domain.location.entity.PinLocation; @@ -68,6 +69,7 @@ public class PinCommunicationCommandServiceImpl implements PinCommunicationComma private final AmazonConfig amazonConfig; private final PinImageUploadCommandService pinImageUploadCommandService; private final S3Utils s3Utils; + private final PinGeoRedisService pinGeoRedisService; private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326); @@ -127,6 +129,12 @@ public CommunicationPinImportResDTO importCommunication(String uid, Communicatio communicationPinRepository.save(CommunicationPin.builder().pin(pin).build()); + // Redis GEO 캐시에 적재 (실패해도 DB 저장에 영향 없음) + pinGeoRedisService.addPin( + pin.getPinId(), PinType.COMMUNICATION.name(), + req.lat(), req.lng(), + detailAddress, location.getRegion(), null); + return toImportRes(pin, location.getRegion(), detailAddress); } catch (PinException e) { throw e; diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java index d00f7e2..b103c7c 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java @@ -9,6 +9,7 @@ import issueissyu.backend.domain.issue.repository.IssuePetitionRepository; import issueissyu.backend.domain.issue.repository.ProblemSolverImageRepository; import issueissyu.backend.domain.issue.repository.ProblemSolverRepository; +import issueissyu.backend.domain.map.cache.PinGeoRedisService; import issueissyu.backend.domain.map.repository.NoticeRepository; import issueissyu.backend.domain.pin.entity.Pin; import issueissyu.backend.domain.pin.enums.PinType; @@ -55,6 +56,7 @@ public class PinDeleteCommandServiceImpl implements PinDeleteCommandService { private final PinLocationRepository pinLocationRepository; private final UserRepository userRepository; private final PinAlarmCleaner pinAlarmCleaner; + private final PinGeoRedisService pinGeoRedisService; @Override public void deletePin(String uid, Long pinId) { @@ -115,6 +117,9 @@ public void deletePin(String uid, Long pinId) { communicationPinRepository.deleteByPin_PinId(pinId); pinLocationRepository.deleteByPin_PinId(pinId); pinRepository.delete(pin); + + // Redis GEO 캐시에서 제거 (실패해도 DB 삭제에 영향 없음) + pinGeoRedisService.removePin(pinId, pin.getPinType().name()); } private void deleteIssueAssociations(IssuePin issuePin) { diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinStoreCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinStoreCommandServiceImpl.java index 328faa5..b5e9e7c 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinStoreCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinStoreCommandServiceImpl.java @@ -1,6 +1,7 @@ package issueissyu.backend.domain.pin.service.command; import issueissyu.backend.domain.community.entity.Community; +import issueissyu.backend.domain.map.cache.PinGeoRedisService; import issueissyu.backend.domain.community.enums.CommunityType; import issueissyu.backend.domain.community.repository.CommunityRepository; import issueissyu.backend.domain.location.dto.res.CoordinateLocationResolveResDTO; @@ -86,6 +87,7 @@ public class PinStoreCommandServiceImpl implements PinStoreCommandService { private final AmazonConfig amazonConfig; private final PinImageUploadCommandService pinImageUploadCommandService; private final S3Utils s3Utils; + private final PinGeoRedisService pinGeoRedisService; @Override public StorePinImportResDTO importStore(String uid, StorePinImportReqDTO req) { @@ -172,6 +174,12 @@ public StorePinImportResDTO importStore(String uid, StorePinImportReqDTO req) { communityRepository.save( Community.builder().pin(pin).communityType(CommunityType.STORE).build()); + // Redis GEO 캐시에 적재 (실패해도 DB 저장에 영향 없음) + pinGeoRedisService.addPin( + pin.getPinId(), PinType.STORE.name(), + req.lat(), req.lng(), + detailAddress, location.getRegion(), req.discount()); + return toImportRes(pin, location.getRegion(), detailAddress, req.storeProfileImageUrl()); } catch (PinException | LocationException e) { throw e; From 03e21f1910184d5396e764ecc03cdf06ebffe552 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 21:58:11 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EC=BA=90=EC=8B=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=ED=99=95=EC=9D=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 9 +++-- .../domain/map/controller/MapController.java | 13 ++++++++ .../map/dto/res/MapPinCacheStatusResDTO.java | 4 +++ .../map/exception/code/MapErrorCode.java | 4 ++- .../map/exception/code/MapSuccessCode.java | 4 ++- .../query/MapPinCacheQueryService.java | 8 +++++ .../query/MapPinCacheQueryServiceImpl.java | 33 +++++++++++++++++++ 7 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/main/java/issueissyu/backend/domain/map/dto/res/MapPinCacheStatusResDTO.java create mode 100644 src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryService.java create mode 100644 src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryServiceImpl.java diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index e900a31..74a4bd8 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -92,12 +92,17 @@ public void removePin(Long pinId, String pinType) { // GEO Set 이 초기화되어 있는지 확인합니다. // (Sorted Set 기반이므로 ZSet 크기로 확인) public boolean isGeoSetReady() { + return getGeoPinCount() > 0; + } + + /** Redis GEO Set(geo:pins)에 저장된 핀 개수. 조회 실패 시 0. */ + public long getGeoPinCount() { try { Long size = redisTemplate.opsForZSet().size(GEO_KEY_ALL); - return size != null && size > 0; + return size != null ? size : 0L; } catch (Exception e) { log.warn("Redis GEO 상태 확인 실패: {}", e.getMessage()); - return false; + return 0L; } } diff --git a/src/main/java/issueissyu/backend/domain/map/controller/MapController.java b/src/main/java/issueissyu/backend/domain/map/controller/MapController.java index f73b1fc..7ee796a 100644 --- a/src/main/java/issueissyu/backend/domain/map/controller/MapController.java +++ b/src/main/java/issueissyu/backend/domain/map/controller/MapController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import issueissyu.backend.domain.map.dto.res.MapNoticeListResDTO; +import issueissyu.backend.domain.map.dto.res.MapPinCacheStatusResDTO; import issueissyu.backend.domain.map.dto.res.MapPinCardResDTO; import issueissyu.backend.domain.map.dto.res.MapPinClusterResDTO; import issueissyu.backend.domain.map.dto.res.MapPinResDTO; @@ -12,6 +13,7 @@ import issueissyu.backend.domain.map.exception.code.MapErrorCode; import issueissyu.backend.domain.map.exception.code.MapSuccessCode; import issueissyu.backend.domain.map.service.query.MapNoticeQueryService; +import issueissyu.backend.domain.map.service.query.MapPinCacheQueryService; import issueissyu.backend.domain.map.service.query.MapPinCardQueryService; import issueissyu.backend.domain.map.service.query.MapPinQueryService; import issueissyu.backend.domain.map.service.query.PatchNoteQueryService; @@ -35,6 +37,7 @@ public class MapController { private final MapPinQueryService mapPinQueryService; private final MapPinCardQueryService mapPinCardQueryService; + private final MapPinCacheQueryService mapPinCacheQueryService; private final MapNoticeQueryService mapNoticeQueryService; private final PatchNoteQueryService patchNoteQueryService; @@ -113,6 +116,16 @@ public ApiResponse getPatchNotes( patchNoteQueryService.getPatchNotes(uid, locationId, size, cursor)); } + @Operation( + summary = "지도 핀 캐시 상태 조회 (ADMIN)", + description = "Redis GEO Set(geo:pins)에 저장된 핀 개수(ZCARD)를 반환합니다. ADMIN 권한 필요.") + @GetMapping("/cache/status") + public ApiResponse getPinCacheStatus(@AuthenticationPrincipal String uid) { + return ApiResponse.onSuccess( + MapSuccessCode.MAP_CACHE_200, + mapPinCacheQueryService.getCacheStatus(uid)); + } + @Operation( summary = "단일 핀 카드 조회 (경로 변수)", description = "communityId는 연결된 커뮤니티가 있을 때만 반환.") diff --git a/src/main/java/issueissyu/backend/domain/map/dto/res/MapPinCacheStatusResDTO.java b/src/main/java/issueissyu/backend/domain/map/dto/res/MapPinCacheStatusResDTO.java new file mode 100644 index 0000000..ec5c097 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/dto/res/MapPinCacheStatusResDTO.java @@ -0,0 +1,4 @@ +package issueissyu.backend.domain.map.dto.res; + +public record MapPinCacheStatusResDTO(long geoPinCount) { +} diff --git a/src/main/java/issueissyu/backend/domain/map/exception/code/MapErrorCode.java b/src/main/java/issueissyu/backend/domain/map/exception/code/MapErrorCode.java index b1799ac..cc62f87 100644 --- a/src/main/java/issueissyu/backend/domain/map/exception/code/MapErrorCode.java +++ b/src/main/java/issueissyu/backend/domain/map/exception/code/MapErrorCode.java @@ -20,7 +20,9 @@ public enum MapErrorCode implements BaseErrorCode { PATCHNOTE_400_1(HttpStatus.BAD_REQUEST, "PATCHNOTE_400_1", "존재하지 않는 지역 입니다."), PATCHNOTE_400_2(HttpStatus.BAD_REQUEST, "PATCHNOTE_400_2", "조회 불가능한 사이즈 입니다."), PATCHNOTE_400_3(HttpStatus.BAD_REQUEST, "PATCHNOTE_400_3", "조회 불가능한 cursor 입니다."), - PATCHNOTE_400_4(HttpStatus.BAD_REQUEST, "PATCHNOTE_400_4", "사용자의 동네가 등록되지 않아 패치노트 조회가 불가능합니다."); + PATCHNOTE_400_4(HttpStatus.BAD_REQUEST, "PATCHNOTE_400_4", "사용자의 동네가 등록되지 않아 패치노트 조회가 불가능합니다."), + + MAP_CACHE_403(HttpStatus.FORBIDDEN, "MAP_CACHE_403", "지도 핀 캐시 상태 조회는 ADMIN 권한이 필요합니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/issueissyu/backend/domain/map/exception/code/MapSuccessCode.java b/src/main/java/issueissyu/backend/domain/map/exception/code/MapSuccessCode.java index 21eda84..8523801 100644 --- a/src/main/java/issueissyu/backend/domain/map/exception/code/MapSuccessCode.java +++ b/src/main/java/issueissyu/backend/domain/map/exception/code/MapSuccessCode.java @@ -29,7 +29,9 @@ public enum MapSuccessCode implements BaseSuccessCode { MAP_NOTICE_200(HttpStatus.OK, "MAP_NOTICE_200", "공지사항 조회에 성공했습니다."), MAP_NOTICE_204(HttpStatus.OK, "MAP_NOTICE_204", "등록된 공지가 없습니다."), - PATCHNOTE_200(HttpStatus.OK, "PATCHNOTE_200", "현재 지역의 패치노트 조회에 성공했습니다."); + PATCHNOTE_200(HttpStatus.OK, "PATCHNOTE_200", "현재 지역의 패치노트 조회에 성공했습니다."), + + MAP_CACHE_200(HttpStatus.OK, "MAP_CACHE_200", "지도 핀 캐시 상태 조회에 성공했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryService.java b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryService.java new file mode 100644 index 0000000..eda575b --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryService.java @@ -0,0 +1,8 @@ +package issueissyu.backend.domain.map.service.query; + +import issueissyu.backend.domain.map.dto.res.MapPinCacheStatusResDTO; + +public interface MapPinCacheQueryService { + + MapPinCacheStatusResDTO getCacheStatus(String uid); +} diff --git a/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryServiceImpl.java b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryServiceImpl.java new file mode 100644 index 0000000..964f6c9 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/service/query/MapPinCacheQueryServiceImpl.java @@ -0,0 +1,33 @@ +package issueissyu.backend.domain.map.service.query; + +import issueissyu.backend.domain.map.cache.PinGeoRedisService; +import issueissyu.backend.domain.map.dto.res.MapPinCacheStatusResDTO; +import issueissyu.backend.domain.map.exception.MapException; +import issueissyu.backend.domain.map.exception.code.MapErrorCode; +import issueissyu.backend.domain.user.entity.User; +import issueissyu.backend.domain.user.enums.UserRole; +import issueissyu.backend.domain.user.repository.UserRepository; +import issueissyu.backend.global.api.code.GeneralErrorCode; +import issueissyu.backend.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MapPinCacheQueryServiceImpl implements MapPinCacheQueryService { + + private final UserRepository userRepository; + private final PinGeoRedisService pinGeoRedisService; + + @Override + public MapPinCacheStatusResDTO getCacheStatus(String uid) { + User user = userRepository.findById(uid) + .orElseThrow(() -> GeneralException.of(GeneralErrorCode.USER_NOT_FOUND)); + if (user.getRole() != UserRole.ADMIN) { + throw MapException.of(MapErrorCode.MAP_CACHE_403); + } + return new MapPinCacheStatusResDTO(pinGeoRedisService.getGeoPinCount()); + } +} From 56f259f0ebf8acc801cdd7da4c0aaf3a42bf3dc7 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 22:58:59 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20Redis=20GEO=20bulk=20populate=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index 74a4bd8..f82a7a6 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -183,15 +183,54 @@ public Optional> searchByBBox( // GEO Set 을 초기화한 뒤, DB에서 조회한 활성 핀 목록으로 전체 재적재합니다. // 서버 시작 및 매일 새벽 스케줄러에서 호출됩니다. + // executePipelined 로 모든 명령을 단일 파이프라인에 묶어 네트워크 왕복을 최소화합니다. + // (개별 addPin: 핀당 3 round-trip → pipeline: 전체 N개를 1회 전송) public void bulkPopulate(List views) { clearGeoKeys(); - for (MapPinView view : views) { - if (view.getPinId() == null || view.getLat() == null || view.getLng() == null) { - continue; - } - addPin(view.getPinId(), view.getPinType(), - view.getLat(), view.getLng(), - view.getDetailAddress(), view.getRegion(), view.getDiscount()); + if (views.isEmpty()) { + return; + } + try { + redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback() { + @Override + @SuppressWarnings("unchecked") + public Object execute(org.springframework.data.redis.core.RedisOperations operations) { + // executePipelined 는 RedisTemplate 자신을 operations 로 전달하므로 + // 인터페이스 타입으로 캐스트하여 파이프라인 내 명령을 큐잉합니다. + org.springframework.data.redis.core.RedisOperations ops = + (org.springframework.data.redis.core.RedisOperations) operations; + + for (MapPinView view : views) { + if (view.getPinId() == null || view.getLat() == null || view.getLng() == null) { + continue; + } + try { + Long pinId = view.getPinId(); + String pinType = view.getPinType(); + double lat = view.getLat(); + double lng = view.getLng(); + String member = pinId.toString(); + Point point = new Point(lng, lat); + + ops.opsForGeo().add(GEO_KEY_ALL, point, member); + ops.opsForGeo().add(GEO_KEY_PREFIX + pinType, point, member); + + MapPinResDTO.PinItemDTO dto = new MapPinResDTO.PinItemDTO( + pinId, pinType, lat, lng, + view.getDetailAddress(), view.getRegion(), view.getDiscount() + ); + String json = objectMapper.writeValueAsString(dto); + ops.opsForValue().set(PIN_INFO_KEY_PREFIX + pinId, json, PIN_INFO_TTL); + } catch (Exception e) { + log.warn("Redis GEO bulk populate 단일 핀 직렬화 실패 pinId={}: {}", + view.getPinId(), e.getMessage()); + } + } + return null; + } + }); + } catch (Exception e) { + log.error("Redis GEO bulk populate 파이프라인 실패: {}", e.getMessage(), e); } } From c82af3facbee5a91db1daba0fd129effc4dcf5dd Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 23:06:55 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20ZSet=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=85=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20volatile=20boolean=20isReady=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EB=8F=84=EC=9E=85=EA=B3=BC=20bulkPopulate?= =?UTF-8?q?=20=EB=82=B4=20isReady=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index f82a7a6..ef1e41e 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -47,6 +47,10 @@ public class PinGeoRedisService { private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; + // bulkPopulate 가 완전히 끝난 시점에만 true 가 됩니다. + // @Async / 스케줄러 스레드에서 쓰고, HTTP 요청 스레드에서 읽으므로 volatile 필수입니다. + private volatile boolean isReady = false; + // ───────────────────────────────────────────────────────────────── // Write 흐름 // ───────────────────────────────────────────────────────────────── @@ -89,13 +93,13 @@ public void removePin(Long pinId, String pinType) { // Read 흐름 // ───────────────────────────────────────────────────────────────── - // GEO Set 이 초기화되어 있는지 확인합니다. - // (Sorted Set 기반이므로 ZSet 크기로 확인) + // 전체 적재 완료 여부(isReady)와 실제 데이터 존재 여부(ZCARD > 0)를 함께 확인합니다. + // Redis가 외부에서 초기화되어 isReady=true 이지만 데이터가 없는 경우도 DB 폴백합니다. public boolean isGeoSetReady() { - return getGeoPinCount() > 0; + return isReady && getGeoPinCount() > 0; } - /** Redis GEO Set(geo:pins)에 저장된 핀 개수. 조회 실패 시 0. */ + // Redis GEO Set(geo:pins)에 저장된 핀 개수. 조회 실패 시 0. public long getGeoPinCount() { try { Long size = redisTemplate.opsForZSet().size(GEO_KEY_ALL); @@ -186,8 +190,10 @@ public Optional> searchByBBox( // executePipelined 로 모든 명령을 단일 파이프라인에 묶어 네트워크 왕복을 최소화합니다. // (개별 addPin: 핀당 3 round-trip → pipeline: 전체 N개를 1회 전송) public void bulkPopulate(List views) { + isReady = false; // 재적재 시작: 불완전한 캐시를 읽지 않도록 차단 clearGeoKeys(); if (views.isEmpty()) { + isReady = true; // 빈 결과도 완성된 상태이며, getGeoPinCount() == 0 으로 DB 폴백됨 return; } try { @@ -229,8 +235,10 @@ public Object execute(org.springframework.data.redis.core.RedisOperations return null; } }); + isReady = true; // 파이프라인이 Redis에서 완전히 처리된 후에만 준비 완료 표시 } catch (Exception e) { log.error("Redis GEO bulk populate 파이프라인 실패: {}", e.getMessage(), e); + // isReady = false 유지 → DB 폴백으로 동작 } } From 1e75b571e5e111cf1176bae00a50fe0a4fd0ab08 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 23:17:16 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20addPin=EA=B3=BC=20removePin?= =?UTF-8?q?=EC=9D=B4=20DB=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=82=B4=EC=97=90=EC=84=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20Tra?= =?UTF-8?q?nsactionSynchronizationManager.afterCommit=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 71 +++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index ef1e41e..cdfcd86 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -14,6 +14,8 @@ import org.springframework.data.redis.domain.geo.BoundingBox; import org.springframework.data.redis.domain.geo.GeoReference; import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; import java.util.ArrayList; @@ -56,34 +58,63 @@ public class PinGeoRedisService { // ───────────────────────────────────────────────────────────────── // 핀을 GEO Set 과 pin:info 에 저장합니다. - // 예외 발생 시 경고 로그만 남기고 DB 응답에 영향을 주지 않습니다. + // 활성 DB 트랜잭션이 있으면 커밋 완료 후에 Redis 쓰기를 실행합니다. + // DB 롤백 시 afterCommit 은 호출되지 않으므로 Redis 불일치가 발생하지 않습니다. public void addPin(Long pinId, String pinType, double lat, double lng, String detailAddress, String region, String discount) { - try { - String member = pinId.toString(); - Point point = new Point(lng, lat); // Redis GEO: (경도, 위도) - - redisTemplate.opsForGeo().add(GEO_KEY_ALL, point, member); - redisTemplate.opsForGeo().add(GEO_KEY_PREFIX + pinType, point, member); + Runnable action = () -> { + try { + String member = pinId.toString(); + Point point = new Point(lng, lat); // Redis GEO: (경도, 위도) + + redisTemplate.opsForGeo().add(GEO_KEY_ALL, point, member); + redisTemplate.opsForGeo().add(GEO_KEY_PREFIX + pinType, point, member); + + MapPinResDTO.PinItemDTO dto = + new MapPinResDTO.PinItemDTO(pinId, pinType, lat, lng, detailAddress, region, discount); + String json = objectMapper.writeValueAsString(dto); + redisTemplate.opsForValue().set(PIN_INFO_KEY_PREFIX + pinId, json, PIN_INFO_TTL); + } catch (Exception e) { + log.warn("Redis GEO 핀 추가 실패 pinId={}: {}", pinId, e.getMessage()); + } + }; - MapPinResDTO.PinItemDTO dto = - new MapPinResDTO.PinItemDTO(pinId, pinType, lat, lng, detailAddress, region, discount); - String json = objectMapper.writeValueAsString(dto); - redisTemplate.opsForValue().set(PIN_INFO_KEY_PREFIX + pinId, json, PIN_INFO_TTL); - } catch (Exception e) { - log.warn("Redis GEO 핀 추가 실패 pinId={}: {}", pinId, e.getMessage()); + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } else { + action.run(); } } // GEO Set 과 pin:info 에서 핀을 삭제합니다. + // 활성 DB 트랜잭션이 있으면 커밋 완료 후에 Redis 삭제를 실행합니다. + // DB 롤백 시 afterCommit 은 호출되지 않으므로 핀이 DB·Redis 양쪽에 그대로 유지됩니다. public void removePin(Long pinId, String pinType) { - try { - String member = pinId.toString(); - redisTemplate.opsForGeo().remove(GEO_KEY_ALL, member); - redisTemplate.opsForGeo().remove(GEO_KEY_PREFIX + pinType, member); - redisTemplate.delete(PIN_INFO_KEY_PREFIX + pinId); - } catch (Exception e) { - log.warn("Redis GEO 핀 삭제 실패 pinId={}: {}", pinId, e.getMessage()); + Runnable action = () -> { + try { + String member = pinId.toString(); + redisTemplate.opsForGeo().remove(GEO_KEY_ALL, member); + redisTemplate.opsForGeo().remove(GEO_KEY_PREFIX + pinType, member); + redisTemplate.delete(PIN_INFO_KEY_PREFIX + pinId); + } catch (Exception e) { + log.warn("Redis GEO 핀 삭제 실패 pinId={}: {}", pinId, e.getMessage()); + } + }; + + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } else { + action.run(); } } From c92eecf3c67a0b04dc9614abcb988a9a2e18526b Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 23:18:34 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EB=8B=A4=EC=A4=91=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A5=B4=EB=A7=81=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=99=EC=8B=9C=20=EC=8B=A4=ED=96=89=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EB=B0=A9=EC=A7=80=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Redis=20SET=20NX=20PX=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=B6=84=EC=82=B0=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoScheduler.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java index 841169a..681c8b8 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java @@ -4,27 +4,41 @@ import issueissyu.backend.domain.map.dto.res.MapPinView; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.Duration; import java.util.List; // Redis GEO 캐시 정합성 유지를 위한 야간 배치 스케줄러. -// 동작 조건 : -// 매일 새벽 1:30 에 DB 기준 활성 핀 목록으로 캐시 전체 재구성 -// 만료 조건: ISSUE 1년, COMMUNICATION 1개월 미반응, STORE/FESTIVAL 이벤트 종료 -// 재적재 후 pin:info TTL 이 48시간으로 갱신되어 메모리 낭비를 방지 +// 다중 서버 환경에서 모든 인스턴스가 동시에 실행되는 문제를 방지하기 위해 +// Redis SET NX PX 기반 분산 락을 사용합니다. +// 락을 획득한 인스턴스만 재구성을 수행하고, 나머지는 즉시 건너뜁니다. +// 락 TTL 이 만료되면 (프로세스 비정상 종료 등) 다음 실행 시 자동으로 획득 가능합니다. @Slf4j @Component @RequiredArgsConstructor public class PinGeoScheduler { + private static final String REBUILD_LOCK_KEY = "lock:geo:rebuild"; + // 재구성 예상 소요 시간보다 충분히 길게 설정 (비정상 종료 시 자동 만료용) + private static final Duration REBUILD_LOCK_TTL = Duration.ofMinutes(10); + private final PinLocationRepository pinLocationRepository; private final PinGeoRedisService pinGeoRedisService; + private final RedisTemplate redisTemplate; @Scheduled(cron = "0 30 1 * * *", zone = "Asia/Seoul") public void rebuildGeoCache() { + // SET lock:geo:rebuild 1 NX PX 600000 — 원자적 명령으로 레이스 컨디션 없음 + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(REBUILD_LOCK_KEY, "1", REBUILD_LOCK_TTL); + if (!Boolean.TRUE.equals(acquired)) { + log.info("[Redis GEO] 다른 인스턴스가 야간 캐시 재구성 중이므로 건너뜁니다."); + return; + } try { log.info("[Redis GEO] 야간 캐시 재구성 시작 ..."); List views = pinLocationRepository.findAllActivePins(); @@ -32,6 +46,9 @@ public void rebuildGeoCache() { log.info("[Redis GEO] 야간 캐시 재구성 완료: {}개 핀", views.size()); } catch (Exception e) { log.error("[Redis GEO] 야간 캐시 재구성 실패: {}", e.getMessage(), e); + } finally { + // 정상·비정상 종료 모두 락 즉시 해제 (다음 예약 실행까지 기다릴 필요 없음) + redisTemplate.delete(REBUILD_LOCK_KEY); } } } From 0620fc48d224941d5a8d6172535a41da7be6d783 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Thu, 18 Jun 2026 03:09:34 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=EC=84=B1=20=EB=B3=B4=EC=9E=A5=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20null=20=EA=B2=BD=EC=9A=B0=20=EB=B9=88=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=99=80,=20KEYS=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=96=B4=20=EC=A0=84=EC=B2=B4=20=ED=83=90=EC=83=89=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=97=B4?= =?UTF-8?q?=EA=B1=B0=EA=B0=92=20=EA=B8=B0=EC=A4=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index cdfcd86..1631d8b 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import issueissyu.backend.domain.map.dto.res.MapPinResDTO; import issueissyu.backend.domain.map.dto.res.MapPinView; +import issueissyu.backend.domain.map.enums.MapPinCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.geo.GeoResults; @@ -22,7 +23,6 @@ import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; // Redis GEO를 활용한 핀 위치 캐싱 서비스. @@ -189,17 +189,25 @@ public Optional> searchByBBox( .toList(); List jsonList = redisTemplate.opsForValue().multiGet(keys); + if (jsonList == null) { + // MGET 자체가 실패한 경우 DB 폴백 + return Optional.empty(); + } + List pins = new ArrayList<>(); - if (jsonList != null) { - for (String json : jsonList) { - if (json == null) continue; // pin:info TTL 만료된 경우 skip - MapPinResDTO.PinItemDTO dto = - objectMapper.readValue(json, MapPinResDTO.PinItemDTO.class); - // BYBOX 검색 결과에 미세한 오차가 있을 수 있으므로 BBox 정확 필터링 - if (dto.latitude() >= swLat && dto.latitude() <= neLat - && dto.longitude() >= swLng && dto.longitude() <= neLng) { - pins.add(dto); - } + for (String json : jsonList) { + if (json == null) { + // GEO Set에는 있지만 pin:info 가 Eviction/TTL 만료된 경우 + // 일부만 반환하면 핀이 지도에서 누락되므로 DB 폴백으로 전체 데이터 보장 + log.debug("Redis GEO 캐시 불일치 감지 (pin:info 누락) → DB 폴백"); + return Optional.empty(); + } + MapPinResDTO.PinItemDTO dto = + objectMapper.readValue(json, MapPinResDTO.PinItemDTO.class); + // BYBOX 검색 결과에 미세한 오차가 있을 수 있으므로 BBox 정확 필터링 + if (dto.latitude() >= swLat && dto.latitude() <= neLat + && dto.longitude() >= swLng && dto.longitude() <= neLng) { + pins.add(dto); } } return Optional.of(pins); @@ -276,14 +284,17 @@ public Object execute(org.springframework.data.redis.core.RedisOperations // 모든 geo:pins, geo:pins:{TYPE} 키를 삭제합니다. // pin:info 키는 48h TTL 으로 자연 만료되므로 별도 삭제하지 않습니다. // (재적재 시 addPin 이 TTL 을 갱신합니다.) + // + // KEYS 명령어(O(N), 전체 DB 블로킹) 대신 MapPinCategory 열거값으로 + // 삭제 대상 키를 직접 구성하여 단일 DEL 명령으로 처리합니다. private void clearGeoKeys() { try { - redisTemplate.delete(GEO_KEY_ALL); - // geo:pins:* 키는 핀 타입 수 만큼이므로 keys() 사용이 안전 - Set typeKeys = redisTemplate.keys(GEO_KEY_PREFIX + "*"); - if (typeKeys != null && !typeKeys.isEmpty()) { - redisTemplate.delete(typeKeys); + List keysToDelete = new ArrayList<>(); + keysToDelete.add(GEO_KEY_ALL); + for (MapPinCategory category : MapPinCategory.values()) { + keysToDelete.add(GEO_KEY_PREFIX + category.getPinType()); } + redisTemplate.delete(keysToDelete); } catch (Exception e) { log.warn("Redis GEO 키 전체 삭제 실패: {}", e.getMessage()); } From 326942df176c0d4e0193cd82943d8b0b1c12d229 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Thu, 18 Jun 2026 03:16:55 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=20=EC=95=88=EC=A0=84=EB=A7=9D=20=ED=98=95=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20isReady=3Dtrue=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=EB=8F=84=20ZCARD=20>=200=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=A0=EC=A7=80=ED=95=98=EA=B3=A0,=20bulkPopulat?= =?UTF-8?q?e()=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20SET=20geo:pins:ready=201?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EB=A5=B8=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=A9=B0,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=8F=84=20=ED=95=A8=EA=BB=98=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisService.java | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java index 1631d8b..e34f082 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -39,6 +39,7 @@ public class PinGeoRedisService { static final String GEO_KEY_ALL = "geo:pins"; static final String GEO_KEY_PREFIX = "geo:pins:"; + static final String GEO_READY_KEY = "geo:pins:ready"; static final String PIN_INFO_KEY_PREFIX = "pin:info:"; private static final Duration PIN_INFO_TTL = Duration.ofHours(48); private static final int GEO_SEARCH_LIMIT = 1000; @@ -124,10 +125,29 @@ public void afterCommit() { // Read 흐름 // ───────────────────────────────────────────────────────────────── - // 전체 적재 완료 여부(isReady)와 실제 데이터 존재 여부(ZCARD > 0)를 함께 확인합니다. - // Redis가 외부에서 초기화되어 isReady=true 이지만 데이터가 없는 경우도 DB 폴백합니다. + // 캐시 사용 가능 여부를 판단합니다. + + // 1. 로컬 isReady=true 이면 ZCARD 로 실제 데이터 존재 여부를 확인합니다. + // → 스케줄러가 GEO Set 을 비운 재구성 중에도 DB 폴백이 정상 동작합니다. + + // 2. 로컬 isReady=false 이면 Redis GEO_READY_KEY("geo:pins:ready") 를 조회합니다. + // → 다른 인스턴스에서 이미 적재가 완료된 경우 로컬 isReady 를 true 로 갱신합니다. + // → 신규 인스턴스가 자체 초기화가 끝나기 전에도 캐시를 활용할 수 있습니다. public boolean isGeoSetReady() { - return isReady && getGeoPinCount() > 0; + if (isReady) { + // 로컬 플래그가 true 라도 재구성 중 GEO Set 이 비어 있으면 DB 폴백합니다. + return getGeoPinCount() > 0; + } + // 로컬 플래그 미설정 → 다른 인스턴스가 이미 적재했는지 Redis 글로벌 키로 확인합니다. + try { + if (Boolean.TRUE.equals(redisTemplate.hasKey(GEO_READY_KEY)) && getGeoPinCount() > 0) { + isReady = true; + return true; + } + } catch (Exception e) { + log.warn("Redis GEO 상태 확인 실패: {}", e.getMessage()); + } + return false; } // Redis GEO Set(geo:pins)에 저장된 핀 개수. 조회 실패 시 0. @@ -274,23 +294,29 @@ public Object execute(org.springframework.data.redis.core.RedisOperations return null; } }); - isReady = true; // 파이프라인이 Redis에서 완전히 처리된 후에만 준비 완료 표시 + // 파이프라인이 완전히 처리된 후에만 준비 완료로 표시합니다. + // GEO_READY_KEY 를 Redis 에 기록해 다른 인스턴스에서도 캐시를 활성화할 수 있도록 합니다. + redisTemplate.opsForValue().set(GEO_READY_KEY, "1"); + isReady = true; } catch (Exception e) { log.error("Redis GEO bulk populate 파이프라인 실패: {}", e.getMessage(), e); // isReady = false 유지 → DB 폴백으로 동작 } } - // 모든 geo:pins, geo:pins:{TYPE} 키를 삭제합니다. + // 모든 geo:pins, geo:pins:{TYPE}, geo:pins:ready 키를 삭제합니다. // pin:info 키는 48h TTL 으로 자연 만료되므로 별도 삭제하지 않습니다. // (재적재 시 addPin 이 TTL 을 갱신합니다.) - // + // KEYS 명령어(O(N), 전체 DB 블로킹) 대신 MapPinCategory 열거값으로 // 삭제 대상 키를 직접 구성하여 단일 DEL 명령으로 처리합니다. + + // GEO_READY_KEY 도 함께 삭제해야 재구성 중인 상태를 다른 인스턴스가 인식합니다. private void clearGeoKeys() { try { List keysToDelete = new ArrayList<>(); keysToDelete.add(GEO_KEY_ALL); + keysToDelete.add(GEO_READY_KEY); for (MapPinCategory category : MapPinCategory.values()) { keysToDelete.add(GEO_KEY_PREFIX + category.getPinType()); } From e90e6b88b4f758efa2853d045cfee17799dce59f Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Thu, 18 Jun 2026 03:21:19 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=ED=99=98=EA=B2=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A0=A4=ED=95=9C=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/cache/PinGeoRedisInitializer.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java index 39c55f2..96e3396 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java @@ -15,6 +15,11 @@ // 서버 시작 시 DB의 활성 핀을 Redis GEO 캐시에 전체 적재합니다. // {@link ApplicationReadyEvent} 이후 비동기로 실행되어 서버 기동 지연이 없습니다. // 초기화 도중 핀 조회 API 호출이 오면 isGeoSetReady() == false 이므로 DB에서 응답합니다. + +// 다중 인스턴스 환경 고려사항: +// - 롤링 배포처럼 인스턴스가 순차적으로 기동되는 경우, geo:pins:ready 키로 중복 초기화를 방지합니다. +// - 인스턴스가 완전히 동시에 기동되더라도 bulkPopulate 는 멱등(idempotent)하게 설계되어 +// 동일 데이터를 중복 적재할 뿐 데이터 손상은 발생하지 않습니다. @Slf4j @Component @RequiredArgsConstructor @@ -28,6 +33,12 @@ public class PinGeoRedisInitializer { @EventListener(ApplicationReadyEvent.class) public void initialize() { try { + // geo:pins:ready 키가 존재하면 다른 인스턴스가 이미 캐시를 적재한 상태입니다. + // 롤링 배포 등 순차 기동 환경에서 불필요한 DB 조회와 Redis 중복 적재를 방지합니다. + if (pinGeoRedisService.isGeoSetReady()) { + log.info("[Redis GEO] 캐시가 이미 초기화되어 있어 초기화를 건너뜁니다."); + return; + } log.info("[Redis GEO] 캐시 초기화 시작 ..."); List views = pinLocationRepository.findAllActivePins(); pinGeoRedisService.bulkPopulate(views); From d8b4a122b6cfa6bd3dabb4dea707b68cb715334e Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Thu, 18 Jun 2026 15:56:12 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=9C=EA=B0=81=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issueissyu/backend/domain/map/cache/PinGeoScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java index 681c8b8..eb81c3d 100644 --- a/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java @@ -30,7 +30,7 @@ public class PinGeoScheduler { private final PinGeoRedisService pinGeoRedisService; private final RedisTemplate redisTemplate; - @Scheduled(cron = "0 30 1 * * *", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 5 * * *", zone = "Asia/Seoul") public void rebuildGeoCache() { // SET lock:geo:rebuild 1 NX PX 600000 — 원자적 명령으로 레이스 컨디션 없음 Boolean acquired = redisTemplate.opsForValue()