SPRING

[SPRING] 조건 검색 개선 (인덱싱, 파티셔닝, 분리 조회)

도원좀비 2025. 5. 2. 18:35

📝 실험 배경

아래 포스트에서 최적 복합 인덱스로 성능 개선을 느낀적이 있었다.

이번에는 그걸 넘어서 파티셔닝으로 성능 개선을 느끼는게 목적이다.

https://sukh115.tistory.com/84

 

[SPRING] 뉴스피드 인덱스 실전 테스트

🔗 실험 배경 이전 포스트에서 updated_at, created_at, title 기준으로 인덱스를 생성하여 단일 조건 기반 조회 성능을 개선한 바 있습니다. 이번에는 실전에서 사용하는 쿼리를 사용해서 실험해보려

sukh115.tistory.com

 

실전 호출 api : GET localhost:8080/todos-partitioned/search?title=할 일&nickname=seokhyeon1&startDate=2025-05-01T00:00:00&endDate=2025-05-02T23:59:59


📌 실험 조건

  • 기간: 2025-05-01 ~ 2025-05-02
  • 검색 조건: title=할 일, nickname=seokhyeon1
  • 더미 데이터: 100만 건
  • 쿼리 구조: title + created_at + nickname 조건 검색
  • 측정 도구: 포스트맨 응답시간 측정

🧩 실험 항목

항목 설명
기본 인덱싱 id, user_id, created_at 단일 인덱스 사용
복합 인덱싱 title, created_at 복합 인덱스 사용
파티셔닝 + 복합 인덱싱 RANGE(created_at) 파티셔닝 + 복합 인덱스 사용
QueryDSL 후 처리 방식 Todo만 검색 후, Manager, Comment 개수는 별도 조회 후 서비스 단에서 조합

🕵️‍♂️ 1 차실험 - 기본 인덱싱

더보기
@Repository
@RequiredArgsConstructor
public class TodoQueryRepositoryImpl implements TodoQueryRepository{

    private final JPAQueryFactory queryFactory;

    @Override
    public Optional<Todo> findByIdWithUser(Long todoId) {
        QTodo todo = QTodo.todo;
        QUser user = QUser.user;

        Todo result = queryFactory
                .selectFrom(todo)
                .leftJoin(todo.user, user).fetchJoin()
                .where(todo.id.eq(todoId))
                .fetchOne();

        return Optional.ofNullable(result);
    }

    @Override
    public Page<TodoSearchResponse> search(TodoSearchCondition condition) {
        QTodo todo = QTodo.todo;
        QUser user = QUser.user;
        QManager manager = QManager.manager;
        QComment comment = QComment.comment;

        List<TodoSearchResponse> results = queryFactory
                .select(Projections.constructor(
                        TodoSearchResponse.class,
                        todo.title,
                        manager.id.countDistinct(),
                        comment.id.countDistinct()
                ))
                .from(todo)
                .leftJoin(todo.managers, manager)
                .leftJoin(todo.comments, comment)
                .leftJoin(todo.user, user)
                .where(
                        containsTitle(condition.getTitle()),
                        containsNickname(condition.getNickname()),
                        betweenCreatedAt(condition.getStartDate(), condition.getEndDate())
                )
                .groupBy(todo.id)
                .orderBy(todo.createdAt.desc())
                .offset((long) (condition.getPage() - 1) * condition.getSize())
                .limit(condition.getSize())
                .fetch();

        Long count = queryFactory
                .select(todo.countDistinct())
                .from(todo)
                .leftJoin(todo.user, user)
                .where(
                        containsTitle(condition.getTitle()),
                        containsNickname(condition.getNickname()),
                        betweenCreatedAt(condition.getStartDate(), condition.getEndDate())
                )
                .fetchOne();

        return new PageImpl<>(results, PageRequest.of(condition.getPage() - 1, condition.getSize()), count);
    }

    private BooleanExpression containsTitle(String title) {
        return StringUtils.hasText(title) ? QTodo.todo.title.contains(title) : null;
    }

    private BooleanExpression containsNickname(String nickname) {
        return StringUtils.hasText(nickname) ? QTodo.todo.user.nickname.contains(nickname) : null;
    }
    private BooleanExpression betweenCreatedAt(LocalDateTime start, LocalDateTime end) {
        if (start != null && end != null) {
            return QTodo.todo.createdAt.between(start, end);
        } else if (start != null) {
            return QTodo.todo.createdAt.goe(start);
        } else if (end != null) {
            return QTodo.todo.createdAt.loe(end);
        } else {
            return null;
        }
    }

응답 시간 측정 결과 요약

  • 최소: 2.37 s
  • 최대: 2.60s
  • 평균: 2.46s

🕵️‍♂️ 2차 실험 - 복합 인덱싱 적용

조회 조건 1차와 동일

  • 추가 인덱스: CREATE INDEX idx_created_at_title ON todos(title, created_at);
  • 결과 스크린샷 첨부
  • 응답 시간 측정 결과 요약
    • 최소: 2.38s
    • 최대: 2.60s
    • 평균: 2.49s

🕵️‍♂️ 3차 실험 - 파티셔닝 + 복합 인덱싱

더보기

(Range 파티셔닝 생성)

CREATE TABLE todos_partitioned (
                                   id BIGINT,
                                   title VARCHAR(255),
                                   contents TEXT,
                                   weather VARCHAR(50),
                                   created_at DATETIME(6),
                                   modified_at DATETIME(6),
                                   user_id BIGINT NOT NULL,

                                   PRIMARY KEY (id, created_at),
                                   KEY idx_created_at_title (created_at, title),
                                   KEY idx_user_id (user_id)
)
    PARTITION BY RANGE (YEAR(created_at)) (
        PARTITION p2023 VALUES LESS THAN (2024),
        PARTITION p2024 VALUES LESS THAN (2025),
        PARTITION p2025 VALUES LESS THAN (2026),
        PARTITION pmax  VALUES LESS THAN MAXVALUE
        );

 

(QueryDsl)

    @Override
    public List<TodoSearchResponse> searchPartitioned(TodoSearchCondition condition) {
        QTodoPartitioned todo = QTodoPartitioned.todoPartitioned;
        QUser user = QUser.user;
        QManager manager = QManager.manager;
        QComment comment = QComment.comment;

        return queryFactory
                .select(Projections.constructor(
                        TodoSearchResponse.class,
                        todo.title,
                        manager.id.countDistinct(),
                        comment.id.countDistinct()
                ))
                .from(todo)
                .leftJoin(manager).on(manager.todo.id.eq(todo.id))
                .leftJoin(comment).on(comment.todo.id.eq(todo.id))
                .leftJoin(todo.user, user)
                .where(
                        containsTitle(condition.getTitle()),
                        containsNickname(condition.getNickname()),
                        betweenCreatedAt(condition.getStartDate(), condition.getEndDate())
                )
                .groupBy(todo.id, todo.title, todo.createdAt)
                .orderBy(todo.createdAt.desc())
                .offset((long) (condition.getPage() - 1) * condition.getSize())
                .limit(condition.getSize())
                .fetch();
    }
  • 테이블: todos_partitioned
  • 파티셔닝 방식: RANGE (created_at)
  • 추가 인덱스: CREATE INDEX idx_partitioned_optimal ON todos_partitioned(title, created_at);
  • 결과 스크린샷 첨부
  • 응답 시간 측정 결과 요약
    • 최소: 4.37s
    • 최대: 4.58s
    • 평균: 4.45s

🕵️‍♂️ 4차 실험 - QueryDSL 각 조회 후 처리 방식

더보기

(TodoPartitionedQueryRepositoryImpl,java)

    @Override
    public List<TodoPartitioned> findTodos(String title, String nickname, LocalDateTime start, LocalDateTime end, Pageable pageable) {
        QTodoPartitioned todo = QTodoPartitioned.todoPartitioned;
        QUser user = QUser.user;

        return queryFactory
                .selectFrom(todo)
                .leftJoin(todo.user, user).fetchJoin()
                .where(
                        containsTitle(title),
                        containsNickname(nickname),
                        betweenCreatedAt(start, end)
                )
                .orderBy(todo.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    }

 

(CommnetQueryRepositoryImpl)

@Repository
@RequiredArgsConstructor
public class CommentQueryRepositoryImpl implements CommentQueryRepository {

    private final JPAQueryFactory queryFactory;

    public Map<Long, Long> countCommentsByTodoIds(List<Long> todoIds) {
        return queryFactory
                .select(comment.todo.id, comment.id.countDistinct())
                .from(comment)
                .where(comment.todo.id.in(todoIds))
                .groupBy(comment.todo.id)
                .fetch()
                .stream()
                .collect(Collectors.toMap(
                        tuple -> tuple.get(0, Long.class),
                        tuple -> tuple.get(1, Long.class)
                ));
    }

}

 

(ManagerQueryRepositoryImpl)

@Repository
@RequiredArgsConstructor
public class ManagerQueryRepositoryImpl implements ManagerQueryRepository{

    private final JPAQueryFactory queryFactory;

    public Map<Long, Long> countManagersByTodoIds(List<Long> todoIds) {
        return queryFactory
                .select(manager.todo.id, manager.id.countDistinct())
                .from(manager)
                .where(manager.todo.id.in(todoIds))
                .groupBy(manager.todo.id)
                .fetch()
                .stream()
                .collect(Collectors.toMap(
                        tuple -> tuple.get(0, Long.class),
                        tuple -> tuple.get(1, Long.class)
                ));
    }

}

 

(TodoPartitionedService.java)

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class TodoPartitionedService {

    private final TodoPartitionedRepository todoPartitionedRepository;
    private final CommentRepository commentRepository;
    private final ManagerRepository managerRepository;

    public List<TodoSearchResponse> searchTodos(TodoSearchCondition condition) {
        Pageable pageable = PageRequest.of(condition.getPage() - 1, condition.getSize());

        // 1. 파티셔닝 테이블 기반 Todo 목록 조회
        List<TodoPartitioned> todos = todoPartitionedRepository.findTodos(
                condition.getTitle(),
                condition.getNickname(),
                condition.getStartDate(),
                condition.getEndDate(),
                pageable
        );

        // 2. Todo ID 목록 추출
        List<Long> todoIds = todos.stream()
                .map(TodoPartitioned::getId)
                .toList();

        // 3. 댓글, 매니저 수 조회
        Map<Long, Long> commentCountMap = commentRepository.countCommentsByTodoIds(todoIds);
        Map<Long, Long> managerCountMap = managerRepository.countManagersByTodoIds(todoIds);

        // 4. 응답 DTO 조립
        return todos.stream()
                .map(todo -> new TodoSearchResponse(
                        todo.getTitle(),
                        managerCountMap.getOrDefault(todo.getId(), 0L),
                        commentCountMap.getOrDefault(todo.getId(), 0L)
                ))
                .toList();
    }
}

 

  • 조회 방식: Todo 리스트만 먼저 조회 → 이후 Manager/Comment 개별 조회 후 매핑
  • Repository 구조
    • findTodos(...)
    • countCommentsByTodoIds(...)
    • countManagersByTodoIds(...)
  • 응답 시간 측정 결과 요약
    • 최소: 0.40s
    • 최대: 0.56s
    • 평균: 0.44s

📊 실험 결과 비교

방식 평균 응답 시간 (s) 최소 (s) 최대 (s)
기본 인덱싱 2.46 2.37 2.60
복합 인덱싱 2.49 2.38 2.60
파티셔닝 + 복합 인덱싱 4.47 4.37 4.58
QueryDSL 각 조회 후 처리 방식 0.43 0.41 0.56
  • 파티셔닝 + 복합 인덱싱이 가장 빠를 것을 기대 했지만 오히려 가장 느림
  • 문제는 조인이 너무 많고 그룹 바이가 병목형상을 일으키기 때문
  • 그래서 분리 조회 후 서비스에서 병합하니 어마어마한 성능 개선이 보임

🎓 마무리

  • 복잡한 JOIN과 GROUP BY, LIKE 사용은 성능을 저하시킬 수 있음
  • QueryDSL 또는 JPQL로 각 테이블을 나눠서 조회하고 서비스에서 병합
  • 조인 대상이 많은 경우, 조인보다 개별 조회 후 조합이 훨씬 유리

 

 

'SPRING' 카테고리의 다른 글

[SPRING] 트랜잭션(Transaction)  (1) 2025.05.09
[SPRING] Java vs Kotlin - 코틀린 기초 정리  (1) 2025.05.08
[SPRING] 파티셔닝(Partitioning)  (1) 2025.05.02
[SPRING] JPA로 테이블 객체 다루기  (2) 2025.04.30
[SPRING] 쿠키(Cookie)  (1) 2025.04.25