Spring/인프런_JPA

[Spring]스프링 데이터 JPA - 쿼리 메소드 기능

빙응이 2024. 7. 22. 21:04

JPA의 쿼리 기능을 알아보자!

📝 메소드 이름으로 쿼리 생성

만약 특정 속성으로 엔티티를 검색한다고 해보자...

 

이름과 나이로 회원 조회

    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();
    }

순수하게 JPA를 사용한다는 기준으로 다음과 같이 구현될 수 있다.

사실 가독성이 별로고 직접 지정해줘야해서 불편하다.

 

 

📌 스프링 데이터 JPA로 발전시키기!!

public interface MemberRepository extends JpaRepository<Member, Long> {
 List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
 }

스프링 데이터 JPA는 쿼리 메소드의 이름을 통해 필터 조건을 제시할 수 있다. 

위의 순수 JPA보다 만들기도 쉽고 가독성도 매우 뛰어나다. 

 

스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
  •  조회 메소드
    • 해당 조건으로 엔티티를 반환하는 메소드이다.
List<User> findByLastName(String lastName);
List<User> readByFirstName(String firstName);
List<User> queryByAge(int age);
List<User> getByEmail(String email);
  • COUNT 메소드 
    • 조건에 맞는 엔티티의 개수를 반환한다.
long countByStatus(String status);
  • EXISTS 메소드
    • 조건에 맞는 엔티티가 존재하는지 여부를 반환한다.
boolean existsByEmail(String email);
  • DELETE 메소드 
    • 조건에 맞는 엔티티를 삭제한다.
long deleteById(Long id);
long removeByStatus(String status);
  • LIMIT 메소드
    • 결과의 개수를 제한하여 조회합니다.
    • findFirst3, findTop5
List<User> findFirst3ByOrderByAgeAsc();
User findFirstByOrderByLastNameAsc();
List<User> findTopByOrderBySalaryDesc();
List<User> findTop3ByOrderByScoreDesc();

 

📌 @Query 사용하기

  • 해당 기능은 리포지토리에서 쿼리를 직접 정의하는 것이다. 
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);
 }

해당처럼 파라미터를 바인딩해서 받고 직접 정의한 메소드를 사용할 수 있다.

 

DTO 커스텀해서 직접 조회하기 
  • @Query는 받아오는 DTO를 직접 정의할 수 있다. 
 @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
 "from Member m join m.team t")
 List<MemberDto> findMemberDto();
@Data
 public class MemberDto {
 	private Long id;
 	private String username;
 	private String teamName;
 }

 

 

📌 JPA 페이징과 정렬

  • 스프링 데이터 JPA에서 사용하는 페이징은 패키지로 구현되어 있다.
    • org.springframework.data.domain.Sort : 정렬 기능
    • org.springframework.data.domain.Pageable : 정렬 기능
  • 특별한 반환 타입도 있다. 
    • org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
    • org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능

슬라이스는 다음 더보기만 존재할 때 사용하고 페이지는 페이지를 게시판 같은 페이징 기능이 필요할 때 사용한다.

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

 

페이징을 통해 다음과 같은 정보를 얻을 수 있다.

    Pageable pageable = PageRequest.of(0, 10); // 첫 페이지, 한 페이지에 10개의 요소
    Page<User> usersPage = userRepository.findAll(pageable);
    
    // 총 페이지 수
    int totalPages = usersPage.getTotalPages();
    
    // 총 요소 수
    long totalElements = usersPage.getTotalElements();
    
    // 현재 페이지 번호 (0부터 시작)
    int currentPage = usersPage.getNumber();
    
    // 현재 페이지의 데이터
    List<User> users = usersPage.getContent();
    
    // 다음 페이지가 있는지 여부
    boolean hasNext = usersPage.hasNext();
    
    // 이전 페이지가 있는지 여부
    boolean hasPrevious = usersPage.hasPrevious();

 

📝 벌크성 수정 쿼리 

모든 객체에 대한 정보를 수정하는 쿼리이다.
이런 것을 벌크성 수정 쿼리라 부른다.
순수한 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;
 }
스프링 JPA를 사용한 벌크성 수정 쿼리 
 @Modifying
 @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
 int bulkAgePlus(@Param("age") int age);

 

순수 JPA를 사용했을 때는 기존과 비슷한 방식을 사용한다.

그러나 스프링 JPA를 사용했을 때는 @Modifying이 들어간다.

 

📌 @Modifying

  • 벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용해야한다.
    • 사용하지 않으면 예외가 발생한다.
 @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);
    Member member = memberRepository.findByUsername("member5");
    System.out.println(member)
    //then
    assertThat(resultCount).isEqualTo(3);
 }

해당 조회의 경우 벌크 연산으로 인해 DB가 41살이 되지만 영속성 컨텍스트로 인해 출력은 40살로 나오기에 

벌크 연산 진행 후 영속성 컨텍스트를 초기화 해줘야 한다. 

 

  • 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화 여부를 설정할 수 있다. 
    • @Modifying(clearAutomatically = true)  = 벌크 연산 실행 후 자동으로 영속성 컨텍스트 초기화
    • 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있으므로 만약 다시 조회해야 한다면 꼭 영속성 컨텍스트를 초기화해야 한다. 
참고 : 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.

권장하는 방안은
       1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행
       2. 벌크 연산 직후 영속성 컨텍스트 초기화 

 

📝 @EntityGraph

나는 전 포스팅에서 모든 관계는 지연 로딩관계를 하라고 배웠다.
이것을 효율적으로 조회하는 것을 알아보자

✔ 무슨 효율성?

지연 로딩 관계에서 객체 조회  지연로딩 관계인 것을 조회하면 그때서야 조회 쿼리를 날려 지연로딩 관계를 조회한다.

만약 우리가 객체 조회 후 지연 로딩 관계의 정보들을 모두 조회한다면 어떻게 될까??

 @Test
 public void findMemberLazy() throws Exception {
 //given
 //member1 -> teamA
 //member2 -> teamB
 Team teamA = new Team("teamA");
 Team teamB = new Team("teamB");
    teamRepository.save(teamA);
    teamRepository.save(teamB);
    memberRepository.save(new Member("member1", 10, teamA));
    memberRepository.save(new Member("member2", 20, teamB));
    em.flush();
    em.clear();
 //when
 List<Member> members = memberRepository.findAll();
 //then
 for (Member member : members) {
        member.getTeam().getName();
    }
 }

 

이렇게 되면

N + 1번의 쿼리가 날아오게 된다.

  • List members = memberRepository.findAll() : 1번
  • member.getTeam().getName() : 조회된 모든 맴버에 대한 Team 쿼리를 날림

 

📌 페치 조인

이러한 N + 1번의 문제를 해결할 때는 페치 조인을 사용한다.

즉 처음 조회 때 모든 조회를 다하여 1번만 조회하게 하는 것이다. 

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

위 코드는 Member를 조회할 때 그의 지연로딩인 team도 같이 조회하는 것이다.

 

 

📌 @EntityGraph

@EntityGraph는 사실상 페치 조인의 간편 버전이다.

참고로, LEFT OUTER JOIN을 사용한다.

 

 //공통 메서드 오버라이드
@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> findByUsername(String username)

해당 코드처럼 어노테이션만 붙이면 페치조인을 자동으로 해준다.

참고, 간단간단할 때는 사용해도 되나 왠만하면 페치조인을 직접 사용하자...

 

📝 JPA Hint

JPA 힌트는 쿼리 실행 시 JPA 구현체에게 특정 동작을 지시하거나
성능을 최적화하기 위해 추가 정보를 제공하는 방법이다.

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @Query("SELECT m FROM Member m WHERE m.status = :status")
    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    List<Member> findByStatus(@Param("status") String status);
}

위처럼 쉽게 적용할 수 있고 다양한 전략이 존재한다.

 

캐시 관련 힌트

 

  • org.hibernate.cacheable: 결과를 2차 캐시에 저장할지 여부를 지정합니다.
@QueryHint(name = "org.hibernate.cacheable", value = "true")
  • javax.persistence.cache.retrieveMode: 엔티티를 가져올 때 캐시에서 검색할지 여부를 지정합니다.
@QueryHint(name = "javax.persistence.cache.retrieveMode", value = "USE")
  • javax.persistence.cache.storeMode: 엔티티를 저장할 때 캐시에 저장할지 여부를 지정합니다.
@QueryHint(name = "javax.persistence.cache.storeMode", value = "REFRESH")

 

 

락킹 관련 힌트
  • javax.persistence.lock.timeout: 락을 얻기 위해 대기할 최대 시간을 지정합니다.
@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")

 

페치 전략 힌트
  • org.hibernate.fetchSize: 페치할 데이터의 크기를 지정합니다
@QueryHint(name = "org.hibernate.fetchSize", value = "50")

 

기타 성능 최적화 힌트

 

  • org.hibernate.readOnly: 읽기 전용 쿼리로 설정합니다.
@QueryHint(name = "org.hibernate.readOnly", value = "true")
  • javax.persistence.query.timeout: 쿼리 실행 시간 초과를 설정합니다.
@QueryHint(name = "javax.persistence.query.timeout", value = "5000")