Skip to content

[REFACTOR] 지도 핀 조회 Redis GEO 캐시 적용 및 ADMIN 캐시 상태 API 추가#367

Merged
taerimiiii merged 13 commits into
developfrom
refactor/310-chache
Jun 18, 2026
Merged

[REFACTOR] 지도 핀 조회 Redis GEO 캐시 적용 및 ADMIN 캐시 상태 API 추가#367
taerimiiii merged 13 commits into
developfrom
refactor/310-chache

Conversation

@taerimiiii

@taerimiiii taerimiiii commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🔗 Related Issue

✨ 작업 개요

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

지도 핀 조회 API(GET /api/map/pins, GET /api/map/clustering/pins) 성능 개선을 위해 Redis GEO 캐시를 도입했습니다.

  • Two-Track 전략: 위치 인덱스(geo:pins, geo:pins:{TYPE})와 렌더링 메타데이터(pin:info:{pinId})를 분리 저장
  • Cache-Aside: Redis 준비 시 GEOSEARCH + MGET으로 조회, 실패·미준비 시 기존 PostGIS DB 폴백
  • Write 동기화: STORE/COMMUNICATION 핀 등록, 핀 삭제 시 Redis 즉시 반영
  • 정합성 유지: 서버 기동 시 DB → Redis 전체 적재, 매일 새벽 1:30 KST 야간 재구성
  • 운영 확인용 API: ADMIN 전용 GET /api/map/cache/status (ZCARD geo:pins 반환)

캐시 상태 API (ADMIN)

GET /api/map/cache/status
Authorization: Bearer {ADMIN_AccessToken}
{
  "isSuccess": true,
  "code": "MAP_CACHE_200",
  "message": "지도 핀 캐시 상태 조회에 성공했습니다.",
  "result": { "geoPinCount": 42 }
}

서버 기동 로그 (정상 적재 시)

[Redis GEO] 캐시 초기화 시작 ...
[Redis GEO] 캐시 초기화 완료: N개 핀 적재

Redis CLI 확인

ZCARD geo:pins
TYPE geo:pins   # zset
GET pin:info:{pinId}
  1. Cache-Aside 폴백 전략
    Redis 장애·미초기화 시 DB로 자동 전환되도록 설계했습니다.

  2. 클러스터링 API의 Java-side 클러스터링
    캐시 히트 시 PostGIS ST_SnapToGrid 대신 round(lat/gridSize)*gridSize로 클러스터링합니다.

  3. Write 동기화 범위
    현재 STORE/COMMUNICATION 등록·삭제만 Redis에 반영합니다. ISSUE/FESTIVAL 등 다른 경로로 생성되는 핀은 야간 스케줄러·서버 재기동 시 재적재됩니다.

  4. 초기화 로그
    캐시 초기화 완료: N개 핀 적재는 DB 조회 건수이며, Redis 저장 실패 건수와 다를 수 있습니다. 운영 중 Error in execution 발생 시 geoPinCount: 0과 함께 DB 폴백으로 동작합니다.

체크리스트

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

📷 이미지 첨부 (선택)

일단 로컬에서는 아무 경고도 에러도 없이 잘 적재 됩니다(감격)
image

GET /api/map/cache/status 성공
image
정상적으로 동일한 갯수가 적재되고 있음을 확인했습니다!

이미 초기화가 되어 있는 경우 건너뛰는 부분도 구현하였습니다.
image

🧐 집중 리뷰 요청

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

GEOSEARCH 사용으로 Redis 6.2+ (또는 호환 Valkey)가 필요합니다. ElastiCache 엔진 버전 확인을 권장합니다.
-> 8.2.0 버전으로 올라가 있는 것 확인했습니다!

주요 변경 파일

구분 파일
캐시 코어 PinGeoRedisService, PinGeoRedisInitializer, PinGeoScheduler
조회 MapPinQueryServiceImpl (Cache-Aside + Java-side 클러스터링)
Write 연동 PinStoreCommandServiceImpl, PinCommunicationCommandServiceImpl, PinDeleteCommandServiceImpl
DB PinLocationRepository.findAllActivePins()
Admin API MapController, MapPinCacheQueryService, MapPinCacheStatusResDTO

isGeoSetReady()에서 예외처리를 구현해 두었기 때문에,
만약 캐시 구현이 잘못되어서 Redis 장애가 발생 할 시, 자동으로 DB 폴백합니다.
즉, ElastiCache에 문제가 생겨도 핀 조회 API는 정상 동작합니다!

Wirte의 경우 addPin / removePin 내부에서 예외를 catch해서 log.warn 출력만 합니다.
Redis 업데이트는 무시하는 방향으로 설계하였습니닷.

걱정되는 점(?) 주의할 점(?)으로는 만약 서버에서 ElastiCache가 다운되는 일이 발생하면,
고대로~~~ 모든 핀 조회가 Redis 기본 타임아웃이 발생하기 까지 지연됩니다.
그으래서 야멜에 타임아웃 설정을 만져주면 좋다?고 합니다..

spring:
  data:
    redis:
      timeout: 1000ms          # 명령 타임아웃 (1초 권장)
      connect-timeout: 5000ms  # 연결 타임아웃(1초가좋다고는하는데걱정되니까5초로..)

쩝 근데 캐시 다운 되지는 않지 않을까여..

구현은 다 되었습니다...!
백엔드에 한해서는 읽기/쓰기 모두 문제가 없지만, 프로젝트 전체 범위로 보면 이야기가 다릅니다.
저희가 핀이 이슈/소통/가게/축제 네 가지인데, 백엔드는 소통과 가게 등록을 담당합니다. 이슈와 축제는 AI 파트가 담당하고 있죠.
현재 캐시는 Redis GEO를 활용하여 구현하였고, 매일 새벽 1:30에 재빌드합니다.
그럼 캐시에서 읽기 연산을 해 핀 조회를 할 때, 당일 추가된 이슈는 캐시 쓰기 연산이 안 들어가기 때문에 다음날 새벽 1:30 재빌드 전까지 조회되지 않습니다.
즉, 조졌습니다 ><..
AI 파트와 BE 파트 모두에서 캐시 적용이 끝나야 배포가 가능합니다 ^v^...(절규)

@taerimiiii taerimiiii requested a review from yaaan7 June 17, 2026 13:08
@taerimiiii taerimiiii self-assigned this Jun 17, 2026
@taerimiiii taerimiiii added the ♻️ refactor 기능 변화 없는 코드 리팩터링 label Jun 17, 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 a Redis GEO-based caching layer for map pins, including services for initialization, scheduling, and querying cache status, alongside updates to synchronize pin creation and deletion with the cache. The review feedback highlights critical opportunities to improve performance and reliability, such as utilizing Redis pipelining for bulk operations, avoiding the blocking KEYS command, preventing race conditions during cache initialization and scheduled execution in clustered environments, synchronizing Redis writes with DB transaction commits, and ensuring strict cache consistency by falling back to the DB when pin metadata is missing.

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 thread src/main/java/issueissyu/backend/domain/map/cache/PinGeoScheduler.java Outdated
Comment on lines +201 to +212
private void clearGeoKeys() {
try {
redisTemplate.delete(GEO_KEY_ALL);
// geo:pins:* 키는 핀 타입 수 만큼이므로 keys() 사용이 안전
Set<String> typeKeys = redisTemplate.keys(GEO_KEY_PREFIX + "*");
if (typeKeys != null && !typeKeys.isEmpty()) {
redisTemplate.delete(typeKeys);
}
} catch (Exception e) {
log.warn("Redis GEO 키 전체 삭제 실패: {}", e.getMessage());
}
}

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

현재 clearGeoKeys 메서드에서 redisTemplate.keys(GEO_KEY_PREFIX + "*")를 사용하고 있습니다. Redis의 KEYS 명령은 모든 키를 탐색하는 블로킹 명령어로, 운영 환경에서 데이터가 많아질 경우 Redis 서버가 일시적으로 멈추는 심각한 성능 장애를 유발할 수 있습니다.

핀 타입은 고정되어 있으므로, 알려진 핀 타입 목록을 정의하여 직접 삭제하거나 SCAN 명령을 사용하는 방식으로 변경하여 블로킹을 방지해야 합니다.

    private static final List<String> PIN_TYPES = List.of("ISSUE", "COMMUNICATION", "STORE", "FESTIVAL");

    private void clearGeoKeys() {
        try {
            redisTemplate.delete(GEO_KEY_ALL);
            List<String> keysToDelete = PIN_TYPES.stream()
                    .map(type -> GEO_KEY_PREFIX + type)
                    .toList();
            redisTemplate.delete(keysToDelete);
        } catch (Exception e) {
            log.warn("Redis GEO 키 전체 삭제 실패: {}", e.getMessage());
        }
    }

Comment on lines +157 to +169
List<MapPinResDTO.PinItemDTO> 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);
}
}
}

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

현재 pin:info 메타데이터 캐시가 만료(TTL 48시간)되었거나 유실된 경우, 해당 핀을 단순히 스킵하고 남은 핀만 반환하고 있습니다. 이 경우 사용자에게 일부 핀이 누락된 불완전한 지도가 노출되는 정합성 문제가 발생합니다.

캐시 데이터의 정합성을 보장하기 위해, multiGet 결과 중 하나라도 null이 존재한다면 캐시 미스로 판단하고 Optional.empty()를 반환하여 안전하게 DB 폴백이 수행되도록 개선하는 것을 권장합니다.

            List<MapPinResDTO.PinItemDTO> pins = new ArrayList<>();
            if (jsonList != null) {
                for (String json : jsonList) {
                    if (json == null) {
                        log.warn("Redis GEO 캐시 정합성 깨짐 (pin:info 분실). DB 폴백을 수행합니다.");
                        return Optional.empty();
                    }
                    MapPinResDTO.PinItemDTO dto =
                            objectMapper.readValue(json, MapPinResDTO.PinItemDTO.class);
                    if (dto.latitude() >= swLat && dto.latitude() <= neLat
                            && dto.longitude() >= swLng && dto.longitude() <= neLng) {
                        pins.add(dto);
                    }
                }
            }
            return Optional.of(pins);

@taerimiiii

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 a Redis GEO-based caching layer for map pins to improve query performance, including cache initialization, nightly cache rebuilding with distributed locks, and an admin endpoint for cache status. The reviewer provided valuable feedback pointing out several production-level issues: the use of the blocking Redis KEYS command, potential data inconsistency if pin metadata is evicted, race conditions during initialization in multi-server environments, clock drift issues with the scheduler's lock release, and a potential exception if the user ID is null in the cache status query. All review comments are highly actionable and should be addressed.

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 thread src/main/java/issueissyu/backend/domain/map/cache/PinGeoRedisService.java Outdated
return null;
}
});
isReady = true; // 파이프라인이 Redis에서 완전히 처리된 후에만 준비 완료 표시

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가 성공적으로 완료된 시점에 Redis에 글로벌 완료 키(geo:pins:ready)를 저장하여, 다른 인스턴스들이 캐시가 준비되었음을 인지할 수 있도록 합니다.

            isReady = true; // 파이프라인이 Redis에서 완전히 처리된 후에만 준비 완료 표시
            redisTemplate.opsForValue().set("geo:pins:ready", "true");

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

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 블록에서 분산 락(REBUILD_LOCK_KEY)을 즉시 삭제해 버리면, 약간 늦게 시작한 다른 서버가 동일한 시간에 락을 다시 획득하여 야간 배치를 중복 실행할 위험이 있습니다.\n\n야간 배치는 하루에 단 한 번만 실행되므로, 락을 명시적으로 삭제하지 않고 REBUILD_LOCK_TTL(10분) 동안 자연 만료되도록 두는 것이 중복 실행을 방지하는 가장 안전한 방법입니다.

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

Comment on lines +25 to +32
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());
}

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

비인증 사용자가 해당 API를 호출하거나 인증 정보가 누락된 경우, @AuthenticationPrincipal String uidnull로 전달될 수 있습니다. 이 경우 userRepository.findById(null) 호출 시 Spring Data JPA 내부에서 IllegalArgumentException이 발생하여 클라이언트에게 500 Internal Server Error가 반환됩니다.\n\n안전한 예외 처리와 명확한 에러 응답을 위해, uid에 대한 null 체크를 선행하여 권한 없음 예외(MAP_CACHE_403)를 명시적으로 던지도록 개선하는 것이 좋습니다.

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

@yaaan7 yaaan7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

그녀가 해냈다ㅏ....!!!!

@selnem

selnem commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

ai 구현중입니다 버저비터 해내보이겠습니다

@taerimiiii taerimiiii mentioned this pull request Jun 18, 2026
3 tasks
@taerimiiii taerimiiii merged commit 4cb0248 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

♻️ refactor 기능 변화 없는 코드 리팩터링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] 최근 조회된 핀 캐시 서버 활용

3 participants