[Spring Data JPA] 2. 쿼리 메서드 기능

HJ·2024년 3월 5일
0

Spring Data JPA

목록 보기
2/4
post-thumbnail

김영한 님의 실전! 스프링 데이터 JPA 강의를 보고 작성한 내용입니다.


1. 쿼리 메소드

Spring Data JPA 는 쿼리 메소드 기능을 제공하는데 쿼리를 어떤 방식으로 작성할 것인지 3가지 방식을 제공합니다.

  1. 메소드 이름으로 쿼리 생성

  2. 메소드 이름으로 JPA NamedQuery 호출

  3. @Query 어노테이션을 사용해서 Repository 인터페이스에 직접 정의


1-1. 메서드 이름으로 쿼리 생성

만약 특정 이름을 가진 유저 중에 n 살 이상인 회원을 조회하고 싶다면 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", 
                    Member.class)
                .setParameter("username", username)
                .setParameter("age", age)
                .getResultList();
}

하지만 Spring Data JPA 는 아래처럼 메서드만 정의해도 정상적으로 실행됩니다.

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

이것이 가능한 이유는 Spring Data JPA 가 메서드 이름을 분석해서 JPQL 을 실행하기 때문입니다. 이때 몇 가지 규칙들이 존재하는데 위의 예시로 살펴보면 다음과 같습니다.

  1. UsernameAndAge : where 절에서 and 조건으로 묶인다

  2. Age 뒤에 GreaterThan 이 붙었기 때문에 > 로 들어간다

  3. Username 은 다른거 없이 Username 만 사용해서 = 로 들어간다

그래서 결과적으로 실행된 쿼리 로그는 아래와 같습니다. 사용할 수 있는 규칙들은 공식문서에서 확인하실 수 있습니다.

select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
     m1_0.username 
from
    member m1_0 
where
    m1_0.username=? 
    and m1_0.age>?

1-2. 메소드 이름으로 JPA NamedQuery 호출

JPA 강의에서 NamedQuery 에 대해서 알아보았습니다. 이 NamedQuery 를 JPA 에서도, Spring Data JPA 에서도 호출할 수 있는데 이를 알아보도록 하겠습니다.

[ NamedQuery ]

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

[ JPA 에서 호출 ]

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

createNamedQuery() 를 통해 NamedQuery 를 호출할 수 있습니다.


[ Spring Data JPA 에서 호출 ]

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

@Query 어노테이션을 사용하고, name 속성 내부에 NamedQuery 의 이름을 작성하면 됩니다.

Spring Data JPA 는 선언한 도메인 클래스 + . + 메서드 이름으로 NamedQuery 를 찾아서 실행합니다.

만약 실행할 NamedQuery 가 없다면 메서드 이름으로 쿼리 생성 전략을 사용합니다.

실무에서 Named Query를 직접 등록해서 사용하는 일은 드물고 @Query 를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다고 합니다.


1-3. @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);
}

@Query 어노테이션을 이용하는 방법으로 실행할 메서드에 JPQL 을 이용해 정적 쿼리를 직접 작성합니다.

createQuery 에서 JPQL 을 작성하는 것은 문자이기 때문에 오타가 있어도 오류가 발생하지 않는데 @Query 는 JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다는 장점이 있습니다.




2. @Query

2-1. 조회하기

[ 값 조회하기 ]

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m.username from Member m")
    List<String> findUsernameList();
}

엔티티에서 특정한 값만을 가져오고 싶을 때 사용합니다.


[ DTO 조회하기 ]

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
            "from Member m join m.team t")
    List<MemberDto> findMemberDto();
}

DTO 를 바로 조회할 수 있는데 이때는 반드시 new 키워드를 사용해야 하고, 패키지경로까지 함께 적어주어야 합니다. 또한 DTO 에는 전달 받을 컬럼들을 가진 생성자가 필요합니다.


2-2. 파라미터 바인딩

파라미터 바인딩은 JPA 강의에서 나온 것처럼 위치 기반과 이름 기반이 있는데 코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하는 것이 좋다고 합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Member m where m.username in :names")
    List<Member> findByNames(@Param("names") List<String> names);
}

Spring Data JPA 에서는 @Param 을 이용해서 파라미터 바인딩을 할 수 있습니다. 또 컬렉션을 넘겨주어 IN 연산자를 사용할 수 있습니다.




3. 반환타입

Member findByUsername(String name); // 엔티티
Optional<Member> findByUsername(String name); // 엔티티 Optional
List<Member> findByUsername(String name); // 엔티티 컬렉션

Spring Data JPA 는 여러 가지 반환 타입을 사용할 수 있습니다. 컬렉션을 반환하는 경우 결과가 없다면 빈 컬렉션이 반환됩니다.

하지만 하나만 조회하는 경우, JPQL 의 getSingleResult() 를 호출하는데, 이 메서드는 조회 결과가 없을 때 NoResultException 이 발생하는데 이를 null 로 반환해줍니다.

2개 이상이라면 NonUniqueResultException 예외가 발생합니다. 이때 Spring Data JPA 가 Spring 의 IncorrectResultSizeDataAccessException 으로 변환해서 반환합니다.

Repository 에 사용하는 기술은 JPA 나 몽고DB 와 같은 기술들을 사용할 수 있는데, 서비스 계층에서 해당 기술에 의존하는게 아니라 스프링이 추상화한 예외에 의존하면 스프링은 동일하게 데이터가 맞지 않으면 Repository 에 사용하는 기술을 변경해도 해당 예외를 발생시키기 때문에 서비스 코드를 변경하지 않아도 된다는 장점이 있습니다.




4. Spring Data JPA 페이징과 정렬

[ JPA ]

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

JPA 에서는 페이징을 할 때 offset 과 limit 를 사용하는데 이는 어디서부터 시작해서( offset ) 몇 개를 가져올지( limit )를 나타냅니다.

이렇게 JPQL 을 작성해놓으면 JPA 는 방언을 기반으로 동작하기 때문에 현재 DB 에 맞는 SQL 을 생성하고 실행하여 DB 에서 페이징 처리를 한 데이터를 가져옵니다.

또 페이징 처리에는 총 몇 개의 데이터가 있는지도 필요한데 이를 count 쿼리를 통해 구할 수 있는데 Spring Data JPA 는 이러한 것들을 편리하게 사용할 수 있도록 제공해줍니다.


4-1. 파라미터와 반환 타입

[ 페이징과 정렬 파라미터 ]

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

패키지를 보면 springframework.data 인 것을 볼 수 있는데 이것은 DB 에 관계없이 페이징을 공통화 시켰다는 의미입니다.


[ 반환 타입 ]

org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능
List : 추가 count 쿼리 없이 결과만 반환

Page 는 totalCount 와 같이 페이징 처리에 필요한 데이터를 가지고 있습니다. 데이터를 가져오는 쿼리와 count 쿼리가 함께 실행됩니다.

Slice 는 TotalCount 가 필요 없는 페이징 처리할 때 사용하는데 무한 스크롤 같은 곳에서 활용할 수 있는 것 같습니다.

List 를 반환타입으로 사용하면 페이징 처리와 관련된 함수들은 사용할 수 없고 페이징 처리된 데이터만 가져오게 됩니다.


4-2. Page 반환

[ Repository ]

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

Pageable 을 파라미터로 전달 받게 되는데 내부에 몇 번 페이지인지와 같은 정보가 들어있고, 반환 타입은 Page 로 작성합니다.


[ 테스트 코드 ]

@Test
void paging() {
    // Pageable 구현체
    PageRequest pageRequest = PageRequest
                                .of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

    Page<Member> page = memberRepository.findByAge(10, pageRequest);    // 쿼리 실행 결과

    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(); // 다음 페이지가 있는가?
}

Spring Data JPA 에서 페이징은 페이지 번호가 0부터 시작합니다. PageRequest 를 만들어 넘겨주었는데 이를 따라가보면 Pageable 인터페이스를 구현한 것임을 알 수 있습니다.

이렇게 Pageable 인터페이스만 전달하면 페이징 처리에 필요한 모든 데이터가 담겨서 들어오게 됩니다.


[ 쿼리 로그 ]

select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username 
from member m1_0 
where m1_0.age=10 
order by m1_0.username desc 
offset 0 rows fetch first 3 rows only;
----------------------------------
select
    count(m1_0.member_id) 
from
    member m1_0 
where
    m1_0.age=10

데이터를 가져오는 쿼리를 수행하고 난 후에 자동으로 count 쿼리가 실행된 것을 확인할 수 있습니다.

위에서 언급한 것처럼 Page 를 반환할 때는 count 쿼리가 추가로 실행되고, 실행 결과 데이터가 Page 에 담기게 됩니다.


4-3. Slice 반환

Slice 는 totalCount 를 가지고 오지 않기 때문에 count 쿼리가 추가로 실행되지 않습니다.

또 slice 는 쿼리를 수행할 때 만약 3개를 요청했으면 1개를 더해서 4개를 요청하게 됩니다.


[ 쿼리 로그 ]

select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username 
from member m1_0 
where m1_0.age=10 
order by m1_0.username desc 
offset 0 rows fetch first 4 rows only;

위의 코드에서 Page 를 반환하지 않고 Slice 를 반환하면 쿼리가 위처럼 실행됩니다. 3개를 요청했는데 4개의 데이터를 가져오는 것을 확인할 수 있습니다.

또 count 쿼리가 나가지 않기 때문에 getTotalElements(), getTotalPages() 를 사용할 수 없게 됩니다.


4-4. count 쿼리 분리

@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);

쿼리에 조인이 걸려있고, 이를 Page 로 반환하면 총 데이터 개수를 가져오기 위해 count 쿼리를 수행할 때도 조인을 수행하게 됩니다.

하지만 하이버네이트 6 부터는 이렇게 의미없는 left join을 최적화 해버리기 때문에 조인을 수행하지 않습니다.


[ 쿼리 로그 ]

select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username 
from member m1_0 
order by m1_0.username desc 
offset 0 rows fetch first 3 rows only;
--------------------------------------------------------------
select count(m1_0.member_id) 
from member m1_0;

쿼리 로그를 살펴보면 join 을 통해 조회하는데 join 이 실행되지 않은 것을 볼 수 있습니다. 왜냐하면 JPQL 을 보았을 때 Team 과 조인을 하지만 전혀 사용하지 않고 있기 때문입니다.

만약 select 나 where 절에서 team 에 대한 조건을 사용하거나 left join fetch 를 사용하면 join 되는 쿼리가 수행됩니다.


[ join fetch 쿼리 로그 ]

select m1_0.member_id,m1_0.age,t1_0.team_id,t1_0.name,m1_0.username 
from member m1_0 
left join team t1_0 on t1_0.team_id=m1_0.team_id 
order by m1_0.username desc 
offset 0 rows fetch first 3 rows only;
--------------------------------------------------------------
select count(m1_0.member_id) 
from member m1_0;

하지만 left join fetch 를 사용해도 count 쿼리를 수행할 때 join 을 하지 않는 것은 동일합니다. 이유는 앞서 말했듯이 하이버네이트 6 부터는 이렇게 의미없는 left join을 최적화 해버리기 때문입니다.


[ countQuery 별도 지정 방법 ]

@Query(value = "select m from Member m left join m.team t", 
        countQuery = "select count(m.username) from Member m")
Page<Member> findByAge(int age, Pageable pageable);

Page 를 반환하면서 @Query 어노테이션을 사용해서 countQuery 속성에 count 쿼리를 분리해서 사용할 수 있습니다.


4-5. 페이징을 유지하면서 DTO 로 변환하기

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

page 내부에는 member 가 있기 때문에 map() 을 통해 member 를 MemberDto 로 변환할 수 있습니다.


[ 사이드 프로젝트에 적용 ]

// 변경 전 코드
Page<Comment> pagingData = commentRepository.findAllByParticipationId(participationId, pageable);
List<Comment> entityList = pagingData.getContent();
List<CommentResponseDTO> dtoList = entityList.stream().map(CommentResponseDTO::new).toList();

// 변경 후 코드
Page<Comment> pagingData = commentRepository.findAllByParticipationId(participationId, pageable);
List<CommentResponseDTO> dtoList1 = pagingData.map(CommentResponseDTO::new).stream().toList();

위 코드는 제가 사이드 프로젝트를 진행하면서 작성한 코드인데 이 방법을 몰라서 getContent() 로 데이터를 가져오고, 이를 반복하며 DTO 로 변환해주는 과정을 했었습니다.

저는 DTO 내부에 세팅해야 하는 데이터가 있어서 List 로 변환했는데 page.map() 을 통해 변환하고, 반환할 때 getContent() 를 쓰는 것도 좋을 것 같습니다. 데이터 말고 Page 자체를 반환해도 됩니다.




5. 벌크성 수정 쿼리

JPA 에서는 변경 감지 기능이 있어서 엔티티의 데이터를 변경하면 트랜잭션 커밋 시점에 update 쿼리가 수행됩니다.

이 방식은 하나씩 하는 방법이고, 여러 건의 데이터를 한 번에 변경하고 싶을 때는 벌크 연산을 사용합니다.

JPA 기본편에서 다루었는데 createQuery() 로 JPQL 을 작성하고, executeUpdate() 를 통해 수행합니다.


[ Repository ]

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
}

반환되는 데이터는 영향을 받은 로우 수이며, 타입은 int 로 설정합니다.

벌크성 수정, 삭제 쿼리는 반드시 @Modifying 어노테이션을 사용해야 하며, 사용하지 않는다면 QueryExecutionRequestException 이 발생하게 됩니다. 왜냐하면 해당 어노테이션이 없다면 executeUpdate() 를 호출하는 것이 아닌 getResultList()getSingleResult() 를 호출하기 때문입니다.

벌크 연산은 영속성 컨텍스트를 무시하고 DB 에 직접 쿼리가 날라가게 되는데 기존에 영속성 컨텍스트에 존재하던 엔티티의 데이터는 변경되지 않습니다. 그래서 벌크 연산 수행 후에는 반드시 영속성 컨텍스트를 초기화해야 하는데 이때 사용하는 옵션이 clearAutomatically 입니다.

@Modifying(clearAutomatically = true) 로 설정하면 벌크성 쿼리를 실행한 후에 영속성 컨텍스트를 초기화합니다. 디폴트는 false 입니다.




6. @EntityGraph

6-1. 지연로딩과 fetch join

연관관계를 지연로딩으로 설정하면 처음 조회할 때는 지연 로딩이 걸린 엔티티에 대해서는 쿼리가 실행되지 않고 실제 사용되는 시점에 쿼리가 수행됩니다. 이 경우 N + 1 문제가 발생합니다.

fetch join 을 사용하면 연관된 엔티티까지 한 번의 쿼리로 가져오는데 이때는 프록시 객체를 가져오는 것이 아닌 진짜 엔티티를 가져오게 됩니다. ( 이전 게시글 참고 )

Spring Data JPA 에서 @Query 내부에 join fetch 를 사용해서 패치조인을 수행할 수 있지만, 이 방법 외에도 연관된 엔티티를 한 번에 조회할 수 있는 기능을 제공합니다.


6-2. @EntityGraph 사용

1. 공통 메서드 오버라이드

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}

JpaRepository 의 findAll() 메서드를 오버라이딩하면서 @EntityGraph 를 사용하는 방법입니다. attributePaths 에는 연관관계가 설정된 이름을 지정하면 됩니다.

위의 메서드를 실행하면 Member 를 조회하면서 연관된 Team 까지 함께 조회하게 됩니다.


2. JPQL과 함께 사용

public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();
}

@Query 에 JPQL 을 작성하고, @EntityGraph 를 적용하는 방법도 가능합니다.


3. 메서드 이름으로 쿼리 생성 시 사용

public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = {"team"})
    List<Member> findByUsername(String username)
}

메서드 이름으로 쿼리가 자동으로 생성되게 설정한 후에 @EntityGraph 를 적용하는 방법도 가능합니다.


6-3. @NamedEntityGraph

@EntityGraph 는 JPA 에서 제공하는 기능입니다. JPA 에서 이 방법 외에도 @NamedEntityGraph 도 제공합니다.

@NamedEntityGraph(name = "Member.all", 
                attributeNodes = @NamedAttributeNode("team"))
@Entity
public class Member {
    ...
}

@NamedEntityGraph 에 이름을 지정하고, @NamedAttributeNode 에 연관관계가 설정된 필드명을 지정합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph("Member.all")
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();
}

그 후 Repository 에서 @NamedEntityaGraph 에서 지정한 이름으로 EntityGraph 기능을 사용할 수 있습니다.




7. JPA Hint & Lock

7-1. JPA 쿼리 힌트

JPA 쿼리 힌트는 SQL 힌트가 아닌 JPA 구현체( 하이버네이트 )에게 제공하는 힌트를 의미합니다. 힌트가 무엇인지는 예제를 통해 살펴보겠습니다.

[ Repository ]

public interface MemberRepository extends JpaRepository<Member, Long> {
    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyByUsername(String username);
}

하이버네이트는 readOnly 라는 기능을 제공하는데 이를 true 로 설정했을 때 테스트와 쿼리 실행 로그를 살펴보겠습니다.


[ 테스트 코드 ]

@Test
public void queryHint() throws Exception {
    memberRepository.save(new Member("member1", 10));
    em.flush();
    em.clear();

    Member member = memberRepository.findReadOnlyByUsername("member1");
    member.setUsername("member2");
    em.flush(); // Update Query 실행 X
}
select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username 
from member m1_0 
where m1_0.username='member1';

Member 를 저장하고, 영속성 컨텍스트를 초기화시킨 후에 조회합니다. 그 후 Member 를 변경하는데 수행되는 쿼리를 보면 select 쿼리만 수행되고, update 쿼리를 수행되지 않는 것을 볼 수 있습니다.

findById() 를 사용해서 조회한 후에 변경을 하면 당연히 update 쿼리가 수행됩니다. 이때 변경감지가 일어나는데 변경 감지 기능을 수행하기 위해서는 결국 비교할 대상이 되는 객체도 가지고 있어야 합니다. 내부적으로 최적화가 되어 있어도 결국 사용하는 객체와 기준이 되는 객체 2가지를 관리해야 하기 때문에 결국 메모리를 사용하게 됩니다.

하지만 위처럼 readOnly 를 설정한 메서드로 가지고 오게 되면 변경 감지를 하지 않기 때문에 변경 감지에 사용하는 스냅샷을 만들지 않게 되고, 성능 최적화가 이루어지게 되는 것입니다.


7-2. Lock

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findLockByUsername(String name);
}

JPA 가 Lock 을 지원하고, Spring Data JPA 에서 편리하게 사용할 수 있도록 어노테이션을 지원합니다.

select m1_0.member_id, m1_0.age, m1_0.team_id, m1_0.username 
from member m1_0 
where m1_0.username=? for update

쿼리 로그를 보면 자동으로 뒤에 for update 라는 키워드가 붙은 것을 확인할 수 있습니다. select 를 하는 중에 다른 사람들이 손댈 수 없도록 lock 을 걸어놓은 것입니다. 방언에 따라서 동작 방식은 달라지게 됩니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글