프로젝트를 진행하면서 필터링을 위해 동적 쿼리를 사용해야 했다.
그렇게 QueryDSL을 사용해서 필터링을 구현했는데,
구현하면서 경험한 성능 개선 방법을 공유하려 한다.
해당 내용으로 프로젝트 기술 발표에서 발표를 진행하였다.
public interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsByEmail(String email);
Optional<Member> findByEmail(String email);
Optional<Member> findByNickname(String nickname);
Optional<Member> findBySocialId(String socialId);
Optional<Member> findByRefreshToken(String refreshToken);
}
JPA가 기본적으로 제공하는 CRUD 메서드 및 쿼리 메서드 기능만으로도 간단한 데이터 조회나 처리는 충분히 가능합니다. 하지만 보다 복
잡하고 다양한 조건에 따라 데이터를 조회할 경우, 필연적으로 JPQL을 작성하게 됩니다.
public interface ArticleRepository extends JpaRepository<Article, Long> {
// 조회수 1 증가
@Modifying
@Transactional
@Query("UPDATE Article a SET a.viewCount = a.viewCount + 1 WHERE a.id = :id")
void incrementViewCount(@Param("id") Long id);
// 좋아요 증가
@Modifying
@Query("UPDATE Article a SET a.likeCount = a.likeCount + 1 WHERE a.id = :id")
void incrementLikeCount(@Param("id") Long id);
// 좋아요 감소
@Modifying
@Query("UPDATE Article a SET a.likeCount = CASE WHEN a.likeCount > 0 THEN a.likeCount -1 ELSE 0 END WHERE a.id = :id")
void decreasementLikeCount(@Param("id") Long id);
// 내가 작성한 게시글 수 조회
@Query("SELECT COUNT(a) FROM Article a WHERE a.member.email = :email")
int countByMemberEmail(@Param("email") String email);
// 내가 받은 좋아요 수 조회
@Query("SELECT COALESCE(SUM(a.likeCount), 0) FROM Article a WHERE a.member.email = :email")
int countTotalLikesByMemberEmail(@Param("email") String email);
}
JPQL을 활용하면 원하는 조건의 데이터를 효과적으로 조회할 수 있지만, 복잡한 로직에서는 쿼리 문자열이 매우 길어지고 가독성이 떨어집니다. 특히, 개행과 공백이 많은 긴 쿼리에서는 오타나 문법적 오류가 쉽게 발생할 수 있으며, 이러한 오류는 대개 런타임 시점에서만 발견되기 때문에 디버깅과 유지보수가 어렵다는 단점이 있습니다.
QueryDSL?
QueryDSL은 이러한 JPQL의 한계를 극복할 수 있도록 도와주는 프레임워크입니다.
QueryDSL을 사용하면 쿼리를 문자열로 적지 않고 자바 코드 형태로 작성하기 때문에 IDE의 자동완성 기능과 컴파일 시점의 타입 검사를 통해 오타나 문법 오류를 즉시 확인할 수 있습니다. 또한 조건이 다양하게 바뀌는 동적 쿼리도 안전하고 간편하게 구현할 수 있어, 코드의 가독성과 유지보수성을 크게 향상시킬 수 있습니다.
QueryDSL 장점
- IDE 자동완성(코드 자동완성)
- 컴파일 타임 에러 체크(잘못된 필드, 오타, 잘못된 타입 등)
- 동적 쿼리 구현이 매우 쉽고 안전
- 복잡한 조인, 서브쿼리, 그룹바이 등 고급 SQL도 타입 안정적으로 지원
QueryDSL 적용하기
Gradle 설정
dependencies {
//QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
Gradle에서 Clean과 CompileJava 실행
Gradle Build Clean
./gradlew clean
Gradle Compile Java
./gradlew compileJava
혹은 Gradle 탭에서 build > clean , other > compile.java 실행

QClass 생성 확인
프로젝트 아래의 경로에서 Entity 클래스의 이름앞에 Q가 붙은 QClass가 생성됐는지 확인

QClass
QueryDSL에서 사용하는 엔티티 클래스의 메타 데이터 클래스로, 정적 타입 세이프 쿼리를 작성하기 위해 자동 생성된 클래스
기존의 문자열 기반의 JPQL이나 SQL 쿼리에서 발생할 수 있는 오타, 문법 오류 등의 문제를 컴파일 시점에 잡아내기 위해 도입
QClass 삭제할 경우
clean {
delete file('src/main/generated')
}
QClass 파일을 직접 삭제해야 하는 경우, gradle에 아래 스크립트를 추가하면 gradle clean 명령어를 실행할 때 src/main/generated 의 파일도 함께 삭제해줍니다.
QueryDSLConfig.java
JPAQueryFactory 를 Bean으로 등록
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QueryDSLConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
JPAQueryFactory* JPAQuery 인스턴스를 손쉽게 생성하기 위한 팩토리 클래스
Custom Repository
QueryDSL을 구현할 구현체를 정의
public interface ArticleFilterRepository {
Page<ArticleResponseDTO> findByFilters(
String carType,
List<String> carName,
List<Integer> carAge,
List<ArticleType> articleType,
SortType sortType,
Pageable pageable,
Long userId
);
Page<ArticleResponseDTO> findByMemberId(
SortType sortType,
Pageable pageable,
String memberId,
Long userId
);
}
RepositoryImpl
구현 클래스에 QueryDSL 쿼리 작성
Spring Data JPA의 사용자 정의 리포지토리 확장(Custom Repository)을 위해서 구현 클래스 이름은 반드시 Impl로 끝나야 함 • 기본적으로 JPA는 <인터페이스명>Impl 을 구현체 이름으로 인식
@Repository
@RequiredArgsConstructor
public class ArticleFilterRepositoryImpl implements ArticleFilterRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<ArticleResponseDTO> findByFilters(
String carType,
List<String> carNames,
List<Integer> carAges,
List<ArticleType> articleTypes,
SortType sortType,
Pageable pageable,
Long userId
) {
...
{ 생략 }
...
return new PageImpl<>(results, pageable, totalCount);
}
@Override
public Page<ArticleResponseDTO> findByMemberId(
SortType sortType,
Pageable pageable,
String memberId,
Long userId
) {
...
{ 생략 }
...
return new PageImpl<>(result, pageable, totalCount);
}
}
- ArticleRepository 인터페이스를 바로 상속받으면 JpaRepository의 기본 메서드들도 Override되므로 새로운 ArticleFilterRepository를 상속받아 해당하는 메서드만 Override 구현
Repository
ArticleFilterRepository를 기존 Repository에 상속
public interface ArticleRepository extends JpaRepository<Article, Long>, **ArticleFilterRepository** {
// 조회수 1 증가
@Modifying
@Transactional
@Query("UPDATE Article a SET a.viewCount = a.viewCount + 1 WHERE a.id = :id")
void incrementViewCount(@Param("id") Long id);
// 좋아요 증가
@Modifying
@Query("UPDATE Article a SET a.likeCount = a.likeCount + 1 WHERE a.id = :id")
void incrementLikeCount(@Param("id") Long id);
// 좋아요 감소
@Modifying
@Query("UPDATE Article a SET a.likeCount = CASE WHEN a.likeCount > 0 THEN a.likeCount -1 ELSE 0 END WHERE a.id = :id")
void decreasementLikeCount(@Param("id") Long id);
// 내가 작성한 게시글 수 조회
@Query("SELECT COUNT(a) FROM Article a WHERE a.member.email = :email")
int countByMemberEmail(@Param("email") String email);
// 내가 받은 좋아요 수 조회
@Query("SELECT COALESCE(SUM(a.likeCount), 0) FROM Article a WHERE a.member.email = :email")
int countTotalLikesByMemberEmail(@Param("email") String email);
...
{ 생략 }
...
}
QueryDSL 쿼리문 예시
간단한 조회 (SELECT)
QArticle article = QArticle.article;
List<Article> articles = queryFactory
.selectFrom(article)
.fetch();
조건이 있는 조회 (WHERE 조건)
QArticle article = QArticle.article;
List<Article> results = queryFactory
.selectFrom(article)
.where(article.title.eq("QueryDSL 입문"))
.fetch();
다중 조건 (AND / OR)
QArticle article = QArticle.article;
List<Article> results = queryFactory
.selectFrom(article)
.where(article.title.contains("입문").and(article.viewCount.gt(100)))
.fetch();
QueryDSL 비교 메서드
- eq(...) : equal, =
- ne(...) : not equal, !=
- gt(…) : greater than, >
- goe(...): greater or equal, >=
- lt(...) : less than, <
- loe(...): less or equal, <=
동적 쿼리 (BooleanBuilder 사용)
QArticle article = QArticle.article;
BooleanBuilder builder = new BooleanBuilder();
String keyword = "QueryDSL";
Long minView = 50L;
if (keyword != null) {
builder.and(article.title.contains(keyword));
}
if (minView != null) {
builder.and(article.viewCount.gt(minView));
}
List<Article> results = queryFactory
.selectFrom(article)
.where(builder)
.fetch();
페이징과 정렬 (Pagination & Sorting)
QArticle article = QArticle.article;
List<Article> results = queryFactory
.selectFrom(article)
.orderBy(article.createdAt.desc())
.offset(0) // 시작 위치
.limit(10) // 조회 개수
.fetch();
페치 조인 (Fetch Join)
QArticle article = QArticle.article;
QMember member = QMember.member;
List<Article> results = queryFactory
.selectFrom(article)
.leftJoin(article.member, member).fetchJoin()
.fetch();
집계함수와 Group By
QArticle article = QArticle.article;
List<Tuple> results = queryFactory
.select(article.articleType, article.viewCount.sum())
.from(article)
.groupBy(article.articleType)
.fetch();
QueryDSL 사용 시 발생할 수 있는 성능 문제점
⚠️ 1. N+1 문제 (Fetch Join 미사용)
문제 상황
- 연관된 엔티티를 조회할 때 Fetch Join을 사용하지 않으면, 기본적으로 지연 로딩(LAZY)으로 인해 연관 엔티티를 호출할 때마다 추가적인 쿼리가 발생합니다.
- 결과적으로 1개의 기본 쿼리 외에도 연관 엔티티 개수(N)만큼 추가 쿼리가 실행되어 성능이 급격히 저하됩니다.
지양해야 하는 코드 예시
// 잘못된 예시
List<Article> articles = queryFactory.selectFrom(article)
.leftJoin(article.carAge).leftJoin(article.member)
.fetch(); // fetchJoin을 사용하지 않음 (추가 쿼리 N개 발생)
해결 방법
- 연관된 데이터를 즉시 로딩할 때는 반드시 Fetch Join을 사용하여, SQL 한번으로 모든 데이터를 가져옵니다.
// 올바른 예시
List<Article> articles = queryFactory.selectFrom(article)
.leftJoin(article.carAge, carAge).fetchJoin()
.leftJoin(article.member, member).fetchJoin()
.fetch();
⚠️ 2. 동적 쿼리에서의 비효율적인 조건 처리 (하드코딩, 반복 분기)
문제 상황
- 조건이 많아질수록, 하드코딩된 쿼리문은 코드가 복잡해지고 가독성도 떨어집니다.
- 유지보수가 어려워지고 불필요한 조건이 항상 실행되어 성능이 떨어질 수 있습니다.
지양해야 하는 코드 예시
// 비효율적인 예시 (하드코딩, 불필요한 조건 반복)
if (carType != null && !carType.isEmpty() && carNames != null && !carNames.isEmpty()) {
builder.and(carTypeEntity.carType.eq(carType).and(carName.carName.in(carNames)));
} else if (carType != null && !carType.isEmpty()) {
builder.and(carTypeEntity.carType.eq(carType));
} else if (carNames != null && !carNames.isEmpty()) {
builder.and(carName.carName.in(carNames));
}
해결 방법
- BooleanBuilder를 활용해 선택된 조건만 동적으로 추가합니다. 불필요한 조건 분기를 최소화하여 성능과 유지보수를 동시에 개선합니다.
// 효율적인 예시 (BooleanBuilder 활용)
BooleanBuilder builder = new BooleanBuilder();
if (carType != null && !carType.isEmpty()) {
builder.and(carTypeEntity.carType.eq(carType));
}
if (carNames != null && !carNames.isEmpty()) {
builder.and(carName.carName.in(carNames));
}
// 필요한 조건만 동적으로 추가됨
⚠️ 3. 불필요한 전체 엔티티 조회 (DTO Projection 미사용)
문제 상황
- 전체 엔티티를 조회하면 필요 없는 컬럼까지 함께 로딩되어 성능 저하 및 메모리 낭비가 발생합니다.
- 조회된 엔티티는 영속성 컨텍스트에 저장되기 때문에 불필요한 캐시 오버헤드가 추가됩니다.
지양해야 하는 코드 예시
// 불필요한 엔티티 전체 조회 (비효율적)
List<Article> articles = queryFactory.selectFrom(article)
.fetch();
해결 방법
- DTO Projection을 통해 화면에 필요한 필드만 선택적으로 조회해 성능을 최적화합니다.
// 필요한 필드만 DTO로 프로젝션
List<ArticleDto> articles = queryFactory
.select(Projections.constructor(ArticleDto.class,
article.id,
article.title,
article.likeCount))
.from(article)
.fetch();
DTO Projection?
1. Projections.constructor
- 방식: DTO의 생성자를 직접 호출
- 코드 예시
List<ArticleDto> list = queryFactory
.select(Projections.constructor(ArticleDto.class,
article.id,
article.title,
article.viewCount))
.from(article)
.fetch();
- 장점: 불변(immutable) DTO 설계에 적합, 생성자 주입으로 값이 깔끔하게 설정됨
- 단점: 파라미터 순서·타입이 정확히 일치해야 하고, 런타임 에러 발생 가능
2. Projections.fields (혹은 Projections.bean)
- 방식: 기본 생성자 + setter(또는 public 필드)로 값 주입
- 코드 예시
List<ArticleDto> list = queryFactory
.select(Projections.fields(ArticleDto.class,
article.id.as("id"),
article.title.as("title"),
article.viewCount.as("viewCount")))
.from(article)
.fetch();
- 장점: 필드 이름(alias)만 맞추면 되고, 파라미터 순서 무관
- 단점: DTO에 setter나 public 필드가 있어야 함
3. @QueryProjection + QDTO
- 방식: DTO 생성자에 @QueryProjection 붙이고, QueryDSL 컴파일러가 생성한 Q타입 DTO 사용
- DTO 클래스 예시
public class ArticleDto {
@QueryProjection
public ArticleDto(Long id, String title, int viewCount) { … }
// getter만 있어도 OK
}
- 쿼리 사용 예시
QArticleDto dto = QArticleDto.articleDto;
List<ArticleDto> list = queryFactory
.select(dto)
.from(article)
.fetch();
- 장점: 컴파일 타임에 파라미터 매칭 완전 검사 → 타입 안전성 최고
- 단점: DTO 빌드 시 추가 컴파일 단계 필요(apt 실행)
⚠️ 4. 잘못된 exists 사용으로 성능 저하
문제 상황
- 쿼리DSL에서 exists를 잘못 사용하면 실제 쿼리는 count 형태로 실행되어 전체 레코드를 스캔할 수 있습니다.
- 원하는 성능(조건 충족 시 즉시 반환)을 얻지 못하고 오히려 성능이 저하됩니다.
지양해야 하는 코드 예시
// 잘못된 exists 사용 (내부적으로 count 사용)
Boolean exists = queryFactory.selectFrom(book)
.where(book.id.eq(bookId))
.fetchCount() > 0;
해결 방법
- exists를 직접 구현할 때는 selectOne()과 fetchFirst()로 조건을 만족하면 즉시 종료하도록 설정합니다.
// 올바른 exists 사용 예시
Boolean exists = queryFactory.selectOne()
.from(book)
.where(book.id.eq(bookId))
.fetchFirst() != null;
⚠️ 5. Cross Join 발생
문제 상황
- 명시적인 조인 없이 연관된 엔티티 필드를 조건절에 직접 사용하면, Hibernate에서 묵시적으로 Cross Join을 발생시킵니다.
- 이는 데이터가 많아질수록 지수적으로 성능 저하가 발생합니다.
지양해야 하는 코드 예시
// 묵시적 cross join 발생
List<Customer> result = queryFactory.selectFrom(customer)
.where(customer.customerNo.gt(customer.shop.shopNo))
.fetch();
해결 방법
- 반드시 명시적인 Join을 사용하여 Cross Join을 예방합니다.
// 올바른 명시적 조인 사용
List<Customer> result = queryFactory.selectFrom(customer)
.join(customer.shop, shop)
.where(customer.customerNo.gt(shop.shopNo))
.fetch();
⚠️ 6. 비효율적인 Count 쿼리 (Fetch Join 남용)
문제 상황
- 페이징 처리를 위한 Count 쿼리에 페치 조인을 그대로 사용하는 경우, 조인 대상 테이블까지 전부 조회하여 비효율적입니다.
- 대량 데이터 환경에서 성능 문제로 이어질 수 있습니다.
지양해야 하는 코드 예시
// 잘못된 Count 쿼리 예시 (불필요한 fetch join)
Long total = queryFactory.select(article.count())
.from(article)
.leftJoin(article.carAge, carAge).fetchJoin()
.leftJoin(carAge.carName, carName).fetchJoin()
.fetchOne();
해결 방법
- Count 쿼리에서는 Fetch Join을 제거하여 최소한의 조인만 사용합니다.
// 올바른 Count 쿼리 예시 (fetch join 제거)
Long total = queryFactory.select(article.count())
.from(article)
.leftJoin(article.carAge, carAge)
.leftJoin(carAge.carName, carName)
.fetchOne();
프로젝트내 QueryDSL 성능 개선 하기
⚠️ 기존 코드의 문제점
1. 엔티티 전체 조회로 인한 불필요한 데이터 로딩
기존 코드에서는 Article 엔티티 전체를 조회하고 있었으며, 이로 인해 필요하지 않은 필드까지 함께 조회되어 메모리 낭비 및 조회 성능 저하가 발생했습니다.
@Repository
@RequiredArgsConstructor
public class ArticleFilterRepositoryImpl implements ArticleFilterRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<**Article**> findByFilters(
String carType,
List<String> carNames,
List<Integer> carAges,
List<ArticleType> articleTypes,
SortType sortType,
Pageable pageable
) {
...
{ 생략 }
...
return new PageImpl<>(result, pageable, total);
}
@Override
public Page<**Article**> findByMemberId(
SortType sortType,
Pageable pageable,
String memberId
) {
...
{ 생략 }
...
return new PageImpl<>(result, pageable, total);
}
}
2. 페이징 조회 시 과도한 Fetch Join으로 인한 데이터 과적재 문제
기존 코드에서는 페이징 조회를 할 때 연관된 모든 엔티티(CarAge, CarName, CarType, Member)를 Fetch Join으로 한 번에 가져오고 있었습니다. 이는 페이징 대상 데이터가 많아질수록 조회된 데이터가 커지고, 복잡한 조인 쿼리로 인해 데이터베이스 및 네트워크 성능에 부담을 줍니다.
List<Article> result = queryFactory
.selectFrom(article)
.leftJoin(article.carAge, carAge)**.fetchJoin()**
.leftJoin(carAge.carName, carName)**.fetchJoin()**
.leftJoin(carName.carType, carTypeEntity)**.fetchJoin()**
.leftJoin(article.member)**.fetchJoin()**
.where(builder)
.orderBy(order)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
3. 서비스 계층에서 발생하던 중복 쿼리
articleRepository.findByFilters(...)로 게시글 목록을 가져온 후, ****각 게시글마다 다음과 같은 추가 쿼리가 발생함:
- commentRepository.countByArticle_Id(...) → 댓글 개수 조회
- articleLikeRepository.existsByArticle_IdAndMember_Id(...) → 좋아요 여부 확인
게시글이 많아질수록 N개의 추가 쿼리 발생 → 중복 호출로 성능 저하
// 게시글 목록 조회 서비스 코드
@Transactional
public Page<ArticleResponseDTO> getArticle(
String carType,
List<String> carName,
List<Integer> carAge,
List<ArticleType> articleType,
SortType sortType,
Pageable pageable,
String memberEmail
) {
Page<Article> articleList = articleRepository.findByFilters(carType, carName, carAge, articleType, sortType, pageable);
// 이메일이 null이 아니면 회원 조회
Long userId;
if (memberEmail != null && !memberEmail.isBlank()) {
Optional<Member> opt = memberRepository.findByEmail(memberEmail.trim());
userId = opt.map(Member::getId).orElse(null);
} else userId = null;
return articleList.map(article -> new ArticleResponseDTO(
article.getId(),
article.getTitle(),
article.getContent(),
article.getLikeCount(),
article.getViewCount(),
**commentRepository.countByArticle_Id(article.getId()),**
article.getCreatedAt(),
**(userId != null) && articleLikeRepository.existsByArticle_IdAndMember_Id(article.getId(), userId),**
article.getCarAge().getCarName().getCarName(),
article.getCarAge().getCarAge()
));
}
✅ 성능 개선 방법 및 적용 사례
1. 엔티티 전체 조회 → DTO Projection 적용
- 이유: 화면에 필요한 필드만 선택적으로 조회하여 불필요한 데이터 로딩을 최소화하기 위함
- 적용 코드:
@Override
public Page<**ArticleResponseDTO**> findByFilters(
String carType,
List<String> carNames,
List<Integer> carAges,
List<ArticleType> articleTypes,
SortType sortType,
Pageable pageable,
Long userId
) {
{... 생략 ...}
return new PageImpl<>(results, pageable, totalCount);
}
@Override
public Page<**ArticleResponseDTO**> findByMemberId(
SortType sortType,
Pageable pageable,
String memberId,
Long userId
) {
{... 생략 ...}
return new PageImpl<>(result, pageable, totalCount);
}
2. Fetch Join → 쿼리에서 Fetch Join 제거
- 이유: Fetch Join을 제거하고 DTO 프로젝션을 적용하여 데이터 로딩 효율성을 높임
- 적용 코드:
// ... QueryDSL ... 부분
List<ArticleResponseDTO> results = queryFactory
.select(**Projections.constructor**(ArticleResponseDTO.class,
article.id,
article.title,
...
{ 생략 }
...
carName.carName,
carAge.carAge
))
.from(article)
.leftJoin(article.carAge, carAge) <-- //.fetchJoin() 제거
.leftJoin(carAge.carName, carName) <-- //.fetchJoin() 제거
.leftJoin(carName.carType, carTypeEntity) <-- //.fetchJoin() 제거
.leftJoin(article.member) <-- //.fetchJoin() 제거
.where(builder)
.orderBy(order)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
3. 서비스 계층 중복 쿼리 → 직접 DTO로 서브쿼리를 작성하여 한 번에 조회
- 이유: ArticleResponseDTO에 필요한 댓글 수, 좋아요 여부 등을 한 쿼리 안에서 조회
- → 서비스 단에서는 더 이상 반복적인 DB 호출이 필요 없음
- 적용 코드:
// ... QueryDSL ... 부분
List<ArticleResponseDTO> result = queryFactory
.select(Projections.constructor(ArticleResponseDTO.class,
article.id,
article.title,
article.content,
article.likeCount,
article.viewCount,
**// 댓글 개수 서브쿼리
JPAExpressions.select(comment.count())
.from(comment)
.where(comment.article.id.eq(article.id)),**
article.createdAt,
**// 좋아요 여부 서브쿼리
userId != null
? JPAExpressions.selectOne()
.from(articleLike)
.where(articleLike.article.id.eq(article.id)
.and(articleLike.member.id.eq(userId)))
.exists()
: Expressions.asBoolean(false),**
carName.carName,
carAge.carAge
))
.from(article)
.leftJoin(article.carAge, carAge)
.leftJoin(carAge.carName, carName)
.leftJoin(carName.carType, carTypeEntity)
.leftJoin(article.member)
.where(article.member.email.eq(memberId))
.orderBy(order)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 게시글 목록 조회 서비스 코드
@Transactional
public Page<ArticleResponseDTO> getMyArticle(SortType sortType, Pageable pageable, String memberEmail) {
// 이메일이 null이 아니면 회원 조회
Long userId;
if (memberEmail != null && !memberEmail.isBlank()) {
Optional<Member> opt = memberRepository.findByEmail(memberEmail.trim());
userId = opt.map(Member::getId).orElse(null);
} else userId = null;
return articleRepository.findByMemberId(sortType, pageable, memberEmail, userId);
4. N+1 문제 해결
- 이유: Fetch Join을 DTO 프로젝션에 맞춰 효율적으로 구성해 N+1 문제 원천 방지
- 적용 방법: 서브쿼리를 통해 댓글 개수 및 좋아요 여부를 조회하여 추가적인 쿼리를 방지
📈 개선 후
- 메모리 및 네트워크 부하 감소: DTO 프로젝션으로 필요한 데이터만 로딩하여 메모리 사용량과 데이터 전송량을 현저히 줄임
- 성능 최적화: Fetch Join의 적절한 사용과 카운트 쿼리 최적화로 조회 속도 개선
- 유지보수성 향상: 동적 쿼리의 명확한 구조로 인해 유지보수 및 코드 가독성 개선
게시글 5000개 정도 있는 데이터에서 필터링 검색
swagger에서 호출 후 웹페이지에서 측정된 시간 기준
개선 전
- 엔티티 직접 반환
- Fetch Join
- 서비스계층에서 중복 쿼리 호출

개선 후
- DTO Projection 적용
- Fetch Join (X)
- QueryDSL 내 서브 쿼리 호출

대략 5000개의 데이터가 있는 환경에서 호출 시간이 단축된 것을 보아, 더욱 많은 대량의 데이터에서 성능 개선 효과가 클 것으로 예상
