📝 실험 배경
아래 포스트에서 최적 복합 인덱스로 성능 개선을 느낀적이 있었다.
이번에는 그걸 넘어서 파티셔닝으로 성능 개선을 느끼는게 목적이다.
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 |