-
Notifications
You must be signed in to change notification settings - Fork 0
[MERGE] 통합 배포 PR #375
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
[MERGE] 통합 배포 PR #375
Changes from all commits
3254a06
ea569c0
03e21f1
1e1fa0e
56f259f
c82af3f
1e75b57
c92eecf
0620fc4
326942d
e90e6b8
d8b4a12
49ba5f0
55a8d1c
4cb0248
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); | ||
| } | ||
| } | ||
| } | ||
| 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)); | ||||||||||||||||||||||||
|
Comment on lines
+24
to
+27
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. Spring Security의
Suggested change
|
||||||||||||||||||||||||
| if (user.getRole() != UserRole.ADMIN) { | ||||||||||||||||||||||||
| throw MapException.of(MapErrorCode.MAP_CACHE_403); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return new MapPinCacheStatusResDTO(pinGeoRedisService.getGeoPinCount()); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
다중 인스턴스 환경에서 각 서버의 시스템 시간 미세 오차(Clock Drift)나 스케줄러 스레드 지연으로 인해 인스턴스 간 실행 시점에 수 초의 차이가 발생할 수 있습니다. 만약 먼저 실행된 인스턴스가 작업을 빠르게 마치고
finally블록에서 분산 락을 즉시 해제(redisTemplate.delete)하면, 약간 늦게 트리거된 다른 인스턴스가 다시 락을 획득하여 중복으로 재구성을 수행하게 됩니다.\n\n이 스케줄러는 하루에 한 번만 실행되므로,finally블록에서 락을 즉시 해제하지 않고REBUILD_LOCK_TTL(10분) 동안 자연 만료되도록 두는 것이 다중 인스턴스 환경에서 중복 실행을 방지하는 데 훨씬 안전합니다.