[Spring Data JPA] 쿼리 메소드 기능
포스트
취소

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

기존 JPA의 JPQL

  • 기존에 JPA만 사용했을 때 JPQL을 작성하려면 많은 작업을 거쳐야 한다.
  • 문자열 사이에 공백이 빠지지 않았는지 체크하고, 파라미터 바인딩이 잘못되지 않게 조심해야 하는 등 여러가지

  • 만약 조건절에 파라미터로 회원명과 나이를 넘긴다고 가정해보자.
  • 만약 내가 넘긴 회원명 파리미터와 값이 같고 나이 파리미터보다 나이가 많은 회원을 구하려면 아래와 같은 메소드가 생성될 것이다.
public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
 return
    em.createQuery("select m from Member m where m.username = :username and m.age > :age")
    .setParameter("username", username)
    .setParameter("age", age)
    .getResultList();
}
  • 이런 코드가 1,2건만 있으면 잠깐 귀찮고 말지 싶지만 현실은 그렇게 쉽지 않다.
  • 하지만 그런 귀찮음을 덜어주기 위해 Spring Data Jpa는 쿼리 메소드라는 기능을 제공한다.

쿼리 메소드

  • 우선 기본적으로 해당 기능은 JpaRepository에서 동작하는 아름다운 기능이다.
  • 우리가 추가적으로 작성한 메소드의 반환형, 인자, 메소드명을 보고 그걸 토대로 그런 기능을 자동으로 만들어 준다.

  • 만약 위에서 사용한 예제를 JpaRepository의 코드로 변경하면 아래와 같이 바뀔 것이다.
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}

메소드 명명 규칙

기능키워드예시JPQL snippet
중복 제거DistinctfindDistinctByLastnameAndFirstnameselect distinct …​ where x.lastname = ?1 and x.firstname = ?2
AND 연산AndfindByLastnameAndFirstname… where x.lastname = ?1 and x.firstname = ?2
OR 연산OrfindByLastnameOrFirstname… where x.lastname = ?1 or x.firstname = ?2
동등 비교Is, EqualsfindByFirstname,findByFirstnameIs,findByFirstnameEquals… where x.firstname = ?1
BETWEEN A AND BBetweenfindByStartDateBetween… where x.startDate between ?1 and ?2
XX 미만LessThanfindByAgeLessThan… where x.age < ?1
XX 이하LessThanEqualfindByAgeLessThanEqual… where x.age <= ?1
XX 초과GreaterThanfindByAgeGreaterThan… where x.age > ?1
XX 이상GreaterThanEqualfindByAgeGreaterThanEqual… where x.age >= ?1
이후AfterfindByStartDateAfter… where x.startDate > ?1
이전BeforefindByStartDateBefore… where x.startDate < ?1
NULL 체크IsNull, NullfindByAge(Is)Null… where x.age is null
NOT NULL 체크IsNotNull, NotNullfindByAge(Is)NotNull… where x.age not null
LIKE ‘%문자열%’LikefindByFirstnameLike… where x.firstname like ?1
NOT LIKE ‘%문자열%’NotLikefindByFirstnameNotLike… where x.firstname not like ?1
LIKE ‘문자열%’StartingWithfindByFirstnameStartingWith… where x.firstname like ?1 (parameter bound with appended %)
LIKE ‘%문자열’EndingWithfindByFirstnameEndingWith… where x.firstname like ?1 (parameter bound with prepended %)
LIKE ‘%문자열%’ContainingfindByFirstnameContaining… where x.firstname like ?1 (parameter bound wrapped in %)
정렬OrderByfindByAgeOrderByLastnameDesc… where x.age = ?1 order by x.lastname desc
NOT 연산NotfindByLastnameNot… where x.lastname <> ?1
IN (A, B, C, …)InfindByAgeIn(Collection ages)… where x.age in ?1
NOT IN (A, B, C, …)NotInfindByAgeNotIn(Collection ages)… where x.age not in ?1
true인지 확인TruefindByActiveTrue()… where x.active = true
false인지 확인FalsefindByActiveFalse()… where x.active = false
대·소문자 구분 무시IgnoreCasefindByFirstnameIgnoreCase… where UPPER(x.firstname) = UPPER(?1)
  • Like와 Containing의 차이
    • Like : 위치 지정자 사용 가능
    • Containing : 위치 지정자도 단순한 문자로 인식한다.

NamedQuery

  • Spring Data Jpa에서의 NamedQuery는 기존의 JPA만 사용하는 방식보다 작성하는 코드 수가 매우 적다.

기존의 JPA만 사용하는 방식

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

public class MemberRepository {
    public List<Member> findByUsername(String username) {
        return 
        em.createNamedQuery("Member.findByUsername", Member.class)
        .setParameter("username", username)
        .getResultList();
    }
} 

Spring Data Jpa를 사용하는 방식

@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query(name = "Member.findByUsername")
    List<Member> findByUsername(@Param("username") String username);
}

JpaRepository에 제네릭으로 선언한 엔티티 클래스를 통해 @Query 어노테이션도 생략할 수 있다.

엔티티 클래스 + . 연산자 + 메소드명으로 사용하면 된다.

Spring Data Jpa를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다.
대신 @Query 를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다.

JpaRepository의 메소드에 JPQL 쿼리 작성하기

  • 실무에서는 JpaRepository를 상속받은 인터페이스에 @Query(실행할 쿼리) 어노테이션을 추가한 메소드를 사용하는 방법을 많이 사용한다.
    • 메소드명으로 쿼리를 생성하는 기능은 파라미터의 수가 증가할 수록 메소드명이 점점 더러워지기 때문이다.
  • @org.springframework.data.jpa.repository.Query 어노테이션을 사용한다.
  • 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라 할 수 있다.
  • JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Member m where m.username= :username and m.age = :age")
    List<Member> findUser(@Param("username") String username, @Param("age") int age);
}

JpaRepository의 메소드에서 DTO로 조회하기

  • new 명령어, 패키지 주소, 클래스명을 명시함으로써 해당 DTO로 직접 조회할 수 있다.
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) "from Member m join m.team t")
List<MemberDto> findMemberDto();

파라미터 바인딩

위치 기반과 이름 기반

  • 위치 기반은 0부터 시작하는 인덱스를 기준으로 파라미터가 바인딩된다.
  • 이름 기반은 동일한 이름을 가진 문자열을 기준으로 파라미터가 바인딩된다.
select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반

코드 가독성과 유지보수를 위해서는 이름 기반 파라미터 바인딩을 사용해야 한다.
만약 위치 기반으로 했다가 순서가 잘못된다면 돌이킬 수 없는 일이 발생할 수도 있다.

컬렉션 파라미터 바인딩

  • 넘기는 파라미터를 컬렉션 타입으로 넘기면 해당 컬렉션에 있던 값들이 자동으로 IN절에 포함된다.
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);

페이징과 정렬

기존의 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")
        .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();
}

Spring Data Jpa를 사용하는 방식

  • Spring Data Jpa가 제공하는 특별한 클래스들을 사용해서 편리하게 처리한다.
  • 페이징과 정렬 파라미터
    • 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을 조회한다.
    • java.util.List
      • 자바 컬렉션
      • 추가 count 쿼리 없이 결과만 반환한다.
  • Page<T>에 대해서 getContent()를 실행하면 페이징된 데이터 목록인 List<T>를 반환한다.
  • Page<T>에 대해서 getTotalElements()를 실행하면 총 데이터 개수인 long을 반환한다.

  • 간단한 예시
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);
  • 페이징 사용 예시
//PageRequest.of(int pageNumber, int pageSize, Sort sort)
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest);

NamedQuery에도 페이징을 사용할 수 있는가?

  • 테스트 해보니 가능하다.
@Query("select m.username from Member m")
List<String> findUsernameList(Pageable pageable);
@Test
public void findUsernameListByPaging() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("AAA", 20);
    Member m3 = new Member("AAA", 30);
    Member m4 = new Member("AAA", 40);
    Member m5 = new Member("AAA", 50);
    memberRepository.save(m1);
    memberRepository.save(m2);
    memberRepository.save(m3);
    memberRepository.save(m4);
    memberRepository.save(m5);

        
    int offset = 0; //시작 번호
    int limit = 3; //페이징 단위
    PageRequest pageRequest = PageRequest.of(offset, limit, Sort.by(Sort.Direction.DESC, "username"));

    List<String> result = memberRepository.findUsernameList(pageRequest);
    for (String s : result) {
        System.out.println("s = " + s);
    }
}

count 쿼리는 분리가 가능하다.

  • 단순히 데이터의 개수를 세는 count 쿼리에 불필요한 조인때문에 속도가 느려질 수도 있다.
  • 상황과 성능을 고려해서 필요하면 countQuery를 따로 선언한다.
@Query(value = "select m from Member m", countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);

벌크성 수정 쿼리

  • 벌크 연산도 Spring Data Jpa가 생산성을 증가시켜준다.

기존의 JPA만 사용하는 방식

public int bulkAgePlus(int age) {
        int resultCount =
                em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
                .setParameter("age", age)
                .executeUpdate();
    return resultCount;
}
@Test
public void bulkUpdate() throws Exception {
        //given
        memberJpaRepository.save(new Member("member1", 10));
        memberJpaRepository.save(new Member("member2", 19));
        memberJpaRepository.save(new Member("member3", 20));
        memberJpaRepository.save(new Member("member4", 21));
        memberJpaRepository.save(new Member("member5", 40));
        
        //when
        int resultCount = memberJpaRepository.bulkAgePlus(20);
        
        //then
        assertThat(resultCount).isEqualTo(3);
}

Spring Data Jpa를 사용하는 방식

  • @Modifying 어노테이션을 추가해야 한다.
  • @Modifying 어노테이션이 있어야지 JPA에서 excuteUpdate()를 실행한다.
  • 만약, @Modifying 어노테이션이 없다면 getResultList()나 getSingleResult()를 호출한다.
  • clearAutomatically = true를 설정하면 자동으로 flush랑 clear를 진행한다.
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
@Test
public void bulkUpdate() throws Exception {
    //given
    memberRepository.save(new Member("member1", 10));
    memberRepository.save(new Member("member2", 19));
    memberRepository.save(new Member("member3", 20));
    memberRepository.save(new Member("member4", 21));
    memberRepository.save(new Member("member5", 40));
    
    //when
    int resultCount = memberRepository.bulkAgePlus(20);
    //em.flush(); //"clearAutomatically = true" 옵션 사용으로 인한 주석 처리
    //em.clear(); //"clearAutomatically = true" 옵션 사용으로 인한 주석 처리
    
    List<Member> result = memberRepository.findMemberByUsername("member5");
    Member member5 = result.get(0);
    
    //then
    assertThat(resultCount).isEqualTo(3);
}

@EntityGraph

  • fetch 전략이 LAZY인 엔티티에 대해서 연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.
  • Spring Data Jpa가 @EntityGraph를 통해서 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용할 수 있게 도와준다.
  • 이 기능을 사용하면 JPQL 없이 페치 조인을 할 수 있다.
  • 장점
    • 쿼리 수 줄이기
      • 한 번의 쿼리로 여러 엔터티 객체 조회 가능
    • 성능 향상
      • 불필요한 쿼리 왕복 감소
    • 코드 간결화
      • 복잡한 쿼리 코드 간소화
  • 주의 사항
    • 엔터티 그래프 이름 충돌 방지
      • 엔터티 그래프 이름은 프로젝트 내에서 유일해야 한다.
    • 엔터티 관계 순환 참조 방지
      • 엔터티 관계 순환 참조는 무한 루프 발생 가능
    • 성능 최적화
      • 엔터티 그래프 사용 시 불필요한 엔터티까지 조회하지 않도록 주의

결국은 @EntityGraph는 페치 조인을 다르게 사용하는 방법이다.

실무에서 사용할 때는 마주하는 상황에 맞게 레포지토리에서 @Query로 직접 작성하거나, @EntityGraph를 사용해야 한다.

@NamedEntityGraph(name = "Member.all", attributeNodes =
@NamedAttributeNode("team"))
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
    //...

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "team_id")
	private Team team;

    //...
}

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
	//...
	
	@OneToMany(mappedBy = "team")
	private List<Member> members = new ArrayList<>();
	
	//...
}
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

@EntityGraph("Member.all")
//@EntityGraph(attributePaths = {"team"})
List<Member> findMemberEntityGraphByUsername(@Param("username") String usernam);

JPA Hint

  • JPA 구현체에게 제공하는 힌트
  • JPQL, Criteria API, 쿼리 메소드 등 다양한 쿼리 방식에서 사용할 수 있다.

용도

  • 성능 최적화
    • 쿼리 실행 계획을 변경하여 성능을 향상시킬 수 있다.
  • 메모리 사용량 감소
    • 쿼리 결과를 캐싱하거나 영속성 컨텍스트에 저장되는 엔티티 수를 줄여 메모리 사용량을 줄일 수 있다.
  • 결과 제한
    • 쿼리 결과의 행 수를 제한하여 불필요한 데이터 처리를 줄일 수 있다.
  • 잠금 설정
    • 쿼리 실행 중에 데이터 변경을 방지하여 일관성을 유지할 수 있다.

주의 사항

  • JPA 구현체에 따라 힌트 지원 여부 다를 수 있다.
  • 힌트 사용 시 쿼리 성능의 변화를 주의 깊게 관찰해야 한다.
  • 잘못된 힌트 사용 시 성능 저하 또는 예상치 못한 결과 발생할 수 있다.

예시

  • org.hibernate.readOnly
    • 엔티티를 읽기 전용 모드로 설정하여 성능을 향상시킨다.
  • org.hibernate.cacheable
    • 쿼리 결과를 캐싱하여 성능을 향상시킨다.
  • org.hibernate.fetchSize
    • 쿼리 실행 시 가져올 데이터의 양을 제한한다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true")) //readonly로 설정한다.
Member findReadOnlyByUsername(String username);
@Test
public void queryHint() throws Exception {
    //given
    memberRepository.save(new Member("member1", 10));
    em.flush();
    em.clear();

    //when
    Member member = memberRepository.findReadOnlyByUsername("member1");
    member.setUsername("member2");
    em.flush(); //readOnly 속성을 true로 했기 때문에 변경감지에 의한 업데이터 쿼리가 발생하지 않는다.

    //then
}

forCounting

  • @QueryHints 어노테이션에 forCounting = true를 설정하면 동작하는 기능이 존재한다.
  • 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트가 적용된다.
  • 기본값은 true다.

JPA Lock

  • JPA에서 제공하는 제공하는 동시성 제어 기능이다.
  • 여러 트랜잭션이 동시에 데이터를 변경하려는 경우 데이터 무결성을 유지하기 위해 사용된다.
  • @org.springframework.data.jpa.repository.Lock 어노테이션을 사용한다.

종류

  • 낙관적 Lock
    • 트랜잭션이 데이터를 커밋하기 전에 충돌을 감지하는 방식
    • 버전 번호를 사용하여 충돌을 감지한다.
    • 성능 오버헤드가 적다.
  • 비관적 Lock
    • 트랜잭션이 데이터를 읽는 즉시 Lock을 걸어 다른 트랜잭션의 접근을 차단하는 방식
    • 충돌을 미연에 방지할 수 있다.
    • 성능 오버헤드가 발생한다.

낙관적 Lock

  • (방법 1) 엔티티 클래스에 버전 필드를 지정하고 @Version 어노테이션을 사용한다.
  • (방법 2) 쿼리 메소드에 @Lock 어노테이션을 사용하고 LockModeType.OPTIMISTIC을 지정한다.
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private int age;
    
    @Version
    private int version;
    
    // ...
}
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    Member findByName(String name);
}
@Test
public void lockTest(){

    try {
        Member member = memberRepository.findByName("Hong Gil Dong");
        member.setName("Go Gil Dong");
        memberRepository.save(member);
    } catch (OptimisticLockingFailureException e) {
        // ...
    }
}

비관적 Lock

  • (방법 1) 쿼리 메소드에 @Lock 어노테이션을 사용하고 LockModeType.PESSIMISTIC_WRITE 또는 LockModeType.PESSIMISTIC_READ를 지정한다.
  • (방법 2) 모든 쿼리에 비관적 Lock을 걸기 위해 PessimisticLockInterceptor 인터셉터를 사용한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String name);
@Test
public void lock() {
    //given
    Member member1 = new Member("member1", 10);
    memberRepository.save(member1);
    em.flush();
    em.clear();
    
    //when
    List<Member> result = memberRepository.findLockByUsername(member1.getUsername());
    
    //then
}
@Configuration
public class PessimisticLockConfig {
    @Bean
    public PessimisticLockInterceptor pessimisticLockInterceptor() {
        return new PessimisticLockInterceptor();
    }
}

하이버네이트 6의 left join 최적화

  • 스프링 부트 3 이상을 사용하면 하이버네이트 6이 적용된다.
  • 하이버네이트 6에서 의미없는 left join을 최적화 해버린다.
    • 실제 실행되는 쿼리에서 left join이 빠진다.
  • 여기서 의미없는 left join이란 left join을 명시하긴 했으나 조인한 테이블을 select절에서 사용하지 않는 경우를 의미한다.
@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.