Spring/Spring Data JPA

[Spring Data JPA] 3. 쿼리 메소드 기능

yoon_seon 2023. 5. 19. 18:11

 쿼리 메서드 기능

  1. 메서드 이름으로 쿼리 생성
  2. JPA NamedQuery
  3. @Query, 리포지토리 메서드에 쿼리 정의하기
  4. @Query, 값, DTO 조회하기
  5. 파라미터 바인딩
  6. 반환 타입
  7. 순수 JPA 페이징과 정렬
  8. 스프링 데이터 JPA 페이징과 정렬
  9. @EntityGraph
  10. JPA Hint & Lock

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

: 메서드 이름을 분석해서 JPQL 쿼리 실행

 

이름과 나이를 기준으로 회원을 조회하는 간단한 예제를 보자

 

순수 JPA 리포지토리

public List<Member> findByUsernameAndAgeGreaterThen(String username, int age) {
    return em.createQuery(
                    "select m" +
                    " from Member m" +
                    " where m.username = :username" +
                    " and m.age > :age"
                    , Member.class)
            .setParameter("username", username)
            .setParameter("age", age)
            .getResultList();
}

 

순수 JPA 테스트 코드

@Test
void findUsernameAndAgeGreaterThen() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("AAA", 20);
    memberJpaRepository.save(m1);
    memberJpaRepository.save(m2);

    List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThen("AAA", 10);

    assertThat(result.get(0).getUsername()).isEqualTo("AAA");
    assertThat(result.get(0).getAge()).isEqualTo(20);
    assertThat(result.size()).isEqualTo(1);
}

 

스프링 데이터 JPA

List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

 

스프링 데이터 JPA가 제공하는 쿼리 메서드 기능

참고 : 
이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 함께 변경해야 한다.변경하지 않으면 런타임 시점에 오류가 발생한다. 이렇게 런타임 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 장점 중 하나이다.

 


📌 JPA NamedQuery

메서드 이름으로 JPA의 NamedQuery를 호출할 수 있다.

 

Entity에 @NamedQuery 정의

// ...
@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"
)
public class Member {
    // ...
}

 

순수 JPA에서 NamedQuery 호출

public List<Member> findByUsername(String username) {
    return em.createNamedQuery("Member.findByUsername", Member.class)
            .setParameter("username", username)
            .getResultList();
}
  • createNamedQuery() 메서드를 사용하고 인자로 @NamedQuery의 name값을 입력한다.

 

스프링 데이터 JPA로 NamedQuery 사용

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query(name = "Member.findByUsername") // 생략 가능 
    List<Member> findByUsername(@Param("username") String username);
}
  • @Query를 생략하고 메서드 이름만으로 NamedQuery를 호출할 수 있다.

 

스프링 데이터 JPA로 NamedQuery 호출

public interface MemberRepository
         extends JpaRepository<Member, Long> { // ** 여기 선언한 Member 도메인 클래스
    
    List<Member> findByUsername(@Param("username") String username);
}
  • 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 쿼리를 찾아서 실행한다.
  • 만약 NamedQuery가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.
  • 우선순위 : NamedQuery > 메서드 이름으로 쿼리 생성 전략 
  • 만약 NamedQuery에서 정의한 JPQL 문법이 잘못 되었다면 런타임 시점에 오류를 발생한다.
참고 : 
스프링 데이터 JPA를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다.
대신 @Query를 사용해서 리포지토리 메서드에서 쿼리를 직접 정의한다.

 


📌 @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> findMember(@Param("username") String username, @Param("age") int age);
    
}
  • @org.springframework.data.jpa.repository.Query 어노테이션을 사용한다.
  • 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 NamedQuery라 할 수 있다.
  • JPA NamedQuery 처럼 런타임 시점에 문법 오류를 발견할 수 있는 큰 장점이 있다.
참고 : 실무에서 간단한 조회는 메서드 이름으로 쿼리 생성 기능을 이용하고
파라미터가 증가하여 메서드 이름이 지저분하거나 복잡한 쿼리를 작성해야할 경우에는  @Query 기능을 자주 사용하게 된다.

 


📌 @Query, 값, DTO 조회하기

단순히 값 하나를 조회

@Query("select m.username from Member m")
List<String> findUsernameList();
  • JPA의 값 타입(@Embedded)도 이 방식으로 조회할 수 있다.

DTO로 직접 조회

package study.datajpa.dto;

@Data
public class MemberDTO {
    private Long id;
    private String username;
    private String teamName;

    public MemberDTO(Long id, String username, String teamName) {
        this.id = id;
        this.username = username;
        this.teamName = teamName;
    }
}
@Query("select new study.datajpa.dto.MemberDTO(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDTO> findMemberDTO();
  • DTO로 직접 조회 시 JPA의 new 명령어를 사용해야한다. 
  • DTO의 생성자 형식, 순서에 맞게 작성해야한다.(JPA와 사용방식 동일)

 


📌 파라미터 바인딩

파라미터를 바인딩은 위치기반 바인딩과 이름기반 바인딩 두가지가 있다.

위치기반 바인딩은 위치가 바뀌어버리면 에러가 발생할 수 있다.

코드 가독성과 유지보수성을 고려해서 가급적 이름기반 파인딩을 사용하는 것이 좋다.

select m from Member m where m.username = ?0 // 위치 기반
select m from Member m where m.username = :name // 이름 기반

 

컬렉션 파라미터 바인딩

@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);

Collection 타입을 IN 절 지원 가능

 


📌 반환 타입

List<Member> findListByUsername(String username); // 컬렉션
Member findMemberByUsername(String username);     // 단건
Optional<Member> findOptionalByUsername(String username); // 단건 Optional
  • 컬렉션
    • 조회 결과 없을 경우 → 빈 컬렉션 반환
  • 단건 조회
    • 조회결과 없을 경우  → null 반환
    • 결과가 2건 이상  → javax.persistence.NonUniqueResultException 예외 발생
참고 : 단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NonUniqueResultException 예외가 발생하는데 개발자 입장에서 다루기가 상당히 불편하다. 스프링 데이터 JPA는 단건을 조회할 때 이 예외가 발생하면 예외를 무시하고 대신에 null 을 반환한다.

 

 


📌 순수 JPA 페이징과 정렬

조회 조건

  • 검색 조건: 나이가 10살
  • 정렬 조건 : 이름으로 내림차순
  • 페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

JPA 페이징 리포지토리 코드

public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery(
            "select m" +
                    " from Member m " +
                    " where m.age = :age" +
                    " order by m.username desc", Member.class)
            .setParameter("age",age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

public long totalCount(int age) {
    return em.createQuery(
            "select count(m)" +
                    " from Member m" +
                    " where m.age = :age", Long.class)
            .setParameter("age", age)
            .getSingleResult();
}
  • 조회 및 페이징 처리 메서드, totalCount 조회 메서드 2개 필요

 

JPA 페이징 테스트 코드

@Test
void paging() {
    // given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 10));
    memberJpaRepository.save(new Member("member3", 10));
    memberJpaRepository.save(new Member("member4", 10));
    memberJpaRepository.save(new Member("member5", 10));

    int age = 10;
    int offset = 0;
    int limit = 3;

    // when
    List<Member> member = memberJpaRepository.findByPage(age, offset, limit);
    long totalCount = memberJpaRepository.totalCount(age);

    // then
    assertThat(member.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}

 


📌 스프링 데이터 JPA 페이징과 정렬

스프링 데이터 JPA는 페이징과 정렬을 공통화 시켰다.

org.springframework.data.domain 패키지 안에 공통화 했기 때문에 데이터베이스와 상관없이 페이징과 정렬 처리가 가능하다.

 

페이징과 정렬 파라미터

  • org.springframework.data.domain.Sort : 정렬 기능
  • org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort 포함)

 

특별한 반환 타입

  • org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
  • org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1조회)
  • List (자바 컬렉션) : 추가 count 쿼리 없이 결과만 반환

 

페이징과 정렬 사용 예제

Page<Member> findByUsername(String name, Pageable pageable);  //count 쿼리 사용
Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByUsername(String name, Pageable pageable);  //count 쿼리 사용 안함
List<Member> findByUsername(String name, Sort sort);

 

 

다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자

 

조회 조건

  • 검색 조건: 나이가 10살
  • 정렬 조건: 이름으로 내림차순
  • 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

 

Page 사용 예제 정의 코드

public interface MemberRepository extends Repository<Member, Long> {
    Page<Member> findByAge(int age, Pageable pageable);
}

 

Page 사용 예제 실행 코드

@Test
void paging() {
    // given
    memberRepository.save(new Member("member1", 10));
    memberRepository.save(new Member("member2", 10));
    memberRepository.save(new Member("member3", 10));
    memberRepository.save(new Member("member4", 10));
    memberRepository.save(new Member("member5", 10));

    int age = 10;
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    // when
    Page<Member> page = memberRepository.findByAge(age, pageRequest);

    Page<MemberDTO> toMap = page.map(m -> new MemberDTO(m.getId(), m.getUsername(), m.getTeam()));
    toMap

    // then
    List<Member> content = page.getContent(); // 조회된 데이터
    
    assertThat(content.size()).isEqualTo(3);          // 조회된 데이터 수
    assertThat(page.getTotalElements()).isEqualTo(5); // 전체 데이터 수
    assertThat(page.getNumber()).isEqualTo(0);        // 페이지 번호
    assertThat(page.getTotalPages()).isEqualTo(2);    // 전체 페이지 번호
    assertThat(page.isFirst()).isTrue();              // 첫번째 항목인가?
    assertThat(page.hasNext()).isTrue();              // 다음 페이지가 있는가?
}
  • MemberRepository.findByAge()의 두번째 파라미터로 받은 Pageable은 인터페이스이다.
    따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
  • PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다.
    참고로 페이지는 0부터 시작한다.

 

Page 인터페이스

public interface Page<T> extends Slice<T> {
    int getTotalPages(); //전체 페이지 수
    long getTotalElements(); //전체 데이터 수
    <U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}

 

Slice 인터페이스

public interface Slice<T> extends Streamable<T> {
    int getNumber();             // 현재 페이지
    int getSize();               // 페이지 크기
    int getNumberOfElements();   // 현재 페이지에 나올 데이터 수
    List<T> getContent();        // 조회된 데이터
    boolean hasContent();        // 조회된 데이터 존재 여부
    Sort getSort();              // 정렬 정보
    boolean isFirst();           // 현재 페이지가 첫 페이지 인지 여부
    boolean isLast();            // 현재 페이지가 마지막 페이지 인지 여부
    boolean hasNext();           // 다음 페이지 여부
    boolean hasPrevious();       // 이전 페이지 여부
    Pageable getPageable();      // 페이지 요청 정보
    Pageable nextPageable();     // 다음 페이지 객체
    Pageable previousPageable(); // 이전 페이지 객체
    <U> Slice<U> map(Function<? super T, ? extends U> converter); // 변환기
}

 

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);
  • 복잡한 쿼리에서 count 쿼리는 굳이 left join을 할 필요가 없기에 이런 복잡한 sql을 사용하는 경우에는 분리해서 사용한다.

 

페이지를 유지하면서 엔티티를 DTO로 변환할 수 있다.

Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDTO> toMap = page.map(m -> new MemberDTO(m.getId(), m.getUsername(), "teamA"));
  • map() 메서드로 엔티티를 DTO로 변환 가능
  • 실무에서 엔티티를 그대로 반환하는 것은 위험하다. 절대적으로 엔티티를 DTO로 변환하여 반환해야한다.

 


📌 벌크성 수정 쿼리

순수 JPA를 사용한 벌크성 수정 쿼리

public int bulkAgePlus(int age) {
    return em.createQuery(
            "update Member m" +
                    " set m.age = m.age + 1" +
                    " where m.age >= :age")
            .setParameter("age", age)
            .executeUpdate();
}

 

스프링 데이터 JPA를 사용한 벌크성 수정 쿼리

@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
  • 벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용해야한다.
    • 사용하지 않으면 다음과 같은 예외가 발생
      org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
  • 벌크성 쿼리를 실행하고나서 꼭 영속성 컨텍스트를 초기화 해주어야한다.
    영속성 컨텍스트 초기화 옵션 설정 : @Modifying(clearAutomatically = true) (이 옵션의 기본값은 false)
    • 이 옵션 없이 회원을 findById 로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.
    •  비즈니스 로직에서 벌크성 로직만 동작하고 트랜잭션을 종료하는게 사실 제일 깔끔하다.
참고 :
벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.

권장하는 방안
1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.

 


📌 @EntityGraph

연관된 엔티티들을 SQL 한번에 조회하는 방법

: 스프링 데이터 JPA에서 JPQL없이 fetch join을 사용하는 방법

 

Member와 Team의 연관관계는 지연로딩 다(N)대일(1)  관계이다. 따라서 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.

 

스프링 데이터 JPA의 JPQL 페치조인

@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();

스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 사용하여 JPQL없이 페치 조인을 사용할 수 있다.(JPQL + 엔티티 그래프도 가능)

// 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

// 메서드 이름으로 쿼리 생성 + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
  • 페치 조인(Fetch Join)은 기본적으로 LEFT OUTER JOIN을 사용한다.

 

NamedEntityGraph

엔티티에 @NamedEntityGraph 어노테이션을 지정하여 EntityGraph를 사용할 수 있다.

//...
@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
    // ...
}
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);

 


📌 JPA Hint & Lock

1. JPA Hint

JPA Hint : JPA 쿼리 힌트(SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)

 

쿼리 힌트 사용

@QueryHints(value = @QueryHint(name="org.hibernate.readOnly", value ="true"))
Member findReadOnlyByUsername(String username);
  • 쿼리를 읽기전용으로 조회한다.
  • 읽기전용으로 조회하기 때문에 변경 감지의 대상이 아님
  • org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용

 

쿼리 힌트 테스트 코드

@Test
void queryHint() {
    // given
    Member member1 = memberRepository.save(new Member("member1", 10));
    em.flush();
    em.clear();

    // when
    Member findMember = memberRepository.findReadOnlyByUsername("member1");
    findMember.setUsername("member2");

    em.flush(); // 읽기전용 이어서 update 되지 않음.
}

 

2. Lock

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
  • org.springframework.data.jpa.repository.Lock 어노테이션을 사용

 

 


해당 글은 인프런의 [실전! 스프링 데이터 JPA] 강의를 정리한 내용입니다.

 

실전! 스프링 데이터 JPA - 인프런 | 강의

스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제공합니다.

www.inflearn.com