feat: 스팟 체크인 실시간 저장소 Redis 도입 (24h TTL)
CI / build (push) Failing after 13m37s

체크인 시 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:
sb
2026-07-11 13:26:30 +09:00
parent 6da7339626
commit 8d3be8493c
5 changed files with 91 additions and 7 deletions
+4
View File
@@ -13,3 +13,7 @@ build/
# OS
.DS_Store
# Redis 로컬 실행 시 생성
dump.rdb
*.rdb
+3
View File
@@ -25,6 +25,9 @@ dependencies {
// 실시간 인앱 채팅 (STOMP over WebSocket)
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// 실시간 스팟 체크인(24h TTL) — 활성 메이트 조회. 통계는 PostgreSQL.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
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.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class SpotService {
/** 체크인 유효시간(분). 이 시간이 지나면 스팟에서 자동 비노출. */
/** 체크인 통계 기록의 만료 표기(분). 실시간 노출은 Redis(24h)가 담당. */
private static final int CHECKIN_TTL_MINUTES = 120;
private final SpotRepository spotRepository;
private final CheckInRepository checkInRepository;
private final CheckInRedisStore checkInRedisStore;
private final SpotReviewRepository reviewRepository;
private final DogRepository dogRepository;
public SpotService(SpotRepository spotRepository,
CheckInRepository checkInRepository,
CheckInRedisStore checkInRedisStore,
SpotReviewRepository reviewRepository,
DogRepository dogRepository) {
this.spotRepository = spotRepository;
this.checkInRepository = checkInRepository;
this.checkInRedisStore = checkInRedisStore;
this.reviewRepository = reviewRepository;
this.dogRepository = dogRepository;
}
@@ -43,17 +50,25 @@ public class SpotService {
if (!dogRepository.existsById(dogId)) {
throw new EntityNotFoundException("강아지를 찾을 수 없습니다: " + dogId);
}
// 통계용 영구 기록(PostgreSQL)
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)
public List<Dog> activeMates(Long spotId) {
List<CheckIn> active = checkInRepository
.findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(spotId, OffsetDateTime.now());
List<Long> dogIds = active.stream().map(CheckIn::getDogId).distinct().toList();
return dogRepository.findAllById(dogIds);
List<Long> dogIds = checkInRedisStore.activeDogIds(spotId);
if (dogIds.isEmpty()) {
return List.of();
}
// 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)
+10
View File
@@ -23,6 +23,16 @@ spring:
baseline-on-migrate: true
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) 자정
app:
scheduler: