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..96e3396 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisInitializer.java @@ -0,0 +1,50 @@ +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에서 응답합니다. + +// 다중 인스턴스 환경 고려사항: +// - 롤링 배포처럼 인스턴스가 순차적으로 기동되는 경우, geo:pins:ready 키로 중복 초기화를 방지합니다. +// - 인스턴스가 완전히 동시에 기동되더라도 bulkPopulate 는 멱등(idempotent)하게 설계되어 +// 동일 데이터를 중복 적재할 뿐 데이터 손상은 발생하지 않습니다. +@Slf4j +@Component +@RequiredArgsConstructor +public class PinGeoRedisInitializer { + + private final PinLocationRepository pinLocationRepository; + private final PinGeoRedisService pinGeoRedisService; + + @Async + @Order(1) + @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); + 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..e34f082 --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java @@ -0,0 +1,328 @@ +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 issueissyu.backend.domain.map.enums.MapPinCategory; +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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +// 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 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; + + // 위도 1도 ≈ 111 km + private static final double DEGREE_TO_KM = 111.0; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // bulkPopulate 가 완전히 끝난 시점에만 true 가 됩니다. + // @Async / 스케줄러 스레드에서 쓰고, HTTP 요청 스레드에서 읽으므로 volatile 필수입니다. + private volatile boolean isReady = false; + + // ───────────────────────────────────────────────────────────────── + // Write 흐름 + // ───────────────────────────────────────────────────────────────── + + // 핀을 GEO Set 과 pin:info 에 저장합니다. + // 활성 DB 트랜잭션이 있으면 커밋 완료 후에 Redis 쓰기를 실행합니다. + // DB 롤백 시 afterCommit 은 호출되지 않으므로 Redis 불일치가 발생하지 않습니다. + public void addPin(Long pinId, String pinType, double lat, double lng, + String detailAddress, String region, String discount) { + 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()); + } + }; + + 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) { + 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(); + } + } + + + + // ───────────────────────────────────────────────────────────────── + // Read 흐름 + // ───────────────────────────────────────────────────────────────── + + // 캐시 사용 가능 여부를 판단합니다. + + // 1. 로컬 isReady=true 이면 ZCARD 로 실제 데이터 존재 여부를 확인합니다. + // → 스케줄러가 GEO Set 을 비운 재구성 중에도 DB 폴백이 정상 동작합니다. + + // 2. 로컬 isReady=false 이면 Redis GEO_READY_KEY("geo:pins:ready") 를 조회합니다. + // → 다른 인스턴스에서 이미 적재가 완료된 경우 로컬 isReady 를 true 로 갱신합니다. + // → 신규 인스턴스가 자체 초기화가 끝나기 전에도 캐시를 활용할 수 있습니다. + public boolean isGeoSetReady() { + 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. + public long getGeoPinCount() { + try { + Long size = redisTemplate.opsForZSet().size(GEO_KEY_ALL); + return size != null ? size : 0L; + } catch (Exception e) { + log.warn("Redis GEO 상태 확인 실패: {}", e.getMessage()); + return 0L; + } + } + + // 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); + + if (jsonList == null) { + // MGET 자체가 실패한 경우 DB 폴백 + return Optional.empty(); + } + + List pins = new ArrayList<>(); + 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); + + } catch (Exception e) { + log.warn("Redis GEO BBox 검색 실패: {}", e.getMessage()); + return Optional.empty(); + } + } + + + + // ───────────────────────────────────────────────────────────────── + // Bulk 초기화 / 스케줄러 + // ───────────────────────────────────────────────────────────────── + + // GEO Set 을 초기화한 뒤, DB에서 조회한 활성 핀 목록으로 전체 재적재합니다. + // 서버 시작 및 매일 새벽 스케줄러에서 호출됩니다. + // 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 { + 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; + } + }); + // 파이프라인이 완전히 처리된 후에만 준비 완료로 표시합니다. + // 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: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()); + } + redisTemplate.delete(keysToDelete); + } 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..eb81c3d --- /dev/null +++ b/src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java @@ -0,0 +1,54 @@ +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.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 캐시 정합성 유지를 위한 야간 배치 스케줄러. + +// 다중 서버 환경에서 모든 인스턴스가 동시에 실행되는 문제를 방지하기 위해 +// 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 0 5 * * *", 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(); + pinGeoRedisService.bulkPopulate(views); + log.info("[Redis GEO] 야간 캐시 재구성 완료: {}개 핀", views.size()); + } catch (Exception e) { + log.error("[Redis GEO] 야간 캐시 재구성 실패: {}", e.getMessage(), e); + } finally { + // 정상·비정상 종료 모두 락 즉시 해제 (다음 예약 실행까지 기다릴 필요 없음) + redisTemplate.delete(REBUILD_LOCK_KEY); + } + } +} 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()); + } +} 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 66bb03e..1f4d480 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; @@ -70,6 +71,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); @@ -129,6 +131,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 f3a8715..8301900 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 @@ -11,6 +11,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.entity.PinImage; @@ -66,6 +67,7 @@ public class PinDeleteCommandServiceImpl implements PinDeleteCommandService { private final PinLocationRepository pinLocationRepository; private final UserRepository userRepository; private final PinAlarmCleaner pinAlarmCleaner; + private final PinGeoRedisService pinGeoRedisService; private final S3Utils s3Utils; @Override @@ -143,6 +145,9 @@ public void deletePin(String uid, Long pinId) { pinRepository.delete(pin); + // Redis GEO 캐시에서 제거 (실패해도 DB 삭제에 영향 없음) + pinGeoRedisService.removePin(pinId, pin.getPinType().name()); + // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 일괄 삭제 if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization( 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;