Session 5JPA Workshop

Spring Data JPA 활용

Repository 인터페이스, 쿼리 메서드, JPQL, 페이징, 동적 쿼리를 학습합니다.

학습 목표

  • JpaRepository의 기본 메서드를 활용할 수 있다
  • 쿼리 메서드 규칙을 이해하고 작성할 수 있다
  • @Query로 JPQL을 작성할 수 있다
  • 페이징과 정렬을 적용할 수 있다
  • Specification으로 동적 쿼리를 구현할 수 있다
  • QueryDSL로 타입 안전한 동적 쿼리를 작성할 수 있다

1. Spring Data JPA 아키텍처

Spring Data JPA란?

JPA를 더 쉽게 사용할 수 있도록 추상화한 Spring 프로젝트입니다. 반복적인 CRUD 코드를 자동 생성하고, 메서드 이름만으로 쿼리를 만들어줍니다.

1.1 Repository 계층 구조

인터페이스 상속 구조
┌─────────────────────────────────────────────────────────────┐
│                    Repository 계층 구조                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Repository<T, ID>              ← 최상위 마커 인터페이스     │
│       │                                                     │
│       ▼                                                     │
│  CrudRepository<T, ID>          ← 기본 CRUD 메서드          │
│       │                            save, findById, delete   │
│       ▼                                                     │
│  PagingAndSortingRepository     ← 페이징, 정렬 추가         │
│       │                            findAll(Pageable)        │
│       ▼                                                     │
│  JpaRepository<T, ID>           ← JPA 특화 기능             │
│                                    flush, saveAndFlush      │
│                                    deleteInBatch            │
└─────────────────────────────────────────────────────────────┘
각 Repository 인터페이스 비교
인터페이스주요 메서드사용 시점
Repository없음 (마커)커스텀 메서드만 정의할 때
CrudRepositorysave, findById, findAll, delete, count, existsById기본 CRUD만 필요할 때
PagingAndSortingRepositoryfindAll(Sort), findAll(Pageable)페이징/정렬이 필요할 때
JpaRepositoryflush, saveAndFlush, deleteInBatch, getOneJPA 사용 시 (권장)

1.2 JpaRepository 주요 메서드

CRUD 메서드 상세
public interface MemberRepository extends JpaRepository<Member, Long> {
    // JpaRepository가 제공하는 메서드들 (자동 구현)
}

// === 저장 ===
Member member = new Member("kim");
memberRepository.save(member);           // INSERT or UPDATE (merge)
memberRepository.saveAndFlush(member);   // save + 즉시 flush
memberRepository.saveAll(List.of(m1, m2)); // 여러 건 저장

// === 조회 ===
Optional<Member> member = memberRepository.findById(1L);
List<Member> members = memberRepository.findAll();
List<Member> members = memberRepository.findAllById(List.of(1L, 2L, 3L));
boolean exists = memberRepository.existsById(1L);
long count = memberRepository.count();

// === 삭제 ===
memberRepository.delete(member);         // 단건 삭제
memberRepository.deleteById(1L);         // ID로 삭제
memberRepository.deleteAll();            // 전체 삭제 (N개 쿼리)
memberRepository.deleteAllInBatch();     // 전체 삭제 (1개 쿼리)
memberRepository.deleteAllByIdInBatch(List.of(1L, 2L)); // 배치 삭제

// === JPA 특화 ===
memberRepository.flush();                // 영속성 컨텍스트 flush
Member ref = memberRepository.getReferenceById(1L); // 프록시 반환

1.3 save() 메서드의 동작 원리

새로운 엔티티 vs 기존 엔티티
// SimpleJpaRepository의 save() 구현
@Transactional
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        em.persist(entity);  // INSERT
        return entity;
    } else {
        return em.merge(entity);  // UPDATE (SELECT + UPDATE)
    }
}

// isNew() 판단 기준
// 1. @Id가 null이면 새로운 엔티티
// 2. @Id가 primitive 타입(long)이고 0이면 새로운 엔티티
// 3. @Version이 null이면 새로운 엔티티
// 4. Persistable 인터페이스 구현 시 isNew() 메서드로 판단

⚠️ merge()의 문제점

merge()는 항상 SELECT 쿼리를 먼저 실행합니다. ID를 직접 할당하는 경우 불필요한 SELECT가 발생할 수 있습니다.

Persistable 인터페이스로 해결
// ID를 직접 할당하는 경우 Persistable 구현
@Entity
public class Item implements Persistable<String> {
    
    @Id
    private String id;  // 직접 할당
    
    @CreatedDate
    private LocalDateTime createdDate;
    
    @Override
    public String getId() {
        return id;
    }
    
    @Override
    public boolean isNew() {
        // createdDate가 null이면 새로운 엔티티
        return createdDate == null;
    }
}

// 사용
Item item = new Item("ITEM-001");
itemRepository.save(item);  // persist() 호출 (SELECT 없음)

1.4 Repository 프록시 생성 원리

Spring Data JPA 내부 동작
┌─────────────────────────────────────────────────────────────┐
│              Spring Data JPA 프록시 생성 과정                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. @EnableJpaRepositories 스캔                             │
│     └─ basePackages 하위의 Repository 인터페이스 탐색        │
│                                                             │
│  2. JpaRepositoryFactoryBean 생성                           │
│     └─ 각 Repository 인터페이스마다 FactoryBean 생성         │
│                                                             │
│  3. 프록시 객체 생성                                         │
│     └─ JdkDynamicAopProxy 또는 CGLIB 프록시                 │
│     └─ SimpleJpaRepository를 타겟으로 설정                  │
│                                                             │
│  4. 쿼리 메서드 분석                                         │
│     └─ 메서드 이름 파싱 → JPQL 생성                         │
│     └─ @Query 어노테이션 → 직접 JPQL 사용                   │
│                                                             │
│  5. 빈 등록                                                  │
│     └─ 프록시 객체를 Spring 컨테이너에 등록                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
// 실제 주입되는 객체 확인
@Repository
public class MemberService {
    
    @Autowired
    private MemberRepository memberRepository;
    
    public void checkProxy() {
        System.out.println(memberRepository.getClass());
        // 출력: class jdk.proxy3.$Proxy123
        // 또는: class com.sun.proxy.$Proxy123
    }
}

Section 1 핵심 정리

  • • JpaRepository는 CRUD + 페이징 + JPA 특화 기능 제공
  • • save()는 isNew()로 INSERT/UPDATE 결정
  • • ID 직접 할당 시 Persistable 구현 권장
  • • Repository는 프록시 객체로 자동 구현됨

2. 쿼리 메서드 (Query Methods)

2.1 메서드 이름으로 쿼리 생성

기본 문법
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // === 기본 조회 ===
    List<Member> findByUsername(String username);
    // SELECT * FROM member WHERE username = ?
    
    Optional<Member> findByEmail(String email);
    // SELECT * FROM member WHERE email = ?
    
    // === 복합 조건 ===
    List<Member> findByUsernameAndAge(String username, int age);
    // SELECT * FROM member WHERE username = ? AND age = ?
    
    List<Member> findByUsernameOrEmail(String username, String email);
    // SELECT * FROM member WHERE username = ? OR email = ?
    
    // === 비교 연산 ===
    List<Member> findByAgeGreaterThan(int age);
    // SELECT * FROM member WHERE age > ?
    
    List<Member> findByAgeGreaterThanEqual(int age);
    // SELECT * FROM member WHERE age >= ?
    
    List<Member> findByAgeLessThan(int age);
    // SELECT * FROM member WHERE age < ?
    
    List<Member> findByAgeBetween(int start, int end);
    // SELECT * FROM member WHERE age BETWEEN ? AND ?
}
쿼리 메서드 키워드 전체 목록
키워드예시JPQL
Is, EqualsfindByUsername(Is)where x.username = ?
BetweenfindByAgeBetweenwhere x.age between ? and ?
LessThanfindByAgeLessThanwhere x.age < ?
LessThanEqualfindByAgeLessThanEqualwhere x.age <= ?
GreaterThanfindByAgeGreaterThanwhere x.age > ?
After/BeforefindByStartDateAfterwhere x.startDate > ?
IsNull, NullfindByAgeIsNullwhere x.age is null
IsNotNull, NotNullfindByAgeNotNullwhere x.age is not null
LikefindByUsernameLikewhere x.username like ?
StartingWithfindByUsernameStartingWithwhere x.username like ?%
EndingWithfindByUsernameEndingWithwhere x.username like %?
ContainingfindByUsernameContainingwhere x.username like %?%
InfindByAgeIn(Collection)where x.age in (?)
NotInfindByAgeNotIn(Collection)where x.age not in (?)
True/FalsefindByActiveTruewhere x.active = true
IgnoreCasefindByUsernameIgnoreCasewhere UPPER(x.username) = UPPER(?)
OrderByfindByAgeOrderByUsernameDescwhere x.age = ? order by x.username desc
NotfindByUsernameNotwhere x.username <> ?

2.2 반환 타입

다양한 반환 타입
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // === 단건 조회 ===
    Member findByUsername(String username);           // null 가능
    Optional<Member> findByEmail(String email);       // Optional 권장
    
    // === 컬렉션 조회 ===
    List<Member> findByAgeGreaterThan(int age);       // 빈 리스트 반환
    Collection<Member> findByTeamName(String name);
    Set<Member> findByStatus(Status status);
    
    // === 페이징 ===
    Page<Member> findByAge(int age, Pageable pageable);   // 카운트 쿼리 포함
    Slice<Member> findByTeam(Team team, Pageable pageable); // 카운트 없음
    List<Member> findByUsername(String username, Pageable pageable);
    
    // === 스트림 ===
    Stream<Member> findAllByAgeGreaterThan(int age);  // try-with-resources 필수
    
    // === 비동기 ===
    @Async
    Future<Member> findByEmail(String email);
    @Async
    CompletableFuture<Member> findByUsername(String username);
    
    // === 카운트/존재 여부 ===
    long countByAge(int age);
    boolean existsByEmail(String email);
    
    // === 삭제 ===
    long deleteByUsername(String username);           // 삭제된 건수 반환
    List<Member> removeByAge(int age);                // 삭제된 엔티티 반환
}

2.3 연관관계 탐색

중첩 프로퍼티 접근
// 엔티티 구조
@Entity
public class Member {
    @ManyToOne
    private Team team;
    
    @Embedded
    private Address address;
}

@Entity
public class Team {
    private String name;
}

@Embeddable
public class Address {
    private String city;
    private String street;
}

// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // 연관 엔티티 프로퍼티 접근 (Team.name)
    List<Member> findByTeamName(String teamName);
    // SELECT m FROM Member m WHERE m.team.name = ?
    
    // Embedded 프로퍼티 접근
    List<Member> findByAddressCity(String city);
    // SELECT m FROM Member m WHERE m.address.city = ?
    
    // 깊은 중첩도 가능
    List<Member> findByTeamLeaderName(String leaderName);
    // SELECT m FROM Member m WHERE m.team.leader.name = ?
}

// ⚠️ 주의: 프로퍼티 이름 충돌
// Member에 teamName 필드가 있으면 team.name이 아닌 teamName으로 해석
// 해결: findByTeam_Name(String name) - 언더스코어로 명시적 구분

2.4 Limit과 정렬

결과 제한과 정렬
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // === First, Top으로 결과 제한 ===
    Member findFirstByOrderByAgeDesc();              // 가장 나이 많은 1명
    Member findTopByOrderByAgeDesc();                // 동일
    
    List<Member> findFirst3ByOrderByAgeDesc();       // 상위 3명
    List<Member> findTop10ByTeamName(String name);   // 팀별 상위 10명
    
    // === 정렬 ===
    List<Member> findByTeamNameOrderByAgeDesc(String teamName);
    List<Member> findByTeamNameOrderByAgeDescUsernameAsc(String teamName);
    
    // === Sort 파라미터 사용 ===
    List<Member> findByTeamName(String teamName, Sort sort);
}

// Sort 사용 예시
Sort sort = Sort.by(Sort.Direction.DESC, "age");
List<Member> members = memberRepository.findByTeamName("teamA", sort);

// 복합 정렬
Sort sort = Sort.by(
    Sort.Order.desc("age"),
    Sort.Order.asc("username")
);

// TypeSafe Sort (권장)
Sort sort = Sort.by(
    Sort.Order.desc("age").nullsLast(),
    Sort.Order.asc("username").ignoreCase()
);

Section 2 핵심 정리

  • • 메서드 이름만으로 쿼리 자동 생성
  • • 다양한 키워드: And, Or, Between, Like, In, OrderBy 등
  • • 반환 타입: Optional, List, Page, Slice, Stream
  • • 연관관계 탐색: findByTeamName, findByAddress_City
  • • 결과 제한: First, Top + 숫자

3. @Query와 JPQL

3.1 @Query 기본 사용법

JPQL 직접 작성
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // 이름 기반 파라미터 (권장)
    @Query("select m from Member m where m.username = :username and m.age = :age")
    List<Member> findByUsernameAndAge(
        @Param("username") String username, 
        @Param("age") int age
    );
    
    // 컬렉션 파라미터
    @Query("select m from Member m where m.username in :names")
    List<Member> findByNames(@Param("names") Collection<String> names);
}

3.2 DTO 직접 조회

// DTO 정의
@Getter @AllArgsConstructor
public class MemberDto {
    private Long id;
    private String username;
    private String teamName;
}

// Repository - new 연산자로 DTO 생성 (패키지명 포함 필수)
@Query("select new com.example.dto.MemberDto(m.id, m.username, m.team.name) " +
       "from Member m join m.team")
List<MemberDto> findMemberDtos();

3.3 벌크 연산

// 벌크 UPDATE - clearAutomatically로 영속성 컨텍스트 초기화
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

// 벌크 DELETE
@Modifying(clearAutomatically = true)
@Query("delete from Member m where m.age < :age")
int bulkDelete(@Param("age") int age);

벌크 연산은 영속성 컨텍스트를 무시합니다. clearAutomatically = true 필수!

핵심 정리

  • • @Query로 JPQL 직접 작성, @Param으로 파라미터 바인딩
  • • DTO 조회: new 연산자 + 패키지명
  • • 벌크 연산: @Modifying + clearAutomatically

4. 페이징과 정렬

4.1 Pageable 인터페이스

// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByAge(int age, Pageable pageable);    // COUNT 쿼리 포함
    Slice<Member> findSliceByAge(int age, Pageable pageable); // COUNT 없음
    List<Member> findListByAge(int age, Pageable pageable);   // 단순 리스트
}

// 사용
PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(20, pageRequest);

// Page 정보 활용
List<Member> content = page.getContent();      // 데이터
int totalPages = page.getTotalPages();         // 전체 페이지 수
long totalElements = page.getTotalElements();  // 전체 데이터 수
boolean hasNext = page.hasNext();              // 다음 페이지 존재 여부

4.2 Page vs Slice vs List

Page<T>

  • • COUNT 쿼리 실행
  • • 전체 페이지 수 계산
  • • 전통적 페이지네이션

Slice<T>

  • • COUNT 쿼리 없음
  • • limit + 1로 다음 페이지 확인
  • • 무한 스크롤에 적합

List<T>

  • • COUNT 쿼리 없음
  • • 페이징 메타 정보 없음
  • • 단순 데이터 조회

4.3 COUNT 쿼리 최적화

// 복잡한 조인이 있는 경우 COUNT 쿼리 분리
@Query(value = "select m from Member m left join m.team t",
       countQuery = "select count(m) from Member m")  // 조인 없이 카운트
Page<Member> findByAge(int age, Pageable pageable);

4.4 DTO 변환

// Page<Entity>를 Page<DTO>로 변환
Page<Member> page = memberRepository.findByAge(20, pageRequest);
Page<MemberDto> dtoPage = page.map(member -> new MemberDto(
    member.getId(),
    member.getUsername(),
    member.getTeam().getName()
));

핵심 정리

  • • Page: COUNT 포함, Slice: COUNT 없음 (무한스크롤)
  • • PageRequest.of(page, size, sort)로 Pageable 생성
  • • countQuery로 COUNT 쿼리 최적화
  • • page.map()으로 DTO 변환

5. Specification 동적 쿼리

5.1 Specification 기본 개념

// Repository - JpaSpecificationExecutor 상속
public interface MemberRepository extends 
        JpaRepository<Member, Long>, 
        JpaSpecificationExecutor<Member> {
}

// Specification 정의
public class MemberSpecs {
    
    public static Specification<Member> hasUsername(String username) {
        return (root, query, cb) -> 
            username == null ? null : cb.equal(root.get("username"), username);
    }
    
    public static Specification<Member> hasTeam(String teamName) {
        return (root, query, cb) -> {
            if (teamName == null) return null;
            Join<Member, Team> team = root.join("team", JoinType.LEFT);
            return cb.equal(team.get("name"), teamName);
        };
    }
    
    public static Specification<Member> ageGreaterThan(Integer age) {
        return (root, query, cb) -> 
            age == null ? null : cb.greaterThan(root.get("age"), age);
    }
}

5.2 Specification 조합

// 동적 쿼리 조합
List<Member> members = memberRepository.findAll(
    Specification.where(MemberSpecs.hasUsername("kim"))
        .and(MemberSpecs.hasTeam("teamA"))
        .and(MemberSpecs.ageGreaterThan(20))
);

// 서비스에서 조건에 따라 동적 조합
public List<Member> searchMembers(MemberSearchCondition condition) {
    Specification<Member> spec = Specification.where(null);
    
    if (condition.getUsername() != null) {
        spec = spec.and(MemberSpecs.hasUsername(condition.getUsername()));
    }
    if (condition.getTeamName() != null) {
        spec = spec.and(MemberSpecs.hasTeam(condition.getTeamName()));
    }
    if (condition.getAgeGoe() != null) {
        spec = spec.and(MemberSpecs.ageGreaterThan(condition.getAgeGoe()));
    }
    
    return memberRepository.findAll(spec);
}

핵심 정리

  • • JpaSpecificationExecutor 상속 필요
  • • null 반환으로 조건 무시 가능
  • • and(), or()로 조건 조합
  • • 복잡한 동적 쿼리는 QueryDSL 권장

6. Auditing (생성/수정 자동 기록)

6.1 Auditing 설정

// 1. 설정 클래스
@Configuration
@EnableJpaAuditing
public class JpaConfig {
    
    @Bean
    public AuditorAware<String> auditorProvider() {
        // Spring Security에서 현재 사용자 가져오기
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName);
    }
}

// 2. BaseEntity 정의
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String updatedBy;
}

// 3. 엔티티에서 상속
@Entity
public class Member extends BaseEntity {
    @Id @GeneratedValue
    private Long id;
    private String username;
    // createdAt, updatedAt, createdBy, updatedBy 자동 관리
}

6.2 시간만 필요한 경우

// 시간만 관리하는 BaseTimeEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseTimeEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
}

// 시간 + 사용자 관리하는 BaseEntity
@MappedSuperclass
@Getter
public abstract class BaseEntity extends BaseTimeEntity {
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String updatedBy;
}

핵심 정리

  • • @EnableJpaAuditing으로 활성화
  • • @CreatedDate, @LastModifiedDate로 시간 자동 기록
  • • @CreatedBy, @LastModifiedBy로 사용자 자동 기록
  • • AuditorAware 빈으로 현재 사용자 제공

7. QueryDSL 활용

QueryDSL이란?

타입 안전한 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크입니다. 컴파일 시점에 오류를 발견할 수 있고, IDE 자동완성을 지원합니다.

7.1 QueryDSL 설정

build.gradle 설정
// build.gradle (Spring Boot 3.x + Gradle)
dependencies {
    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'
}

// Q클래스 생성 위치 설정
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
    main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
JPAQueryFactory 설정
@Configuration
public class QueryDslConfig {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

7.2 기본 사용법

기본 조회
@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {
    
    private final JPAQueryFactory queryFactory;
    
    // 기본 조회
    public List<Member> findAll() {
        return queryFactory
            .selectFrom(member)
            .fetch();
    }
    
    // 조건 조회
    public List<Member> findByUsername(String username) {
        return queryFactory
            .selectFrom(member)
            .where(member.username.eq(username))
            .fetch();
    }
    
    // 정렬
    public List<Member> findAllOrderByAge() {
        return queryFactory
            .selectFrom(member)
            .orderBy(member.age.desc())
            .fetch();
    }
    
    // 페이징
    public List<Member> findWithPaging(int offset, int limit) {
        return queryFactory
            .selectFrom(member)
            .offset(offset)
            .limit(limit)
            .fetch();
    }
}

7.3 동적 쿼리

BooleanExpression 활용
public List<MemberDto> searchMembers(MemberSearchCondition condition) {
    return queryFactory
        .select(new QMemberDto(
            member.id,
            member.username,
            member.age,
            team.name
        ))
        .from(member)
        .leftJoin(member.team, team)
        .where(
            usernameEq(condition.getUsername()),
            teamNameEq(condition.getTeamName()),
            ageGoe(condition.getAgeGoe()),
            ageLoe(condition.getAgeLoe())
        )
        .fetch();
}

// 동적 조건 메서드들
private BooleanExpression usernameEq(String username) {
    return hasText(username) ? member.username.eq(username) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return hasText(teamName) ? team.name.eq(teamName) : null;
}

private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe != null ? member.age.goe(ageGoe) : null;
}

private BooleanExpression ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}

// 조건 조합
private BooleanExpression ageBetween(Integer ageGoe, Integer ageLoe) {
    return ageGoe(ageGoe).and(ageLoe(ageLoe));
}

7.4 Fetch Join

QueryDSL Fetch Join
// ToOne 관계 Fetch Join
public List<Member> findAllWithTeam() {
    return queryFactory
        .selectFrom(member)
        .join(member.team, team).fetchJoin()
        .fetch();
}

// ToMany 관계 Fetch Join (주의: 페이징 불가)
public List<Team> findAllWithMembers() {
    return queryFactory
        .selectFrom(team)
        .join(team.members, member).fetchJoin()
        .distinct()
        .fetch();
}

// 동적 Fetch Join
public List<Member> findMembers(boolean fetchTeam) {
    JPAQuery<Member> query = queryFactory.selectFrom(member);
    
    if (fetchTeam) {
        query.join(member.team, team).fetchJoin();
    }
    
    return query.fetch();
}

7.5 페이징 처리

Spring Data와 통합
public Page<MemberDto> searchMembersPage(
        MemberSearchCondition condition, 
        Pageable pageable) {
    
    // 데이터 조회
    List<MemberDto> content = queryFactory
        .select(new QMemberDto(
            member.id,
            member.username,
            member.age,
            team.name
        ))
        .from(member)
        .leftJoin(member.team, team)
        .where(
            usernameEq(condition.getUsername()),
            teamNameEq(condition.getTeamName())
        )
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
    
    // Count 쿼리 최적화
    JPAQuery<Long> countQuery = queryFactory
        .select(member.count())
        .from(member)
        .leftJoin(member.team, team)
        .where(
            usernameEq(condition.getUsername()),
            teamNameEq(condition.getTeamName())
        );
    
    // 마지막 페이지면 count 쿼리 생략
    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

7.6 Specification vs QueryDSL

Specification
  • • Spring Data JPA 표준
  • • 추가 의존성 없음
  • • 복잡한 쿼리에 한계
  • • 가독성 낮음
QueryDSL (권장)
  • • 타입 안전
  • • IDE 자동완성
  • • 복잡한 쿼리 가능
  • • 가독성 높음

QueryDSL Best Practices

  • • BooleanExpression으로 조건을 메서드로 분리
  • • null 반환으로 조건 무시 (where절에서 자동 제외)
  • • 복잡한 쿼리는 Custom Repository로 분리
  • • Count 쿼리는 PageableExecutionUtils로 최적화