📝 실험 배경
이전 포스트에서 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값을 가져오는게 크게 의미가 없음
- 쿼리나 인덱스 캐시도 생각을 해서 개선을 고려
'SPRING' 카테고리의 다른 글
| [SPRING] 커스텀 메트릭 수집 및 시각화 (2) | 2025.05.20 |
|---|---|
| [SPRING] 메트릭 기반 모니터링 시스템(Prometheus, Grafana) (2) | 2025.05.19 |
| [SPRING] @EventListener (3) | 2025.05.14 |
| [SPRING] @TransactionalEventListener (0) | 2025.05.13 |
| [SPRING] 트랜잭션(Transaction) (1) | 2025.05.09 |