목차
반응형
1. 요구사항 정리
- 실시간 집계
사용자가 검색 즉시 “인기 검색어” 점수에 반영
- 조작 방지
동일 IP(또는 로그인 유저)가 같은 키워드를 반복 검색해 점수를 부당하게 올리는 행위 차단
- 시간 가중치 적용
최근 검색일수록 더 큰 가중치를 부여
- 시간대 보정
새벽 시간대에는 보정값을 높여 점수를 낮게, 오후 시간대엔 보정값을 낮춰 점수를 높게
- 전체 검색량 보정
평소 검색량(과거 일주일 등)이 많은 키워드는 보정값으로 점수를 낮춤
- 지속성 확보
Redis 인메모리 휘발성 대비 RDBMS 백업
위 내용으로 요구사항을 정리한 뒤 구현에 들어갔다.
2. 구현
2-1. 새로운 로직 추가 (Trending)
package com.example.footprint.trending.controller;
import com.example.footprint.trending.dto.TrendingDto;
import com.example.footprint.trending.service.TrendingService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/trending")
@RequiredArgsConstructor
public class TrendingController {
private final TrendingService trendSvc;
/**
* 검색 호출 시마다 기록 & 실시간 top10 반환
*/
@PostMapping("/record")
public ResponseEntity<List<TrendingDto>> record(@RequestParam String keyword,
HttpServletRequest req) {
String ip = req.getRemoteAddr();
return ResponseEntity.ok(trendSvc.record(keyword, ip));
}
/** 단순 조회: 실시간 top10 */
@GetMapping
public ResponseEntity<List<TrendingDto>> getTop() {
return ResponseEntity.ok(trendSvc.getTop());
}
}
package com.example.footprint.trending.dto;
import lombok.*;
@Getter @Setter @AllArgsConstructor @NoArgsConstructor @Builder
public class TrendingDto {
private int rank; // 1~10
private String keyword;
private String change; // "up" | "down" | "same"
private int changeValue; // 변동 폭
private double score; // 내부 점수
}
package com.example.footprint.trending.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "search_event")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SearchEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String keyword;
@Column(name = "client_ip", length = 45)
private String clientIp;
@Column(name = "user_id")
private Long userId;
@Column(name = "event_time", nullable = false)
private LocalDateTime eventTime;
}
package com.example.footprint.trending.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "trending_backup")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TrendingBackup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String keyword;
@Column(nullable = false)
private double score;
@Column(name = "snapshot_at", nullable = false)
private LocalDateTime snapshotAt;
}
package com.example.footprint.trending.repository;
import com.example.footprint.trending.entity.SearchEvent;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.Optional;
public interface SearchEventRepository extends JpaRepository<SearchEvent, Long> {
// 특정 키워드의 시간 범위 내 검색 횟수
@Query("SELECT COUNT(e) FROM SearchEvent e " +
"WHERE e.keyword = :kw " +
" AND e.eventTime BETWEEN :start AND :end")
long countByKeywordBetween(
@Param("kw") String keyword,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
// 최근 이벤트 시간 조회 (시간 가중치용)
@Query("SELECT MAX(e.eventTime) FROM SearchEvent e WHERE e.keyword = :kw")
Optional<LocalDateTime> findLastEventTime(@Param("kw") String keyword);
}
package com.example.footprint.trending.repository;
import com.example.footprint.trending.entity.TrendingBackup;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.List;
public interface TrendingBackupRepository extends JpaRepository<TrendingBackup, Long> {
/** 가장 최신 snapshotAt 을 직접 JPQL 로 조회 */
@Query("SELECT MAX(t.snapshotAt) FROM TrendingBackup t")
LocalDateTime findLatestSnapshotAt();
/** 해당 timestamp 에 찍힌 레코드 중 score 내림차순 TOP10 */
List<TrendingBackup> findTop10BySnapshotAtOrderByScoreDesc(LocalDateTime snapshotAt);
}
package com.example.footprint.trending.scheduler;
import com.example.footprint.trending.entity.TrendingBackup;
import com.example.footprint.trending.repository.SearchEventRepository;
import com.example.footprint.trending.repository.TrendingBackupRepository;
import com.example.footprint.trending.service.TrendingService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class TrendingScheduler {
private final RedisTemplate<String, String> redis;
private final SearchEventRepository evRepo;
private final TrendingBackupRepository backupRepo;
private final TrendingService trendSvc;
/**
* 매시간 재계산: 시간 가중치, 시간대 보정, 전체 검색량 보정
*/
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void recompute() {
String key = "trending:keywords";
Set<String> keywords = redis.opsForZSet().range(key, 0, -1);
if (keywords == null) return;
LocalDateTime now = LocalDateTime.now();
for (String kw : keywords) {
// 1h, 24h, 1w 통계
double h1 = evRepo.countByKeywordBetween(kw, now.minusHours(1), now);
double h24= evRepo.countByKeywordBetween(kw, now.minusDays(1), now);
double w7 = evRepo.countByKeywordBetween(kw, now.minusWeeks(1), now);
double avgW = w7 / (7 * 24);
// 시간대 보정 x(time)
double x = biasByHour(now.getHour());
// 시간 가중치 w(time) = 1/(지연된 분 +1)
double w = evRepo.findLastEventTime(kw)
.map(t -> Duration.between(t, now).toMinutes() + 1)
.map(d -> 1.0 / d)
.orElse(0.0);
// 전체 검색량 보정
double vAdj = Math.pow(2, -Math.log(w7 + 1));
// 최종 점수
double dayScore = h1 / (h24 == 0 ? 1 : h24);
double weekScore = h1 / (avgW == 0 ? 1 : avgW);
double totalScore = (dayScore * weekScore - x) * vAdj * w;
// Redis 덮어쓰기
redis.opsForZSet().add(key, kw, totalScore);
}
}
/**
* 매시간 top10 RDB 백업 (지속성 확보)
*/
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void backup() {
LocalDateTime ts = LocalDateTime.now();
trendSvc.getTop().forEach(dto -> {
backupRepo.save(TrendingBackup.builder()
.keyword(dto.getKeyword())
.score(dto.getScore())
.snapshotAt(ts)
.build());
});
}
private double biasByHour(int hour) {
if (hour < 6) return 0.2; // 새벽
if (hour < 12) return 0.1; // 오전
if (hour < 18) return 0.05; // 오후
return 0.1; // 저녁
}
}
package com.example.footprint.trending.service;
import com.example.footprint.trending.dto.TrendingDto;
import com.example.footprint.trending.entity.SearchEvent;
import com.example.footprint.trending.entity.TrendingBackup;
import com.example.footprint.trending.repository.SearchEventRepository;
import com.example.footprint.trending.repository.TrendingBackupRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class TrendingService {
private static final String TREND_KEY = "trending:keywords";
private static final long IP_TTL_SEC = 60;
private final RedisTemplate<String, String> redis;
private final DefaultRedisScript<List<String>> trendingScript;
private final TrendingBackupRepository backupRepo;
private final SearchEventRepository evRepo;
public List<TrendingDto> record(String keyword, String clientIp) {
// 1) Redis Lua 스크립트 실행
String ipKey = "search:ip:" + clientIp;
List<String> raw = redis.execute(
trendingScript,
List.of(ipKey),
keyword, String.valueOf(IP_TTL_SEC)
);
if (raw == null) return Collections.emptyList();
SearchEvent event = SearchEvent.builder()
.keyword(keyword)
.clientIp(clientIp)
.eventTime(LocalDateTime.now())
.build();
evRepo.save(event);
// 2) 이전 스냅샷 Top10 가져오기
LocalDateTime latestTs = backupRepo.findLatestSnapshotAt();
List<TrendingBackup> prev = latestTs == null
? Collections.emptyList()
: backupRepo.findTop10BySnapshotAtOrderByScoreDesc(latestTs);
Map<String, Integer> prevRank = new HashMap<>();
for (int i = 0; i < prev.size(); i++) {
prevRank.put(prev.get(i).getKeyword(), i + 1);
}
// 3) 결과 DTO 변환
List<TrendingDto> out = new ArrayList<>();
for (int i = 0, rank = 1; i < raw.size(); i += 2, rank++) {
String kw = raw.get(i);
double sc = Double.parseDouble(raw.get(i + 1));
int old = prevRank.getOrDefault(kw, 11);
int diff = old - rank;
String change = diff > 0 ? "up" : diff < 0 ? "down" : "same";
int changeValue = Math.abs(diff);
out.add(TrendingDto.builder()
.rank(rank)
.keyword(kw)
.change(change)
.changeValue(changeValue)
.score(sc)
.build());
}
return out;
}
public List<TrendingDto> getTop() {
// 1) Redis 에서 실시간 Top10
Set<ZSetOperations.TypedTuple<String>> tuples =
redis.opsForZSet().reverseRangeWithScores(TREND_KEY, 0, 9);
// 2) 이전 스냅샷 Top10 가져오기 (같은 ts)
LocalDateTime latestTs = backupRepo.findLatestSnapshotAt();
List<TrendingBackup> prev = latestTs == null
? Collections.emptyList()
: backupRepo.findTop10BySnapshotAtOrderByScoreDesc(latestTs);
Map<String, Integer> prevRank = new HashMap<>();
for (int i = 0; i < prev.size(); i++) {
prevRank.put(prev.get(i).getKeyword(), i + 1);
}
// 3) DTO 변환
List<TrendingDto> out = new ArrayList<>();
int rank = 1;
if (tuples != null) {
for (var t : tuples) {
String kw = t.getValue();
double sc = t.getScore();
int old = prevRank.getOrDefault(kw, 11);
int diff = old - rank;
String change = diff > 0 ? "up" : diff < 0 ? "down" : "same";
int changeValue = Math.abs(diff);
out.add(TrendingDto.builder()
.rank(rank++)
.keyword(kw)
.change(change)
.changeValue(changeValue)
.score(sc)
.build());
}
}
return out;
}
}
#application.yml
spring:
task:
scheduling:
pool:
size: 5
package com.example.footprint.redis;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.List;
@Configuration
public class RedisLuaConfig {
/**
* KEYS[1] = "search:ip:{ip}"
* ARGV[1] = keyword
* ARGV[2] = ttlSeconds
*
* 1) IP별 SET에 keyword가 없으면
* - ZINCRBY trending:keywords 1 keyword (실시간 집계)
* - SADD search:ip:{ip} keyword (조작 방지)
* - EXPIRE search:ip:{ip} ttlSeconds (중복 차단 TTL)
* 2) ZREVRANGE top 10 WITHSCORES 반환
*/
@Bean
public DefaultRedisScript<List<String>> trendingScript() {
String lua =
"if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 0 then\n" +
" redis.call('ZINCRBY','trending:keywords',1,ARGV[1])\n" +
" redis.call('SADD', KEYS[1],ARGV[1])\n" +
" redis.call('EXPIRE', KEYS[1],tonumber(ARGV[2]))\n" +
"end\n" +
"return redis.call('ZREVRANGE','trending:keywords',0,9,'WITHSCORES')";
DefaultRedisScript<List<String>> script = new DefaultRedisScript<>();
script.setScriptText(lua);
@SuppressWarnings("unchecked")
Class<List<String>> resultType = (Class<List<String>>) (Class<?>) List.class;
script.setResultType(resultType);
return script;
}
}
@GetMapping("/posts/search")
public ResponseEntity<Page<PostResponseDto>> searchPosts(
// 기존 파라미터,
HttpServletRequest request
) {
try {
// 실시간 급상승 검색어 집계
String clientIp = request.getRemoteAddr();
trendingService.record(keyword, clientIp);
// 기존 로직
return ResponseEntity.ok(postResponseDtoPage);
} catch (Exception e) {
// 기존 로직
}
}
3. 핵심 기능 정리
전체 아키텍처 개요
- 사용자가 검색 키워드를 입력하면 TrendingService.record()가 호출된다.
- 이 서비스는 먼저 Redis Lua 스크립트로 실시간 집계를 수행하고, 검색 이벤트를 RDB의 SearchEvent 테이블에 저장한다.
- Lua 스크립트 실행 결과로 실시간 Top10 키워드와 점수를 반환한다.
- 별도의 스케줄러가 매시간 두 가지 작업을 실행한다
- recompute() – Redis ZSET 점수를 최신 통계로 재계산
- backup() – 재계산된 Top10을 RDB의 TrendingBackup 테이블에 스냅샷으로 저장
검색 기록(record) 흐름
- Redis Lua 스크립트를 실행해 IP별 중복 집계를 차단하고, Sorted Set에 점수를 누적한다.
- 검색 이벤트를 RDB에 즉시 저장해, 이후 스케줄러가 통계 집계를 할 때 원시 데이터를 사용할 수 있게 한다.
- Lua 스크립트가 반환한 [키워드, 점수]를 받아 이전 스냅샷 순위와 비교하여 순위 변동 정보(상승, 하락, 변동폭)를 계산한 뒤 TrendingDto 리스트로 반환한다.
점수 재계산(recompute) 흐름
- 매시간 실행 시점에 Redis ZSET에 저장된 모든 키워드를 조회한다.
- 각 키워드에 대해 SearchEvent 테이블에서 최근 1시간(h1), 24시간(h24), 7일(w7)간의 검색 건수를 집계한다.
- 시간대별 보정값(x), 마지막 검색 후 경과 시간 가중치(w), 전체 검색량 보정(vAdj)을 계산한다.
- 최종 점수는 (h1/h24 * h1/(w7/168) - x) * vAdj * w 공식을 통해 산출하며, Redis ZSET에 덮어쓴다.
스냅샷 백업(backup) 흐름
- 매시간 재계산 직후 또는 별도 스케줄러에 의해 호출된다.
- Redis에서 실시간 Top10을 조회해, 각 키워드와 점수를 TrendingBackup 엔티티로 저장한다.
- 이력 데이터를 보관함으로써 다양한 곳에 활용할 수 있다.
반응형
'공부메모 & 오류해결 > Spring Boot' 카테고리의 다른 글
[Spring Boot] Gzip 압축을 통해 로딩 성능 최적화 하기 (0) | 2025.01.14 |
---|---|
[Elasticsearch] 엘라스틱 서치의 Analyzer, Tokenizer 정리(Nori_tokenizer) (0) | 2024.04.18 |
[Spring Boot] lombok을 분명 적용했는데 사용이 안될때 (0) | 2024.04.09 |
[Spring Boot + SMTP] 이메일 인증 구현 (0) | 2024.04.01 |
[Spring Boot] LogBack을 사용해서 로그파일 저장하기 (0) | 2024.03.18 |
남건욱's 공부기록