체크인 시 PostgreSQL(통계용 영구 기록)과 Redis(실시간 활성 체크인)에 함께 저장. 활성 메이트 조회를 Redis ZSet(member=dogId, score=체크인시각, 24h TTL)에서 최근 24시간·최신순으로 수행. Postgres 는 이벤트마다 누적(통계). - CheckInRedisStore: ZSet + 24h 키 TTL, 조회 시 만료 정리 - application.yml: spring.data.redis (env 주입, 기본 localhost:6379) - Redis 미기동 시에도 컨텍스트 로드(Lettuce 지연연결) → CI/테스트 안전 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,7 @@ build/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Redis 로컬 실행 시 생성
|
||||||
|
dump.rdb
|
||||||
|
*.rdb
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ dependencies {
|
|||||||
// 실시간 인앱 채팅 (STOMP over WebSocket)
|
// 실시간 인앱 채팅 (STOMP over WebSocket)
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
|
|
||||||
|
// 실시간 스팟 체크인(24h TTL) — 활성 메이트 조회. 통계는 PostgreSQL.
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
||||||
implementation 'org.hibernate.orm:hibernate-spatial'
|
implementation 'org.hibernate.orm:hibernate-spatial'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.dog.dognation.domain.spot;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스팟 실시간 체크인 저장소 (Redis).
|
||||||
|
* 스팟별 ZSet(member=dogId, score=체크인 epoch초)으로 관리한다.
|
||||||
|
* - 조회 시 24시간이 지난 항목은 정리하고, 키에도 24h TTL 을 걸어 유휴 시 자동 삭제.
|
||||||
|
* - 영구 통계는 PostgreSQL check_ins 테이블이 담당(이 저장소와 별개).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CheckInRedisStore {
|
||||||
|
|
||||||
|
/** 실시간 체크인 유효시간. 이 시간이 지나면 활성 메이트에서 비노출. */
|
||||||
|
static final Duration TTL = Duration.ofHours(24);
|
||||||
|
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
|
||||||
|
public CheckInRedisStore(StringRedisTemplate redis) {
|
||||||
|
this.redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String key(Long spotId) {
|
||||||
|
return "spot:checkin:" + spotId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 체크인 기록 — dogId 를 현재 시각 점수로 추가하고 키 TTL 을 24h 로 갱신한다. */
|
||||||
|
public void add(Long spotId, Long dogId) {
|
||||||
|
String k = key(spotId);
|
||||||
|
redis.opsForZSet().add(k, String.valueOf(dogId), Instant.now().getEpochSecond());
|
||||||
|
redis.expire(k, TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최근 24시간 내 체크인한 dogId 목록(최신순). 만료 항목은 조회 시 정리. */
|
||||||
|
public List<Long> activeDogIds(Long spotId) {
|
||||||
|
String k = key(spotId);
|
||||||
|
double cutoff = Instant.now().minus(TTL).getEpochSecond();
|
||||||
|
redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff);
|
||||||
|
Set<String> members = redis.opsForZSet()
|
||||||
|
.reverseRangeByScore(k, cutoff, Double.POSITIVE_INFINITY);
|
||||||
|
if (members == null || members.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return members.stream().map(Long::valueOf).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,24 +8,31 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SpotService {
|
public class SpotService {
|
||||||
|
|
||||||
/** 체크인 유효시간(분). 이 시간이 지나면 스팟에서 자동 비노출. */
|
/** 체크인 통계 기록의 만료 표기(분). 실시간 노출은 Redis(24h)가 담당. */
|
||||||
private static final int CHECKIN_TTL_MINUTES = 120;
|
private static final int CHECKIN_TTL_MINUTES = 120;
|
||||||
|
|
||||||
private final SpotRepository spotRepository;
|
private final SpotRepository spotRepository;
|
||||||
private final CheckInRepository checkInRepository;
|
private final CheckInRepository checkInRepository;
|
||||||
|
private final CheckInRedisStore checkInRedisStore;
|
||||||
private final SpotReviewRepository reviewRepository;
|
private final SpotReviewRepository reviewRepository;
|
||||||
private final DogRepository dogRepository;
|
private final DogRepository dogRepository;
|
||||||
|
|
||||||
public SpotService(SpotRepository spotRepository,
|
public SpotService(SpotRepository spotRepository,
|
||||||
CheckInRepository checkInRepository,
|
CheckInRepository checkInRepository,
|
||||||
|
CheckInRedisStore checkInRedisStore,
|
||||||
SpotReviewRepository reviewRepository,
|
SpotReviewRepository reviewRepository,
|
||||||
DogRepository dogRepository) {
|
DogRepository dogRepository) {
|
||||||
this.spotRepository = spotRepository;
|
this.spotRepository = spotRepository;
|
||||||
this.checkInRepository = checkInRepository;
|
this.checkInRepository = checkInRepository;
|
||||||
|
this.checkInRedisStore = checkInRedisStore;
|
||||||
this.reviewRepository = reviewRepository;
|
this.reviewRepository = reviewRepository;
|
||||||
this.dogRepository = dogRepository;
|
this.dogRepository = dogRepository;
|
||||||
}
|
}
|
||||||
@@ -43,17 +50,25 @@ public class SpotService {
|
|||||||
if (!dogRepository.existsById(dogId)) {
|
if (!dogRepository.existsById(dogId)) {
|
||||||
throw new EntityNotFoundException("강아지를 찾을 수 없습니다: " + dogId);
|
throw new EntityNotFoundException("강아지를 찾을 수 없습니다: " + dogId);
|
||||||
}
|
}
|
||||||
|
// 통계용 영구 기록(PostgreSQL)
|
||||||
OffsetDateTime expiresAt = OffsetDateTime.now().plusMinutes(CHECKIN_TTL_MINUTES);
|
OffsetDateTime expiresAt = OffsetDateTime.now().plusMinutes(CHECKIN_TTL_MINUTES);
|
||||||
return checkInRepository.save(new CheckIn(spotId, userId, dogId, expiresAt));
|
CheckIn saved = checkInRepository.save(new CheckIn(spotId, userId, dogId, expiresAt));
|
||||||
|
// 실시간 활성 체크인(Redis, 24h TTL) — 활성 메이트 노출용
|
||||||
|
checkInRedisStore.add(spotId, dogId);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 스팟에 현재 체크인 중인 강아지(메이트) 목록. */
|
/** 스팟에 현재 체크인 중인 강아지(메이트) 목록 — 최근 24시간(Redis), 최신순. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Dog> activeMates(Long spotId) {
|
public List<Dog> activeMates(Long spotId) {
|
||||||
List<CheckIn> active = checkInRepository
|
List<Long> dogIds = checkInRedisStore.activeDogIds(spotId);
|
||||||
.findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(spotId, OffsetDateTime.now());
|
if (dogIds.isEmpty()) {
|
||||||
List<Long> dogIds = active.stream().map(CheckIn::getDogId).distinct().toList();
|
return List.of();
|
||||||
return dogRepository.findAllById(dogIds);
|
}
|
||||||
|
// Redis 최신순 유지 — findAllById 는 순서를 보장하지 않으므로 매핑 후 재정렬.
|
||||||
|
Map<Long, Dog> byId = dogRepository.findAllById(dogIds).stream()
|
||||||
|
.collect(Collectors.toMap(Dog::getId, Function.identity()));
|
||||||
|
return dogIds.stream().map(byId::get).filter(Objects::nonNull).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ spring:
|
|||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
|
||||||
|
# 실시간 스팟 체크인(24h TTL) 저장소. 값은 .env 에서 주입, 미설정 시 로컬 기본값.
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:}
|
||||||
|
# StringRedisTemplate 만 사용(RedisHash 리포지토리 없음) → JPA 리포지토리 오분류 스캔 비활성.
|
||||||
|
repositories:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
# 스케줄러/시간 기준: 한국 시간(KST) 자정
|
# 스케줄러/시간 기준: 한국 시간(KST) 자정
|
||||||
app:
|
app:
|
||||||
scheduler:
|
scheduler:
|
||||||
|
|||||||
Reference in New Issue
Block a user