-
Notifications
You must be signed in to change notification settings - Fork 0
[REFACTOR] 지도 핀 조회 Redis GEO 캐시 적용 및 ADMIN 캐시 상태 API 추가 #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3254a06
ea569c0
03e21f1
1e1fa0e
56f259f
c82af3f
1e75b57
c92eecf
0620fc4
326942d
e90e6b8
d8b4a12
49ba5f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MapPinView> views = pinLocationRepository.findAllActivePins(); | ||
| pinGeoRedisService.bulkPopulate(views); | ||
| log.info("[Redis GEO] 캐시 초기화 완료: {}개 핀 적재", views.size()); | ||
| } catch (Exception e) { | ||
| log.warn("[Redis GEO] 캐시 초기화 실패 (서버는 정상 기동, DB 폴백 사용): {}", e.getMessage()); | ||
| } | ||
| } | ||
| } | ||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, String> 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<MapPinView> 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); | ||
| } | ||
| } | ||
|
Comment on lines
+34
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 다중 서버 환경에서 서버 간의 미세한 시간 차이(Clock Drift)가 존재할 때, 먼저 실행된 서버가 작업을 빠르게 마치고 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<MapPinView> views = pinLocationRepository.findAllActivePins();
pinGeoRedisService.bulkPopulate(views);
log.info("[Redis GEO] 야간 캐시 재구성 완료: {}개 핀", views.size());
} catch (Exception e) {
log.error("[Redis GEO] 야간 캐시 재구성 실패: {}", e.getMessage(), e);
}
} |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| package issueissyu.backend.domain.map.dto.res; | ||
|
|
||
| public record MapPinCacheStatusResDTO(long geoPinCount) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } | ||
|
Comment on lines
+25
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 비인증 사용자가 해당 API를 호출하거나 인증 정보가 누락된 경우, @Override
public MapPinCacheStatusResDTO getCacheStatus(String uid) {
if (uid == null) {
throw MapException.of(MapErrorCode.MAP_CACHE_403);
}
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());
} |
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.