안녕하세요. 오늘은 최근 진행한 게시글 조회 API의 성능 최적화 경험을 자세히 공유하고자 합니다. 특히 서브쿼리 활용과 기존 batch size 설정의 한계를 넘어선 새로운 접근 방식에 대해 이야기하겠습니다.
1. 문제 상황
우리 서비스의 게시글 목록 조회 API는 다음과 같은 문제점을 가지고 있었습니다:
- 엔티티를 조회한 후 DTO로 변환하는 과정에서 불필요한 데이터 로딩
- N+1 문제로 인한 추가적인 쿼리 발생
- 복잡한 정렬 로직으로 인한 애플리케이션 레벨에서의 추가 연산
- 찬성/반대 투표 수 집계를 위한 별도의 쿼리 실행
2. 이전의 개선 시도: 전역 Batch Size 설정
초기에는 N+1 문제를 해결하기 위해 application.yml 파일에서 전역으로 batch size를 설정했습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 방식으로 어느 정도 성능 향상을 이룰 수 있었지만, 여전히 다음과 같은 한계가 있었습니다:
- 추가 쿼리 실행: Batch Size 만큼의 IN 쿼리가 실행됨
- 메모리 사용량 증가: 엔티티 전체를 로딩하므로 불필요한 데이터도 메모리에 올라옴
- 복잡한 집계 연산: 애플리케이션에서 투표 수 등을 계산해야 함
- 세밀한 제어의 어려움: 모든 연관 관계에 동일한 batch size가 적용되어 상황에 따른 최적화가 어려움
방식으로 어느 정도 성능 향상을 이룰 수 있었지만, 여전히 한계가 있었습니다.
3. 새로운 접근: Querydsl과 다양한 DTO 조회 전략
이러한 한계를 극복하기 위해 Querydsl을 사용하여 Repository에서 직접 DTO를 반환하는 방식을 채택했습니다. Querydsl을 사용한 DTO 조회에는 여러 가지 방법이 있으며, 각 방법의 특징과 장단점을 살펴보겠습니다.
Projections.constructor() 사용
이 방법은 DTO의 생성자를 이용해 결과를 매핑합니다.
public List<PostDto> findAllSorted(SortType sortType) {
return jpaQueryFactory
.select(Projections.constructor(PostDto.class,
post.id,
post.title,
post.thumbnail,
post.createdAt,
post.isDone,
post.comments.size(),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isTrue())),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isFalse()))
))
.from(post)
.where(/* 조건절 */)
.fetch();
}
장점:
- 타입 안전성이 높습니다.
- 생성자의 파라미터 순서만 맞으면 됩니다.
단점:
- 생성자의 인자가 많아지면 가독성이 떨어질 수 있습니다.
Projections.fields() 사용
DTO의 필드명과 일치하는 값을 매핑합니다.
return jpaQueryFactory
.select(Projections.fields(PostDto.class,
post.id,
post.title,
post.thumbnail,
post.createdAt,
post.isDone,
post.comments.size().as("commentCount"),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isTrue())).as("yesVotes"),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isFalse())).as("noVotes")
))
.from(post)
.fetch();
장점:
- 필드명만 일치하면 되므로 유연합니다.
- as()를 사용해 별칭을 지정할 수 있습니다.
단점:
- 필드명을 문자열로 지정하므로 타입 안전성이 떨어집니다.
@QueryProjection 사용
DTO 생성자에 @QueryProjection 어노테이션을 붙여 사용합니다.
public class PostDto {
@QueryProjection
public PostDto(Long id, String title, String thumbnail, LocalDateTime createdAt,
boolean isDone, long commentCount, long yesVotes, long noVotes) {
// 생성자 내용
}
}
// 쿼리 메소드
return jpaQueryFactory
.select(new QPostDto(
post.id,
post.title,
post.thumbnail,
post.createdAt,
post.isDone,
post.comments.size(),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isTrue())),
JPAExpressions.select(vote.count())
.from(vote)
.where(vote.post.eq(post).and(vote.isYes.isFalse()))
))
.from(post)
.fetch();
장점:
- 컴파일 시점에 타입 체크가 가능해 가장 안전합니다.
단점:
- DTO 클래스가 Querydsl에 의존성을 갖게 됩니다.
서브쿼리 활용의 장점
- 단일 쿼리로 처리: 모든 데이터를 하나의 쿼리로 조회하여 N+1 문제 해결
- 데이터베이스 레벨의 집계: 찬성/반대 투표 수를 DB에서 직접 계산
- 필요한 데이터만 조회: DTO로 직접 매핑하여 불필요한 데이터 로딩 방지
- 타입 안전성: Querydsl의 타입 안전한 쿼리 작성으로 런타임 오류 감소
- 세밀한 제어 가능: 각 상황에 맞는 최적의 쿼리 구성 가능
저는 생성자를 사용했으며 약 1000건의 데이터를 사용하여 테스트한 결과 약 20 퍼센트의 시간이 단축됬습니다.