diff --git a/.gitignore b/.gitignore index 6655dac..25559a6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ build/ # OS .DS_Store + +# Redis 로컬 실행 시 생성 +dump.rdb +*.rdb diff --git a/build.gradle b/build.gradle index 600a39d..0b9c9ee 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java b/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java new file mode 100644 index 0000000..af3abd8 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java @@ -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 activeDogIds(Long spotId) { + String k = key(spotId); + double cutoff = Instant.now().minus(TTL).getEpochSecond(); + redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff); + Set members = redis.opsForZSet() + .reverseRangeByScore(k, cutoff, Double.POSITIVE_INFINITY); + if (members == null || members.isEmpty()) { + return List.of(); + } + return members.stream().map(Long::valueOf).toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotService.java b/src/main/java/com/dog/dognation/domain/spot/SpotService.java index 36fa161..dd353bb 100644 --- a/src/main/java/com/dog/dognation/domain/spot/SpotService.java +++ b/src/main/java/com/dog/dognation/domain/spot/SpotService.java @@ -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 activeMates(Long spotId) { - List active = checkInRepository - .findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(spotId, OffsetDateTime.now()); - List dogIds = active.stream().map(CheckIn::getDogId).distinct().toList(); - return dogRepository.findAllById(dogIds); + List dogIds = checkInRedisStore.activeDogIds(spotId); + if (dogIds.isEmpty()) { + return List.of(); + } + // Redis 최신순 유지 — findAllById 는 순서를 보장하지 않으므로 매핑 후 재정렬. + Map 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) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7be8a24..5e6eb47 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: