Skip to content

[MERGE] 통합 배포 PR#375

Merged
taerimiiii merged 15 commits into
developfrom
merge/310-cache
Jun 18, 2026
Merged

[MERGE] 통합 배포 PR#375
taerimiiii merged 15 commits into
developfrom
merge/310-cache

Conversation

@taerimiiii

@taerimiiii taerimiiii commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🔗 Related Issue

✨ 작업 개요

작업 내용을 간략하게 작성해주세요.

통합 PR 입니다.

체크리스트

  • Reviewers, Assignees, Labels를 모두 등록했나요?
  • .gitignore 설정을 하였나요?
  • PR 머지 전 반드시 CI가 정상적으로 작동하는지 확인해주세요!

📷 이미지 첨부 (선택)

  • 작업 결과를 확인할 수 있는 이미지나 GIF를 첨부해주세요.
  • UI 변경, API 응답 샘플, 테스트 결과 등이 포함되면 좋아요!

🧐 집중 리뷰 요청

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요.

…te() 완료 시 SET geo:pins:ready 1을 다른 인스턴스에 전달하며, 삭제도 함께 함
@taerimiiii taerimiiii self-assigned this Jun 18, 2026
@taerimiiii taerimiiii added 🐞 bug 버그 수정 ♻️ refactor 기능 변화 없는 코드 리팩터링 ⭐ feat 새로운 기능 labels Jun 18, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Redis GEO caching for map pins to optimize spatial queries and reduce database load. It includes a startup initializer, a nightly rebuild scheduler with distributed locking, and synchronization of pin additions and deletions with the cache. Additionally, the filter for 'EN_ROUTE' state was removed when querying a user's solver pins. The review feedback highlights three key areas for improvement: letting the scheduler's distributed lock expire naturally to prevent duplicate executions caused by clock drift, batching Redis pipeline operations during bulk population to avoid blocking the Redis server, and adding a defensive null check for the user ID in the cache status query service to prevent potential internal server errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +42 to +52
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

다중 인스턴스 환경에서 각 서버의 시스템 시간 미세 오차(Clock Drift)나 스케줄러 스레드 지연으로 인해 인스턴스 간 실행 시점에 수 초의 차이가 발생할 수 있습니다. 만약 먼저 실행된 인스턴스가 작업을 빠르게 마치고 finally 블록에서 분산 락을 즉시 해제(redisTemplate.delete)하면, 약간 늦게 트리거된 다른 인스턴스가 다시 락을 획득하여 중복으로 재구성을 수행하게 됩니다.\n\n이 스케줄러는 하루에 한 번만 실행되므로, finally 블록에서 락을 즉시 해제하지 않고 REBUILD_LOCK_TTL(10분) 동안 자연 만료되도록 두는 것이 다중 인스턴스 환경에서 중복 실행을 방지하는 데 훨씬 안전합니다.

        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);
        }

Comment on lines +258 to +296
try {
redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
@Override
@SuppressWarnings("unchecked")
public <K, V> Object execute(org.springframework.data.redis.core.RedisOperations<K, V> operations) {
// executePipelined 는 RedisTemplate 자신을 operations 로 전달하므로
// 인터페이스 타입으로 캐스트하여 파이프라인 내 명령을 큐잉합니다.
org.springframework.data.redis.core.RedisOperations<String, String> ops =
(org.springframework.data.redis.core.RedisOperations<String, String>) 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;
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 bulkPopulate 메서드는 전체 활성 핀 목록(views)을 단일 Redis 파이프라인(executePipelined)으로 처리하고 있습니다. 만약 활성 핀의 개수가 수만 개 이상으로 늘어날 경우, 단일 파이프라인에 너무 많은 명령(핀당 3개씩, 총 수만 개의 명령)이 포함되어 Redis 서버의 메모리 사용량이 급증하거나 다른 요청을 블로킹할 위험이 있습니다.\n\n따라서, 데이터를 일정 크기(예: 1000개)의 배치(Batch) 단위로 나누어 파이프라인을 나누어 실행하는 것이 안전합니다.

        try {
            int batchSize = 1000;
            for (int i = 0; i < views.size(); i += batchSize) {
                List<MapPinView> batch = views.subList(i, Math.min(i + batchSize, views.size()));
                redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
                    @Override
                    @SuppressWarnings("unchecked")
                    public <K, V> Object execute(org.springframework.data.redis.core.RedisOperations<K, V> operations) {
                        org.springframework.data.redis.core.RedisOperations<String, String> ops =
                                (org.springframework.data.redis.core.RedisOperations<String, String>) operations;

                        for (MapPinView view : batch) {
                            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;
                    }
                });
            }

Comment on lines +24 to +27
@Override
public MapPinCacheStatusResDTO getCacheStatus(String uid) {
User user = userRepository.findById(uid)
.orElseThrow(() -> GeneralException.of(GeneralErrorCode.USER_NOT_FOUND));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Spring Security의 @AuthenticationPrincipal을 통해 주입되는 uid는 인증되지 않은 요청이거나 설정 오류 시 null이 될 수 있습니다. uidnull인 상태로 userRepository.findById(uid)를 호출하면 Spring Data JPA 내부에서 IllegalArgumentException이 발생하여 500 Internal Server Error를 유발할 수 있습니다.\n\n방어적 프로그래밍(Defensive Programming) 관점에서 uidnull인지 먼저 검증하고 적절한 예외를 던지도록 개선하는 것이 안전합니다.

Suggested change
@Override
public MapPinCacheStatusResDTO getCacheStatus(String uid) {
User user = userRepository.findById(uid)
.orElseThrow(() -> GeneralException.of(GeneralErrorCode.USER_NOT_FOUND));
@Override
public MapPinCacheStatusResDTO getCacheStatus(String uid) {
if (uid == null) {
throw GeneralException.of(GeneralErrorCode.USER_NOT_FOUND);
}
User user = userRepository.findById(uid)
.orElseThrow(() -> GeneralException.of(GeneralErrorCode.USER_NOT_FOUND));

@taerimiiii taerimiiii merged commit a0921f3 into develop Jun 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞 bug 버그 수정 ⭐ feat 새로운 기능 ♻️ refactor 기능 변화 없는 코드 리팩터링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 사용자 참여 시민해결사 조회 시 '이동중' 상태 제약 조건 해지 [REFACTOR] 최근 조회된 핀 캐시 서버 활용

1 participant