✅ 목적
검색 데이터를 기반으로 로그를 기록하고, 이를 계산/활용하여 사용자 경험 향상 및 데이터 분석 기반 마련을 목표로 함.
본 포스팅에서는 검색 요청의 로그 기록 및 활용 방식과 관련된 캐시 전략, 그리고 이에 따른 고민 포인트를 정리함.
⚙️ 현재 동작 방식
- 검색 요청
- 사용자가 검색어 입력 → 다양한 결과 응답
- 검색 결과 기록
- 다양한 검색 결과를 로그로 기록
- 로그 분석/계산
- 추후 로그들을 기반으로 통계적 분석 및 인기 메뉴/가게 도출 등 활용 예정
🤔 고민 포인트
고민 1. 로그를 메뉴 / 가게 기준으로 구분해서 저장해야 할까?
- 문제:
검색 결과는 메뉴와 가게 정보를 모두 포함하지만, 이를 각각 별도의 테이블로 저장하려면- ERD 변경 필요 (예: SearchLogMenu, SearchLogStore 테이블 분리)
- Repository 또한 이원화되어 관리 복잡도 증가
- 현재 선택:
- 메뉴/가게 정보를 따로 저장하지 않고, 전체 결과를 문자열(String) 형태로 저장
- 가게에 대한 기본 정보는 API 응답 명세를 통해 제공
- 메뉴 단위로의 별도 저장은 하지 않음
- 이유:
- 초기 개발 단계에서는 구조 간소화와 개발 효율성을 우선시
- 메뉴/가게를 개별 참조 가능한 구조로 만들 경우 ERD 및 로직 복잡도 증가
- 결과 자체에는 메뉴와 가게 정보가 포함되어 있으므로, 단순 문자열 저장으로도 충분한 분석 가능
고민 2. 로그 저장은 동기식으로 해도 될까?
- 현재 상태: 동기식으로 로그 저장 중
- 우려:
- 검색 요청마다 DB I/O 발생 → 트래픽 증가 시 병목 우려
- 대안: 비동기 처리 (ex. 이벤트 큐, 메시지 브로커 등)
- 현재 선택 이유:
- 우선은 동기식으로 테스트하며 성능 파악
- 실제 저장량, 요청량 분석 후 비동기로 전환 여부 판단 예정
고민 3. 검색 키워드별 count, 왜 실시간 처리 대신 스케줄러 기반 집계를 선택했는가?
배경:
배달 및 가게 도메인의 특성상 인기 검색어는 실시간 집계보다는 일정 주기로 집계하는 것이 충분히 의미 있고 효율적입니다.
이전 방식의 문제점:
- 동시성 문제
- 여러 사용자가 동시에 같은 키워드를 검색할 때 count 증가 시 race condition 발생 가능
- 이를 방지하려면 락, atomic 연산 필요, 구현 복잡도 증가
- 실시간 집계에 따른 성능 부담
- 검색 요청 시마다 count 값을 즉시 갱신하면 DB 쓰기 부하가 커지고, 트래픽 급증 시 병목 우려
- 중복 판단의 메모리 부담
- 동일 키워드에 대해 중복 판단을 애플리케이션 메모리에서 처리하면 메모리 사용량 증가와 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과 계산로직 추가 할 예정입니다.



