데이터 수가 증가함에 따라 현재 Store 엔티티와 연관된 검색 기능에서 성능 문제가 발생하고 있습니다.
searchStoreQuery 메서드에서 다수의 조인과 조건 필터링이 이루어지며 응답 속도가 저하됩니다. 이로 인해 사용자가 검색 결과를 확인하는 데 시간이 오래 걸리고, 서버의 리소스 사용량이 증가하는 문제가 발생합니다.
BooleanExpression을 이용한 필터링에서 인덱스 활용 부족net.datafaker.Faker를 사용하여 랜덤 데이터를 생성하고, 이를 DB에 저장합니다.package com.gotcha.earlytable.domain.store;
import com.gotcha.earlytable.domain.file.FileService;
import com.gotcha.earlytable.domain.store.entity.Store;
import com.gotcha.earlytable.domain.store.enums.StoreCategory;
import com.gotcha.earlytable.domain.store.enums.StoreStatus;
import com.gotcha.earlytable.domain.user.UserRepository;
import com.gotcha.earlytable.domain.user.entity.User;
import com.gotcha.earlytable.global.enums.RegionBottom;
import com.gotcha.earlytable.global.enums.RegionTop;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import net.datafaker.Faker;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Random;
@Component
@RequiredArgsConstructor
public class StoreDummyDataInitializer {
private final StoreRepository storeRepository;
private final UserRepository userRepository;
private final FileService fileService;
private final Random random = new Random();
private final Faker faker = new Faker(Locale.KOREAN);
private static final int STORE_COUNT = 500_000;
@PostConstruct
@Transactional
public void init() {
List<User> users = userRepository.findAll();
if (users.isEmpty()) {
throw new IllegalStateException("User가 존재하지 않습니다.");
}
List<Store> stores = new ArrayList<>();
for (int i = 0; i < STORE_COUNT; i++) {
stores.add(createRandomStore(users));
if (i % 10_000 == 0) { // 10,000개씩 배치 저장
storeRepository.saveAll(stores);
stores.clear();
System.out.println(i + " 개의 데이터를 저장 완료...");
}
}
if (!stores.isEmpty()) {
storeRepository.saveAll(stores);
}
System.out.println("총 " + STORE_COUNT + " 개의 더미 데이터 저장 완료!");
}
private Store createRandomStore(List<User> users) {
return new Store(
faker.company().name(), // 랜덤 상점명
faker.phoneNumber().phoneNumber(), // 랜덤 전화번호
faker.lorem().sentence(), // 랜덤 설명
faker.address().fullAddress(), // 랜덤 주소
randomEnum(StoreStatus.class), // 랜덤 StoreStatus
randomEnum(StoreCategory.class), // 랜덤 StoreCategory
randomEnum(RegionTop.class), // 랜덤 RegionTop
randomEnum(RegionBottom.class), // 랜덤 RegionBottom
getRandomElement(users), // 랜덤 User
fileService.createFile() // New File
);
}
private <T extends Enum<?>> T randomEnum(Class<T> enumClass) {
T[] enumConstants = enumClass.getEnumConstants();
return enumConstants[random.nextInt(enumConstants.length)];
}
private <T> T getRandomElement(List<T> list) {
return list.get(random.nextInt(list.size()));
}
}
implementation 'net.datafaker:datafaker:2.0.2'

현재 storeName, regionTop, regionBottom, storeCategory 등에 대해 적절한 인덱스가 없어 DB에서 검색하는 데 시간이 오래 걸립니다.
아래와 같이 인덱스를 추가하여 검색 성능을 개선할 수 있습니다
@Entity
@Table(name = "store", indexes = {
@Index(name = "idx_store_name", columnList = "storeName"),
@Index(name = "idx_region_top", columnList = "regionTop"),
@Index(name = "idx_region_bottom", columnList = "regionBottom"),
@Index(name = "idx_store_category", columnList = "storeCategory")
})
public class Store extends BaseEntity {
// ...이하 생략
}
기본 키와 외래 키는 이미 데이터베이스에서 자동으로 인덱스를 생성함




전체적으로 성능이 개선되긴 했지만 큰 지역(31,813개)과 카테고리(85,001개)처럼 많은 양의 데이터를 가져오는 상황에서는 크게 유의미한 결과를 보여주진 못하고, 작은 지역과 작은 지역(2,259개) + 카테고리(5,303개) 10% 이하의 데이터를 가져올 때는 큰 성능 향상이 있었습니다.
동일한 검색이 반복적으로 발생할 경우, 캐싱이 없으면 매번 DB에서 조회해야하는 문제가 있습니다.
동일한 검색이 반복적으로 발생할 경우, DB에서 매번 조회하는 대신 Redis를 이용해 캐싱함으로써 성능을 개선할 수 있습니다.
/**
* 가게 조건 검색 메서드
*
* @param requestDto
* @return
*/
public List<StoreSearchResponseDto> searchStore(StoreSearchRequestDto requestDto) {
String cacheKey = "store_search:" + getCacheKey(requestDto);
// RBucket을 JsonJacksonCodec과 함께 사용
RBucket<String> cachedResult = redissonClient.getBucket(cacheKey, JsonJacksonCodec.INSTANCE);
Instant start = Instant.now(); // 시작 시간 기록
// 캐시된 결과가 있으면 반환
String resultJson = cachedResult.get();
List<StoreSearchResponseDto> result = null;
if (resultJson != null && !resultJson.isEmpty()) {
try {
result = objectMapper.readValue(resultJson, objectMapper.getTypeFactory().constructCollectionType(List.class, StoreSearchResponseDto.class));
} catch (Exception e) {
log.error("Error deserializing cached result", e);
}
}
if (result == null || result.isEmpty()) {
// 캐시된 결과가 없으면 DB에서 조회 후 캐시에 저장
result = storeRepository.searchStoreQuery(requestDto);
try {
String resultJsonToCache = objectMapper.writeValueAsString(result); // List to JSON
cachedResult.set(resultJsonToCache, 10, TimeUnit.MINUTES); // 캐시 만료 시간은 10분으로 설정
} catch (Exception e) {
log.error("Error serializing result to cache", e);
}
}
Instant end = Instant.now(); // 종료 시간 기록
long elapsedTime = Duration.between(start, end).toMillis(); // 실행 시간(ms)
log.info("searchStoreQuery 실행 시간: {} ms, 결과 개수: {}", elapsedTime, result.size());
return result;
}
// 캐시 키를 위한 핵심 파라미터들만 조합하는 메소드
private String getCacheKey(StoreSearchRequestDto requestDto) {
return requestDto.getSearchWord() + ":" +
requestDto.getRegionTop() + ":" +
requestDto.getRegionBottom() + ":" +
requestDto.getStoreCategory();
}
적용 전
테스트 1


테스트 2


적용 후



데이터를 캐싱한 결과 평균 95% 이상의 성능 개선이 이루어졌습니다. 자주 조회되는 조건들에 경우에는 아주 효과적인 개선 방법이 될 수 있습니다. 하지만 캐시 적중률이 높지 않다면 결국에는 DB 조회가 일어날 것입니다.
또한, 모든 조회들을 캐싱에 담아두고 긴 시간동안 보관하면 메모리 부하가 발생할 것입니다. 그렇기 때문에 적절한 만료시간과 자주 조회되는 컬럼을 적용시키면 아주 효과적인 성능 개선이 이루어질 것입니다.
@OneToMany(fetch = FetchType.LAZY) 관계에서 N+1 문제가 발생할 가능성이 있습니다.
@BatchSize(size = 100)를 사용하여 성능을 개선할 수 있습니다.
@Entity
@Table(name = "store", indexes = {
@Index(name = "idx_store_name", columnList = "storeName"),
@Index(name = "idx_region_top", columnList = "regionTop"),
@Index(name = "idx_region_bottom", columnList = "regionBottom"),
@Index(name = "idx_store_category", columnList = "storeCategory")
})
public class Store extends BaseEntity {
// ...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "file_id", nullable = false)
private File file;
@BatchSize(size = 100)
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private final List<Menu> menuList = new ArrayList<>();
@BatchSize(size = 100)
@OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private final List<Review> reviewList = new ArrayList<>();
}
@Getter
@Entity
@Table(name = "file")
@BatchSize(size = 100)
public class File extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long fileId;
@BatchSize(size = 100)
@OneToMany(mappedBy = "file", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FileDetail> fileDetailList = new ArrayList<>();
public File() {
}
}


이처럼 수십 번 발생할 쿼리를 몇 번의 쿼리로 데이터를 가져오게 됩니다.
실제로 76번의 쿼리가 4개의 쿼리로 줄어들었습니다.
또한 실행 시간도 약간이지만 단축되는 효과가 있었습니다.

이것은 batchsize를 적용하기 전의 실행 시간이다. batchsize를 적용 후 3% 정도 단축되었습니다.
추가 조회 실행 후에도


약 10% 정도 단축되는 것을 볼 수 있습니다.
이를 통해 batchsize를 알맞은 상황에 적용하면 쿼리도 최적화를 하고 성능도 개선할 수 있다는 것을 알 수 있습니다.
가게를 조회할 때 항상 사용되는 필드는 조인해서 하나의 쿼리에서 데이터를 가져오도록 설정합니다.


왼쪽은 fetchType 이 Lazy 일 때 쿼리가 3번 발생하는 상황이고, fetchType 을 Eager로 설정해서 1번의 쿼리만 발생하는 상황입니다. 항상 같이 조회되는 데이터의 경우는 조인을 통해 같이 가져오는 방법이 성능 개선이 도움을 줄 수 있습니다.
| 개선 방법 | 성능 향상 비율 | 결과 |
|---|---|---|
| 인덱스 최적화 | 10% ~ 80% 개선 | 작은 데이터에서 큰 성능 향상 |
| Redis 캐싱 | 95% 개선 | 반복적인 조회에 매우 효과적 |
| N+1 문제 해결 | 3% ~ 10% 개선 | 불필요한 쿼리 수를 줄여 성능 개선 |
| FetchType 조정 | 20% 개선 | 하나의 쿼리로 데이터 조회 |
검색 성능을 더욱 향상하기 위해 Elasticsearch 도입을 고려.
검색 성능을 위해 더 다양한 인덱스 추가 고려