WEB/JPA

실전! Spring Data JPA 1,2(공통 인터페이스 기능, 쿼리 메소드 기능)

Tony Lim 2021. 4. 12. 16:07
728x90
public interface MemberRepository extends JpaRepository<Member,Long>
{

}

이렇게 해놓으면 Spring Data JPA가 프록시 객체를 만들어서 필요한 곳에 위 인터페이스 구현체를 집어 넣어준다.

@Repository 생략해도 괜찮다.

 

기본적으로 Spring Data가 공통적으로 제공되는 기술과 특화된 기술을 제공해주는 파트가 나뉘어져있다. 

 

쿼리 메소드 기능

public interface MemberRepository extends JpaRepository<Member,Long>
{
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
    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();
    }

SpringDataJPA가 인터페이스에 저렇게 적기만 해도 아래와 같이 JPQL을 생성해서 쿼리를 날려준다.

SpringDataJPA 메뉴얼에 쿼리생성에 대한 간단한 문법이 나와잇다. 

Spring Data JPA - Reference Documentation

 

네임드 쿼리

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id","username","age"})
@NamedQuery(
        name="Member.findByUsername",
        query="select m from Member m where m.username =:username"
)
public class Member
{

이런식으로 위에 쿼리를 작성할 수 있다. 호출 할떄는 직접

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

Repository 에서 직접쿼리를 호출해서 새로운 메도스로 감싸서 호출할 수도 있고

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

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

Spring Datajpa가 제공해주는 기능을 이용하여 @Query에 정의한 네임드쿼리 이름을 @Param 을 통해서 jpql 의 parameter 인자 값을 넘겨줄 수 있다.

@Query를 생략하더라도 내부적으로 Member(제네릭인자로 주어진 클래스명).findByUsername (메소드명) 로 된 네임드쿼리를 찾고 없으면 쿼리메소드를 생성해준다.

쓰는 이유중 하나는 네임드쿼리는 application loading 시점에 한번 parsing 을 해보기 때문에 개발자의 글자오류를 바로 잡아준다. 그렇지않으면 사용자가 버튼을 누를 때 에러가 뜨는 대참사가 일어 날 수 도 있다.

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

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

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

이런식으로 직접 정의해 줄 수 있다. 또한 메소드 명도 쿼리메소드 처럼 길게 안써도 되고 자신이 마음대로 써줄수 있기에 용이하다. 마찬가지로 로딩 시점에 String에 개발자가 만든 오류를 감지해준다.

사실상 이름이 없는 쿼리라고 생각하면 된다.

 

@Query, 값, DTO 조회하기

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

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

    @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("select m.username from Member m")
    List<String> findUsernameList();

    @Query("select new SpringDataJPA.SpringDataJPA.dto.MemberDto(m.id, m.username,t.name) from Member m join m.team t")
    List<MemberDto> findMemberDto();
}

MemberDto를 따로 정해주고 jpql을안에서 똑같이 작성해주면된다. 

    @Query("select m from Member m where m.username in :names")
    List<Member> findByNames(@Param("names") Collection<String> names);
Hibernate: 
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.tema_id as tema_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.username in (
            ? , ?
        )

컬렉션을 파리미터로 주면 알아서 in 쿼리를 만들어서 준다. 

spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true

application properties 에서 설정을 해주면 성능을 최적화할수 있다.
이렇게 되면 execution plan을 재사용할 수 있게되어 execution plan cache가 힙을 가득채워버리는 문제도 해결이 된다. 한마디로 듬성듬성 미리 in 쿼리를 만들어놓고 남는 부분은 마지막 숫자로 패딩해서 유용하게 사용한다.

 

반환타입

    List<Member> findMemberByUsername(String username);
    Member findMemberByUsername(String username);
    Optional<Member> findMemberByUsername(String username);

같은 쿼리를 만들어주더라도 반환타입에따라서 알아서 다르게 return 해준다.
하지만 단건조회일 경우 2개 이상이면 exception을 발생시킨다.

List의 경우는 없으면 그냥 empty collection이 반환이된다. null 이 아니다.                

 

스프링 데이터 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 조회)
이게 그 유튜브처럼 쭈욱 내리면 몇개씩 계속 보여주는 방식이다.

모두다 공통적으로 정의 된 내용이다. db specific 하지 않다.

    Page<Member> findByAge(int age, Pageable pageable);
Hibernate: 
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.tema_id as tema_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit ?
2021-04-12 11:49:06.729  INFO 2040 --- [           main] p6spy                                    : #1618195746729 | took 1ms | statement | connection 3| url jdbc:mysql://localhost:3306/spring_data_jpa?serverTimezone=UTC&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.tema_id as tema_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=? order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.tema_id as tema_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=20 order by member0_.username desc limit 3;
Hibernate: 
    select
        count(member0_.member_id) as col_0_0_ 
    from
        member member0_ 
    where
        member0_.age=?

반환값을 Page로 해주면 내부적으로 페이징 쿼리를 날려서 List<Membmer> 를 줄뿐만아니라 전체 갯수도 돌려준다. 

    @Test
    public void paging() throws Exception
    {
        //given
        Member m1 = new Member("member1", 20);
        Member m2 = new Member("member2", 20);
        Member m3 = new Member("member3", 20);
        Member m4 = new Member("member4", 20);
        Member m5 = new Member("member5", 20);
        memberRepository.save(m1);
        memberRepository.save(m2);
        memberRepository.save(m3);
        memberRepository.save(m4);
        memberRepository.save(m5);
        //when
        int age = 20;
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
        //then
        Page<Member> page = memberRepository.findByAge(age,pageRequest);

        List<Member> content = page.getContent();
        long totalElements = page.getTotalElements();

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

page가 제공해주는 메소드가 엄청많다. 

 

이번엔 Slice로 받아보자

    Slice<Member> findByAge(int age, Pageable pageable);
    @Test
    public void paging() throws Exception
    {
        //given
        Member m1 = new Member("member1", 20);
        Member m2 = new Member("member2", 20);
        Member m3 = new Member("member3", 20);
        Member m4 = new Member("member4", 20);
        Member m5 = new Member("member5", 20);
        memberRepository.save(m1);
        memberRepository.save(m2);
        memberRepository.save(m3);
        memberRepository.save(m4);
        memberRepository.save(m5);
        //when
        int age = 20;
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
        //then
        Slice<Member> page = memberRepository.findByAge(age,pageRequest);

        List<Member> content = page.getContent();

        assertThat(content.size()).isEqualTo(3);
        assertThat(page.getNumber()).isEqualTo(0);
        assertThat(page.isFirst()).isTrue();
        assertThat(page.hasNext()).isTrue();
    }

Slice는 getTotalElements 와 getTotalPages 메소드가 없다.

Hibernate: 
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.tema_id as tema_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit ?
2021-04-12 12:00:46.866  INFO 6776 --- [           main] p6spy                                    : #1618196446866 | took 1ms | statement | connection 3| url jdbc:mysql://localhost:3306/spring_data_jpa?serverTimezone=UTC&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.tema_id as tema_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=? order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.tema_id as tema_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=20 order by member0_.username desc limit 4;

쿼리를 날릴떄 요청한 limit 보다 +1 해서 쿼리해온다.

 

실무에서는 totalcount 자체가 성능의 문제를 일으킨다. 전체 DB를 훑어 봐야하기 때문이다. 

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

실무에서는 countQuery를 따로 최적화해서 작성해주는 것이 좋다. 

 

벌크성 수정 쿼리

    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();
    }
    @Modifying
    @Query("update Member m set m.age = m.age +1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);

executeUpdate() 가 return 해주는것은 update가 실행된 row의 갯수다. @Modifying 이 꼭 필요하다.
벌크연산은 1차캐시를 거치지 않고 바로 db에 update 쿼리를 날리기 때문에 조심해야한다.

 

@EntityGraph

    @Query("select m from Member m left join fetch m.team")
    List<Member> findMemberFetchJoin(); 
    
    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
    
    @EntityGraph(attributePaths = {"team"})
    @Query("select m form Member m")
    List<Member> findMemberEntityGraph();
    
    @EntityGraph(attributePaths = {"team"})
    List<Member> findEntityGraphByUsername(@Param("username") String username);

내부적으로 @EntityGraph는 join fetch를 쓰는것이다.  마지막 메소드명에서 EntityGraph 앞에 대문자만 들어가고 아무거나 써도된다.

 

JPA Hint & Lock

나의 경우엔 hibernate에게 알려주는 힌트이다.  

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

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

        //then
    }
    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyByUsername(String username);

flush할떄 dirty checking에 의해서 update 쿼리가 날아가는게 정상이지만

readOnly 옵션을 주었기에 따로 스냅샷을 안만들어 놓아서 dirty checking 기능이 작동을 안한다. 스냅샷이란 영속성 컨텍스트가 생성될 때 , 향후 dirty checking을 위해 원본을 복사해서 만들어두는 객체를 의미한다.

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findLockByUsername(String username);

여러 Lock 옵션들을제공한다.

 

728x90