제육's 휘발성 코딩
article thumbnail
반응형

쿼리 메서드 기능

  • 메서드 이름으로 쿼리 생성
  • NamedQuery
  • @Query
  • 파라미터 바인딩
  • 반환 타입
  • 페이징과 정렬
  • 벌크성 수정 쿼리
  • @EntityGraph

JPA 공식문서 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation

Spring Data JPA는 다음과 같이 여러 쿼리 메서드 기능을 제공한다.

순수 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에서 다음과 같이 사용자 중 나이가 지정한 나이보다 많은 사람을 조회하는 기능을 테스트해보자.
@Test
public void findByUsernameAndAgeGreaterThan() {
  Member m1 = new Member("AAA", 10);
  Member m2 = new Member("AAA", 20);
  memberJpaRepository.save(m1);
  memberJpaRepository.save(m2);

  List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThan("AAA", 15);
  assertThat(result.get(0).getUsername()).isEqualTo("AAA");
  assertThat(result.get(0).getAge()).isEqualTo(20);
  assertThat(result.size()).isEqualTo(1);
}
  • 다음과 같이 테스트 했을 때 정상적으로 동작하는 것을 확인할 수 있다.

스프링 데이터 JPA

스프링 데이터 JPA는 지정된 메서드명을 사용하면 자동으로 쿼리를 생성해준다.

  • 조회 : find...By, read...By, query...By, get...By 등 사용
  • COUNT : count...By (반환 타입 : long)
  • EXISTS : exists...By (반환 타입 : boolean)
  • 삭제 : delete...By, remove...By (반환 타입 : long)
  • DISTINCT : findDistinct. findMemberDistinctBy 등
  • LIMIT : findFirst3, findFirst, findTop, findTop3 등
package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Member;
import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {
  List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 해당 기능을 다음과 같이 한 줄로 작성(메서드 명)할 수 있다. 즉, 스프링 데이터 JPA가 메서드 이름을 분석해서 JPQL을 생성하고 실행해준다.
  • 메서드명이 잘못된 경우 no property와 같은 ReferenceException이 발생한다.

@NamedQuery

@NamedQuery 어노테이션으로 쿼리를 정의할 수 있다. 사용할 일은 거의 없지만, 참고용으로 알아보자.

@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();
}
  • 레포지토리에서 다음과 같이 createNamedQuery를 사용해서 지정한 쿼리를 사용할 수 있다.
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);
}
  • 스프링 데이터 JPA는 선언한 도메인 "클래스 +. + 메서드 이름"으로 Named 쿼리를 찾아서 실행한다. 만약에 없으면 메서드 이름으로 쿼리 생성전략을 사용한다. 즉, @Query가 없어도 메서드 이름으로 정상 동작한다.

NameQuery를 직접 등록하여 사용하는 일을 드물다. @Query를 사용하여 레포지토리에 쿼리를 직접 정의하는 방식을 주로 다뤄보자.

@Query

@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);
  • 쿼리에 JPQL 쿼리를 작성하여 레포지토리에서 바로 사용할 수 있다.
  • NameQuery 처럼 애플리케이션 실행 시점(런타임)에 문법 오류를 발견할 수 있다.

DTO 조회

package study.datajpa.dto;

import lombok.Data;

@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;
    }
}
  • 다음과 같이 MemberDto 를 생성하자.
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
       "from Member m join m.team t")
List<MemberDto> findMemberDto();
  • 다음과 같이 엔티티가 아닌 경우 new 연산자를 통해 해당 경로와 생성자를 매칭시켜주면 DTO도 사용할 수 있다.

파라미터 바인딩

쿼리 내용을 보면 ?: 를 자주 봤을 것이다. ?는 위치 기반, :는 이름 기반으로 위치 기반은 실수의 여지가 많으므로 이름 기반인 :를 사용하자.

@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
  • 다음과 같이 파라미터를 받을 때 :를 사용해서 받을 수 있다.
@Test
public void findByNames() {
  Member m1 = new Member("AAA", 10);
  Member m2 = new Member("BBB", 20);
  memberRepository.save(m1);
  memberRepository.save(m2);

  List<Member> result = memberRepository.findByNames(Arrays.asList("AAA", "BBB"));
  assertThat(result.get(0).getUsername()).isEqualTo("AAA");
  assertThat(result.get(1).getUsername()).isEqualTo("BBB");
}
  • 테스트 코드 작성하면서 컬렉션 타입으로 데이터를 보내면 통과하는 것을 볼 수 있다. 그 이유는 JPA에서 컬렉션 타입의 경우 자동으로 IN 연산자를 사용하여 값을 넣어주기 때문이다.
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.username in ('AAA' , 'BBB');
  • 로그에 출력되는 쿼리문은 다음과 같다.

반환 타입

List<Member> findListByUsername(String username); // 컬렉션
Member findMemberByUsername(String username); // 단건
Optional<Member> findOptionalByUsername(String name); // Optional
  • 스프링 데이터 JPA는 컬렉션, 단건, Optional 등 유연한 반환 타입을 지원한다.
    • 반환 타입이 List 일 때 값이 없는 경우 Null이 아닌 빈 컬렉션을 반환한다.
    • 반환 타입이 단건 일 때 값이 없는 경우 Null을 반환한다. (순수 JPA는 예외 발생)
    • 반환 타입이 Optional 은 값이 없으면 Null 있으면 값을 반환한다.

페이징과 정렬 (순수 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();
}
  • 순수 JPA로 페이징과 정렬 기능을 적용한 리포지토리 코드
@Test
public 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> members = memberJpaRepository.findByPage(age, offset, limit);
  long totalCount = memberJpaRepository.totalCount(age);

  // then
  assertThat(members.size()).isEqualTo(3);
  assertThat(totalCount).isEqualTo(5);
}
  • 테스트 코드는 다음과 같다.
    • 페이지 계산 공식 : totalPage = totalCount / size

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

Page<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용 
Slice<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByAge(int age, Pageable pageable); //count 쿼리 사용 안함
List<Member> findByAge(int age, Sort sort);
  • Page : 추가 count 쿼리 결과를 포함하는 페이징
  • Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit+1) 조회
  • List : 추가 count 쿼리 없이 결과만 반환
@Test
public 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> memberDtoPage = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));

  // 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(); // 다음 페이지 여부
}
  • 페이징 처리 테스트 코드는 다음과 같다.
    • 스프링 데이터 JPA에선 Pageable 인터페이스를 제공한다. 따라서, 실제 사용할 때 PageRequest 구현체를 생성하여 (of(page,size,(sort, properties)) 적용 시킨 후 넣어주면 된다.
    • 엔티티를 반환하는 것은 API 설계에 큰 문제를 발생할 수 있다. page.map()을 사용하여 DTO로 변환할 수 있다.
  • Page는 1부터가 아닌 0부터 시작임을 주의하자.
@Query(value = “select m from Member m”,
       countQuery = “select count(m.username) from Member m”)
Page<Member> findMemberAllCountBy(Pageable pageable);
  • Page 는 기본적으로 count 쿼리를 제공해준다. 애플리케이션이 확장될 수록 연산에 장애가 발생할 수 있다. 다음과 같이 countQuery를 분리해주면 카운트는 left join을 사용하지 않아도 된다. (성능이 빨라진다.)

벌크성 수정 쿼리(순수 JPA)

벌크성이란 한 번에 모든 데이터를 처리하는 경우를 의미한다. 다음 예제를 통해 순수 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();
}
  • 입력받은 나이 보다 크거나 같은 경우 나이를 1씩 증가 시키는 예제를 진행해보자.
  • 수정 시에는 executeUpdate()를 사용한다.
@Test
public void bulkUpdate() {
  // 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);
}
  • 다음과 같이 20살 이상인 회원은 모두 증가되는 것을 테스트할 수 있다.

벌크성 수정 쿼리(스프링 데이터 JPA)

@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
  • 스프링 데이터 JPA에선 벌크 연산을 위해 @Modifying을 추가로 설정해줘야 한다.
    • 어노테이션을 적용하지 않는 경우 예외가 발생한다. Not supported for DML operations
    • 그 이유는 save로 데이터를 영속성 컨텍스트에 등록은 했지만, 트랜잭션의 종료 시점이 아니기 때문에 플러시가 된 상태가 아니기 때문에 강제로 넣어줘야하기 때문이다. 즉, DB에는 값이 반영되어 있지만, 영속성 컨텍스트에선 값이 변경이 안된 상황
  • 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 수행하도록 하자.
    • 만약 부득이하게 사용하게 된다면 벌크 연산 직후 영속성 컨텍스트를 초기화 하자.
    • 초기화 방식 : em.flush(), em.clear() 또는 @Modifying(clearAutomatically = true) 설정

@EntityGraph

@EntityGraph는 연관된 엔티티를 SQL 한번에 조회하는 방법이다.

@Test
public void findMemberLazy() {
  // given 
  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();
  }
}
  • 해당 예시를 보면 getTeam() 데이터를 조회할 때마다 프록시에서 실제 값을 가져오기 위한 쿼리가 실행된다. 즉, N+1 문제가 발생한다.
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
  • 다음과 같이 페치 조인을 사용한 메서드를 사용하면 한방 쿼리로 가져올 수 있다. 즉시 로딩으로 모든 데이터를 가져오기 때문에 Team 객체가 프록시 객체가 아닌 실객체로 변환된 채로 조회한다.
// 공통 메서드 오버라이드 
@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);
  • 다음과 같이 다양한 방법으로 어노테이션으로 페치조인을 적용하여 N+1 문제를 해결할 수 있다.
    • attributePaths 을 페치조인할 대상을 지정해준다.
    • 페치 조인의 간편 버전이라고 생각하면 된다.
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
  • 네임드 쿼리처럼 엔티티 그래프에서도 사용할 수 있다.
@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
  • 다음과 같이 사용할 수 있다.

JPA Hint & Lock

JPA Hint는 JPA에게 힌트를 제공하는 것으로 실시간 조회가 많은 상황이고 redis와 같이 별도의 캐싱기능이 적용되지 않은 상황일 때 사용한다. 모든 애플리케이션의 장애의 대부분은 복잡한 쿼리문이므로 해당 내용은 가볍게 알고 넘어가자. 조회 기능을 개선하는 것은 큰 차이가 없다.

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
  • JPA 구현체에게 제공하는 힌트 기능이 있다. 성능 최적화를 사용하기 위해 사용하는 경우가 가끔 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsernameByLock(String name);
  • Locking 기능을 어노테이션으로 제공한다.

본 포스팅은 인프런 - 김영한님의 '실전! 스프링 데이터 JPA' 편을 정리한 내용입니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^