SPRING

[SPRING] 조건 검색 개선 3탄 (분리 조회,Projection, 복합 인덱스)

도원좀비 2025. 5. 15. 22:39

📝 실험 배경

이전 포스트에서 QueryDSL 후 서비스에서 처리 방식으로 많은 성능 개선을 느꼈다.

Todo 리스트만 먼저 조회` 를 할 때 `TodoPartitioned` 를 한번에 가져오는게 아니라 `ID(PK)` 필드만 Projection 해서 조회한다면 커버링 인덱스에 의해서 더 빠르게 동작할 것 같은데 여기까지 실험을 연장해볼 수 있을까요?

이번엔 해당 피드백을 받아 개선을 진행했다.

https://sukh115.tistory.com/100

 

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

📝 실험 배경아래 포스트에서 최적 복합 인덱스로 성능 개선을 느낀적이 있었다. 이번에는 그걸 넘어서 파티셔닝으로 성능 개선을 느끼는게 목적이다.https://sukh115.tistory.com/84 [SPRING] 뉴스피드

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(PK)` 필드만 Projection 해서 조회 id(pk)만 조회해서 커버링인덱스 적용
ID(PK)` 필드만 Projection +
복합 인덱싱
위 에서 ( created_at, title, user_id, id) 복합 인덱스 적용
QueryDSL 후 처리 방식 +
복합 인덱싱
기존 Todo만 검색 후, Manager, Comment 개수는 별도 조회 후 서비스 단에서 조합 +
복합 엔덱싱 적용

🕵️‍♂️ 1 차실험 - ID(PK)` 필드만 Projection 해서 조회

더보기

(TodoPartitionedQueryRepositoryImpl,java)

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

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

    @Override
    public List<TodoPartitioned> findTodosByIds(List<Long> ids) {
        QTodoPartitioned todo = QTodoPartitioned.todoPartitioned;

        return queryFactory
                .selectFrom(todo)
                .where(todo.id.in(ids))
                .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(...)
  • 응답 시간 측정 결과 요약
    • 최소: 152ms
    • 최대: 165ms
    • 평균: 157ms

🕵️‍♂️ 2차 실험 - ID(PK)` 필드만 Projection + 복합 인덱싱

더보기

(TodoPartitionedQueryRepositoryImpl,java)

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

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

    @Override
    public List<TodoPartitioned> findTodosByIds(List<Long> ids) {
        QTodoPartitioned todo = QTodoPartitioned.todoPartitioned;

        return queryFactory
                .selectFrom(todo)
                .where(todo.id.in(ids))
                .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 개별 조회 후 매핑 +
                    복합 인덱스(created_at, title, user_id, id)
  • Repository 구조
    • findTodos(...)
    • countCommentsByTodoIds(...)
    • countManagersByTodoIds(...)
  • 응답 시간 측정 결과 요약
    • 최소: 29ms
    • 최대: 33ms
    • 평균: 31ms

🕵️‍♂️ 3차 실험 - 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();
    }
}

 

  • 조회 방식: 기존 방식인  QueryDSL 후 처리 방식 + 복합 인덱싱 (created_at, title, user_id, id)
  • Repository 구조
    • findTodos(...)
    • countCommentsByTodoIds(...)
    • countManagersByTodoIds(...)
  • 응답 시간 측정 결과 요약
    • 최소: 24ms
    • 최대: 25ms
    • 평균: 27ms

📊 실험 결과 비교

실험 구조 인덱스 구성 쿼리 수 평균 응답(ms)  시간비고
ID(PK)` 필드만 Projection 해서 조회 id (PK)만  4 152~165 이전 실험보다
성능 3배 개선 
복합 인덱스 + id만 Projection created_at, title, user_id, id 4 29~33 약간 느림
복합 인덱스 + 기존 검색 created_at, title, user_id, id 3 24~27 ✅ 가장 빠름

🔬 분석

① ID(PK)` 필드만 Projection 해서 조회

  • 특히 기존 실험(뉴스피드 조회 성능 실험)에서 500ms 이상 걸리던 구조와 비교해 3배 이상의 성능 개선을 확인
  • 하지만 title, created_at 등의 조건을 위한 인덱스가 없으므로, 결국 테이블 접근 비용이 존재함
    조건 필터링 범위가 넓을 경우 성능 저하 가능

② 복합 인덱스 + id만 Projection

  • 조건 (title, created_at)에 맞는 복합 인덱스를 타며, 정렬도 인덱스 순서로 정리됨
  • id만 조회는 커버링 인덱스 효과를 내며 테이블 접근 없이 처리 가능
  • 하지만 쿼리가 4개로 쪼개져서 조합 비용이 약간 발생 → 한방 쿼리보단 느림
    → 구조적으로 유연하긴 하지만 성능 면에선 조금 손해

③ 복합 인덱스 + 기존 검색

  • 인덱스 정렬 + 필터 조건 + 조회 컬럼까지 모두 커버함
  • selectFrom(todo)로 Todo 전체 필드를 한 번에 가져와도, 인덱스가 워낙 잘 맞아 디스크 접근 최소화
  • 댓글 수, 매니저 수는 그대로 IN 조건 + GROUP BY로 한 번에 처리
    쿼리 구조 간결 + 실행계획 최적화 + 최소 I/O 조합
    → 실험 중 가장 빠른 결과

🎓 마무리

  • id필드만으로 Projection해서 조회했을 때 기존보다 많으 성능 개선을 경험
  • 더 강한 성능 개선을 보기 위해서 복합 인덱스까지 적용 하니 8배의 성능이 추가로 개선
  • 기존 검색에 복합 인덱스만 걸어도 캐시로 띄워놓기 때문에 id만 projection으로 id값을 가져오는게 크게 의미가 없음
  • 쿼리나 인덱스 캐시도 생각을 해서 개선을 고려