공부메모 & 오류해결/Spring Boot

[Spring Boot] 실시간검색어 구현하기

남건욱 2025. 7. 13. 22:20

목차

    반응형

    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 키워드와 점수를 반환한다.
    • 별도의 스케줄러가 매시간 두 가지 작업을 실행한다
      1. recompute() – Redis ZSET 점수를 최신 통계로 재계산
      2. backup() – 재계산된 Top10을 RDB의 TrendingBackup 테이블에 스냅샷으로 저장

    검색 기록(record) 흐름

    1. Redis Lua 스크립트를 실행해 IP별 중복 집계를 차단하고, Sorted Set에 점수를 누적한다.
    2. 검색 이벤트를 RDB에 즉시 저장해, 이후 스케줄러가 통계 집계를 할 때 원시 데이터를 사용할 수 있게 한다.
    3. 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 엔티티로 저장한다.
    • 이력 데이터를 보관함으로써 다양한 곳에 활용할 수 있다.

     

    반응형
    프로필사진

    남건욱's 공부기록