Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3254a06
feat: 핀 조회 시 Elastic Cache 구축
taerimiiii Jun 17, 2026
ea569c0
Merge remote-tracking branch 'origin/develop' into refactor/310-chache
taerimiiii Jun 17, 2026
03e21f1
feat: 캐시 적재 확인 API 구현
taerimiiii Jun 17, 2026
1e1fa0e
merge: 충돌 처리
taerimiiii Jun 17, 2026
56f259f
fix: Redis GEO bulk populate 파이프라인 개선
taerimiiii Jun 17, 2026
c82af3f
fix: ZSet 크기 설정에 따른 논리 오류 해결을 위한 volatile boolean isReady 플래그 도입과 bul…
taerimiiii Jun 17, 2026
1e75b57
fix: addPin과 removePin이 DB 트랜잭션 범위 내에서 호출되는 문제를 방지하기 위해 TransactionSy…
taerimiiii Jun 17, 2026
c92eecf
fix: 다중 서버 환경에서 스케쥴링 인스턴스 동시 실행 문제를 방지하기 위한 Redis SET NX PX 기반 분산 락 적용
taerimiiii Jun 17, 2026
0620fc4
feat: 데이터 정확성 보장을 위한 null 경우 빈 처리와, KEYS 명령어 전체 탐색 대신 카테고리 열거값 기준 삭제 적용
taerimiiii Jun 17, 2026
326942d
fix: 재구성 중 안전망 형성을 위해 isReady=true일 때도 ZCARD > 0 체크를 유지하고, bulkPopula…
taerimiiii Jun 17, 2026
e90e6b8
fix: 다중 인스턴스 환경을 고려한 초기화 건너뛰기 추가
taerimiiii Jun 17, 2026
d8b4a12
fix: 캐시 재빌드 시각 재설정
taerimiiii Jun 18, 2026
49ba5f0
Merge remote-tracking branch 'origin/develop' into refactor/310-chache
taerimiiii Jun 18, 2026
55a8d1c
fix: 사용자 시민해결사 조회 시 '이동중' 상태 제약 조건 해지
taerimiiii Jun 18, 2026
4cb0248
Merge remote-tracking branch 'origin/bug/373-user-solver' into merge/…
taerimiiii Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,33 @@ List<MapPinClusterView> findPinClustersInBoundingBox(
);

Optional<PinLocation> 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<MapPinView> findAllActivePins();
}
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 +42 to +52

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

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -113,6 +116,16 @@ public ApiResponse<PatchNoteResDTO> getPatchNotes(
patchNoteQueryService.getPatchNotes(uid, locationId, size, cursor));
}

@Operation(
summary = "지도 핀 캐시 상태 조회 (ADMIN)",
description = "Redis GEO Set(geo:pins)에 저장된 핀 개수(ZCARD)를 반환합니다. ADMIN 권한 필요.")
@GetMapping("/cache/status")
public ApiResponse<MapPinCacheStatusResDTO> getPinCacheStatus(@AuthenticationPrincipal String uid) {
return ApiResponse.onSuccess(
MapSuccessCode.MAP_CACHE_200,
mapPinCacheQueryService.getCacheStatus(uid));
}

@Operation(
summary = "단일 핀 카드 조회 (경로 변수)",
description = "communityId는 연결된 커뮤니티가 있을 때만 반환.")
Expand Down
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
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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

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

if (user.getRole() != UserRole.ADMIN) {
throw MapException.of(MapErrorCode.MAP_CACHE_403);
}
return new MapPinCacheStatusResDTO(pinGeoRedisService.getGeoPinCount());
}
}
Loading
Loading