JPQL이란?
String jpql = "SELECT u FROM User u WHERE u.age > 20";
List<User> result = em.createQuery(jpql, User.class).getResultList();
JPQL의 한계
QueryDSL의 등장
JPQL을 대체하는 것이 아니라 JPQL을 생성하는 DSL
SQL이 아닌 JPA 위에서 동작
Java 코드로 쿼리를 작성
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
중요
Spring Boot 3.x → jakarta.persistence 기반
반드시 :jakarta suffix 필요
QuerydslConfig 설정
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
Q-Type 이해하기
QueryDSL은 엔티티를 기반으로 Q타입 클래스를 자동 생성한다.
엔티티
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String username;
private int age;
}
@Generated("com.querydsl.codegen.EntitySerializer")
public class QUser extends EntityPathBase<User> {
public static final QUser user = new QUser("user");
public final StringPath username = createString("username");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
}
build → classes → java → main → 엔티티 패키지 → Q클래스
기본 QueryDSL 쿼리 예제
public interface PostRepository
extends JpaRepository<Post, Long>, PostCustomRepository {
@EntityGraph(attributePaths = {"user", "comments"})
List<Post> findByUserUsername(String username);
}
public interface PostCustomRepository {
List<PostSummaryDto> findPostSummary(String username);
}
public class PostCustomRepositoryImpl implements PostCustomRepository {
private final JPAQueryFactory queryFactory;
public PostCustomRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<PostSummaryDto> findPostSummary(String username) {
return queryFactory
.select(Projections.constructor(
PostSummaryDto.class,
post.content,
comment.countDistinct().intValue()
))
.from(post)
.leftJoin(post.comments, comment)
.where(post.user.username.eq(username))
.groupBy(post.id)
.fetch();
}
}
JPQL → QueryDSL 리팩토링 비교
❌ JPQL 버전
@Query("""
SELECT new org.example.plus.domain.post.model.dto.PostSummaryDto(
p.content,
SIZE(p.comments)
)
FROM Post p
WHERE p.user.username = :username
""")
List<PostSummaryDto> findAllWithCommentsByUsername(
@Param("username") String username
);
문제점
문자열 기반
필드명 변경 시 런타임 오류
IDE 도움 거의 없음
✅ QueryDSL 버전
@Override
public List<PostSummaryDto> findAllWithCommentsByUsername(String username) {
return queryFactory
.select(Projections.constructor(
PostSummaryDto.class,
post.content,
comment.countDistinct().intValue()
))
.from(post)
.leftJoin(post.comments, comment)
.where(post.user.username.eq(username))
.groupBy(post.id)
.fetch();
}
장점
자동완성 지원
컴파일 타임 검증
쿼리 구조가 코드 흐름 그대로 드러남
JPQL은 문자열 기반이라는 한계가 있으며,
QueryDSL은 이를 타입 안전하고 유지보수 가능한 방식으로 해결한다.
실무에서는 복잡한 조회 로직일수록 QueryDSL의 가치가 커진다.
QueryDSL 기본 구조 이해
queryFactory
.select(조회대상)
.from(대상엔티티)
.where(조건)
.orderBy(정렬)
.fetch();
SQL과 유사하지만 Java 코드 기반
각 단계가 메서드 체이닝으로 명확하게 구분됨
필요 없는 절은 생략 가능 (where, orderBy 등)
QueryDSL은 컴파일 시점에 엔티티 기반 Q타입 클래스를 자동 생성한다.
생성된 QUser 예시
public class QUser extends EntityPathBase<User> {
public static final QUser user = new QUser("user");
public final StringPath username = createString("username");
public final StringPath email = createString("email");
public final EnumPath<UserRoleEnum> roleEnum =
createEnum("roleEnum", UserRoleEnum.class);
}
QUser.user 를 통해 엔티티 필드에 접근
문자열이 아닌 객체 + 메서드 기반
IDE 자동완성 지원
필드명 변경 시 컴파일 에러로 즉시 확인 가능
List<User> result = queryFactory
.selectFrom(user)
.where(
user.roleEnum.eq(UserRoleEnum.NORMAL),
user.email.endsWith("gmail.com")
)
.fetch();
eq() : 같다
ne() : 다르다
gt() : 초과
goe() : 이상
contains() : 포함
endsWith() : 끝남
fetch() → 리스트 반환
fetchOne() → 단건 (2개 이상이면 예외)
fetchFirst() → 첫 번째 결과만 반환
List<User> result = queryFactory
.selectFrom(user)
.orderBy(
user.username.asc(),
user.id.desc()
)
.limit(3)
.fetch();
결과
offset() → 시작 위치
limit() → 조회 개수
Spring Data Pageable과 함께 사용 가능
List<Post> result = queryFactory
.selectFrom(post)
.where(post.content.contains("여행"))
.fetch();
List<User> result = queryFactory
.selectFrom(user)
.where(
user.roleEnum.eq(UserRoleEnum.ADMIN)
.or(user.username.contains("밥"))
)
.fetch();
List<Post> result = queryFactory
.selectFrom(post)
.join(post.user, user)
.where(user.username.eq("앨리스"))
.fetch();
결과
join(post.user, user)
→ SQL의 INNER JOIN과 동일
List<Post> result = queryFactory
.selectFrom(post)
.join(post.user, user).fetchJoin()
.fetch();
fetchJoin() 사용 시
지연 로딩 무시
한 번의 쿼리로 연관 엔티티 조회
N+1 문제 방지
List<Comment> comments = queryFactory
.selectFrom(comment)
.join(comment.post, post)
.where(post.content.eq("리제로 3기 감상평"))
.fetch();
List<Post> page2 = queryFactory
.selectFrom(post)
.orderBy(post.id.asc())
.offset(5)
.limit(5)
.fetch();
요약
QueryDSL은 문자열 JPQL의 한계를 해결하고,
타입 안전성·가독성·유지보수성을 동시에 제공하는 JPA쿼리 도구이다.
기본 CRUD를 넘는 조회 로직에서는 사실상 필수에 가깝다.
사용자가 입력한 조건에 따라 쿼리를 유연하게 생성하는 방식을 말한다.
이름만 입력 → 이름으로 검색
이메일만 입력 → 이메일로 검색
이름 + 이메일 → 두 조건 모두로 검색
처럼 입력 조건이 매번 달라지는 경우에 사용한다.
QueryDSL에서는 주로 다음 두 가지 방식으로 동적 쿼리를 구현한다.
BooleanBuilder
BooleanExpression
List<User> findByUsernameAndEmailContainsAndRoleEnum(
String username, String email, UserRoleEnum roleEnum
);
findByUsername
findByEmailContains
findByUsernameAndRoleEnum
findByUsernameOrEmailContainsAndRoleEnum …
if (username != null && email != null) {
return userRepository.findByUsernameAndEmail(username, email);
} else if (username != null) {
return userRepository.findByUsername(username);
} else if (email != null) {
return userRepository.findByEmail(email);
}
조건이 늘어날수록 분기 지옥
코드 중복 증가
검색 조건 수정 시 로직 전체 수정 필요
올바른 해석
동적 쿼리는
BooleanBuilder
조건을 하나씩 추가하는 가변(Mutable) 컨테이너
조건을 순차적으로 조립할 때 유용
BooleanBuilder builder = new BooleanBuilder();
if (username != null) {
builder.and(user.username.contains(username));
}
if (email != null) {
builder.and(user.email.contains(email));
}
if (role != null) {
builder.and(user.roleEnum.eq(role));
}
List<User> result = queryFactory
.selectFrom(user)
.where(builder)
.fetch();
조건을 자유롭게 추가 / 제거 가능
if문으로 직접 null 체크 필요
코드가 길어질 수 있음
BooleanExpression
하나의 조건을 표현하는 불변(Immutable) 표현식
null을 반환하면 QueryDSL이 자동으로 무시
private BooleanExpression usernameContains(String username) {
return username != null ? user.username.contains(username) : null;
}
private BooleanExpression emailContains(String email) {
return email != null ? user.email.contains(email) : null;
}
private BooleanExpression roleEq(UserRoleEnum role) {
return role != null ? user.roleEnum.eq(role) : null;
}
List<User> result = queryFactory
.selectFrom(user)
.where(
usernameContains(username),
emailContains(email),
roleEq(role)
)
.fetch();
null 자동 무시 → 코드 깔끔
메서드 단위 재사용 가능
구조가 명확함
| 구분 | BooleanBuilder | BooleanExpression |
|---|---|---|
| 개념 | 조건 컨테이너 | 단일 조건 표현식 |
| 객체 성격 | 가변(Mutable) | 불변(Immutable) |
| null 처리 | 직접 if문 | 자동 무시 |
| 가독성 | 상대적으로 복잡 | 깔끔 |
| 재사용성 | 낮음 | 높음 |
| 추천 상황 | 조건을 즉석에서 조립해야 할 때 | 일반적인 검색 조건 |
public interface UserRepository
extends JpaRepository<User, Long>, UserCustomRepository {
}
public interface UserCustomRepository {
List<UserSearchResponse> searchUserByMultiCondition(
UserSearchRequest request, Pageable pageable);
List<UserSearchResponse> searchUserByMultiConditionV2(
UserSearchRequest request, Pageable pageable);
Page<UserSearchResponse> searchUserByMultiConditionPage(
UserSearchRequest request, Pageable pageable);
}
@RequiredArgsConstructor
public class UserCustomRepositoryImpl implements UserCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public List<UserSearchResponse> searchUserByMultiCondition(
UserSearchRequest request, Pageable pageable) {
return queryFactory
.select(Projections.constructor(UserSearchResponse.class,
user.username,
user.email,
user.roleEnum))
.from(user)
.where(
usernameContains(request.getUsername()),
emailContains(request.getEmail()),
roleEq(request.getRole())
)
.orderBy(user.username.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
private BooleanExpression usernameContains(String username) {
return username != null ? user.username.contains(username) : null;
}
private BooleanExpression emailContains(String email) {
return email != null ? user.email.contains(email) : null;
}
private BooleanExpression roleEq(UserRoleEnum role) {
return role != null ? user.roleEnum.eq(role) : null;
}
}
@Override
public List<UserSearchResponse> searchUserByMultiConditionV2(
UserSearchRequest request, Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if (request.getUsername() != null && !request.getUsername().isBlank()) {
builder.and(user.username.contains(request.getUsername()));
}
if (request.getEmail() != null && !request.getEmail().isBlank()) {
builder.and(user.email.contains(request.getEmail()));
}
if (request.getRole() != null) {
builder.and(user.roleEnum.eq(request.getRole()));
}
return queryFactory
.select(Projections.constructor(UserSearchResponse.class,
user.username,
user.email,
user.roleEnum))
.from(user)
.where(builder)
.orderBy(user.username.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
@Override
public Page<UserSearchResponse> searchUserByMultiConditionPage(
UserSearchRequest request, Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if (request.getUsername() != null && !request.getUsername().isBlank()) {
builder.and(user.username.contains(request.getUsername()));
}
if (request.getEmail() != null && !request.getEmail().isBlank()) {
builder.and(user.email.contains(request.getEmail()));
}
if (request.getRole() != null) {
builder.and(user.roleEnum.eq(request.getRole()));
}
// 데이터 조회
List<UserSearchResponse> content = queryFactory
.select(Projections.constructor(UserSearchResponse.class,
user.username,
user.email,
user.roleEnum))
.from(user)
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 전체 카운트 조회
Long total = queryFactory
.select(user.count())
.from(user)
.where(builder)
.fetchOne();
if (total == null) total = 0L;
return new PageImpl<>(content, pageable, total);
}
요약
동적 쿼리는 검색 조건이 유동적인 상황에서 필수이며,
QueryDSL의 BooleanExpression을 활용하면 가독성과 재사용성을 모두 잡을 수 있다.
JPA에서 연관관계를 사용하는 이유
JPA는 객체(Object)와 테이블(Table)을 매핑하는 ORM이다.
즉, 단순히 테이블을 다루는 것이 아니라 객체 그래프 탐색을 가능하게 하는 것이 핵심 목적이다.
연관관계 예시
@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
post.getUser().getUsername();
개발자가 SQL을 직접 작성하지 않아도
객체 참조만으로 연관 데이터 접근 가능
→ JPA가 내부적으로 쿼리를 생성
| 문제 | 설명 |
|---|---|
| N+1 문제 | 연관 엔티티 조회 시 쿼리가 N번 추가 실행 |
| Lazy / Eager 충돌 | 서비스마다 로딩 전략이 달라 일관성 붕괴 |
| 순환 참조 | 양방향 매핑 시 JSON 직렬화 무한 루프 |
| 쿼리 제어 불가 | JPA가 어떤 SQL을 날리는지 예측 어려움 |
| 성능 튜닝 한계 | fetch join, batch size는 임시 해결책 |
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getUsername());
}
실제 실행 SQL
SELECT * FROM post;
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
SELECT * FROM user WHERE id = 3;
...
QueryDSL은 타입 안전한 SQL(JPQL) 빌더이다.
JPA가 “자동으로 쿼리를 만들어주는 방식”이라면
QueryDSL은 “개발자가 쿼리를 직접 조립하는 방식”이다.
List<Post> result = queryFactory
.selectFrom(post)
.join(post.user, user)
.where(user.username.eq("김동현"))
.fetch();
JPA 내부 동작에 의존 ❌
개발자가 조인 시점과 조건을 명시적으로 제어 ⭕
ID 기반 설계 — 연관관계 없는 엔티티
QueryDSL이 등장하면서 연관관계를 꼭 유지할 필요가 없어졌다.
❌ 기존 연관관계 방식
@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY)
private User user;
}
실무형 ID 기반 방식
@Entity
public class Post {
private Long userId; // FK만 저장
}
엔티티는 순수 데이터 구조
관계는 쿼리에서만 조립
List<PostResponse> result = queryFactory
.select(Projections.constructor(
PostResponse.class,
post.content,
user.username))
.from(post)
.leftJoin(user)
.on(post.userId.eq(user.id))
.fetch();
| 항목 | JPA 연관관계 | QueryDSL + ID |
|---|---|---|
| 관계 표현 | @ManyToOne / @OneToMany | Long userId |
| 쿼리 생성 | JPA 자동 | 개발자 직접 |
| 성능 예측 | 어려움 | 매우 쉬움 |
| Lazy 로딩 | Proxy 필요 | 불필요 |
| 순환 참조 | 발생 가능 | 완전 제거 |
| 유지보수성 | 낮음 | ✅ 매우 높음 |
[JPA 초기]
객체 중심 설계 → 연관관계 사용
↓
[N+1, Lazy 문제 발생]
fetch join, batch size로 임시 해결
↓
[QueryDSL 도입]
쿼리 직접 제어 가능
↓
[현재 실무 트렌드]
연관관계 제거
ID 기반 설계 + 명시적 Join(QueryDSL)
요약
JPA는 객체 그래프 탐색을 위해 연관관계를 도입했지만,
QueryDSL의 등장으로 개발자가 직접 Join을 제어할 수 있게 되었고,
실무에서는 연관관계를 제거한
ID 기반 설계 + 명시적 Join(QueryDSL) 방식이 주류가 되었다.
User
// BEFORE
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
// AFTER
// 연관관계 제거
Post
// BEFORE
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// AFTER
private long userId;
Comment
// BEFORE
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
// AFTER
private long postId;
요약
연관관계는 JPA의 철학에서 출발했지만,
QueryDSL은 실무의 통제권을 개발자에게 돌려주었다.
불편함이 쌓이면, 기술은 자연스럽게 진화한다.
우와 한눈에 보기 좋네요 역시 J 용......