얼리테이블 - 트러블 슈팅 : 조건 검색 성능 개선

take_the_king·2025년 2월 17일

조건 검색 성능 개선

1. 배경

데이터 수가 증가함에 따라 현재 Store 엔티티와 연관된 검색 기능에서 성능 문제가 발생하고 있습니다.

2. 문제

searchStoreQuery 메서드에서 다수의 조인과 조건 필터링이 이루어지며 응답 속도가 저하됩니다. 이로 인해 사용자가 검색 결과를 확인하는 데 시간이 오래 걸리고, 서버의 리소스 사용량이 증가하는 문제가 발생합니다.

2.1 원인

  • 조건 검색의 비효율성: BooleanExpression을 이용한 필터링에서 인덱스 활용 부족
  • 반복적인 DB 접근: Redis 캐싱 미적용으로 인해 동일한 검색이 반복적으로 DB에 접근
  • Lazy Loading으로 인한 N+1 문제 가능성
  • 복잡한 검색 필터링 로직: 최적화가 필요

3. 성능 개선 방법

3.0 데이터 준비

  • store 더미 데이터 50만 개 생성
    • 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()));
    }
}
  • build.gradle에 의존성 추가
implementation 'net.datafaker:datafaker:2.0.2'
  • 더미 데이터 예시

3.1 인덱스 최적화

현재 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 {
		
	// ...이하 생략
	
}
  • 기본 키와 외래 키는 이미 데이터베이스에서 자동으로 인덱스를 생성함

    • JPA에서는 @Id(storeId) 와 @manyToOne(UserId), @OneToOne(fileId) 관계의 필드를 인덱스로 자동 생성

  • 조건 중 검색어는 가게 이름과 대표 메뉴 이름을 LIKE 조건으로 검색하기 때문에 “%검색어” 나 “%검색어%” 처럼 정렬이 무의미한 경우는 인덱스가 의미가 없음 → 실제로 시간이 더 오래 걸림(인덱스 수정)
  • 조건 중 가격은 자주 변경될 가능성이 높고, BETWEEN 조건이라 성능 개선이 크게 이루어지지 않을 가능성이 높음

적용 전/후 성능 비교

  • 인덱스 적용 전
  • 인덱스 적용 후

결과

전체적으로 성능이 개선되긴 했지만 큰 지역(31,813개)과 카테고리(85,001개)처럼 많은 양의 데이터를 가져오는 상황에서는 크게 유의미한 결과를 보여주진 못하고, 작은 지역과 작은 지역(2,259개) + 카테고리(5,303개) 10% 이하의 데이터를 가져올 때는 큰 성능 향상이 있었습니다.

3.2 Redis 캐싱 적용

동일한 검색이 반복적으로 발생할 경우, 캐싱이 없으면 매번 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

  • 적용 후

    • 테스트 1 - 캐싱 후
    • 테스트 2 - 캐싱 후

결과

데이터를 캐싱한 결과 평균 95% 이상의 성능 개선이 이루어졌습니다. 자주 조회되는 조건들에 경우에는 아주 효과적인 개선 방법이 될 수 있습니다. 하지만 캐시 적중률이 높지 않다면 결국에는 DB 조회가 일어날 것입니다.

또한, 모든 조회들을 캐싱에 담아두고 긴 시간동안 보관하면 메모리 부하가 발생할 것입니다. 그렇기 때문에 적절한 만료시간과 자주 조회되는 컬럼을 적용시키면 아주 효과적인 성능 개선이 이루어질 것입니다.

주의사항

  • 변경 빈도 고려: 만약 가게 데이터가 자주 변경된다면, 캐시에 저장된 데이터와 DB의 데이터 간에 불일치(데이터 정합성 문제)가 발생할 수 있습니다. 이런 경우 캐시를 자주 무효화하거나 업데이트해야 하므로, 캐시 업데이트 작업이 빈번해져 오히려 Redis에 부하를 줄 수 있습니다.
  • 캐시 적중률 고려: 캐시 적중률이 높다면 대부분의 요청이 Redis에서 처리되어 DB 부하가 줄어들지만, 캐시 적중률이 낮으면 DB 조회가 빈번하게 발생합니다. 특히 캐시 미스가 잦은 경우, Redis와 DB 간의 네트워크 트래픽이 증가할 수 있습니다.

3.3 N + 1 문제 해결

@OneToMany(fetch = FetchType.LAZY) 관계에서 N+1 문제가 발생할 가능성이 있습니다.

해결 방법 1(Hibernate Batch Size 조정)

@BatchSize(size = 100)를 사용하여 성능을 개선할 수 있습니다.

  • store 엔티티
@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<>();
}
  • file 엔티티
@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% 정도 단축되었습니다.

추가 조회 실행 후에도

  • batchsize 적용 전 (2차)

  • batchsize 적용 후 (2차)

약 10% 정도 단축되는 것을 볼 수 있습니다.

이를 통해 batchsize를 알맞은 상황에 적용하면 쿼리도 최적화를 하고 성능도 개선할 수 있다는 것을 알 수 있습니다.

해결 방법 2(fetchType 설정)

가게를 조회할 때 항상 사용되는 필드는 조인해서 하나의 쿼리에서 데이터를 가져오도록 설정합니다.

결과

왼쪽은 fetchType 이 Lazy 일 때 쿼리가 3번 발생하는 상황이고, fetchType 을 Eager로 설정해서 1번의 쿼리만 발생하는 상황입니다. 항상 같이 조회되는 데이터의 경우는 조인을 통해 같이 가져오는 방법이 성능 개선이 도움을 줄 수 있습니다.


4. 결론

  • 인덱스 최적화, 데이터 캐싱, N+1 문제 해결을 통해 성능 개선을 이룰 수 있었습니다.
  • 특히 인덱스 최적화와 Redis 캐싱이 성능 향상에 중요한 역할을 했습니다.
  • 이러한 최적화 방법들은 각 상황에 맞게 사용하면, 시스템 성능을 크게 개선할 수 있습니다.
개선 방법성능 향상 비율결과
인덱스 최적화10% ~ 80% 개선작은 데이터에서 큰 성능 향상
Redis 캐싱95% 개선반복적인 조회에 매우 효과적
N+1 문제 해결3% ~ 10% 개선불필요한 쿼리 수를 줄여 성능 개선
FetchType 조정20% 개선하나의 쿼리로 데이터 조회

5. 추후 고려 사항

5.1 Elasticsearch 도입 고려

검색 성능을 더욱 향상하기 위해 Elasticsearch 도입을 고려.

  • 빠른 텍스트 검색 가능
  • 복합적인 필터링 및 랭킹 기능 제공

5.2 인덱스 추가

검색 성능을 위해 더 다양한 인덱스 추가 고려

  • 알러지에 대한 인덱스 추가
  • 복합 인덱스 추가
profile
개발을 좋아하는 taketheking 입니다.

0개의 댓글