본문 바로가기

카테고리 없음

🔍 캐시 동작 방식 및 고민 정리

✅ 목적

검색 데이터를 기반으로 로그를 기록하고, 이를 계산/활용하여 사용자 경험 향상 및 데이터 분석 기반 마련을 목표로 함.
본 포스팅에서는 검색 요청의 로그 기록 및 활용 방식과 관련된 캐시 전략, 그리고 이에 따른 고민 포인트를 정리함.

 

⚙️ 현재 동작 방식

  1. 검색 요청
    • 사용자가 검색어 입력 → 다양한 결과 응답
  2. 검색 결과 기록
    • 다양한 검색 결과를 로그로 기록
  3. 로그 분석/계산
    • 추후 로그들을 기반으로 통계적 분석 및 인기 메뉴/가게 도출 등 활용 예정

 

🤔 고민 포인트

고민 1. 로그를 메뉴 / 가게 기준으로 구분해서 저장해야 할까?

  • 문제:
    검색 결과는 메뉴와 가게 정보를 모두 포함하지만, 이를 각각 별도의 테이블로 저장하려면
    • ERD 변경 필요 (예: SearchLogMenu, SearchLogStore 테이블 분리)
    • Repository 또한 이원화되어 관리 복잡도 증가
  • 현재 선택:
    • 메뉴/가게 정보를 따로 저장하지 않고, 전체 결과를 문자열(String) 형태로 저장
    • 가게에 대한 기본 정보는 API 응답 명세를 통해 제공
    • 메뉴 단위로의 별도 저장은 하지 않음
  • 이유:
    • 초기 개발 단계에서는 구조 간소화개발 효율성을 우선시
    • 메뉴/가게를 개별 참조 가능한 구조로 만들 경우 ERD 및 로직 복잡도 증가
    • 결과 자체에는 메뉴와 가게 정보가 포함되어 있으므로, 단순 문자열 저장으로도 충분한 분석 가능

고민 2. 로그 저장은 동기식으로 해도 될까?

  • 현재 상태: 동기식으로 로그 저장 중
  • 우려:
    • 검색 요청마다 DB I/O 발생 → 트래픽 증가 시 병목 우려
  • 대안: 비동기 처리 (ex. 이벤트 큐, 메시지 브로커 등)
  • 현재 선택 이유:
    • 우선은 동기식으로 테스트하며 성능 파악
    • 실제 저장량, 요청량 분석 후 비동기로 전환 여부 판단 예정

고민 3. 검색 키워드별 count, 왜 실시간 처리 대신 스케줄러 기반 집계를 선택했는가?

배경:
배달 및 가게 도메인의 특성상 인기 검색어는 실시간 집계보다는 일정 주기로 집계하는 것이 충분히 의미 있고 효율적입니다.

이전 방식의 문제점:

  1. 동시성 문제
    • 여러 사용자가 동시에 같은 키워드를 검색할 때 count 증가 시 race condition 발생 가능
    • 이를 방지하려면 락, atomic 연산 필요, 구현 복잡도 증가
  2. 실시간 집계에 따른 성능 부담
    • 검색 요청 시마다 count 값을 즉시 갱신하면 DB 쓰기 부하가 커지고, 트래픽 급증 시 병목 우려
  3. 중복 판단의 메모리 부담
    • 동일 키워드에 대해 중복 판단을 애플리케이션 메모리에서 처리하면 메모리 사용량 증가와 GC 병목 유발
    • Java 환경에서 모든 로그를 불러와 비교하는 방식은 성능 저하 위험

현재 선택:

  • 로그는 검색 시점마다 저장만 수행
  • count 집계는 별도의 스케줄러가 주기적으로 수행
  • 스케줄러가 search_log 테이블에서 인기 키워드를 집계해 별도 테이블(TrendingKeyword 등)에 저장

장점:

  • 실시간 동시성 문제 해소
  • 검색 요청 처리 시 부하 최소화
  • 통계 정확도 유지
  • 집계 주기를 유연하게 조정 가능 (예: 5분, 30분, 1시간 단위)

4. 오래된 로그 정리는 어떻게 해야 할까?

  • 로그는 시간이 지날수록 쌓이기 때문에, 필연적으로 정리 정책이 필요합니다.
  • 지나치게 오래된 로그를 계속 유지하면, DB 성능에도 영향을 줄 수 있습니다.
  • 이에 따라 현재 시점을 기준으로 일주일 이상 지난 로그는 삭제하는 방식이 적절하다고 판단했습니다.
  • 이를 위해 추후 다음과 같은 기능을 도입할 계획입니다:
    • @Scheduled 를 활용한 주기적 정리 로직
    • 삭제 기준: searchedAt < LocalDateTime.now().minusWeeks(1)
  • 이 방식은 불필요한 로그 데이터를 최소화하면서,
    통계나 분석을 위한 최근 로그는 유지할 수 있는 균형 잡힌 접근입니다.

🛠 실행 방법 및 결과

1️⃣ 📂 코드 구성

📦 CashConfig 

 

더보기
package com.example.springrider.config.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;
import java.util.concurrent.TimeUnit;

@EnableCaching
@Configuration
public class CashConfig {

    @Bean
    public CacheManager cacheManager() {
        // 각각의 캐시에 대해 개별 설정 가능
        CaffeineCache searchCache = new CaffeineCache("searchCache",
                Caffeine.newBuilder()
                        .expireAfterWrite(1, TimeUnit.MINUTES)
                        .maximumSize(1000)
                        .build());
        // 필요한 캐시들을 추가

        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(List.of(searchCache));
        return cacheManager;
    }
}

 

📦 DTO (Request / Response)

더보기
package com.example.springrider.domain.search.dto.response;

import com.example.springrider.domain.store.entity.Store;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.time.LocalTime;

@Getter
@AllArgsConstructor
public class SearchResponseDto {
    private final Long id;
    private final String name;
    private final String address;
    private final String category;
    private final LocalTime openTime;
    private final LocalTime closeTime;
    private final Integer minOrderPrice;

    public static SearchResponseDto of(Store store) {
        return new SearchResponseDto(
                store.getId(),
                store.getName(),
                store.getAddress(),
                store.getCategory(),
                store.getOpenTime(),
                store.getCloseTime(),
                store.getMinOrderPrice()
        );
    }
}
package com.example.springrider.domain.search.dto.response;

import lombok.Getter;

@Getter
public class SearchTrendingResponseDto {
    private Long id;
    //가게 상호명 또는 메뉴
    private String keyword;
    private Long count;
    public SearchTrendingResponseDto(Long id, String keyword, Long count) {
        this.id = id;
        this.keyword = keyword;
        this.count = count;
    }
}

🧾 Entity (로그 저장)

더보기
package com.example.springrider.domain.search.entity;


import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;


@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor

//검색 기록이 아닌 검색한 결과의 기록
public class SearchLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    String keyword;

    public SearchLog(String keyword) {
        this.keyword=keyword;
    }
}

 

📡 Controller

더보기
package com.example.springrider.domain.search.controller;


import com.example.springrider.domain.search.dto.response.SearchResponseDto;
import com.example.springrider.domain.search.dto.response.SearchTrendingResponseDto;
import com.example.springrider.domain.search.service.SearchLogService;
import com.example.springrider.domain.search.service.SearchService;
import com.example.springrider.global.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class SearchController {

    private final SearchService searchService;
    private final SearchLogService searchLogService;


    //검색은 필수 -why 가게 조회와 별개의 api
    @GetMapping("api/search")
    public ApiResponse<Page<SearchResponseDto>> find(@RequestParam String keyword,
                                                     @RequestParam(defaultValue = "0")int page,
                                                     @RequestParam(defaultValue = "10")int size){
        Pageable pageable= PageRequest.of(page,size);


        return ApiResponse.ok(searchService.find(keyword,pageable));
    }

    @GetMapping("api/search/trending/v1")
    public ApiResponse<Page<SearchTrendingResponseDto>> findV1(@RequestParam(defaultValue = "0")int page,
                                                     @RequestParam(defaultValue = "10")int size){
        Pageable pageable= PageRequest.of(page,size);


        return ApiResponse.ok(searchLogService.findV1(pageable));
    }
    @GetMapping("api/search/trending/v2")
    public ApiResponse<Page<SearchTrendingResponseDto>> findV2(@RequestParam(defaultValue = "0")int page,
                                                               @RequestParam(defaultValue = "10")int size){
        Pageable pageable= PageRequest.of(page,size);


        return ApiResponse.ok(searchLogService.findV2(pageable));
    }
}

💾 Repository

더보기
package com.example.springrider.domain.search.repository;

import com.example.springrider.domain.search.dto.response.SearchTrendingResponseDto;
import com.example.springrider.domain.search.entity.SearchLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;


//검색 기록이 아닌 검색한 결과의 기록
public interface SearchLogRepository extends JpaRepository<SearchLog,Long> {

    @Query("SELECT new com.example.springrider.domain.search.dto.response.SearchTrendingResponseDto(min (sl.id), sl.keyword,COUNT(sl)) " +
            "FROM SearchLog sl " +
            "GROUP BY sl.keyword " +
            "ORDER BY COUNT(sl) DESC")
    Page<SearchTrendingResponseDto> findTrending(Pageable pageable);

}

 

검색을 위한 가게 Repository 코드 

@EntityGraph(attributePaths = "menus")
@Query("SELECT s FROM Store s WHERE s.name LIKE %:keyword% OR EXISTS (SELECT m FROM Menu m WHERE m.store = s AND m.name LIKE %:keyword%)")
Page<Store> findByKeyword(@Param("keyword") String keyword, Pageable pageable);

🧠 Service (로그 저장 포함)

더보기
package com.example.springrider.domain.search.service;


import com.example.springrider.domain.search.dto.response.SearchTrendingResponseDto;
import com.example.springrider.domain.search.entity.SearchLog;
import com.example.springrider.domain.search.repository.SearchLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SearchLogService {

    private final SearchLogRepository searchLogRepository;
    //검색결과 도출
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void create(String keyword){

        SearchLog searchLog=new SearchLog(keyword);

        searchLogRepository.save(searchLog);

    }

    public Page<SearchTrendingResponseDto> findV1(Pageable pageable){

        return searchLogRepository.findTrending(pageable);
    }


    // 페이지에선 명시적으로 해줄 필요가 있다고
    @Transactional(readOnly = true)
    @Cacheable(value = "searchCache", key = "'trending:' + #pageable.pageNumber + ':' + #pageable.pageSize")
    public Page<SearchTrendingResponseDto> findV2(Pageable pageable){
        System.out.println("🔥 DB HIT!");
        return searchLogRepository.findTrending(pageable);
    }



}
package com.example.springrider.domain.search.service;
import com.example.springrider.domain.menu.entity.Menu;
import com.example.springrider.domain.search.dto.response.SearchResponseDto;
import com.example.springrider.domain.store.entity.Store;
import com.example.springrider.domain.store.repository.StoreRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SearchService {

    private final StoreRepository storeRepository;
    private final SearchLogService searchLogService;

    public Page<SearchResponseDto> find(String keyword, Pageable pageable) {
        Page<Store> storePage = storeRepository.findByKeyword(keyword, pageable);
        // 중복 코드일 가능성 농후
        storePage.forEach(store -> {
            // 상점 이름이 keyword 포함된 경우만 저장
            if (store.getName().contains(keyword)){
                searchLogService.create(store.getName());
            }

            // 메뉴 중 keyword 포함된 것만 필터링 후 로깅
            store.getMenus().stream()
                    .map(Menu::getName)
                    .filter(menuName -> menuName.contains(keyword))
                    .forEach(searchLogService::create);
        });

        return storePage.map(SearchResponseDto::of);
    }

}

 

3️⃣ ✅ 테스트 & 실행 결과

  • 테스트용 데이터 전송: Postman,

📡 Controller (테스트용)

더보기
package com.example.springrider.domain.test.controller;
import com.example.springrider.domain.test.service.SearchLogServiceTest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/test")
public class DummyLogController {

    private final SearchLogServiceTest searchLogService;

    @PostMapping("/generate-logs")
    public String generateDummyLog(@RequestParam(defaultValue = "100000") int count) {

        List<String> keywords = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            int randomNum = 1 + (int)(Math.random() * 6);  // 1 ~ 6 사이 숫자 생성
            keywords.add(String.valueOf(randomNum));
        }

        searchLogService.batchInsert(keywords);

        return count + "개의 더미 Log 데이터를 배치로 생성했습니다.";
    }
}

 

🧠 Service (테스트용)

더보기
package com.example.springrider.domain.test.service;

import com.example.springrider.domain.search.dto.response.SearchTrendingResponseDto;
import com.example.springrider.domain.search.entity.SearchLog;
import com.example.springrider.domain.search.repository.SearchLogRepository;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class SearchLogServiceTest {

    private final EntityManager entityManager;

    @Transactional
    public void batchInsert(List<String> keywords) {
        int batchSize = 1000;
        for (int i = 0; i < keywords.size(); i++) {
            SearchLog searchLog = new SearchLog(keywords.get(i));
            entityManager.persist(searchLog);

            if (i > 0 && i % batchSize == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
        entityManager.flush();
        entityManager.clear();
    }
}

 테스트결과

cacheX

cacheO

📝 마무리

  • 로그는 최대한 단순하게 저장하되, 활용은 유연하게
  • count와 같은 계산은 나중에, 정확하고 안전하게
  • 처음은 간단하게, 구조는 나중에 확장 가능하도록 설계
  • 인기검색어 시간이 정해지면, 로그에 creatTime과 계산로직 추가 할 예정입니다.