Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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());
}
}
Comment thread
taerimiiii marked this conversation as resolved.
}

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

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

}
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));
if (user.getRole() != UserRole.ADMIN) {
throw MapException.of(MapErrorCode.MAP_CACHE_403);
}
return new MapPinCacheStatusResDTO(pinGeoRedisService.getGeoPinCount());
}
Comment on lines +25 to +32

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

}
Loading
Loading